refactor: extract toWizardErrors, adopted by all 3 wizards (RD-20)

Each wizard flattened its per-field error record into the shell's
WizardError[] summary with its own copy of the same loop. Extract one
pure helper, wizard-errors.ts, next to naarStapLabel. Add a spec that
covers a flat record, an empty record, skipped undefined/empty-string
values, the idPrefix, and a skipped nested object.

registratie-wizard.machine.ts changes Errors from an interface to a
type alias, because only a type alias gets an implicit index
signature and is assignable to the helper's Record<string, unknown>
parameter. The other two machines already declare their error maps as
type aliases, so this also makes the three consistent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-04 22:00:04 +02:00
co-authored by Claude Sonnet 5
parent a196a380ce
commit 831940f1b9
9 changed files with 239 additions and 24 deletions
@@ -9,6 +9,7 @@ import {
WizardPhase,
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
@@ -251,12 +252,7 @@ export class HerregistratieWizardComponent {
}
});
/** Current step's field errors, flattened for the shell's error summary. */
protected errorList = computed<WizardError[]>(() => {
const e = this.editing()?.errors ?? {};
return (Object.keys(e) as (keyof typeof e)[])
.filter((k) => e[k])
.map((k) => ({ id: k, message: e[k]! }));
});
protected errorList = computed<WizardError[]>(() => toWizardErrors(this.editing()?.errors ?? {}));
constructor() {
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
@@ -15,6 +15,7 @@ import {
WizardPhase,
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store';
@@ -360,12 +361,9 @@ export class IntakeWizardComponent {
});
/** Current step's field errors, flattened for the shell's error summary. The
field ids match the answer keys, so the summary anchors jump to the field. */
protected errorList = computed<WizardError[]>(() => {
const e = this.answering()?.errors ?? {};
return (Object.keys(e) as (keyof Answers)[])
.filter((k) => e[k])
.map((k) => ({ id: k, message: e[k]! }));
});
protected errorList = computed<WizardError[]>(() =>
toWizardErrors(this.answering()?.errors ?? {}),
);
protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
protected set = (key: keyof Answers, value: string) =>
@@ -66,7 +66,7 @@ export type DraftField = 'straat' | 'postcode' | 'woonplaats' | 'email';
/** Per-field error map. `antwoorden` holds per-policy-question errors, keyed by
question id (a step can show several questions). */
export interface Errors {
export type Errors = {
straat?: string;
postcode?: string;
woonplaats?: string;
@@ -75,7 +75,7 @@ export interface Errors {
diploma?: string;
documenten?: string;
antwoorden?: Record<string, string>;
}
};
export type RegistratieState =
| { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors; upload: UploadState }
@@ -17,6 +17,7 @@ import {
WizardPhase,
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
import { ASYNC } from '@shared/ui/async/async.component';
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store';
@@ -468,14 +469,7 @@ export class RegistratieWizardComponent {
/** Current step's errors (incl. per-question), flattened for the error summary. */
protected errorList = computed<WizardError[]>(() => {
const e = this.invullen()?.errors ?? {};
const out: WizardError[] = [];
for (const [k, v] of Object.entries(e)) {
if (k !== 'antwoorden' && typeof v === 'string' && v) out.push({ id: k, message: v });
}
for (const [qid, msg] of Object.entries(e.antwoorden ?? {})) {
if (msg) out.push({ id: 'vraag-' + qid, message: msg });
}
return out;
return [...toWizardErrors(e), ...toWizardErrors(e.antwoorden ?? {}, 'vraag-')];
});
protected adresSamenvatting = computed(() => {
const d = this.draft();
@@ -0,0 +1,170 @@
# 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<string, unknown>, 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<string, unknown>` 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<string, unknown>` and the call site will not compile. The other two machines already
declare their error maps as type aliases (`Partial<Record<…, string>>`), 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 5 (helper, spec, 3 wizards)
```
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.
+1 -1
View File
@@ -114,7 +114,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
| RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | | done |
| RD-18 | Ticket-reference sweep, frontend — 181 refs, 100 files | 01 | yes | done |
| RD-19 | Ticket-reference sweep, backend — 370 refs, 86 files | 01 | | done |
| RD-20 | `wizard-errors.ts` + spec, adopted by all 3 wizards | 02 | | todo |
| RD-20 | `wizard-errors.ts` + spec, adopted by all 3 wizards | 02 | | done |
| RD-21 | `rich-text-dom.ts` helpers + spec cases | 02 | yes | todo |
| RD-22 | `intake-wizard` to 3 step components | 08, 20 | yes | todo |
| RD-23 | `registratie-wizard` to 3 steps + the upload-controller move | 08, 20 | yes | todo |
+10 -1
View File
@@ -20,7 +20,7 @@ tested where._
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
**is** the suite, reshaped for a business reader. 532 frontend behaviours across
**is** the suite, reshaped for a business reader. 538 frontend behaviours across
9 contexts; 261 backend behaviours across 42 test
classes.
@@ -973,6 +973,15 @@ classes.
- unwraps a Success value
- is the fallback for every other state
#### toWizardErrors
- flattens a flat record of field errors
- returns an empty list for an empty record
- skips a value that is undefined
- skips a value that is an empty string
- prefixes every id with idPrefix when given
- skips a value that is a nested object, so the caller can flatten it separately
#### upload lifecycle messages
- queued → progress → complete
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { toWizardErrors } from './wizard-errors';
describe('toWizardErrors', () => {
it('flattens a flat record of field errors', () => {
expect(toWizardErrors({ straat: 'Verplicht', postcode: 'Ongeldig' })).toEqual([
{ id: 'straat', message: 'Verplicht' },
{ id: 'postcode', message: 'Ongeldig' },
]);
});
it('returns an empty list for an empty record', () => {
expect(toWizardErrors({})).toEqual([]);
});
it('skips a value that is undefined', () => {
expect(toWizardErrors({ straat: undefined })).toEqual([]);
});
it('skips a value that is an empty string', () => {
expect(toWizardErrors({ straat: '' })).toEqual([]);
});
it('prefixes every id with idPrefix when given', () => {
expect(toWizardErrors({ q1: 'Verplicht' }, 'vraag-')).toEqual([
{ id: 'vraag-q1', message: 'Verplicht' },
]);
});
it('skips a value that is a nested object, so the caller can flatten it separately', () => {
expect(toWizardErrors({ straat: 'Verplicht', antwoorden: { q1: 'Verplicht' } })).toEqual([
{ id: 'straat', message: 'Verplicht' },
]);
});
});
@@ -0,0 +1,13 @@
import type { WizardError } from './wizard-shell.component';
/** 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<string, unknown>, 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;
}