# RD-20 — `wizard-errors.ts` + spec, adopted by all three wizards Status: done Source: PLAN.md 3c ## Why Each of the three wizards flattens its per-field error record into the shell's `WizardError[]` summary, and each writes the flattening itself. Two of the three are the same six lines with a different key type. The third does the same job plus a nested group. The loop is pure, it has no spec, and it lives in a component — so the one part of the wizard that decides what the user sees in the error summary is the one part no test covers. Extract one pure helper beside `naarStapLabel`, which lives in that folder for exactly this reason. ## Read first - `libs/shared/src/layout/wizard-shell/wizard-shell.component.ts:19-27` — `naarStapLabel` and the `WizardError` interface. The new file sits beside this one. - The three call sites, in the order they get easier: - `herregistratie-wizard.component.ts:253-259` - `intake-wizard.component.ts:361-368` - `registratie-wizard.component.ts:468-479` — the one with the nested group. - `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts:69` — `Errors`, declared as an `interface`. Decision 3 is about this line. ## Decisions (pre-made, don't relitigate) 1. **One helper, two parameters, both used today:** ```ts /** Flatten a machine's per-field error record into the shell's summary list. Values that are not a non-empty string are skipped, so a nested group (the registratie wizard's `antwoorden`) is appended by the caller with its own `idPrefix` rather than special-cased here. */ export function toWizardErrors(errors: Record, idPrefix = ''): WizardError[] { const out: WizardError[] = []; for (const [k, v] of Object.entries(errors)) { if (typeof v === 'string' && v) out.push({ id: idPrefix + k, message: v }); } return out; } ``` `Record` plus the `typeof` guard narrows `v` to `string` with **no cast**, which keeps the file inside the repo's `any`-free rule. Do not reach for a generic key type; the three machines key their errors differently and the helper does not care. 2. **New file `libs/shared/src/layout/wizard-shell/wizard-errors.ts`, plus its spec.** It takes the `WizardError` type from the component file with a **type-only import**: ```ts import type { WizardError } from './wizard-shell.component'; ``` `import type` is erased at compile time, so the helper stays pure and its spec needs no TestBed and pulls in no Angular at run time. Moving the `WizardError` interface into the new file would be tidier on paper and would touch four more files for no behaviour; not worth it. 3. **`registratie-wizard.machine.ts:69` changes from `interface Errors` to `type Errors`.** This is required, not cosmetic. TypeScript gives an implicit index signature to a type alias but **not** to an interface, so `Errors` as an interface is not assignable to `Record` and the call site will not compile. The other two machines already declare their error maps as type aliases (`Partial>`), so this also makes the three consistent. Nothing `extends` or `implements` `Errors` — verified. 4. **The three call sites become:** | Wizard | Body | | -------------- | ------------------------------------------------------------------------- | | herregistratie | `toWizardErrors(this.editing()?.errors ?? {})` | | intake | `toWizardErrors(this.answering()?.errors ?? {})` | | registratie | `[...toWizardErrors(e), ...toWizardErrors(e.antwoorden ?? {}, 'vraag-')]` | The registratie site keeps its `const e = this.invullen()?.errors ?? {};` line. The `vraag-` prefix and the skipping of non-string values together replace its `if (k !== 'antwoorden' && typeof v === 'string' && v)` filter. 5. **Do not touch the three `shellStatus` switches.** The tags genuinely differ per machine and an exhaustive switch is the house style. This ticket is about the error list only. 6. **No story.** The helper is a pure function. Its spec is the test; `wizard-shell.stories.ts` already renders the error summary. ## Files - `libs/shared/src/layout/wizard-shell/wizard-errors.ts` (new) - `libs/shared/src/layout/wizard-shell/wizard-errors.spec.ts` (new) - `apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts` - `apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts` - `apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts` - `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts` (decision 3, one line) - `libs/shared/docs/behaviour-spec.mdx` (regenerated, never hand-edited) ## Steps 1. Write `wizard-errors.ts` per decisions 1 and 2. 2. Write `wizard-errors.spec.ts`. Cover: a flat record; an empty record; a record whose values are `undefined` or `''` (both skipped); the `idPrefix`; and a record holding a nested object value (skipped, which is what lets decision 4's registratie case work). 3. Apply decision 3 — one word, `interface` → `type`, and the `{` stays. 4. Convert the three call sites per decision 4. 5. Run `npm run gen:behaviour-spec` — the new spec titles otherwise fail the drift check. 6. `git add -A`, then run the acceptance commands. 7. Update this ticket's `Status:` to `done` and the README's RD-20 row to `done`. 8. Commit all of it together. ## Acceptance criteria Measured against the tree before handover. ```bash git grep -c "export function toWizardErrors" -- libs/shared/src/layout/wizard-shell/wizard-errors.ts # MUST be 1 git grep -l "toWizardErrors" -- apps libs | wc -l # is 0 -> MUST be 6 ``` **Corrected after the ticket ran: 6, not the 5 first written.** The sixth file is `libs/shared/docs/behaviour-spec.mdx`. Step 5 regenerates it, and the generator publishes every `describe` title — so `describe('toWizardErrors', …)` puts the name in the generated document. The helper, its spec, the three wizards, and the generated page. The hand-rolled flattening is gone from all three: ```bash git grep -c "filter((k) => e\[k\])" -- apps | awk -F: '{s+=$NF} END {print s+0}' # is 2 -> MUST be 0 git grep -c "k !== 'antwoorden'" -- apps | awk -F: '{s+=$NF} END {print s+0}' # is 1 -> MUST be 0 ``` Decision 3 landed: ```bash git grep -c "export interface Errors" -- apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts # is 1 -> MUST be 0 git grep -c "export type Errors" -- apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts # MUST be 1 ``` ```bash npm run ci # exits 0 ``` ## Verification `npm run ci` is enough. `--full` is **not** required: no story changes, and the new file is under `libs/shared/src/layout/`, not `libs/shared/src/ui/`. Regenerating `behaviour-spec.mdx` does not trigger `--full` on its own — RD-17 set that precedent. `npm run lint` inside the gate is what proves the three `/* eslint-disable max-lines */` directives are still needed. `reportUnusedDisableDirectives` is `error`, so a directive that stops being necessary fails the build. ## Out of scope - Splitting any wizard into step components. RD-22 (intake) and RD-23 (registratie) own that, and this ticket makes both smaller first. - The `shellStatus` switches (decision 5). - `change-request-form` and `besluit-form`. Neither builds a `WizardError[]`; they render field errors directly. There is nothing to share. ## Risks - **The `interface` → `type` change is load-bearing** (decision 3). Skipping it produces "Index signature for type 'string' is missing in type 'Errors'" at the registratie call site, and the tempting wrong fix is to widen the helper's parameter to `object`, which forces an `any` and fails lint. - **PLAN says this ticket deletes an `eslint-disable max-lines` from `herregistratie-wizard.component.ts`. That is stale — there is no such directive.** Earlier tickets already brought the file under the budget: it measures ~248 effective lines against a limit of 250. The two disables that do exist (intake, registratie) stay; RD-22 and RD-23 remove them. Both files are far above 250 (~362 and ~574 effective), so this ticket's saving cannot make either directive unused. - **`behaviour-spec.mdx` drift** from the new spec titles. Run `gen:behaviour-spec` in the same commit, and never edit that file by hand. - **Keep `import type`, not a value import** (decision 2). A value import of the component into the helper drags Angular into a pure module and its spec.