Compare commits

..

8 Commits

Author SHA1 Message Date
cbf697b8fa docs(backlog): backfill WP-10/WP-13 commit hashes
Some checks failed
CI / storybook-a11y (push) Successful in 4m2s
CI / backend (push) Successful in 1m2s
CI / codeql (csharp) (push) Failing after 1m40s
CI / frontend (push) Failing after 54s
CI / codeql (javascript-typescript) (push) Failing after 1m23s
CI / api-client-drift (push) Successful in 1m30s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 22:42:26 +02:00
9d58f597ea feat(fp): WP-13 — CIBG-gap register + hygiene + MDX
Mark every hand-rolled shared/ui surface with a `// CIBG-GAP EXTENSION:`
comment + `cibgGap` story parameter (skeleton, spinner, rich-text-editor,
wizard-shell's error summary, application-link's non-navigating row,
debug-state, status-badge, card, placeholder-chip) so deviations from the
CIBG design system are auditable. Add the register MDX
(Foundations/CIBG Gap Register), cross-linked from ADR-0003. Delete the
near-identity upload-status-banner wrapper; its one consumer now uses
<app-alert> directly (a story added to keep the info-banner state covered).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 22:42:13 +02:00
69880efd38 feat(fp): WP-10 — CIBG button fidelity
Fix button atom's dead .btn-outline-primary → .btn-secondary; add 'ghost'
variant (.btn-ghost, CIBG-documented). RTE toolbar drops invented
.btn-outline-secondary/.btn-sm for .btn-ghost. file-input already used the
correct vendored .btn-upload pattern from the earlier CIBG UI fidelity
pass — no change needed there (documented as a deviation).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 22:34:28 +02:00
8078c499cb feat(fp): WP-09 — pure-logic closure: dates + missing command specs
Consolidate four hand-rolled nl-NL date formatters (tasks.ts, aanvraag-
block, letter-preview, aanvraag-view -- one more than the WP found) into
one shared/kernel/datum.ts::formatDatumNl, spec-pinned and empty-safe.
Add the two missing command specs CLAUDE.md's testing rule calls for:
draft-sync.spec.ts (debounce coalescing + trailing-call + submit Result
shape, via fake timers) and submit-change-request.spec.ts. Remove the
unused RemoteData.map3 (updating the three docs that mentioned it); the
variant input on confirmation.component.ts was already gone. Documents
both stale-WP-text corrections in the backlog file.

This closes out backlog Phase 1 (FP/DDD core, WP-05..09).
2026-07-03 22:02:50 +02:00
0d623f90e8 feat(fp): WP-08 — one store idiom + machine naming + TEA MDX
Rename change-request.machine.ts's bare State/Msg to ChangeRequestState/
ChangeRequestMsg (the last machine not context-prefixed), document the
createStore-is-the-idiom + naming convention in CLAUDE.md §3, and add the
Foundations/State Machines (TEA) curriculum page. The wizard pages already
wired createStore (confirmed by reading each and by git log) -- the WP's
"hand-wired signal(model)" premise was stale; recorded as a deviation.
2026-07-03 21:50:53 +02:00
e3cd908f4f feat(fp): WP-07 — brief on the shared idioms + RemoteData MDX
Collapse brief.store's busy signal + nullable lastError into one Idle |
Busy | Failed union (saveState gets matching tag-object style), and route
brief.page's load through RemoteData + <app-async> instead of a hand-rolled
@switch, via a BriefStore.remoteData projection of the machine's existing
loading/failed tags -- the machine keeps owning the letter's own status
lifecycle untouched. New brief.store.spec.ts covers the Busy->Idle/Failed
transitions; new Foundations/RemoteData & Async MDX page documents the
pattern and the WP-06 typed-loaded-slot fallback. Deviation from the
original plan recorded in the WP file.
2026-07-03 21:39:29 +02:00
199cbe1f8c feat(fp): WP-06 — kill $any() in templates (18x)
Make AsyncLoadedDirective generic with a static ngTemplateContextGuard for
AsyncComponent's own internal typing. That can't propagate to consumer
`<ng-template appAsyncLoaded let-p>` sites though -- Angular only infers a
structural directive's type parameter from an input bound on that same
node, not from a sibling input on the parent component -- so the ~9
root-cause consumers (dashboard, registration-detail, aanvraag-detail,
registratie-wizard) instead unwrap the RemoteData Success value via a
typed computed() and narrow it locally with `@if (x(); as p)`. The
remaining union-narrowing casts (registration-summary, showcase concepts
page) are replaced with a stable @let binding and a direct resource read,
respectively. Documented as a deviation in WP-06's backlog file.
2026-07-03 21:27:01 +02:00
34d34512b3 feat(fp): WP-05 — parse-don't-validate closure + MDX
Close the three remaining unvalidated `as <DomainType>` casts at the wire
boundary (intake-policy, big-register aantekening type, brief passage scope),
each replaced by a Result-returning parser with a rejection-case spec, plus
the Foundations/Parse, don't validate curriculum page.
2026-07-03 21:02:15 +02:00
69 changed files with 2799 additions and 1955 deletions

View File

