The folder now equals the layer, as CLAUDE.md decision 2 requires. 33 directories move by git mv (25 flat, plus upload/'s 8 subfolders split across all three layers). 28 distinct @shared/ui/* specifiers rewrite across 73 files, longest-first. Five relative imports inside upload/ become @shared/ui aliases because their sibling now lives in a different layer; two stay relative because both ends stay in the same layer. Four .mdx docs get their seven broken story imports fixed; atomic-design.mdx's page-shell import is untouched, because layout/ does not move. No component, template, story title, or layer-tag comment changes. That is RD-28's job. Verified against the ticket's acceptance commands: the 26 flat directories become exactly 3 layer folders with the counts the ticket names, only three @shared/ui/* prefixes remain (atoms, molecules, organisms), the .mdx import count holds at 7, and the relative-import count inside ui/ drops from 7 to 2 as decision 4 requires. The @shared/ui/ occurrence count moves from 200 to 205: decision 4 mandates turning 5 of those 7 relative imports into @shared/ui/* aliases, which decision 3's "200 before, 200 after" check does not account for. The 5-occurrence gap is exactly the 5 conversions decision 4 names, not a lost or duplicated specifier. npm run ci --full passes: lint, typecheck, dep:check, format, tokens, seam, both apps' + both libraries' tests, both apps' localized build, audit, backend tests, all three generated-artifact drift checks, and both Storybook instances' build + axe-core a11y suite (67+45 suites, 198+112 tests, all green). Co-Authored-By: Claude Sonnet 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`, `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`/`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.
|