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
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"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.
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 →
Overview*. The `reference/` docs are the long-form source; the Foundations pages are the
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
condensed, cross-linked curriculum.
## `reference/` — information
| 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/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/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). |
| [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). |
| [ui-ux-audit.md](reference/ui-ux-audit.md) | Early UI/UX audit against NL Design System (predates ADR-0003 — read in that light). |
| 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/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/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). |
| [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). |
| [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
| Doc | What it is |
| --- | --- |
| [backlog/README.md](project/backlog/README.md) | The work-package backlog index (WP-01…WP-28) — the live tracker. |
| Doc | What it is |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| [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/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. |
| [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. |
+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
for its existing violations, so every WP ends green.
| WP | Title | Phase | Status |
| --------------------------------------- | ------------------------------------------------------------ | --------------------------- | ------ |
| [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-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done |
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | 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-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-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-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-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-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-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-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 | Title | Phase | Status |
| ---------------------------------------- | ------------------------------------------------------------ | --------------------------- | ------ |
| [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-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | done |
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | done |
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | 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-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-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-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-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-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-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-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 |
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
@@ -4,6 +4,7 @@ Status: done (7ec13d8)
Phase: 5 — productie-volwassenheid
> **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
> (`Authz.CanRevealBigNummer` + `BriefDecisionsDto.CanRevealBigNummer`); a
> 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
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.
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
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
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
canvas class vocabulary exactly, inlines `public/letter.css` from disk, inlines the
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)
- **`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
one) and marked the paper drop-shadow `token-ok`; also fixed a pre-existing
`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=`
(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).
- **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
@@ -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.)
- **Data-file format for `professions`.** `professions.json` (embedded resource) replaces the
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.
- **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`
@@ -138,7 +138,7 @@ canonical case (art. 9 / special-category data):
**audited** (§8).
> **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
> is realized there (`canRevealBigNummer`, `POST /brief/reveal-bignummer`) — the on-the-wire
> 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
second locale is a translation file, not a code change. That is already the compile-time
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
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
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
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
`StamdataValidationTests`. Separate the **data** (what the business tunes) from the **rules**
(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
(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
**valid-time** (`geldigVan`/`geldigTot`, half-open `[van, tot)`); `StamdataCatalog` +
`StamdataTable.Of<T>` describe every table generically (columns reflected from the record)
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
| 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) |
| 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 |
| 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 |
### The deliberate exception: org-templates
Per-organization letterhead (return address, footer, signature, margins) **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
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.
+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.
canActivate: [capabilityGuard('orgtemplate:edit')],
canDeactivate: [flushPendingGuard],
loadComponent: () =>
import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage),
loadComponent: () => import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage),
},
{
path: 'beheer/stamdata',
@@ -47,11 +47,17 @@ describe('stamdata-editor reduce', () => {
it('edit messages are ignored unless loaded', () => {
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', () => {
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' });
});
});
+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);
});
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'), '2019-12-31')).toBe(true);
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'), '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 });
});
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', () => {
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: [] },
],
};
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) {
const cell = (row[c.name] ?? '').trim();
out[c.name] =
cell === '' ? (c.type === 'text' || c.type === 'enum' ? '' : null)
: c.type === 'number' ? Number(cell)
: cell;
cell === ''
? c.type === 'text' || c.type === 'enum'
? ''
: null
: c.type === 'number'
? Number(cell)
: cell;
}
return out;
});
@@ -30,7 +30,11 @@ describe('parseStamdataTable', () => {
});
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;
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 ?? [] });
}
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))
return err('stamdata table: bad shape');
const columns: StamColumn[] = [];
@@ -1,12 +1,6 @@
import { Component, computed, input, output } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component';
import {
ChangeCounts,
StamColumn,
StamRow,
StamTable,
activeOn,
} from '@beheer/domain/stamdata';
import { ChangeCounts, StamColumn, StamRow, StamTable, activeOn } from '@beheer/domain/stamdata';
interface DisplayRow {
row: StamRow;
@@ -94,7 +88,9 @@ interface DisplayRow {
/>
</div>
@if (previewing()) {
<app-button variant="subtle" (click)="previewDateChanged.emit('')">{{ showAll }}</app-button>
<app-button variant="subtle" (click)="previewDateChanged.emit('')">{{
showAll
}}</app-button>
}
}
</div>
@@ -123,7 +119,9 @@ interface DisplayRow {
[value]="item.row[col.name]"
[disabled]="previewing()"
[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>
@for (opt of col.options; track opt) {
@@ -138,7 +136,9 @@ interface DisplayRow {
[class.is-invalid]="!!errors()[item.index]"
[disabled]="previewing()"
[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>
@@ -17,7 +17,12 @@ const table: StamTable = {
const rows: StamRow[] = [
{ program: 'geneeskunde', beroep: 'Arts', 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> = {
@@ -48,11 +53,14 @@ export const Dirty: Story = {
export const Invalid: Story = {
args: {
rows: [{ 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)],
),
rows: [
{ 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({
selector: 'app-stamdata-page',
imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, StamdataTableEditorComponent],
imports: [
PageShellComponent,
AlertComponent,
ButtonComponent,
...ASYNC,
StamdataTableEditorComponent,
],
template: `
<app-page-shell [heading]="heading" [intro]="intro" backLink="/dashboard">
@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 ---
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[]) => ({
sectionKey: 'kern',
@@ -152,7 +156,8 @@ function loadedBrief(store: BriefStore): Brief {
}
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 });
await store.load();
return store;
@@ -203,12 +208,16 @@ describe('BriefStore undo/redo history', () => {
describe('BriefStore rejection diff', () => {
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 = {
...filledBrief,
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({
load: () => ok({ ...filledView, brief: submitted }),
save: () => ok(filledView),
@@ -272,7 +281,10 @@ describe('BriefStore.revealBigNummer (PRD-0002 §5c)', () => {
afterEach(() => vi.restoreAllMocks());
// 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 () => {
const store = setup({ load: () => Promise.resolve({ ok: true, value: maskedView }) });
@@ -88,9 +88,12 @@ export class OrgTemplateStore implements PendingSave {
readonly draftValid = computed(() => {
const d = this.draft();
if (!d) return false;
const marginsOk = [d.margins.topMm, d.margins.rightMm, d.margins.bottomMm, d.margins.leftMm].every(
(v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM,
);
const marginsOk = [
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;
});
@@ -107,7 +110,10 @@ export class OrgTemplateStore implements PendingSave {
if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;
const status = this.categoriesRes.status();
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).
registerPendingSave(this);
@@ -259,8 +265,9 @@ export class OrgTemplateStore implements PendingSave {
fileName: file.name,
fileSizeMb: file.size / 1e6,
});
this.shell.upload({ localId, categoryId: cat.categoryId, wizardId: 'org-template', file }, (m) =>
this.onUploadMsg(m),
this.shell.upload(
{ 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 { 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 => ({
passageId: over.passageId ?? 'x',
@@ -18,8 +20,18 @@ const lib: LibraryPassage[] = [
p({ passageId: 'intro', besluit: undefined }), // shared, any besluit
p({ passageId: 'pos', besluit: 'positief' }),
p({ passageId: 'neg', besluit: 'negatief' }),
p({ 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: '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
];
@@ -35,17 +47,24 @@ describe('passagesForBesluit', () => {
});
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']);
});
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']);
});
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);
if (!source) continue;
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 };
}
+5 -1
View File
@@ -3,7 +3,11 @@ import { Brief, LetterBlock } from './brief';
import { diffBlocks, changedBlocks } from './brief-diff';
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 {
@@ -24,7 +24,8 @@ const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView =>
...over,
});
const loaded = (): OrgTemplateState => reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
const loaded = (): OrgTemplateState =>
reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
const logoCategory: DocumentCategory = {
categoryId: 'org-logo',
@@ -84,7 +85,9 @@ describe('org-template.machine', () => {
});
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',
});
});
@@ -96,7 +99,13 @@ describe('org-template.machine', () => {
});
const selected = reduce(withCat, {
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, {
tag: 'Upload',
+7 -5
View File
@@ -50,10 +50,7 @@ export type OrgTemplateMsg =
| { tag: 'Upload'; msg: UploadMsg };
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */
function editDraft(
s: OrgTemplateState,
f: (draft: OrgTemplate) => OrgTemplate,
): OrgTemplateState {
function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState {
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);
// A completed/removed logo upload also updates the draft's logoDocumentId.
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') {
const { logoDocumentId: _dropped, ...rest } = s.draft;
return { ...s, upload, draft: rest, dirty: true };
@@ -62,7 +62,13 @@ const view: BriefViewDto = {
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: {
subOrgId: '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', () => {
expect(parseBriefView({ ...view, caseContext: undefined }).ok).toBe(false);
expect(
parseBriefView({ ...view, caseContext: { ...view.caseContext!, bigNummer: undefined as never } })
.ok,
parseBriefView({
...view,
caseContext: { ...view.caseContext!, bigNummer: undefined as never },
}).ok,
).toBe(false);
});
@@ -47,7 +47,9 @@ describe('parseOrgTemplateAdminView', () => {
it('rejects a malformed history entry', () => {
const r = parseOrgTemplateAdminView({
...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);
});
@@ -65,10 +65,7 @@ export class OrgTemplateAdapter {
return r.ok ? parsePublish(r.value) : r;
}
async rollback(
subOrgId: string,
version: number,
): Promise<Result<string, OrgTemplateAdminView>> {
async rollback(subOrgId: string, version: number): Promise<Result<string, OrgTemplateAdminView>> {
const r = await runSubmit(() => this.client.orgTemplateRollback(subOrgId, version), FAILED);
return r.ok ? parseAdminView(r.value) : r;
}
@@ -31,7 +31,11 @@ export class RevealBigNummerAdapter {
if (!res.ok) return err(await errorMessage(res));
const body: unknown = await res.json().catch(() => null);
// 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 err(REVEAL_FAILED);
@@ -126,12 +126,9 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com
<div class="bar">
<app-button variant="subtle" (click)="openPreview()">{{ previewLabel() }}</app-button>
<app-button
variant="primary"
[disabled]="!canSubmit() || busy()"
(click)="submit.emit()"
>{{ submitLabel() }}</app-button
>
<app-button variant="primary" [disabled]="!canSubmit() || busy()" (click)="submit.emit()">{{
submitLabel()
}}</app-button>
@if (!canSubmit()) {
<span class="app-text-subtle">{{ submitHint() }}</span>
}
@@ -149,7 +146,9 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com
/>
</div>
<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>
</div>
</dialog>
@@ -1,9 +1,17 @@
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 { 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 = {
subOrgId: 'cibg-registers',
@@ -26,9 +34,34 @@ const caseContext: CaseContext = {
};
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-scholing', scope: 'global', sectionKey: 'kern', label: 'Onvoldoende scholing', version: 1, besluit: 'negatief', reason: 'onvoldoende_scholing', content: text('Onvoldoende scholing.') },
{
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-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 {
@@ -43,9 +76,27 @@ function brief(status: BriefStatus, kernBlocks: Brief['sections'][number]['block
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
],
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: 'slot', title: 'Slot', required: false, locked: true, blocks: [{ type: 'freeText', blockId: 'slot-1', content: text('Met vriendelijke groet,') }] },
{
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: '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.
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
@@ -96,7 +161,12 @@ export const MaskedBigNummer: Story = {
export const Rejected: Story = {
render: (args) => {
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,
);
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 { 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[] = [
{ 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-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.') },
{
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-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> = {
@@ -178,7 +178,9 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
(click)="zoomBy(0.1)"
>+</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>
@if (editableRegions() === 'none') {
<app-button
@@ -399,7 +401,10 @@ export class LetterCanvasComponent {
protected editing = computed(() => this.editableRegions() === 'template');
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(() => {
@@ -227,7 +227,9 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
<ul class="history-list">
@for (v of history(); track v.version) {
<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)">
{{ rollbackLabel() }}
</app-button>
@@ -326,12 +328,15 @@ export class OrgTemplateEditorComponent {
}
}
protected impactText = computed(() =>
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
protected impactText = computed(
() =>
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
);
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 historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);
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()"
[previewUrlFor]="previewUrlFor"
(selectSubOrg)="store.selectSubOrg($event)"
(templateEdit)="store.edit({ tag: 'FieldEdited', field: $event.field, value: $event.value })"
(marginEdit)="store.edit({ tag: 'MarginEdited', edge: $event.edge, value: $event.value })"
(templateEdit)="
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)"
(logoRemoved)="store.onLogoRemoved($event)"
(logoRetry)="store.onLogoRetry($event)"
+1 -6
View File
@@ -1,9 +1,4 @@
import {
DestroyRef,
ENVIRONMENT_INITIALIZER,
Injectable,
inject,
} from '@angular/core';
import { DestroyRef, ENVIRONMENT_INITIALIZER, Injectable, inject } from '@angular/core';
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.
*/
export type Capability =
| 'brief:approve'
| 'brief:reject'
| 'brief:send'
| 'orgtemplate:edit'
| 'stamdata:edit';
'brief:approve' | '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. */
export function textOf(block: RichTextBlock): string {
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(' ')
.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
context" cases in [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs).)
| 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. |
| `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. |
| `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]>`. |
| 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. |
| `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. |
| `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]>`. |
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
+13 -13
View File
@@ -5,10 +5,10 @@ import { Meta } from '@storybook/addon-docs/blocks';
# Behaviour-driven tests
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
[Testing strategy](?path=/docs/foundations-testing-strategy--docs) (which owns *what to
test, by layer*); BDD owns *how each test is phrased and scoped*.
[Testing strategy](?path=/docs/foundations-testing-strategy--docs) (which owns _what to
test, by layer_); BDD owns _how each test is phrased and scoped_.
## 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."
### 2. One behaviour per test
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) |
| --- | --- |
| 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 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 |
| 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 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 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,
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)
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*
drafts, a *beoordelaar* approves, a *herregistratie* is *ingediend*. The test name is
[bounded contexts](?path=/docs/foundations-domain-driven-design--docs): a _behandelaar_
drafts, a _beoordelaar_ approves, a _herregistratie_ is _ingediend_. The test name is
readable by someone who knows the domain but not the code.
```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
| Piece | Where | What |
| --- | --- | --- |
| 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` |
| Missing-translation policy | `angular.json` → `i18nMissingTranslation` | `error` — a missing `<target>` fails the build |
| 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` |
| Piece | Where | What |
| -------------------------- | ----------------------------------------- | ------------------------------------------------------------ |
| 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` |
| Missing-translation policy | `angular.json` → `i18nMissingTranslation` | `error` — a missing `<target>` fails the build |
| 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` |
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).
@@ -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
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,
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
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
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.
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
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
@@ -61,13 +61,13 @@ Code is organised first by **bounded context** — a business capability with it
language: `shared`, `auth`, `registratie`, `herregistratie`, `brief`, `showcase`. Inside
each context are five **layers**, and dependencies only ever point **inward**:
| Layer | Job | Angular? |
| --- | --- | --- |
| `domain/` | business rules + data types | **No — pure TS**, has `.spec.ts` |
| `application/` | coordinate state/tasks (stores, commands) | yes (signals) |
| `infrastructure/` | where data comes from (HTTP) | yes |
| `contracts/` | wire DTOs (the FE⇄BE seam) | no |
| `ui/` | how it looks | yes |
| Layer | Job | Angular? |
| ----------------- | ----------------------------------------- | -------------------------------- |
| `domain/` | business rules + data types | **No — pure TS**, has `.spec.ts` |
| `application/` | coordinate state/tasks (stores, commands) | yes (signals) |
| `infrastructure/` | where data comes from (HTTP) | yes |
| `contracts/` | wire DTOs (the FE⇄BE seam) | no |
| `ui/` | how it looks | yes |
`ui → application → domain`, never the reverse; `ui` never touches `infrastructure`
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,
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.
**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.
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
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
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
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.
**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.
`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
`map` / `map2` / `andThen` (precedence: Failure > Loading > Empty > Success), and render
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;
"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 —
`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
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.
**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
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
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.
@@ -258,11 +258,11 @@ which gets a Storybook story instead?
`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
*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
(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*
drafts, a *beoordelaar* approves — the same words as the bounded contexts.
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.
**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
@@ -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
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
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
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.
**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
**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
one is the **authority**?
**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
- [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 →
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,
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
test, by layer*; how each test is **phrased and scoped** — one behaviour, in the domain's
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
language — is [BDD](?path=/docs/foundations-bdd--docs).
## What gets tested where
| Layer | Test kind | Tool | Rule |
| --- | --- | --- | --- |
| `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. |
| `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`. |
| `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. |
| backend | rule + endpoint + golden | xUnit | Mirror of the FE domain rules, plus `WebApplicationFactory` integration. |
| Layer | Test kind | Tool | Rule |
| -------------------------- | ------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `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. |
| `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`. |
| `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. |
| backend | rule + endpoint + golden | xUnit | Mirror of the FE domain rules, plus `WebApplicationFactory` integration. |
## Tooling