style: format the repo with prettier (green format:check)

`npm run format:check` (a CI gate) had drifted red across 44 files — pre-existing
files plus recently-added ones committed without formatting. Ran `prettier --write .`;
no logic changes. Also regenerates documentation.json (compodoc reflects the reformatted
component sources).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-21 17:20:42 +02:00
co-authored by Claude Opus 4.8
parent 7dfbd4501f
commit 5761b13dd2
46 changed files with 762 additions and 543 deletions
-1
View File
@@ -2,7 +2,6 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "ng serve", "name": "ng serve",
"type": "chrome", "type": "chrome",
+17 -17
View File
@@ -10,28 +10,28 @@ Docs are split by **kind**, and kept out of each other's way:
process-facing material. process-facing material.
Teaching material that is best read **next to the components** lives in Storybook, not Teaching material that is best read **next to the components** lives in Storybook, not
here — see the **Foundations** section (`src/docs/*.mdx`), starting at *Foundations → here — see the **Foundations** section (`src/docs/*.mdx`), starting at _Foundations →
Overview*. The `reference/` docs are the long-form source; the Foundations pages are the Overview_. The `reference/` docs are the long-form source; the Foundations pages are the
condensed, cross-linked curriculum. condensed, cross-linked curriculum.
## `reference/` — information ## `reference/` — information
| Doc | What it is | | Doc | What it is |
| --- | --- | | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
| [architecture/ARCHITECTURE.md](reference/architecture/ARCHITECTURE.md) | The architecture walkthrough: contexts/layers, state management, parse-don't-validate, the feature recipe, the .NET backend seam. | | [architecture/ARCHITECTURE.md](reference/architecture/ARCHITECTURE.md) | The architecture walkthrough: contexts/layers, state management, parse-don't-validate, the feature recipe, the .NET backend seam. |
| [architecture/0001-bff-lite-decision-dtos.md](reference/architecture/0001-bff-lite-decision-dtos.md) | ADR — BFF-lite endpoints + decision DTOs (backend decides, FE renders). | | [architecture/0001-bff-lite-decision-dtos.md](reference/architecture/0001-bff-lite-decision-dtos.md) | ADR — BFF-lite endpoints + decision DTOs (backend decides, FE renders). |
| [architecture/0002-user-groups-and-bounded-contexts.md](reference/architecture/0002-user-groups-and-bounded-contexts.md) | ADR — user groups as actors; identity vs authorization. | | [architecture/0002-user-groups-and-bounded-contexts.md](reference/architecture/0002-user-groups-and-bounded-contexts.md) | ADR — user groups as actors; identity vs authorization. |
| [architecture/0003-cibg-huisstijl.md](reference/architecture/0003-cibg-huisstijl.md) | ADR — adopt CIBG Huisstijl (vendored Bootstrap 5.2) + the token bridge. | | [architecture/0003-cibg-huisstijl.md](reference/architecture/0003-cibg-huisstijl.md) | ADR — adopt CIBG Huisstijl (vendored Bootstrap 5.2) + the token bridge. |
| [architecture/0004-stamdata-as-code.md](reference/architecture/0004-stamdata-as-code.md) | ADR — business-tunable reference data as typed, compile-time-validated config (not a production DB). | | [architecture/0004-stamdata-as-code.md](reference/architecture/0004-stamdata-as-code.md) | ADR — business-tunable reference data as typed, compile-time-validated config (not a production DB). |
| [fp-tea-atomic-design.md](reference/fp-tea-atomic-design.md) | Long-form learning guide: FP + The Elm Architecture + atomic design. | | [fp-tea-atomic-design.md](reference/fp-tea-atomic-design.md) | Long-form learning guide: FP + The Elm Architecture + atomic design. |
| [wcag-checklist.md](reference/wcag-checklist.md) | Manual WCAG checks automation can't catch (tab order, focus traps, reflow). | | [wcag-checklist.md](reference/wcag-checklist.md) | Manual WCAG checks automation can't catch (tab order, focus traps, reflow). |
| [ui-ux-audit.md](reference/ui-ux-audit.md) | Early UI/UX audit against NL Design System (predates ADR-0003 — read in that light). | | [ui-ux-audit.md](reference/ui-ux-audit.md) | Early UI/UX audit against NL Design System (predates ADR-0003 — read in that light). |
## `project/` — administration ## `project/` — administration
| Doc | What it is | | Doc | What it is |
| --- | --- | | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| [backlog/README.md](project/backlog/README.md) | The work-package backlog index (WP-01…WP-28) — the live tracker. | | [backlog/README.md](project/backlog/README.md) | The work-package backlog index (WP-01…WP-28) — the live tracker. |
| [prd/0001-mijn-aanvragen-en-wizardstatus.md](project/prd/0001-mijn-aanvragen-en-wizardstatus.md) | PRD — "Mijn aanvragen": running wizards, application status, document preview. | | [prd/0001-mijn-aanvragen-en-wizardstatus.md](project/prd/0001-mijn-aanvragen-en-wizardstatus.md) | PRD — "Mijn aanvragen": running wizards, application status, document preview. |
| [prd/0002-attribute-based-access-control.md](project/prd/0002-attribute-based-access-control.md) | PRD — attribute-based access control in the UI. | | [prd/0002-attribute-based-access-control.md](project/prd/0002-attribute-based-access-control.md) | PRD — attribute-based access control in the UI. |
| [SHOWCASE-ROADMAP.md](project/SHOWCASE-ROADMAP.md) | Superseded roadmap (absorbed into `project/backlog/`) — kept for history. | | [SHOWCASE-ROADMAP.md](project/SHOWCASE-ROADMAP.md) | Superseded roadmap (absorbed into `project/backlog/`) — kept for history. |
+31 -31
View File
@@ -43,37 +43,37 @@ WP-19's own file), so it's a separate manual/CI step, not chained into the other
Gates land before the work they cover; each lint rule lands in the same WP as the fixes Gates land before the work they cover; each lint rule lands in the same WP as the fixes
for its existing violations, so every WP ends green. for its existing violations, so every WP ends green.
| WP | Title | Phase | Status | | WP | Title | Phase | Status |
| --------------------------------------- | ------------------------------------------------------------ | --------------------------- | ------ | | ---------------------------------------- | ------------------------------------------------------------ | --------------------------- | ------ |
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done | | [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done | | [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-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-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 | done | | [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-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-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-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-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-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-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-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 | done | | [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 | done | | [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done | | [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done | | [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done | | [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done | | [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done | | [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done | | [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | done |
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done | | [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | done |
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done | | [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | done |
| [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done | | [WP-23](WP-23-org-template-backend.md) | Org-template backend + admin role | 6 · Brief v2 | done |
| [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done | | [WP-24](WP-24-letter-canvas.md) | Letter canvas (edit on the letter) | 6 · Brief v2 | done |
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | todo | | [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | todo |
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done | | [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | todo | | [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | todo |
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo | | [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo |
| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done | | [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done |
Sequencing dependencies (stated in the WPs too): 01 before 1015 (axe covers story churn); Sequencing dependencies (stated in the WPs too): 01 before 1015 (axe covers story churn);
03/04 before 0509 (boundaries stop new violations during refactors); 06 before 07 (typed 03/04 before 0509 (boundaries stop new violations during refactors); 06 before 07 (typed
@@ -4,6 +4,7 @@ Status: done (7ec13d8)
Phase: 5 — productie-volwassenheid Phase: 5 — productie-volwassenheid
> **Follow-up (P2/P3-lite delivered later).** On top of this P1 spine: > **Follow-up (P2/P3-lite delivered later).** On top of this P1 spine:
>
> - **P2 field-level PII (§5c):** the case screen's **BIG-nummer** now ships masked > - **P2 field-level PII (§5c):** the case screen's **BIG-nummer** now ships masked
> (`Authz.CanRevealBigNummer` + `BriefDecisionsDto.CanRevealBigNummer`); a > (`Authz.CanRevealBigNummer` + `BriefDecisionsDto.CanRevealBigNummer`); a
> step-up-stubbed (`X-Step-Up` header), audited `POST /brief/reveal-bignummer` unmasks > step-up-stubbed (`X-Step-Up` header), audited `POST /brief/reveal-bignummer` unmasks
+1 -1
View File
@@ -94,6 +94,6 @@ polish + standaardbrief + diff badges (WP-27).
## Risks ## Risks
The canvas duplicates the letter markup that WP-25's backend renderer will emit — The canvas duplicates the letter markup that WP-25's backend renderer will emit —
acceptable *only* because `letter.css` is shared and WP-25 adds the class-parity acceptable _only_ because `letter.css` is shared and WP-25 adds the class-parity
test; until WP-25 lands, the canvas is the sole consumer, so no drift is possible. test; until WP-25 lands, the canvas is the sole consumer, so no drift is possible.
Deleting `letter-preview` breaks any deep import of it — repo-wide grep before delete. Deleting `letter-preview` breaks any deep import of it — repo-wide grep before delete.
@@ -24,7 +24,7 @@ same composition is archived with the brief, making sent letters immutable.
composed, print-ready letter (`@page` CSS; browser print-to-PDF is the manual composed, print-ready letter (`@page` CSS; browser print-to-PDF is the manual
affordance). The endpoint is the seam where a headless-Chromium PDF render slots affordance). The endpoint is the seam where a headless-Chromium PDF render slots
in later; mark it `// ponytail: HTML today, Chromium PDF behind this same route if in later; mark it `// ponytail: HTML today, Chromium PDF behind this same route if
the POC ever needs real PDF bytes`. the POC ever needs real PDF bytes`.
- **`LetterHtml.Render(brief, orgTemplate)`** is a pure static composer: mirrors the - **`LetterHtml.Render(brief, orgTemplate)`** is a pure static composer: mirrors the
canvas class vocabulary exactly, inlines `public/letter.css` from disk, inlines the canvas class vocabulary exactly, inlines `public/letter.css` from disk, inlines the
logo bytes as a data-URI. Placeholders: auto-resolvable keys resolve from logo bytes as a data-URI. Placeholders: auto-resolvable keys resolve from
@@ -73,7 +73,7 @@ content a read-only sample). PRD Brief v2 §5, §7h.
## Deviations / notes (as built) ## Deviations / notes (as built)
- **`check:tokens` was already red on `main`** (WP-24's canvas landed `var(--rhc-*, - **`check:tokens` was already red on `main`** (WP-24's canvas landed `var(--rhc-*,
#hex)` fallbacks + an `rgb()` paper shadow, and WP-25 committed over it). Fixed here #hex)` fallbacks + an `rgb()` paper shadow, and WP-25 committed over it). Fixed here
to end GREEN: dropped the redundant hex fallbacks (the token bridge defines every to end GREEN: dropped the redundant hex fallbacks (the token bridge defines every
one) and marked the paper drop-shadow `token-ok`; also fixed a pre-existing one) and marked the paper drop-shadow `token-ok`; also fixed a pre-existing
`passage-picker` `var(--rhc-color-wit, #fff)` hit. `passage-picker` `var(--rhc-color-wit, #fff)` hit.
@@ -24,7 +24,7 @@ authority. No production database, no runtime write path (ADR-0004 unchanged).
- **Read-only endpoints only.** `GET /stamdata` (catalog) and `GET /stamdata/{table}?peildatum=` - **Read-only endpoints only.** `GET /stamdata` (catalog) and `GET /stamdata/{table}?peildatum=`
(schema + rows). No POST/PUT/DELETE — the edit lands as a reviewed PR, not a write. The (schema + rows). No POST/PUT/DELETE — the edit lands as a reviewed PR, not a write. The
`stamdata:edit` capability / `CanEditStamdata` gate the *reads* (naming is the enforce-twin `stamdata:edit` capability / `CanEditStamdata` gate the _reads_ (naming is the enforce-twin
of a future edit capability; deliberate). of a future edit capability; deliberate).
- **Generic, schema-driven.** One endpoint pair + one grid editor serve every table. Columns - **Generic, schema-driven.** One endpoint pair + one grid editor serve every table. Columns
are reflected from the typed record (`StamdataTable.Of<T>`); the FE renders inputs by column are reflected from the typed record (`StamdataTable.Of<T>`); the FE renders inputs by column
@@ -32,7 +32,7 @@ authority. No production database, no runtime write path (ADR-0004 unchanged).
endpoint, UI, or test. (Catalog of one today; this is the shape ADR-0004 prescribed.) endpoint, UI, or test. (Catalog of one today; this is the shape ADR-0004 prescribed.)
- **Data-file format for `professions`.** `professions.json` (embedded resource) replaces the - **Data-file format for `professions`.** `professions.json` (embedded resource) replaces the
hardcoded C# dictionary, deserialized into `ProfessionMapping`. This trades compile-time hardcoded C# dictionary, deserialized into `ProfessionMapping`. This trades compile-time
*value* checking (gate #1) for editor ergonomics — the value gate becomes _value_ checking (gate #1) for editor ergonomics — the value gate becomes
`StamdataValidationTests` (gate #2), exactly the trade-off ADR-0004's consequences listed. `StamdataValidationTests` (gate #2), exactly the trade-off ADR-0004's consequences listed.
- **Valid-time.** `geldigVan`/`geldigTot` (half-open `[van, tot)`); a table is temporal iff it - **Valid-time.** `geldigVan`/`geldigTot` (half-open `[van, tot)`); a table is temporal iff it
has both columns. `peildatum` previews "which rows applied on date X". `Professions.ByProgram` has both columns. `peildatum` previews "which rows applied on date X". `Professions.ByProgram`
@@ -138,7 +138,7 @@ canonical case (art. 9 / special-category data):
**audited** (§8). **audited** (§8).
> **Implementation note.** No **BSN** actually travels the wire in this POC (the BSN lives only in > **Implementation note.** No **BSN** actually travels the wire in this POC (the BSN lives only in
> the faked login and is never persisted). The sensitive identifier the backend *does* serve is the > the faked login and is never persisted). The sensitive identifier the backend _does_ serve is the
> **BIG-nummer** on the backoffice case screen (`CaseContextDto`), so the delivered field-level reveal > **BIG-nummer** on the backoffice case screen (`CaseContextDto`), so the delivered field-level reveal
> is realized there (`canRevealBigNummer`, `POST /brief/reveal-bignummer`) — the on-the-wire > is realized there (`canRevealBigNummer`, `POST /brief/reveal-bignummer`) — the on-the-wire
> equivalent of this BSN illustration. > equivalent of this BSN illustration.
@@ -23,7 +23,7 @@ was neither isolated nor validated:
- User-facing UI copy is already **`$localize`** (`src/locale/*.xlf`) — git-tracked, and a - User-facing UI copy is already **`$localize`** (`src/locale/*.xlf`) — git-tracked, and a
second locale is a translation file, not a code change. That is already the compile-time second locale is a translation file, not a code change. That is already the compile-time
model for text. model for text.
- The profession↔diploma map lived as a *private* `Dictionary` inside `DiplomaRules`, mixed - The profession↔diploma map lived as a _private_ `Dictionary` inside `DiplomaRules`, mixed
in with the rules that consume it, with **no cross-reference check**: a diploma whose in with the rules that consume it, with **no cross-reference check**: a diploma whose
program wasn't in the map silently rendered `"Onbekend"`. program wasn't in the map silently rendered `"Onbekend"`.
@@ -38,11 +38,11 @@ production database, never runtime-editable.
concern. A table lives **either** as plain typed C# data (records / dictionaries) **or** as concern. A table lives **either** as plain typed C# data (records / dictionaries) **or** as
a typed JSON data-file deserialized into a record (`professions.json``ProfessionMapping`, a typed JSON data-file deserialized into a record (`professions.json``ProfessionMapping`,
loaded via `StamdataFile`). Both are checked-in config-as-code, gated the same way; the loaded via `StamdataFile`). Both are checked-in config-as-code, gated the same way; the
data-file trades the compiler's *value* check (gate #1 sees only the shape, not a wrong data-file trades the compiler's _value_ check (gate #1 sees only the shape, not a wrong
`beroep`) for hand-editing ergonomics and the low-code editor below — the value gate becomes `beroep`) for hand-editing ergonomics and the low-code editor below — the value gate becomes
`StamdataValidationTests`. Separate the **data** (what the business tunes) from the **rules** `StamdataValidationTests`. Separate the **data** (what the business tunes) from the **rules**
(dev-owned logic that consumes it): the profession *table* is `Stamdata.Professions`; the (dev-owned logic that consumes it): the profession _table_ is `Stamdata.Professions`; the
*rule* "an English diploma needs a B2 question" stays in `DiplomaRules`. Tables may carry _rule_ "an English diploma needs a B2 question" stays in `DiplomaRules`. Tables may carry
**valid-time** (`geldigVan`/`geldigTot`, half-open `[van, tot)`); `StamdataCatalog` + **valid-time** (`geldigVan`/`geldigTot`, half-open `[van, tot)`); `StamdataCatalog` +
`StamdataTable.Of<T>` describe every table generically (columns reflected from the record) `StamdataTable.Of<T>` describe every table generically (columns reflected from the record)
so one endpoint pair and one grid editor serve all of them. so one endpoint pair and one grid editor serve all of them.
@@ -59,17 +59,17 @@ production database, never runtime-editable.
### Where each kind of business-controllable thing lives ### Where each kind of business-controllable thing lives
| Kind | Home | Gate | | Kind | Home | Gate |
| --- | --- | --- | | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Reference tables + tunable numbers (professions↔diplomas, thresholds, policy questions, document categories) | `Stamdata/` typed C# **or** typed JSON data-file (`professions.json`), optionally valid-timed | compiler (shape; + values when C#) + `StamdataValidationTests` (values, references, validity windows) | | Reference tables + tunable numbers (professions↔diplomas, thresholds, policy questions, document categories) | `Stamdata/` typed C# **or** typed JSON data-file (`professions.json`), optionally valid-timed | compiler (shape; + values when C#) + `StamdataValidationTests` (values, references, validity windows) |
| User-facing UI copy | `$localize``src/locale/*.xlf` | build (`i18nMissingTranslation: error`) | | User-facing UI copy | `$localize``src/locale/*.xlf` | build (`i18nMissingTranslation: error`) |
| Letter / brief passage content | config-as-code in the backend (seed content), **not** the DB | compiler + endpoint tests | | Letter / brief passage content | config-as-code in the backend (seed content), **not** the DB | compiler + endpoint tests |
### The deliberate exception: org-templates ### The deliberate exception: org-templates
Per-organization letterhead (return address, footer, signature, margins) **is** Per-organization letterhead (return address, footer, signature, margins) **is**
runtime-editable in SQLite, via the org-template admin editor (WP-23/26). That is runtime-editable in SQLite, via the org-template admin editor (WP-23/26). That is
intentional and does not contradict this ADR: it is *operational configuration* owned by an intentional and does not contradict this ADR: it is _operational configuration_ owned by an
admin persona, versioned with publish/rollback inside the app, and specific to one admin persona, versioned with publish/rollback inside the app, and specific to one
sub-organization's identity — not the shared business rules a wrong value would break for sub-organization's identity — not the shared business rules a wrong value would break for
everyone. Stamdata (the rules and reference tables the whole register runs on) stays code. everyone. Stamdata (the rules and reference tables the whole register runs on) stays code.
+308 -308
View File
File diff suppressed because one or more lines are too long
+1 -2
View File
@@ -64,8 +64,7 @@ export const routes: Routes = [
// via the OrgAdmin gate — the guard just avoids loading a page that would 403. // via the OrgAdmin gate — the guard just avoids loading a page that would 403.
canActivate: [capabilityGuard('orgtemplate:edit')], canActivate: [capabilityGuard('orgtemplate:edit')],
canDeactivate: [flushPendingGuard], canDeactivate: [flushPendingGuard],
loadComponent: () => loadComponent: () => import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage),
import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage),
}, },
{ {
path: 'beheer/stamdata', path: 'beheer/stamdata',
@@ -47,11 +47,17 @@ describe('stamdata-editor reduce', () => {
it('edit messages are ignored unless loaded', () => { it('edit messages are ignored unless loaded', () => {
expect(reduce(initial, { tag: 'RowAdded' })).toBe(initial); expect(reduce(initial, { tag: 'RowAdded' })).toBe(initial);
expect(reduce({ tag: 'failed', reason: 'x' }, { tag: 'CellEdited', row: 0, column: 'a', value: 'b' }).tag).toBe('failed'); expect(
reduce({ tag: 'failed', reason: 'x' }, { tag: 'CellEdited', row: 0, column: 'a', value: 'b' })
.tag,
).toBe('failed');
}); });
it('LoadFailed and Loading transition regardless of prior state', () => { it('LoadFailed and Loading transition regardless of prior state', () => {
expect(reduce(seedLoaded(), { tag: 'LoadFailed', reason: 'boom' })).toEqual({ tag: 'failed', reason: 'boom' }); expect(reduce(seedLoaded(), { tag: 'LoadFailed', reason: 'boom' })).toEqual({
tag: 'failed',
reason: 'boom',
});
expect(reduce(seedLoaded(), { tag: 'Loading' })).toEqual({ tag: 'loading' }); expect(reduce(seedLoaded(), { tag: 'Loading' })).toEqual({ tag: 'loading' });
}); });
}); });
+14 -4
View File
@@ -28,8 +28,12 @@ describe('activeOn (valid-time, half-open [van, tot))', () => {
expect(activeOn(professions, row('a', 'A', '2000-01-01'), '1999-01-01')).toBe(false); expect(activeOn(professions, row('a', 'A', '2000-01-01'), '1999-01-01')).toBe(false);
}); });
it('excludes on the geldigTot boundary (half-open)', () => { it('excludes on the geldigTot boundary (half-open)', () => {
expect(activeOn(professions, row('a', 'A', '2000-01-01', '2020-01-01'), '2020-01-01')).toBe(false); expect(activeOn(professions, row('a', 'A', '2000-01-01', '2020-01-01'), '2020-01-01')).toBe(
expect(activeOn(professions, row('a', 'A', '2000-01-01', '2020-01-01'), '2019-12-31')).toBe(true); false,
);
expect(activeOn(professions, row('a', 'A', '2000-01-01', '2020-01-01'), '2019-12-31')).toBe(
true,
);
}); });
}); });
@@ -55,7 +59,11 @@ describe('changeCounts (diff against the loaded snapshot, by key)', () => {
expect(changeCounts(professions, original, draft)).toEqual({ added: 1, removed: 0, edited: 0 }); expect(changeCounts(professions, original, draft)).toEqual({ added: 1, removed: 0, edited: 0 });
}); });
it('counts a removed key', () => { it('counts a removed key', () => {
expect(changeCounts(professions, original, [original[0]])).toEqual({ added: 0, removed: 1, edited: 0 }); expect(changeCounts(professions, original, [original[0]])).toEqual({
added: 0,
removed: 1,
edited: 0,
});
}); });
it('counts an edited cell', () => { it('counts an edited cell', () => {
const draft = [row('a', 'CHANGED', '2000-01-01'), original[1]]; const draft = [row('a', 'CHANGED', '2000-01-01'), original[1]];
@@ -81,6 +89,8 @@ describe('toJson (draft → file shape)', () => {
{ name: 'jaar', type: 'number', isKey: false, options: [] }, { name: 'jaar', type: 'number', isKey: false, options: [] },
], ],
}; };
expect(JSON.parse(toJson(table, [{ code: 'x', jaar: '2020' }]))).toEqual([{ code: 'x', jaar: 2020 }]); expect(JSON.parse(toJson(table, [{ code: 'x', jaar: '2020' }]))).toEqual([
{ code: 'x', jaar: 2020 },
]);
}); });
}); });
+7 -3
View File
@@ -116,9 +116,13 @@ export function toJson(table: StamTable, rows: readonly StamRow[]): string {
for (const c of table.columns) { for (const c of table.columns) {
const cell = (row[c.name] ?? '').trim(); const cell = (row[c.name] ?? '').trim();
out[c.name] = out[c.name] =
cell === '' ? (c.type === 'text' || c.type === 'enum' ? '' : null) cell === ''
: c.type === 'number' ? Number(cell) ? c.type === 'text' || c.type === 'enum'
: cell; ? ''
: null
: c.type === 'number'
? Number(cell)
: cell;
} }
return out; return out;
}); });
@@ -30,7 +30,11 @@ describe('parseStamdataTable', () => {
}); });
it('falls back to text for an unknown column type', () => { it('falls back to text for an unknown column type', () => {
const r = parseStamdataTable({ ...wire, columns: [{ name: 'x', type: 'weird', isKey: true }], rows: [] }); const r = parseStamdataTable({
...wire,
columns: [{ name: 'x', type: 'weird', isKey: true }],
rows: [],
});
if (!r.ok) return; if (!r.ok) return;
expect(r.value.table.columns[0].type).toBe('text'); expect(r.value.table.columns[0].type).toBe('text');
}); });
@@ -67,7 +67,12 @@ function parseColumn(dto: StamdataColumnDto): Result<string, StamColumn> {
return ok({ name: dto.name, type, isKey: dto.isKey === true, options: dto.options ?? [] }); return ok({ name: dto.name, type, isKey: dto.isKey === true, options: dto.options ?? [] });
} }
function parseTable(dto: { id?: string; label?: string; columns?: StamdataColumnDto[]; temporal?: boolean }): Result<string, StamTable> { function parseTable(dto: {
id?: string;
label?: string;
columns?: StamdataColumnDto[];
temporal?: boolean;
}): Result<string, StamTable> {
if (typeof dto.id !== 'string' || !Array.isArray(dto.columns)) if (typeof dto.id !== 'string' || !Array.isArray(dto.columns))
return err('stamdata table: bad shape'); return err('stamdata table: bad shape');
const columns: StamColumn[] = []; const columns: StamColumn[] = [];
@@ -1,12 +1,6 @@
import { Component, computed, input, output } from '@angular/core'; import { Component, computed, input, output } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { import { ChangeCounts, StamColumn, StamRow, StamTable, activeOn } from '@beheer/domain/stamdata';
ChangeCounts,
StamColumn,
StamRow,
StamTable,
activeOn,
} from '@beheer/domain/stamdata';
interface DisplayRow { interface DisplayRow {
row: StamRow; row: StamRow;
@@ -94,7 +88,9 @@ interface DisplayRow {
/> />
</div> </div>
@if (previewing()) { @if (previewing()) {
<app-button variant="subtle" (click)="previewDateChanged.emit('')">{{ showAll }}</app-button> <app-button variant="subtle" (click)="previewDateChanged.emit('')">{{
showAll
}}</app-button>
} }
} }
</div> </div>
@@ -123,7 +119,9 @@ interface DisplayRow {
[value]="item.row[col.name]" [value]="item.row[col.name]"
[disabled]="previewing()" [disabled]="previewing()"
[attr.aria-label]="cellLabel(col, item.index)" [attr.aria-label]="cellLabel(col, item.index)"
(change)="cellEdited.emit({ row: item.index, column: col.name, value: asValue($event) })" (change)="
cellEdited.emit({ row: item.index, column: col.name, value: asValue($event) })
"
> >
<option value=""></option> <option value=""></option>
@for (opt of col.options; track opt) { @for (opt of col.options; track opt) {
@@ -138,7 +136,9 @@ interface DisplayRow {
[class.is-invalid]="!!errors()[item.index]" [class.is-invalid]="!!errors()[item.index]"
[disabled]="previewing()" [disabled]="previewing()"
[attr.aria-label]="cellLabel(col, item.index)" [attr.aria-label]="cellLabel(col, item.index)"
(input)="cellEdited.emit({ row: item.index, column: col.name, value: asValue($event) })" (input)="
cellEdited.emit({ row: item.index, column: col.name, value: asValue($event) })
"
/> />
} }
</td> </td>
@@ -17,7 +17,12 @@ const table: StamTable = {
const rows: StamRow[] = [ const rows: StamRow[] = [
{ program: 'geneeskunde', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: '' }, { program: 'geneeskunde', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: '' },
{ program: 'verpleegkunde', beroep: 'Verpleegkundige', geldigVan: '2000-01-01', geldigTot: '' }, { program: 'verpleegkunde', beroep: 'Verpleegkundige', geldigVan: '2000-01-01', geldigTot: '' },
{ program: 'fysiotherapie', beroep: 'Fysiotherapeut', geldigVan: '2000-01-01', geldigTot: '2020-01-01' }, {
program: 'fysiotherapie',
beroep: 'Fysiotherapeut',
geldigVan: '2000-01-01',
geldigTot: '2020-01-01',
},
]; ];
const meta: Meta<StamdataTableEditorComponent> = { const meta: Meta<StamdataTableEditorComponent> = {
@@ -48,11 +53,14 @@ export const Dirty: Story = {
export const Invalid: Story = { export const Invalid: Story = {
args: { args: {
rows: [{ program: '', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: '' }, ...rows.slice(1)], rows: [
errors: rowErrors( { program: '', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: '' },
table, ...rows.slice(1),
[{ program: '', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: '' }, ...rows.slice(1)], ],
), errors: rowErrors(table, [
{ program: '', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: '' },
...rows.slice(1),
]),
}, },
}; };
+7 -1
View File
@@ -14,7 +14,13 @@ import { StamdataTableEditorComponent } from '@beheer/ui/stamdata-table-editor/s
*/ */
@Component({ @Component({
selector: 'app-stamdata-page', selector: 'app-stamdata-page',
imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, StamdataTableEditorComponent], imports: [
PageShellComponent,
AlertComponent,
ButtonComponent,
...ASYNC,
StamdataTableEditorComponent,
],
template: ` template: `
<app-page-shell [heading]="heading" [intro]="intro" backLink="/dashboard"> <app-page-shell [heading]="heading" [intro]="intro" backLink="/dashboard">
@if (!access.ready()) { @if (!access.ready()) {
+17 -5
View File
@@ -133,7 +133,11 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
// --- WP-27: undo/redo history + rejection diff --- // --- WP-27: undo/redo history + rejection diff ---
function block(id: string, text: string): LetterBlock { function block(id: string, text: string): LetterBlock {
return { type: 'freeText', blockId: id, content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] } }; return {
type: 'freeText',
blockId: id,
content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] },
};
} }
const kern = (blocks: LetterBlock[]) => ({ const kern = (blocks: LetterBlock[]) => ({
sectionKey: 'kern', sectionKey: 'kern',
@@ -152,7 +156,8 @@ function loadedBrief(store: BriefStore): Brief {
} }
async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> { async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> {
const ok = (v: BriefView): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: v }); const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: v });
const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over }); const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over });
await store.load(); await store.load();
return store; return store;
@@ -203,12 +208,16 @@ describe('BriefStore undo/redo history', () => {
describe('BriefStore rejection diff', () => { describe('BriefStore rejection diff', () => {
it('captures the rejected letter and diffs a subsequent edit against it', async () => { it('captures the rejected letter and diffs a subsequent edit against it', async () => {
const submitted: Brief = { ...filledBrief, status: { tag: 'submitted', submittedBy: 'u', submittedAt: 't' } }; const submitted: Brief = {
...filledBrief,
status: { tag: 'submitted', submittedBy: 'u', submittedAt: 't' },
};
const rejected: Brief = { const rejected: Brief = {
...filledBrief, ...filledBrief,
status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' }, status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' },
}; };
const ok = (v: BriefView): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: v }); const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: v });
const store = setup({ const store = setup({
load: () => ok({ ...filledView, brief: submitted }), load: () => ok({ ...filledView, brief: submitted }),
save: () => ok(filledView), save: () => ok(filledView),
@@ -272,7 +281,10 @@ describe('BriefStore.revealBigNummer (PRD-0002 §5c)', () => {
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
// Loaded with a MASKED BIG-nummer, as the server ships it by default. // Loaded with a MASKED BIG-nummer, as the server ships it by default.
const maskedView: BriefView = { ...view, caseContext: { ...caseContext, bigNummer: '********601' } }; const maskedView: BriefView = {
...view,
caseContext: { ...caseContext, bigNummer: '********601' },
};
it('swaps the masked value for the revealed one on success', async () => { it('swaps the masked value for the revealed one on success', async () => {
const store = setup({ load: () => Promise.resolve({ ok: true, value: maskedView }) }); const store = setup({ load: () => Promise.resolve({ ok: true, value: maskedView }) });
@@ -88,9 +88,12 @@ export class OrgTemplateStore implements PendingSave {
readonly draftValid = computed(() => { readonly draftValid = computed(() => {
const d = this.draft(); const d = this.draft();
if (!d) return false; if (!d) return false;
const marginsOk = [d.margins.topMm, d.margins.rightMm, d.margins.bottomMm, d.margins.leftMm].every( const marginsOk = [
(v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM, d.margins.topMm,
); d.margins.rightMm,
d.margins.bottomMm,
d.margins.leftMm,
].every((v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM);
return d.orgName.trim().length > 0 && d.signatureName.trim().length > 0 && marginsOk; return d.orgName.trim().length > 0 && d.signatureName.trim().length > 0 && marginsOk;
}); });
@@ -107,7 +110,10 @@ export class OrgTemplateStore implements PendingSave {
if (s.tag !== 'loaded' || s.upload.categories.length > 0) return; if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;
const status = this.categoriesRes.status(); const status = this.categoriesRes.status();
if (status === 'resolved' || status === 'local') if (status === 'resolved' || status === 'local')
this.dispatchUpload({ type: 'CategoriesLoaded', categories: this.categoriesRes.value() ?? [] }); this.dispatchUpload({
type: 'CategoriesLoaded',
categories: this.categoriesRes.value() ?? [],
});
}); });
// Flush a pending debounced edit before navigation/unload (see pending-saves.ts). // Flush a pending debounced edit before navigation/unload (see pending-saves.ts).
registerPendingSave(this); registerPendingSave(this);
@@ -259,8 +265,9 @@ export class OrgTemplateStore implements PendingSave {
fileName: file.name, fileName: file.name,
fileSizeMb: file.size / 1e6, fileSizeMb: file.size / 1e6,
}); });
this.shell.upload({ localId, categoryId: cat.categoryId, wizardId: 'org-template', file }, (m) => this.shell.upload(
this.onUploadMsg(m), { localId, categoryId: cat.categoryId, wizardId: 'org-template', file },
(m) => this.onUploadMsg(m),
); );
} }
+25 -6
View File
@@ -2,7 +2,9 @@ import { describe, it, expect } from 'vitest';
import { Besluit, LetterBlock, LibraryPassage } from './brief'; import { Besluit, LetterBlock, LibraryPassage } from './brief';
import { inferSelection, passagesForBesluit, redenenFor } from './besluit'; import { inferSelection, passagesForBesluit, redenenFor } from './besluit';
const block = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] }); const block = (t: string): LibraryPassage['content'] => ({
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
});
const p = (over: Partial<LibraryPassage>): LibraryPassage => ({ const p = (over: Partial<LibraryPassage>): LibraryPassage => ({
passageId: over.passageId ?? 'x', passageId: over.passageId ?? 'x',
@@ -18,8 +20,18 @@ const lib: LibraryPassage[] = [
p({ passageId: 'intro', besluit: undefined }), // shared, any besluit p({ passageId: 'intro', besluit: undefined }), // shared, any besluit
p({ passageId: 'pos', besluit: 'positief' }), p({ passageId: 'pos', besluit: 'positief' }),
p({ passageId: 'neg', besluit: 'negatief' }), p({ passageId: 'neg', besluit: 'negatief' }),
p({ passageId: 'neg-scholing', besluit: 'negatief', reason: 'onvoldoende_scholing', label: 'Onvoldoende scholing' }), p({
p({ passageId: 'neg-gegevens', besluit: 'negatief', reason: 'onjuiste_gegevens', label: 'Onjuiste gegevens' }), passageId: 'neg-scholing',
besluit: 'negatief',
reason: 'onvoldoende_scholing',
label: 'Onvoldoende scholing',
}),
p({
passageId: 'neg-gegevens',
besluit: 'negatief',
reason: 'onjuiste_gegevens',
label: 'Onjuiste gegevens',
}),
p({ passageId: 'slot-x', sectionKey: 'slot', besluit: undefined }), // not kern → never offered p({ passageId: 'slot-x', sectionKey: 'slot', besluit: undefined }), // not kern → never offered
]; ];
@@ -35,17 +47,24 @@ describe('passagesForBesluit', () => {
}); });
it('negatief with a reden ticked includes that reason-specific passage only', () => { it('negatief with a reden ticked includes that reason-specific passage only', () => {
const ids = passagesForBesluit(lib, 'negatief', ['onvoldoende_scholing']).map((x) => x.passageId); const ids = passagesForBesluit(lib, 'negatief', ['onvoldoende_scholing']).map(
(x) => x.passageId,
);
expect(ids).toEqual(['intro', 'neg', 'neg-scholing']); expect(ids).toEqual(['intro', 'neg', 'neg-scholing']);
}); });
it('preserves library order (= reading order)', () => { it('preserves library order (= reading order)', () => {
const ids = passagesForBesluit(lib, 'negatief', ['onjuiste_gegevens', 'onvoldoende_scholing']).map((x) => x.passageId); const ids = passagesForBesluit(lib, 'negatief', [
'onjuiste_gegevens',
'onvoldoende_scholing',
]).map((x) => x.passageId);
expect(ids).toEqual(['intro', 'neg', 'neg-scholing', 'neg-gegevens']); expect(ids).toEqual(['intro', 'neg', 'neg-scholing', 'neg-gegevens']);
}); });
it('never offers non-kern passages', () => { it('never offers non-kern passages', () => {
expect(passagesForBesluit(lib, 'positief', []).some((x) => x.sectionKey !== 'kern')).toBe(false); expect(passagesForBesluit(lib, 'positief', []).some((x) => x.sectionKey !== 'kern')).toBe(
false,
);
}); });
}); });
+2 -1
View File
@@ -66,7 +66,8 @@ export function inferSelection(
const source = byId.get(b.sourcePassageId); const source = byId.get(b.sourcePassageId);
if (!source) continue; if (!source) continue;
if (source.besluit !== undefined) besluit = source.besluit; if (source.besluit !== undefined) besluit = source.besluit;
if (source.reason !== undefined && !reasons.includes(source.reason)) reasons.push(source.reason); if (source.reason !== undefined && !reasons.includes(source.reason))
reasons.push(source.reason);
} }
return { besluit, reasons }; return { besluit, reasons };
} }
+5 -1
View File
@@ -3,7 +3,11 @@ import { Brief, LetterBlock } from './brief';
import { diffBlocks, changedBlocks } from './brief-diff'; import { diffBlocks, changedBlocks } from './brief-diff';
function block(id: string, text: string): LetterBlock { function block(id: string, text: string): LetterBlock {
return { type: 'freeText', blockId: id, content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] } }; return {
type: 'freeText',
blockId: id,
content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] },
};
} }
function brief(blocks: LetterBlock[]): Brief { function brief(blocks: LetterBlock[]): Brief {
@@ -24,7 +24,8 @@ const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView =>
...over, ...over,
}); });
const loaded = (): OrgTemplateState => reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() }); const loaded = (): OrgTemplateState =>
reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
const logoCategory: DocumentCategory = { const logoCategory: DocumentCategory = {
categoryId: 'org-logo', categoryId: 'org-logo',
@@ -84,7 +85,9 @@ describe('org-template.machine', () => {
}); });
it('edits are no-ops in non-loaded states', () => { it('edits are no-ops in non-loaded states', () => {
expect(reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' })).toEqual({ expect(
reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
).toEqual({
tag: 'loading', tag: 'loading',
}); });
}); });
@@ -96,7 +99,13 @@ describe('org-template.machine', () => {
}); });
const selected = reduce(withCat, { const selected = reduce(withCat, {
tag: 'Upload', tag: 'Upload',
msg: { type: 'FileSelected', categoryId: 'org-logo', localId: 'a', fileName: 'l.png', fileSizeMb: 0.1 }, msg: {
type: 'FileSelected',
categoryId: 'org-logo',
localId: 'a',
fileName: 'l.png',
fileSizeMb: 0.1,
},
}); });
const done = reduce(selected, { const done = reduce(selected, {
tag: 'Upload', tag: 'Upload',
+7 -5
View File
@@ -50,10 +50,7 @@ export type OrgTemplateMsg =
| { tag: 'Upload'; msg: UploadMsg }; | { tag: 'Upload'; msg: UploadMsg };
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */ /** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */
function editDraft( function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState {
s: OrgTemplateState,
f: (draft: OrgTemplate) => OrgTemplate,
): OrgTemplateState {
return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s; return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
} }
@@ -87,7 +84,12 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
const upload = reduceUpload(s.upload, m.msg); const upload = reduceUpload(s.upload, m.msg);
// A completed/removed logo upload also updates the draft's logoDocumentId. // A completed/removed logo upload also updates the draft's logoDocumentId.
if (m.msg.type === 'UploadComplete') if (m.msg.type === 'UploadComplete')
return { ...s, upload, draft: { ...s.draft, logoDocumentId: m.msg.documentId }, dirty: true }; return {
...s,
upload,
draft: { ...s.draft, logoDocumentId: m.msg.documentId },
dirty: true,
};
if (m.msg.type === 'UploadRemoved') { if (m.msg.type === 'UploadRemoved') {
const { logoDocumentId: _dropped, ...rest } = s.draft; const { logoDocumentId: _dropped, ...rest } = s.draft;
return { ...s, upload, draft: rest, dirty: true }; return { ...s, upload, draft: rest, dirty: true };
@@ -62,7 +62,13 @@ const view: BriefViewDto = {
reason: 'onvoldoende_scholing', reason: 'onvoldoende_scholing',
}, },
], ],
decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false, canRevealBigNummer: false }, decisions: {
canEdit: false,
canApprove: true,
canReject: true,
canSend: false,
canRevealBigNummer: false,
},
orgTemplate: { orgTemplate: {
subOrgId: 'cibg-registers', subOrgId: 'cibg-registers',
orgName: 'CIBG — Registers', orgName: 'CIBG — Registers',
@@ -126,8 +132,10 @@ describe('brief.adapter parse boundary', () => {
it('rejects a view whose case context is missing or malformed', () => { it('rejects a view whose case context is missing or malformed', () => {
expect(parseBriefView({ ...view, caseContext: undefined }).ok).toBe(false); expect(parseBriefView({ ...view, caseContext: undefined }).ok).toBe(false);
expect( expect(
parseBriefView({ ...view, caseContext: { ...view.caseContext!, bigNummer: undefined as never } }) parseBriefView({
.ok, ...view,
caseContext: { ...view.caseContext!, bigNummer: undefined as never },
}).ok,
).toBe(false); ).toBe(false);
}); });
@@ -47,7 +47,9 @@ describe('parseOrgTemplateAdminView', () => {
it('rejects a malformed history entry', () => { it('rejects a malformed history entry', () => {
const r = parseOrgTemplateAdminView({ const r = parseOrgTemplateAdminView({
...view, ...view,
history: [{ version: 2, publishedAt: '2026-06-01', template: { ...draft, orgName: undefined } }], history: [
{ version: 2, publishedAt: '2026-06-01', template: { ...draft, orgName: undefined } },
],
}); });
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
}); });
@@ -65,10 +65,7 @@ export class OrgTemplateAdapter {
return r.ok ? parsePublish(r.value) : r; return r.ok ? parsePublish(r.value) : r;
} }
async rollback( async rollback(subOrgId: string, version: number): Promise<Result<string, OrgTemplateAdminView>> {
subOrgId: string,
version: number,
): Promise<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(() => this.client.orgTemplateRollback(subOrgId, version), FAILED); const r = await runSubmit(() => this.client.orgTemplateRollback(subOrgId, version), FAILED);
return r.ok ? parseAdminView(r.value) : r; return r.ok ? parseAdminView(r.value) : r;
} }
@@ -31,7 +31,11 @@ export class RevealBigNummerAdapter {
if (!res.ok) return err(await errorMessage(res)); if (!res.ok) return err(await errorMessage(res));
const body: unknown = await res.json().catch(() => null); const body: unknown = await res.json().catch(() => null);
// Trust boundary: validate the shape before handing back a plain string. // Trust boundary: validate the shape before handing back a plain string.
if (typeof body === 'object' && body !== null && typeof (body as { bigNummer?: unknown }).bigNummer === 'string') { if (
typeof body === 'object' &&
body !== null &&
typeof (body as { bigNummer?: unknown }).bigNummer === 'string'
) {
return ok((body as { bigNummer: string }).bigNummer); return ok((body as { bigNummer: string }).bigNummer);
} }
return err(REVEAL_FAILED); return err(REVEAL_FAILED);
@@ -126,12 +126,9 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com
<div class="bar"> <div class="bar">
<app-button variant="subtle" (click)="openPreview()">{{ previewLabel() }}</app-button> <app-button variant="subtle" (click)="openPreview()">{{ previewLabel() }}</app-button>
<app-button <app-button variant="primary" [disabled]="!canSubmit() || busy()" (click)="submit.emit()">{{
variant="primary" submitLabel()
[disabled]="!canSubmit() || busy()" }}</app-button>
(click)="submit.emit()"
>{{ submitLabel() }}</app-button
>
@if (!canSubmit()) { @if (!canSubmit()) {
<span class="app-text-subtle">{{ submitHint() }}</span> <span class="app-text-subtle">{{ submitHint() }}</span>
} }
@@ -149,7 +146,9 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com
/> />
</div> </div>
<div class="modal-bar"> <div class="modal-bar">
<app-button variant="secondary" (click)="preview.emit()">{{ openDocumentLabel() }}</app-button> <app-button variant="secondary" (click)="preview.emit()">{{
openDocumentLabel()
}}</app-button>
<app-button variant="primary" (click)="closePreview()">{{ closeLabel() }}</app-button> <app-button variant="primary" (click)="closePreview()">{{ closeLabel() }}</app-button>
</div> </div>
</dialog> </dialog>
@@ -1,9 +1,17 @@
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { Brief, BriefStatus, CaseContext, LibraryPassage, allDiagnostics } from '@brief/domain/brief'; import {
Brief,
BriefStatus,
CaseContext,
LibraryPassage,
allDiagnostics,
} from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { BehandelSchermComponent } from './behandel-scherm.component'; import { BehandelSchermComponent } from './behandel-scherm.component';
const text = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] }); const text = (t: string): LibraryPassage['content'] => ({
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
});
const orgTemplate: OrgTemplate = { const orgTemplate: OrgTemplate = {
subOrgId: 'cibg-registers', subOrgId: 'cibg-registers',
@@ -26,9 +34,34 @@ const caseContext: CaseContext = {
}; };
const passages: LibraryPassage[] = [ const passages: LibraryPassage[] = [
{ passageId: 'p-kern-positief', scope: 'global', sectionKey: 'kern', label: 'Toewijzing', version: 1, besluit: 'positief', content: text('Uw aanvraag is toegewezen.') }, {
{ passageId: 'p-kern-negatief', scope: 'global', sectionKey: 'kern', label: 'Afwijzing', version: 1, besluit: 'negatief', content: text('Uw aanvraag is afgewezen.') }, passageId: 'p-kern-positief',
{ passageId: 'p-kern-scholing', scope: 'global', sectionKey: 'kern', label: 'Onvoldoende scholing', version: 1, besluit: 'negatief', reason: 'onvoldoende_scholing', content: text('Onvoldoende scholing.') }, scope: 'global',
sectionKey: 'kern',
label: 'Toewijzing',
version: 1,
besluit: 'positief',
content: text('Uw aanvraag is toegewezen.'),
},
{
passageId: 'p-kern-negatief',
scope: 'global',
sectionKey: 'kern',
label: 'Afwijzing',
version: 1,
besluit: 'negatief',
content: text('Uw aanvraag is afgewezen.'),
},
{
passageId: 'p-kern-scholing',
scope: 'global',
sectionKey: 'kern',
label: 'Onvoldoende scholing',
version: 1,
besluit: 'negatief',
reason: 'onvoldoende_scholing',
content: text('Onvoldoende scholing.'),
},
]; ];
function brief(status: BriefStatus, kernBlocks: Brief['sections'][number]['blocks'] = []): Brief { function brief(status: BriefStatus, kernBlocks: Brief['sections'][number]['blocks'] = []): Brief {
@@ -43,9 +76,27 @@ function brief(status: BriefStatus, kernBlocks: Brief['sections'][number]['block
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false }, { key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
], ],
sections: [ sections: [
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: true, blocks: [{ type: 'freeText', blockId: 'aanhef-1', content: text('Geachte heer/mevrouw,') }] }, {
{ sectionKey: 'kern', title: 'Kern van het besluit', required: true, locked: false, blocks: kernBlocks }, sectionKey: 'aanhef',
{ sectionKey: 'slot', title: 'Slot', required: false, locked: true, blocks: [{ type: 'freeText', blockId: 'slot-1', content: text('Met vriendelijke groet,') }] }, title: 'Aanhef',
required: true,
locked: true,
blocks: [{ type: 'freeText', blockId: 'aanhef-1', content: text('Geachte heer/mevrouw,') }],
},
{
sectionKey: 'kern',
title: 'Kern van het besluit',
required: true,
locked: false,
blocks: kernBlocks,
},
{
sectionKey: 'slot',
title: 'Slot',
required: false,
locked: true,
blocks: [{ type: 'freeText', blockId: 'slot-1', content: text('Met vriendelijke groet,') }],
},
], ],
}; };
} }
@@ -66,8 +117,22 @@ export const EmptyKern: Story = {
// A besluit-sourced kern block (carries provenance), so the panel re-seeds itself from it. // A besluit-sourced kern block (carries provenance), so the panel re-seeds itself from it.
const negatiefScholingKern: Brief['sections'][number]['blocks'] = [ const negatiefScholingKern: Brief['sections'][number]['blocks'] = [
{ type: 'passage', blockId: 'local-1', sourcePassageId: 'p-kern-negatief', sourceVersion: 1, edited: false, content: text('Uw aanvraag is afgewezen.') }, {
{ type: 'passage', blockId: 'local-2', sourcePassageId: 'p-kern-scholing', sourceVersion: 1, edited: false, content: text('Onvoldoende scholing.') }, type: 'passage',
blockId: 'local-1',
sourcePassageId: 'p-kern-negatief',
sourceVersion: 1,
edited: false,
content: text('Uw aanvraag is afgewezen.'),
},
{
type: 'passage',
blockId: 'local-2',
sourcePassageId: 'p-kern-scholing',
sourceVersion: 1,
edited: false,
content: text('Onvoldoende scholing.'),
},
]; ];
/** Draft with a negatief besluit: the kern is filled from the selection and the besluit /** Draft with a negatief besluit: the kern is filled from the selection and the besluit
@@ -96,7 +161,12 @@ export const MaskedBigNummer: Story = {
export const Rejected: Story = { export const Rejected: Story = {
render: (args) => { render: (args) => {
const b = brief( const b = brief(
{ tag: 'rejected', rejectedBy: 'demo-approver', rejectedAt: '2026-07-01', comments: 'Graag de reden concreter.' }, {
tag: 'rejected',
rejectedBy: 'demo-approver',
rejectedAt: '2026-07-01',
comments: 'Graag de reden concreter.',
},
negatiefScholingKern, negatiefScholingKern,
); );
return { props: { ...args, brief: b, diagnostics: allDiagnostics(b), canSubmit: true } }; return { props: { ...args, brief: b, diagnostics: allDiagnostics(b), canSubmit: true } };
@@ -2,13 +2,49 @@ import type { Meta, StoryObj } from '@storybook/angular';
import { LibraryPassage } from '@brief/domain/brief'; import { LibraryPassage } from '@brief/domain/brief';
import { BesluitPanelComponent } from './besluit-panel.component'; import { BesluitPanelComponent } from './besluit-panel.component';
const text = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] }); const text = (t: string): LibraryPassage['content'] => ({
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
});
const passages: LibraryPassage[] = [ const passages: LibraryPassage[] = [
{ passageId: 'p-kern-positief', scope: 'global', sectionKey: 'kern', label: 'Toewijzing', version: 1, besluit: 'positief', content: text('Toegewezen.') }, {
{ passageId: 'p-kern-negatief', scope: 'global', sectionKey: 'kern', label: 'Afwijzing', version: 1, besluit: 'negatief', content: text('Afgewezen.') }, passageId: 'p-kern-positief',
{ passageId: 'p-kern-scholing', scope: 'global', sectionKey: 'kern', label: 'Onvoldoende scholing', version: 1, besluit: 'negatief', reason: 'onvoldoende_scholing', content: text('Onvoldoende scholing.') }, scope: 'global',
{ passageId: 'p-kern-gegevens', scope: 'global', sectionKey: 'kern', label: 'Onjuiste gegevens', version: 1, besluit: 'negatief', reason: 'onjuiste_gegevens', content: text('Onjuiste gegevens.') }, sectionKey: 'kern',
label: 'Toewijzing',
version: 1,
besluit: 'positief',
content: text('Toegewezen.'),
},
{
passageId: 'p-kern-negatief',
scope: 'global',
sectionKey: 'kern',
label: 'Afwijzing',
version: 1,
besluit: 'negatief',
content: text('Afgewezen.'),
},
{
passageId: 'p-kern-scholing',
scope: 'global',
sectionKey: 'kern',
label: 'Onvoldoende scholing',
version: 1,
besluit: 'negatief',
reason: 'onvoldoende_scholing',
content: text('Onvoldoende scholing.'),
},
{
passageId: 'p-kern-gegevens',
scope: 'global',
sectionKey: 'kern',
label: 'Onjuiste gegevens',
version: 1,
besluit: 'negatief',
reason: 'onjuiste_gegevens',
content: text('Onjuiste gegevens.'),
},
]; ];
const meta: Meta<BesluitPanelComponent> = { const meta: Meta<BesluitPanelComponent> = {
@@ -178,7 +178,9 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
(click)="zoomBy(0.1)" (click)="zoomBy(0.1)"
>+</app-button >+</app-button
> >
<app-button variant="subtle" (click)="zoomLevel.set(1)">{{ zoomResetLabel() }}</app-button> <app-button variant="subtle" (click)="zoomLevel.set(1)">{{
zoomResetLabel()
}}</app-button>
</div> </div>
@if (editableRegions() === 'none') { @if (editableRegions() === 'none') {
<app-button <app-button
@@ -399,7 +401,10 @@ export class LetterCanvasComponent {
protected editing = computed(() => this.editableRegions() === 'template'); protected editing = computed(() => this.editableRegions() === 'template');
protected emitEdit(field: OrgTemplateTextField, event: Event) { protected emitEdit(field: OrgTemplateTextField, event: Event) {
this.templateEdit.emit({ field, value: (event.target as HTMLInputElement | HTMLTextAreaElement).value }); this.templateEdit.emit({
field,
value: (event.target as HTMLInputElement | HTMLTextAreaElement).value,
});
} }
protected marginStyle = computed(() => { protected marginStyle = computed(() => {
@@ -227,7 +227,9 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
<ul class="history-list"> <ul class="history-list">
@for (v of history(); track v.version) { @for (v of history(); track v.version) {
<li class="history-row"> <li class="history-row">
<span>{{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}</span> <span
>{{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}</span
>
<app-button variant="subtle" [disabled]="busy()" (click)="rollback.emit(v.version)"> <app-button variant="subtle" [disabled]="busy()" (click)="rollback.emit(v.version)">
{{ rollbackLabel() }} {{ rollbackLabel() }}
</app-button> </app-button>
@@ -326,12 +328,15 @@ export class OrgTemplateEditorComponent {
} }
} }
protected impactText = computed(() => protected impactText = computed(
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`, () =>
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
); );
protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`); protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);
protected marginsLegend = input($localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`); protected marginsLegend = input(
$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`,
);
protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`); protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`);
protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`); protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);
protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`); protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`);
+6 -2
View File
@@ -61,8 +61,12 @@ import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-te
[saveText]="saveText()" [saveText]="saveText()"
[previewUrlFor]="previewUrlFor" [previewUrlFor]="previewUrlFor"
(selectSubOrg)="store.selectSubOrg($event)" (selectSubOrg)="store.selectSubOrg($event)"
(templateEdit)="store.edit({ tag: 'FieldEdited', field: $event.field, value: $event.value })" (templateEdit)="
(marginEdit)="store.edit({ tag: 'MarginEdited', edge: $event.edge, value: $event.value })" store.edit({ tag: 'FieldEdited', field: $event.field, value: $event.value })
"
(marginEdit)="
store.edit({ tag: 'MarginEdited', edge: $event.edge, value: $event.value })
"
(logoSelected)="store.onLogoSelected($event)" (logoSelected)="store.onLogoSelected($event)"
(logoRemoved)="store.onLogoRemoved($event)" (logoRemoved)="store.onLogoRemoved($event)"
(logoRetry)="store.onLogoRetry($event)" (logoRetry)="store.onLogoRetry($event)"
+1 -6
View File
@@ -1,9 +1,4 @@
import { import { DestroyRef, ENVIRONMENT_INITIALIZER, Injectable, inject } from '@angular/core';
DestroyRef,
ENVIRONMENT_INITIALIZER,
Injectable,
inject,
} from '@angular/core';
import { CanDeactivateFn } from '@angular/router'; import { CanDeactivateFn } from '@angular/router';
/** /**
+1 -5
View File
@@ -3,8 +3,4 @@
* Server-resolved and opaque to the FE never derived from a role client-side. * Server-resolved and opaque to the FE never derived from a role client-side.
*/ */
export type Capability = export type Capability =
| 'brief:approve' 'brief:approve' | 'brief:reject' | 'brief:send' | 'orgtemplate:edit' | 'stamdata:edit';
| 'brief:reject'
| 'brief:send'
| 'orgtemplate:edit'
| 'stamdata:edit';
+3 -1
View File
@@ -53,7 +53,9 @@ export function deepCopyBlock(block: RichTextBlock): RichTextBlock {
passages. Placeholders contribute their key so "naam" matches a `naam_zorgverlener` chip. */ passages. Placeholders contribute their key so "naam" matches a `naam_zorgverlener` chip. */
export function textOf(block: RichTextBlock): string { export function textOf(block: RichTextBlock): string {
return block.paragraphs return block.paragraphs
.flatMap((p) => p.nodes.map((n) => (n.type === 'text' ? n.text : n.type === 'placeholder' ? n.key : ''))) .flatMap((p) =>
p.nodes.map((n) => (n.type === 'text' ? n.text : n.type === 'placeholder' ? n.key : '')),
)
.join(' ') .join(' ')
.toLowerCase(); .toLowerCase();
} }
+7 -7
View File
@@ -108,13 +108,13 @@ the next person doesn't spend an afternoon re-deciding. (Deliberate CIBG-specifi
live in [CIBG gaps](?path=/docs/foundations-cibg-gap-register--docs); the FE⇄DS "same shape, different live in [CIBG gaps](?path=/docs/foundations-cibg-gap-register--docs); the FE⇄DS "same shape, different
context" cases in [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs).) context" cases in [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs).)
| Pair | Why kept separate | | Pair | Why kept separate |
| --- | --- | | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `choice-link` vs `application-link` | Share the same `to`/`clickable`/`activate` navigation triad, but bind **different vendored patterns** — CIBG _Keuzelijst_ (`.keuzelijst__link`, `.stretched-link`) vs _Aanvragen_ (`.dashboard-block.applications li a`) — with different list/host semantics (`app-choice-link` renders an inner `<li>`; `application-link` **is** the `<li>`). Merging would fight the vendored CSS. Extract the shared triad into a mixin only if it grows. | | `choice-link` vs `application-link` | Share the same `to`/`clickable`/`activate` navigation triad, but bind **different vendored patterns** — CIBG _Keuzelijst_ (`.keuzelijst__link`, `.stretched-link`) vs _Aanvragen_ (`.dashboard-block.applications li a`) — with different list/host semantics (`app-choice-link` renders an inner `<li>`; `application-link` **is** the `<li>`). Merging would fight the vendored CSS. Extract the shared triad into a mixin only if it grows. |
| `text-input` / `radio-group` / `checkbox` | Share only the standard Angular **ControlValueAccessor** boilerplate (the `writeValue`/`registerOn*`/`setDisabledState` block). They render genuinely different controls, so they stay three atoms. A base CVA class is the only DRY move — a refactor, not a component merge, and not worth it at three. | | `text-input` / `radio-group` / `checkbox` | Share only the standard Angular **ControlValueAccessor** boilerplate (the `writeValue`/`registerOn*`/`setDisabledState` block). They render genuinely different controls, so they stay three atoms. A base CVA class is the only DRY move — a refactor, not a component merge, and not worth it at three. |
| `button variant="subtle"` (`.btn-link`) vs `app-link` | A subtle button _looks_ like a link but is an **action** (`<button>`, emits click); `app-link` is **navigation** (`<a routerLink>`). Different semantics and a11y roles → keep both. | | `button variant="subtle"` (`.btn-link`) vs `app-link` | A subtle button _looks_ like a link but is an **action** (`<button>`, emits click); `app-link` is **navigation** (`<a routerLink>`). Different semantics and a11y roles → keep both. |
| `shell` / `page-shell` / `wizard-shell` | Three distinct jobs that **compose**, not overlap: persistent app chrome (mounted once) → routed page body → the wizard form/step frame. | | `shell` / `page-shell` / `wizard-shell` | Three distinct jobs that **compose**, not overlap: persistent app chrome (mounted once) → routed page body → the wizard form/step frame. |
| Raw `<h3>` in `application-link` vs the `heading` atom | The vendored `.applications li a h3` chain styles the **bare `<h3>`**; wrapping it in the `app-heading` host element would sit between the anchor and the h3 and can break that selector. This is the one sanctioned raw-heading; everywhere else uses `<app-heading [level]>`. | | Raw `<h3>` in `application-link` vs the `heading` atom | The vendored `.applications li a h3` chain styles the **bare `<h3>`**; wrapping it in the `app-heading` host element would sit between the anchor and the h3 and can break that selector. This is the one sanctioned raw-heading; everywhere else uses `<app-heading [level]>`. |
Single-consumer shared blocks (e.g. `placeholder-chip`, `rich-text-editor`, `checkbox`, the Single-consumer shared blocks (e.g. `placeholder-chip`, `rich-text-editor`, `checkbox`, the
`task-list`/`choice-list`/`choice-link` family) currently have one consumer each. They stay in `task-list`/`choice-list`/`choice-link` family) currently have one consumer each. They stay in
+13 -13
View File
@@ -5,10 +5,10 @@ import { Meta } from '@storybook/addon-docs/blocks';
# Behaviour-driven tests # Behaviour-driven tests
Tests here read as **specifications of behaviour**, not checks of implementation. A test Tests here read as **specifications of behaviour**, not checks of implementation. A test
says what the system *does* — in the domain's own words — so a failing test names a broken says what the system _does_ — in the domain's own words — so a failing test names a broken
behaviour, and the suite doubles as living documentation. This is the BDD half of the behaviour, and the suite doubles as living documentation. This is the BDD half of the
[Testing strategy](?path=/docs/foundations-testing-strategy--docs) (which owns *what to [Testing strategy](?path=/docs/foundations-testing-strategy--docs) (which owns _what to
test, by layer*); BDD owns *how each test is phrased and scoped*. test, by layer_); BDD owns _how each test is phrased and scoped_.
## Three rules ## Three rules
@@ -25,20 +25,20 @@ describe('parsePostcode', () => {
}); });
``` ```
Read top-to-bottom it *is* the spec: "parsePostcode — normalises to 1234 AB; rejects Read top-to-bottom it _is_ the spec: "parsePostcode — normalises to 1234 AB; rejects
malformed input." malformed input."
### 2. One behaviour per test ### 2. One behaviour per test
A test asserts **one behaviour**, not one `expect()`. Several assertions that pin down the A test asserts **one behaviour**, not one `expect()`. Several assertions that pin down the
*same* behaviour belong together; assertions about *different* behaviours belong apart. _same_ behaviour belong together; assertions about _different_ behaviours belong apart.
| Keep together (one behaviour) | Split apart (separate behaviours) | | Keep together (one behaviour) | Split apart (separate behaviours) |
| --- | --- | | -------------------------------------------------------------- | -------------------------------------------------------- |
| A `Result`'s `.ok` then its `.value` | The `ok` branch **and** the `err` branch of a transition | | A `Result`'s `.ok` then its `.value` | The `ok` branch **and** the `err` branch of a transition |
| A whole-object `toEqual` | An invalid-input case **and** a valid-input case | | A whole-object `toEqual` | An invalid-input case **and** a valid-input case |
| A loop asserting one rule over many inputs | Two independent state transitions | | A loop asserting one rule over many inputs | Two independent state transitions |
| A truth-table (`draft` → true, `approver` → false) of one rule | An authorization check **and** a rendering check | | A truth-table (`draft` → true, `approver` → false) of one rule | An authorization check **and** a rendering check |
A title that needs `/`, `;`, "then" or "and" to join two behaviours is the smell — split it, A title that needs `/`, `;`, "then" or "and" to join two behaviours is the smell — split it,
and each half gets its own present-tense name. and each half gets its own present-tense name.
@@ -46,8 +46,8 @@ and each half gets its own present-tense name.
### 3. Speak the ubiquitous language (the DDD tie-in) ### 3. Speak the ubiquitous language (the DDD tie-in)
Test names use the **domain vocabulary**, not technical jargon — the same words as the Test names use the **domain vocabulary**, not technical jargon — the same words as the
[bounded contexts](?path=/docs/foundations-domain-driven-design--docs): a *behandelaar* [bounded contexts](?path=/docs/foundations-domain-driven-design--docs): a _behandelaar_
drafts, a *beoordelaar* approves, a *herregistratie* is *ingediend*. The test name is drafts, a _beoordelaar_ approves, a _herregistratie_ is _ingediend_. The test name is
readable by someone who knows the domain but not the code. readable by someone who knows the domain but not the code.
```ts ```ts
+8 -8
View File
@@ -11,13 +11,13 @@ third-party i18n library. The source locale is **`nl`**; a second locale is a
## How it's wired ## How it's wired
| Piece | Where | What | | Piece | Where | What |
| --- | --- | --- | | -------------------------- | ----------------------------------------- | ------------------------------------------------------------ |
| Source locale | `angular.json` → `i18n.sourceLocale` | `nl` — the language the code is written in | | Source locale | `angular.json` → `i18n.sourceLocale` | `nl` — the language the code is written in |
| Locales | `angular.json` → `i18n.locales.en` | points at `src/locale/messages.en.xlf` | | Locales | `angular.json` → `i18n.locales.en` | points at `src/locale/messages.en.xlf` |
| Missing-translation policy | `angular.json` → `i18nMissingTranslation` | `error` — a missing `<target>` fails the build | | Missing-translation policy | `angular.json` → `i18nMissingTranslation` | `error` — a missing `<target>` fails the build |
| Runtime global | `angular.json` → `polyfills` | `@angular/localize/init` provides `$localize` | | Runtime global | `angular.json` → `polyfills` | `@angular/localize/init` provides `$localize` |
| English build/serve | `angular.json` → `configurations.en` | `ng build --configuration=en`, `ng serve --configuration=en` | | English build/serve | `angular.json` → `configurations.en` | `ng build --configuration=en`, `ng serve --configuration=en` |
Locale switching is **build-time**, not runtime: each locale is its own bundle. There is Locale switching is **build-time**, not runtime: each locale is its own bundle. There is
no in-app language picker (out of scope for the POC). no in-app language picker (out of scope for the POC).
@@ -70,7 +70,7 @@ forgotten target breaks the `en` build rather than silently shipping Dutch.
**Rule: never assert on rendered copy.** Copy is the thing that changes per locale and per **Rule: never assert on rendered copy.** Copy is the thing that changes per locale and per
edit — a test that reads `"Voer een geldige postcode in"` breaks the moment a translator or edit — a test that reads `"Voer een geldige postcode in"` breaks the moment a translator or
a product owner touches the wording, in every locale. Assert on what's *invariant* instead: a product owner touches the wording, in every locale. Assert on what's _invariant_ instead:
- **Parsers / value objects** — assert on the `Result` discriminant and the parsed value, - **Parsers / value objects** — assert on the `Result` discriminant and the parsed value,
not the error message. This is the existing house pattern not the error message. This is the existing house pattern
+22 -22
View File
@@ -6,7 +6,7 @@ import { Meta } from '@storybook/addon-docs/blocks';
A paced, hands-on route through this codebase for a developer who is a **strong A paced, hands-on route through this codebase for a developer who is a **strong
programmer but new to frontend functional programming**. The [Overview](?path=/docs/foundations-overview--docs) programmer but new to frontend functional programming**. The [Overview](?path=/docs/foundations-overview--docs)
is the *map* — every idea, cross-linked. This is the *route*: what to read first, what is the _map_ — every idea, cross-linked. This is the _route_: what to read first, what
to **do** to make it stick, and how to know you understood it. Work through it over to **do** to make it stick, and how to know you understood it. Work through it over
roughly three days. roughly three days.
@@ -33,7 +33,7 @@ below is a way to stop the compiler letting you build a state that can't actuall
**Goal:** name the failure mode this whole architecture is designed to prevent. **Goal:** name the failure mode this whole architecture is designed to prevent.
Most UI bugs are not wrong algorithms — they're **impossible states that the types Most UI bugs are not wrong algorithms — they're **impossible states that the types
allowed anyway**. `isLoading` true *and* `error` set *and* `data` present: three allowed anyway**. `isLoading` true _and_ `error` set _and_ `data` present: three
booleans give eight combinations, but only four are real. The extra four are bugs booleans give eight combinations, but only four are real. The extra four are bugs
waiting to be rendered. The reflex this codebase trains: when you reach for a second or waiting to be rendered. The reflex this codebase trains: when you reach for a second or
third boolean to track one thing, model a **discriminated union** instead, so the third boolean to track one thing, model a **discriminated union** instead, so the
@@ -61,13 +61,13 @@ Code is organised first by **bounded context** — a business capability with it
language: `shared`, `auth`, `registratie`, `herregistratie`, `brief`, `showcase`. Inside language: `shared`, `auth`, `registratie`, `herregistratie`, `brief`, `showcase`. Inside
each context are five **layers**, and dependencies only ever point **inward**: each context are five **layers**, and dependencies only ever point **inward**:
| Layer | Job | Angular? | | Layer | Job | Angular? |
| --- | --- | --- | | ----------------- | ----------------------------------------- | -------------------------------- |
| `domain/` | business rules + data types | **No — pure TS**, has `.spec.ts` | | `domain/` | business rules + data types | **No — pure TS**, has `.spec.ts` |
| `application/` | coordinate state/tasks (stores, commands) | yes (signals) | | `application/` | coordinate state/tasks (stores, commands) | yes (signals) |
| `infrastructure/` | where data comes from (HTTP) | yes | | `infrastructure/` | where data comes from (HTTP) | yes |
| `contracts/` | wire DTOs (the FE⇄BE seam) | no | | `contracts/` | wire DTOs (the FE⇄BE seam) | no |
| `ui/` | how it looks | yes | | `ui/` | how it looks | yes |
`ui → application → domain`, never the reverse; `ui` never touches `infrastructure` `ui → application → domain`, never the reverse; `ui` never touches `infrastructure`
directly. Cross-context is one-directional too: `herregistratie → registratie → shared`, directly. Cross-context is one-directional too: `herregistratie → registratie → shared`,
@@ -120,7 +120,7 @@ Four tools do the heavy lifting. **Pure functions:** output depends only on inpu
side effects — trivially testable, no mocks. **Immutability:** you compute new values, side effects — trivially testable, no mocks. **Immutability:** you compute new values,
you don't mutate old ones, so nothing changes under you. **Unidirectional flow:** data you don't mutate old ones, so nothing changes under you. **Unidirectional flow:** data
moves one way (state → view → message → new state), never a tangle of two-way bindings. moves one way (state → view → message → new state), never a tangle of two-way bindings.
**Sum and product types:** a *product* is "A and B" (a record); a *sum* is "A **or** B" **Sum and product types:** a _product_ is "A and B" (a record); a _sum_ is "A **or** B"
(a discriminated union) — sums are how you make illegal states unrepresentable. (a discriminated union) — sums are how you make illegal states unrepresentable.
Put together: the **functional core** is pure logic (all of `domain/`, the reducers, the Put together: the **functional core** is pure logic (all of `domain/`, the reducers, the
@@ -149,13 +149,13 @@ returns the next model, the view re-renders. All wiring goes through one idiom,
The rule that keeps `reduce` pure: **side effects live in commands, not the reducer.** A The rule that keeps `reduce` pure: **side effects live in commands, not the reducer.** A
command (`application/submit-*.ts`) does the HTTP, then dispatches a message describing command (`application/submit-*.ts`) does the HTTP, then dispatches a message describing
the *outcome*. Reducer = "what the new state is"; command = "go do it, then say what the _outcome_. Reducer = "what the new state is"; command = "go do it, then say what
happened." And **derive, don't store** anything you can compute — e.g. a wizard's visible happened." And **derive, don't store** anything you can compute — e.g. a wizard's visible
steps are `visibleSteps(answers)`, not a stored field. steps are `visibleSteps(answers)`, not a stored field.
A field's value lands in the Model on **every keystroke** (not on blur — blur only marks A field's value lands in the Model on **every keystroke** (not on blur — blur only marks
the field "touched"); a separate 600 ms debounce off the model snapshot autosaves the the field "touched"); a separate 600 ms debounce off the model snapshot autosaves the
draft to the backend, an effect that lives *outside* the reducer. See draft to the backend, an effect that lives _outside_ the reducer. See
`docs/reference/architecture/ARCHITECTURE.md` §2g. `docs/reference/architecture/ARCHITECTURE.md` §2g.
**Do:** run `/form-machine` for a toy single field (say a "nickname" field with a max **Do:** run `/form-machine` for a toy single field (say a "nickname" field with a max
@@ -172,7 +172,7 @@ does?
**Goal:** replace loading/error/empty booleans with one four-state value. **Goal:** replace loading/error/empty booleans with one four-state value.
`RemoteData<E,T>` is a sum type with exactly four cases: `Loading | Empty | `RemoteData<E,T>` is a sum type with exactly four cases: `Loading | Empty |
Failure{error} | Success{value}`. That's the four *real* states from lesson 1.1, and no Failure{error} | Success{value}`. That's the four _real_ states from lesson 1.1, and no
others — you literally cannot construct "loading and error." Combine sources with others — you literally cannot construct "loading and error." Combine sources with
`map` / `map2` / `andThen` (precedence: Failure > Loading > Empty > Success), and render `map` / `map2` / `andThen` (precedence: Failure > Loading > Empty > Success), and render
it with the `<app-async>` molecule, which picks one of four mutually-exclusive templates it with the `<app-async>` molecule, which picks one of four mutually-exclusive templates
@@ -202,12 +202,12 @@ boundary `parse*` adapters in `infrastructure/` (the FE⇄BE seam, where untrust
becomes domain types). "Validate" scatters `if`-checks everywhere and forgets one; becomes domain types). "Validate" scatters `if`-checks everywhere and forgets one;
"parse" concentrates the check at the door and lets the compiler enforce the rest. "parse" concentrates the check at the door and lets the compiler enforce the rest.
**Why "brand"?** TypeScript is *structurally* typed, so a bare `type Postcode = string` **Why "brand"?** TypeScript is _structurally_ typed, so a bare `type Postcode = string`
would accept any string and lose all proof of validation. Intersecting a phantom marker — would accept any string and lose all proof of validation. Intersecting a phantom marker —
`string & { readonly __brand: 'Postcode' }` — makes the type **nominal**: no plain string `string & { readonly __brand: 'Postcode' }` — makes the type **nominal**: no plain string
satisfies it, so the only way to hold a `Postcode` is to go through the parser that stamps satisfies it, so the only way to hold a `Postcode` is to go through the parser that stamps
the brand. The brand is compile-time proof the value was validated (it exists only in the the brand. The brand is compile-time proof the value was validated (it exists only in the
types, never at runtime). The DDD name for the concept is a *value object*; "brand" is just types, never at runtime). The DDD name for the concept is a _value object_; "brand" is just
the TypeScript trick that makes it enforceable. the TypeScript trick that makes it enforceable.
**Do:** run `/value-object` for a small field (e.g. a Dutch phone number). Read the parser **Do:** run `/value-object` for a small field (e.g. a Dutch phone number). Read the parser
@@ -223,7 +223,7 @@ re-validate its format? Why not?
You've now seen it twice: **small honest pieces, each only allowed to reach one level You've now seen it twice: **small honest pieces, each only allowed to reach one level
down, with illegal combinations forbidden by structure.** Atomic design applies it to down, with illegal combinations forbidden by structure.** Atomic design applies it to
*components* (atoms compose upward); The Elm Architecture applies it to *state* (pure _components_ (atoms compose upward); The Elm Architecture applies it to _state_ (pure
`reduce` composes messages into models). They are the same principle at two scales — that `reduce` composes messages into models). They are the same principle at two scales — that
is the thesis of this codebase. Read `docs/reference/fp-tea-atomic-design.md` Part 5; it's is the thesis of this codebase. Read `docs/reference/fp-tea-atomic-design.md` Part 5; it's
the "aha" that ties Day 1 and Day 2 together. the "aha" that ties Day 1 and Day 2 together.
@@ -258,11 +258,11 @@ which gets a Storybook story instead?
`describe` names the subject; each `it` states **one observable behaviour** in `describe` names the subject; each `it` states **one observable behaviour** in
present tense — no `should`, no Given/When/Then ceremony. One behaviour per test means one present tense — no `should`, no Given/When/Then ceremony. One behaviour per test means one
*behaviour*, not one `expect`: assertions pinning down the same behaviour stay together _behaviour_, not one `expect`: assertions pinning down the same behaviour stay together
(a `Result`'s `.ok` then its `.value`); assertions about different behaviours split apart (a `Result`'s `.ok` then its `.value`); assertions about different behaviours split apart
(the ok branch **and** the err branch). If a title needs "and"/"then"/"/" to join two (the ok branch **and** the err branch). If a title needs "and"/"then"/"/" to join two
things, that's the smell — split it. And speak the **ubiquitous language**: a *behandelaar* things, that's the smell — split it. And speak the **ubiquitous language**: a _behandelaar_
drafts, a *beoordelaar* approves — the same words as the bounded contexts. drafts, a _beoordelaar_ approves — the same words as the bounded contexts.
**Do:** read `registratie/domain/registratie-wizard.machine.spec.ts` — one transition per **Do:** read `registratie/domain/registratie-wizard.machine.spec.ts` — one transition per
test, each named as a behaviour. (You saw this style get enforced when the specs were test, each named as a behaviour. (You saw this style get enforced when the specs were
@@ -338,7 +338,7 @@ live swatches; then skim the [CIBG gap register](?path=/docs/foundations-cibg-ga
Two framing ideas first. **BFF-lite + decision DTOs (ADR-0001):** each screen gets one Two framing ideas first. **BFF-lite + decision DTOs (ADR-0001):** each screen gets one
screen-shaped endpoint returning a **decision-enriched** DTO — the backend computes the screen-shaped endpoint returning a **decision-enriched** DTO — the backend computes the
business rules, and **the FE renders decisions, it does not recompute them.** Per rule you business rules, and **the FE renders decisions, it does not recompute them.** Per rule you
pick a *decision flag* (server sends the boolean) or a *config value* (server sends the pick a _decision flag_ (server sends the boolean) or a _config value_ (server sends the
threshold, FE applies it for instant feedback, server re-validates as authority). The FE threshold, FE applies it for instant feedback, server re-validates as authority). The FE
keeps only **format** validation, never as authority. keeps only **format** validation, never as authority.
@@ -358,8 +358,8 @@ skills in pipeline order:
`/new-feature` walks the whole pipeline if you'd rather do it in one guided pass. `/new-feature` walks the whole pipeline if you'd rather do it in one guided pass.
**Check yourself:** for your slice, name for each business rule whether it's a *decision **Check yourself:** for your slice, name for each business rule whether it's a _decision
flag* or a *config value*, and which layer owns it. If a rule lives in two layers, which flag_ or a _config value_, and which layer owns it. If a rule lives in two layers, which
one is the **authority**? one is the **authority**?
**Go deeper:** `docs/reference/architecture/0001-bff-lite-decision-dtos.md`; **Go deeper:** `docs/reference/architecture/0001-bff-lite-decision-dtos.md`;
+1 -1
View File
@@ -15,7 +15,7 @@ cross-linked take on one idea; the long-form source lives in `docs/reference/`
## Architecture & domain ## Architecture & domain
- [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs) — bounded - [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs) — bounded
contexts + layers, dependencies pointing inward (the folder structure *is* the architecture). contexts + layers, dependencies pointing inward (the folder structure _is_ the architecture).
- [Atomic design](?path=/docs/foundations-atomic-design--docs) — Atoms → Molecules → - [Atomic design](?path=/docs/foundations-atomic-design--docs) — Atoms → Molecules →
Organisms → Templates; a new page is composition, not new building blocks. Organisms → Templates; a new page is composition, not new building blocks.
+11 -11
View File
@@ -6,21 +6,21 @@ import { Meta } from '@storybook/addon-docs/blocks';
Tests follow the same grain as the architecture: **push the logic down to where it's pure, Tests follow the same grain as the architecture: **push the logic down to where it's pure,
test it there directly, and keep the layers above thin.** No single tool covers everything, test it there directly, and keep the layers above thin.** No single tool covers everything,
so each layer gets the cheapest test that catches its class of bug. This page owns *what to so each layer gets the cheapest test that catches its class of bug. This page owns _what to
test, by layer*; how each test is **phrased and scoped** — one behaviour, in the domain's test, by layer_; how each test is **phrased and scoped** — one behaviour, in the domain's
language — is [BDD](?path=/docs/foundations-bdd--docs). language — is [BDD](?path=/docs/foundations-bdd--docs).
## What gets tested where ## What gets tested where
| Layer | Test kind | Tool | Rule | | Layer | Test kind | Tool | Rule |
| --- | --- | --- | --- | | -------------------------- | ------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `domain/` | pure-function unit spec | Vitest | **Required.** Call the function directly — **no `TestBed`**. Reducers, combinators, `visibleSteps`, parsers, policies. | | `domain/` | pure-function unit spec | Vitest | **Required.** Call the function directly — **no `TestBed`**. Reducers, combinators, `visibleSteps`, parsers, policies. |
| `domain/value-objects/` | parser spec | Vitest | Happy path + normalisation + **each** rejection. Assert on the `Result`, never the message. | | `domain/value-objects/` | parser spec | Vitest | Happy path + normalisation + **each** rejection. Assert on the `Result`, never the message. |
| `infrastructure/` `parse*` | trust-boundary spec | Vitest | Accept a valid DTO; **reject `null` / `{}` / malformed**. Name it `describe('… (trust boundary)')`. | | `infrastructure/` `parse*` | trust-boundary spec | Vitest | Accept a valid DTO; **reject `null` / `{}` / malformed**. Name it `describe('… (trust boundary)')`. |
| `application/` | store / command spec | Vitest | Reducer purity, optimistic begin→confirm/rollback, command `Result`. | | `application/` | store / command spec | Vitest | Reducer purity, optimistic begin→confirm/rollback, command `Result`. |
| `ui/` | Storybook story | Storybook + a11y | Kept thin. Axe runs on every story; add a `play` only for wiring axe can't see. | | `ui/` | Storybook story | Storybook + a11y | Kept thin. Axe runs on every story; add a `play` only for wiring axe can't see. |
| flows | e2e smoke | Playwright | One happy path + one error state per critical journey. | | flows | e2e smoke | Playwright | One happy path + one error state per critical journey. |
| backend | rule + endpoint + golden | xUnit | Mirror of the FE domain rules, plus `WebApplicationFactory` integration. | | backend | rule + endpoint + golden | xUnit | Mirror of the FE domain rules, plus `WebApplicationFactory` integration. |
## Tooling ## Tooling