diff --git a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts index f93de66..f2bf768 100644 --- a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts +++ b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts @@ -6,7 +6,7 @@ import { AlertComponent } from '@shared/ui/alert/alert.component'; import { WizardShellComponent, WizardError, - WizardStatus, + WizardPhase, naarStapLabel, } from '@shared/layout/wizard-shell/wizard-shell.component'; import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component'; @@ -50,11 +50,10 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload. [stepTitle]="stepTitle()" i18n-processName="@@herregWizard.processName" processName="Herregistratie aanvragen" - [status]="shellStatus()" + [phase]="phase()" [primaryLabel]="primaryLabel()" [canGoBack]="step() > 1" [errors]="errorList()" - [errorMessage]="errorMessage()" (primary)="dispatch({ tag: 'Primary' })" (back)="dispatch({ tag: 'Back' })" (cancel)="restart()" @@ -214,7 +213,6 @@ export class HerregistratieWizardComponent { protected errJaren = computed(() => this.editing()?.errors.jaren ?? ''); protected errPunten = computed(() => this.editing()?.errors.punten ?? ''); protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? ''); - protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); protected uploadCtl = createUploadController({ wizardId: 'herregistratie', getUpload: () => this.upload(), @@ -234,19 +232,22 @@ export class HerregistratieWizardComponent { protected goToStep(index: number) { this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 }); } - protected errorMessage = computed( - () => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`, - ); - protected shellStatus = computed(() => { - switch (this.state().tag) { + /** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary, + composing the localized failure prefix so the `Failed` message arrives intact. */ + protected phase = computed(() => { + const s = this.state(); + switch (s.tag) { case 'Editing': - return 'editing'; + return { tag: 'Editing' }; case 'Submitting': - return 'submitting'; + return { tag: 'Submitting' }; case 'Submitted': - return 'submitted'; + return { tag: 'Submitted' }; case 'Failed': - return 'failed'; + return { + tag: 'Failed', + message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`, + }; } }); /** Current step's field errors, flattened for the shell's error summary. */ diff --git a/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts b/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts index 72dc03c..82bd2ed 100644 --- a/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts +++ b/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts @@ -12,7 +12,7 @@ import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.comp import { WizardShellComponent, WizardError, - WizardStatus, + WizardPhase, naarStapLabel, } from '@shared/layout/wizard-shell/wizard-shell.component'; import { createStore } from '@shared/application/store'; @@ -59,11 +59,10 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto [stepTitle]="stepTitle()" i18n-processName="@@intake.processName" processName="Herregistratie-intake" - [status]="shellStatus()" + [phase]="phase()" [primaryLabel]="primaryLabel()" [canGoBack]="cursor() > 0" [errors]="errorList()" - [errorMessage]="errorMessage()" (primary)="dispatch({ tag: 'Primary' })" (back)="dispatch({ tag: 'Back' })" (cancel)="restart()" @@ -323,7 +322,6 @@ export class IntakeWizardComponent { ); /** Whether the inline scholing question is shown (and required) in the 'werk' step. */ protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold())); - protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? ''); // --- Presentational wiring for the shared wizard shell --------------------- readonly stepLabels = [ @@ -342,19 +340,22 @@ export class IntakeWizardComponent { const next = this.cursor() + 1; return naarStapLabel(next + 1, this.stepLabels[next]); }); - protected errorMessage = computed( - () => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`, - ); - protected shellStatus = computed(() => { - switch (this.state().tag) { + /** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary, + composing the localized failure prefix so the `Failed` message arrives intact. */ + protected phase = computed(() => { + const s = this.state(); + switch (s.tag) { case 'Answering': - return 'editing'; + return { tag: 'Editing' }; case 'Submitting': - return 'submitting'; + return { tag: 'Submitting' }; case 'Submitted': - return 'submitted'; + return { tag: 'Submitted' }; case 'Failed': - return 'failed'; + return { + tag: 'Failed', + message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`, + }; } }); /** Current step's field errors, flattened for the shell's error summary. The diff --git a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts index 8333e83..3f64c28 100644 --- a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -14,7 +14,7 @@ import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.comp import { WizardShellComponent, WizardError, - WizardStatus, + WizardPhase, naarStapLabel, } from '@shared/layout/wizard-shell/wizard-shell.component'; import { ASYNC } from '@shared/ui/async/async.component'; @@ -83,11 +83,10 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid'; [stepTitle]="stepTitle()" i18n-processName="@@regWizard.processName" processName="Inschrijven in het BIG-register" - [status]="shellStatus()" + [phase]="phase()" [primaryLabel]="primaryLabel()" [canGoBack]="cursor() > 0" [errors]="errorList()" - [errorMessage]="errorMessage()" i18n-submittingLabel="@@regWizard.submitting" submittingLabel="Uw registratie wordt verwerkt…" (primary)="dispatch({ tag: 'Primary' })" @@ -440,7 +439,6 @@ export class RegistratieWizardComponent { () => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)], ); protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? ''); - protected failedError = computed(() => whenTag(this.state(), 'Mislukt')?.error ?? ''); // --- Presentational wiring for the shared wizard shell --------------------- protected primaryLabel = computed(() => { @@ -448,21 +446,23 @@ export class RegistratieWizardComponent { const next = this.cursor() + 1; return naarStapLabel(next + 1, this.stepLabels[next]); }); - protected errorMessage = computed( - () => - $localize`:@@regWizard.indienenMislukt:Het indienen is niet gelukt:` + - ` ${this.failedError()}`, - ); - protected shellStatus = computed(() => { - switch (this.state().tag) { + /** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary, + composing the localized failure prefix so the `Failed` message arrives intact. */ + protected phase = computed(() => { + const s = this.state(); + switch (s.tag) { case 'Invullen': - return 'editing'; + return { tag: 'Editing' }; case 'Indienen': - return 'submitting'; + return { tag: 'Submitting' }; case 'Ingediend': - return 'submitted'; + return { tag: 'Submitted' }; case 'Mislukt': - return 'failed'; + return { + tag: 'Failed', + message: + $localize`:@@regWizard.indienenMislukt:Het indienen is niet gelukt:` + ` ${s.error}`, + }; } }); /** Current step's errors (incl. per-question), flattened for the error summary. */ diff --git a/docs/project/readable-codebase/RD-10-wizard-phase.md b/docs/project/readable-codebase/RD-10-wizard-phase.md new file mode 100644 index 0000000..1469079 --- /dev/null +++ b/docs/project/readable-codebase/RD-10-wizard-phase.md @@ -0,0 +1,178 @@ +# 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: + +```ts +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` — `WizardStatus` 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.ts` — `base` at ~39 and the five + stories that set `status` +- The three `shellStatus`/`errorMessage`/`failedError` computeds listed above +- `libs/shared/src/kernel/fp.ts:27` — `whenTag`, which returns `Extract<…> | null` + +## Decisions (pre-made, don't relitigate) + +1. **Replace `WizardStatus` and the `errorMessage` input with one payload-carrying union:** + + ```ts + export type WizardPhase = + | { tag: 'Editing' } + | { tag: 'Submitting' } + | { tag: 'Submitted' } + | { tag: 'Failed'; message: string }; + ``` + + The shell takes `phase = input.required()`. 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: + +```bash +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 elsewhere** — `brief/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 forms** — + `besluit-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: + +```bash +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. + +```bash +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. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index b377bbb..56482a1 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -104,7 +104,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di | RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | | done | | RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | yes | done | | RD-09 | Teach the effect map: ARCHITECTURE §2d + fp-tea (2 docs, no generator) | 08 | | done | -| RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | todo | +| RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | done | | RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | todo | | RD-12 | `ActionState` becomes `action` on `BriefState.Loaded` | 11 | | todo | | RD-13 | Same for org-template, folding `pendingPublish` in | 12 | | todo | diff --git a/libs/shared/src/layout/wizard-shell/wizard-shell.component.ts b/libs/shared/src/layout/wizard-shell/wizard-shell.component.ts index ab478ac..453dfc4 100644 --- a/libs/shared/src/layout/wizard-shell/wizard-shell.component.ts +++ b/libs/shared/src/layout/wizard-shell/wizard-shell.component.ts @@ -1,9 +1,19 @@ -import { Component, ElementRef, effect, input, output, untracked, viewChild } from '@angular/core'; +import { + Component, + ElementRef, + computed, + effect, + input, + output, + untracked, + viewChild, +} from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ButtonComponent } from '@shared/ui/button/button.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { SpinnerComponent } from '@shared/ui/spinner/spinner.component'; import { StepperComponent } from '@shared/ui/stepper/stepper.component'; +import { whenTag } from '@shared/kernel/fp'; /** CIBG procesnavigatie primary-button copy for a non-final step: "Naar stap 2 - Werk". Shared so every wizard's `primaryLabel` reads the same way. */ @@ -16,7 +26,13 @@ export interface WizardError { readonly message: string; } -export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed'; +/** The wizard shell's lifecycle union. The `Failed` variant carries the localized + message intact, so the shell needs no separate input to say what went wrong. */ +export type WizardPhase = + | { tag: 'Editing' } + | { tag: 'Submitting' } + | { tag: 'Submitted' } + | { tag: 'Failed'; message: string }; /** * Template: the canonical shell every wizard renders into, so they cannot drift. @@ -52,8 +68,8 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed'; `, ], template: ` - @switch (status()) { - @case ('editing') { + @switch (phase().tag) { + @case ('Editing') { } - @case ('submitting') { + @case ('Submitting') { {{ submittingLabel() }} } - @case ('submitted') { + @case ('Submitted') { } - @case ('failed') { - {{ errorMessage() }} + @case ('Failed') { + {{ failedMessage() }}
Opnieuw proberen(); /** Overall process name, shown above the step title (e.g. "Herregistratie aanvragen"). */ processName = input(''); - status = input.required(); + phase = input.required(); primaryLabel = input.required(); canGoBack = input(false); errors = input([]); - errorMessage = input(''); submittingLabel = input($localize`:@@wizard.submitting:Aanvraag wordt verwerkt…`); + /** The `Failed` message, or '' otherwise. `@switch` can't narrow a union in a + template, so the narrowing happens here via the shared `whenTag` helper. */ + protected failedMessage = computed(() => whenTag(this.phase(), 'Failed')?.message ?? ''); + primary = output(); back = output(); cancel = output(); diff --git a/libs/shared/src/layout/wizard-shell/wizard-shell.stories.ts b/libs/shared/src/layout/wizard-shell/wizard-shell.stories.ts index a0267be..460b603 100644 --- a/libs/shared/src/layout/wizard-shell/wizard-shell.stories.ts +++ b/libs/shared/src/layout/wizard-shell/wizard-shell.stories.ts @@ -8,8 +8,8 @@ const meta: Meta = { props: args, template: `

Voorbeeld-stapinhoud (de stapvelden worden hier geprojecteerd).

Uw aanvraag is ontvangen.

@@ -36,23 +36,25 @@ const base = { primaryLabel: 'Volgende', canGoBack: true, errors: [], - errorMessage: '', goToStep: () => {}, }; -export const Editing: Story = { args: { ...base, status: 'editing' } }; +export const Editing: Story = { args: { ...base, phase: { tag: 'Editing' } } }; export const EditingMetFouten: Story = { args: { ...base, - status: 'editing', + phase: { tag: 'Editing' }, errors: [ { id: 'uren', message: 'Vul het aantal gewerkte uren in.' }, { id: 'diploma', message: 'Kies een diploma.' }, ], }, }; -export const Submitting: Story = { args: { ...base, status: 'submitting' } }; -export const Submitted: Story = { args: { ...base, status: 'submitted' } }; +export const Submitting: Story = { args: { ...base, phase: { tag: 'Submitting' } } }; +export const Submitted: Story = { args: { ...base, phase: { tag: 'Submitted' } } }; export const Failed: Story = { - args: { ...base, status: 'failed', errorMessage: 'Het indienen is niet gelukt: netwerkfout.' }, + args: { + ...base, + phase: { tag: 'Failed', message: 'Het indienen is niet gelukt: netwerkfout.' }, + }, };