One-time prettier --write so the new format:check CI gate starts green. .prettierignore excludes generated (api-client.ts, documentation.json), vendored (public/cibg-huisstijl), and backend (dotnet format owns it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
85 lines
3.0 KiB
Markdown
85 lines
3.0 KiB
Markdown
---
|
|
name: form-machine
|
|
description: Add a form or wizard as an Elm-style state machine (Model/Msg/pure reduce) — the one idiom for anything with validation or submission, one step or many. Use instead of hand-rolled mutable fields + ad-hoc error signals.
|
|
---
|
|
|
|
# Form machine (Model / Msg / reduce)
|
|
|
|
If you're about to add a second boolean to track state, stop — model a discriminated
|
|
union. Fields exist only in the states that need them, so illegal states are
|
|
unrepresentable.
|
|
|
|
## Skeleton
|
|
|
|
`<context>/domain/<name>.machine.ts` (pure TS, no Angular imports):
|
|
|
|
```ts
|
|
import { Result, assertNever } from '@shared/kernel/fp';
|
|
|
|
export interface Draft {
|
|
postcode: string;
|
|
uren: string;
|
|
} // raw strings as typed
|
|
export type StepErrors = Partial<Record<keyof Draft, string>>;
|
|
export interface Valid {
|
|
postcode: Postcode;
|
|
uren: Uren;
|
|
} // branded, proven valid
|
|
|
|
export type State =
|
|
| { tag: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors }
|
|
| { tag: 'Submitting'; data: Valid }
|
|
| { tag: 'Submitted'; data: Valid }
|
|
| { tag: 'Failed'; data: Valid; error: string };
|
|
|
|
export type Msg =
|
|
| { tag: 'SetField'; key: keyof Draft; value: string }
|
|
| { tag: 'Next' }
|
|
| { tag: 'Back' }
|
|
| { tag: 'Submit' }
|
|
| { tag: 'Retry' }
|
|
| { tag: 'SubmitConfirmed' }
|
|
| { tag: 'SubmitFailed'; error: string }
|
|
| { tag: 'Seed'; state: State }; // mount any state (stories, resume)
|
|
|
|
export const initial: State = { tag: 'Editing', step: 1, draft: emptyDraft, errors: {} };
|
|
|
|
export function reduce(s: State, m: Msg): State {
|
|
switch (m.tag) {
|
|
/* … pure transitions only … */
|
|
default:
|
|
return assertNever(m); // exhaustiveness enforced
|
|
}
|
|
}
|
|
|
|
export function validate(draft: Draft): Result<StepErrors, Valid> {
|
|
/* calls value-object parsers */
|
|
}
|
|
```
|
|
|
|
Rules:
|
|
|
|
- **Reducer stays pure.** HTTP lives in a command that dispatches `SubmitConfirmed` / `SubmitFailed` (see **mutation-command** skill).
|
|
- **Derive, don't store**: anything computable from answers is a pure function (`visibleSteps(answers)`), never a stored field.
|
|
- Server-owned thresholds arrive as config values; keep only an offline fallback constant (see `SCHOLING_THRESHOLD_DEFAULT` in the intake machine).
|
|
- Co-located `.machine.spec.ts` is **required**: drive `reduce` with messages, assert states. No TestBed.
|
|
|
|
## Wiring in the UI
|
|
|
|
The organism holds `createStore(initial, reduce)` (`@shared/application/store`) as a
|
|
field initializer, derives view state via `computed` + `whenTag(state, 'Editing')`,
|
|
and renders into `<app-wizard-shell>` (`shared/layout/wizard-shell`) — status, steps,
|
|
errors, and primary/back/retry outputs map 1:1 onto the machine.
|
|
|
|
## Worked examples
|
|
|
|
- `src/app/herregistratie/domain/herregistratie.machine.ts` — canonical multi-step + submit lifecycle.
|
|
- `src/app/herregistratie/domain/intake.machine.ts` — progressive disclosure, derive-don't-store, server-owned threshold.
|
|
- `src/app/herregistratie/ui/` — the wizard organism + page composition.
|
|
|
|
## Verify
|
|
|
|
```bash
|
|
npm test && npm run lint && npm run build
|
|
```
|