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:
Vendored
-1
@@ -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
@@ -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. |
|
||||||
|
|||||||
@@ -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 10–15 (axe covers story churn);
|
Sequencing dependencies (stated in the WPs too): 01 before 10–15 (axe covers story churn);
|
||||||
03/04 before 05–09 (boundaries stop new violations during refactors); 06 before 07 (typed
|
03/04 before 05–09 (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
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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.`);
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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';
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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`;
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user