Files
ehoandClaude Sonnet 5 11664d2efa refactor: WizardStatus to a payload-carrying WizardPhase (RD-10)
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>
2026-09-04 17:45:05 +02:00

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.tsWizardStatus at 19, status input at 148, errorMessage input at 152, the @switch at 56-133 (the @case ('failed') at 131 is the only consumer of errorMessage)
  • libs/shared/src/layout/wizard-shell/wizard-shell.stories.tsbase at ~39 and the five stories that set status
  • The three shellStatus/errorMessage/failedError computeds listed above
  • libs/shared/src/kernel/fp.ts:27whenTag, which returns Extract<…> | null

Decisions (pre-made, don't relitigate)

  1. Replace WizardStatus and the errorMessage input 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>(). The errorMessage input is deleted — nothing else reads it.

  2. 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 Dutch Invullen/Indienen/Ingediend/Mislukt. Those are not the shell's vocabulary and must not become it. What changes is that each mapping now returns a WizardPhase carrying the message, so failedError and errorMessage fold into it and each wizard goes from three computeds to one.

  3. Compose the localized prefix inside the new computed, exactly as errorMessage does 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.

  4. @switch cannot narrow a union in an Angular template. So the Failed branch reads the message through the existing helper: whenTag(this.phase(), 'Failed')?.message ?? ''. Note whenTag returns | null, not | undefined. Do not add a new narrowing helper — this is the idiom all five form components already use.

  5. Update wizard-shell.stories.ts in the same commit. base carries errorMessage: '' and five stories set status:; all become phase:. The failed story's message ("Het indienen is niet gelukt: netwerkfout.") moves inside the phase object. Both Storybook instances glob this file.

  6. Do not touch errorList or WizardError. 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.ts
  • libs/shared/src/layout/wizard-shell/wizard-shell.stories.ts
  • 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

No machine changes. No xlf changes.

Steps

  1. Add WizardPhase to the shell, swap status + errorMessage for one phase input, and read the failed message per decision 4.
  2. Delete WizardStatus.
  3. In each wizard, collapse failedError + errorMessage + shellStatus into one phase computed returning a WizardPhase, keeping the localized prefix composition.
  4. Update the shell's stories per decision 5.
  5. Update this ticket's Status: to done and the README's RD-10 row to done.
  6. 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:

  • errorMessage legitimately exists elsewherebrief/infrastructure/letter-preview.adapter.ts, its spec, reveal-bignummer.adapter.ts, and the generated libs/shared/docs/behaviour-spec.mdx. All unrelated to this seam. Leave them.
  • failedError legitimately survives in the two single-step formsbesluit-form.component.ts and change-request-form.component.ts. RD-06 gave those their own Failed branch 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's type: discriminant. Optional RD-35.

Risks

  • ng build --localize fails on a changed $localize id or source text. Keep both template literals byte-identical and only move where they are composed (decision 3).
  • whenTag returns null, not undefined. ?? '' covers both, but a === undefined check would silently fail.
  • input.required has no default, unlike the errorMessage = input('') it replaces. Every call site must pass phase, including all five stories. A missed story fails at runtime in Storybook, not at compile time — which is why --full is mandatory here.
  • Do not let the shell learn the machines' tags. If WizardPhase grows an Invullen or Answering member, the mapping has leaked into the shared layer and the change has made things worse.