Files
atomic-design-poc/libs/shared/docs/remote-data.mdx
T
ehoandClaude Opus 5 44dcc69811 refactor: ui/dashboard becomes ui/overzicht-secties (RD-36)
RD-03 moved the dashboard page to overzicht/ui/overzicht.page.ts and left four
sections in registratie/ui/dashboard/. The folder was named after a page that
lives in another context. A reader who opened it found four sections that are
not the dashboard.

The folder is now overzicht-secties/ — registratie's sections for the overzicht
page. The alias does not change, because the sections stay in the registratie
context. The story titles do not change, because they name the context.

Five documents cited registratie/ui/dashboard.page.ts, a file that RD-03
renamed. They now name overzicht.page.ts, or the section that owns the
behaviour they describe.

The /dashboard route keeps its path. It is a user-visible URL.

npm run ci --full passes: 67 and 45 storybook suites, 306 axe tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:21:15 +02:00

102 lines
4.4 KiB
Plaintext

import { Meta, Canvas } from '@storybook/addon-docs/blocks';
import * as AsyncStories from '../src/ui/molecules/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`, `overzicht.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`/`Loaded` tags purely mirror the fetch (nothing extra
beyond "not loaded yet" / "the GET failed"), project them with `fromLoadLifecycle` 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.