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>
102 lines
4.4 KiB
Plaintext
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.
|