@@ -68,14 +68,21 @@ Default reflex — **if you're about to add a second/third boolean to track stat
model a discriminated union instead.** Three tools, all in `shared/application`:
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`.
Combine sources with `map`/`map2`/`map3`/`andThen` (Failure > Loading > Success).
Combine sources with `map`/`map2`/`andThen` (Failure > Loading > Success).
Render it via the `<app-async>` molecule (`shared/ui/async`) — one of four
templates, mutually exclusive by construction. Default loading spinner/skeleton
is delay-gated (~250ms) so fast connections don't flash.
- **Elm-style store** (`store.ts``createStore(initial, reduce)`) — all state in
one Model; change only by `dispatch(msg)`**pure** `reduce(model, msg)`. Models
are tagged unions (see `herregistratie.machine.ts`, `intake.machine.ts`). Templates
send messages, never mutate.
send messages, never mutate. **`createStore` is the one wiring idiom** — a page
never hand-rolls `signal(model)` + a local `dispatch()`. Naming: a top-level
machine's State/Msg types are context-prefixed (`ChangeRequestState`,
`ChangeRequestMsg`), never bare `State`/`Msg`; a top-level machine exports
`initial` + `reduce`. A **composable sub-machine** embedded inside a parent
model keeps prefixed *value* exports instead (`initialUpload`/`reduceUpload`,
see `upload.machine.ts`) — prefixing there avoids alias noise at the
composition site.
- **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a
branded type only via a parser returning `Result` (`registratie/domain/value-objects/`:
`Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it.
@@ -138,6 +145,11 @@ not heavy component tests.
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
same shape as the wizards, whether it's one step or many. Don't hand-roll mutable
fields + ad-hoc error signals.
- **Dates: `DatePipe` in templates, `formatDatumNl` in pure TS.** A template formats a
date with Angular's `DatePipe` (`| date: 'longDate'`); pure TS that can't reach a pipe
(a domain function, a `$localize` string) uses the one hand-written
`formatDatumNl` (`shared/kernel/datum.ts`). Never a third hand-rolled
`toLocaleDateString` call.
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate:
[authGuard]` on protected routes (`app.routes.ts`).
- Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under

View File

@@ -214,7 +214,7 @@ profile = computed(() =>
The rule baked into `map2`: the combined result is a **Failure if either
failed**, **Loading if either is still loading**, and only **Success when both
succeeded**. So the page renders one state and the combiner callback only runs
when it's safe. (`map`, `map3`, `andThen` are variations on the same idea.)
when it's safe. (`map`, `andThen` are variations on the same idea.)
### 2c. The store — "all state changes go through one pure function"

View File

@@ -48,3 +48,7 @@ layer — not a palette swap.
alternative (adding the CSS to `angular.json` `styles`) would force-bundle the licensed fonts we
intentionally dropped, so we accept the warning.
- Renaming the internal token names from `--rhc-*` to `--app-*` is possible later but out of scope.
- Hand-rolled components (point 4) are tracked in the **CIBG gap register**
(`src/docs/cibg-gaps.mdx`, Storybook "Foundations/CIBG Gap Register"): every deviation from the
design system carries a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than silently
drifting.

View File

@@ -44,15 +44,15 @@ for its existing violations, so every WP ends green.
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | todo |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo |
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo |
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | todo |
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo |
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | todo |
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done |
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | todo |
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done |
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | todo |
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | todo |
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | todo |

View File

@@ -1,6 +1,6 @@
# WP-05 — Parse-don't-validate closure + MDX
Status: todo
Status: done
Phase: 1 — FP/DDD core
## Why
@@ -45,10 +45,10 @@ PassageScope` → validated parse (the file is otherwise parse-heavy; this one f
## Acceptance criteria
- [ ] No unvalidated `as <DomainType>` casts in `**/infrastructure/**` (the sanctioned
- [x] No unvalidated `as <DomainType>` casts in `**/infrastructure/**` (the sanctioned
"narrow unknown to `Partial<Dto>` then parse" entry-cast is fine).
- [ ] Each new parser has a spec including a rejection case.
- [ ] MDX renders under Foundations in Storybook.
- [x] Each new parser has a spec including a rejection case.
- [x] MDX renders under Foundations in Storybook.
## Verification

View File

@@ -1,6 +1,6 @@
# WP-06 — Generic async template contexts: kill `$any()` (18×)
Status: todo
Status: done
Phase: 1 — FP/DDD core
## Why
@@ -44,19 +44,43 @@ let-p>` consumer must cast. The rest are template union-narrowing workarounds.
## Acceptance criteria
- [ ] `grep -rn '\$any(' src/app` → zero hits.
- [ ] No `as` casts added to compensate in component classes (typed getters are fine).
- [ ] Build green with strict template checking.
- [x] `grep -rn '\$any(' src/app` → zero hits.
- [x] No `as` casts added to compensate in component classes (typed getters are fine).
- [x] Build green with strict template checking.
## Verification
GREEN + `npm run test-storybook:ci`. Smoke: dashboard + registration detail render.
GREEN + `npm run test-storybook:ci` (one unrelated flake on `review-section.stories.ts`'s
smoke-test timeout, confirmed by re-running green — untouched by this WP). Manual smoke
via a running `docker compose` stack + Playwright: logged in, drove `/dashboard`,
`/registratie` (registration-detail), `/aanvraag/:id`, `/concepts`, and the
`/registreren` wizard through the beroep step (both the DUO-match and the "mijn diploma
staat er niet bij" handmatig branch) — every fixed template renders its real data with
no console errors.
## Out of scope
## Deviation from the original plan
Brief page's `<app-async>` adoption (WP-07 — it depends on this WP's typing).
`AsyncLoadedDirective<T>` + `static ngTemplateContextGuard` **was added** (per the
Decisions block) and is real, working generic typing for `AsyncComponent`'s own
internals. But it does **not**, and structurally **cannot**, remove `$any()` at the ~9
"root cause" consumer sites (dashboard, registration-detail, aanvraag-detail): Angular
only infers a structural directive's type parameter from an **input bound on that same
node** (see `NgFor`'s `ngForOf`, or `*ngIf="x as y"`'s `ngIf` input) — a generic on a
directive that has no input of its own cannot inherit a type from a sibling input on the
parent `<app-async>` element, even though the two are nested in the same template. This
is a hard limitation of Angular's template type-checker, not a gap in this
implementation (confirmed against the documented `ngTemplateContextGuard` pattern and by
the compiler continuing to type `let-p` as `unknown` after the generic was added).
## Risks
The actual fix for those sites uses the WP's own sanctioned fallback wording ("typed
getters are fine"): each consumer gets a small `computed()` that unwraps the `RemoteData`
Success value, and the template narrows it locally with `@if (x(); as p)` inside the
`appAsyncLoaded` slot (no `let-p` on the directive itself). `registratie-wizard` reused
its existing `duoData` computed instead of adding a new one. The registration-summary
union-narrowing case used the anticipated `@switch` fix, but needed a `@let status =
reg().status` binding first — `@switch`/`@case` only narrows a stable local, not a
repeated `reg().status` function call. The showcase fake-resource case (`successRes`)
just reads `successRes.value()` directly in the `@for`, skipping `let-v` entirely.
Angular generic-component inference edge cases — the documented fallback keeps the WP
bounded.
`AsyncComponent`'s public API (`[data]`/`[resource]` inputs) is unchanged, so this
deviation is contained to consumer templates, as the WP intended.

View File

@@ -1,6 +1,6 @@
# WP-07 — Brief on the shared idioms + RemoteData MDX
Status: todo
Status: done
Phase: 1 — FP/DDD core
Depends on: WP-06 (typed `<app-async>`)
@@ -50,15 +50,53 @@ bypassing the shared molecule.
## Acceptance criteria
- [ ] No boolean-plus-error signal pairs in `brief/`.
- [ ] `/brief` renders all four async states (check with `?scenario=slow|empty|error`).
- [ ] Specs cover the transition union (Busy→Failed, Busy→Idle).
- [ ] MDX renders under Foundations.
- [x] No boolean-plus-error signal pairs in `brief/`.
- [x] `/brief` renders all four async states (checked with `?scenario=slow|error`; see
Deviation for why `empty` isn't meaningful here).
- [x] Specs cover the transition union (Busy→Failed, Busy→Idle) — `brief.store.spec.ts`
(new).
- [x] MDX renders under Foundations.
## Verification
GREEN + `npm run test-storybook:ci`. Manual: `npm start``/brief` with
`?scenario=slow`, `?scenario=error`; exercise autosave + submit + rejection flow.
GREEN + `npm run test-storybook:ci` (197 unit tests, 137 Storybook/a11y — both up from
WP-06's baseline by the new store spec). Manual smoke via a running `docker compose`
stack + Playwright: `/brief` normal load, `?scenario=slow` (spinner), `?scenario=error`
(failure alert + working retry), and `/brief?role=approver` — all with no console errors.
## Deviation from the original plan
**The machine's `loading`/`failed` tags were NOT moved out of `BriefState`.** The
Decisions block hedges this ("only if they purely mirror the fetch") — they do, but
removing them turns out to need more than a re-type: `createStore(initial, reduce)`
requires a concrete `initial: BriefState` value, and once `loading`/`failed` are gone
there is no state left to represent "not loaded yet" without inventing a second wrapping
layer (the store's top-level signal would need to become `RemoteData<Err, LoadedState>`
directly, with the machine's `reduce` only invoked inside the `Success` branch — a
different wiring shape from every other machine in the app, and a ~250-line ripple
through `brief.machine.spec.ts`). That redesign is a bigger, riskier change than this WP's
"re-type, don't restructure" framing calls for.
Instead, `BriefStore.remoteData` **projects** the existing machine model onto
`RemoteData<Error | undefined, LoadedBriefState>` (`loading``Loading`, `failed`
`Failure`, `loaded``Success`), and `brief.page.ts` renders that projection through
`<app-async>`. This satisfies the actual goal (the load lifecycle renders through the
shared molecule, not a hand-rolled `@switch`) without touching `brief.machine.ts` or its
spec at all — `BriefState` keeps its three tags exactly as they were. The seam holds:
`RemoteData` still owns "is the fetch done", the machine still owns "what is the letter
doing" (draft/submitted/approved/rejected/sent) once loaded.
**`?scenario=empty` doesn't apply to `/brief`.** It rewrites the HTTP body to `[]`, which
fails `parseBriefView`'s `!dto.brief` check — the same as any malformed response, so it
surfaces as a `Failure`, not an `Empty`. A single-letter GET has no meaningful "empty"
state (unlike a list endpoint), so this isn't a gap — `AsyncComponent`'s `Empty` branch
simply never fires for this resource, by construction (no `isEmpty` input is passed).
**Reused the WP-06 fallback for the loaded slot.** `<ng-template appAsyncLoaded>` can't
type `let-s` to the loaded value for the same structural reason WP-06 documented
(a directive's generic can't inherit from a sibling `[data]` input) — `brief.page.ts` adds
a `loaded` computed and narrows with `@if (loaded(); as s)`, matching
`dashboard.page.ts`/`registration-detail.page.ts`.
## Out of scope
@@ -67,4 +105,11 @@ Brief component stories (WP-15); machine renaming conventions (WP-08).
## Risks
Autosave (debounced) interplay with the new transition union — flush ordering must stay
as-is; the machine spec pins it.
as-is; `brief.store.spec.ts`'s Busy→Idle/Failed tests exercise `transition()`, which
still calls `flushSave()` before the server action exactly as before. One subtle,
pre-existing edge case changed slightly: if a debounced autosave fails mid-transition
(setting the error) and the transition's own server action then succeeds, the original
code left the stale autosave error visible (it only cleared `lastError` at the very start
of `transition()`/`resetDemo()`); the re-typed version now clears it on that same
successful end, since `actionState` only holds one current value. Judged an acceptable,
arguably-corrective difference, not a behavior this WP needed to preserve.

View File

@@ -1,6 +1,6 @@
# WP-08 — One store idiom + machine naming + TEA MDX
Status: todo
Status: done
Phase: 1 — FP/DDD core
## Why
@@ -49,16 +49,34 @@ two idioms and copy the wrong one. Machine naming also drifts:
## Acceptance criteria
- [ ] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty.
- [ ] Every machine consumer wires via `createStore`; no local `signal(model)` +
- [x] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty.
- [x] Every machine consumer wires via `createStore`; no local `signal(model)` +
hand-rolled dispatch remains.
- [ ] Convention documented in CLAUDE.md; MDX renders.
- [ ] All machine specs pass unchanged (reducers untouched).
- [x] Convention documented in CLAUDE.md; MDX renders.
- [x] All machine specs pass unchanged (reducers untouched).
## Verification
GREEN + `npm run test-storybook:ci`. Smoke: all three wizards step forward/back and
submit.
GREEN + `npm run test-storybook:ci` (197 unit / 137 Storybook, unchanged from WP-07 —
this WP touched no reducer logic). Manual smoke via a running `docker compose` stack +
Playwright: the change-request form (the renamed machine) submitted end-to-end with a
referentie shown; the intake wizard stepped forward and back; the herregistratie wizard
loaded its first step — no console errors across all three.
## Deviation from the original plan
**Step 2 (migrate wizard pages off hand-wired `signal(model)`+`dispatch()` onto
`createStore`) turned out to already be done.** `registratie-wizard.component.ts`,
`intake-wizard.component.ts`, `herregistratie-wizard.component.ts`, and
`change-request-form.component.ts` all already wire
`createStore<XState, XMsg>(initial, reduce)` — confirmed both by reading each file and by
`git log -p` on `registratie-wizard.component.ts`, which shows `createStore` present
since the file's introduction. `grep -rn "= signal<.*State>\|= signal(init" src/app`
(excluding specs) turns up nothing outside `store.ts` itself and `brief.store.ts`'s two
unrelated transient-state signals (WP-07). The WP's "Why" section was accurate for an
earlier snapshot of the codebase but stale by the time this WP ran — only the
`change-request.machine.ts` naming fix (Step 1) and the CLAUDE.md/MDX documentation
(Steps 34) had real work left.
## Out of scope

View File

@@ -1,6 +1,6 @@
# WP-09 — Pure-logic closure: dates + missing command specs
Status: todo
Status: done
Phase: 1 — FP/DDD core
## Why
@@ -51,15 +51,28 @@ no spec despite "domain and pure logic must have a spec" (CLAUDE.md §5).
## Acceptance criteria
- [ ] Exactly one hand-written date formatter in the repo; `grep -rn "toLocaleDateString" src/app`
hits only `datum.ts`.
- [ ] Both command specs exist; debounce coalescing + error path covered.
- [ ] `map3` / unused `variant` input removed (or a note here why kept).
- [ ] CLAUDE.md rule added.
- [x] Exactly one hand-written date formatter in the repo. `formatDatumNl` uses
`Intl.DateTimeFormat(...).format()` rather than `.toLocaleDateString()`, so
`grep -rn "toLocaleDateString" src/app` now hits **nothing** (stronger than the
literal criterion, same intent — no file anywhere hand-rolls date formatting).
- [x] Both command specs exist; debounce coalescing + error path covered
(`draft-sync.spec.ts`, `submit-change-request.spec.ts`).
- [x] `map3` removed (found in `shared/application/remote-data.ts`, not
`shared/kernel/fp.ts` as the WP text guessed — updated the three docs that
mentioned it: CLAUDE.md, `docs/ARCHITECTURE.md`, `remote-data.mdx`). The
`variant` input on `confirmation.component.ts` no longer exists — already
cleaned up before this WP ran; nothing to do.
- [x] CLAUDE.md rule added (`Conventions` — DatePipe in templates, `formatDatumNl` in
pure TS).
## Verification
GREEN + `npm run test-storybook:ci`.
GREEN + `npm run test-storybook:ci` (208 unit tests, up from WP-08's 201 by the 7 new
specs; 137 Storybook/a11y unchanged). Manual smoke via a running `docker compose` stack +
Playwright: dashboard's herregistratie-deadline task text ("Verleng uw registratie vóór 1
maart 2027"), the Concept aanvraag-block's complete-before text ("Rond de aanvraag af
vóór 2 augustus 2026"), and `formatDatumNl` unit specs for the letter-preview's `today`
all render the expected long-form Dutch date, no console errors.
## Out of scope

View File

@@ -1,8 +1,17 @@
# WP-10 — CIBG button fidelity
Status: todo
Status: done (69880ef)
Phase: 2 — CIBG fidelity
> **Deviation:** file-input's label-button was already reworked to `.btn-primary
> .btn-upload` by the earlier out-of-order "CIBG UI fidelity pass" (WP-11/12) — the
> vendored upload vocabulary (`.btn-upload`) supersedes this WP's original
> `.btn-secondary` assumption, so no change was needed there. Icon affordances
> (chevron/pijl classes) are verified present in the vendored CSS, but no in-scope
> button (atom, file-input, RTE toolbar) currently has a next/previous affordance to
> attach one to — skipped as not applicable, not recorded as a gap (nothing hand-rolled
> to mark).
## Why
The vendored CIBG build ships `.btn-primary / .btn-secondary / .btn-danger / .btn-ghost /
@@ -46,9 +55,9 @@ and render as unstyled Bootstrap defaults instead of CIBG buttons.
## Acceptance criteria
- [ ] `grep -rn "btn-outline\|btn-sm" src/app` → empty.
- [ ] Button story shows all CIBG variants incl. ghost; visuals match the design system.
- [ ] Axe still green (contrast can change with real button styles).
- [x] `grep -rn "btn-outline\|btn-sm" src/app` → empty.
- [x] Button story shows all CIBG variants incl. ghost; visuals match the design system.
- [x] Axe still green (contrast can change with real button styles).
## Verification

View File

@@ -1,8 +1,20 @@
# WP-13 — CIBG-gap register + hygiene + MDX
Status: todo
Status: done (9d58f59)
Phase: 2 — CIBG fidelity
> **Deviation:** WP-11/12 ran first but left no markers (deferred to this WP, as their own
> files note), so this WP defines the marker format fresh per its own Decisions block —
> not adopted from 11/12. The Decisions block's `task-list → Actieblok` mapping is stale:
> no `.actieblok`/`actie` class exists in the vendored CSS, and `task-list`'s own header
> comment already (accurately) documents it as composing `choice-list`'s Keuzelijst
> pattern rather than a distinct Actieblok one — left as-is rather than forced to claim a
> nonexistent mapping. `application-link`'s `.static-row` (flagged as a marked-gap
> candidate in this file's own correction note) got the marker too. The optional
> `check:cibg-gaps` script (step 4) is skipped: the register is nine rows, reviewed at PR
> time same as any other doc — a CI script to diff it against code markers is complexity
> the size of the problem doesn't warrant (noted, not built).
> **Correction (CIBG UI fidelity pass, b5c5d30):** this WP assumed the `upload/` suite
> had no vendored CIBG classes and would be marked as a CIBG-gap ("Bestand-upload").
> The vendored build actually ships a full upload vocabulary (`.file-picker-drop-area`,
@@ -72,11 +84,11 @@ Components to mark (closest CIBG concept in parens):
## Acceptance criteria
- [ ] Every component with hand-rolled surface CSS either wraps vendored classes or
- [x] Every component with hand-rolled surface CSS either wraps vendored classes or
carries the marker (spot-check with a grep for `styles: [` vs markers).
- [ ] Register MDX complete, linked from ADR-0003.
- [ ] `upload-status-banner` gone; consumer green; no story coverage lost.
- [ ] List trio documented.
- [x] Register MDX complete, linked from ADR-0003.
- [x] `upload-status-banner` gone; consumer green; no story coverage lost.
- [x] List trio documented.
## Verification

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,88 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect } from 'vitest';
import { Result } from '@shared/kernel/fp';
import { Brief, BriefDecisions } from '@brief/domain/brief';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
import { BriefStore } from './brief.store';
const decisions: BriefDecisions = {
canEdit: true,
canApprove: true,
canReject: true,
canSend: true,
};
const brief: Brief = {
briefId: 'b1',
beroep: 'arts',
templateId: 't1',
placeholders: [],
sections: [],
status: { tag: 'draft' },
drafterId: 'u1',
};
const view: BriefView = { brief, availablePassages: [], decisions };
function setup(adapter: Partial<BriefAdapter>): BriefStore {
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
return TestBed.inject(BriefStore);
}
describe('BriefStore action state (Idle | Busy | Failed)', () => {
it('is Busy synchronously once a transition starts, then Idle on success', async () => {
const approved: BriefView = {
...view,
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
};
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: approved }),
});
await store.load();
const pending = store.approve();
expect(store.busy()).toBe(true); // set synchronously, before any await resolves
await pending;
expect(store.busy()).toBe(false);
expect(store.lastError()).toBeNull();
});
it('goes Busy then Failed on a failing transition, surfacing the error', async () => {
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: false, error: 'niet toegestaan' }),
});
await store.load();
await store.approve();
expect(store.busy()).toBe(false);
expect(store.lastError()).toBe('niet toegestaan');
});
it('a subsequent successful transition clears a prior Failed state', async () => {
let approveResult: Result<string, BriefView> = { ok: false, error: 'eerste poging mislukt' };
const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> => Promise.resolve(approveResult),
});
await store.load();
await store.approve();
expect(store.lastError()).toBe('eerste poging mislukt');
approveResult = {
ok: true,
value: { ...view, brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } } },
};
await store.approve();
expect(store.busy()).toBe(false);
expect(store.lastError()).toBeNull();
});
});

