Files
ehoandClaude Sonnet 5 8e1de38c68 refactor: split intake-wizard into three step components (RD-22)
The parent held one @switch with three @case blocks — three screens'
markup in one file. Each case is independent and needs only the
answers, the errors, and (for two of them) the scholing threshold.

Extract buitenland.step.ts, werk.step.ts, and review.step.ts as pure,
presentational steps: inputs down, one narrow output up, dispatch
never passed down. The parent keeps the store, the shell, and
draftSync, and maps each step's output back to a machine message.

This is the first *.step.ts in the repo, so it sets the naming
convention that RD-23 does the same job with.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:58:52 +02:00

9.7 KiB

RD-22 — Split intake-wizard into three step components

Status: done Source: PLAN.md 3c, order step 4

Why

intake-wizard.component.ts measures ~362 effective lines against a limit of 250, and carries /* eslint-disable max-lines */. Nearly all of the excess is one @switch with three @case blocks — three screens' worth of markup in one file, where reading any one of them means scrolling past the other two.

The three cases are already independent. Each reads only the answers, the errors and (for two of them) the scholing threshold. None needs the store.

Read first

  • apps/ssp/src/app/registratie/ui/address-fields/address-fields.component.ts:13-18the contract to copy, verbatim. "Pure & presentational — values in via value, errors in via errors, every keystroke out via fieldChange. No store, no services, no internal state; the container owns the Model and decides what a change means." Two containers already reuse it.
  • intake-wizard.component.ts:72-249 — the @switch and its three cases.
  • intake.machine.tsAnswers (21), lageUren (52), SCHOLING_THRESHOLD_DEFAULT (43), and Errors at line 63, which decision 2 exports.
  • libs/shared/src/layout/wizard-shell/wizard-shell.component.ts:103-113 — the <form> and the <ng-content /> the steps are projected into. Relevant to the first risk.

Decisions (pre-made, don't relitigate)

  1. Three new files, beside the parent, named *.step.ts:

    File Class Selector
    buitenland.step.ts BuitenlandStep app-intake-buitenland-step
    werk.step.ts WerkStep app-intake-werk-step
    review.step.ts ReviewStep app-intake-review-step

    These are the repository's first *.step.ts files, so this ticket sets the convention that RD-23 follows. The max-lines glob already includes step, so they are guarded from the moment they exist.

  2. Inputs down, one narrow output up, dispatch never passed down.

    Step Inputs Output
    buitenland answers, errors answerChange: { key: keyof Answers; value: string }
    werk answers, errors, scholingThreshold answerChange (same shape)
    review answers, scholingThreshold edit: number (the cursor to jump to)

    All inputs are input.required<T>(). The parent maps the outputs back to messages: (answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })" and (edit)="dispatch({ tag: 'GaNaarStap', cursor: $event })".

  3. scholingZichtbaar is not an input — each step derives it. werk and review both call the pure lageUren(this.answers(), this.scholingThreshold()) themselves. "Derive, don't store" (CLAUDE.md decision 3). The parent's scholingZichtbaar computed is deleted; it has exactly three references today, all of them in the two blocks that move.

  4. Export Errors from intake.machine.ts:63. It is type Errors = … without export today, so a step cannot name its own input type. One word. Do not redeclare the type in the step files, and do not widen the input to Record<string, string>.

  5. The parent keeps the shell, the store, and everything that touches them. After the split it holds: the store and its effect map, draftSync, IntakePolicyStore, restart(), phase, primaryLabel, stepTitle, stepLabels, errorList, and the wizardSuccess block. It loses err, set, jaNee and scholingZichtbaar, and gains one computed:

    protected errors = computed<Errors>(() => this.answering()?.errors ?? {});
    
  6. Prune the parent's imports: array. After the move it needs only ButtonComponent, ConfirmationComponent, WizardShellComponent and the three steps. FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, AlertComponent, DataRowComponent and ReviewSectionComponent all move into the steps that use them. A stale entry is not an error, so nothing fails if you forget — check the list by hand.

  7. Delete /* eslint-disable max-lines */ from the parent. Mandatory, not bookkeeping: reportUnusedDisableDirectives is error, so the two rules pin each other. Still over budget → max-lines fails. Under budget with the directive left in → unused-directive fails.

  8. No stories for the new steps. PLAN's corollary: each wizard's existing story already mounts every step by seeding the machine. intake-wizard.stories.ts is unchanged, and the parent's public API does not move.

  9. Move the markup, do not improve it. Copy each @case body into its step's template and change only what decisions 2 and 3 require: err('x') becomes errors()['x'] ?? '' through a local helper, set('x', $event) becomes an answerChange.emit(…), and dispatch(GaNaarStap) becomes edit.emit(n). Every i18n id, label, placeholder and fieldId stays byte-identical.

