feat(fp): WP-08 — one store idiom + machine naming + TEA MDX
Rename change-request.machine.ts's bare State/Msg to ChangeRequestState/ ChangeRequestMsg (the last machine not context-prefixed), document the createStore-is-the-idiom + naming convention in CLAUDE.md §3, and add the Foundations/State Machines (TEA) curriculum page. The wizard pages already wired createStore (confirmed by reading each and by git log) -- the WP's "hand-wired signal(model)" premise was stale; recorded as a deviation.
This commit is contained in:
@@ -75,7 +75,14 @@ model a discriminated union instead.** Three tools, all in `shared/application`:
|
||||
- **Elm-style store** (`store.ts` → `createStore(initial, reduce)`) — all state in
|
||||
one Model; change only by `dispatch(msg)` → **pure** `reduce(model, msg)`. Models
|
||||
are tagged unions (see `herregistratie.machine.ts`, `intake.machine.ts`). Templates
|
||||
send messages, never mutate.
|
||||
send messages, never mutate. **`createStore` is the one wiring idiom** — a page
|
||||
never hand-rolls `signal(model)` + a local `dispatch()`. Naming: a top-level
|
||||
machine's State/Msg types are context-prefixed (`ChangeRequestState`,
|
||||
`ChangeRequestMsg`), never bare `State`/`Msg`; a top-level machine exports
|
||||
`initial` + `reduce`. A **composable sub-machine** embedded inside a parent
|
||||
model keeps prefixed *value* exports instead (`initialUpload`/`reduceUpload`,
|
||||
see `upload.machine.ts`) — prefixing there avoids alias noise at the
|
||||
composition site.
|
||||
- **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a
|
||||
branded type only via a parser returning `Result` (`registratie/domain/value-objects/`:
|
||||
`Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it.
|
||||
|
||||
@@ -47,7 +47,7 @@ for its existing violations, so every WP ends green.
|
||||
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | done |
|
||||
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | done |
|
||||
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | done |
|
||||
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | todo |
|
||||
| [WP-08](WP-08-store-idiom.md) | One store idiom + machine naming + TEA MDX | 1 · FP/DDD | done |
|
||||
| [WP-09](WP-09-pure-logic.md) | Pure-logic closure: dates + missing command specs | 1 · FP/DDD | todo |
|
||||
| [WP-10](WP-10-button-fidelity.md) | CIBG button fidelity | 2 · CIBG | todo |
|
||||
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-08 — One store idiom + machine naming + TEA MDX
|
||||
|
||||
Status: todo
|
||||
Status: done
|
||||
Phase: 1 — FP/DDD core
|
||||
|
||||
## Why
|
||||
@@ -49,16 +49,34 @@ two idioms and copy the wrong one. Machine naming also drifts:
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty.
|
||||
- [ ] Every machine consumer wires via `createStore`; no local `signal(model)` +
|
||||
- [x] `grep -rn "export type State\b\|export type Msg\b" src/app` → empty.
|
||||
- [x] Every machine consumer wires via `createStore`; no local `signal(model)` +
|
||||
hand-rolled dispatch remains.
|
||||
- [ ] Convention documented in CLAUDE.md; MDX renders.
|
||||
- [ ] All machine specs pass unchanged (reducers untouched).
|
||||
- [x] Convention documented in CLAUDE.md; MDX renders.
|
||||
- [x] All machine specs pass unchanged (reducers untouched).
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci`. Smoke: all three wizards step forward/back and
|
||||
submit.
|
||||
GREEN + `npm run test-storybook:ci` (197 unit / 137 Storybook, unchanged from WP-07 —
|
||||
this WP touched no reducer logic). Manual smoke via a running `docker compose` stack +
|
||||
Playwright: the change-request form (the renamed machine) submitted end-to-end with a
|
||||
referentie shown; the intake wizard stepped forward and back; the herregistratie wizard
|
||||
loaded its first step — no console errors across all three.
|
||||
|
||||
## Deviation from the original plan
|
||||
|
||||
**Step 2 (migrate wizard pages off hand-wired `signal(model)`+`dispatch()` onto
|
||||
`createStore`) turned out to already be done.** `registratie-wizard.component.ts`,
|
||||
`intake-wizard.component.ts`, `herregistratie-wizard.component.ts`, and
|
||||
`change-request-form.component.ts` all already wire
|
||||
`createStore<XState, XMsg>(initial, reduce)` — confirmed both by reading each file and by
|
||||
`git log -p` on `registratie-wizard.component.ts`, which shows `createStore` present
|
||||
since the file's introduction. `grep -rn "= signal<.*State>\|= signal(init" src/app`
|
||||
(excluding specs) turns up nothing outside `store.ts` itself and `brief.store.ts`'s two
|
||||
unrelated transient-state signals (WP-07). The WP's "Why" section was accurate for an
|
||||
earlier snapshot of the codebase but stale by the time this WP ran — only the
|
||||
`change-request.machine.ts` naming fix (Step 1) and the CLAUDE.md/MDX documentation
|
||||
(Steps 3–4) had real work left.
|
||||
|
||||
## Out of scope
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,9 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { State, reduce, initial } from './change-request.machine';
|
||||
import { ChangeRequestState, reduce, initial } from './change-request.machine';
|
||||
|
||||
const editingWith = (
|
||||
draft: Partial<{ straat: string; postcode: string; woonplaats: string }>,
|
||||
): State => ({
|
||||
): ChangeRequestState => ({
|
||||
tag: 'Editing',
|
||||
draft: { straat: '', postcode: '', woonplaats: '', ...draft },
|
||||
errors: {},
|
||||
@@ -13,13 +13,13 @@ describe('change-request reduce', () => {
|
||||
it('SetField updates the draft while editing', () => {
|
||||
const s = reduce(initial, { tag: 'SetField', key: 'straat', value: 'Lange Voorhout 9' });
|
||||
expect(s.tag).toBe('Editing');
|
||||
expect((s as Extract<State, { tag: 'Editing' }>).draft.straat).toBe('Lange Voorhout 9');
|
||||
expect((s as Extract<ChangeRequestState, { tag: 'Editing' }>).draft.straat).toBe('Lange Voorhout 9');
|
||||
});
|
||||
|
||||
it('Submit with an invalid draft stays Editing and reports field errors', () => {
|
||||
const s = reduce(editingWith({ straat: '', postcode: 'nope' }), { tag: 'Submit' });
|
||||
expect(s.tag).toBe('Editing');
|
||||
const errors = (s as Extract<State, { tag: 'Editing' }>).errors;
|
||||
const errors = (s as Extract<ChangeRequestState, { tag: 'Editing' }>).errors;
|
||||
expect(errors.straat).toBeTruthy();
|
||||
expect(errors.postcode).toBeTruthy();
|
||||
});
|
||||
@@ -29,7 +29,7 @@ describe('change-request reduce', () => {
|
||||
tag: 'Submit',
|
||||
});
|
||||
expect(s.tag).toBe('Submitting');
|
||||
expect((s as Extract<State, { tag: 'Submitting' }>).data.postcode).toBe('2514 EA');
|
||||
expect((s as Extract<ChangeRequestState, { tag: 'Submitting' }>).data.postcode).toBe('2514 EA');
|
||||
});
|
||||
|
||||
it('confirms and fails only from Submitting; Retry re-submits a failure', () => {
|
||||
|
||||
@@ -23,13 +23,13 @@ export type Errors = Partial<Record<keyof Draft, string>>;
|
||||
* Submitting/Submitted/Failed carry the parsed `Valid`. Illegal states (submitting
|
||||
* an invalid draft, a success screen with errors) are unrepresentable.
|
||||
*/
|
||||
export type State =
|
||||
export type ChangeRequestState =
|
||||
| { tag: 'Editing'; draft: Draft; errors: Errors }
|
||||
| { tag: 'Submitting'; data: Valid }
|
||||
| { tag: 'Submitted'; data: Valid; referentie: string }
|
||||
| { tag: 'Failed'; data: Valid; error: string };
|
||||
|
||||
export const initial: State = {
|
||||
export const initial: ChangeRequestState = {
|
||||
tag: 'Editing',
|
||||
draft: { straat: '', postcode: '', woonplaats: '' },
|
||||
errors: {},
|
||||
@@ -51,16 +51,16 @@ function validate(draft: Draft): Result<Errors, Valid> {
|
||||
return { ok: false, error: errors };
|
||||
}
|
||||
|
||||
export type Msg =
|
||||
export type ChangeRequestMsg =
|
||||
| { tag: 'SetField'; key: keyof Draft; value: string }
|
||||
| { tag: 'Submit' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed'; referentie: string }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
| { tag: 'Reset' }
|
||||
| { tag: 'Seed'; state: State }; // mount a specific state (stories/tests)
|
||||
| { tag: 'Seed'; state: ChangeRequestState }; // mount a specific state (stories/tests)
|
||||
|
||||
export function reduce(s: State, m: Msg): State {
|
||||
export function reduce(s: ChangeRequestState, m: ChangeRequestMsg): ChangeRequestState {
|
||||
switch (m.tag) {
|
||||
case 'SetField':
|
||||
return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@registratie/ui/address-fields/address-fields.component';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { whenTag } from '@shared/kernel/fp';
|
||||
import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine';
|
||||
import { ChangeRequestState, ChangeRequestMsg, initial, reduce } from '@registratie/domain/change-request.machine';
|
||||
import { createSubmitChangeRequest } from '@registratie/application/submit-change-request';
|
||||
|
||||
/**
|
||||
@@ -70,10 +70,10 @@ export class ChangeRequestFormComponent {
|
||||
// adapter); the UI holds only this bound command. Field initializer = injection
|
||||
// context, like createStore below.
|
||||
private submit = createSubmitChangeRequest();
|
||||
private store = createStore<State, Msg>(initial, reduce);
|
||||
private store = createStore<ChangeRequestState, ChangeRequestMsg>(initial, reduce);
|
||||
|
||||
/** Optional seed so Storybook / tests can mount any state directly. */
|
||||
seed = input<State>(initial);
|
||||
seed = input<ChangeRequestState>(initial);
|
||||
|
||||
readonly state = this.store.model;
|
||||
protected dispatch = this.store.dispatch;
|
||||
|
||||
100
src/docs/machines.mdx
Normal file
100
src/docs/machines.mdx
Normal file
@@ -0,0 +1,100 @@
|
||||
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.
|
||||
Reference in New Issue
Block a user