The wizard shell took two inputs to say one thing: a flat WizardStatus string and a separate errorMessage input. Each wizard needed three computeds (failedError, errorMessage, shellStatus) to take the state apart and put it back together for the shell. WizardPhase replaces both inputs with one discriminated union. Its Failed variant carries the message directly, so no data travels through a second channel. Each wizard now maps its own tags onto WizardPhase in one computed, composing the localized failure prefix at the same spot errorMessage did before. The three machines and their own vocabulary (Editing/Answering/Invullen, Indienen/Ingediend/Mislukt) are unchanged; only the shell's input contract changes. The shell reads the Failed message via the existing whenTag helper, because @switch cannot narrow a union in an Angular template. Both $localize ids (wizard.indienenMislukt, regWizard.indienenMislukt) keep byte-identical source text, so no locale file changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
8.4 KiB
RD-10 — Let the wizard shell carry the error, not drop it
Status: done Source: PLAN.md 1b#4
Why
WizardStatus is a payload-free string union:
export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
Each wizard flattens its own state tag down to it with an identical 12-line switch, which
throws the error away. The error then has to travel separately, through a second
errorMessage input, and each wizard needs three computeds to take apart and reassemble what
one union could have carried intact:
| Wizard | failedError |
errorMessage |
shellStatus |
|---|---|---|---|
herregistratie-wizard |
217 | 238 | 240-251 |
intake-wizard |
326 | 346 | 348-359 |
registratie-wizard |
443 | 453 | 456-467 |
Nine computeds and three switches exist because the type at the seam is too weak. One payload-carrying union replaces all of it with three computeds — one per wizard.
Read first
libs/shared/src/layout/wizard-shell/wizard-shell.component.ts—WizardStatusat 19,statusinput at 148,errorMessageinput at 152, the@switchat 56-133 (the@case ('failed')at 131 is the only consumer oferrorMessage)libs/shared/src/layout/wizard-shell/wizard-shell.stories.ts—baseat ~39 and the five stories that setstatus- The three
shellStatus/errorMessage/failedErrorcomputeds listed above libs/shared/src/kernel/fp.ts:27—whenTag, which returnsExtract<…> | null
Decisions (pre-made, don't relitigate)
-
Replace
WizardStatusand theerrorMessageinput with one payload-carrying union:export type WizardPhase = | { tag: 'Editing' } | { tag: 'Submitting' } | { tag: 'Submitted' } | { tag: 'Failed'; message: string };The shell takes
phase = input.required<WizardPhase>(). TheerrorMessageinput is deleted — nothing else reads it. -
Keep the three mapping computeds. Do not try to remove them. Each machine's tags are its own and genuinely differ —
Editing/Answering/Invullen, and registratie's DutchInvullen/Indienen/Ingediend/Mislukt. Those are not the shell's vocabulary and must not become it. What changes is that each mapping now returns aWizardPhasecarrying the message, sofailedErroranderrorMessagefold into it and each wizard goes from three computeds to one. -
Compose the localized prefix inside the new computed, exactly as
errorMessagedoes today, so both ids survive byte-identically:@@wizard.indienenMislukt— "Indienen mislukt:" (herregistratie and intake)@@regWizard.indienenMislukt— "Het indienen is niet gelukt:" (registratie)
The prefix differs per wizard, which is exactly why the mapping stays in the wizard. Do not move either string into the shell, and do not reword them — same id with different source text fails extraction.
-
@switchcannot narrow a union in an Angular template. So theFailedbranch reads the message through the existing helper:whenTag(this.phase(), 'Failed')?.message ?? ''. NotewhenTagreturns| null, not| undefined. Do not add a new narrowing helper — this is the idiom all five form components already use. -
Update
wizard-shell.stories.tsin the same commit.basecarrieserrorMessage: ''and five stories setstatus:; all becomephase:. The failed story's message ("Het indienen is niet gelukt: netwerkfout.") moves inside the phase object. Both Storybook instances glob this file. -
Do not touch
errorListorWizardError. Those carry the current step's field errors for the shell's error summary — a different concern from the submit failure, on a different axis. They stay exactly as they are.
Files
libs/shared/src/layout/wizard-shell/wizard-shell.component.tslibs/shared/src/layout/wizard-shell/wizard-shell.stories.tsapps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.tsapps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.tsapps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts
No machine changes. No xlf changes.
Steps
- Add
WizardPhaseto the shell, swapstatus+errorMessagefor onephaseinput, and read the failed message per decision 4. - Delete
WizardStatus. - In each wizard, collapse
failedError+errorMessage+shellStatusinto onephasecomputed returning aWizardPhase, keeping the localized prefix composition. - Update the shell's stories per decision 5.
- Update this ticket's
Status:todoneand the README's RD-10 row todone. - Commit all of it together.
Acceptance criteria
The weak type is gone, and no wizard still needs three computeds to say one thing. These commands were dry-run against the tree before this ticket was written, and the last two are path-scoped deliberately — an unscoped version of either can never return nothing:
git grep -n "WizardStatus" -- apps libs # MUST return nothing
W=libs/shared/src/layout/wizard-shell
H=apps/ssp/src/app/herregistratie/ui
R=apps/ssp/src/app/registratie/ui/registratie-wizard
git grep -n "errorMessage" -- $W $H $R # MUST return nothing
git grep -n "failedError" -- $H $R # MUST return nothing
Why the scoping, so nobody "fixes" correct code to satisfy a bad check:
errorMessagelegitimately exists elsewhere —brief/infrastructure/letter-preview.adapter.ts, its spec,reveal-bignummer.adapter.ts, and the generatedlibs/shared/docs/behaviour-spec.mdx. All unrelated to this seam. Leave them.failedErrorlegitimately survives in the two single-step forms —besluit-form.component.tsandchange-request-form.component.ts. RD-06 gave those their ownFailedbranch and they keep their own computed. This ticket touches only the three wizards.
Both $localize ids survive unchanged, so no new translation is needed. Scope to source —
an unscoped -- apps also matches the three locale files, which must not change:
git grep -l "wizard.indienenMislukt" -- $H # exactly 2: herregistratie + intake
git grep -l "regWizard.indienenMislukt" -- $R # exactly 1: registratie
# The locale files must be untouched by this ticket:
git diff --name-only HEAD | git grep -c "locale/messages" || true # expect no locale diff
For reference, the ids already exist in apps/ssp/src/locale/messages.xlf,
apps/ssp/src/locale/messages.en.xlf and apps/behandelportal/src/locale/messages.en.xlf.
Keeping the source text byte-identical is what lets all three stay as they are.
npm run ci # exits 0
npm run ci --full # exits 0 — required, this changes stories
Then confirm the error still reaches the user: seed each wizard's failed state in Storybook and check the alert shows the full message, prefix included. That is the behaviour this ticket exists to protect, and the type change is what makes losing it impossible.
Verification
npm run ci --full. --full is mandatory: this edits wizard-shell.stories.ts, and only
build-storybook plus the axe run exercise it. Both Storybook instances glob the shared
library, so both must build.
Out of scope
errorList/WizardError(decision 6).- The three machines. This ticket changes only the UI seam.
- Splitting any wizard into steps. RD-22 and RD-23.
UploadStatus'stype:discriminant. Optional RD-35.
Risks
ng build --localizefails on a changed$localizeid or source text. Keep both template literals byte-identical and only move where they are composed (decision 3).whenTagreturnsnull, notundefined.?? ''covers both, but a=== undefinedcheck would silently fail.input.requiredhas no default, unlike theerrorMessage = input('')it replaces. Every call site must passphase, including all five stories. A missed story fails at runtime in Storybook, not at compile time — which is why--fullis mandatory here.- Do not let the shell learn the machines' tags. If
WizardPhasegrows anInvullenorAnsweringmember, the mapping has leaked into the shared layer and the change has made things worse.