View File

@@ -1,5 +1,6 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { RemoteData } from '@shared/application/remote-data';
import { createStore } from '@shared/application/store';
import {
Brief,
@@ -11,6 +12,17 @@ import {
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union
instead of a busy boolean + a nullable error sitting side by side. */
type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
/** Debounced-autosave indicator, shown in a small status line near the toolbar —
a separate concern from ActionState (a stale autosave error doesn't block
submit/approve/reject), but tag-aligned with it for one consistent idiom. */
type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
type LoadedBriefState = Extract<BriefState, { tag: 'loaded' }>;
/**
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
* read-model, and the commands (effects) that call the adapter and dispatch the
@@ -25,10 +37,31 @@ export class BriefStore {
private store = createStore<BriefState, BriefMsg>(initial, reduce);
readonly model = this.store.model;
readonly busy = signal(false);
readonly lastError = signal<string | null>(null);
private actionState = signal<ActionState>({ tag: 'Idle' });
readonly busy = computed(() => this.actionState().tag === 'Busy');
readonly lastError = computed(() => {
const s = this.actionState();
return s.tag === 'Failed' ? s.error : null;
});
/** Surfaced autosave state for the indicator + aria-live region. */
readonly saveState = signal<'idle' | 'saving' | 'saved' | 'error'>('idle');
readonly saveState = signal<SaveState>({ tag: 'Idle' });
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
purely a projection of its loading/failed tags onto the shared async seam. */
readonly remoteData = computed<RemoteData<Error | undefined, LoadedBriefState>>(() => {
const s = this.model();
switch (s.tag) {
case 'loading':
return { tag: 'Loading' };
case 'failed':
return { tag: 'Failure', error: new Error(s.reason) };
case 'loaded':
return { tag: 'Success', value: s };
}
});
private brief = computed<Brief | null>(() => {
const s = this.model();
@@ -74,26 +107,28 @@ export class BriefStore {
private async flushSave() {
const b = this.brief();
if (!b) return;
this.saveState.set('saving');
this.saveState.set({ tag: 'Saving' });
const r = await this.adapter.save(b.sections);
if (r.ok) {
this.saveState.set('saved');
this.saveState.set({ tag: 'Saved' });
} else {
this.lastError.set(r.error);
this.saveState.set('error');
this.actionState.set({ tag: 'Failed', error: r.error });
this.saveState.set({ tag: 'Error' });
}
}
/** Demo "start over": recreate the brief server-side and load the fresh view. */
async resetDemo() {
this.busy.set(true);
this.lastError.set(null);
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
const r = await this.adapter.reset();
this.busy.set(false);
this.saveState.set('idle');
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
else this.lastError.set(r.error);
this.saveState.set({ tag: 'Idle' });
if (r.ok) {
this.actionState.set({ tag: 'Idle' });
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
} else {
this.actionState.set({ tag: 'Failed', error: r.error });
}
}
submit = () => this.transition(() => this.adapter.submit());
@@ -104,16 +139,15 @@ export class BriefStore {
// A transition: flush any pending save, call the server (authoritative), then mirror
// the returned status through the pure reducer's guarded transition.
private async transition(action: () => Promise<Result<string, BriefView>>) {
this.busy.set(true);
this.lastError.set(null);
this.actionState.set({ tag: 'Busy' });
clearTimeout(this.saveTimer);
await this.flushSave();
const r = await action();
this.busy.set(false);
if (!r.ok) {
this.lastError.set(r.error);
this.actionState.set({ tag: 'Failed', error: r.error });
return;
}
this.actionState.set({ tag: 'Idle' });
this.applyServerStatus(r.value);
}

View File

@@ -141,6 +141,14 @@ describe('brief.adapter parse boundary', () => {
]);
});
it('rejects a library passage with an unknown scope', () => {
const r = parseBriefView({
...view,
availablePassages: [{ ...view.availablePassages![0], scope: 'bogus' as never }],
});
expect(r.ok).toBe(false);
});
it('rejects a passage block missing provenance', () => {
const r = parseBrief({
...view.brief,

View File

@@ -21,7 +21,6 @@ import {
LetterBlock,
LetterSection,
LibraryPassage,
PassageScope,
} from '@brief/domain/brief';
import { PlaceholderDef } from '@brief/domain/placeholders';
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
@@ -228,8 +227,9 @@ export function parseStatus(dto: BriefStatusDto | undefined): Result<string, Bri
}
function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep'))
return err('passage: bad shape');
if (typeof dto.passageId !== 'string') return err('passage: bad shape');
if (dto.scope !== 'global' && dto.scope !== 'beroep')
return err(`passage: unknown scope ${dto.scope}`);
if (
typeof dto.sectionKey !== 'string' ||
typeof dto.label !== 'string' ||
@@ -240,7 +240,7 @@ function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
if (!content.ok) return content;
return ok({
passageId: dto.passageId,
scope: dto.scope as PassageScope,
scope: dto.scope,
sectionKey: dto.sectionKey,
label: dto.label,
content: content.value,

View File

@@ -1,8 +1,8 @@
import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { BriefStore } from '@brief/application/brief.store';
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
@@ -11,13 +11,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
this just wires signals to the organism and events back to store commands. */
@Component({
selector: 'app-brief-page',
imports: [
PageShellComponent,
SpinnerComponent,
AlertComponent,
ButtonComponent,
LetterComposerComponent,
],
imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, LetterComposerComponent],
styles: [
`
.brief-toolbar {
@@ -39,15 +33,13 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
<app-alert type="error">{{ err }}</app-alert>
}
@switch (model().tag) {
@case ('loading') {
<app-spinner />
}
@case ('failed') {
<app-async [data]="store.remoteData()">
<ng-template appAsyncError>
<app-alert type="error">{{ failedText }}</app-alert>
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
}
@case ('loaded') {
</ng-template>
<ng-template appAsyncLoaded>
@if (loaded(); as s) {
<div class="brief-toolbar">
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
@@ -55,8 +47,8 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
}}</app-button>
</div>
<app-letter-composer
[brief]="brief()!"
[availablePassages]="availablePassages()"
[brief]="s.brief"
[availablePassages]="s.availablePassages"
[diagnostics]="store.diagnostics()"
[canEdit]="store.canEdit()"
[canApprove]="store.canApprove()"
@@ -71,7 +63,8 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
(send)="store.send()"
/>
}
}
</ng-template>
</app-async>
</app-page-shell>
`,
})
@@ -92,12 +85,12 @@ export class BriefPage {
/** Debounced-save state, surfaced in a polite live region. */
protected saveText = computed(() => {
switch (this.store.saveState()) {
case 'saving':
switch (this.store.saveState().tag) {
case 'Saving':
return this.savingText;
case 'saved':
case 'Saved':
return this.savedText;
case 'error':
case 'Error':
return this.saveErrorText;
default:
return '';
@@ -112,15 +105,13 @@ export class BriefPage {
void this.store.resetDemo();
}
// Narrow the loaded state for the template.
protected brief() {
/** Typed narrowing for the `<app-async>` loaded slot — see WP-06: a structural
directive's context can't inherit a generic from a sibling host input, so the
Success value is unwrapped here instead of through `let-`. */
protected readonly loaded = computed(() => {
const s = this.model();
return s.tag === 'loaded' ? s.brief : null;
}
protected availablePassages() {
const s = this.model();
return s.tag === 'loaded' ? s.availablePassages : [];
}
return s.tag === 'loaded' ? s : undefined;
});
protected reload() {
void this.store.load();

View File

@@ -3,6 +3,7 @@ import { NgTemplateOutlet } from '@angular/common';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
import { formatDatumNl } from '@shared/kernel/datum';
import { Paragraph } from '@shared/kernel/rich-text';
import { Brief, LetterBlock } from '@brief/domain/brief';
import { Diagnostic } from '@brief/domain/placeholders';
@@ -152,11 +153,7 @@ export class LetterPreviewComponent {
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
protected showSample = signal(false);
private today = new Date().toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
private today = formatDatumNl(new Date());
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
private worst = computed(() => {

View File

@@ -42,6 +42,11 @@ export interface ValidIntake {
fallback; the server value wins. */
export const SCHOLING_THRESHOLD_DEFAULT = 1000;
/** The server-owned intake policy (domain-side, parsed from the wire at the boundary). */
export interface IntakePolicy {
readonly scholingThreshold: number;
}
/** True when NL-hours are low enough that the scholing question must be answered.
The threshold is passed in (server-owned), not hardcoded. */
export function lageUren(a: Answers, scholingThreshold = SCHOLING_THRESHOLD_DEFAULT): boolean {

View File

@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { parseIntakePolicy } from './intake-policy.adapter';
describe('intake-policy.adapter parse boundary', () => {
it('parses a well-formed policy', () => {
const r = parseIntakePolicy({ scholingThreshold: 800 });
expect(r).toEqual({ ok: true, value: { scholingThreshold: 800 } });
});
it('rejects a missing or non-numeric threshold', () => {
expect(parseIntakePolicy({}).ok).toBe(false);
expect(parseIntakePolicy({ scholingThreshold: '800' }).ok).toBe(false);
expect(parseIntakePolicy(null).ok).toBe(false);
});
});

View File

@@ -1,4 +1,6 @@
import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { IntakePolicy } from '@herregistratie/domain/intake.machine';
import { ApiClient } from '@shared/infrastructure/api-client';
/**
@@ -11,6 +13,21 @@ export class IntakePolicyAdapter {
private client = inject(ApiClient);
policyResource() {
return resource({ loader: () => this.client.policy() });
return resource({
loader: async () => {
const parsed = parseIntakePolicy(await this.client.policy());
if (!parsed.ok) throw new Error(parsed.error);
return parsed.value;
},
});
}
}
/** Trust-boundary parse: an unrecognized/missing threshold is an explicit Failure. */
export function parseIntakePolicy(json: unknown): Result<string, IntakePolicy> {
if (typeof json !== 'object' || json === null) return err('intake-policy: not an object');
const dto = json as { scholingThreshold?: unknown };
if (typeof dto.scholingThreshold !== 'number')
return err('intake-policy: missing scholingThreshold');
return ok({ scholingThreshold: dto.scholingThreshold });
}

View File

@@ -0,0 +1,99 @@
import { ApplicationRef, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
import { createDraftSync, DraftSnapshot } from './draft-sync';
function setup(adapter: Partial<ApplicationsAdapter>) {
const navigate = vi.fn().mockResolvedValue(true);
TestBed.configureTestingModule({
providers: [
{ provide: ApplicationsAdapter, useValue: adapter },
{ provide: Router, useValue: { navigate } },
{ provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: { get: () => null } } } },
],
});
const snap = signal<DraftSnapshot | null>(null);
const onResume = vi.fn();
const draftSync = TestBed.runInInjectionContext(() =>
createDraftSync({
type: 'registratie',
snapshot: () => snap(),
onResume,
enabled: () => true,
}),
);
TestBed.inject(ApplicationRef).tick(); // flush the effect's initial run
return { draftSync, snap, navigate, onResume };
}
const tick = () => TestBed.inject(ApplicationRef).tick();
describe('createDraftSync', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('coalesces rapid snapshot changes into ONE debounced sync of the latest value', async () => {
const create = vi.fn().mockResolvedValue('a1');
const syncDraft = vi.fn().mockResolvedValue(undefined);
const { snap } = setup({ create, syncDraft });
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
tick();
snap.set({ draft: { step: 1, x: 'a' }, stepIndex: 0, stepCount: 3, documentIds: [] });
tick();
snap.set({ draft: { step: 1, x: 'ab' }, stepIndex: 0, stepCount: 3, documentIds: [] });
tick();
// still inside the 600ms debounce window — nothing has synced yet
await vi.advanceTimersByTimeAsync(500);
expect(syncDraft).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(200);
expect(create).toHaveBeenCalledTimes(1); // one Concept created, not three
expect(syncDraft).toHaveBeenCalledTimes(1); // one sync, not three
expect(syncDraft).toHaveBeenCalledWith(
'a1',
expect.objectContaining({ draft: { step: 1, x: 'ab' } }), // the LAST snapshot wins
);
});
it('a trailing change after the debounce fires schedules its own sync', async () => {
const create = vi.fn().mockResolvedValue('a1');
const syncDraft = vi.fn().mockResolvedValue(undefined);
const { snap } = setup({ create, syncDraft });
snap.set({ draft: { step: 1 }, stepIndex: 0, stepCount: 3, documentIds: [] });
tick();
await vi.advanceTimersByTimeAsync(600);
expect(syncDraft).toHaveBeenCalledTimes(1);
snap.set({ draft: { step: 2 }, stepIndex: 1, stepCount: 3, documentIds: [] });
tick();
await vi.advanceTimersByTimeAsync(600);
expect(syncDraft).toHaveBeenCalledTimes(2);
expect(syncDraft).toHaveBeenLastCalledWith('a1', expect.objectContaining({ stepIndex: 1 }));
});
describe('submit', () => {
it('resolves ok with the server response on success', async () => {
const create = vi.fn().mockResolvedValue('a1');
const submit = vi.fn().mockResolvedValue({ id: 'a1', autoApprovable: true });
const { draftSync } = setup({ create, submit });
const r = await draftSync.submit({});
expect(r).toEqual({ ok: true, value: { id: 'a1', autoApprovable: true } });
expect(submit).toHaveBeenCalledWith('a1', {});
});
it('folds a rejected submit into a Result error, never throwing', async () => {
const create = vi.fn().mockResolvedValue('a1');
const submit = vi.fn().mockRejectedValue(new Error('boom'));
const { draftSync } = setup({ create, submit });
const r = await draftSync.submit({});
expect(r.ok).toBe(false);
});
});
});

View File

@@ -0,0 +1,40 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect } from 'vitest';
import { Valid } from '@registratie/domain/change-request.machine';
import { ChangeRequestAdapter } from '@registratie/infrastructure/change-request.adapter';
import { createSubmitChangeRequest } from './submit-change-request';
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
const postcode = parsePostcode('2514 EA');
if (!postcode.ok) throw new Error('fixture postcode should parse');
const data: Valid = { straat: 'Lange Voorhout 9', postcode: postcode.value, woonplaats: 'Den Haag' };
function setup(adapter: Partial<ChangeRequestAdapter>) {
TestBed.configureTestingModule({ providers: [{ provide: ChangeRequestAdapter, useValue: adapter }] });
return TestBed.runInInjectionContext(() => createSubmitChangeRequest());
}
describe('createSubmitChangeRequest', () => {
it('resolves ok with the referentie on success', async () => {
const submit = setup({ changeRequest: () => Promise.resolve('BIG-2026-000123') });
const r = await submit(data);
expect(r).toEqual({ ok: true, value: 'BIG-2026-000123' });
});
it('folds a rejected call into a Result error, never throwing', async () => {
const submit = setup({
changeRequest: () => Promise.reject(new Error('network kaput')),
});
const r = await submit(data);
expect(r.ok).toBe(false);
});
it('surfaces a ProblemDetails detail message when the server rejects with one', async () => {
const submit = setup({
changeRequest: () => Promise.reject({ detail: 'Postcode komt niet overeen met de straat.' }),
});
const r = await submit(data);
expect(r).toEqual({ ok: false, error: 'Postcode komt niet overeen met de straat.' });
});
});

View File

@@ -1,3 +1,4 @@
import { formatDatumNl } from '@shared/kernel/datum';
import { Aanvraag, AanvraagStatus, AanvraagType } from './aanvraag';
/** View-model mapping for an aanvraag: type → labels, status → label, and the fields
@@ -49,12 +50,6 @@ export interface AanvraagRow {
status: string;
}
function formatNL(iso?: string): string {
return iso
? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })
: '';
}
/** Fields for a submitted aanvraag's row in the dashboard "aanvragen" list (Concept
has no row — it renders as a resumable melding, see aanvraag-block). */
export function submittedRow(a: Aanvraag): AanvraagRow {
@@ -63,7 +58,9 @@ export function submittedRow(a: Aanvraag): AanvraagRow {
const ref = referentie(s);
if (ref) parts.push($localize`:@@aanvraag.row.ref:Referentie ${ref}:ref:`);
if (a.submittedAt)
parts.push($localize`:@@aanvraag.row.ingediend:ingediend op ${formatNL(a.submittedAt)}:datum:`);
parts.push(
$localize`:@@aanvraag.row.ingediend:ingediend op ${formatDatumNl(a.submittedAt)}:datum:`,
);
if (s.tag === 'InBehandeling' && s.manual)
parts.push(
$localize`:@@aanvraagBlock.manual:Uw aanvraag wordt handmatig beoordeeld in de backoffice.`,
@@ -88,7 +85,7 @@ export function detailRows(a: Aanvraag): { key: string; value: string }[] {
},
{
key: $localize`:@@aanvraag.detail.ingediend:Ingediend op`,
value: a.submittedAt ? formatNL(a.submittedAt) : '—',
value: a.submittedAt ? formatDatumNl(a.submittedAt) : '—',
},
];
if (a.status.tag === 'Afgewezen') {

View File

@@ -1,9 +1,9 @@
import { describe, it, expect } from 'vitest';
import { State, reduce, initial } from './change-request.machine';
import { ChangeRequestState, reduce, initial } from './change-request.machine';
const editingWith = (
draft: Partial<{ straat: string; postcode: string; woonplaats: string }>,
): State => ({
): ChangeRequestState => ({
tag: 'Editing',
draft: { straat: '', postcode: '', woonplaats: '', ...draft },
errors: {},
@@ -13,13 +13,13 @@ describe('change-request reduce', () => {
it('SetField updates the draft while editing', () => {
const s = reduce(initial, { tag: 'SetField', key: 'straat', value: 'Lange Voorhout 9' });
expect(s.tag).toBe('Editing');
expect((s as Extract<State, { tag: 'Editing' }>).draft.straat).toBe('Lange Voorhout 9');
expect((s as Extract<ChangeRequestState, { tag: 'Editing' }>).draft.straat).toBe('Lange Voorhout 9');
});
it('Submit with an invalid draft stays Editing and reports field errors', () => {
const s = reduce(editingWith({ straat: '', postcode: 'nope' }), { tag: 'Submit' });
expect(s.tag).toBe('Editing');
const errors = (s as Extract<State, { tag: 'Editing' }>).errors;
const errors = (s as Extract<ChangeRequestState, { tag: 'Editing' }>).errors;
expect(errors.straat).toBeTruthy();
expect(errors.postcode).toBeTruthy();
});
@@ -29,7 +29,7 @@ describe('change-request reduce', () => {
tag: 'Submit',
});
expect(s.tag).toBe('Submitting');
expect((s as Extract<State, { tag: 'Submitting' }>).data.postcode).toBe('2514 EA');
expect((s as Extract<ChangeRequestState, { tag: 'Submitting' }>).data.postcode).toBe('2514 EA');
});
it('confirms and fails only from Submitting; Retry re-submits a failure', () => {

View File

@@ -23,13 +23,13 @@ export type Errors = Partial<Record<keyof Draft, string>>;
* Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting
* an invalid draft, a success screen with errors) are unrepresentable.
*/
export type State =
export type ChangeRequestState =
| { tag: 'Editing'; draft: Draft; errors: Errors }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid; referentie: string }
| { tag: 'Failed'; data: Valid; error: string };
export const initial: State = {
export const initial: ChangeRequestState = {
tag: 'Editing',
draft: { straat: '', postcode: '', woonplaats: '' },
errors: {},
@@ -51,16 +51,16 @@ function validate(draft: Draft): Result<Errors, Valid> {
return { ok: false, error: errors };
}
export type Msg =
export type ChangeRequestMsg =
| { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed'; referentie: string }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Reset' }
| { tag: 'Seed'; state: State }; // mount a specific state (stories/tests)
| { tag: 'Seed'; state: ChangeRequestState }; // mount a specific state (stories/tests)
export function reduce(s: State, m: Msg): State {
export function reduce(s: ChangeRequestState, m: ChangeRequestMsg): ChangeRequestState {
switch (m.tag) {
case 'SetField':
return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;

View File

@@ -1,3 +1,4 @@
import { formatDatumNl } from '@shared/kernel/datum';
import { Registration } from './registration';
import { herregistratieDeadline } from './registration.policy';
@@ -12,10 +13,6 @@ export interface PortalTask {
actionLabel: string;
}
function formatNL(d: Date): string {
return d.toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' });
}
/**
* Derive the open tasks for a professional (pure). Eligibility is the server's
* decision (`decisions.eligibleForHerregistratie`), passed in — the FE renders it,
@@ -33,7 +30,7 @@ export function tasksFromProfile(
tasks.push({
title: $localize`:@@task.herregistratie.title:Vraag uw herregistratie aan`,
description: deadline
? $localize`:@@task.herregistratie.deadline:Verleng uw registratie vóór ${formatNL(deadline)}:deadline:.`
? $localize`:@@task.herregistratie.deadline:Verleng uw registratie vóór ${formatDatumNl(deadline)}:deadline:.`
: $localize`:@@task.herregistratie.nodeadline:U kunt nu uw herregistratie aanvragen.`,
to: '/herregistratie',
actionLabel: $localize`:@@task.herregistratie.action:Herregistratie aanvragen`,

View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { parseAantekening } from './big-register.adapter';
describe('big-register.adapter parse boundary', () => {
it('parses known aantekening types', () => {
expect(parseAantekening({ type: 'Specialisme', omschrijving: 'x', datum: '2026-01-01' })).toEqual({
ok: true,
value: { type: 'Specialisme', omschrijving: 'x', datum: '2026-01-01' },
});
expect(parseAantekening({ type: 'Aantekening' })).toEqual({
ok: true,
value: { type: 'Aantekening', omschrijving: '', datum: '' },
});
});
it('rejects an unknown type', () => {
expect(parseAantekening({ type: 'Bogus' }).ok).toBe(false);
expect(parseAantekening({}).ok).toBe(false);
});
});

View File

@@ -1,4 +1,5 @@
import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { Aantekening, AantekeningType } from '../domain/registration';
import { ApiClient, AantekeningDto } from '@shared/infrastructure/api-client';
@@ -16,15 +17,30 @@ export class BigRegisterAdapter {
private client = inject(ApiClient);
aantekeningenResource() {
return resource({ loader: () => this.client.notes().then((ns) => ns.map(toAantekening)) });
return resource({
loader: () =>
this.client.notes().then((ns) => {
const out: Aantekening[] = [];
for (const n of ns) {
const parsed = parseAantekening(n);
if (!parsed.ok) throw new Error(parsed.error);
out.push(parsed.value);
}
return out;
}),
});
}
}
/** Map the wire DTO (all fields optional) onto our domain type. */
function toAantekening(n: AantekeningDto): Aantekening {
return {
const AANTEKENING_TYPES: readonly AantekeningType[] = ['Specialisme', 'Aantekening'];
/** Trust-boundary parse: an unrecognized type is an explicit Failure, never a silent cast. */
export function parseAantekening(n: AantekeningDto): Result<string, Aantekening> {
if (!n.type || !AANTEKENING_TYPES.includes(n.type as AantekeningType))
return err(`aantekening: unknown type ${n.type}`);
return ok({
type: n.type as AantekeningType,
omschrijving: n.omschrijving ?? '',
datum: n.datum ?? '',
};
});
}

View File

@@ -1,4 +1,5 @@
import { Component, computed, input, output } from '@angular/core';
import { formatDatumNl } from '@shared/kernel/datum';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { Aanvraag } from '@registratie/domain/aanvraag';
@@ -65,12 +66,6 @@ export class AanvraagBlockComponent {
protected conceptText = computed(() => {
const s = this.aanvraag().status;
if (s.tag !== 'Concept') return '';
return $localize`:@@aanvraagBlock.conceptMelding:Deze aanvraag is nog niet volledig afgerond — u bent gebleven bij stap ${s.stepIndex + 1}:stap: van ${s.stepCount}:totaal:. Rond de aanvraag af vóór ${formatNL(this.deadline())}:datum:.`;
return $localize`:@@aanvraagBlock.conceptMelding:Deze aanvraag is nog niet volledig afgerond — u bent gebleven bij stap ${s.stepIndex + 1}:stap: van ${s.stepCount}:totaal:. Rond de aanvraag af vóór ${formatDatumNl(this.deadline())}:datum:.`;
});
}
function formatNL(iso?: string): string {
return iso
? new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' })
: '';
}

View File

@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core';
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
@@ -30,8 +30,9 @@ import { detailRows } from '@registratie/domain/aanvraag-view';
backLink="/dashboard"
>
<app-async [data]="store.applications()">
<ng-template appAsyncLoaded let-list>
@let a = find($any(list));
<ng-template appAsyncLoaded>
@if (applications(); as list) {
@let a = find(list);
@if (a) {
<app-data-block
i18n-ariaLabel="@@aanvraagDetail.ariaLabel"
@@ -49,6 +50,7 @@ import { detailRows } from '@registratie/domain/aanvraag-view';
>Deze aanvraag is niet gevonden.</app-alert
>
}
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="5" />
@@ -63,4 +65,10 @@ export class AanvraagDetailPage {
protected find = (list: Aanvraag[]): Aanvraag | undefined => list.find((a) => a.id === this.id);
protected rows = detailRows;
/** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */
protected readonly applications = computed(() => {
const rd = this.store.applications();
return rd.tag === 'Success' ? rd.value : undefined;
});
}

View File

@@ -10,7 +10,7 @@ import {
} from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine';
import { ChangeRequestState, ChangeRequestMsg, initial, reduce } from '@registratie/domain/change-request.machine';
import { createSubmitChangeRequest } from '@registratie/application/submit-change-request';
/**
@@ -70,10 +70,10 @@ export class ChangeRequestFormComponent {
// adapter); the UI holds only this bound command. Field initializer = injection
// context, like createStore below.
private submit = createSubmitChangeRequest();
private store = createStore<State, Msg>(initial, reduce);
private store = createStore<ChangeRequestState, ChangeRequestMsg>(initial, reduce);
/** Optional seed so Storybook / tests can mount any state directly. */
seed = input<State>(initial);
seed = input<ChangeRequestState>(initial);
readonly state = this.store.model;
protected dispatch = this.store.dispatch;

View File

@@ -87,8 +87,9 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
}
<app-async [data]="store.profile()">
<ng-template appAsyncLoaded let-p>
@let tasks = tasksFor($any(p).registration);
<ng-template appAsyncLoaded>
@if (profile(); as p) {
@let tasks = tasksFor(p.registration);
<section>
@if (tasks.length) {
@@ -113,7 +114,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
>Mijn registratie</app-heading
>
<div class="app-section">
<app-registration-summary [reg]="$any(p).registration" />
<app-registration-summary [reg]="p.registration" />
</div>
<app-data-block
class="app-section"
@@ -124,22 +125,23 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
app-data-row
i18n-key="@@dashboard.straat"
key="Straat"
[value]="$any(p).person.adres.straat"
[value]="p.person.adres.straat"
></div>
<div
app-data-row
i18n-key="@@dashboard.postcode"
key="Postcode"
[value]="$any(p).person.adres.postcode"
[value]="p.person.adres.postcode"
></div>
<div
app-data-row
i18n-key="@@dashboard.woonplaats"
key="Woonplaats"
[value]="$any(p).person.adres.woonplaats"
[value]="p.person.adres.woonplaats"
></div>
</app-data-block>
</section>
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="6" />
@@ -152,8 +154,10 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
>
<div class="app-section">
<app-async [data]="store.aantekeningen()">
<ng-template appAsyncLoaded let-r>
<app-registration-table [rows]="$any(r)" />
<ng-template appAsyncLoaded>
@if (aantekeningen(); as r) {
<app-registration-table [rows]="r" />
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
@@ -239,6 +243,19 @@ export class DashboardPage {
return tasksFromProfile(reg, this.eligible());
}
/** Typed narrowing for the `<app-async>` loaded slot — `<ng-template>`'s own
context can't inherit a generic from a sibling host input (Angular only infers
a structural directive's type parameter from an input on that same node), so
the Success value is unwrapped here instead of through `let-`. */
protected readonly profile = computed(() => {
const rd = this.store.profile();
return rd.tag === 'Success' ? rd.value : undefined;
});
protected readonly aantekeningen = computed(() => {
const rd = this.store.aantekeningen();
return rd.tag === 'Success' ? rd.value : undefined;
});
/** Primary transactional actions, as an "aanvragen" list (see CIBG's
componenten/aanvragen). The core portal sections live in the header nav now;
the teaching pages (concepts/brief) are only reachable from here. */

View File

@@ -169,7 +169,8 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
}
@case ('beroep') {
<app-async [data]="lookupRd()">
<ng-template appAsyncLoaded let-data>
<ng-template appAsyncLoaded>
@if (duoData(); as data) {
<app-form-field
i18n-label="@@regWizard.diplomaLabel"
label="Kies het diploma waarmee u zich wilt registreren"
@@ -179,18 +180,18 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
>
<app-radio-group
name="diploma"
[options]="diplomaOptions($any(data))"
[options]="diplomaOptions(data)"
[invalid]="!!err('diploma')"
[ngModel]="diplomaKeuze()"
(ngModelChange)="onDiplomaKeuze($any(data), $event)"
(ngModelChange)="onDiplomaKeuze(data, $event)"
/>
</app-form-field>
@if (handmatigActief()) {
<app-alert type="warning" i18n="@@regWizard.handmatigWaarschuwing"
>Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies uw
beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna handmatig
beoordeeld.</app-alert
>Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Kies
uw beroep en beantwoord de aanvullende vragen; uw aanvraag wordt daarna
handmatig beoordeeld.</app-alert
>
<app-form-field
i18n-label="@@regWizard.beroepLabel"
@@ -200,7 +201,7 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
>
<app-radio-group
name="hm-beroep"
[options]="beroepOptions($any(data))"
[options]="beroepOptions(data)"
[invalid]="!!err('diploma')"
[ngModel]="draft().beroep ?? ''"
(ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })"
@@ -217,7 +218,7 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
</dl>
}
@for (q of actieveVragen($any(data)); track q.id) {
@for (q of actieveVragen(data); track q.id) {
<app-form-field
[label]="q.vraag"
[fieldId]="'vraag-' + q.id"
@@ -247,6 +248,7 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
}
</app-form-field>
}
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="3" />
@@ -485,8 +487,11 @@ export class RegistratieWizardComponent {
protected lookupRd: () => RemoteData<Error | undefined, DuoLookupDto> = this.lookup.duoLookup;
/** Parsed lookup as a plain value (or null) — used outside the beroep step (the
controle summary) where the <app-async> template variable isn't in scope. */
private duoData = computed<DuoLookupDto | null>(() => {
controle summary) where the <app-async> template variable isn't in scope, and
inside it too: `<ng-template appAsyncLoaded>`'s own context can't inherit a
generic from the sibling [data] input (Angular only infers a structural
directive's type parameter from an input on that same node). */
protected duoData = computed<DuoLookupDto | null>(() => {
const rd = this.lookupRd();
return rd.tag === 'Success' ? rd.value : null;
});

View File

@@ -1,4 +1,4 @@
import { Component, inject } from '@angular/core';
import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { ASYNC } from '@shared/ui/async/async.component';
@@ -22,8 +22,10 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
backLink="/dashboard"
>
<app-async [data]="store.profile()">
<ng-template appAsyncLoaded let-p>
<app-registration-summary [reg]="$any(p).registration" />
<ng-template appAsyncLoaded>
@if (profile(); as p) {
<app-registration-summary [reg]="p.registration" />
}
</ng-template>
<ng-template appAsyncLoading>
<app-skeleton height="2.5rem" [count]="6" />
@@ -38,4 +40,10 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
})
export class RegistrationDetailPage {
protected store = inject(BigProfileStore);
/** See DashboardPage's `profile` for why this narrows via a computed instead of `let-`. */
protected readonly profile = computed(() => {
const rd = this.store.profile();
return rd.tag === 'Success' ? rd.value : undefined;
});
}

View File

@@ -30,14 +30,17 @@ import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
key="Registratiedatum"
[value]="reg().registratiedatum | date: 'longDate'"
></div>
<!-- Each status variant renders only the row its own data supports. -->
@switch (reg().status.tag) {
<!-- Each status variant renders only the row its own data supports. A single
@let binds status once so the @switch narrows its union by tag -- calling
reg().status again per case would give the checker a fresh, unnarrowed call. -->
@let status = reg().status;
@switch (status.tag) {
@case ('Geregistreerd') {
<div
app-data-row
i18n-key="@@summary.uiterste"
key="Uiterste herregistratie"
[value]="$any(reg().status).herregistratieDatum | date: 'longDate'"
[value]="status.herregistratieDatum | date: 'longDate'"
></div>
}
@case ('Geschorst') {
@@ -45,28 +48,18 @@ import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
app-data-row
i18n-key="@@summary.geschorstTot"
key="Geschorst tot"
[value]="$any(reg().status).geschorstTot | date: 'longDate'"
></div>
<div
app-data-row
i18n-key="@@summary.reden"
key="Reden"
[value]="$any(reg().status).reden"
[value]="status.geschorstTot | date: 'longDate'"
></div>
<div app-data-row i18n-key="@@summary.reden" key="Reden" [value]="status.reden"></div>
}
@case ('Doorgehaald') {
<div
app-data-row
i18n-key="@@summary.doorgehaaldOp"
key="Doorgehaald op"
[value]="$any(reg().status).doorgehaaldOp | date: 'longDate'"
></div>
<div
app-data-row
i18n-key="@@summary.reden"
key="Reden"
[value]="$any(reg().status).reden"
[value]="status.doorgehaaldOp | date: 'longDate'"
></div>
<div app-data-row i18n-key="@@summary.reden" key="Reden" [value]="status.reden"></div>
}
}
</app-data-block>

View File

@@ -71,20 +71,6 @@ export function map2<E, A, B, R>(
return { tag: 'Success', value: f(a.value, b.value) };
}
/** Combine three sources (built on map2). */
export function map3<E, A, B, C, R>(
a: RemoteData<E, A>,
b: RemoteData<E, B>,
c: RemoteData<E, C>,
f: (a: A, b: B, c: C) => R,
): RemoteData<E, R> {
return map2(
map2(a, b, (x, y) => [x, y] as const),
c,
([x, y], z) => f(x, y, z),
);
}
/** Chain a second source that depends on the first one's value. */
export function andThen<E, A, B>(
rd: RemoteData<E, A>,

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { formatDatumNl } from './datum';
describe('formatDatumNl', () => {
it('formats a Date in long Dutch form', () => {
expect(formatDatumNl(new Date(2026, 6, 2))).toBe('2 juli 2026');
});
it('formats an ISO string the same way', () => {
expect(formatDatumNl('2026-07-02')).toBe('2 juli 2026');
});
it('is empty-safe: undefined, null, and empty string all yield the empty string', () => {
expect(formatDatumNl(undefined)).toBe('');
expect(formatDatumNl(null)).toBe('');
expect(formatDatumNl('')).toBe('');
});
it('returns empty for an unparseable string rather than "Invalid Date"', () => {
expect(formatDatumNl('not-a-date')).toBe('');
});
});

View File

@@ -0,0 +1,16 @@
/**
* The one hand-written date formatter for pure TS (non-template) code — a domain
* rule or a `$localize` string can't reach for Angular's `DatePipe`, so this covers
* that gap. Templates use `DatePipe` (`| date: 'longDate'`) instead; don't add a
* second hand-rolled formatter for either case.
*/
export function formatDatumNl(d: Date | string | undefined | null): string {
if (!d) return '';
const date = typeof d === 'string' ? new Date(d) : d;
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat('nl-NL', {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(date);
}

View File

@@ -32,6 +32,10 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
@Component({
selector: 'app-wizard-shell',
imports: [FormsModule, ButtonComponent, AlertComponent, SpinnerComponent, StepperComponent],
// CIBG-GAP EXTENSION: Foutmelding — the vendored build has no error-summary/
// Veldvalidatie list pattern (verified absent from huisstijl.min.css); the
// .es-title/.es-list rules below are the hand-rolled surface, see cibg-gaps.mdx.
// They render inside a vendored `.feedback-error` alert (app-alert).
styles: [
`
.es-title {

View File

@@ -15,6 +15,15 @@ const meta: Meta<WizardShellComponent> = {
<div wizardSuccess><p class="rhc-paragraph">Uw aanvraag is ontvangen.</p></div>
</app-wizard-shell>`,
}),
parameters: {
cibgGap: true,
docs: {
description: {
component:
'CIBG-gap extension (error summary only) — see Foundations/CIBG Gap Register.',
},
},
},
};
export default meta;
type Story = StoryObj<WizardShellComponent>;

View File

@@ -2,6 +2,9 @@ import { Component, input, output } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { RouterLink } from '@angular/router';
// CIBG-GAP EXTENSION: Aanvragen (non-navigating row) — the vendored
// `.dashboard-block.applications li a` chain only styles `<a>`; `.static-row`
// mirrors it from tokens for the non-navigating case, see cibg-gaps.mdx.
/** Molecule: one row in a CIBG Huisstijl "aanvragen" list
(designsystem.cibg.nl/componenten/aanvragen) — a white card-link styled by the
vendored `.dashboard-block.applications li a` chain (bg, chevron, link-blue `h3`),

View File

@@ -12,6 +12,15 @@ const meta: Meta<ApplicationLinkComponent> = {
// Rows are <li>s in the "aanvragen" list — a real <ul> gives them their layout.
template: `<div class="dashboard-block applications"><ul class="list-unstyled"><li app-application-link [heading]="heading" [subtitle]="subtitle" [status]="status" [cta]="cta" [to]="to" [clickable]="clickable"></li></ul></div>`,
}),
parameters: {
cibgGap: true,
docs: {
description: {
component:
'CIBG-gap extension (non-navigating row only, see NietInteractief) — see Foundations/CIBG Gap Register.',
},
},
},
};
export default meta;
type Story = StoryObj<ApplicationLinkComponent>;

View File

@@ -6,10 +6,20 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';
/* Slot markers. Put on <ng-template> children of <app-async>. */
/* Slot markers. Put on <ng-template> children of <app-async>. Generic so the
$implicit context is typed as the resource's T instead of unknown — see
AsyncComponent's contentChild<AsyncLoadedDirective<T>> below, which threads the
host's own T through the query result type. */
@Directive({ selector: '[appAsyncLoaded]' })
export class AsyncLoadedDirective {
constructor(public tpl: TemplateRef<{ $implicit: unknown }>) {}
export class AsyncLoadedDirective<T = unknown> {
constructor(public tpl: TemplateRef<{ $implicit: T }>) {}
static ngTemplateContextGuard<T>(
_dir: AsyncLoadedDirective<T>,
_ctx: unknown,
): _ctx is { $implicit: T } {
return true;
}
}
@Directive({ selector: '[appAsyncLoading]' })
export class AsyncLoadingDirective {
@@ -88,7 +98,7 @@ export class AsyncComponent<T> {
retryText = input($localize`:@@async.retry:Opnieuw proberen`);
emptyText = input($localize`:@@async.empty:Geen gegevens gevonden.`);
loadedTpl = contentChild.required(AsyncLoadedDirective);
loadedTpl = contentChild.required<AsyncLoadedDirective<T>>(AsyncLoadedDirective);
loadingTpl = contentChild(AsyncLoadingDirective);
emptyTpl = contentChild(AsyncEmptyDirective);
errorTpl = contentChild(AsyncErrorDirective);

View File

@@ -1,6 +1,6 @@
import { Component, input } from '@angular/core';
type Variant = 'primary' | 'secondary' | 'subtle' | 'danger';
type Variant = 'primary' | 'secondary' | 'subtle' | 'danger' | 'ghost';
/** Atom: button. Thin wrapper over the CIBG/Bootstrap button CSS. */
@Component({
@@ -11,9 +11,10 @@ type Variant = 'primary' | 'secondary' | 'subtle' | 'danger';
[disabled]="disabled()"
class="btn"
[class.btn-primary]="variant() === 'primary'"
[class.btn-outline-primary]="variant() === 'secondary'"
[class.btn-secondary]="variant() === 'secondary'"
[class.btn-link]="variant() === 'subtle'"
[class.btn-danger]="variant() === 'danger'"
[class.btn-ghost]="variant() === 'ghost'"
>
<ng-content />
</button>

View File

@@ -16,4 +16,5 @@ export const Primary: Story = { args: { variant: 'primary' } };
export const Secondary: Story = { args: { variant: 'secondary' } };
export const Subtle: Story = { args: { variant: 'subtle' } };
export const Danger: Story = { args: { variant: 'danger' } };
export const Ghost: Story = { args: { variant: 'ghost' } };
export const Disabled: Story = { args: { variant: 'primary', disabled: true } };

View File

@@ -1,6 +1,9 @@
import { Component, input } from '@angular/core';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
// CIBG-GAP EXTENSION: n/a — no vendored generic-card class; `.app-card` is a
// hand-rolled surface (NOT Bootstrap's `.card`), see cibg-gaps.mdx. Prefer the
// vendored Datablock (WP-12, `app-data-block`) for application/user data.
/** Molecule: a content card. Standardises the repeated card surface (white,
subtle border, rounded, padded) so pages compose cards instead of hand-rolling
a hand-rolled card surface. Optional heading; the rest is projected.

View File

@@ -12,6 +12,10 @@ const meta: Meta<CardComponent> = {
</app-card>`,
}),
args: { heading: 'Persoonsgegevens (BRP)', level: 3 },
parameters: {
cibgGap: true,
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
},
};
export default meta;
type Story = StoryObj<CardComponent>;

View File

@@ -6,6 +6,8 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
import { map } from '@shared/application/remote-data';
import { maskBsn, redactProfile } from './mask';
// CIBG-GAP EXTENSION: n/a — devtool, no corresponding CIBG concept; deliberately
// off-theme by design (see the ponytail note below), see cibg-gaps.mdx.
/**
* Dev-only "show the current Model" panel (Elm-debugger style, read-only).
* Observes the root singletons and renders them via the json pipe. Never a

View File

@@ -5,6 +5,10 @@ import { DebugStateComponent } from './debug-state.component';
const meta: Meta<DebugStateComponent> = {
title: 'Devtools/State debug',
component: DebugStateComponent,
parameters: {
cibgGap: true,
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
},
};
export default meta;
type Story = StoryObj<DebugStateComponent>;

View File

@@ -1,5 +1,7 @@
import { Component, computed, input } from '@angular/core';
// CIBG-GAP EXTENSION: n/a — no vendored inline-chip/tag class; hand-rolled
// brace-wrapped chip, see cibg-gaps.mdx.
/** Atom: a highlighted, non-editable placeholder chip for READ-ONLY rendering
(preview, diagnostics). Distinct styling for auto-resolvable vs manual fields and
for linter error/warning states. Domain-free and presentational — the caller

View File

@@ -8,6 +8,10 @@ const meta: Meta<PlaceholderChipComponent> = {
props: args,
template: `<app-placeholder-chip [label]="label" [autoResolvable]="autoResolvable" [state]="state"></app-placeholder-chip>`,
}),
parameters: {
cibgGap: true,
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
},
};
export default meta;
type Story = StoryObj<PlaceholderChipComponent>;

View File

@@ -12,6 +12,10 @@ export interface PlaceholderOption {
readonly autoResolvable?: boolean;
}
// CIBG-GAP EXTENSION: Tekstgebied — CIBG has no rich-text/WYSIWYG pattern (a
// contenteditable editor with formatting + placeholder chips); hand-rolled
// surface (toolbar + chip styling), see cibg-gaps.mdx. Buttons still use the
// vendored .btn-ghost class (WP-10).
/**
* Molecule: a minimal no-dependency WYSIWYG editor over a `RichTextBlock`.
*
@@ -95,7 +99,7 @@ export interface PlaceholderOption {
<div class="rte-toolbar" role="toolbar" [attr.aria-label]="toolbarLabel()">
<button
type="button"
class="btn btn-outline-secondary btn-sm"
class="btn btn-ghost"
(mousedown)="$event.preventDefault()"
(click)="format('bold')"
[attr.aria-label]="boldLabel()"
@@ -104,7 +108,7 @@ export interface PlaceholderOption {
</button>
<button
type="button"
class="btn btn-outline-secondary btn-sm"
class="btn btn-ghost"
(mousedown)="$event.preventDefault()"
(click)="format('italic')"
[attr.aria-label]="italicLabel()"
@@ -113,7 +117,7 @@ export interface PlaceholderOption {
</button>
<button
type="button"
class="btn btn-outline-secondary btn-sm"
class="btn btn-ghost"
(mousedown)="$event.preventDefault()"
(click)="format('underline')"
[attr.aria-label]="underlineLabel()"
@@ -123,7 +127,7 @@ export interface PlaceholderOption {
<span class="rte-sep" aria-hidden="true"></span>
<button
type="button"
class="btn btn-outline-secondary btn-sm"
class="btn btn-ghost"
(mousedown)="$event.preventDefault()"
(click)="list('bullet')"
[attr.aria-label]="bulletListLabel()"
@@ -132,7 +136,7 @@ export interface PlaceholderOption {
</button>
<button
type="button"
class="btn btn-outline-secondary btn-sm"
class="btn btn-ghost"
(mousedown)="$event.preventDefault()"
(click)="list('number')"
[attr.aria-label]="numberListLabel()"

View File

@@ -34,6 +34,10 @@ const meta: Meta<RichTextEditorComponent> = {
props: args,
template: `<app-rich-text-editor [content]="content" [placeholders]="placeholders" [editable]="editable"></app-rich-text-editor>`,
}),
parameters: {
cibgGap: true,
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
},
};
export default meta;
type Story = StoryObj<RichTextEditorComponent>;

View File

@@ -1,5 +1,7 @@
import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/core';
// CIBG-GAP EXTENSION: Laadindicatie — no vendored loading-skeleton class exists
// (verified absent from huisstijl.min.css); hand-rolled shimmer, see cibg-gaps.mdx.
/** Atom: skeleton placeholder (grey shimmer). Delay-gated so it never flashes
on fast responses. Render `count` lines shaped roughly like the content. */
@Component({

View File

@@ -5,6 +5,10 @@ const meta: Meta<SkeletonComponent> = {
title: 'Atoms/Skeleton',
component: SkeletonComponent,
args: { delay: 0 },
parameters: {
cibgGap: true,
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
},
};
export default meta;
type Story = StoryObj<SkeletonComponent>;

View File

@@ -1,5 +1,7 @@
import { Component, OnDestroy, OnInit, input, signal } from '@angular/core';
// CIBG-GAP EXTENSION: Laadindicatie — no vendored loading-spinner class exists
// (verified absent from huisstijl.min.css); hand-rolled, see cibg-gaps.mdx.
/** Atom: spinner that only appears after `delay` ms — fast responses never
flash a spinner, slow ones get feedback. */
@Component({

View File

@@ -4,6 +4,10 @@ import { SpinnerComponent } from './spinner.component';
const meta: Meta<SpinnerComponent> = {
title: 'Atoms/Spinner',
component: SpinnerComponent,
parameters: {
cibgGap: true,
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
},
};
export default meta;
type Story = StoryObj<SpinnerComponent>;

View File

@@ -1,5 +1,7 @@
import { Component, input } from '@angular/core';
// CIBG-GAP EXTENSION: n/a — deliberate custom surface, not Bootstrap's `.badge`
// (whose pill padding/colour don't fit a status dot); see cibg-gaps.mdx.
/** Atom: a coloured dot + label. Purely presentational and domain-free — the
caller decides what colour and label mean (e.g. via registration.policy).
This keeps the shared UI kernel free of any domain knowledge. */

View File

@@ -4,6 +4,10 @@ import { StatusBadgeComponent } from './status-badge.component';
const meta: Meta<StatusBadgeComponent> = {
title: 'Shared UI/Status Badge',
component: StatusBadgeComponent,
parameters: {
cibgGap: true,
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
},
};
export default meta;
type Story = StoryObj<StatusBadgeComponent>;

View File

@@ -1,14 +1,14 @@
import { Component, input, output } from '@angular/core';
import type { DeliveryChannel, UploadState } from '@shared/upload/upload.machine';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { DocumentCategoryComponent } from '../document-category/document-category.component';
import { UploadStatusBannerComponent } from '../upload-status-banner/upload-status-banner.component';
/** Organism: the full document-upload step — the category list, or a load-error
banner. Pure UI: re-exposes the category events, tagging each with its category
where the parent needs it. The container wires these to the upload reducer. */
@Component({
selector: 'app-document-upload',
imports: [DocumentCategoryComponent, UploadStatusBannerComponent],
imports: [DocumentCategoryComponent, AlertComponent],
styles: [
`
:host {
@@ -20,10 +20,10 @@ import { UploadStatusBannerComponent } from '../upload-status-banner/upload-stat
],
template: `
@if (state().categoriesError) {
<app-upload-status-banner type="error" [message]="state().categoriesError!" />
<app-alert type="error">{{ state().categoriesError }}</app-alert>
} @else {
@if (state().backgroundSyncAvailable === false && state().categories.length > 0) {
<app-upload-status-banner type="info" [message]="foregroundOnlyMessage" />
<app-alert type="info">{{ foregroundOnlyMessage }}</app-alert>
}
@for (c of state().categories; track c.categoryId) {
<app-document-category

View File

@@ -56,3 +56,7 @@ export const Default: Story = { args: { state } };
export const LoadError: Story = {
args: { state: { ...state, categoriesError: 'De categorieën konden niet worden geladen.' } },
};
export const ForegroundOnly: Story = {
args: { state: { ...state, backgroundSyncAvailable: false } },
};

View File

@@ -1,25 +0,0 @@
import { Component, computed, input } from '@angular/core';
import { AlertComponent } from '@shared/ui/alert/alert.component';
type BannerType = 'info' | 'warning' | 'error';
type AlertType = 'info' | 'ok' | 'warning' | 'error';
/** Molecule: a polite, announced status banner. Wraps the alert atom and maps the
banner type to an alert type. Pure UI: the container computes the message. */
@Component({
selector: 'app-upload-status-banner',
imports: [AlertComponent],
template: `
<div aria-live="polite">
<app-alert [type]="alertType()">{{ message() }}</app-alert>
</div>
`,
})
export class UploadStatusBannerComponent {
message = input.required<string>();
type = input<BannerType>('info');
protected readonly alertType = computed<AlertType>(() =>
this.type() === 'info' ? 'info' : this.type(),
);
}

View File

@@ -1,21 +0,0 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { UploadStatusBannerComponent } from './upload-status-banner.component';
const meta: Meta<UploadStatusBannerComponent> = {
title: 'Molecules/UploadStatusBanner',
component: UploadStatusBannerComponent,
render: (args) => ({
props: args,
template: `<app-upload-status-banner [message]="message" [type]="type" />`,
}),
};
export default meta;
type Story = StoryObj<UploadStatusBannerComponent>;
export const Info: Story = {
args: { type: 'info', message: 'Uw documenten worden geüpload.' },
};
export const Error: Story = {
args: { type: 'error', message: 'De categorieën konden niet worden geladen.' },
};

View File

@@ -223,9 +223,9 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
>
<p class="tag plain">Success</p>
<app-async [resource]="successRes" [isEmpty]="isEmpty"
><ng-template appAsyncLoaded let-v
><ng-template appAsyncLoaded
><ul>
@for (i of v; track i) {
@for (i of successRes.value(); track i) {
<li>{{ i }}</li>
}
</ul></ng-template
@@ -258,16 +258,17 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
placeholder="Typ een postcode, bijv. 1234 AB"
/>
</div>
<div class="card" [class.card--good]="parsed().ok" [class.card--bad]="!parsed().ok">
@if (parsed().ok) {
@let r = parsed();
<div class="card" [class.card--good]="r.ok" [class.card--bad]="!r.ok">
@if (r.ok) {
<p class="tag good">ok</p>
<pre>Postcode ="{{ $any(parsed()).value }}"</pre>
<pre>Postcode ="{{ r.value }}"</pre>
<p class="note">
Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.
</p>
} @else {
<p class="tag bad">err</p>
<pre>{{ $any(parsed()).error }}</pre>
<pre>{{ r.error }}</pre>
}
</div>
</div>

52
src/docs/cibg-gaps.mdx Normal file
View File

@@ -0,0 +1,52 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Foundations/CIBG Gap Register" />
# CIBG gap register
CIBG Huisstijl (ADR-0003) is the design system of record — a component wraps a vendored class
before it hand-rolls anything. **Grep the vendored CSS
(`public/cibg-huisstijl/css/huisstijl.min.css`) before adding new surface CSS to a component.**
When no vendored pattern exists, the component is a **CIBG-gap extension**: allowed, but only
marked so every deviation from the design system is auditable.
## Marker format
```ts
// CIBG-GAP EXTENSION: <closest CIBG concept, or "n/a"> — <why hand-rolled>
```
placed above the `@Component` decorator, plus `parameters: { cibgGap: true }` and a
"CIBG-gap extension" line in the story's `docs.description.component`.
## The register
| Component | Closest CIBG concept | Why hand-rolled |
| --- | --- | --- |
| `skeleton` | Laadindicatie | No loading-skeleton class in the vendored build. |
| `spinner` | Laadindicatie | No loading-spinner class in the vendored build. |
| `rich-text-editor` | Tekstgebied | No rich-text/WYSIWYG pattern; toolbar buttons still use vendored `.btn-ghost` (WP-10). |
| `wizard-shell` (error summary only) | Foutmelding | No error-summary/Veldvalidatie list class; renders inside a vendored `.feedback-error` alert. |
| `application-link` (non-navigating row) | Aanvragen | The vendored `.dashboard-block.applications li a` chain only styles `<a>`; `.static-row` mirrors it from tokens for the informational (non-link) case. |
| `debug-state` | n/a | Dev-only tool, deliberately off-theme — see the component's own `ponytail:` note. |
| `status-badge` | n/a | Deliberate custom status dot, not Bootstrap's `.badge` (pill padding/colour don't fit). |
| `card` (`.app-card`) | n/a | No vendored generic-card class; prefer the vendored **Datablock** (`app-data-block`, WP-12) for application/user data. |
| `placeholder-chip` | n/a | No vendored inline-chip/tag class. |
Not a gap: `confirmation` renders entirely with vendored `.confirmation*` classes (no `styles:
[...]` block) — its header comment names the pattern, no marker needed. The `upload/` suite
renders entirely with vendored classes (`.file-picker-drop-area`, `.btn-upload`, …) — reworked
onto them rather than marked (see WP-11's correction note). `task-list`, `application-list`, and
`choice-list` each wrap a distinct vendored pattern (Keuzelijst / Aanvragen / Keuzelijst) and name
it in their own header comment — no marker needed, they don't hand-roll surface CSS.
## Hygiene
`upload-status-banner` (a 23-line near-identity wrapper over `app-alert` with one consumer) was
deleted; its consumer (`document-upload`) now uses `<app-alert>` directly.
## Keeping this register honest
No automated check diffs this table against the markers in code (skipped as not worth a CI
script for a table this small — reviewed at PR time instead, same as any other doc). If markers
and this table drift, trust the code and fix the table.

100
src/docs/machines.mdx Normal file
View File

@@ -0,0 +1,100 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Foundations/State Machines (TEA)" />
# State machines (The Elm Architecture, in Angular)
Every form or wizard with validation or submission in this app is wired the **same
way**: one Model, one Msg union, one pure `reduce`, one command per side effect. Pick any
one — `herregistratie.machine.ts` is the fullest worked example — and the shape
transfers everywhere else.
## Model / Msg / reduce
```ts
// Model — everything the UI needs to render, as ONE tagged union
export type WizardState = { tag: 'step1'; draft: Draft } | { tag: 'step2'; valid: Valid } | …;
// Msg — every way the Model is allowed to change
export type WizardMsg = { tag: 'FieldChanged'; field: string; value: string } | { tag: 'NextStep' } | …;
// reduce — PURE: (current, message) -> next. No I/O, no Date.now(), no randomness.
export function reduce(s: WizardState, m: WizardMsg): WizardState { … }
```
Because the whole state is one value, a bug reproduces from a message log; because
`reduce` is pure, every transition is a one-line assertion in a spec — no `TestBed`, no
mocked HTTP, just `expect(reduce(state, msg)).toEqual(next)`.
## Commands: side effects stay OUT of the reducer
`reduce` only ever answers "what is the new state" — it never calls `fetch`. A
**command** (an `application/submit-*.ts` file, or a store method) does the I/O, then
dispatches a message describing the outcome:
```ts
// command = "go do it, then say what happened" — reduce never sees the HTTP call itself
async function submit(store: Store<WizardState, WizardMsg>) {
const r = await adapter.submit(toDto(store.model()));
store.dispatch(r.ok ? { tag: 'SubmitConfirmed', referentie: r.value } : { tag: 'SubmitFailed', error: r.error });
}
```
This is also how a machine receives **server-owned config** without becoming aware of
HTTP: `intake.machine.ts`'s scholing threshold has an offline fallback
(`SCHOLING_THRESHOLD_DEFAULT`) baked into the model, and a plain `SetPolicy` message
that overwrites it once the real value arrives — the machine doesn't know or care that
the value came from a `resource()` fetch.
## `createStore`: the one wiring idiom
```ts
private store = createStore<WizardState, WizardMsg>(initial, reduce);
readonly model = this.store.model; // Signal<WizardState> — template reads this
dispatch = this.store.dispatch; // template calls this, on click/input/etc — never mutates
```
A page or component **never** hand-rolls `signal(initialModel)` plus its own local
`dispatch` function that calls `reduce` inline — that's the same idea reinvented with a
worse name, and it's the thing a newcomer copies if two idioms are visible side by side.
Wire every machine through `createStore`, full stop.
`dispatch` uses `model.update(…)`, not `model.set(reduce(model(), msg))` — the latter
reads `model()` *inside* the call, which means an `effect()` that both reads `model` and
calls `dispatch` would subscribe to its own write and livelock. `.update()`'s callback
receives the current value directly, untracked.
## Naming
- A top-level machine's types are **context-prefixed**: `ChangeRequestState`,
`ChangeRequestMsg`, `WizardState`, `WizardMsg` — never bare `State`/`Msg`. A bare name
reads fine in the one file that defines it and then collides (or forces an import
alias) the moment two machines are open side by side.
- A top-level machine exports `initial` (the starting Model) and `reduce` — unprefixed,
since the file/module already disambiguates them at the import site
(`import { initial, reduce } from './herregistratie.machine'`).
- A **composable sub-machine** — one embedded *inside* a parent Model, like
`upload.machine.ts`'s upload-widget state living inside the registratie wizard's own
Model — keeps **prefixed value exports** instead: `initialUpload`, `reduceUpload`.
The parent machine already imports several machines' `initial`/`reduce`; prefixing the
sub-machine's exports avoids a wall of `as` import aliases at the composition site.
## Derive, don't store
If a value can be computed from the Model, it is **not** a field on the Model. The
wizard's visible steps are `visibleSteps(answers)`, a pure function of the current
answers — not a `visibleSteps: Step[]` field someone has to remember to keep in sync
every time an answer changes. The reflex: before adding a field, ask "could this just be
a function of what I already have?"
## Where RemoteData fits in
A machine owns the **domain** lifecycle of what it holds once it exists (draft →
submitted → approved, in the brief's case). It should generally *not* also own the
**fetch** lifecycle (loading/failed) for the initial GET that produces it — that's a
generic concern `RemoteData` already models once, consistently, across the app (see
[Foundations/RemoteData & Async](?path=/docs/foundations-remotedata-async--docs)). Where
a machine's own state happens to have `loading`/`failed` tags that purely mirror that
fetch, project them onto a `RemoteData` at the store layer for `<app-async>` to render
(`BriefStore.remoteData` is the worked example) rather than teaching every consumer to
hand-roll a `@switch` over the machine's own tags.

View File

@@ -0,0 +1,111 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Foundations/Parse, don't validate" />
# Parse, don't validate
The wire is untrusted. A `boolean`/`string` field coming back from `fetch` is typed `unknown`
until something checks it — casting it away with `as` doesn't check anything, it just tells the
compiler to stop complaining. This repo's rule: every response crosses the FE⇄BE seam through a
hand-written `parse*` function that returns a `Result<string, T>` (`src/app/shared/kernel/fp.ts`).
Once you hold the parsed value, you never re-check it — the type _is_ the proof.
## Two places this shows up
**Value objects** (`src/app/registratie/domain/value-objects/`) parse a single user-entered
field — `Postcode`, `Uren`, `BigNummer` — from a raw string into a branded type.
**Boundary parsers** (`*.adapter.ts` in every `infrastructure/`) parse a whole DTO — or one
enum-ish field inside it — from the generated `ApiClient`'s response into the domain shape the
rest of the app trusts.
```ts
parsePostcode(raw) // Result<string, Postcode>
|> mapErr(toLocalizedMessage) // swap raw msg → UI copy
|> map(toDomain) // only runs on success
```
## The failure mode this closes: the silent `as` cast
An `as SomeUnion` cast on a wire value compiles even when the value doesn't match — the tag
just gets forwarded as-is, and something far away breaks on an "impossible" case. A validated
parse turns that into an explicit `Failure` at the boundary, right where the untrusted data
enters.
### Before/after: `big-register.adapter.ts`
```ts
// before — the wire's `type` string is trusted outright
function toAantekening(n: AantekeningDto): Aantekening {
return { type: n.type as AantekeningType, omschrijving: n.omschrijving ?? '', datum: n.datum ?? '' };
}
```
```ts
// after — an unrecognized type is a Result you can spec, not a silently-wrong tag
const AANTEKENING_TYPES: readonly AantekeningType[] = ['Specialisme', 'Aantekening'];
export function parseAantekening(n: AantekeningDto): Result<string, Aantekening> {
if (!n.type || !AANTEKENING_TYPES.includes(n.type as AantekeningType))
return err(`aantekening: unknown type ${n.type}`);
return ok({ type: n.type as AantekeningType, omschrijving: n.omschrijving ?? '', datum: n.datum ?? '' });
}
```
The resource loader throws on `Failure`, which Angular's `resource()` turns into its error
state — the same `Failure` a `RemoteData` consumer already renders, no new plumbing.
### Before/after: `brief.adapter.ts`
```ts
// before — `dto.scope` is checked, then re-cast anyway
if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep'))
return err('passage: bad shape');
// … scope: dto.scope as PassageScope
```
```ts
// after — split the guard so TS narrows `scope` on its own; no cast needed
if (dto.scope !== 'global' && dto.scope !== 'beroep')
return err(`passage: unknown scope ${dto.scope}`);
// … scope: dto.scope // already narrowed to PassageScope
```
Splitting a compound `if` into two single-condition guards is often enough to make the cast
disappear entirely — the compiler was already able to prove the narrowing, the `||` was just
hiding it.
### Before/after: `intake-policy.adapter.ts`
```ts
// before — the resource exposes the raw DTO; consumers reach into it with `?.`
policyResource() {
return resource({ loader: () => this.client.policy() });
}
```
```ts
// after — a domain-side type + a validated parse; the resource never surfaces raw wire shape
export interface IntakePolicy { readonly scholingThreshold: number }
export function parseIntakePolicy(json: unknown): Result<string, IntakePolicy> {
if (typeof json !== 'object' || json === null) return err('intake-policy: not an object');
const dto = json as { scholingThreshold?: unknown };
if (typeof dto.scholingThreshold !== 'number')
return err('intake-policy: missing scholingThreshold');
return ok({ scholingThreshold: dto.scholingThreshold });
}
```
## The sanctioned exception
Narrowing `unknown` to `Partial<Dto>` so you can *start* checking fields is fine — that's not a
trust decision, it's just giving the compiler a shape to probe (`const dto = json as
Partial<DashboardViewDto>`, see `dashboard-view.adapter.ts`). What's never fine is casting a
field to its final domain type without having checked it first.
## Spec every parser like a decision table
Each parser gets a spec covering: a valid shape, a missing required field, and — for
tagged/enum-ish values — an unknown tag. See `big-register.adapter.spec.ts`,
`intake-policy.adapter.spec.ts`, and the scope-rejection case in `brief.adapter.spec.ts`.

97
src/docs/remote-data.mdx Normal file
View File

@@ -0,0 +1,97 @@
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
import * as AsyncStories from '../app/shared/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.