test: split multi-assertion specs into single-behavior tests
One behavior per test across FE machine/store specs and backend endpoint tests, so a failure names exactly what broke. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
# ADR 0001 — Frontend⇄backend: BFF-lite endpoints + decision DTOs
|
||||
|
||||
Status: Accepted · Date: 2026-06-26
|
||||
|
||||
## Problem
|
||||
|
||||
The frontend makes many separate calls and aggregates them itself, and business
|
||||
rules are hardwired in the client. Two concrete symptoms:
|
||||
|
||||
- The dashboard stitched **three** independent `httpResource`s (BIG-register
|
||||
registration, BRP person, notes) together client-side. Each could be
|
||||
loading/erroring independently → inconsistent snapshots ("state out of sync").
|
||||
- Policy was duplicated on the client: the scholing threshold (`1000`) and the
|
||||
herregistratie eligibility window (`12` months) lived in frontend code. If the
|
||||
backend changes a rule, the UI silently diverges — bad for governance.
|
||||
|
||||
Goal: **unify FE/BE policy, cut the number of calls, and make the rules
|
||||
transparent/auditable — without coupling the two sides too tightly.** We own the
|
||||
backend team.
|
||||
|
||||
## Options considered
|
||||
|
||||
| Option | Fewer calls? | Unifies policy? | Cost |
|
||||
| -------------------------------------------------------------- | ------------------------- | --------------------------------------------------------- | --------------------------- |
|
||||
| 1. Status quo (client calls upstreams, aggregates, owns rules) | No | No | — |
|
||||
| 2. Unified client API layer (one facade in the FE) | No — still N round-trips | No — rules still on client | Low, but misses the goal |
|
||||
| **3. Screen-shaped endpoints on our own backend ("BFF-lite")** | **Yes** — 1 call/screen | **Yes** — server computes decisions | Low–medium |
|
||||
| 4. Separately-deployed BFF service | Yes | Yes | Medium — another deployable |
|
||||
| 5. GraphQL gateway | Yes (client picks fields) | **No, not by itself** — still need resolvers to own rules | Medium–high; new infra |
|
||||
|
||||
GraphQL solves over/under-fetching but does not, on its own, move rules
|
||||
server-side — and our problem is policy unification + drift, not field-selection
|
||||
flexibility. Option 4 is option 3 with a deployment boundary added.
|
||||
|
||||
## Decision
|
||||
|
||||
**Screen-shaped ("BFF-lite") endpoints that return decision-enriched DTOs, defined
|
||||
by a single shared contract. The frontend renders decisions; it does not recompute
|
||||
them.** Keep it minimal: implement BFF-shaped endpoints on the backend we already
|
||||
own. Promote to a separately-deployed BFF service only when a second consumer
|
||||
(mobile/partner) or a team boundary demands it — not before.
|
||||
|
||||
### Why DTOs _decouple_ rather than couple
|
||||
|
||||
The coupling people fear comes from **not** having DTOs — i.e. serializing internal
|
||||
DB/domain entities straight onto the wire, so every schema change ripples to the
|
||||
client. A DTO is the decoupling seam:
|
||||
|
||||
```
|
||||
DB entity / domain model → DTO (the wire contract) → FE view model
|
||||
(backend's own) (the agreed contract) (frontend's own)
|
||||
```
|
||||
|
||||
Each side keeps its own internal model and refactors freely; only the DTO is a
|
||||
deliberate, versioned change. The one coupling that remains — both sides agreeing
|
||||
on the contract — is the _wanted_, reviewable seam. Manage it with **one source of
|
||||
truth** (OpenAPI or TypeSpec) that **generates types for both sides**. That spec is
|
||||
the governance/transparency artifact.
|
||||
|
||||
### Two shapes of "policy over the wire" — pick per rule
|
||||
|
||||
- **Config value** — for simple thresholds. Server sends the value; the FE applies
|
||||
it for instant feedback; **the backend re-validates on submit as the authority.**
|
||||
Example here: the scholing threshold.
|
||||
- **Decision flag** — for anything non-trivial/sensitive. Server computes the
|
||||
boolean (optionally with a `reason`); the FE just renders it. Example here:
|
||||
herregistratie eligibility.
|
||||
|
||||
The frontend keeps only **format** validation (postcode shape, integer parsing) for
|
||||
instant feedback — never as the authority.
|
||||
|
||||
## Worked example in this POC
|
||||
|
||||
This POC has no real backend (static mock JSON + fake submit timers), so the
|
||||
"BFF output" is a static file; the `decisions` block stands in for what the backend
|
||||
would compute. Two slices were implemented to demonstrate **both** policy shapes:
|
||||
|
||||
**A. Dashboard profile → one aggregated, decision-enriched call (decision-flag).**
|
||||
|
||||
- Contract: `src/app/registratie/contracts/dashboard-view.dto.ts`
|
||||
(`DashboardViewDto` = registration + person + `decisions`).
|
||||
- Endpoint: `public/mock/dashboard-view.json` (one call replaces three).
|
||||
- Boundary parse: `parseDashboardView()` in
|
||||
`src/app/registratie/infrastructure/dashboard-view.adapter.ts` validates the
|
||||
untrusted shape and maps DTO → domain (hand-written; no schema lib for one
|
||||
contract).
|
||||
- `BigProfileStore` now derives `profile` and `decisions` from the single
|
||||
validated view (was a 3-resource `map2`). One request → one consistent snapshot.
|
||||
- `herregistratie.page.ts` reads `decisions.eligibleForHerregistratie` instead of
|
||||
calling `isHerregistratieEligible()`. That rule is now marked server-owned in
|
||||
`registration.policy.ts` (kept as reference impl + unit test; FE no longer calls it).
|
||||
- The unused upstream adapters/mocks (`brp.adapter.ts`, `registration.json`,
|
||||
`brp.json`) were deleted — those calls live behind the BFF now.
|
||||
|
||||
**B. Intake scholing threshold → config value.**
|
||||
|
||||
- Contract: `src/app/herregistratie/contracts/intake-policy.dto.ts`.
|
||||
- Endpoint: `public/mock/intake-policy.json` (`{ "scholingThreshold": 1000 }`).
|
||||
- `intake.machine.ts`: the hardcoded `LAGE_UREN_DREMPEL` constant is gone;
|
||||
`lageUren(a, scholingThreshold)` and validation take the value, which lives in
|
||||
machine state and is set via a `SetPolicy` message. A `SCHOLING_THRESHOLD_DEFAULT`
|
||||
remains only as the offline fallback.
|
||||
- `intake-wizard.component.ts` fetches the policy and dispatches `SetPolicy`.
|
||||
|
||||
## Migration sequence (for the real app)
|
||||
|
||||
1. Define the contract in OpenAPI/TypeSpec; generate types for FE and BE.
|
||||
2. Stand up screen-shaped endpoints on the existing backend that aggregate the
|
||||
upstreams and compute `decisions`.
|
||||
3. Point each screen at its single endpoint; delete client-side aggregation.
|
||||
4. Move each hardwired rule server-side; expose as decision flag or config value.
|
||||
5. Reduce the FE to format-validation + rendering.
|
||||
|
||||
## Out of scope here (next steps, not built in the worked example)
|
||||
|
||||
- Runtime DTO validation on **every** endpoint (only the dashboard view has it).
|
||||
- Optimistic-update race fix in `BigProfileStore`
|
||||
(`beginHerregistratie`/`rollbackHerregistratie` can leave `pending` wrong under
|
||||
concurrent submits).
|
||||
- Session persistence / multi-tab sync (`SessionStore` is in-memory).
|
||||
- Real OpenAPI/TypeSpec codegen toolchain.
|
||||
|
||||
ponytail: build the pattern once on one slice; copy it across screens when the real
|
||||
backend lands, rather than scaffolding all of it up front.
|
||||
@@ -0,0 +1,135 @@
|
||||
# ADR 0002 — User groups as actors, not bounded contexts
|
||||
|
||||
Status: Proposed · Date: 2026-07-01
|
||||
|
||||
## Problem
|
||||
|
||||
Today the app knows exactly one actor. `auth/domain/session.ts` is a flat
|
||||
`Session { bsn, naam }`, authentication is a faked DigiD flow, and the backend has no
|
||||
role model at all (only an `X-Admin: true` header seam in `Program.cs` and a stringly-typed
|
||||
`Actor` on audit entries). This whole repo _is_ the **Zorgverlener** self-service portal (SSP).
|
||||
|
||||
We now need a second user group — **Behandelaar** (backoffice: assessing and deciding on
|
||||
applications) — and want room for others later (admin, auditor, institution rep). The question
|
||||
is a modelling one, not a coding one:
|
||||
|
||||
> How do user groups map onto our DDD structure? Is "Zorgverlener" a bounded context? Is
|
||||
> "Behandelaar" a folder next to `registratie`/`herregistratie`? Where does "who may do what" live?
|
||||
|
||||
Getting this wrong is expensive: split the code by role and every feature smears across
|
||||
"folders per persona"; lump everyone into one `users` context and it becomes a god-context.
|
||||
|
||||
Confirmed constraints (with the product owner):
|
||||
|
||||
- The backoffice is a **separate frontend application**, own audience, own deployment.
|
||||
- The groups **authenticate differently**: Zorgverlener via DigiD/BSN; Behandelaar via employee SSO.
|
||||
- Both act on the **same underlying aggregate** — the aanvraag/registration — but see different views.
|
||||
|
||||
## Options considered
|
||||
|
||||
| Option | Ubiquitous language respected? | Coupling | Verdict |
|
||||
| ------------------------------------------------------------------------------ | --------------------------------------------------------------- | -------- | --------- |
|
||||
| 1. Split contexts **by role** (`zorgverlener/`, `behandelaar/` folders) | No — role ≠ capability; features smear across personas | High | Reject |
|
||||
| 2. One catch-all **`users`/`identity`** context owning everything about people | No — becomes a god-context; mixes identity, authz, and features | High | Reject |
|
||||
| 3. **Actors are personas; contexts are capabilities; identity is typed** | Yes | Low | **Adopt** |
|
||||
|
||||
## Decision
|
||||
|
||||
**A user group is an _actor_, not a bounded context.** Bounded contexts are drawn by
|
||||
**ubiquitous language + capability**, never by who logs in. Concretely:
|
||||
|
||||
### 1. Two capability contexts, two apps, one shared backend domain
|
||||
|
||||
The same real-world thing is described in two different languages:
|
||||
|
||||
- **Zelfbediening (SSP)** — the Zorgverlener: _"ik vraag herregistratie aan"_ — eligibility, fill in
|
||||
my data, upload documents, submit. **This repo.**
|
||||
- **Behandeling (backoffice)** — the Behandelaar: _"ik beoordeel de aanvraag"_ — werkvoorraad,
|
||||
beoordeling, besluit, meer-info-opvragen, SLA, audit. **A sibling application**, not a folder here.
|
||||
|
||||
Diverging verbs over the same noun is the textbook signal for **two bounded contexts**.
|
||||
|
||||
### 2. The aggregate is owned by the backend; the contexts integrate through it
|
||||
|
||||
The aanvraag/registration is the **system of record in the backend domain**. Neither frontend owns
|
||||
it. They integrate _through the backend_ using the **BFF-lite decision DTOs of ADR-0001** — the same
|
||||
aggregate projected into two screen-shaped views. The **aanvraag status lifecycle** is the _published
|
||||
contract_ between the two contexts:
|
||||
|
||||
```
|
||||
Ingediend → In behandeling → (Meer info gevraagd ⇄) → Goedgekeurd / Afgewezen
|
||||
```
|
||||
|
||||
The Behandeling context **advances** this lifecycle; the SSP **reads** it. Today the SSP already holds
|
||||
the seed of it — `pendingHerregistratie` in `big-profile.store.ts:53` is the first, coarsest read of
|
||||
that status ("in behandeling"). As the backoffice appears, that single boolean grows into a real
|
||||
status the backend publishes.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph FE["Frontend bounded contexts (separate apps)"]
|
||||
SSP["<b>Zelfbediening (SSP)</b><br/>Zorgverlener · DigiD/BSN<br/><i>this repo</i>"]
|
||||
BO["<b>Behandeling (backoffice)</b><br/>Behandelaar · employee SSO<br/><i>sibling app</i>"]
|
||||
end
|
||||
BE["<b>Backend domain</b><br/>aanvraag aggregate (system of record)<br/>status lifecycle · authorization"]
|
||||
SSP -- "reads aanvraag status<br/>(decision DTOs, ADR-0001)" --> BE
|
||||
BO -- "advances aanvraag status<br/>(decision DTOs, ADR-0001)" --> BE
|
||||
classDef c fill:#e5f1fb,stroke:#007bc7,color:#00567d;
|
||||
classDef d fill:#fff4e5,stroke:#e8830c,color:#8a4b00;
|
||||
class SSP,BO c;
|
||||
class BE d;
|
||||
```
|
||||
|
||||
Both FE contexts are **Customer/Conformist** to the backend's published aanvraag model. This is
|
||||
deliberately **not** a Shared Kernel between the two apps — coupling two audiences' codebases directly
|
||||
would defeat the point of splitting them.
|
||||
|
||||
### 3. Separate identity from authorization
|
||||
|
||||
These are two concerns people habitually conflate; keeping them apart is the crux of the model.
|
||||
|
||||
- **Identity — "who are you, how did you log in"** → the `auth` context. Model the principal as a
|
||||
**discriminated union**, the same "make illegal states unrepresentable" reflex as `RemoteData`:
|
||||
|
||||
```ts
|
||||
type Principal =
|
||||
| { kind: 'zorgverlener'; bsn: string; naam: string } // DigiD/BSN
|
||||
| { kind: 'medewerker'; medewerkerId: string; naam: string; rollen: Rol[] }; // employee SSO
|
||||
```
|
||||
|
||||
The union captures that the two actors authenticate differently and carry different identifiers —
|
||||
a Behandelaar has no BSN, a Zorgverlener has no `rollen`. This replaces the flat `Session` the day a
|
||||
second actor arrives.
|
||||
|
||||
- **Authorization — "what may you do"** → enforced at the **backend / context boundary**, where the
|
||||
backend is the authority (per ADR-0001). It is _not_ a permission matrix living in `auth`. The
|
||||
frontend receives only the decisions it needs to render (e.g. a `canBeoordelen` flag), exactly like
|
||||
every other server-owned rule.
|
||||
|
||||
### 4. "Other users" slot in without inventing contexts
|
||||
|
||||
Admin, auditor, institution-rep are additional **`Principal` variants** or additional **`rollen` on
|
||||
`medewerker`** — never a new folder-per-role. A genuinely new _bounded context_ is warranted only when
|
||||
an actor brings a new **language and capability** (e.g. an "Toezicht/Handhaving" enforcement context),
|
||||
not merely a new login.
|
||||
|
||||
## Consequences
|
||||
|
||||
- This repo **stays the pure SSP**. No backoffice code leaks in; no role-named folders appear.
|
||||
- The backoffice ships as a **separate app** against the same backend and the same OpenAPI contract.
|
||||
- The one concrete FE change when actor #2 lands is `Session → Principal` in the `auth` context; the
|
||||
`authGuard`/`SessionStore` seams already localise that (`auth.guard.ts`, `session.store.ts`).
|
||||
- The backend becomes the authority for the **aanvraag status lifecycle** and for **authorization**,
|
||||
publishing both as decision DTOs — a natural extension of ADR-0001, not a new pattern.
|
||||
- `pendingHerregistratie` is understood as a _temporary stand-in_ for a real, backend-owned status.
|
||||
|
||||
## Out of scope here (next steps, not built)
|
||||
|
||||
- Building the Behandeling backoffice application.
|
||||
- Real authentication: DigiD (SSP) and employee SSO / eHerkenning (backoffice).
|
||||
- The `auth` `Session → Principal` refactor — deferred until a second actor is actually introduced.
|
||||
- The backend aanvraag status lifecycle + authorization endpoints/DTOs.
|
||||
|
||||
ponytail: this ADR draws the boundaries so nothing has to be undone later; it does **not** scaffold a
|
||||
second app or a role system now. Introduce the `Principal` union and the status lifecycle when the
|
||||
backoffice work actually starts — YAGNI until then.
|
||||
@@ -0,0 +1,54 @@
|
||||
# ADR-0003 — Adopt the CIBG Huisstijl (Bootstrap 5.2) as the design system
|
||||
|
||||
Status: Accepted · Date: 2026-07-02
|
||||
|
||||
## Context
|
||||
|
||||
The portal must adhere visually to the **CIBG design system** (designsystem.cibg.nl) — CIBG runs
|
||||
the real BIG-register. Until now the app was themed with **Rijkshuisstijl-Community / Utrecht**
|
||||
(`@rijkshuisstijl-community/components-css` + `design-tokens`), emitting `utrecht-*`/`rhc-*` classes
|
||||
and consuming a `--rhc-*` CSS-custom-property token set.
|
||||
|
||||
CIBG Huisstijl (`@cibg/huisstijl`, v3.22.0) is **a customized build of Bootstrap 5.2.0**: standard
|
||||
Bootstrap CSS classes (`btn`, `form-control`, `card`, `table`, `breadcrumb`) themed via SCSS variable
|
||||
overrides, with a full `--bs-*` runtime variable surface. For SPA frameworks it is **CSS-only — no
|
||||
Bootstrap JS** (the framework drives interactivity). The two systems share neither class names nor
|
||||
tokens, so adopting CIBG is a re-skin of the shared component layer plus a replacement of the token
|
||||
layer — not a palette swap.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Vendor the package** under `public/cibg-huisstijl/` (not an npm dep — it was delivered as files),
|
||||
loaded via a `<link>` in `src/index.html` so the CSS's relative `url(../fonts|icons|images)`
|
||||
references resolve at runtime. Storybook serves the same via `staticDirs`.
|
||||
2. **Token bridge over token rewrite.** `src/styles.scss` redefines the app's ~54 `--rhc-*` tokens
|
||||
onto CIBG values (`--bs-*` where one exists, CIBG palette hex otherwise). The `--rhc-*` names are
|
||||
now an internal alias set; the _values_ are CIBG. This avoided rewriting 300+ token references and
|
||||
keeps the "components reference tokens" convention intact. (`styles.scss` is exempt from
|
||||
`check:tokens`, so palette hex lives in that one file only.)
|
||||
3. **Re-skin atoms, keep their `input()` APIs.** Each `shared/ui` atom now emits Bootstrap/CIBG classes
|
||||
(`app-button` → `btn btn-primary`, `text-input` → `form-control`, radio/checkbox → `form-check-*`);
|
||||
domain pages compose the same atoms and barely changed.
|
||||
4. **Hand-roll what CIBG's build drops.** CIBG omits Bootstrap's `.alert` and `.navbar`, so `app-alert`
|
||||
is a small token-styled surface and the header/side-nav use `.nav` + a local blue bar. Local class
|
||||
names that collide with Bootstrap components were renamed (`.card` → `.app-card`, badge → `.status-badge`).
|
||||
5. **System-font stack; no licensed fonts.** `--bs-font-sans-serif` is overridden to `system-ui`; the
|
||||
licensed RO/Rijks **text** woffs are removed from the vendored copy (CIBG icon font kept). Logo stays
|
||||
a text wordmark. Interactivity stays Angular-driven (no Bootstrap JS).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Wiring the design system touches `styles.scss` (token bridge), `index.html`, `angular.json`
|
||||
(`public/` already copied), and `.storybook/` — plus the class strings in ~40 `shared/ui` +
|
||||
`shared/layout` + a few domain components. The `@rijkshuisstijl-community/*` deps are dropped.
|
||||
- `check:tokens` still guards raw hex in components; the token bridge + hand-rolled surfaces comply.
|
||||
- Known benign build warning: _"Unable to locate stylesheet: /cibg-huisstijl/css/huisstijl.min.css"_ —
|
||||
Angular's index optimizer doesn't process a `public/` stylesheet at build time. The asset is copied
|
||||
and the link is preserved (verified: served 200, `.btn-primary` present); the build exits green. The
|
||||
alternative (adding the CSS to `angular.json` `styles`) would force-bundle the licensed fonts we
|
||||
intentionally dropped, so we accept the warning.
|
||||
- Renaming the internal token names from `--rhc-*` to `--app-*` is possible later but out of scope.
|
||||
- Hand-rolled components (point 4) are tracked in the **CIBG gap register**
|
||||
(`src/docs/cibg-gaps.mdx`, Storybook "Foundations/CIBG Gap Register"): every deviation from the
|
||||
design system carries a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than silently
|
||||
drifting.
|
||||
@@ -0,0 +1,485 @@
|
||||
# Architecture guide
|
||||
|
||||
A walkthrough of how this app is organised and, especially, **how state is
|
||||
managed** — written for a developer who has _not_ done functional programming
|
||||
before. No prior FP knowledge assumed. Where we use an FP idea, we explain it in
|
||||
plain language first.
|
||||
|
||||
This is a demo of a Dutch BIG-register self-service portal (a healthcare
|
||||
professional logs in, sees their registration, and can apply for
|
||||
re-registration — "herregistratie").
|
||||
|
||||
> New to functional programming or The Elm Architecture? Start with the progressive
|
||||
> learning guide [`fp-tea-atomic-design.md`](./fp-tea-atomic-design.md), which teaches
|
||||
> the concepts (with Elm ↔ this-app examples) and the recipes; this document is the
|
||||
> reference deep-dive it points back to.
|
||||
|
||||
---
|
||||
|
||||
## 1. The big picture: six "contexts", five "layers"
|
||||
|
||||
The code is split first by **business area** (a "bounded context" in DDD terms),
|
||||
then inside each area by **layer**.
|
||||
|
||||
```
|
||||
src/app/
|
||||
shared/ things every context reuses (no business logic of its own)
|
||||
auth/ logging in / the current session
|
||||
registratie/ the user's BIG registration + personal data
|
||||
herregistratie/ the re-registration application flow
|
||||
brief/ letter-composition teaching slice
|
||||
showcase/ a teaching page; not a real feature (may read every context)
|
||||
```
|
||||
|
||||
`showcase/` is a **sanctioned exception** to the direction rules: its whole point is
|
||||
showing multiple contexts side by side, so it may import any context. Nothing imports
|
||||
`showcase`. (Enforced in `eslint.config.mjs`; same precedent as the `debug-state` panel.)
|
||||
|
||||
### The atomic-design hierarchy, visualised
|
||||
|
||||
The UI is built bottom-up: tiny **atoms** combine into **molecules**, which combine
|
||||
into **organisms**, which fill **templates**, which become **pages**. Each level only
|
||||
ever uses the level(s) below it — so anything you build is reusable by everything above.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
P["<b>Pages</b><br/>dashboard.page · login.page · intake.page"]
|
||||
T["<b>Templates</b><br/>page-shell · shell"]
|
||||
O["<b>Organisms</b><br/>login-form · registration-table · intake-wizard"]
|
||||
M["<b>Molecules</b><br/>form-field · data-row · async"]
|
||||
A["<b>Atoms</b><br/>button · text-input · radio-group · alert · heading"]
|
||||
P --> T --> O --> M --> A
|
||||
classDef l fill:#e5f1fb,stroke:#007bc7,color:#00567d;
|
||||
class P,T,O,M,A l;
|
||||
```
|
||||
|
||||
Adding the branching intake wizard needed **one new atom** (`radio-group`) and **one new
|
||||
organism** (`intake-wizard`) — everything else (`form-field`, `text-input`, `button`,
|
||||
`alert`, `spinner`, the page shell) was reused unchanged. That is the payoff of the
|
||||
hierarchy.
|
||||
|
||||
Inside a context you'll see the same five folders. They answer five different
|
||||
questions:
|
||||
|
||||
| Layer | Answers… | May import Angular? | Example here |
|
||||
| ----------------- | ------------------------------------- | ------------------- | ------------------------------------------- |
|
||||
| `domain/` | What are the business rules and data? | **No** (pure TS) | `registration.ts`, `registration.policy.ts` |
|
||||
| `application/` | How do we coordinate a task / state? | Yes (signals) | `big-profile.store.ts` |
|
||||
| `infrastructure/` | Where does data come from? | Yes (HTTP) | `big-register.adapter.ts`, `brp.adapter.ts` |
|
||||
| `contracts/` | What's the FE⇄BE wire shape? | **No** (pure DTOs) | `dashboard-view.dto.ts` |
|
||||
| `ui/` | How does it look? | Yes (components) | `dashboard.page.ts` |
|
||||
|
||||
**The one rule that keeps it sane: dependencies only point _inward_.** UI may use
|
||||
application, application may use domain, everyone may use `shared`. Never the
|
||||
other way around. In particular **`ui/` and `layout/` never import `infrastructure/`
|
||||
directly** — they reach data through an application store or command (lint-enforced).
|
||||
The `domain/` layer imports nothing from Angular, so the business rules are plain
|
||||
functions you can read and test in isolation.
|
||||
|
||||
Allowed direction: `herregistratie → registratie → shared`, `auth → shared`,
|
||||
`brief → shared` (`showcase` may read every context; see above).
|
||||
|
||||
### Why the `shared/` kernel is split too
|
||||
|
||||
- `shared/kernel/` — tiny generic helpers (no Angular).
|
||||
- `shared/application/` — generic state tools (RemoteData, the store).
|
||||
- `shared/ui/` — the atomic-design building blocks (buttons, inputs, the async renderer). These know nothing about BIG-register.
|
||||
- `shared/layout/` — page chrome (header, footer, shells).
|
||||
- `shared/infrastructure/` — the demo HTTP interceptor.
|
||||
|
||||
Imports use path aliases so they read as direction statements:
|
||||
`@shared/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`, `@brief/*`.
|
||||
|
||||
---
|
||||
|
||||
## 2. The state-management ideas (the important part)
|
||||
|
||||
Most UI bugs come from **state that can lie** — two booleans that disagree, data
|
||||
that's shown while an error is also showing, a "submit" that fires while a field
|
||||
is invalid. The whole strategy here is: **make those impossible by choosing
|
||||
better types.** Three tools do the work.
|
||||
|
||||
### Why not "just signals"?
|
||||
|
||||
You _can_ track a network call with three signals — `isLoading`, `error`, `data`. The
|
||||
problem is the **state space**: three booleans is 2³ = **8** combinations, and most are
|
||||
nonsense the compiler still lets you write. A single discriminated union has **exactly
|
||||
the 4 states that are real** — the illegal ones can't be expressed at all.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph bad["3 booleans = 8 states (most illegal)"]
|
||||
direction TB
|
||||
b1["loading ✓ · error ✗ · data ✗ ✅"]
|
||||
b2["loading ✗ · error ✓ · data ✗ ✅"]
|
||||
b3["loading ✗ · error ✗ · data ✓ ✅"]
|
||||
b4["loading ✓ · error ✓ · data ✓ ❌ nonsense"]
|
||||
b5["loading ✓ · error ✗ · data ✓ ❌ nonsense"]
|
||||
b6["… 3 more illegal combos ❌"]
|
||||
end
|
||||
subgraph good["1 union = 4 legal states"]
|
||||
direction TB
|
||||
g1["Loading"]
|
||||
g2["Empty"]
|
||||
g3["Failure (carries error)"]
|
||||
g4["Success (carries value)"]
|
||||
end
|
||||
bad -->|"choose a better type"| good
|
||||
classDef ok fill:#e8f5e9,stroke:#39870c; classDef no fill:#fdecea,stroke:#d52b1e;
|
||||
class b1,b2,b3,g1,g2,g3,g4 ok; class b4,b5,b6 no;
|
||||
```
|
||||
|
||||
The same argument applies to forms (a `submitting` boolean that can be true _with_
|
||||
validation errors) and to the branching wizard (don't store "which step is next" — it can
|
||||
drift out of sync with the answers; **derive** it instead, see §5). Signals are still the
|
||||
engine underneath; we just give them types that can't lie.
|
||||
|
||||
### 2a. `RemoteData` — one value instead of three booleans
|
||||
|
||||
The naive way to track a network call:
|
||||
|
||||
```ts
|
||||
isLoading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
data = signal<Thing | null>(null);
|
||||
```
|
||||
|
||||
Three signals = eight combinations, and most are nonsense (loading **and** has
|
||||
data **and** has an error?). You end up writing defensive `if`s everywhere.
|
||||
|
||||
Instead we use **one** value that is _exactly one of_ four shapes
|
||||
(`shared/application/remote-data.ts`):
|
||||
|
||||
```ts
|
||||
type RemoteData<E, T> =
|
||||
| { tag: 'Loading' }
|
||||
| { tag: 'Empty' }
|
||||
| { tag: 'Failure'; error: E } // only this shape has an error
|
||||
| { tag: 'Success'; value: T }; // only this shape has a value
|
||||
```
|
||||
|
||||
This is called a **discriminated union** (a.k.a. "tagged union" or "sum type"):
|
||||
a value that is one of several labelled shapes, where the `tag` tells you which.
|
||||
Notice the data lives _on_ the shape — you literally cannot read `.value` unless
|
||||
you're in the `Success` case, so "loaded but no data" can't be written down.
|
||||
|
||||
To use it, you handle every case once. The `<app-async>` component
|
||||
(`shared/ui/async/async.component.ts`) does this for you: you give it a
|
||||
`RemoteData` (or a raw `httpResource`) and four templates, and it shows exactly
|
||||
one. There's also `foldRemote(rd, { loading, empty, failure, success })` for
|
||||
doing the same in TypeScript — the compiler makes you cover all four.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Loading: fetch starts
|
||||
Loading --> Success: data arrived
|
||||
Loading --> Empty: arrived, but no rows
|
||||
Loading --> Failure: request failed
|
||||
Failure --> Loading: reload()
|
||||
note right of Success
|
||||
value lives ONLY here
|
||||
end note
|
||||
note right of Failure
|
||||
error lives ONLY here
|
||||
end note
|
||||
```
|
||||
|
||||
`map2` (§2b) combines two of these into one: **Failure if either failed, Loading if either
|
||||
is still loading, Success only when both succeeded** — so a page renders one state, never a
|
||||
contradictory mix.
|
||||
|
||||
> **FP term:** a _pure function_ is one whose output depends only on its inputs
|
||||
> and which changes nothing else (no network, no writing to variables outside
|
||||
> it). Pure functions are easy to test and reason about. We push impure things
|
||||
> (HTTP, timers) to the edges.
|
||||
|
||||
### 2b. Combining sources with `map2` — two services, one state
|
||||
|
||||
The dashboard needs data from **two** services: the BIG-register (status,
|
||||
specialisms) and the BRP (name, address). Each is its own `RemoteData`. Tracking
|
||||
both by hand means juggling two loading flags, two errors…
|
||||
|
||||
`map2` folds them into **one** `RemoteData` (`big-profile.store.ts`):
|
||||
|
||||
```ts
|
||||
profile = computed(() =>
|
||||
map2(
|
||||
fromResource(this.registrationRes), // RemoteData from service A
|
||||
fromResource(this.personRes), // RemoteData from service B
|
||||
(registration, person) => ({ registration, person }), // runs only if BOTH succeeded
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
The rule baked into `map2`: the combined result is a **Failure if either
|
||||
failed**, **Loading if either is still loading**, and only **Success when both
|
||||
succeeded**. So the page renders one state and the combiner callback only runs
|
||||
when it's safe. (`map`, `andThen` are variations on the same idea.)
|
||||
|
||||
### 2c. The store — "all state changes go through one pure function"
|
||||
|
||||
This is the "Elm-style" pattern. The idea in one sentence:
|
||||
|
||||
> **Keep all state in one value (the _Model_). The only way to change it is to
|
||||
> send a _message_ (_Msg_) to a pure function `update(model, msg)` that returns
|
||||
> the next Model.**
|
||||
|
||||
Why bother? Because to understand _every_ way the screen can change, you read
|
||||
_one_ function. No state is mutated anywhere else.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant View as View (template)
|
||||
participant Store as createStore (signal)
|
||||
participant Reduce as reduce() — PURE
|
||||
User->>View: clicks / types
|
||||
View->>Store: dispatch(msg)
|
||||
Store->>Reduce: reduce(model, msg)
|
||||
Reduce-->>Store: next model
|
||||
Store-->>View: signal updates → re-render
|
||||
Note over Reduce: the ONLY place state changes;<br/>no HTTP, no timers, no mutation
|
||||
```
|
||||
|
||||
Side effects (HTTP) sit _outside_ this loop: a command does the I/O, then `dispatch`es a
|
||||
message describing the outcome (§2d). So the reducer stays pure and testable.
|
||||
|
||||
The wizard (`herregistratie/domain/herregistratie.machine.ts`) is the clearest
|
||||
example. Its Model is a discriminated union:
|
||||
|
||||
```ts
|
||||
type WizardState =
|
||||
| { tag: 'Editing'; step: 1 | 2; draft: Draft; errors: {...} }
|
||||
| { tag: 'Submitting'; data: Valid } // carries ONLY validated data
|
||||
| { tag: 'Submitted'; data: Valid }
|
||||
| { tag: 'Failed'; data: Valid; error: string };
|
||||
```
|
||||
|
||||
Because `step` and `errors` exist _only_ on `Editing`, and the other states
|
||||
carry already-validated `data`, "submitting with validation errors showing" is
|
||||
not expressible. The messages and the pure reducer:
|
||||
|
||||
```ts
|
||||
type WizardMsg =
|
||||
| { tag: 'SetField'; key; value }
|
||||
| { tag: 'Next' }
|
||||
| { tag: 'Back' }
|
||||
| { tag: 'Submit' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed' }
|
||||
| { tag: 'SubmitFailed'; error };
|
||||
|
||||
function reduce(state, msg) {
|
||||
/* returns the next state; no side effects */
|
||||
}
|
||||
```
|
||||
|
||||
The component (`herregistratie-wizard.component.ts`) wires it to a signal with
|
||||
the tiny helper in `shared/application/store.ts`:
|
||||
|
||||
```ts
|
||||
private store = createStore(initial, reduce);
|
||||
state = this.store.model; // a read-only signal of the current Model
|
||||
dispatch = this.store.dispatch; // send a Msg
|
||||
```
|
||||
|
||||
In the template you don't mutate anything — you send messages:
|
||||
`(click)="dispatch({ tag: 'Back' })"`.
|
||||
|
||||
### 2d. Side effects (HTTP) without polluting the reducer
|
||||
|
||||
`reduce` is pure — it must not call the network. So how does a submit happen?
|
||||
The component has a small **command** method that does the impure work and then
|
||||
sends messages describing the outcome:
|
||||
|
||||
```ts
|
||||
async runIfSubmitting() {
|
||||
if (this.state().tag !== 'Submitting') return;
|
||||
this.profile.beginHerregistratie(); // 1. optimistic (see below)
|
||||
const r = await submitHerregistratie(s.data); // 2. the actual call
|
||||
if (r.ok) { this.dispatch({ tag: 'SubmitConfirmed' }); this.profile.confirmHerregistratie(); }
|
||||
else { this.dispatch({ tag: 'SubmitFailed', error: r.error }); this.profile.rollbackHerregistratie(); }
|
||||
}
|
||||
```
|
||||
|
||||
So the split is: **reducer = "what the new state is", command = "go do the thing,
|
||||
then tell the reducer what happened."**
|
||||
|
||||
### 2e. Optimistic update + rollback, and shared state across pages
|
||||
|
||||
`BigProfileStore` is marked `providedIn: 'root'`, which means Angular creates
|
||||
**one** instance for the whole app. Every page that injects it sees the same
|
||||
signals. That single shared instance _is_ our cross-page state — no extra
|
||||
library needed.
|
||||
|
||||
When the user submits a herregistratie:
|
||||
|
||||
1. **Optimistic:** `beginHerregistratie()` flips a `pendingHerregistratie`
|
||||
signal **before** the server answers. The dashboard already reads that
|
||||
signal, so it instantly shows "in behandeling" (in progress). The UI feels
|
||||
fast.
|
||||
2. **On success:** `confirmHerregistratie()` clears the flag and calls
|
||||
`resource.reload()` — that re-fetches the registration so the screen shows the
|
||||
real, updated server data. ("Invalidation": throw away the stale copy, fetch
|
||||
fresh.)
|
||||
3. **On failure:** `rollbackHerregistratie()` clears the flag, undoing the
|
||||
optimistic guess so the UI matches reality again.
|
||||
|
||||
### 2f. Auth/session + the route guard
|
||||
|
||||
`SessionStore` (`auth/application/session.store.ts`) holds `Session | null`, also
|
||||
a root singleton. `login()` is a command that calls the (mock) DigiD adapter and
|
||||
stores the result. The route guard (`auth/auth.guard.ts`) just reads
|
||||
`store.isAuthenticated()` and redirects to `/login` if you're not signed in.
|
||||
Protected routes list `canActivate: [authGuard]` in `app.routes.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 3. "Parse, don't validate" — value objects
|
||||
|
||||
A raw `string` could be anything. After you've checked a postcode is valid, the
|
||||
_type_ should remember that. So we have a `Postcode` type that can only be
|
||||
created by `parsePostcode`, which returns a `Result` (success-or-error)
|
||||
(`registratie/domain/value-objects/`):
|
||||
|
||||
```ts
|
||||
const r = parsePostcode(userInput);
|
||||
if (r.ok)
|
||||
save(r.value); // r.value is a Postcode — guaranteed well-formed
|
||||
else showError(r.error); // r.error is the message
|
||||
```
|
||||
|
||||
Once something hands you a `Postcode`, you never re-check it. The validity is
|
||||
baked into the type. Same idea for `Uren` and `BigNummer`.
|
||||
|
||||
> **FP term:** `Result<E, T>` is "either an error `E` or a value `T`" — a
|
||||
> discriminated union with `{ ok: true, value }` or `{ ok: false, error }`. It's
|
||||
> how a function reports failure without throwing.
|
||||
|
||||
---
|
||||
|
||||
## 4. How to add a new feature (recipe)
|
||||
|
||||
1. **Domain first.** Add the types and pure rules in the right context's
|
||||
`domain/`. No Angular. Write a `.spec.ts` next to it.
|
||||
2. **Infrastructure.** If you need data, add an adapter in `infrastructure/`
|
||||
returning an `httpResource` (or a command function returning a `Result`).
|
||||
3. **Application.** If there's state to coordinate, add/extend a store
|
||||
(`providedIn: 'root'` if it must be shared across pages). Model state as a
|
||||
discriminated union; change it only through a pure `update`/`reduce`.
|
||||
4. **UI last.** Build the page/organism from `shared/ui` atoms. Render async
|
||||
state through `<app-async>`. Send messages; don't mutate.
|
||||
|
||||
If you're tempted to add a third boolean to track state — stop and model it as a
|
||||
discriminated union instead.
|
||||
|
||||
> **Worked example — the branching intake wizard** (`herregistratie/`). Domain first:
|
||||
> `intake.machine.ts` is one tagged union plus a pure `reduce` and a pure
|
||||
> `visibleSteps(answers)`. A command `submit-intake.ts` does the I/O. UI last:
|
||||
> `intake-wizard.component.ts` (organism) is built from `form-field`, `text-input` and the
|
||||
> new `radio-group` atom; `intake.page.ts` assembles it. No new state library, no booleans.
|
||||
|
||||
---
|
||||
|
||||
## 5. Branching by _deriving_, not storing
|
||||
|
||||
The intake wizard (`herregistratie/domain/intake.machine.ts`) shows the most important
|
||||
state-management habit: **don't store what you can derive.** Naively you'd track "which
|
||||
step is next" in a field and update it by hand on every answer — and the moment an earlier
|
||||
answer changes, that field is stale. Instead, the set of steps is a pure function of the
|
||||
answers:
|
||||
|
||||
```ts
|
||||
function visibleSteps(a: Answers): StepId[] {
|
||||
const steps: StepId[] = ['buitenland'];
|
||||
if (a.buitenlandGewerkt === 'ja') steps.push('buitenlandDetails'); // branch appears
|
||||
steps.push('uren');
|
||||
if (lageUren(a)) steps.push('scholing'); // branch appears
|
||||
steps.push('punten', 'review');
|
||||
return steps;
|
||||
}
|
||||
```
|
||||
|
||||
The state keeps only the raw `answers` and a numeric `cursor`; the visible step is
|
||||
`visibleSteps(answers)[cursor]`. Change "buiten Nederland gewerkt?" to _ja_ and the country
|
||||
question simply exists; change it back and it's gone — the cursor is clamped to the new
|
||||
list. There's no synchronisation code to get wrong, and `visibleSteps` is a one-line unit
|
||||
test. Answers persist to `localStorage` (an `effect` in the component) so a reload resumes
|
||||
where the user left off.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Answering
|
||||
Answering --> Answering: SetAnswer / Next / Back<br/>(steps re-derived each time)
|
||||
Answering --> Submitting: Submit (all answers valid)
|
||||
Submitting --> Submitted: ok
|
||||
Submitting --> Failed: error
|
||||
Failed --> Submitting: Retry
|
||||
```
|
||||
|
||||
See it live on `/concepts` (section 5) — the step list and the "stap N van M" counter
|
||||
update as you type.
|
||||
|
||||
---
|
||||
|
||||
## 6. Connecting to a .NET backend
|
||||
|
||||
> **Implemented.** No longer hypothetical: a minimal ASP.NET Core backend now hosts
|
||||
> the business rules and serves the endpoints; the FE consumes it through an
|
||||
> NSwag-generated typed client. See `backend/README.md`. The text below remains as
|
||||
> the rationale for _why_ only `infrastructure/` + `contracts/` had to change.
|
||||
|
||||
The adapters used to read static JSON (`mock/*.json`). Because `infrastructure/` is the only
|
||||
layer that touches the network — the **anti-corruption boundary** — pointing the app at a
|
||||
real ASP.NET API touched _only these files_. Domain, application and UI don't change.
|
||||
|
||||
The one concrete change per adapter: a **DTO** type matching the .NET response, a
|
||||
`toDomain` mapper, and a real URL.
|
||||
|
||||
```ts
|
||||
// infrastructure/big-register.adapter.ts
|
||||
|
||||
// 1) Shape exactly as ASP.NET serialises it (camelCase via the default JsonSerializer).
|
||||
interface RegistrationDto {
|
||||
bigNumber: string;
|
||||
name: string;
|
||||
status: 'Registered' | 'Suspended' | 'StruckOff';
|
||||
reregistrationDate?: string;
|
||||
// …
|
||||
}
|
||||
|
||||
// 2) Map the wire shape to our domain union (this is the anti-corruption layer).
|
||||
function toDomain(dto: RegistrationDto): Registration { /* build the tagged union */ }
|
||||
|
||||
// 3) Same httpResource, real endpoint instead of mock/registration.json.
|
||||
registrationResource() {
|
||||
return httpResource(() => `${environment.apiBaseUrl}/registrations/me`, { parse: toDomain });
|
||||
}
|
||||
```
|
||||
|
||||
Practical notes, kept lazy:
|
||||
|
||||
- **Base URL** via Angular environments (`environment.apiBaseUrl`); `proxy.conf.json` in dev
|
||||
to avoid CORS, or enable CORS on the .NET side for the SPA origin.
|
||||
- **Auth**: send the bearer/cookie with an `HttpInterceptor` (the existing
|
||||
`scenario.interceptor.ts` shows the pattern — replace or disable it for the real API).
|
||||
- **The contract**: start with **hand-written DTOs** (shown above) — zero tooling. When the
|
||||
API surface grows, generate a typed client from the .NET **OpenAPI/Swagger** document
|
||||
(e.g. NSwag) so the DTOs stay in sync automatically. Either way, keep `toDomain` as the
|
||||
single place the wire format meets our types.
|
||||
- Nothing else moves: `<app-async>`, the stores, and every page keep working unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 7. Mini-glossary
|
||||
|
||||
- **Pure function** — output depends only on inputs; no side effects. Easy to test.
|
||||
- **Discriminated / tagged union (sum type)** — a value that is exactly one of several labelled shapes (`{ tag: 'A'; ... } | { tag: 'B'; ... }`). The `tag` says which; each shape carries only the data that makes sense for it.
|
||||
- **`RemoteData`** — a tagged union for an async value: Loading / Empty / Failure / Success.
|
||||
- **`Result<E,T>`** — a tagged union for success-or-error.
|
||||
- **Value object** — a small type whose validity is guaranteed by its constructor (e.g. `Postcode`).
|
||||
- **Reducer (`update`/`reduce`)** — the one pure function that maps `(state, message) → next state`.
|
||||
- **Command** — an impure function that does I/O (HTTP, timer) and then dispatches messages with the outcome.
|
||||
- **Optimistic update** — show the expected result immediately, then confirm or roll back when the server answers.
|
||||
- **Bounded context** — a self-contained business area with its own language and folder (`auth`, `registratie`, `herregistratie`).
|
||||
- **`signal` / `computed`** — Angular's reactive values; `computed` recalculates automatically when the signals it reads change.
|
||||
@@ -0,0 +1,640 @@
|
||||
# Functional programming, The Elm Architecture & atomic design — a guide
|
||||
|
||||
A **progressive learning guide** for developers who are strong programmers but have
|
||||
done little or no **functional programming (FP)** in the frontend. It teaches three
|
||||
ideas and shows they are one idea:
|
||||
|
||||
1. **FP for the frontend** — pure functions, immutability, types that can't lie.
|
||||
2. **The Elm Architecture (TEA)** — one state, one direction, one pure update.
|
||||
3. **Atomic design** — small pure components composed into bigger ones.
|
||||
|
||||
The claim of Part 5 is that **TEA and atomic design are the same principle at two
|
||||
scales**, and this app already lives that way.
|
||||
|
||||
**How to read it.** A junior can read top-to-bottom and arrive at "I can add a
|
||||
feature." A senior can skim Parts 1–4 and jump to **Part 5** (FP × atomic design),
|
||||
**Part 6** (why it reduces complexity), and **Part 7** (the recipes). Every term is
|
||||
defined in plain words on first use and again in the **glossary** (Part 8).
|
||||
|
||||
This guide is the _teaching_ layer. For the reference deep-dives it points to
|
||||
[`ARCHITECTURE.md`](./ARCHITECTURE.md) and
|
||||
[ADR-0001](./architecture/0001-bff-lite-decision-dtos.md) rather than repeating them.
|
||||
Every code snippet below is real code from this repo, with its file path.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Why this exists (the complexity problem)
|
||||
|
||||
Most UI bugs are not algorithmic. They come from **state that can lie**:
|
||||
|
||||
- Two booleans that disagree — `isLoading` is `true` _and_ `data` is set.
|
||||
- A screen that shows an error _and_ a success at the same time.
|
||||
- A "Submit" that fires while a field is still invalid.
|
||||
- A wizard whose "next step" field drifts out of sync with the answers.
|
||||
- The 3am question: _"who changed this value, and when?"_
|
||||
|
||||
The root cause is the same each time: state is **scattered** across many mutable
|
||||
variables, and it changes from **many places**. The number of states explodes, and
|
||||
most of them are nonsense the compiler still lets you write.
|
||||
|
||||
The promise of this architecture, in one line:
|
||||
|
||||
> **One state. One direction. Pure logic. Predictable everything.**
|
||||
|
||||
Keep all the state in a single value; change it only by sending a message to one pure
|
||||
function; let the view be a function of that state; push side effects (HTTP, timers)
|
||||
to the edges. The illegal states stop being reachable, the update logic becomes a unit
|
||||
test with no mocks, and onboarding becomes "learn one small pattern, apply it
|
||||
everywhere." The rest of this guide builds that up from first principles.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — FP fundamentals for the frontend
|
||||
|
||||
FP here is not category theory. It is four habits that make state predictable. Each is
|
||||
shown in **Elm** (a tiny, canonical functional UI language — our teaching device) and
|
||||
then in **this app's real TypeScript**.
|
||||
|
||||
### 2a. Pure functions
|
||||
|
||||
A **pure function**'s output depends _only_ on its inputs, and it changes nothing else
|
||||
— no network, no writing to outside variables, no clock. Same input → same output,
|
||||
every time. That is what makes it trivially testable (no mocks) and easy to reason
|
||||
about.
|
||||
|
||||
```elm
|
||||
-- Elm: pure by default — there is no way to do I/O inside this
|
||||
add : Int -> Int -> Int
|
||||
add a b = a + b
|
||||
```
|
||||
|
||||
In this app, the parsers and reducers are pure. For example
|
||||
(`src/app/registratie/domain/value-objects/uren.ts`):
|
||||
|
||||
```ts
|
||||
export function parseUren(raw: string): Result<string, Uren> {
|
||||
const t = raw.trim();
|
||||
const n = Number(t);
|
||||
if (t === '' || !Number.isInteger(n) || n < 0) {
|
||||
return err('Vul een geheel aantal in (0 of meer).');
|
||||
}
|
||||
return ok(n as Uren);
|
||||
}
|
||||
```
|
||||
|
||||
Give it `"4160"`, you always get the same `ok(4160)`. No surprises. **Why it helps:**
|
||||
its unit test is one line per case and never flakes.
|
||||
|
||||
### 2b. Immutability
|
||||
|
||||
Never mutate a value in place; produce a **new** value instead. The spread `{ ...s, x }`
|
||||
copies the old fields and overrides one.
|
||||
|
||||
```elm
|
||||
-- Elm: { model | count = model.count + 1 } makes a NEW record
|
||||
```
|
||||
|
||||
This app's reducers always return a fresh object — e.g.
|
||||
`src/app/herregistratie/domain/herregistratie.machine.ts`:
|
||||
|
||||
```ts
|
||||
export function setField(s: WizardState, key: keyof Draft, value: string): WizardState {
|
||||
if (s.tag !== 'Editing') return s;
|
||||
return { ...s, draft: { ...s.draft, [key]: value } };
|
||||
}
|
||||
```
|
||||
|
||||
**Why it helps:** because old states are never overwritten, back-navigation and
|
||||
"resume where you left off" are free (the previous value still exists), and Angular's
|
||||
change detection can tell something changed by identity. Time-travel/replay is possible
|
||||
_because_ nothing is destroyed.
|
||||
|
||||
### 2c. Unidirectional data flow
|
||||
|
||||
Data flows **down**; events flow **up**; there is exactly one loop. A view never reaches
|
||||
sideways to mutate another component's state — it emits an event, which becomes a
|
||||
message, which goes through the one update function, which produces the next state,
|
||||
which flows back down. Part 3 makes this loop concrete.
|
||||
|
||||
### 2d. Modelling state with types (make illegal states unrepresentable)
|
||||
|
||||
Two kinds of type do most of the work:
|
||||
|
||||
- **Product type** — a record that holds several things _at once_ (`interface Draft { uren; jaren; punten }`).
|
||||
- **Sum type / discriminated union** — a value that is _exactly one of_ several
|
||||
labelled shapes, where a `tag` says which, and **each shape carries only the data that
|
||||
makes sense for it**.
|
||||
|
||||
The decisive move is choosing types so that **illegal states can't be written down**.
|
||||
Compare three booleans (2³ = 8 combinations, most nonsense) with one union of the 4 real
|
||||
states — see [`ARCHITECTURE.md` §2a](./ARCHITECTURE.md#2a-remotedata--one-value-instead-of-three-booleans)
|
||||
for the full `RemoteData` treatment and diagram. The wizard's own Model is the same
|
||||
idea (`herregistratie.machine.ts`):
|
||||
|
||||
```ts
|
||||
export type WizardState =
|
||||
| { tag: 'Editing'; step: 1 | 2; draft: Draft; errors: Partial<Record<keyof Draft, string>> }
|
||||
| { tag: 'Submitting'; data: Valid }
|
||||
| { tag: 'Submitted'; data: Valid }
|
||||
| { tag: 'Failed'; data: Valid; error: string };
|
||||
```
|
||||
|
||||
Because `step` and `errors` exist **only** on `Editing`, and `Submitting`/`Submitted`
|
||||
carry already-validated `Valid` data and _no_ error field, "submitting while a field is
|
||||
invalid" or "success screen with errors still set" cannot be constructed. The bug class
|
||||
is gone at compile time.
|
||||
|
||||
> **FP term — sum type / discriminated union:** one value that is one-of-several
|
||||
> labelled shapes. The `tag` discriminates; the compiler then knows which fields exist.
|
||||
|
||||
### 2e. Side effects at the edges (functional core, imperative shell)
|
||||
|
||||
A **side effect** is anything beyond computing a return value: HTTP, timers,
|
||||
`localStorage`, focus. Pure code can't do them. So we keep a **pure core** (parsers,
|
||||
reducers, `visibleSteps`) and push every effect to a thin **imperative shell** (the
|
||||
Angular component/service). The core decides _what the state is_; the shell _goes and
|
||||
does things_, then feeds the result back in as a message. Part 4d shows exactly how.
|
||||
|
||||
> **FP term — pure core / imperative shell:** all decisions in pure functions; all I/O
|
||||
> in a thin outer layer that calls them.
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — The Elm Architecture (TEA)
|
||||
|
||||
TEA is four pieces and one loop. In Elm:
|
||||
|
||||
- **Model** — the single source of truth (all your state, one value).
|
||||
- **Msg** — every thing that can happen, as a union.
|
||||
- **`update : Msg -> Model -> Model`** — the _only_ place state changes; pure.
|
||||
- **`view : Model -> Html Msg`** — a pure function of the state that emits messages.
|
||||
|
||||
```elm
|
||||
type alias Model = { count : Int }
|
||||
|
||||
type Msg = Increment | Decrement
|
||||
|
||||
update : Msg -> Model -> Model
|
||||
update msg model =
|
||||
case msg of
|
||||
Increment -> { model | count = model.count + 1 }
|
||||
Decrement -> { model | count = model.count - 1 }
|
||||
|
||||
view : Model -> Html Msg
|
||||
view model =
|
||||
div []
|
||||
[ button [ onClick Decrement ] [ text "-" ]
|
||||
, text (String.fromInt model.count)
|
||||
, button [ onClick Increment ] [ text "+" ]
|
||||
]
|
||||
```
|
||||
|
||||
The runtime wires it into a loop:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
S["Model (state)"] --> V["view(Model)"]
|
||||
V -->|"user event"| M["Msg"]
|
||||
M --> U["update(Msg, Model) — PURE"]
|
||||
U -->|"next Model"| S
|
||||
classDef l fill:#e5f1fb,stroke:#007bc7,color:#00567d;
|
||||
class S,V,M,U l;
|
||||
```
|
||||
|
||||
**Effects** don't break the loop. In Elm, `update` can return a `Cmd` (a _description_
|
||||
of an effect — "go do this HTTP call"); the runtime performs it and feeds the result
|
||||
back in as another `Msg`. **Subscriptions** are the same for incoming events (time,
|
||||
websockets). The key property survives: `update` itself stays pure — it only ever
|
||||
_describes_ effects, never performs them. This app does the same with a small twist
|
||||
(Part 4d): the effect lives in the component, and its outcome is dispatched as a `Msg`.
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — How we do TEA in Angular with signals
|
||||
|
||||
This app implements TEA with Angular **signals**. There is no extra state library. One
|
||||
important shape difference from textbook Elm: **state is per-wizard, not one global
|
||||
Model** — each flow (`herregistratie`, `intake`, `registratie`) has its own little
|
||||
store. Cross-page state that _must_ be shared lives in one root singleton
|
||||
(`BigProfileStore`, see [`ARCHITECTURE.md` §2e](./ARCHITECTURE.md#2e-optimistic-update--rollback-and-shared-state-across-pages)).
|
||||
|
||||
### 4a. The store — TEA's runtime in ~10 lines
|
||||
|
||||
`src/app/shared/application/store.ts`:
|
||||
|
||||
```ts
|
||||
export interface Store<Model, Msg> {
|
||||
/** The current state, as a read-only Angular signal. */
|
||||
readonly model: Signal<Model>;
|
||||
/** Send a message; the model becomes update(model, msg). */
|
||||
dispatch(msg: Msg): void;
|
||||
}
|
||||
|
||||
export function createStore<Model, Msg>(
|
||||
init: Model,
|
||||
update: (model: Model, msg: Msg) => Model,
|
||||
): Store<Model, Msg> {
|
||||
const model = signal(init);
|
||||
return {
|
||||
model: model.asReadonly(),
|
||||
dispatch: (msg) => model.set(update(model(), msg)),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
This _is_ the Elm runtime: a `signal` holds the Model, and `dispatch` is the only way
|
||||
to change it — it runs the pure `update` and `set`s the new value.
|
||||
|
||||
> **Naming note (read the code, not the textbook):** the factory parameter is called
|
||||
> `update` (the Elm word), but each feature exports its reducer as **`reduce`** and
|
||||
> passes it in: `createStore(initial, reduce)`. "update" and "reduce" are the same role.
|
||||
|
||||
### 4b. Model + Msg + reduce
|
||||
|
||||
Mapping the four TEA pieces to real code, using the herregistratie wizard (the smallest
|
||||
machine) as the example — `src/app/herregistratie/domain/herregistratie.machine.ts`:
|
||||
|
||||
- **Model** → `WizardState` (the discriminated union from §2d).
|
||||
- **Msg** → `WizardMsg`, every event as one union:
|
||||
|
||||
```ts
|
||||
export type WizardMsg =
|
||||
| { tag: 'SetField'; key: keyof Draft; value: string }
|
||||
| { tag: 'Next' }
|
||||
| { tag: 'Back' }
|
||||
| { tag: 'Submit' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed' }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
| { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)
|
||||
```
|
||||
|
||||
- **update** → the pure `reduce(state, msg)` — no injection, no HTTP, no mutation:
|
||||
|
||||
```ts
|
||||
export function reduce(s: WizardState, m: WizardMsg): WizardState {
|
||||
switch (m.tag) {
|
||||
case 'SetField':
|
||||
return setField(s, m.key, m.value);
|
||||
case 'Next':
|
||||
return next(s);
|
||||
case 'Back':
|
||||
return back(s);
|
||||
case 'Submit':
|
||||
return submit(s);
|
||||
case 'Retry':
|
||||
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||
case 'SubmitConfirmed':
|
||||
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
|
||||
case 'SubmitFailed':
|
||||
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
|
||||
case 'Seed':
|
||||
return m.state;
|
||||
default:
|
||||
return assertNever(m); // compiler error if a Msg is unhandled
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`assertNever` (`src/app/shared/kernel/fp.ts`) makes the switch **exhaustive**: add a new
|
||||
`Msg` variant and forget to handle it, and the build fails. (`intake.machine.ts` and
|
||||
`registratie-wizard.machine.ts` have larger unions, same exact shape.)
|
||||
|
||||
### 4c. view → template + `computed()` + `dispatch`
|
||||
|
||||
The container component (`src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts`)
|
||||
creates the store and derives view values with `computed()`:
|
||||
|
||||
```ts
|
||||
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
||||
readonly state = this.store.model; // a read-only signal of the Model
|
||||
protected dispatch = this.store.dispatch; // the only way to change it
|
||||
|
||||
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<WizardState, { tag: 'Editing' }>) : null));
|
||||
protected step = computed(() => this.editing()?.step ?? 1);
|
||||
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });
|
||||
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
|
||||
```
|
||||
|
||||
The template is a **function of the state**: it reads those `computed()` signals and
|
||||
sends messages on events — it never mutates:
|
||||
|
||||
```html
|
||||
<app-text-input
|
||||
[ngModel]="draft().uren"
|
||||
(ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
|
||||
...
|
||||
/>
|
||||
...
|
||||
<app-button (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
|
||||
```
|
||||
|
||||
That is the loop: `state → template → event → dispatch(Msg) → reduce → new state →
|
||||
template`.
|
||||
|
||||
### 4d. Effects → a command that dispatches the outcome
|
||||
|
||||
`reduce` is pure, so it can't call the network. The component holds a small **command**
|
||||
method. It does the impure work, then dispatches a `Msg` describing what happened — the
|
||||
result re-enters through the same pure loop:
|
||||
|
||||
```ts
|
||||
private async runIfSubmitting() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Submitting') return;
|
||||
this.profile.beginHerregistratie(); // optimistic flag (shared store)
|
||||
const r = await submitHerregistratie(s.data); // the actual I/O — a Result
|
||||
if (r.ok) { this.dispatch({ tag: 'SubmitConfirmed' }); this.profile.confirmHerregistratie(); }
|
||||
else { this.dispatch({ tag: 'SubmitFailed', error: r.error }); this.profile.rollbackHerregistratie(); }
|
||||
}
|
||||
```
|
||||
|
||||
The command itself (`src/app/herregistratie/application/submit-herregistratie.ts`)
|
||||
returns a `Result` — success-or-error as a value, never a thrown exception:
|
||||
|
||||
```ts
|
||||
export async function submitHerregistratie(data: Valid): Promise<Result<string, void>> {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.');
|
||||
return ok(undefined);
|
||||
}
|
||||
```
|
||||
|
||||
The split, in one line: **reducer = "what the new state is"; command = "go do the
|
||||
thing, then say what happened."** Incoming effects (an arriving HTTP value, a
|
||||
server-owned config) are wired with `effect()` and `untracked()` so the dispatch
|
||||
doesn't loop on its own write — see the BRP prefill and policy-threshold effects in
|
||||
`registratie-wizard.component.ts` / `intake-wizard.component.ts`.
|
||||
|
||||
### 4e. Because state is one value, you can watch it
|
||||
|
||||
Each wizard exposes `state` as a **read-only signal**, deliberately public so the
|
||||
teaching page can highlight the live state. See it on the in-app showcase
|
||||
(`src/app/showcase/concepts.page.ts`, route `/concepts`): section 4 lights up the
|
||||
current `WizardState` among `Editing → Submitting → Submitted/Failed` as you drive the
|
||||
form, and section 5 shows the intake steps re-deriving as you type.
|
||||
|
||||
> **Discrepancy with the PRD — open question.** The PRD refers to a dedicated "state
|
||||
> debug view" / inspector. **No such feature exists** in the code today. What exists is
|
||||
> the `/concepts` showcase (live state highlight) and the `?scenario=slow|loading|empty|error`
|
||||
> interceptor (`src/app/shared/infrastructure/scenario.ts`) for exercising async states.
|
||||
> A JSON state inspector _would be trivial here_ — single one-way state means you could
|
||||
> render `JSON.stringify(state())` in a panel and watch every transition — precisely
|
||||
> because of everything in Part 6. Treat building one as a future task, not documented
|
||||
> reality.
|
||||
|
||||
---
|
||||
|
||||
## Part 5 — FP × atomic design (the unifying chapter)
|
||||
|
||||
The central idea of this guide:
|
||||
|
||||
> **FP and atomic design are the same principle at two scales — composition of pure
|
||||
> pieces, with state pushed to the boundary.**
|
||||
|
||||
### 5a. Atoms & molecules _are_ view functions
|
||||
|
||||
A pure function maps inputs → output with no side effects. A **presentational
|
||||
component** does exactly that: it maps **inputs → DOM**, emits events, and has **no
|
||||
injected services, no internal mutable state, no effects**. Same inputs → same DOM.
|
||||
That is referential transparency at the component scale.
|
||||
|
||||
In this codebase the form **atoms** (`text-input`, `radio-group`) are thin wrappers over
|
||||
the design system. They take config via `input()` and — because they implement
|
||||
Angular's `ControlValueAccessor` — emit changes through `[ngModel]` / `(ngModelChange)`.
|
||||
The `form-field` **molecule** composes a label + projected control + error. The
|
||||
`address-fields` **organism** (`src/app/registratie/ui/address-fields/address-fields.component.ts`)
|
||||
composes three `form-field`s and emits with `output()`:
|
||||
|
||||
```ts
|
||||
export class AddressFieldsComponent {
|
||||
value = input.required<AdresValue>(); // data in
|
||||
errors = input<AdresErrors>({}); // data in
|
||||
fieldChange = output<{ key: keyof AdresValue; value: string }>(); // events out
|
||||
}
|
||||
```
|
||||
|
||||
> **Read the code, not the slogan:** "events up" has two real forms here. Design-system
|
||||
> form atoms emit via `ControlValueAccessor`/`ngModel`; higher composites use `output()`.
|
||||
> Both are "data down, events up" — just different Angular mechanisms.
|
||||
|
||||
### 5b. Composition stays pure
|
||||
|
||||
Molecules compose atoms; organisms compose molecules — exactly like composing pure
|
||||
functions, where the composite is still pure. `address-fields` is pure because the
|
||||
`form-field` and `text-input` it's built from are pure. Each atomic level only uses the
|
||||
level(s) below it (see the hierarchy diagram in
|
||||
[`ARCHITECTURE.md` §1](./ARCHITECTURE.md#1-the-big-picture-three-contexts-four-layers)).
|
||||
|
||||
### 5c. Pages / containers are the TEA runtime (the shell)
|
||||
|
||||
The boundary between "pure presentational" and "stateful container" **is** the
|
||||
functional-core / imperative-shell line from §2e. The container (e.g.
|
||||
`herregistratie-wizard.component.ts`) is where the Model signal lives, where
|
||||
`dispatch`/`reduce` run, and where effects are wired. Everything below it is pure view.
|
||||
|
||||
### 5d. The loop, overlaid on the atomic layers
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph shell["Container = TEA runtime (imperative shell)"]
|
||||
ST["Model signal + dispatch + reduce + effects"]
|
||||
end
|
||||
subgraph pure["Pure presentational (functional core)"]
|
||||
O["Organism (e.g. address-fields)"]
|
||||
M["Molecules (form-field, async)"]
|
||||
A["Atoms (text-input, radio-group, button)"]
|
||||
end
|
||||
ST -->|"state flows DOWN as inputs"| O
|
||||
O --> M --> A
|
||||
A -.->|"events flow UP (output / ngModelChange)"| O
|
||||
O -.->|"(fieldChange, click)"| ST
|
||||
ST -->|"dispatch(Msg) → reduce → new state"| ST
|
||||
classDef l fill:#e5f1fb,stroke:#007bc7,color:#00567d;
|
||||
classDef c fill:#e8f5e9,stroke:#39870c,color:#1b5e20;
|
||||
class ST l; class O,M,A c;
|
||||
```
|
||||
|
||||
It is the identical Elm loop of Part 3 — just composed through the atomic hierarchy.
|
||||
|
||||
---
|
||||
|
||||
## Part 6 — How this reduces complexity (concrete payoffs)
|
||||
|
||||
Each property maps to a tangible benefit you can point at in this repo:
|
||||
|
||||
- **Single source of truth.** All wizard state is one `WizardState` value. To learn
|
||||
every way the screen can change, you read **one** function (`reduce`). There is no
|
||||
"who else mutates this?"
|
||||
|
||||
- **Pure, exhaustive `reduce`.** It's a unit test with **no mocks** — pass a state and a
|
||||
msg, assert the next state (`herregistratie.machine.spec.ts`). `assertNever` makes the
|
||||
compiler reject an unhandled `Msg`, so adding an event can't silently do nothing.
|
||||
|
||||
- **Illegal states won't compile.** `Submitting` carries `Valid` data and has no `errors`
|
||||
field, so "submit with errors showing" is unwritable. A whole bug class disappears
|
||||
before runtime — contrast the 8-state boolean soup in
|
||||
[`ARCHITECTURE.md` §2a](./ARCHITECTURE.md#2a-remotedata--one-value-instead-of-three-booleans).
|
||||
|
||||
- **Pure presentational components.** `address-fields` is tested by inputs → DOM and
|
||||
reused in two call-sites (the registratie wizard and the change-request form) with no
|
||||
hidden state to surprise you.
|
||||
|
||||
- **Isolated effects.** Every async path is a `submit-*` command returning a `Result`,
|
||||
invoked from one `runIf*` method. Async reasoning happens in one place, not sprinkled
|
||||
through the view.
|
||||
|
||||
- **Debuggability.** One value flowing one way means you can render and watch it (§4e) —
|
||||
reproduction is "set the Model to this," nothing more.
|
||||
|
||||
- **Onboarding.** It's one small pattern repeated everywhere. Learn it once; the recipes
|
||||
below are that pattern written down for the four common tasks.
|
||||
|
||||
---
|
||||
|
||||
## Part 7 — Recipes
|
||||
|
||||
Each recipe follows the existing pattern and naming, and ends with the same reminder:
|
||||
**this is the same loop, again.**
|
||||
|
||||
### Recipe A — Add an atomic component (atom / molecule / organism)
|
||||
|
||||
**When:** you genuinely need a new building block (not a one-off; reuse must earn it —
|
||||
see [CLAUDE.md §2](../CLAUDE.md)).
|
||||
|
||||
**Where:** `shared/ui/` if generic; a context's `ui/` if domain-specific. Pick the level
|
||||
by composition: composes nothing → **atom**; composes atoms → **molecule**; composes
|
||||
molecules into a domain block → **organism**.
|
||||
|
||||
**Steps:** build it **pure/presentational** — `input()`s for data/config, `output()`s
|
||||
for events, `computed()` for derived display; **no inject, no state, no effects**. Theme
|
||||
only with design tokens (no hardcoded hex — CI checks via `npm run check:tokens`).
|
||||
Add a co-located `*.stories.ts` titled `Layer/Name`.
|
||||
|
||||
```ts
|
||||
// shape — see src/app/registratie/ui/address-fields/address-fields.component.ts
|
||||
export class AddressFieldsComponent {
|
||||
value = input.required<AdresValue>();
|
||||
errors = input<AdresErrors>({});
|
||||
fieldChange = output<{ key: keyof AdresValue; value: string }>();
|
||||
}
|
||||
```
|
||||
|
||||
**Tests:** Storybook stories + the a11y addon are the UI coverage (repo convention — no
|
||||
component DOM tests; pure logic gets a `.spec.ts`, presentational components don't).
|
||||
|
||||
_This is the same loop, again: data down via `input()`, events up via `output()`._
|
||||
|
||||
### Recipe B — Add state + a state update (Elm-style)
|
||||
|
||||
**When:** a new thing can happen to a feature's state.
|
||||
|
||||
**Steps:** extend the `Model` immutably; add a `Msg` variant; handle it in the pure
|
||||
`reduce` returning a **new** model (keep the union exhaustive — `assertNever` guards
|
||||
you); expose derived values with `computed()`; `dispatch` the `Msg` where the event
|
||||
originates. Keep effects **out** of `reduce`.
|
||||
|
||||
```ts
|
||||
// 1. Msg variant (herregistratie.machine.ts)
|
||||
| { tag: 'Reset' }
|
||||
// 2. reduce arm
|
||||
case 'Reset': return initial;
|
||||
// 3. dispatch from the view
|
||||
(click)="dispatch({ tag: 'Reset' })"
|
||||
```
|
||||
|
||||
**Tests:** `reduce(model, msg)` → expected model. Pure, no mocks
|
||||
(`herregistratie.machine.spec.ts`).
|
||||
|
||||
_This is the same loop, again._
|
||||
|
||||
### Recipe C — Add a field + a validation rule
|
||||
|
||||
**When:** the form needs a new input with its own rule.
|
||||
|
||||
**Steps:** combine A and B. Add the field to the `Draft`; add/extend a `SetField`-style
|
||||
`Msg`; handle it in `reduce`. Write validation as a **pure** function returning `Result`
|
||||
(model it on `parseUren` / `parsePostcode`). **Derive** the error/validity with
|
||||
`computed()` — don't store what you can compute. Render with the `form-field` molecule +
|
||||
a field atom, and wire validity into the step/submit gating via a `computed()`.
|
||||
|
||||
```ts
|
||||
// pure rule (value-objects/) — Result<error, branded value>
|
||||
export function parsePostcode(raw: string): Result<string, Postcode> {
|
||||
/* ... */
|
||||
}
|
||||
// reduce uses it; the view shows the message via <app-form-field [error]="...">
|
||||
```
|
||||
|
||||
**Tests:** the parser's `.spec.ts` (each accept/reject case) + a `reduce` spec for the
|
||||
new field.
|
||||
|
||||
_This is the same loop, again — the rule is just another pure function._
|
||||
|
||||
### Recipe D — Add a wizard step
|
||||
|
||||
**When:** a flow needs another step.
|
||||
|
||||
**Steps:** compose A–C. Model the step's state in the Model; **derive** the visible steps
|
||||
rather than storing "next" — copy `visibleSteps(answers)` from
|
||||
`src/app/herregistratie/domain/intake.machine.ts`:
|
||||
|
||||
```ts
|
||||
export function visibleSteps(a: Answers): StepId[] {
|
||||
const steps: StepId[] = ['buitenland'];
|
||||
if (a.buitenlandGewerkt === 'ja') steps.push('buitenlandDetails');
|
||||
steps.push('uren');
|
||||
if (lageUren(a)) steps.push('scholing');
|
||||
steps.push('punten', 'review');
|
||||
return steps;
|
||||
}
|
||||
```
|
||||
|
||||
Build the step as a presentational component (Recipe A), composed from atoms/molecules.
|
||||
Derive "can advance" from a `computed()` over the step's validity; back-navigation keeps
|
||||
earlier answers for free (immutability). Effects (BRP/DUO/submit) go through `submit-*`
|
||||
commands in the shell, with results dispatched as `Msg`s (Part 4d).
|
||||
|
||||
**Tests:** `reduce` specs for the step's messages; a story for the step component; a
|
||||
`visibleSteps`/machine spec as the acceptance check (`intake.machine.spec.ts`).
|
||||
|
||||
_This is the same loop, again — now nested inside the wizard._
|
||||
|
||||
---
|
||||
|
||||
## Part 8 — Glossary
|
||||
|
||||
- **Pure function** — output depends only on inputs; no side effects. Trivial to test.
|
||||
- **Immutability** — never change a value in place; produce a new one (`{ ...s, x }`).
|
||||
- **Side effect** — anything beyond returning a value: HTTP, timers, `localStorage`, focus.
|
||||
- **Unidirectional data flow** — data down (inputs), events up (outputs); one loop.
|
||||
- **Product type** — a record holding several values at once (`interface Draft { ... }`).
|
||||
- **Sum type / discriminated (tagged) union** — a value that is exactly one of several
|
||||
labelled shapes; a `tag` field says which, and each shape carries only its own data.
|
||||
- **Make illegal states unrepresentable** — choose types so nonsense states can't be written.
|
||||
- **`Model`** — the single value holding all of a feature's state.
|
||||
- **`Msg`** — a union of every event that can happen to the state.
|
||||
- **`update` / `reduce`** — the one pure function mapping `(Model, Msg) → next Model`.
|
||||
(This codebase calls the factory parameter `update`; features export it as `reduce`.)
|
||||
- **`dispatch`** — send a `Msg`; the store runs `reduce` and updates the signal.
|
||||
- **Command** — an impure function that does I/O and returns a `Result`, after which the
|
||||
caller dispatches a `Msg` with the outcome.
|
||||
- **`Result<E, T>`** — success-or-error as a value: `{ ok: true, value }` or `{ ok: false, error }`.
|
||||
- **Value object / `Brand`** — a type whose validity is guaranteed by its parser
|
||||
(e.g. `Postcode`); a `Brand<string, 'Postcode'>` is only mintable through `parsePostcode`.
|
||||
- **`signal`** — Angular's reactive container for a value.
|
||||
- **`computed`** — a derived signal; recomputes automatically when its inputs change.
|
||||
- **Presentational (pure) component** — inputs in, events out, `computed()` for display;
|
||||
no inject, no state, no effects. A view function.
|
||||
- **Container component** — holds the Model signal, runs `dispatch`/`reduce`, wires
|
||||
effects. The TEA runtime / imperative shell.
|
||||
- **Functional core / imperative shell** — all decisions in pure functions; all I/O in a
|
||||
thin outer layer.
|
||||
- **TEA (The Elm Architecture)** — Model / Msg / update / view + the one-directional loop.
|
||||
|
||||
---
|
||||
|
||||
_See also:_ [`ARCHITECTURE.md`](./ARCHITECTURE.md) (reference deep-dive on RemoteData,
|
||||
the store, parse-don't-validate, and the .NET backend seam) and
|
||||
[ADR-0001](./architecture/0001-bff-lite-decision-dtos.md) (the BFF-lite + decision-DTO
|
||||
decision). Live demo: `/concepts` in the running app.
|
||||
@@ -0,0 +1,112 @@
|
||||
# UI/UX audit — BIG-register wizard (Rijkshuisstijl alignment)
|
||||
|
||||
Phase A deliverable. Audits the registration wizard and the shared atoms/chrome it
|
||||
relies on against the project's design system (`@rijkshuisstijl-community`, the NL Design
|
||||
System Rijkshuisstijl theme — the canonical system here; CIBG's own DS is not installed)
|
||||
and WCAG 2.1 AA. Each finding maps to the token / component / pattern that resolves it.
|
||||
This is an **alignment, not a redesign**: fixes go through the token/theme layer and
|
||||
existing components; no new libraries, no restricted Rijksoverheid assets.
|
||||
|
||||
Scope: registratie wizard + shared atoms/chrome (intake/herregistratie inherit the shared
|
||||
fixes). Their own step copy/inline styles are out of scope.
|
||||
|
||||
Priority: **H** = blocks "professional/accessible" bar (a11y or broken token); **M** =
|
||||
visible inconsistency; **L** = polish.
|
||||
|
||||
---
|
||||
|
||||
## 1. Tokens vs. hardcoded values
|
||||
|
||||
The app imports RHC design tokens but the wizard/chrome were written with **raw inline
|
||||
values** (≈48 in wizard+chrome, ≈16 in atoms). RHC exposes a full token set we should map
|
||||
onto: spacing `--rhc-space-max-{sm..5xl}`, type `--rhc-text-font-size-*` /
|
||||
`--rhc-text-font-weight-*`, color `--rhc-color-foreground-{default,subtle}` /
|
||||
`--rhc-color-{lintblauw,donkerblauw,cool-grey}-*` / `--rhc-color-wit`, radius
|
||||
`--rhc-border-radius-*`.
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| 1.1 | **H** | `var(--rhc-color-grijs-700)` is referenced in 3 wizards but **`--rhc-color-grijs-*` does not exist** in the package → the "Stap X van Y" text falls back to the default color, not grey. | `--rhc-color-foreground-subtle` |
|
||||
| 1.2 | **H** | `site-header`/`site-footer` set bg to `var(--rhc-color-lintblauw-700,#154273)` / `…-900,#01689b` — `lintblauw-900` doesn't exist (only 50–700), so the **hex fallback renders**. | header `--rhc-color-lintblauw-700`; footer `--rhc-color-donkerblauw-700`; text `--rhc-color-wit` |
|
||||
| 1.3 | M | Raw spacing everywhere: `0.25/0.5/0.75/1/1.5/2/3rem` as `gap`/`margin`/`padding` (≈30 occurrences). | `--rhc-space-max-{sm,md,lg,xl,2xl,3xl,5xl}` |
|
||||
| 1.4 | M | Inconsistent form/content widths: `28rem`, `30rem`, `32rem`, `64rem` as raw `max-width`. | app width tokens in `styles.scss` (`--app-form-max`, `--app-content-max`) mapped once |
|
||||
| 1.5 | M | `spinner` & `skeleton` hardcode greys/accent (`#cad0d6`, `#e8ebee`, `#f3f5f6`) and the spinner accent via bogus `--rhc-color-lintblauw-700,#154273` fallback. | `--rhc-color-cool-grey-{200,300}`, `--rhc-color-lintblauw-700` |
|
||||
| 1.6 | L | `site-header` hardcodes `font-weight:700/400`, `font-size:0.9rem`, `opacity:0.85`. | `--rhc-text-font-weight-{bold,regular}`, `--rhc-text-font-size-sm` |
|
||||
|
||||
## 2. Typography & heading hierarchy
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| 2.1 | **H** | Each page has one `<h1>` (page-shell) but the **wizard steps have no `<h2>`** — step title is a plain `<p>`. Screen-reader users get no step heading to navigate to. | per-step `<h2>` via `app-heading [level]="2"` |
|
||||
| 2.2 | M | Type scale not applied to bespoke text (header wordmark uses raw sizes). | `--rhc-text-font-size-*`, `app-heading` |
|
||||
|
||||
## 3. Color & contrast
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| 3.1 | **H** | Because of 1.1/1.2 the actual rendered colors are partly accidental (default text color, hex fallbacks) — contrast is unverified. | map to real tokens, then verify AA |
|
||||
| 3.2 | M | Palette discipline: confirm a single primary blue (lintblauw) + neutrals + status-only accents (`alert` types ok/info/warning/error already map to Utrecht alert variants). | keep alert variants; route blues to lintblauw/donkerblauw tokens |
|
||||
|
||||
## 4. Spacing & layout
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
|
||||
| 4.1 | M | Repeated inline idioms: `display:flex;gap:0.5rem;margin-top:1rem` (button rows, 6×), `max-width:30rem` (forms, 3×), `margin:1rem 0` (summaries). | utility classes `.app-button-row`, `.app-form`, `.app-stack` in `styles.scss` |
|
||||
| 4.2 | L | `shell` uses a custom `--app-content-max:64rem` defined inline. | promote to the `styles.scss` token layer |
|
||||
|
||||
## 5. Component reuse
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | --- | ----------------------------------------------------------------------------------------------------------------------- | --------------------- |
|
||||
| 5.1 | L | `intake-wizard` review step uses bespoke `<div><dt><dd>` instead of `app-data-row` (out of scope here; note for later). | `app-data-row` |
|
||||
| 5.2 | M | Button rows / form containers are ad-hoc inline layout rather than a shared idiom. | utility classes (4.1) |
|
||||
|
||||
## 6. Form, validation & status patterns
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| 6.1 | **H** | `form-field` renders the error with `role="alert"` but **no `id`**, and the projected input has **no `aria-describedby`** → error not programmatically linked. | error `id="${fieldId}-error"`; input `aria-describedby` |
|
||||
| 6.2 | **H** | `text-input` never sets `aria-invalid` even when `invalid()` is true. | `[attr.aria-invalid]` |
|
||||
| 6.3 | **H** | `radio-group` has `role="radiogroup"` but **no accessible name** and no invalid state. | `aria-labelledby="${name}-label"`, add `invalid` input → `aria-invalid`/`aria-describedby` |
|
||||
| 6.4 | M | Async states (`app-async`) render but aren't announced (no live region) — SR users miss loading→loaded/empty/error. | wrap in `aria-live="polite"` + `aria-busy` |
|
||||
| 6.5 | L | No error-summary pattern; per-field inline errors only. Acceptable for short steps; revisit if steps grow. | (defer) |
|
||||
|
||||
## 7. Page chrome
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | --- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| 7.1 | M | No breadcrumb / location context in the portal chrome. | new `app-breadcrumb` (`.rhc-breadcrumb-nav`), wired via `page-shell` |
|
||||
| 7.2 | M | Step progress is non-semantic text. | new `app-stepper` (`<ol>`, `aria-current="step"`) |
|
||||
| 7.3 | L | Skip-link uses `left:-999px` (works) and landmarks (`header/main/footer`) are correct. | keep; tokenize offset |
|
||||
|
||||
## 8. Copy & tone
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | --- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| 8.1 | M | Microcopy is functional but informal/uneven (labels, helper text, button text, the manual-diploma warning, the controle summary). | formal, plain official Dutch; consistent with domain terms (registratie wizard + chrome only) |
|
||||
|
||||
## 9. Accessibility (WCAG 2.1 AA) — consolidated
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| --- | ----- | --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| 9.1 | **H** | No focus management: after Next/Back focus stays on the button; SR/keyboard users aren't moved to the new step. | move focus to the step `<h2>` (`tabindex="-1"`) on `cursor` change |
|
||||
| 9.2 | **H** | Error/label/invalid association missing (6.1–6.3). | as above |
|
||||
| 9.3 | M | Step changes & async states not announced (6.4). | `aria-live` |
|
||||
| 9.4 | M | `skeleton` placeholders are announced as content. | `aria-hidden="true"` |
|
||||
| 9.5 | L | No automated a11y in CI; only Storybook `addon-a11y` (axe) exists. | add a11y `parameters` in `preview.ts`; keep manual keyboard/SR pass; no new deps |
|
||||
|
||||
## 10. Responsive
|
||||
|
||||
| # | Pri | Finding | Resolves to |
|
||||
| ---- | --- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| 10.1 | M | Fixed `rem` widths and inline flex rows; verify reflow + ≥24px targets at narrow widths. | token widths + `flex-wrap` on button rows; manual check |
|
||||
|
||||
---
|
||||
|
||||
## Tooling note
|
||||
|
||||
No eslint/stylelint/axe-core in the repo, and the prime directive forbids adding
|
||||
dependencies. Token compliance is enforced by a **no-dep `check:tokens` npm script**
|
||||
(greps touched templates for raw `#hex` and bare `rem/px` outside `var(...)`);
|
||||
accessibility uses the already-installed **Storybook `addon-a11y`** plus a manual
|
||||
keyboard/screen-reader pass.
|
||||
@@ -0,0 +1,59 @@
|
||||
# WCAG manual checklist
|
||||
|
||||
Automation (axe on every story — WP-01; template a11y lint — WP-17; the form-field/alert
|
||||
play tests — WP-16) catches structural and component-level issues. It cannot catch
|
||||
cross-page flows: tab order across a whole page, focus traps, zoom/reflow, or how a
|
||||
screen reader actually narrates a journey. This checklist is the manual complement —
|
||||
a living doc, filled in per page as it's walked, not a one-time sign-off.
|
||||
|
||||
See `src/docs/a11y.mdx` (Storybook → Foundations → Accessibility) for how this fits
|
||||
with the automated layers.
|
||||
|
||||
## How to run a page through this checklist
|
||||
|
||||
1. **Keyboard walk**: `Tab`/`Shift+Tab` through the whole page. Every interactive
|
||||
element reachable, in a sensible order, with a visible focus ring; no trap (you can
|
||||
always tab back out).
|
||||
2. **No traps**: a modal/dropdown/menu, if present, returns focus on close/`Escape`.
|
||||
3. **200% zoom / reflow**: browser zoom to 200% (or a 320px-wide viewport). Content
|
||||
reflows to one column; nothing is clipped or requires horizontal scroll.
|
||||
4. **Screen reader pass**: NVDA (Windows) or VoiceOver (macOS) — navigate by heading
|
||||
and by tab; confirm labels, descriptions, and error announcements are heard, not
|
||||
just visible.
|
||||
5. **Visible focus**: every focused element has a visible indicator (no
|
||||
`outline: none` without a replacement).
|
||||
6. **Error announcement**: submitting an invalid form announces the error (this is
|
||||
what WP-16's `role="alert"` + `aria-describedby` wiring is for) — confirm it's
|
||||
actually heard, not just present in the DOM.
|
||||
|
||||
## Status
|
||||
|
||||
Legend: ✅ pass · ⚠️ pass with notes · ❌ fails · — not yet walked
|
||||
|
||||
| Page | Keyboard walk | No traps | 200% zoom/reflow | Screen reader | Visible focus | Error announcement |
|
||||
| -------------------------- | :-----------: | :------: | :--------------: | :-----------: | :-----------: | :----------------: |
|
||||
| Login (`/login`) | ✅ | ✅ | ✅ | — | ✅ | n/a¹ |
|
||||
| Dashboard (`/dashboard`) | — | — | ❌² | — | — | — |
|
||||
| Registratie wizard | — | — | — | — | ✅³ | ✅³ |
|
||||
| Herregistratie wizard | — | — | — | — | — | — |
|
||||
| Brief (letter composition) | — | — | — | — | — | — |
|
||||
|
||||
¹ Login's demo form has no client-side validation/error state to exercise.
|
||||
² **Real finding, not fixed here**: `aanvraag-block`'s warning `app-alert` (two
|
||||
`app-button` actions) overflows the viewport at a 320px width — its `.feedback` flex
|
||||
row doesn't wrap, pushing the second button past the edge. Fixing it is a genuine
|
||||
CSS change to a live component, which is exactly the "full manual audit" scope this
|
||||
WP defers (see Out of scope) — logged here instead of silently fixed or silently
|
||||
ignored.
|
||||
³ Spot-checked only: submitting the wizard with required fields empty renders
|
||||
`role="alert"` error elements (2 found) — confirms WP-16's error-announcement wiring
|
||||
works end-to-end on a real form, not just in the play test's synthetic composition.
|
||||
Full keyboard walk / zoom / screen-reader pass on this page not yet done.
|
||||
|
||||
"Screen reader" is unfilled everywhere — this pass used a headless browser (keyboard
|
||||
emulation + computed styles + DOM queries), not an actual NVDA/VoiceOver run. Don't
|
||||
read the automation above as a substitute for that row; it isn't one.
|
||||
|
||||
Filling in the remaining rows (and fixing finding ²) is ongoing work (WP-17's
|
||||
out-of-scope note) — this table makes what's been checked, and what hasn't, visible
|
||||
rather than assumed.
|
||||
Reference in New Issue
Block a user