Files
atomic-design-poc/libs/shared/docs/remote-data.mdx
T
ehoandClaude Sonnet 5 e7156c5132 feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects)
plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's
separate sibling repo. That split had already produced real drift: a
hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree
forked and silently diverging (7 files), and beheer + the styles.scss
token bridge duplicated byte-for-byte across both repos.

- git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/,
  environments/, the Storybook docs/*.mdx, and styles.scss into
  libs/shared + libs/beheer (all confirmed identical between the two
  repos before merging). auth stays deliberately duplicated per
  ADR-0002 (actor-specific, expected to diverge) - amended there.
- One generated API client (libs/shared), no more vendored swagger.json.
- .dependency-cruiser split into a base factory + one config per app,
  and Storybook into .storybook-ssp/.storybook-behandelportal - both
  forced by the @auth/* alias resolving to different directories per app.
- SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/
  HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies
  its own nav/admin-links/dev-panel instead of one being hardcoded.
- CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated;
  WP-67 backlog entry documents the full decision trail.

npm run ci green (lint, dep:check x2, 360 tests across ssp/
behandelportal/shared/beheer, both localized builds, backend tests,
snippet + api-client drift); both dev servers, both Storybook
instances, and docker compose verified working.

The old sibling repo (/home/eho/repos/behandelportal) is left
untouched, not deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 21:01:57 +02:00

102 lines
4.4 KiB
Plaintext

import { Meta, Canvas } from '@storybook/addon-docs/blocks';
import * as AsyncStories from '../src/ui/async/async.stories';
<Meta title="Foundations/RemoteData & Async" />
# RemoteData & Async
An async fetch has exactly four states: still loading, loaded-but-empty, failed, or
loaded-with-a-value. Modeling that as `loading`/`error`/`data` booleans permits nonsense
combinations ("loading **and** error", "data **and** error" — which one does the UI
believe?). `src/app/shared/application/remote-data.ts` closes that off with one tagged
union instead:
```ts
type RemoteData<E, T> =
| { tag: 'Loading' }
| { tag: 'Empty' }
| { tag: 'Failure'; error: E }
| { tag: 'Success'; value: T };
```
## Combining sources
Two or more independent fetches often need to render as ONE state (e.g. a registration
call and a BRP call feeding the same page). `map`/`map2`/`andThen` combine them with one
precedence rule: **Failure beats Loading beats Empty beats Success** — if either source
failed, the combined result is a failure; only when every source succeeded do you get a
combined value.
```ts
map2(registration, person, (reg, p) => ({ registration: reg, person: p }));
```
## Rendering it: `<app-async>`
<Canvas of={AsyncStories.Loading} />
<Canvas of={AsyncStories.ErrorState} />
`shared/ui/async` renders exactly one of the four templates — never two at once, by
construction, since the component switches on the union's tag. Feed it either:
- **`[resource]`** — a raw Angular `resource()` (the common case; the component projects
it into a `RemoteData` internally via `fromResource`), or
- **`[data]`** — an already-combined `RemoteData` (e.g. from a store's `computed()` using
`map`/`map2`).
The default loading UI is a spinner, delay-gated (~250ms) so a fast response never
flashes it; override with an `appAsyncLoading` template. `appAsyncEmpty` and
`appAsyncError` are likewise optional — omit them and you get a sensible default (a
"geen gegevens" message / an alert with a retry button).
## The `appAsyncLoaded` slot isn't generically typed to your value
This is a real Angular constraint, not an oversight: a structural directive's type
parameter can only be inferred from an **input bound on that same element** (this is how
`*ngFor="let x of items"` and `*ngIf="x as y"` work — the type comes from `ngForOf`/`ngIf`,
inputs on the very same tag). `<ng-template appAsyncLoaded let-p>` sits on a _different_
node than `<app-async [data]="…">`, so `p` cannot inherit a type from that sibling input,
even though they're nested in the same template. Angular types it `unknown`, and
`ngTemplateContextGuard` can't fix that without an input to seed it from — the shared
`AsyncComponent`/`AsyncLoadedDirective` pair is properly generic internally, but that
genericity stops at the component's own boundary.
The idiom this repo uses instead — see `brief.page.ts`, `dashboard.page.ts`,
`registration-detail.page.ts` — is a small **typed `computed()`** that unwraps the
`Success` value, narrowed locally in the template with `@if (x(); as p)`:
```ts
// in the component class
protected readonly loaded = computed(() => {
const s = this.model(); // or store.someRemoteData()
return s.tag === 'loaded' ? s : undefined;
});
```
```html
<!-- in the template, inside <ng-template appAsyncLoaded> -->
@if (loaded(); as s) {
<app-letter-composer [brief]="s.brief" ... />
}
```
No `$any()`, no cast — `loaded()` is a real, checked `T | undefined`, and `@if (…; as s)`
narrows it the same way any other nullable signal would.
## The `?scenario=` dev toggle
Any data page can be forced through all four states without touching the backend:
`?scenario=slow|loading|empty|error` (dev-only, `scenario.interceptor.ts`) rewrites the
timing/outcome of `/api/*` calls. Try it on `/brief` or `/dashboard`.
## Where the fetch ends and the domain begins
A store's own state machine (its `*.machine.ts`) should own the **domain** lifecycle of
what it holds (draft → submitted → approved, in the brief's case) — not the network
fetch's loading/failure, which is a generic concern `RemoteData` already models. Where a
machine's own `loading`/`failed` tags purely mirror the fetch (nothing extra beyond "not
loaded yet" / "the GET failed"), project them onto a `RemoteData` computed at the store
layer for `<app-async>` to render, the way `BriefStore.remoteData` does — the machine
keeps deciding what the _letter_ is doing, `RemoteData` keeps deciding what the _fetch_ is
doing.