Compare commits
8 Commits
5d6a78d4ec
...
cbf697b8fa
| Author | SHA1 | Date | |
|---|---|---|---|
| cbf697b8fa | |||
| 9d58f597ea | |||
| 69880efd38 | |||
| 8078c499cb | |||
| 0d623f90e8 | |||
| e3cd908f4f | |||
| 199cbe1f8c | |||
| 34d34512b3 |
16
CLAUDE.md
16
CLAUDE.md
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 3–4) had real work left.
|
||||
|
||||
## Out of scope
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
3016
documentation.json
3016
documentation.json
File diff suppressed because one or more lines are too long
88
src/app/brief/application/brief.store.spec.ts
Normal file
88
src/app/brief/application/brief.store.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,39 +33,38 @@ 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') {
|
||||
<div class="brief-toolbar">
|
||||
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
|
||||
resetLabel
|
||||
}}</app-button>
|
||||
</div>
|
||||
<app-letter-composer
|
||||
[brief]="brief()!"
|
||||
[availablePassages]="availablePassages()"
|
||||
[diagnostics]="store.diagnostics()"
|
||||
[canEdit]="store.canEdit()"
|
||||
[canApprove]="store.canApprove()"
|
||||
[canReject]="store.canReject()"
|
||||
[canSend]="store.canSend()"
|
||||
[canSubmit]="store.canSubmit()"
|
||||
[busy]="store.busy()"
|
||||
(edit)="store.edit($event)"
|
||||
(submit)="store.submit()"
|
||||
(approve)="store.approve()"
|
||||
(reject)="store.reject($event)"
|
||||
(send)="store.send()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</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()">{{
|
||||
resetLabel
|
||||
}}</app-button>
|
||||
</div>
|
||||
<app-letter-composer
|
||||
[brief]="s.brief"
|
||||
[availablePassages]="s.availablePassages"
|
||||
[diagnostics]="store.diagnostics()"
|
||||
[canEdit]="store.canEdit()"
|
||||
[canApprove]="store.canApprove()"
|
||||
[canReject]="store.canReject()"
|
||||
[canSend]="store.canSend()"
|
||||
[canSubmit]="store.canSubmit()"
|
||||
[busy]="store.busy()"
|
||||
(edit)="store.edit($event)"
|
||||
(submit)="store.submit()"
|
||||
(approve)="store.approve()"
|
||||
(reject)="store.reject($event)"
|
||||
(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();
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
99
src/app/registratie/application/draft-sync.spec.ts
Normal file
99
src/app/registratie/application/draft-sync.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.' });
|
||||
});
|
||||
});
|
||||
@@ -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') {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
: '';
|
||||
}
|
||||
|
||||
@@ -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,24 +30,26 @@ import { detailRows } from '@registratie/domain/aanvraag-view';
|
||||
backLink="/dashboard"
|
||||
>
|
||||
<app-async [data]="store.applications()">
|
||||
<ng-template appAsyncLoaded let-list>
|
||||
@let a = find($any(list));
|
||||
@if (a) {
|
||||
<app-data-block
|
||||
i18n-ariaLabel="@@aanvraagDetail.ariaLabel"
|
||||
ariaLabel="Aanvraaggegevens"
|
||||
>
|
||||
@for (row of rows(a); track row.key) {
|
||||
<div app-data-row [key]="row.key" [value]="row.value"></div>
|
||||
}
|
||||
</app-data-block>
|
||||
<app-alert class="app-section" type="info" i18n="@@aanvraagDetail.stub">
|
||||
De volledige afhandeling van deze aanvraag is nog niet beschikbaar in deze POC.
|
||||
</app-alert>
|
||||
} @else {
|
||||
<app-alert type="warning" i18n="@@aanvraagDetail.nietGevonden"
|
||||
>Deze aanvraag is niet gevonden.</app-alert
|
||||
>
|
||||
<ng-template appAsyncLoaded>
|
||||
@if (applications(); as list) {
|
||||
@let a = find(list);
|
||||
@if (a) {
|
||||
<app-data-block
|
||||
i18n-ariaLabel="@@aanvraagDetail.ariaLabel"
|
||||
ariaLabel="Aanvraaggegevens"
|
||||
>
|
||||
@for (row of rows(a); track row.key) {
|
||||
<div app-data-row [key]="row.key" [value]="row.value"></div>
|
||||
}
|
||||
</app-data-block>
|
||||
<app-alert class="app-section" type="info" i18n="@@aanvraagDetail.stub">
|
||||
De volledige afhandeling van deze aanvraag is nog niet beschikbaar in deze POC.
|
||||
</app-alert>
|
||||
} @else {
|
||||
<app-alert type="warning" i18n="@@aanvraagDetail.nietGevonden"
|
||||
>Deze aanvraag is niet gevonden.</app-alert
|
||||
>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoading>
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -87,59 +87,61 @@ 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) {
|
||||
<app-task-list
|
||||
class="app-section"
|
||||
i18n-listHeading="@@dashboard.watMoetIkRegelen"
|
||||
listHeading="Wat moet ik regelen"
|
||||
[tasks]="tasks"
|
||||
/>
|
||||
} @else {
|
||||
<app-heading [level]="2" i18n="@@dashboard.watMoetIkRegelen"
|
||||
>Wat moet ik regelen</app-heading
|
||||
<section>
|
||||
@if (tasks.length) {
|
||||
<app-task-list
|
||||
class="app-section"
|
||||
i18n-listHeading="@@dashboard.watMoetIkRegelen"
|
||||
listHeading="Wat moet ik regelen"
|
||||
[tasks]="tasks"
|
||||
/>
|
||||
} @else {
|
||||
<app-heading [level]="2" i18n="@@dashboard.watMoetIkRegelen"
|
||||
>Wat moet ik regelen</app-heading
|
||||
>
|
||||
<p class="app-text-subtle" i18n="@@dashboard.nietsOpenstaan">
|
||||
U heeft op dit moment niets openstaan.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<app-heading [level]="2" i18n="@@dashboard.mijnRegistratie"
|
||||
>Mijn registratie</app-heading
|
||||
>
|
||||
<p class="app-text-subtle" i18n="@@dashboard.nietsOpenstaan">
|
||||
U heeft op dit moment niets openstaan.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<app-heading [level]="2" i18n="@@dashboard.mijnRegistratie"
|
||||
>Mijn registratie</app-heading
|
||||
>
|
||||
<div class="app-section">
|
||||
<app-registration-summary [reg]="$any(p).registration" />
|
||||
</div>
|
||||
<app-data-block
|
||||
class="app-section"
|
||||
i18n-heading="@@dashboard.persoonsgegevens"
|
||||
heading="Persoonsgegevens (BRP)"
|
||||
>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@dashboard.straat"
|
||||
key="Straat"
|
||||
[value]="$any(p).person.adres.straat"
|
||||
></div>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@dashboard.postcode"
|
||||
key="Postcode"
|
||||
[value]="$any(p).person.adres.postcode"
|
||||
></div>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@dashboard.woonplaats"
|
||||
key="Woonplaats"
|
||||
[value]="$any(p).person.adres.woonplaats"
|
||||
></div>
|
||||
</app-data-block>
|
||||
</section>
|
||||
<div class="app-section">
|
||||
<app-registration-summary [reg]="p.registration" />
|
||||
</div>
|
||||
<app-data-block
|
||||
class="app-section"
|
||||
i18n-heading="@@dashboard.persoonsgegevens"
|
||||
heading="Persoonsgegevens (BRP)"
|
||||
>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@dashboard.straat"
|
||||
key="Straat"
|
||||
[value]="p.person.adres.straat"
|
||||
></div>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@dashboard.postcode"
|
||||
key="Postcode"
|
||||
[value]="p.person.adres.postcode"
|
||||
></div>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@dashboard.woonplaats"
|
||||
key="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. */
|
||||
|
||||
@@ -169,83 +169,85 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
|
||||
}
|
||||
@case ('beroep') {
|
||||
<app-async [data]="lookupRd()">
|
||||
<ng-template appAsyncLoaded let-data>
|
||||
<app-form-field
|
||||
i18n-label="@@regWizard.diplomaLabel"
|
||||
label="Kies het diploma waarmee u zich wilt registreren"
|
||||
fieldId="diploma"
|
||||
required
|
||||
[error]="err('diploma')"
|
||||
>
|
||||
<app-radio-group
|
||||
name="diploma"
|
||||
[options]="diplomaOptions($any(data))"
|
||||
[invalid]="!!err('diploma')"
|
||||
[ngModel]="diplomaKeuze()"
|
||||
(ngModelChange)="onDiplomaKeuze($any(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
|
||||
>
|
||||
<ng-template appAsyncLoaded>
|
||||
@if (duoData(); as data) {
|
||||
<app-form-field
|
||||
i18n-label="@@regWizard.beroepLabel"
|
||||
label="Voor welk beroep wilt u zich registreren?"
|
||||
fieldId="hm-beroep"
|
||||
i18n-label="@@regWizard.diplomaLabel"
|
||||
label="Kies het diploma waarmee u zich wilt registreren"
|
||||
fieldId="diploma"
|
||||
required
|
||||
[error]="err('diploma')"
|
||||
>
|
||||
<app-radio-group
|
||||
name="hm-beroep"
|
||||
[options]="beroepOptions($any(data))"
|
||||
name="diploma"
|
||||
[options]="diplomaOptions(data)"
|
||||
[invalid]="!!err('diploma')"
|
||||
[ngModel]="draft().beroep ?? ''"
|
||||
(ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })"
|
||||
[ngModel]="diplomaKeuze()"
|
||||
(ngModelChange)="onDiplomaKeuze(data, $event)"
|
||||
/>
|
||||
</app-form-field>
|
||||
} @else if (draft().beroep) {
|
||||
<dl class="mb-0 app-section">
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@regWizard.beroepAfgeleid"
|
||||
key="Beroep (afgeleid uit diploma)"
|
||||
[value]="draft().beroep ?? ''"
|
||||
></div>
|
||||
</dl>
|
||||
}
|
||||
|
||||
@for (q of actieveVragen($any(data)); track q.id) {
|
||||
<app-form-field
|
||||
[label]="q.vraag"
|
||||
[fieldId]="'vraag-' + q.id"
|
||||
[error]="vraagErr(q.id)"
|
||||
>
|
||||
@if (q.type === 'ja-nee') {
|
||||
@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
|
||||
>
|
||||
<app-form-field
|
||||
i18n-label="@@regWizard.beroepLabel"
|
||||
label="Voor welk beroep wilt u zich registreren?"
|
||||
fieldId="hm-beroep"
|
||||
[error]="err('diploma')"
|
||||
>
|
||||
<app-radio-group
|
||||
[name]="'vraag-' + q.id"
|
||||
[options]="jaNee"
|
||||
[invalid]="!!vraagErr(q.id)"
|
||||
[ngModel]="antwoord(q.id)"
|
||||
(ngModelChange)="
|
||||
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
|
||||
"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
name="hm-beroep"
|
||||
[options]="beroepOptions(data)"
|
||||
[invalid]="!!err('diploma')"
|
||||
[ngModel]="draft().beroep ?? ''"
|
||||
(ngModelChange)="dispatch({ tag: 'DeclareerBeroep', beroep: $event })"
|
||||
/>
|
||||
} @else {
|
||||
<app-text-input
|
||||
[inputId]="'vraag-' + q.id"
|
||||
[invalid]="!!vraagErr(q.id)"
|
||||
[ngModel]="antwoord(q.id)"
|
||||
(ngModelChange)="
|
||||
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
|
||||
"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
/>
|
||||
}
|
||||
</app-form-field>
|
||||
</app-form-field>
|
||||
} @else if (draft().beroep) {
|
||||
<dl class="mb-0 app-section">
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@regWizard.beroepAfgeleid"
|
||||
key="Beroep (afgeleid uit diploma)"
|
||||
[value]="draft().beroep ?? ''"
|
||||
></div>
|
||||
</dl>
|
||||
}
|
||||
|
||||
@for (q of actieveVragen(data); track q.id) {
|
||||
<app-form-field
|
||||
[label]="q.vraag"
|
||||
[fieldId]="'vraag-' + q.id"
|
||||
[error]="vraagErr(q.id)"
|
||||
>
|
||||
@if (q.type === 'ja-nee') {
|
||||
<app-radio-group
|
||||
[name]="'vraag-' + q.id"
|
||||
[options]="jaNee"
|
||||
[invalid]="!!vraagErr(q.id)"
|
||||
[ngModel]="antwoord(q.id)"
|
||||
(ngModelChange)="
|
||||
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
|
||||
"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
/>
|
||||
} @else {
|
||||
<app-text-input
|
||||
[inputId]="'vraag-' + q.id"
|
||||
[invalid]="!!vraagErr(q.id)"
|
||||
[ngModel]="antwoord(q.id)"
|
||||
(ngModelChange)="
|
||||
dispatch({ tag: 'SetAntwoord', vraagId: q.id, value: $event })
|
||||
"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
/>
|
||||
}
|
||||
</app-form-field>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoading>
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>,
|
||||
|
||||
22
src/app/shared/kernel/datum.spec.ts
Normal file
22
src/app/shared/kernel/datum.spec.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
16
src/app/shared/kernel/datum.ts
Normal file
16
src/app/shared/kernel/datum.ts
Normal 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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } },
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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.' },
|
||||
};
|
||||
@@ -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
52
src/docs/cibg-gaps.mdx
Normal 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
100
src/docs/machines.mdx
Normal 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.
|
||||
111
src/docs/parse-dont-validate.mdx
Normal file
111
src/docs/parse-dont-validate.mdx
Normal 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
97
src/docs/remote-data.mdx
Normal 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.
|
||||
Reference in New Issue
Block a user