Files

  • apps/ssp/src/app/herregistratie/ui/intake-wizard/buitenland.step.ts (new)
  • apps/ssp/src/app/herregistratie/ui/intake-wizard/werk.step.ts (new)
  • apps/ssp/src/app/herregistratie/ui/intake-wizard/review.step.ts (new)
  • apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts
  • apps/ssp/src/app/herregistratie/domain/intake.machine.ts (decision 4, one word)

Steps

  1. Export Errors (decision 4).
  2. Write the three step components, moving each @case body verbatim per decision 9.
  3. Replace the @switch in the parent with the three elements, wire the outputs per decision 2.
  4. Delete err, set, jaNee, scholingZichtbaar; add the errors computed (decision 5).
  5. Prune imports: (decision 6) and delete the disable (decision 7).
  6. git add -A, then run the acceptance commands.
  7. Update this ticket's Status: to done and the README's RD-22 row to done.
  8. Commit all of it together.

Acceptance criteria

Measured against the tree before handover. Run after git add -Agit ls-files does not see an unstaged new file.

git ls-files 'apps/ssp/src/app/herregistratie/ui/intake-wizard/*.step.ts' | wc -l   # is 0 -> MUST be 3

The markup left the parent, and the store did not follow it into the steps:

P=apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts
git grep -c "ngModel" -- $P                        # is 12 -> MUST be 0
git grep -c "eslint-disable max-lines" -- $P       # is 1  -> MUST be 0
git grep -c "dispatch" -- 'apps/ssp/src/app/herregistratie/ui/intake-wizard/*.step.ts' | awk -F: '{s+=$NF} END {print s+0}'   # MUST be 0

Decisions 3 and 4 landed:

git grep -c "export type Errors" -- apps/ssp/src/app/herregistratie/domain/intake.machine.ts   # is 0 -> MUST be 1
git grep -c "scholingZichtbaar" -- $P              # is 3 -> MUST be 0
git grep -c "lageUren" -- 'apps/ssp/src/app/herregistratie/ui/intake-wizard/*.step.ts' | awk -F: '{s+=$NF} END {print s+0}'   # MUST be >= 2

The copy did not drift (decision 9) — the i18n ids are the same set, only in different files:

git grep -ho "@@intake\.[a-zA-Z.]*" -- apps/ssp/src/app/herregistratie/ui/intake-wizard/ | sort -u | wc -l   # is 31 -> MUST still be 31
npm run ci --full   # exits 0

Verification

The @@intake.* id count is 31 today, measured across the whole intake-wizard/ directory so the three new files are included. A dropped or renamed id breaks the second locale, and ng build --localize inside the gate fails on a missing translation — but only for an id that is added, never for one silently lost. The count is the only check that catches a loss.

--full is required. The steps render inside the existing story, and the axe run over that story is what proves the projected markup still has its labels and error wiring.

Do not add a line-count command. npm run lint is the exact check; decision 7 explains why.

Out of scope

  • registratie-wizard. RD-23 does the same job there, and follows this ticket's naming.
  • herregistratie-wizard. PLAN: do not split it for symmetry — it is ~248 effective lines with a ~100-line template.
  • Adding stories for the steps (decision 8).
  • Changing any validation, message or i18n id.

Risks

  • ngModel and the projected <form>. The shell renders <form> and <ng-content /> in its own view, so today's ngModel elements are projected into it from the parent's template. Angular resolves a directive's injector by the declaration site, not the DOM position, so those controls already do not register with the shell's NgForm — the bindings are one-way [ngModel] plus (ngModelChange). Moving them one level deeper changes nothing about that. Keep the bindings exactly as they are. If a form-control warning or error appears, stop and report it rather than adding ngModelOptions or an [ngModelGroup] to silence it.
  • Deleting the disable is mandatory (decision 7), and its failure message ("Unused eslint-disable directive") reads like an unrelated error.
  • The review step needs a cursor, not a dispatch. Its two edit buttons jump to cursor 0 and 1. Emit the number; let the parent build the message.
  • Three @case blocks, three files — do not merge them. buitenland and werk look similar; they are not the same screen and share no markup worth extracting.