Files
atomic-design-poc/.claude/skills/form-machine/SKILL.md
Edwin van den Houdt e82309786d style: format frontend, docs and skills with prettier; add .prettierignore
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>
2026-07-03 13:39:31 +02:00

3.0 KiB

name, description
name description
form-machine 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):

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

npm test && npm run lint && npm run build