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>
105 lines
5.1 KiB
Plaintext
105 lines
5.1 KiB
Plaintext
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.
|