feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
|
||||
import * as AlertStories from '../src/ui/alert/alert.stories';
|
||||
import * as FormFieldStories from '../src/ui/form-field/form-field.stories';
|
||||
|
||||
<Meta title="Foundations/Accessibility" />
|
||||
|
||||
# Accessibility
|
||||
|
||||
No single tool catches every a11y class of bug, so this repo layers four, each catching
|
||||
what the ones below/above it can't.
|
||||
|
||||
## The layers
|
||||
|
||||
1. **Axe on every story** (WP-01) — `@storybook/addon-a11y` in the panel, plus
|
||||
`@storybook/test-runner` + `axe-playwright` gating CI (`npm run test-storybook:ci`).
|
||||
Catches structural/contrast/ARIA-shape violations on every component, automatically,
|
||||
as soon as a story exists. Escape hatch: `parameters: { a11y: { disable: true } }`,
|
||||
only with an inline justification comment + a cross-reference to the WP that will fix
|
||||
it (see e.g. `task-list.stories.ts`).
|
||||
2. **Template a11y lint** (WP-17) — `angular-eslint`'s `templateAccessibility` config
|
||||
(`alt-text`, `label-has-associated-control`, `click`/`mouse-events-have-key-events`,
|
||||
`interactive-supports-focus`, `valid-aria`, `no-autofocus`, …) running on every inline
|
||||
template via `angular.processInlineTemplates` (this repo has no `.html` files — every
|
||||
template is a string in the `@Component` decorator; the processor extracts each one
|
||||
into a virtual file the template rules can lint). Catches missing alt text, unlabelled
|
||||
controls, and interactive elements that can't be reached by keyboard — at lint time,
|
||||
before a story even exists.
|
||||
3. **Play tests** (WP-16) — Storybook stories assert the wiring axe/lint can't see:
|
||||
`form-field.stories.ts`'s canonical composition asserts `aria-describedby` joins
|
||||
`-desc`/`-error` in the right order; `alert.stories.ts` asserts `role="alert"` for
|
||||
errors vs `role="status"` for info/ok/warning. These run as part of the same
|
||||
`test-storybook:ci` gate as the axe checks, so a regression fails CI, not just a panel.
|
||||
4. **Manual WCAG checklist** (`docs/reference/wcag-checklist.md`) — what none of the above can see:
|
||||
tab order across a whole page, focus traps, 200%-zoom reflow, and how a real screen
|
||||
reader narrates a flow. A living per-page checklist, not a one-time audit — it already
|
||||
caught a real bug (a dashboard alert overflowing at 320px) that no automated layer here
|
||||
would have flagged.
|
||||
|
||||
## Component wiring this protects
|
||||
|
||||
<Canvas of={FormFieldStories.WithDescriptionAndError} />
|
||||
|
||||
The description (`-desc`) and error (`-error`) ids are joined in a pinned order so a
|
||||
screen reader announces the hint, then the error, never neither. See
|
||||
`text-input.component.ts`'s `describedBy()`.
|
||||
|
||||
<Canvas of={AlertStories.Error} />
|
||||
|
||||
Errors are `role="alert"` (assertive — interrupts, because the user needs to know
|
||||
_now_); info/ok/warning stay `role="status"` (polite) so they don't interrupt whatever
|
||||
the user is doing. See `alert.component.ts`.
|
||||
|
||||
## Route-change focus
|
||||
|
||||
Client-side routing has no page (re)load, so a screen reader/keyboard user's focus stays
|
||||
wherever it was — usually the link they just clicked, now detached from any content that
|
||||
matters. `shared/layout/route-focus.ts` moves focus to the new page's `<h1>` (every page
|
||||
has exactly one via `page-shell`) on every navigation after the initial load, deferred via
|
||||
`afterNextRender` so it doesn't race the view-transition DOM swap. Scroll position resets
|
||||
the same way (`withInMemoryScrolling`), both wired once in `app.config.ts` — not per page.
|
||||
|
||||
## Where the skip register lives
|
||||
|
||||
`npm run lint` fails the build on a real template a11y violation, and `test-storybook:ci`
|
||||
fails it on a real axe violation. Both can be locally disabled — the lint rule via a
|
||||
normal ESLint disable comment, axe via `parameters: { a11y: { disable: true } }` — but
|
||||
only with a comment naming _why_ and a cross-reference to the WP expected to remove the
|
||||
skip (see `docs/project/backlog/WP-13-cibg-gap-register.md`'s marker convention, reused here).
|
||||
Grep `a11y: { disable: true }` in `*.stories.ts` for the current list.
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
|
||||
import * as ButtonStories from '../src/ui/button/button.stories';
|
||||
import * as FormFieldStories from '../src/ui/form-field/form-field.stories';
|
||||
import * as PageShellStories from '../src/layout/page-shell/page-shell.stories';
|
||||
import * as DocumentUploadStories from '../src/ui/upload/document-upload/document-upload.stories';
|
||||
|
||||
<Meta title="Foundations/Atomic Design" />
|
||||
|
||||
# Atomic design
|
||||
|
||||
Every screen in this app is built from a small set of layers, each composed **only from
|
||||
the layer below it**. Read a screen top-down and you always land on the same handful of
|
||||
atoms — that is the whole point: fewer things to understand, nothing bespoke per page.
|
||||
|
||||
<div style={{ display: 'grid', gap: '0.5rem', maxWidth: '32rem', margin: '1.5rem 0' }}>
|
||||
{[
|
||||
[
|
||||
'Templates',
|
||||
'shared/layout',
|
||||
'shell, page-shell, wizard-shell — the page skeleton',
|
||||
'#1e3a5f',
|
||||
],
|
||||
[
|
||||
'Organisms',
|
||||
'shared/ui/upload/document-upload …',
|
||||
'self-contained sections that own a bit of behaviour',
|
||||
'#2a5a8a',
|
||||
],
|
||||
['Molecules', 'shared/ui/form-field, async …', 'a label + control + error, grouped', '#3f7cb5'],
|
||||
[
|
||||
'Atoms',
|
||||
'shared/ui/button, text-input …',
|
||||
'thin wrappers over CIBG Huisstijl (Bootstrap) CSS classes',
|
||||
'#6aa6d8',
|
||||
],
|
||||
].map(([name, where, why, bg], i) => (
|
||||
<div
|
||||
key={name}
|
||||
style={{
|
||||
background: bg,
|
||||
color: '#fff',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '6px',
|
||||
marginLeft: `${i * 1.5}rem`,
|
||||
}}
|
||||
>
|
||||
<strong>{name}</strong> <span style={{ opacity: 0.85 }}>— {why}</span>
|
||||
<div
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.75rem', opacity: 0.8, marginTop: '0.2rem' }}
|
||||
>
|
||||
{where}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
## The rule, enforced
|
||||
|
||||
**Each layer only uses layers below it, and dependencies point inward.** This is not a
|
||||
convention you have to remember — `eslint.config.mjs` fails the build if `domain/` imports
|
||||
Angular, or if a context imports "upward". See [the FP-in-the-UI primer](?path=/docs/foundations-fp-in-the-ui--docs)
|
||||
for how the same discipline shapes state and effects.
|
||||
|
||||
## A composition chain, live
|
||||
|
||||
Here is one real chain from atom → molecule → template. Each is a published Storybook
|
||||
story below; click through to the sidebar entries to explore every variant.
|
||||
|
||||
### Atom — `button`
|
||||
|
||||
A thin wrapper: we own a typed `variant` input, the CIBG CSS owns the pixels.
|
||||
|
||||
<Canvas of={ButtonStories.Primary} />
|
||||
|
||||
### Molecule — `form-field`
|
||||
|
||||
Label + control + error text, grouped so the error is announced via `role="alert"`. It
|
||||
composes atoms; it adds no new visual primitives of its own.
|
||||
|
||||
<Canvas of={FormFieldStories.WithError} />
|
||||
|
||||
### Organism — `document-upload`
|
||||
|
||||
`shared/ui/upload/document-upload` composes molecules (a file input, alert, progress bar,
|
||||
chips) into a section that owns real upload behaviour.
|
||||
|
||||
<Canvas of={DocumentUploadStories.Default} />
|
||||
|
||||
### Template — `page-shell`
|
||||
|
||||
The page skeleton — title, optional back-link, content slot. Pages drop composed
|
||||
organisms into it; the template never knows what they are.
|
||||
|
||||
<Canvas of={PageShellStories.WithBackLink} />
|
||||
|
||||
## Why bother
|
||||
|
||||
A new page should be **composition of existing blocks**. Adding a new building block is the
|
||||
exception, not the reflex — if you reach for one, that is a signal to check whether an
|
||||
existing atom/molecule already covers it. Fewer primitives → less to test, less to learn,
|
||||
one place to fix a bug.
|
||||
|
||||
## Convergence decisions — pairs that look duplicated but stay separate
|
||||
|
||||
Periodically we audit for near-duplicate blocks. Some collapse into one; a few **look**
|
||||
similar but earn their separation. This table records the "don't merge these" verdicts so
|
||||
the next person doesn't spend an afternoon re-deciding. (Deliberate CIBG-specific deviations
|
||||
live in [CIBG gaps](?path=/docs/foundations-cibg-gap-register--docs); the FE⇄DS "same shape, different
|
||||
context" cases in [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs).)
|
||||
|
||||
| Pair | Why kept separate |
|
||||
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `choice-link` vs `application-link` | Share the same `to`/`clickable`/`activate` navigation triad, but bind **different vendored patterns** — CIBG _Keuzelijst_ (`.keuzelijst__link`, `.stretched-link`) vs _Aanvragen_ (`.dashboard-block.applications li a`) — with different list/host semantics (`app-choice-link` renders an inner `<li>`; `application-link` **is** the `<li>`). Merging would fight the vendored CSS. Extract the shared triad into a mixin only if it grows. |
|
||||
| `text-input` / `radio-group` / `checkbox` | Share only the standard Angular **ControlValueAccessor** boilerplate (the `writeValue`/`registerOn*`/`setDisabledState` block). They render genuinely different controls, so they stay three atoms. A base CVA class is the only DRY move — a refactor, not a component merge, and not worth it at three. |
|
||||
| `button variant="subtle"` (`.btn-link`) vs `app-link` | A subtle button _looks_ like a link but is an **action** (`<button>`, emits click); `app-link` is **navigation** (`<a routerLink>`). Different semantics and a11y roles → keep both. |
|
||||
| `shell` / `page-shell` / `wizard-shell` | Three distinct jobs that **compose**, not overlap: persistent app chrome (mounted once) → routed page body → the wizard form/step frame. |
|
||||
| Raw `<h3>` in `application-link` vs the `heading` atom | The vendored `.applications li a h3` chain styles the **bare `<h3>`**; wrapping it in the `app-heading` host element would sit between the anchor and the h3 and can break that selector. This is the one sanctioned raw-heading; everywhere else uses `<app-heading [level]>`. |
|
||||
|
||||
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
|
||||
`shared` as design-system primitives; relocate one into its consuming context only if it stays
|
||||
single-consumer long-term. That is a watch-item, not a merge.
|
||||
|
||||
The last audit also **removed** a genuinely dead block — a generic white `app-card` with zero
|
||||
consumers (superseded by the grey `app-data-block` as the single data surface).
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/BDD" />
|
||||
|
||||
# Behaviour-driven tests
|
||||
|
||||
Tests here read as **specifications of behaviour**, not checks of implementation. A test
|
||||
says what the system _does_ — in the domain's own words — so a failing test names a broken
|
||||
behaviour, and the suite doubles as living documentation. This is the BDD half of the
|
||||
[Testing strategy](?path=/docs/foundations-testing-strategy--docs) (which owns _what to
|
||||
test, by layer_); BDD owns _how each test is phrased and scoped_.
|
||||
|
||||
## Three rules
|
||||
|
||||
### 1. `describe` = the subject, `it` = one observable behaviour
|
||||
|
||||
The `describe()` block names the unit under test; each `it()` states a single behaviour in
|
||||
**declarative present tense** — the implicit subject is "it". No `should`, no
|
||||
Given/When/Then ceremony: present-tense declaration already reads as a spec.
|
||||
|
||||
```ts
|
||||
describe('parsePostcode', () => {
|
||||
it('normalises to "1234 AB" (uppercase, single space, trimmed)', () => { … });
|
||||
it('rejects malformed input', () => { … });
|
||||
});
|
||||
```
|
||||
|
||||
Read top-to-bottom it _is_ the spec: "parsePostcode — normalises to 1234 AB; rejects
|
||||
malformed input."
|
||||
|
||||
### 2. One behaviour per test
|
||||
|
||||
A test asserts **one behaviour**, not one `expect()`. Several assertions that pin down the
|
||||
_same_ behaviour belong together; assertions about _different_ behaviours belong apart.
|
||||
|
||||
| Keep together (one behaviour) | Split apart (separate behaviours) |
|
||||
| -------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| A `Result`'s `.ok` then its `.value` | The `ok` branch **and** the `err` branch of a transition |
|
||||
| A whole-object `toEqual` | An invalid-input case **and** a valid-input case |
|
||||
| A loop asserting one rule over many inputs | Two independent state transitions |
|
||||
| A truth-table (`draft` → true, `approver` → false) of one rule | An authorization check **and** a rendering check |
|
||||
|
||||
A title that needs `/`, `;`, "then" or "and" to join two behaviours is the smell — split it,
|
||||
and each half gets its own present-tense name.
|
||||
|
||||
### 3. Speak the ubiquitous language (the DDD tie-in)
|
||||
|
||||
Test names use the **domain vocabulary**, not technical jargon — the same words as the
|
||||
[bounded contexts](?path=/docs/foundations-domain-driven-design--docs): a _behandelaar_
|
||||
drafts, a _beoordelaar_ approves, a _herregistratie_ is _ingediend_. The test name is
|
||||
readable by someone who knows the domain but not the code.
|
||||
|
||||
```ts
|
||||
it('drafter cannot approve or reject even when submitted', …);
|
||||
it('confirmed dutch proficiency requires taalvaardigheid proof', …);
|
||||
```
|
||||
|
||||
## How it fits TDD & DDD
|
||||
|
||||
- **TDD** — the loop is red → green → refactor: write the behaviour as a failing `it`, make
|
||||
it pass, then clean up. Because tests describe behaviour (not internals), a refactor that
|
||||
preserves behaviour keeps them green. Pure domain logic is tested directly — no `TestBed`
|
||||
(see [Testing strategy](?path=/docs/foundations-testing-strategy--docs)).
|
||||
- **DDD** — behaviour is expressed in the ubiquitous language, so the spec and the code
|
||||
share one vocabulary. Domain rules (reducers, value-object parsers, policies) are the
|
||||
richest specs; the wire boundary is tested as "rejects malformed input", the UI as
|
||||
Storybook stories.
|
||||
|
||||
## Where to look
|
||||
|
||||
Canonical behaviour specs in the repo: `registratie/domain/value-objects/postcode.spec.ts`
|
||||
(parser behaviour), `registratie/domain/registratie-wizard.machine.spec.ts` (one transition
|
||||
per test), and backend `AuthzTests.cs` (rule truth-tables). The
|
||||
[Testing strategy](?path=/docs/foundations-testing-strategy--docs) page maps which layer
|
||||
gets which kind of test.
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/CIBG Gap Register" />
|
||||
|
||||
# CIBG gap register
|
||||
|
||||
CIBG Huisstijl (ADR-0003) is the design system of record — a component wraps a vendored class
|
||||
before it hand-rolls anything. **Grep the vendored CSS
|
||||
(`public/cibg-huisstijl/css/huisstijl.min.css`) before adding new surface CSS to a component.**
|
||||
When no vendored pattern exists, the component is a **CIBG-gap extension**: allowed, but only
|
||||
marked so every deviation from the design system is auditable.
|
||||
|
||||
## Marker format
|
||||
|
||||
```ts
|
||||
// CIBG-GAP EXTENSION: <closest CIBG concept, or "n/a"> — <why hand-rolled>
|
||||
```
|
||||
|
||||
placed above the `@Component` decorator, plus `parameters: { cibgGap: true }` and a
|
||||
"CIBG-gap extension" line in the story's `docs.description.component`.
|
||||
|
||||
## The register
|
||||
|
||||
| Component | Closest CIBG concept | Why hand-rolled |
|
||||
| --------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `skeleton` | Laadindicatie | No loading-skeleton class in the vendored build. |
|
||||
| `spinner` | Laadindicatie | No loading-spinner class in the vendored build. |
|
||||
| `rich-text-editor` | Tekstgebied | No rich-text/WYSIWYG pattern; toolbar buttons still use vendored `.btn-ghost` (WP-10). |
|
||||
| `wizard-shell` (error summary only) | Foutmelding | No error-summary/Veldvalidatie list class; renders inside a vendored `.feedback-error` alert. |
|
||||
| `application-link` (non-navigating row) | Aanvragen | The vendored `.dashboard-block.applications li a` chain only styles `<a>`; `.static-row` mirrors it from tokens for the informational (non-link) case. |
|
||||
| `debug-state` | n/a | Dev-only tool, deliberately off-theme — see the component's own `ponytail:` note. |
|
||||
| `status-badge` | n/a | Deliberate custom status dot, not Bootstrap's `.badge` (pill padding/colour don't fit). |
|
||||
| `placeholder-chip` | n/a | No vendored inline-chip/tag class. |
|
||||
|
||||
Not a gap: `confirmation` renders entirely with vendored `.confirmation*` classes (no `styles:
|
||||
[...]` block) — its header comment names the pattern, no marker needed. The `upload/` suite
|
||||
renders entirely with vendored classes (`.file-picker-drop-area`, `.btn-upload`, …) — reworked
|
||||
onto them rather than marked (see WP-11's correction note). `task-list`, `application-list`, and
|
||||
`choice-list` each wrap a distinct vendored pattern (Keuzelijst / Aanvragen / Keuzelijst) and name
|
||||
it in their own header comment — no marker needed, they don't hand-roll surface CSS.
|
||||
|
||||
## Hygiene
|
||||
|
||||
`upload-status-banner` (a 23-line near-identity wrapper over `app-alert` with one consumer) was
|
||||
deleted; its consumer (`document-upload`) now uses `<app-alert>` directly.
|
||||
|
||||
`card` (`.app-card`, a generic white surface) was deleted — it had zero consumers; the grey
|
||||
vendored **Datablock** (`app-data-block`) is the single data surface. The convergence verdicts
|
||||
for the pairs we deliberately keep separate live in
|
||||
[Atomic Design → Convergence decisions](?path=/docs/foundations-atomic-design--docs).
|
||||
|
||||
## Keeping this register honest
|
||||
|
||||
No automated check diffs this table against the markers in code (skipped as not worth a CI
|
||||
script for a table this small — reviewed at PR time instead, same as any other doc). If markers
|
||||
and this table drift, trust the code and fix the table.
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
import { useState, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
<Meta title="Foundations/Design Tokens" />
|
||||
|
||||
# Design tokens
|
||||
|
||||
We do not hand-write colours or spacing. `src/styles.scss` defines a semantic `--rhc-*` token
|
||||
vocabulary and redefines every one of those tokens onto the vendored **CIBG Huisstijl**
|
||||
(Bootstrap 5.2) values — `--bs-*`/`--ro-*` custom properties where one exists, CIBG palette hex
|
||||
otherwise (that one file is exempt from `npm run check:tokens`, which fails the build on any
|
||||
_other_ hardcoded hex colour in atoms/molecules/chrome). The `--rhc-*` names are an internal
|
||||
alias set now; the values are CIBG's.
|
||||
|
||||
**Prefer a CIBG class over a token where one exists** — `.btn`, `.form-control`, `.card`,
|
||||
`.stepper`, `.confirmation`, `.applications`, … are already themed by the vendored CSS (see
|
||||
`public/cibg-huisstijl/`). Reach for a `--rhc-*` token only where CIBG has no ready-made class
|
||||
(an `alert` surface, a skeleton loader, a status badge — see ADR-0003).
|
||||
|
||||
> Resolved values below are read live from the running theme via `getComputedStyle`, so they
|
||||
> can't drift from what ships. `body.brand--cibg` (set in `index.html` and Storybook's
|
||||
> `preview.ts`) activates CIBG's robijn/lintblauw palette; no extra wrapper class is needed.
|
||||
|
||||
export const Resolved = ({ token }) => {
|
||||
const ref = useRef(null);
|
||||
const [val, setVal] = useState('');
|
||||
useLayoutEffect(() => {
|
||||
if (ref.current) setVal(getComputedStyle(ref.current).getPropertyValue(token).trim());
|
||||
}, [token]);
|
||||
return (
|
||||
<span ref={ref} style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#666' }}>
|
||||
{val || '…'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
## When to use which token
|
||||
|
||||
- **CIBG class first** (see above) — a token is for the gaps a CIBG class doesn't cover.
|
||||
- **Semantic first** — reach for a role token (`--rhc-color-foreground-default`,
|
||||
`--rhc-color-border-default`, `--rhc-color-foreground-link`) before a raw palette step
|
||||
(`--rhc-color-lintblauw-500`). Roles survive a theme swap; palette steps don't.
|
||||
- **`--rhc-space-max-*`** for all spacing/gaps — never a raw `rem`.
|
||||
- **`--app-*`** (in `src/styles.scss`) only for app measures CIBG has no token for
|
||||
(`--app-content-max`, `--app-form-narrow`). If you're tempted to add one, check CIBG first.
|
||||
|
||||
## Spacing scale — `--rhc-space-max-*`
|
||||
|
||||
<div style={{ display: 'grid', gap: '0.4rem', margin: '1rem 0' }}>
|
||||
{['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl'].map((step) => {
|
||||
const token = `--rhc-space-max-${step}`;
|
||||
return (
|
||||
<div key={step} style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<code style={{ width: '12rem', fontSize: '0.78rem' }}>{token}</code>
|
||||
<div
|
||||
style={{
|
||||
height: '1rem',
|
||||
width: `var(${token})`,
|
||||
background: 'var(--rhc-color-lintblauw-500)',
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
/>
|
||||
<Resolved token={token} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
## Semantic colours
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(14rem, 1fr))',
|
||||
gap: '0.75rem',
|
||||
margin: '1rem 0',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
'--rhc-color-foreground-default',
|
||||
'--rhc-color-foreground-subtle',
|
||||
'--rhc-color-foreground-link',
|
||||
'--rhc-color-layout',
|
||||
'--rhc-color-lintblauw-500',
|
||||
'--rhc-color-lintblauw-700',
|
||||
'--rhc-color-border-default',
|
||||
'--rhc-color-border-strong',
|
||||
'--rhc-color-cool-grey-100',
|
||||
].map((token) => (
|
||||
<div key={token} style={{ border: '1px solid #ddd', borderRadius: '6px', overflow: 'hidden' }}>
|
||||
<div style={{ height: '3rem', background: `var(${token})` }} />
|
||||
<div style={{ padding: '0.4rem 0.5rem' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', wordBreak: 'break-all' }}>
|
||||
{token}
|
||||
</div>
|
||||
<Resolved token={token} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
|
||||
import * as AsyncStories from '../src/ui/async/async.stories';
|
||||
|
||||
<Meta title="Foundations/FP in the UI" />
|
||||
|
||||
# Functional programming in the UI
|
||||
|
||||
The components in this library are the _view_. Behind them, three small functional tools do
|
||||
the heavy lifting — all so that **illegal states can't be represented**. This page is the
|
||||
Storybook front door; the full narrative lives in `docs/reference/fp-tea-atomic-design.md`, and a
|
||||
side-by-side "before/after" runs at the app's **`/concepts`** route.
|
||||
|
||||
## 1. `RemoteData<E,T>` — async has four states, not a boolean soup
|
||||
|
||||
`src/app/shared/application/remote-data.ts`. Instead of juggling `loading`, `error`, and
|
||||
`data` flags (which permit "loading **and** error" nonsense), one tagged union:
|
||||
`Loading | Empty | Failure | Success`. You combine sources with `map`/`map2`/`andThen` and
|
||||
render it through the `async` molecule — exactly one of four templates shows, by
|
||||
construction:
|
||||
|
||||
<Canvas of={AsyncStories.Loading} />
|
||||
<Canvas of={AsyncStories.ErrorState} />
|
||||
|
||||
## 2. The Elm-style store — all state in one Model, changed only by pure `reduce`
|
||||
|
||||
`src/app/shared/application/store.ts` + the `*.machine.ts` files. State is one tagged-union
|
||||
value; the template never mutates it, it `dispatch`es a message and a **pure**
|
||||
`reduce(model, msg)` returns the next state. Side effects live in a _command_, never in the
|
||||
reducer:
|
||||
|
||||
```ts
|
||||
// reducer = "what the new state is" — pure, testable, no I/O
|
||||
function reduce(model: Model, msg: Msg): Model { … }
|
||||
|
||||
// command = "go do it, then say what happened"
|
||||
async function submit(...) {
|
||||
const res = await http(...);
|
||||
dispatch(res.ok ? { tag: 'Submitted' } : { tag: 'Failed', error: res.error });
|
||||
}
|
||||
```
|
||||
|
||||
Because state is one value, the whole thing is inspectable and every transition has a spec.
|
||||
|
||||
## 3. Parse, don't validate — raw input becomes a branded type once
|
||||
|
||||
`src/app/registratie/domain/value-objects/`. A `Postcode` is a distinct type from `string`,
|
||||
mintable only through `parsePostcode`, which returns a `Result`. Once you hold the type, you
|
||||
never re-check it — the type _is_ the proof. Compose the parse pipeline with the `Result`
|
||||
combinators in `src/app/shared/kernel/fp.ts` (`map`, `mapErr`, `andThen`, `fold`) rather than
|
||||
hand-branching `r.ok ? … : …` at every step.
|
||||
|
||||
```ts
|
||||
parsePostcode(raw) // Result<string, Postcode>
|
||||
|> mapErr(toLocalizedMessage) // swap raw msg → UI copy
|
||||
|> map(toDomain) // only runs on success
|
||||
```
|
||||
|
||||
## How it connects to atomic design
|
||||
|
||||
Atoms and molecules are pure view functions of their inputs; pages are the TEA runtime (the
|
||||
"shell") that holds the store and wires effects. Same inward-pointing discipline as the
|
||||
[layer rule](?path=/docs/foundations-atomic-design--docs), applied to state and effects
|
||||
instead of imports.
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/Internationalization" />
|
||||
|
||||
# Internationalization (the locale seam)
|
||||
|
||||
Every user-visible string goes through Angular's first-party **`$localize`** — no
|
||||
third-party i18n library. The source locale is **`nl`**; a second locale is a
|
||||
**translation file, not a code change**. That's the seam: adding English touched
|
||||
`src/locale/messages.en.xlf`, not the components.
|
||||
|
||||
## How it's wired
|
||||
|
||||
| Piece | Where | What |
|
||||
| -------------------------- | ----------------------------------------- | ------------------------------------------------------------ |
|
||||
| Source locale | `angular.json` → `i18n.sourceLocale` | `nl` — the language the code is written in |
|
||||
| Locales | `angular.json` → `i18n.locales.en` | points at `src/locale/messages.en.xlf` |
|
||||
| Missing-translation policy | `angular.json` → `i18nMissingTranslation` | `error` — a missing `<target>` fails the build |
|
||||
| Runtime global | `angular.json` → `polyfills` | `@angular/localize/init` provides `$localize` |
|
||||
| English build/serve | `angular.json` → `configurations.en` | `ng build --configuration=en`, `ng serve --configuration=en` |
|
||||
|
||||
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).
|
||||
|
||||
## Authoring copy
|
||||
|
||||
Two forms, same custom-id rule. The id is **stable** and shaped `@@<context>.<key>`, so
|
||||
translations survive copy edits.
|
||||
|
||||
**In TS logic / value objects — tagged template:**
|
||||
|
||||
```ts
|
||||
// src/app/registratie/domain/value-objects/postcode.ts
|
||||
return err($localize`:@@validation.postcode:Voer een geldige postcode in, bijv. 1234 AB.`);
|
||||
```
|
||||
|
||||
With placeholders (named, so translators can reorder):
|
||||
|
||||
```ts
|
||||
$localize`:@@aanvraag.row.ingediend:ingediend op ${formatDatumNl(a.submittedAt)}:datum:`;
|
||||
```
|
||||
|
||||
**In inline component templates — the `i18n` attribute:**
|
||||
|
||||
```html
|
||||
<!-- src/app/auth/ui/login-form/login-form.component.ts -->
|
||||
<app-button type="submit" i18n="@@login.submit">Inloggen met DigiD</app-button>
|
||||
```
|
||||
|
||||
**Shared/English components never hardcode Dutch.** They expose copy as `input()`s with
|
||||
localizable defaults; the domain caller may override. See
|
||||
`shared/ui/async/async.component.ts`:
|
||||
|
||||
```ts
|
||||
errorText = input($localize`:@@async.error:Er ging iets mis bij het laden van de gegevens.`);
|
||||
```
|
||||
|
||||
## Extract & translate loop
|
||||
|
||||
```bash
|
||||
npm run extract-i18n # ng extract-i18n → src/locale/messages.xlf (source, nl)
|
||||
```
|
||||
|
||||
Then a translator fills `<target>`s in `src/locale/messages.en.xlf`. Both files carry the
|
||||
same trans-units (currently 690 = 690, no drift); the `.en.xlf` header is
|
||||
`source-language="nl" target-language="en"`. Because `i18nMissingTranslation: error`, a
|
||||
forgotten target breaks the `en` build rather than silently shipping Dutch.
|
||||
|
||||
## Testing languages without coupling to the strings
|
||||
|
||||
**Rule: never assert on rendered copy.** Copy is the thing that changes per locale and per
|
||||
edit — a test that reads `"Voer een geldige postcode in"` breaks the moment a translator or
|
||||
a product owner touches the wording, in every locale. Assert on what's _invariant_ instead:
|
||||
|
||||
- **Parsers / value objects** — assert on the `Result` discriminant and the parsed value,
|
||||
not the error message. This is the existing house pattern
|
||||
(`registratie/domain/value-objects/postcode.spec.ts`):
|
||||
|
||||
```ts
|
||||
expect(parsePostcode('0234AB').ok).toBe(false); // rejects — never inspects the $localize string
|
||||
```
|
||||
|
||||
- **The seam itself** — if you must verify that translation works, check that a known
|
||||
**id flips**, not that a specific phrase appears. Build/serve the `en` configuration and
|
||||
confirm the target for a stable id renders, e.g. `login.submit`: `nl` "Inloggen met
|
||||
DigiD" → `en` "Log in with DigiD". You're testing the wiring, not the wording.
|
||||
|
||||
```bash
|
||||
ng serve --configuration=en # then eyeball, or point an e2e at the en bundle
|
||||
```
|
||||
|
||||
See [Testing strategy](?path=/docs/foundations-testing-strategy--docs) for how this fits the
|
||||
rest of the test pyramid.
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/Domain-Driven Design" />
|
||||
|
||||
# Domain-driven design: bounded contexts & layers
|
||||
|
||||
This project is **domain-driven**: the code is organised first by **bounded context**
|
||||
(a business capability with its own language) and then by **layer** inside each context,
|
||||
with dependencies pointing inward. The Storybook sidebar is laid out to **be** that
|
||||
architecture, not just document it: **Foundations** (this curriculum) → **Design System**
|
||||
(reusable, domain-free) → **Domein** (the six DDD contexts). If a component lives under a context's `ui/`, it's in Domein; everything else
|
||||
in `shared/ui`/`shared/layout` is Design System. See [Atomic design](?path=/docs/foundations-atomic-design--docs)
|
||||
for the Atoms → Molecules → Organisms → Templates ladder inside Design System.
|
||||
|
||||
## Six contexts, one direction
|
||||
|
||||
```
|
||||
src/app/<context>/<layer>/
|
||||
```
|
||||
|
||||
Contexts: `shared` (the base layer — depends on nothing), `auth`, `registratie`,
|
||||
`herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching page,
|
||||
sanctioned to read every context — nothing imports it).
|
||||
|
||||
**Dependencies only point inward and in one declared direction between contexts:**
|
||||
|
||||
```
|
||||
herregistratie → registratie → shared
|
||||
auth → shared
|
||||
brief → shared
|
||||
```
|
||||
|
||||
Never the other way — `registratie` may not import `herregistratie`, and no context but
|
||||
`shared` is imported by everyone.
|
||||
|
||||
## Five layers, one direction
|
||||
|
||||
| Layer | Job | Angular allowed? |
|
||||
| ----------------- | ----------------------------------------- | ----------------- |
|
||||
| `domain/` | business rules + data types | **No — pure TS.** |
|
||||
| `application/` | coordinate state/tasks (stores, commands) | yes (signals) |
|
||||
| `infrastructure/` | where data comes from (HTTP adapters) | yes (HTTP) |
|
||||
| `contracts/` | wire DTOs (the FE⇄BE seam) | no |
|
||||
| `ui/` | how it looks (components, pages) | yes |
|
||||
|
||||
`ui → application → domain`; `ui`/`layout` never import `infrastructure/` directly — they
|
||||
reach data through an application store or command.
|
||||
|
||||
## This is enforced, not just written down
|
||||
|
||||
`eslint.config.mjs` fails the build on every rule above:
|
||||
|
||||
- `domain/` importing `@angular/*` at all (any context).
|
||||
- `shared/` importing a feature context (`@auth/*`, `@registratie/*`, `@herregistratie/*`,
|
||||
`@brief/*`) — the base layer depends on nothing.
|
||||
- `registratie/` importing `@herregistratie/*`/`@brief/*`, `auth/`/`brief/` importing a
|
||||
sibling context — the cross-context direction above.
|
||||
- `contracts/**` importing **anything** — not Angular, not an alias, not even a relative
|
||||
path (ADR-0001's wire seam has to stay a pure DTO shape).
|
||||
- `ui/**`/`layout/**` importing `*/infrastructure/*` — the anti-corruption boundary
|
||||
(ADR-0001) stays behind a store/command, so a page can never bypass it and hand-recompute
|
||||
a business rule the backend already decided.
|
||||
- The generated `ApiClient` imported as a value outside an `infrastructure/` adapter
|
||||
(type-only DTO imports are exempt — they grant no network access).
|
||||
|
||||
Two components get a documented exemption from the "nothing reaches across" rule:
|
||||
`shared/ui/debug-state` (reads every root store, for the dev-only state panel) and
|
||||
`showcase/` (reads every context, for side-by-side teaching pages). Both exemptions live
|
||||
next to the rule they break, in `eslint.config.mjs`, so they can't rot silently.
|
||||
|
||||
## The English/Dutch seam
|
||||
|
||||
Shared/reusable UI is named in **English** (language-agnostic: `button`, `wizard-shell`);
|
||||
domain contexts are named in **Dutch** (`registratie`, `herregistratie`, `*.machine.ts`).
|
||||
Pick the language by which side of the seam the code is on — it's the same seam this
|
||||
sidebar's Design System/Domein split makes visible.
|
||||
|
||||
## See it in the sidebar
|
||||
|
||||
Compare a Design System primitive with the same shape reused across contexts:
|
||||
|
||||
- [Design System → Molecules → Application Link](?path=/story/design-system-molecules-application-link--navigatie) —
|
||||
domain-free, the caller supplies heading/subtitle/cta.
|
||||
- [Domein → Registratie → Aanvraag Block](?path=/story/domein-registratie-aanvraag-block--concept) —
|
||||
a context-specific organism composed from Design System atoms/molecules.
|
||||
@@ -0,0 +1,375 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/Learning Path" />
|
||||
|
||||
# Learning path
|
||||
|
||||
A paced, hands-on route through this codebase for a developer who is a **strong
|
||||
programmer but new to frontend functional programming**. The [Overview](?path=/docs/foundations-overview--docs)
|
||||
is the _map_ — every idea, cross-linked. This is the _route_: what to read first, what
|
||||
to **do** to make it stick, and how to know you understood it. Work through it over
|
||||
roughly three days.
|
||||
|
||||
Each lesson has the same shape:
|
||||
|
||||
- **Goal** — one sentence: what you'll be able to do.
|
||||
- **~time** — a rough budget so a day stays a day.
|
||||
- a few paragraphs that **teach the idea** (self-contained — you can read straight
|
||||
through), then
|
||||
- **Do** — a hands-on exercise. Most reuse the repo's invocable skills (`/new-feature`,
|
||||
`/form-machine`, …), which scaffold real code the house way.
|
||||
- **Check yourself** — a question; if you can answer it, move on.
|
||||
- **Go deeper** — the deep-dive page and the long-form source in `docs/reference/`.
|
||||
|
||||
The one idea underneath everything: **make illegal states unrepresentable.** Every rule
|
||||
below is a way to stop the compiler letting you build a state that can't actually happen.
|
||||
|
||||
---
|
||||
|
||||
## Day 1 — Orient: the shape of the codebase
|
||||
|
||||
### 1.1 Why this exists — state that can lie · ~15 min
|
||||
|
||||
**Goal:** name the failure mode this whole architecture is designed to prevent.
|
||||
|
||||
Most UI bugs are not wrong algorithms — they're **impossible states that the types
|
||||
allowed anyway**. `isLoading` true _and_ `error` set _and_ `data` present: three
|
||||
booleans give eight combinations, but only four are real. The extra four are bugs
|
||||
waiting to be rendered. The reflex this codebase trains: when you reach for a second or
|
||||
third boolean to track one thing, model a **discriminated union** instead, so the
|
||||
illegal combinations can't be typed.
|
||||
|
||||
The second big idea is structural. The folder layout is not filing — **the folder
|
||||
structure _is_ the architecture**. Where a file lives declares what it's allowed to
|
||||
depend on, and that rule is enforced by lint, not hoped for. You'll meet the same
|
||||
"compose small honest pieces, forbid the illegal combinations" principle at three
|
||||
scales today and tomorrow: in the domain model, in the component tree, and in state.
|
||||
|
||||
**Do:** open `src/app/` and read the top of `CLAUDE.md` ("The decisions"). Just get the
|
||||
lay of the land — six contexts, five layers.
|
||||
|
||||
**Check yourself:** three booleans model how many states, and how many are real for a
|
||||
"fetch"? Why is that gap the enemy?
|
||||
|
||||
**Go deeper:** `docs/reference/fp-tea-atomic-design.md` Part 1.
|
||||
|
||||
### 1.2 Domain-driven design: contexts then layers · ~25 min
|
||||
|
||||
**Goal:** predict which imports are legal before the linter tells you.
|
||||
|
||||
Code is organised first by **bounded context** — a business capability with its own
|
||||
language: `shared`, `auth`, `registratie`, `herregistratie`, `brief`, `showcase`. Inside
|
||||
each context are five **layers**, and dependencies only ever point **inward**:
|
||||
|
||||
| Layer | Job | Angular? |
|
||||
| ----------------- | ----------------------------------------- | -------------------------------- |
|
||||
| `domain/` | business rules + data types | **No — pure TS**, has `.spec.ts` |
|
||||
| `application/` | coordinate state/tasks (stores, commands) | yes (signals) |
|
||||
| `infrastructure/` | where data comes from (HTTP) | yes |
|
||||
| `contracts/` | wire DTOs (the FE⇄BE seam) | no |
|
||||
| `ui/` | how it looks | yes |
|
||||
|
||||
`ui → application → domain`, never the reverse; `ui` never touches `infrastructure`
|
||||
directly. Cross-context is one-directional too: `herregistratie → registratie → shared`,
|
||||
`auth → shared`, `brief → shared`. A context downstream may lean on one upstream; the
|
||||
upstream never learns the downstream exists. This keeps the domain pure and testable and
|
||||
stops the dependency graph rotting into a ball of mud.
|
||||
|
||||
**Do:** open `eslint.config.mjs` and find the import-boundary rules. Then pick any file
|
||||
in `herregistratie/` and trace one import back into `registratie` or `shared`.
|
||||
|
||||
**Check yourself:** why may `herregistratie` import from `registratie`, but `registratie`
|
||||
may **not** import from `herregistratie`? What breaks if you invert it?
|
||||
|
||||
**Go deeper:** [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs);
|
||||
`docs/reference/architecture/ARCHITECTURE.md` §1.
|
||||
|
||||
### 1.3 Atomic design: composition is the default · ~20 min
|
||||
|
||||
**Goal:** decide, for a new screen, whether to add a building block or just compose.
|
||||
|
||||
The design system is a ladder: **Atoms → Molecules → Organisms → Templates**, each level
|
||||
built only from the level below. Atoms (`button`, `form-field`) are thin typed wrappers
|
||||
over CIBG Huisstijl CSS classes; molecules compose atoms; organisms compose molecules;
|
||||
templates lay out organisms; a context's `ui/` page composes templates. A new page should
|
||||
be **composition of existing blocks** — adding a block is the exception, not the reflex.
|
||||
|
||||
Notice this is the same shape as 1.2: small honest pieces, each only allowed to reach
|
||||
one level down, illegal combinations forbidden by structure. That's not a coincidence —
|
||||
you'll see why tomorrow.
|
||||
|
||||
**Do:** trace a real composition chain in Storybook: **Atoms → Button**, then find where
|
||||
it's used up through `form-field → document-upload → page-shell`. Watch each level only
|
||||
reach one level down.
|
||||
|
||||
**Check yourself:** you need a new "application summary" screen. What's the first
|
||||
question you ask before writing a component?
|
||||
|
||||
**Go deeper:** [Atomic design](?path=/docs/foundations-atomic-design--docs). Adding a
|
||||
block (only when composition truly can't do it): the `/ui-component` skill.
|
||||
|
||||
---
|
||||
|
||||
## Day 2 — The functional core
|
||||
|
||||
### 2.1 FP fundamentals · ~25 min
|
||||
|
||||
**Goal:** read code as "functional core, imperative shell" and spot which is which.
|
||||
|
||||
Four tools do the heavy lifting. **Pure functions:** output depends only on input, no
|
||||
side effects — trivially testable, no mocks. **Immutability:** you compute new values,
|
||||
you don't mutate old ones, so nothing changes under you. **Unidirectional flow:** data
|
||||
moves one way (state → view → message → new state), never a tangle of two-way bindings.
|
||||
**Sum and product types:** a _product_ is "A and B" (a record); a _sum_ is "A **or** B"
|
||||
(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
|
||||
parsers) that knows nothing about Angular or HTTP; the **imperative shell** (components,
|
||||
adapters) does the messy I/O and hands data in and out of the core. Bugs hide in the
|
||||
shell; the core stays provable.
|
||||
|
||||
**Do:** open any `domain/` file next to its `.spec.ts` and confirm the spec uses no
|
||||
Angular `TestBed` — it calls the function directly. That's the core being pure.
|
||||
|
||||
**Check yourself:** which of these is a sum type and why — "a form field's value" vs. "a
|
||||
form's submission state (idle / submitting / failed / done)"?
|
||||
|
||||
**Go deeper:** [FP in the UI](?path=/docs/foundations-fp-in-the-ui--docs);
|
||||
`docs/reference/fp-tea-atomic-design.md` Part 2.
|
||||
|
||||
### 2.2 State machines — The Elm Architecture · ~30 min
|
||||
|
||||
**Goal:** model a form as `Model → Msg → reduce`, with effects kept out of the reducer.
|
||||
|
||||
Every form and wizard here is one state machine: a **Model** (a tagged union — the
|
||||
current state), a **Msg** union (everything that can happen), and a **pure** `reduce(model,
|
||||
msg): model`. The template never mutates state; it **dispatches a message**, `reduce`
|
||||
returns the next model, the view re-renders. All wiring goes through one idiom,
|
||||
`createStore(initial, reduce)` — you never hand-roll `signal(model)` + a local dispatch.
|
||||
|
||||
The rule that keeps `reduce` pure: **side effects live in commands, not the reducer.** A
|
||||
command (`application/submit-*.ts`) does the HTTP, then dispatches a message describing
|
||||
the _outcome_. Reducer = "what the new state is"; command = "go do it, then say what
|
||||
happened." And **derive, don't store** anything you can compute — e.g. a wizard's visible
|
||||
steps are `visibleSteps(answers)`, not a stored field.
|
||||
|
||||
A field's value lands in the Model on **every keystroke** (not on blur — blur only marks
|
||||
the field "touched"); a separate 600 ms debounce off the model snapshot autosaves the
|
||||
draft to the backend, an effect that lives _outside_ the reducer. See
|
||||
`docs/reference/architecture/ARCHITECTURE.md` §2g.
|
||||
|
||||
**Do:** run `/form-machine` for a toy single field (say a "nickname" field with a max
|
||||
length). Read the generated Model / Msg / reduce and its spec.
|
||||
|
||||
**Check yourself:** why can't `reduce` make the HTTP call itself? What goes wrong if it
|
||||
does?
|
||||
|
||||
**Go deeper:** [State machines (TEA)](?path=/docs/foundations-state-machines-tea--docs);
|
||||
`docs/reference/fp-tea-atomic-design.md` Parts 3–4.
|
||||
|
||||
### 2.3 RemoteData & async · ~20 min
|
||||
|
||||
**Goal:** replace loading/error/empty booleans with one four-state value.
|
||||
|
||||
`RemoteData<E,T>` is a sum type with exactly four cases: `Loading | Empty |
|
||||
Failure{error} | Success{value}`. That's the four _real_ states from lesson 1.1, and no
|
||||
others — you literally cannot construct "loading and error." Combine sources with
|
||||
`map` / `map2` / `andThen` (precedence: Failure > Loading > Empty > Success), and render
|
||||
it with the `<app-async>` molecule, which picks one of four mutually-exclusive templates
|
||||
by construction. The default spinner is delay-gated (~250 ms) so fast connections don't
|
||||
flash.
|
||||
|
||||
**Do:** open a data page in the running app with `?scenario=slow`, then `?scenario=empty`,
|
||||
then `?scenario=error` (the dev-only scenario toggle). Watch `<app-async>` switch
|
||||
templates without any `*ngIf` soup.
|
||||
|
||||
**Check yourself:** a page combines two independent fetches with `map2`. One is still
|
||||
loading, the other has failed — what does the combined value show, and why that
|
||||
precedence?
|
||||
|
||||
**Go deeper:** [RemoteData & Async](?path=/docs/foundations-remotedata-async--docs);
|
||||
`docs/reference/architecture/ARCHITECTURE.md` §2.
|
||||
|
||||
### 2.4 Parse, don't validate · ~20 min
|
||||
|
||||
**Goal:** turn untrusted input into a domain type once, then trust it forever.
|
||||
|
||||
Raw input (`unknown`, a string, a wire DTO) becomes a **branded value object** only by
|
||||
passing through a **parser** that returns `Result<E,T>` — `parsePostcode`, `parseUren`,
|
||||
`parseBigNummer`. Once you hold a `Postcode`, its shape is guaranteed by the type system;
|
||||
you **never re-check it**. This happens in two places: value objects (form fields) and
|
||||
boundary `parse*` adapters in `infrastructure/` (the FE⇄BE seam, where untrusted JSON
|
||||
becomes domain types). "Validate" scatters `if`-checks everywhere and forgets one;
|
||||
"parse" concentrates the check at the door and lets the compiler enforce the rest.
|
||||
|
||||
**Why "brand"?** TypeScript is _structurally_ typed, so a bare `type Postcode = string`
|
||||
would accept any string and lose all proof of validation. Intersecting a phantom marker —
|
||||
`string & { readonly __brand: 'Postcode' }` — makes the type **nominal**: no plain string
|
||||
satisfies it, so the only way to hold a `Postcode` is to go through the parser that stamps
|
||||
the brand. The brand is compile-time proof the value was validated (it exists only in the
|
||||
types, never at runtime). The DDD name for the concept is a _value object_; "brand" is just
|
||||
the TypeScript trick that makes it enforceable.
|
||||
|
||||
**Do:** run `/value-object` for a small field (e.g. a Dutch phone number). Read the parser
|
||||
and its spec — note it returns `Result`, not a boolean, and note the branded type.
|
||||
|
||||
**Check yourself:** you're three functions deep and you hold a `Postcode`. Should you
|
||||
re-validate its format? Why not?
|
||||
|
||||
**Go deeper:** [Parse, don't validate](?path=/docs/foundations-parse-dont-validate--docs);
|
||||
`docs/reference/architecture/ARCHITECTURE.md` §3.
|
||||
|
||||
### Day 2 closer — one principle, two scales
|
||||
|
||||
You've now seen it twice: **small honest pieces, each only allowed to reach one level
|
||||
down, with illegal combinations forbidden by structure.** Atomic design applies it to
|
||||
_components_ (atoms compose upward); The Elm Architecture applies it to _state_ (pure
|
||||
`reduce` composes messages into models). They are the same principle at two scales — that
|
||||
is the thesis of this codebase. Read `docs/reference/fp-tea-atomic-design.md` Part 5; it's
|
||||
the "aha" that ties Day 1 and Day 2 together.
|
||||
|
||||
---
|
||||
|
||||
## Day 3 — Quality & shipping
|
||||
|
||||
### 3.1 Testing strategy — what to test, by layer · ~20 min
|
||||
|
||||
**Goal:** know where a test goes and what kind it is, given any change.
|
||||
|
||||
Test grain follows the layer. **Domain and pure logic must have a spec** — reducers,
|
||||
combinators, `visibleSteps`, parsers, boundary `parse*` adapters — tested **directly, no
|
||||
TestBed**, because they're pure. **UI is exercised via Storybook stories** (co-located
|
||||
`*.stories.ts`, a11y addon on), not heavy component tests. Backend rules have their own
|
||||
`dotnet test`. The GREEN gate before you push: `npm run lint && npm test && npm run build`
|
||||
(plus `cd backend && dotnet test`).
|
||||
|
||||
**Do:** run `/test-strategy` and read where it says each layer's test belongs. Then run
|
||||
`npm test` and watch the pure specs fly (no browser, no mocks).
|
||||
|
||||
**Check yourself:** you add a new parser and a new page. Which gets a `.spec.ts`, and
|
||||
which gets a Storybook story instead?
|
||||
|
||||
**Go deeper:** [Testing strategy](?path=/docs/foundations-testing-strategy--docs);
|
||||
`CLAUDE.md` §5.
|
||||
|
||||
### 3.2 BDD — one behaviour per test · ~15 min
|
||||
|
||||
**Goal:** write test names that read as a specification in the domain's language.
|
||||
|
||||
`describe` names the subject; each `it` states **one observable behaviour** in
|
||||
present tense — no `should`, no Given/When/Then ceremony. One behaviour per test means one
|
||||
_behaviour_, not one `expect`: assertions pinning down the same behaviour stay together
|
||||
(a `Result`'s `.ok` then its `.value`); assertions about different behaviours split apart
|
||||
(the ok branch **and** the err branch). If a title needs "and"/"then"/"/" to join two
|
||||
things, that's the smell — split it. And speak the **ubiquitous language**: a _behandelaar_
|
||||
drafts, a _beoordelaar_ approves — the same words as the bounded contexts.
|
||||
|
||||
**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
|
||||
recently split.)
|
||||
|
||||
**Check yourself:** `it('submits and then shows the reference')` — what's wrong with this
|
||||
name?
|
||||
|
||||
**Go deeper:** [BDD](?path=/docs/foundations-bdd--docs).
|
||||
|
||||
### 3.3 Accessibility — four layered tools · ~15 min
|
||||
|
||||
**Goal:** know which a11y bug each tool catches, and what only a human catches.
|
||||
|
||||
Four layers, each a different bug class: **axe on every story** (CI-gated, catches
|
||||
contrast/roles/labels), **template a11y lint** (catches missing alt/labels at author
|
||||
time), **Storybook play tests** (catches keyboard/focus interaction), and a **manual WCAG
|
||||
checklist** for what automation can't — tab order across a page, focus traps, 200% zoom,
|
||||
screen-reader narration. a11y is a build gate here, not a nice-to-have.
|
||||
|
||||
**Do:** open any story and check the **Accessibility** tab (axe results). Then skim
|
||||
`docs/reference/wcag-checklist.md` — note the honest empty "Screen reader" column: some
|
||||
things only a human pass finds.
|
||||
|
||||
**Check yourself:** axe passes on a form. Name one real a11y bug it still can't catch.
|
||||
|
||||
**Go deeper:** [Accessibility](?path=/docs/foundations-accessibility--docs).
|
||||
|
||||
### 3.4 Internationalization — the locale seam · ~15 min
|
||||
|
||||
**Goal:** wrap user-facing copy so a second language is a translation file, not a code
|
||||
change.
|
||||
|
||||
Every user-visible string is wrapped in Angular's first-party `$localize` with a stable
|
||||
custom id — `` $localize`:@@context.key:Tekst` ``. Source locale is `nl`; English is a
|
||||
translation file, not edited code — that's the seam. Shared/English components must **not**
|
||||
hardcode Dutch: they expose copy as `input()`s with localizable defaults, and the Dutch
|
||||
domain caller supplies the text.
|
||||
|
||||
**Do:** grep for `$localize` in a `ui/` component; note the `@@`-prefixed stable ids. Find
|
||||
one `shared/ui` component that takes copy as an `input()` rather than hardcoding it.
|
||||
|
||||
**Check yourself:** why must a shared English atom take its label as an `input()` instead
|
||||
of writing the Dutch word directly?
|
||||
|
||||
**Go deeper:** [Internationalization](?path=/docs/foundations-internationalization--docs).
|
||||
|
||||
### 3.5 The design-system track (parallel) · ~15 min
|
||||
|
||||
**Goal:** style via semantic tokens and the CIBG Huisstijl, never hand-written colours.
|
||||
|
||||
This strand is largely independent of the FP/state spine — learn it whenever. The app
|
||||
speaks a semantic `--rhc-*` token vocabulary; `src/styles.scss` is a **token bridge** that
|
||||
maps those onto the vendored **CIBG Huisstijl** (a customized Bootstrap 5.2) `--bs-*`
|
||||
values. Rule: reach for a **CIBG class first, then a token** — no hand-written hex. Where
|
||||
CIBG lacks a class (e.g. `alert`), the atom is hand-rolled from tokens and recorded in the
|
||||
**CIBG gap register** so the divergence stays honest.
|
||||
|
||||
**Do:** open [Design tokens](?path=/docs/foundations-design-tokens--docs) and read the
|
||||
live swatches; then skim the [CIBG gap register](?path=/docs/foundations-cibg-gap-register--docs).
|
||||
|
||||
**Check yourself:** you need a warning colour. Where does it come from, and where does it
|
||||
**not**?
|
||||
|
||||
**Go deeper:** `docs/reference/architecture/0003-cibg-huisstijl.md` (ADR-0003).
|
||||
|
||||
---
|
||||
|
||||
## Capstone — add a feature end-to-end
|
||||
|
||||
**Goal:** ship one small vertical slice the house way, and name which layer owns each rule.
|
||||
|
||||
Two framing ideas first. **BFF-lite + decision DTOs (ADR-0001):** each screen gets one
|
||||
screen-shaped endpoint returning a **decision-enriched** DTO — the backend computes the
|
||||
business rules, and **the FE renders decisions, it does not recompute them.** Per rule you
|
||||
pick a _decision flag_ (server sends the boolean) or a _config value_ (server sends the
|
||||
threshold, FE applies it for instant feedback, server re-validates as authority). The FE
|
||||
keeps only **format** validation, never as authority.
|
||||
|
||||
Then the house pipeline, always in this order: **domain** (types + pure rules + spec, no
|
||||
Angular) → **infrastructure** (adapter with a `parse*` boundary, or a command returning
|
||||
`Result`) → **application** (a store if state is shared; union + pure `reduce`) → **ui**
|
||||
last (compose `shared/ui` atoms, wrap async in `<app-async>`, dispatch messages).
|
||||
|
||||
**Do:** build a tiny slice — e.g. a one-field "update phone number" action — using the
|
||||
skills in pipeline order:
|
||||
|
||||
1. `/value-object` — the field's parser + branded type (domain).
|
||||
2. `/bff-endpoint` — a screen-shaped read with a decision DTO + `parse*` boundary.
|
||||
3. `/form-machine` — the form's Model/Msg/reduce.
|
||||
4. `/mutation-command` — the write, returning `Result`, keeping the reducer pure.
|
||||
5. `/ui-component` **only if** no existing block composes — otherwise just compose.
|
||||
|
||||
`/new-feature` walks the whole pipeline if you'd rather do it in one guided pass.
|
||||
|
||||
**Check yourself:** for your slice, name for each business rule whether it's a _decision
|
||||
flag_ or a _config value_, and which layer owns it. If a rule lives in two layers, which
|
||||
one is the **authority**?
|
||||
|
||||
**Go deeper:** `docs/reference/architecture/0001-bff-lite-decision-dtos.md`;
|
||||
`docs/reference/fp-tea-atomic-design.md` Part 7 (the copy-paste recipes);
|
||||
`docs/reference/architecture/ARCHITECTURE.md` §4 (the recipe) and §6a (the full FE⇄BE
|
||||
request lifecycle, read + write, with file links). For how contexts scale to a second
|
||||
app and actor-based authorization, ADR-0002 (the advanced read).
|
||||
|
||||
---
|
||||
|
||||
You've done the route. From here the [Overview](?path=/docs/foundations-overview--docs)
|
||||
map is your reference, the deep-dive pages hold the detail, and the skills scaffold each
|
||||
new piece the house way.
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/State Machines (TEA)" />
|
||||
|
||||
# State machines (The Elm Architecture, in Angular)
|
||||
|
||||
Every form or wizard with validation or submission in this app is wired the **same
|
||||
way**: one Model, one Msg union, one pure `reduce`, one command per side effect. Pick any
|
||||
one — `herregistratie.machine.ts` is the fullest worked example — and the shape
|
||||
transfers everywhere else.
|
||||
|
||||
## Model / Msg / reduce
|
||||
|
||||
```ts
|
||||
// Model — everything the UI needs to render, as ONE tagged union
|
||||
export type WizardState = { tag: 'step1'; draft: Draft } | { tag: 'step2'; valid: Valid } | …;
|
||||
|
||||
// Msg — every way the Model is allowed to change
|
||||
export type WizardMsg = { tag: 'FieldChanged'; field: string; value: string } | { tag: 'NextStep' } | …;
|
||||
|
||||
// reduce — PURE: (current, message) -> next. No I/O, no Date.now(), no randomness.
|
||||
export function reduce(s: WizardState, m: WizardMsg): WizardState { … }
|
||||
```
|
||||
|
||||
Because the whole state is one value, a bug reproduces from a message log; because
|
||||
`reduce` is pure, every transition is a one-line assertion in a spec — no `TestBed`, no
|
||||
mocked HTTP, just `expect(reduce(state, msg)).toEqual(next)`.
|
||||
|
||||
## Commands: side effects stay OUT of the reducer
|
||||
|
||||
`reduce` only ever answers "what is the new state" — it never calls `fetch`. A
|
||||
**command** (an `application/submit-*.ts` file, or a store method) does the I/O, then
|
||||
dispatches a message describing the outcome:
|
||||
|
||||
```ts
|
||||
// command = "go do it, then say what happened" — reduce never sees the HTTP call itself
|
||||
async function submit(store: Store<WizardState, WizardMsg>) {
|
||||
const r = await adapter.submit(toDto(store.model()));
|
||||
store.dispatch(
|
||||
r.ok
|
||||
? { tag: 'SubmitConfirmed', referentie: r.value }
|
||||
: { tag: 'SubmitFailed', error: r.error },
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This is also how a machine receives **server-owned config** without becoming aware of
|
||||
HTTP: `intake.machine.ts`'s scholing threshold has an offline fallback
|
||||
(`SCHOLING_THRESHOLD_DEFAULT`) baked into the model, and a plain `SetPolicy` message
|
||||
that overwrites it once the real value arrives — the machine doesn't know or care that
|
||||
the value came from a `resource()` fetch.
|
||||
|
||||
## `createStore`: the one wiring idiom
|
||||
|
||||
```ts
|
||||
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
||||
readonly model = this.store.model; // Signal<WizardState> — template reads this
|
||||
dispatch = this.store.dispatch; // template calls this, on click/input/etc — never mutates
|
||||
```
|
||||
|
||||
A page or component **never** hand-rolls `signal(initialModel)` plus its own local
|
||||
`dispatch` function that calls `reduce` inline — that's the same idea reinvented with a
|
||||
worse name, and it's the thing a newcomer copies if two idioms are visible side by side.
|
||||
Wire every machine through `createStore`, full stop.
|
||||
|
||||
`dispatch` uses `model.update(…)`, not `model.set(reduce(model(), msg))` — the latter
|
||||
reads `model()` _inside_ the call, which means an `effect()` that both reads `model` and
|
||||
calls `dispatch` would subscribe to its own write and livelock. `.update()`'s callback
|
||||
receives the current value directly, untracked.
|
||||
|
||||
## Naming
|
||||
|
||||
- A top-level machine's types are **context-prefixed**: `ChangeRequestState`,
|
||||
`ChangeRequestMsg`, `WizardState`, `WizardMsg` — never bare `State`/`Msg`. A bare name
|
||||
reads fine in the one file that defines it and then collides (or forces an import
|
||||
alias) the moment two machines are open side by side.
|
||||
- A top-level machine exports `initial` (the starting Model) and `reduce` — unprefixed,
|
||||
since the file/module already disambiguates them at the import site
|
||||
(`import { initial, reduce } from './herregistratie.machine'`).
|
||||
- A **composable sub-machine** — one embedded _inside_ a parent Model, like
|
||||
`upload.machine.ts`'s upload-widget state living inside the registratie wizard's own
|
||||
Model — keeps **prefixed value exports** instead: `initialUpload`, `reduceUpload`.
|
||||
The parent machine already imports several machines' `initial`/`reduce`; prefixing the
|
||||
sub-machine's exports avoids a wall of `as` import aliases at the composition site.
|
||||
|
||||
## Derive, don't store
|
||||
|
||||
If a value can be computed from the Model, it is **not** a field on the Model. The
|
||||
wizard's visible steps are `visibleSteps(answers)`, a pure function of the current
|
||||
answers — not a `visibleSteps: Step[]` field someone has to remember to keep in sync
|
||||
every time an answer changes. The reflex: before adding a field, ask "could this just be
|
||||
a function of what I already have?"
|
||||
|
||||
## Where RemoteData fits in
|
||||
|
||||
A machine owns the **domain** lifecycle of what it holds once it exists (draft →
|
||||
submitted → approved, in the brief's case). It should generally _not_ also own the
|
||||
**fetch** lifecycle (loading/failed) for the initial GET that produces it — that's a
|
||||
generic concern `RemoteData` already models once, consistently, across the app (see
|
||||
[Foundations/RemoteData & Async](?path=/docs/foundations-remotedata-async--docs)). Where
|
||||
a machine's own state happens to have `loading`/`failed` tags that purely mirror that
|
||||
fetch, project them onto a `RemoteData` at the store layer for `<app-async>` to render
|
||||
(`BriefStore.remoteData` is the worked example) rather than teaching every consumer to
|
||||
hand-roll a `@switch` over the machine's own tags.
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/Overview" />
|
||||
|
||||
# Foundations
|
||||
|
||||
The **why** behind this codebase, as a short curriculum. Each page is a condensed,
|
||||
cross-linked take on one idea; the long-form source lives in `docs/reference/`
|
||||
(see the repo's `docs/README.md`). Read them in roughly this order.
|
||||
|
||||
> **New here?** Follow the [Learning Path](?path=/docs/foundations-learning-path--docs)
|
||||
> for a paced 3-day route with exercises and self-checks. This page is the map; the
|
||||
> Learning Path is the route through it.
|
||||
|
||||
## Architecture & domain
|
||||
|
||||
- [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs) — bounded
|
||||
contexts + layers, dependencies pointing inward (the folder structure _is_ the architecture).
|
||||
- [Atomic design](?path=/docs/foundations-atomic-design--docs) — Atoms → Molecules →
|
||||
Organisms → Templates; a new page is composition, not new building blocks.
|
||||
|
||||
## Functional core
|
||||
|
||||
- [FP in the UI](?path=/docs/foundations-fp-in-the-ui--docs) — the three functional tools behind the view.
|
||||
- [State machines (TEA)](?path=/docs/foundations-state-machines-tea--docs) — every form/wizard as Model → Msg → pure `reduce`.
|
||||
- [RemoteData & Async](?path=/docs/foundations-remotedata-async--docs) — the four async states as one value.
|
||||
- [Parse, don't validate](?path=/docs/foundations-parse-dont-validate--docs) — narrow untrusted `unknown` at the boundary into domain types.
|
||||
|
||||
## Design system
|
||||
|
||||
- [Design tokens](?path=/docs/foundations-design-tokens--docs) — semantic `--rhc-*` tokens; no hand-written colours.
|
||||
- [CIBG gap register](?path=/docs/foundations-cibg-gap-register--docs) — where we diverge from the CIBG Huisstijl (ADR-0003).
|
||||
|
||||
## Quality & process
|
||||
|
||||
- [Accessibility](?path=/docs/foundations-accessibility--docs) — four layered a11y tools, each catching a different bug class.
|
||||
- [Testing strategy](?path=/docs/foundations-testing-strategy--docs) — what to test, by layer grain.
|
||||
- [BDD](?path=/docs/foundations-bdd--docs) — how each test is phrased and scoped: one behaviour, in the domain's language.
|
||||
- [Internationalization](?path=/docs/foundations-internationalization--docs) — `$localize` for every user-visible string; the locale seam.
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/Parse, don't validate" />
|
||||
|
||||
# Parse, don't validate
|
||||
|
||||
The wire is untrusted. A `boolean`/`string` field coming back from `fetch` is typed `unknown`
|
||||
until something checks it — casting it away with `as` doesn't check anything, it just tells the
|
||||
compiler to stop complaining. This repo's rule: every response crosses the FE⇄BE seam through a
|
||||
hand-written `parse*` function that returns a `Result<string, T>` (`src/app/shared/kernel/fp.ts`).
|
||||
Once you hold the parsed value, you never re-check it — the type _is_ the proof.
|
||||
|
||||
## Two places this shows up
|
||||
|
||||
**Value objects** (`src/app/registratie/domain/value-objects/`) parse a single user-entered
|
||||
field — `Postcode`, `Uren`, `BigNummer` — from a raw string into a branded type.
|
||||
|
||||
**Boundary parsers** (`*.adapter.ts` in every `infrastructure/`) parse a whole DTO — or one
|
||||
enum-ish field inside it — from the generated `ApiClient`'s response into the domain shape the
|
||||
rest of the app trusts.
|
||||
|
||||
```ts
|
||||
parsePostcode(raw) // Result<string, Postcode>
|
||||
|> mapErr(toLocalizedMessage) // swap raw msg → UI copy
|
||||
|> map(toDomain) // only runs on success
|
||||
```
|
||||
|
||||
## The failure mode this closes: the silent `as` cast
|
||||
|
||||
An `as SomeUnion` cast on a wire value compiles even when the value doesn't match — the tag
|
||||
just gets forwarded as-is, and something far away breaks on an "impossible" case. A validated
|
||||
parse turns that into an explicit `Failure` at the boundary, right where the untrusted data
|
||||
enters.
|
||||
|
||||
### Before/after: `big-register.adapter.ts`
|
||||
|
||||
```ts
|
||||
// before — the wire's `type` string is trusted outright
|
||||
function toAantekening(n: AantekeningDto): Aantekening {
|
||||
return {
|
||||
type: n.type as AantekeningType,
|
||||
omschrijving: n.omschrijving ?? '',
|
||||
datum: n.datum ?? '',
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// after — an unrecognized type is a Result you can spec, not a silently-wrong tag
|
||||
const AANTEKENING_TYPES: readonly AantekeningType[] = ['Specialisme', 'Aantekening'];
|
||||
|
||||
export function parseAantekening(n: AantekeningDto): Result<string, Aantekening> {
|
||||
if (!n.type || !AANTEKENING_TYPES.includes(n.type as AantekeningType))
|
||||
return err(`aantekening: unknown type ${n.type}`);
|
||||
return ok({
|
||||
type: n.type as AantekeningType,
|
||||
omschrijving: n.omschrijving ?? '',
|
||||
datum: n.datum ?? '',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The resource loader throws on `Failure`, which Angular's `resource()` turns into its error
|
||||
state — the same `Failure` a `RemoteData` consumer already renders, no new plumbing.
|
||||
|
||||
### Before/after: `brief.adapter.ts`
|
||||
|
||||
```ts
|
||||
// before — `dto.scope` is checked, then re-cast anyway
|
||||
if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep'))
|
||||
return err('passage: bad shape');
|
||||
// … scope: dto.scope as PassageScope
|
||||
```
|
||||
|
||||
```ts
|
||||
// after — split the guard so TS narrows `scope` on its own; no cast needed
|
||||
if (dto.scope !== 'global' && dto.scope !== 'beroep')
|
||||
return err(`passage: unknown scope ${dto.scope}`);
|
||||
// … scope: dto.scope // already narrowed to PassageScope
|
||||
```
|
||||
|
||||
Splitting a compound `if` into two single-condition guards is often enough to make the cast
|
||||
disappear entirely — the compiler was already able to prove the narrowing, the `||` was just
|
||||
hiding it.
|
||||
|
||||
### Before/after: `intake-policy.adapter.ts`
|
||||
|
||||
```ts
|
||||
// before — the resource exposes the raw DTO; consumers reach into it with `?.`
|
||||
policyResource() {
|
||||
return resource({ loader: () => this.client.policy() });
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// after — a domain-side type + a validated parse; the resource never surfaces raw wire shape
|
||||
export interface IntakePolicy {
|
||||
readonly scholingThreshold: number;
|
||||
}
|
||||
|
||||
export function parseIntakePolicy(json: unknown): Result<string, IntakePolicy> {
|
||||
if (typeof json !== 'object' || json === null) return err('intake-policy: not an object');
|
||||
const dto = json as { scholingThreshold?: unknown };
|
||||
if (typeof dto.scholingThreshold !== 'number')
|
||||
return err('intake-policy: missing scholingThreshold');
|
||||
return ok({ scholingThreshold: dto.scholingThreshold });
|
||||
}
|
||||
```
|
||||
|
||||
## The sanctioned exception
|
||||
|
||||
Narrowing `unknown` to `Partial<Dto>` so you can _start_ checking fields is fine — that's not a
|
||||
trust decision, it's just giving the compiler a shape to probe (`const dto = json as
|
||||
Partial<DashboardViewDto>`, see `dashboard-view.adapter.ts`). What's never fine is casting a
|
||||
field to its final domain type without having checked it first.
|
||||
|
||||
## Spec every parser like a decision table
|
||||
|
||||
Each parser gets a spec covering: a valid shape, a missing required field, and — for
|
||||
tagged/enum-ish values — an unknown tag. See `big-register.adapter.spec.ts`,
|
||||
`intake-policy.adapter.spec.ts`, and the scope-rejection case in `brief.adapter.spec.ts`.
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
|
||||
import * as AsyncStories from '../src/ui/async/async.stories';
|
||||
|
||||
<Meta title="Foundations/RemoteData & Async" />
|
||||
|
||||
# RemoteData & Async
|
||||
|
||||
An async fetch has exactly four states: still loading, loaded-but-empty, failed, or
|
||||
loaded-with-a-value. Modeling that as `loading`/`error`/`data` booleans permits nonsense
|
||||
combinations ("loading **and** error", "data **and** error" — which one does the UI
|
||||
believe?). `src/app/shared/application/remote-data.ts` closes that off with one tagged
|
||||
union instead:
|
||||
|
||||
```ts
|
||||
type RemoteData<E, T> =
|
||||
| { tag: 'Loading' }
|
||||
| { tag: 'Empty' }
|
||||
| { tag: 'Failure'; error: E }
|
||||
| { tag: 'Success'; value: T };
|
||||
```
|
||||
|
||||
## Combining sources
|
||||
|
||||
Two or more independent fetches often need to render as ONE state (e.g. a registration
|
||||
call and a BRP call feeding the same page). `map`/`map2`/`andThen` combine them with one
|
||||
precedence rule: **Failure beats Loading beats Empty beats Success** — if either source
|
||||
failed, the combined result is a failure; only when every source succeeded do you get a
|
||||
combined value.
|
||||
|
||||
```ts
|
||||
map2(registration, person, (reg, p) => ({ registration: reg, person: p }));
|
||||
```
|
||||
|
||||
## Rendering it: `<app-async>`
|
||||
|
||||
<Canvas of={AsyncStories.Loading} />
|
||||
<Canvas of={AsyncStories.ErrorState} />
|
||||
|
||||
`shared/ui/async` renders exactly one of the four templates — never two at once, by
|
||||
construction, since the component switches on the union's tag. Feed it either:
|
||||
|
||||
- **`[resource]`** — a raw Angular `resource()` (the common case; the component projects
|
||||
it into a `RemoteData` internally via `fromResource`), or
|
||||
- **`[data]`** — an already-combined `RemoteData` (e.g. from a store's `computed()` using
|
||||
`map`/`map2`).
|
||||
|
||||
The default loading UI is a spinner, delay-gated (~250ms) so a fast response never
|
||||
flashes it; override with an `appAsyncLoading` template. `appAsyncEmpty` and
|
||||
`appAsyncError` are likewise optional — omit them and you get a sensible default (a
|
||||
"geen gegevens" message / an alert with a retry button).
|
||||
|
||||
## The `appAsyncLoaded` slot isn't generically typed to your value
|
||||
|
||||
This is a real Angular constraint, not an oversight: a structural directive's type
|
||||
parameter can only be inferred from an **input bound on that same element** (this is how
|
||||
`*ngFor="let x of items"` and `*ngIf="x as y"` work — the type comes from `ngForOf`/`ngIf`,
|
||||
inputs on the very same tag). `<ng-template appAsyncLoaded let-p>` sits on a _different_
|
||||
node than `<app-async [data]="…">`, so `p` cannot inherit a type from that sibling input,
|
||||
even though they're nested in the same template. Angular types it `unknown`, and
|
||||
`ngTemplateContextGuard` can't fix that without an input to seed it from — the shared
|
||||
`AsyncComponent`/`AsyncLoadedDirective` pair is properly generic internally, but that
|
||||
genericity stops at the component's own boundary.
|
||||
|
||||
The idiom this repo uses instead — see `brief.page.ts`, `dashboard.page.ts`,
|
||||
`registration-detail.page.ts` — is a small **typed `computed()`** that unwraps the
|
||||
`Success` value, narrowed locally in the template with `@if (x(); as p)`:
|
||||
|
||||
```ts
|
||||
// in the component class
|
||||
protected readonly loaded = computed(() => {
|
||||
const s = this.model(); // or store.someRemoteData()
|
||||
return s.tag === 'loaded' ? s : undefined;
|
||||
});
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- in the template, inside <ng-template appAsyncLoaded> -->
|
||||
@if (loaded(); as s) {
|
||||
<app-letter-composer [brief]="s.brief" ... />
|
||||
}
|
||||
```
|
||||
|
||||
No `$any()`, no cast — `loaded()` is a real, checked `T | undefined`, and `@if (…; as s)`
|
||||
narrows it the same way any other nullable signal would.
|
||||
|
||||
## The `?scenario=` dev toggle
|
||||
|
||||
Any data page can be forced through all four states without touching the backend:
|
||||
`?scenario=slow|loading|empty|error` (dev-only, `scenario.interceptor.ts`) rewrites the
|
||||
timing/outcome of `/api/*` calls. Try it on `/brief` or `/dashboard`.
|
||||
|
||||
## Where the fetch ends and the domain begins
|
||||
|
||||
A store's own state machine (its `*.machine.ts`) should own the **domain** lifecycle of
|
||||
what it holds (draft → submitted → approved, in the brief's case) — not the network
|
||||
fetch's loading/failure, which is a generic concern `RemoteData` already models. Where a
|
||||
machine's own `loading`/`failed` tags purely mirror the fetch (nothing extra beyond "not
|
||||
loaded yet" / "the GET failed"), project them onto a `RemoteData` computed at the store
|
||||
layer for `<app-async>` to render, the way `BriefStore.remoteData` does — the machine
|
||||
keeps deciding what the _letter_ is doing, `RemoteData` keeps deciding what the _fetch_ is
|
||||
doing.
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/Testing strategy" />
|
||||
|
||||
# Testing strategy
|
||||
|
||||
Tests follow the same grain as the architecture: **push the logic down to where it's pure,
|
||||
test it there directly, and keep the layers above thin.** No single tool covers everything,
|
||||
so each layer gets the cheapest test that catches its class of bug. This page owns _what to
|
||||
test, by layer_; how each test is **phrased and scoped** — one behaviour, in the domain's
|
||||
language — is [BDD](?path=/docs/foundations-bdd--docs).
|
||||
|
||||
## What gets tested where
|
||||
|
||||
| Layer | Test kind | Tool | Rule |
|
||||
| -------------------------- | ------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| `domain/` | pure-function unit spec | Vitest | **Required.** Call the function directly — **no `TestBed`**. Reducers, combinators, `visibleSteps`, parsers, policies. |
|
||||
| `domain/value-objects/` | parser spec | Vitest | Happy path + normalisation + **each** rejection. Assert on the `Result`, never the message. |
|
||||
| `infrastructure/` `parse*` | trust-boundary spec | Vitest | Accept a valid DTO; **reject `null` / `{}` / malformed**. Name it `describe('… (trust boundary)')`. |
|
||||
| `application/` | store / command spec | Vitest | Reducer purity, optimistic begin→confirm/rollback, command `Result`. |
|
||||
| `ui/` | Storybook story | Storybook + a11y | Kept thin. Axe runs on every story; add a `play` only for wiring axe can't see. |
|
||||
| flows | e2e smoke | Playwright | One happy path + one error state per critical journey. |
|
||||
| backend | rule + endpoint + golden | xUnit | Mirror of the FE domain rules, plus `WebApplicationFactory` integration. |
|
||||
|
||||
## Tooling
|
||||
|
||||
Vitest runs through Angular's built-in `@angular/build:unit-test` builder — **there is no
|
||||
`vitest.config.ts`**; config is implicit via `tsconfig.spec.json`.
|
||||
|
||||
```bash
|
||||
npm test # ng test → Vitest, all *.spec.ts co-located next to their unit
|
||||
```
|
||||
|
||||
Specs import `{ describe, it, expect }` from `vitest` and are co-located with the unit
|
||||
they cover.
|
||||
|
||||
## The house style
|
||||
|
||||
Pure and direct. A value-object parser spec
|
||||
(`registratie/domain/value-objects/postcode.spec.ts`):
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parsePostcode } from './postcode';
|
||||
|
||||
describe('parsePostcode', () => {
|
||||
it('normalises to "1234 AB"', () => {
|
||||
const r = parsePostcode(' 1234ab ');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value).toBe('1234 AB');
|
||||
});
|
||||
it('rejects malformed input', () => {
|
||||
expect(parsePostcode('0234AB').ok).toBe(false); // asserts the tag, not the copy
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
A trust-boundary adapter (`registratie/infrastructure/brp.adapter.spec.ts`) additionally
|
||||
proves the untrusted shape is rejected:
|
||||
|
||||
```ts
|
||||
expect(parseBrpAddress(null).ok).toBe(false);
|
||||
expect(parseBrpAddress({}).ok).toBe(false); // missing required field
|
||||
```
|
||||
|
||||
Elm-style machines test the pure `reduce` with inline state fixtures — no Angular
|
||||
(`registratie/domain/registratie-wizard.machine.spec.ts`).
|
||||
|
||||
## UI = Storybook, not heavy component tests
|
||||
|
||||
`@storybook/addon-a11y` runs the `wcag2a/2aa/21a/21aa` rule sets on **every** story;
|
||||
`@storybook/test-runner` + `axe-playwright` turn that into a CI gate:
|
||||
|
||||
```bash
|
||||
npm run test-storybook # axe over every story against a running Storybook
|
||||
npm run test-storybook:ci # builds storybook-static, serves :6006, runs the gate
|
||||
```
|
||||
|
||||
Disabling a11y on a story needs an inline justification + a WP cross-reference (see
|
||||
[Accessibility](?path=/docs/foundations-accessibility--docs)).
|
||||
|
||||
## Don't assert on copy
|
||||
|
||||
Localized strings change per locale and per edit. Tests assert on the `Result`
|
||||
discriminant, the value object, or the message **id** — never the rendered Dutch/English
|
||||
text. Full detail in [Internationalization](?path=/docs/foundations-internationalization--docs).
|
||||
|
||||
## The GREEN gate
|
||||
|
||||
```bash
|
||||
npm run lint && npm run check:tokens && npm test && npm run build && npm run build-storybook
|
||||
cd backend && dotnet test
|
||||
```
|
||||
|
||||
Everything above must pass before a work package is done.
|
||||
Reference in New Issue
Block a user