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>
This commit is contained in:
eho
2026-09-04 22:58:52 +02:00
co-authored by Claude Sonnet 5
parent 4b3e6a6cfd
commit 8e1de38c68
7 changed files with 452 additions and 195 deletions
@@ -60,7 +60,7 @@ export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
// #endregion showcase:steps
/** Per-field error map: one message per question, since a step holds several. */
type Errors = Partial<Record<keyof Answers, string>>;
export type Errors = Partial<Record<keyof Answers, string>>;
export type IntakeState =
| {
@@ -0,0 +1,77 @@
import { Component, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component';
import { Answers, Errors } from '@herregistratie/domain/intake.machine';
/** Step: the intake wizard's first screen (foreign work in the last 5 years).
Pure & presentational — values in via `answers`/`errors`, every keystroke out
via `answerChange`. No store, no services, no internal state; the parent owns
the Model and decides what a change means. */
@Component({
selector: 'app-intake-buitenland-step',
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent],
template: `
<fieldset>
<app-form-field
i18n-label="@@intake.q.buitenland"
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
fieldId="buitenlandGewerkt"
required
[error]="err('buitenlandGewerkt')"
>
<app-radio-group
name="buitenlandGewerkt"
[options]="jaNee"
[ngModel]="answers().buitenlandGewerkt ?? ''"
(ngModelChange)="answerChange.emit({ key: 'buitenlandGewerkt', value: $event })"
/>
</app-form-field>
</fieldset>
@if (answers().buitenlandGewerkt === 'ja') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.land"
label="In welk land?"
fieldId="land"
required
[error]="err('land')"
>
<app-text-input
inputId="land"
[ngModel]="answers().land ?? ''"
(ngModelChange)="answerChange.emit({ key: 'land', value: $event })"
name="land"
i18n-placeholder="@@intake.q.landPlaceholder"
placeholder="bijv. België"
/>
</app-form-field>
<app-form-field
i18n-label="@@intake.q.buitenlandseUren"
label="Hoeveel uur heeft u daar gewerkt?"
fieldId="buitenlandseUren"
required
[error]="err('buitenlandseUren')"
>
<app-text-input
inputId="buitenlandseUren"
[ngModel]="answers().buitenlandseUren ?? ''"
(ngModelChange)="answerChange.emit({ key: 'buitenlandseUren', value: $event })"
name="buitenlandseUren"
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
placeholder="bijv. 800"
/>
</app-form-field>
</fieldset>
}
`,
})
export class BuitenlandStep {
answers = input.required<Answers>();
errors = input.required<Errors>();
answerChange = output<{ key: keyof Answers; value: string }>();
readonly jaNee = JA_NEE;
protected err = (k: keyof Answers) => this.errors()[k] ?? '';
}
@@ -1,13 +1,5 @@
/* eslint-disable max-lines */ // one wizard shell for the intake steps — removed by RD-22
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
import {
WizardShellComponent,
@@ -23,16 +15,19 @@ import {
IntakeState,
IntakeMsg,
Answers,
Errors,
StepId,
initial,
reduce,
STEPS,
lageUren,
hasProgress,
SCHOLING_THRESHOLD_DEFAULT,
} from '@herregistratie/domain/intake.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';
import { BuitenlandStep } from './buitenland.step';
import { WerkStep } from './werk.step';
import { ReviewStep } from './review.step';
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
@@ -42,16 +37,12 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
@Component({
selector: 'app-intake-wizard',
imports: [
FormsModule,
FormFieldComponent,
TextInputComponent,
RadioGroupComponent,
ButtonComponent,
AlertComponent,
DataRowComponent,
ReviewSectionComponent,
ConfirmationComponent,
WizardShellComponent,
BuitenlandStep,
WerkStep,
ReviewStep,
],
template: `
<app-wizard-shell
@@ -72,180 +63,26 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
>
@switch (step()) {
@case ('buitenland') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.buitenland"
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
fieldId="buitenlandGewerkt"
required
[error]="err('buitenlandGewerkt')"
>
<app-radio-group
name="buitenlandGewerkt"
[options]="jaNee"
[ngModel]="answers().buitenlandGewerkt ?? ''"
(ngModelChange)="set('buitenlandGewerkt', $event)"
<app-intake-buitenland-step
[answers]="answers()"
[errors]="errors()"
(answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
/>
</app-form-field>
</fieldset>
@if (answers().buitenlandGewerkt === 'ja') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.land"
label="In welk land?"
fieldId="land"
required
[error]="err('land')"
>
<app-text-input
inputId="land"
[ngModel]="answers().land ?? ''"
(ngModelChange)="set('land', $event)"
name="land"
i18n-placeholder="@@intake.q.landPlaceholder"
placeholder="bijv. België"
/>
</app-form-field>
<app-form-field
i18n-label="@@intake.q.buitenlandseUren"
label="Hoeveel uur heeft u daar gewerkt?"
fieldId="buitenlandseUren"
required
[error]="err('buitenlandseUren')"
>
<app-text-input
inputId="buitenlandseUren"
[ngModel]="answers().buitenlandseUren ?? ''"
(ngModelChange)="set('buitenlandseUren', $event)"
name="buitenlandseUren"
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
placeholder="bijv. 800"
/>
</app-form-field>
</fieldset>
}
}
@case ('werk') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.urenNl"
label="Gewerkte uren in Nederland (afgelopen 5 jaar)"
fieldId="uren"
required
[error]="err('uren')"
>
<app-text-input
inputId="uren"
[ngModel]="answers().uren ?? ''"
(ngModelChange)="set('uren', $event)"
name="uren"
i18n-placeholder="@@intake.q.urenNlPlaceholder"
placeholder="bijv. 4160"
<app-intake-werk-step
[answers]="answers()"
[errors]="errors()"
[scholingThreshold]="scholingThreshold()"
(answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
/>
</app-form-field>
</fieldset>
@if (scholingZichtbaar()) {
<fieldset>
<app-form-field
i18n-label="@@intake.q.scholing"
label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?"
fieldId="scholingGevolgd"
required
[error]="err('scholingGevolgd')"
>
<app-radio-group
name="scholingGevolgd"
[options]="jaNee"
[ngModel]="answers().scholingGevolgd ?? ''"
(ngModelChange)="set('scholingGevolgd', $event)"
/>
</app-form-field>
</fieldset>
}
@if (answers().scholingGevolgd === 'ja') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.punten"
label="Behaalde nascholingspunten"
fieldId="punten"
required
[error]="err('punten')"
>
<app-text-input
inputId="punten"
[ngModel]="answers().punten ?? ''"
(ngModelChange)="set('punten', $event)"
name="punten"
i18n-placeholder="@@intake.q.puntenPlaceholder"
placeholder="bijv. 200"
/>
</app-form-field>
</fieldset>
}
}
@case ('review') {
<app-alert type="info" i18n="@@intake.review.controleer"
>Controleer uw antwoorden en dien de aanvraag in.</app-alert
>
<app-review-section
i18n-heading="@@intake.sectie.buitenland"
heading="Buitenland"
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
editAriaLabel="Wijzigen buitenland"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
>
<div
app-data-row
i18n-key="@@intake.review.buitenNl"
key="Buiten NL gewerkt"
[value]="answers().buitenlandGewerkt ?? '—'"
></div>
@if (answers().buitenlandGewerkt === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.land"
key="Land"
[value]="answers().land ?? ''"
></div>
<div
app-data-row
i18n-key="@@intake.review.buitenlandseUren"
key="Buitenlandse uren"
[value]="answers().buitenlandseUren ?? ''"
></div>
}
</app-review-section>
<app-review-section
class="app-section"
i18n-heading="@@intake.sectie.werk"
heading="Werk in Nederland"
i18n-editAriaLabel="@@intake.werkWijzigenAria"
editAriaLabel="Wijzigen werk in Nederland"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
>
<div
app-data-row
i18n-key="@@intake.review.urenNl"
key="Uren NL"
[value]="answers().uren ?? ''"
></div>
@if (scholingZichtbaar()) {
<div
app-data-row
i18n-key="@@intake.review.scholing"
key="Aanvullende scholing"
[value]="answers().scholingGevolgd ?? ''"
></div>
}
@if (answers().scholingGevolgd === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.punten"
key="Nascholingspunten"
[value]="answers().punten ?? ''"
></div>
}
</app-review-section>
<app-intake-review-step
[answers]="answers()"
[scholingThreshold]="scholingThreshold()"
(edit)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
/>
}
}
@@ -295,7 +132,6 @@ export class IntakeWizardComponent {
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<IntakeState>(initial);
readonly jaNee = JA_NEE;
readonly state = this.store.model;
readonly dispatch = this.store.dispatch;
@@ -321,8 +157,7 @@ export class IntakeWizardComponent {
protected scholingThreshold = computed(
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
);
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
protected errors = computed<Errors>(() => this.answering()?.errors ?? {});
// --- Presentational wiring for the shared wizard shell ---------------------
readonly stepLabels = [
@@ -365,10 +200,6 @@ export class IntakeWizardComponent {
toWizardErrors(this.answering()?.errors ?? {}),
);
protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
protected set = (key: keyof Answers, value: string) =>
this.dispatch({ tag: 'SetAnswer', key, value });
constructor() {
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
@@ -0,0 +1,85 @@
import { Component, computed, input, output } from '@angular/core';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
import { Answers, lageUren } from '@herregistratie/domain/intake.machine';
/** Step: the intake wizard's review screen. Pure & presentational — values in via
`answers`/`scholingThreshold`, the cursor to jump back to out via `edit`. No
store, no services, no internal state; the parent maps the cursor onto its own
`GaNaarStap` message. */
@Component({
selector: 'app-intake-review-step',
imports: [AlertComponent, DataRowComponent, ReviewSectionComponent],
template: `
<app-alert type="info" i18n="@@intake.review.controleer"
>Controleer uw antwoorden en dien de aanvraag in.</app-alert
>
<app-review-section
i18n-heading="@@intake.sectie.buitenland"
heading="Buitenland"
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
editAriaLabel="Wijzigen buitenland"
(edit)="edit.emit(0)"
>
<div
app-data-row
i18n-key="@@intake.review.buitenNl"
key="Buiten NL gewerkt"
[value]="answers().buitenlandGewerkt ?? '—'"
></div>
@if (answers().buitenlandGewerkt === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.land"
key="Land"
[value]="answers().land ?? ''"
></div>
<div
app-data-row
i18n-key="@@intake.review.buitenlandseUren"
key="Buitenlandse uren"
[value]="answers().buitenlandseUren ?? ''"
></div>
}
</app-review-section>
<app-review-section
class="app-section"
i18n-heading="@@intake.sectie.werk"
heading="Werk in Nederland"
i18n-editAriaLabel="@@intake.werkWijzigenAria"
editAriaLabel="Wijzigen werk in Nederland"
(edit)="edit.emit(1)"
>
<div
app-data-row
i18n-key="@@intake.review.urenNl"
key="Uren NL"
[value]="answers().uren ?? ''"
></div>
@if (scholingZichtbaar()) {
<div
app-data-row
i18n-key="@@intake.review.scholing"
key="Aanvullende scholing"
[value]="answers().scholingGevolgd ?? ''"
></div>
}
@if (answers().scholingGevolgd === 'ja') {
<div
app-data-row
i18n-key="@@intake.review.punten"
key="Nascholingspunten"
[value]="answers().punten ?? ''"
></div>
}
</app-review-section>
`,
})
export class ReviewStep {
answers = input.required<Answers>();
scholingThreshold = input.required<number>();
edit = output<number>();
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
}
@@ -0,0 +1,84 @@
import { Component, computed, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component';
import { Answers, Errors, lageUren } from '@herregistratie/domain/intake.machine';
/** Step: the intake wizard's second screen (work experience in the Netherlands,
with the inline scholing follow-up). Pure & presentational — values in via
`answers`/`errors`/`scholingThreshold`, every keystroke out via `answerChange`.
No store, no services, no internal state; the parent owns the Model and
decides what a change means. */
@Component({
selector: 'app-intake-werk-step',
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent],
template: `
<fieldset>
<app-form-field
i18n-label="@@intake.q.urenNl"
label="Gewerkte uren in Nederland (afgelopen 5 jaar)"
fieldId="uren"
required
[error]="err('uren')"
>
<app-text-input
inputId="uren"
[ngModel]="answers().uren ?? ''"
(ngModelChange)="answerChange.emit({ key: 'uren', value: $event })"
name="uren"
i18n-placeholder="@@intake.q.urenNlPlaceholder"
placeholder="bijv. 4160"
/>
</app-form-field>
</fieldset>
@if (scholingZichtbaar()) {
<fieldset>
<app-form-field
i18n-label="@@intake.q.scholing"
label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?"
fieldId="scholingGevolgd"
required
[error]="err('scholingGevolgd')"
>
<app-radio-group
name="scholingGevolgd"
[options]="jaNee"
[ngModel]="answers().scholingGevolgd ?? ''"
(ngModelChange)="answerChange.emit({ key: 'scholingGevolgd', value: $event })"
/>
</app-form-field>
</fieldset>
}
@if (answers().scholingGevolgd === 'ja') {
<fieldset>
<app-form-field
i18n-label="@@intake.q.punten"
label="Behaalde nascholingspunten"
fieldId="punten"
required
[error]="err('punten')"
>
<app-text-input
inputId="punten"
[ngModel]="answers().punten ?? ''"
(ngModelChange)="answerChange.emit({ key: 'punten', value: $event })"
name="punten"
i18n-placeholder="@@intake.q.puntenPlaceholder"
placeholder="bijv. 200"
/>
</app-form-field>
</fieldset>
}
`,
})
export class WerkStep {
answers = input.required<Answers>();
errors = input.required<Errors>();
scholingThreshold = input.required<number>();
answerChange = output<{ key: keyof Answers; value: string }>();
readonly jaNee = JA_NEE;
protected err = (k: keyof Answers) => this.errors()[k] ?? '';
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
}
@@ -0,0 +1,180 @@
# 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-18` — **the
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.ts``Answers` (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:
```ts
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 -A``git ls-files` does not see
an unstaged new file.
```bash
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:
```bash
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:
```bash
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:
```bash
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
```
```bash
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.
+1 -1
View File
@@ -116,7 +116,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
| RD-19 | Ticket-reference sweep, backend — 370 refs, 86 files | 01 | | done |
| RD-20 | `wizard-errors.ts` + spec, adopted by all 3 wizards | 02 | | done |
| RD-21 | `rich-text-dom.ts` helpers + spec cases | 02 | yes | done |
| RD-22 | `intake-wizard` to 3 step components | 08, 20 | yes | todo |
| RD-22 | `intake-wizard` to 3 step components | 08, 20 | yes | done |
| RD-23 | `registratie-wizard` to 3 steps + the upload-controller move | 08, 20 | yes | todo |
| RD-24 | `concepts.page` to 6 sections + `concept-card` + globals + code tokens | 02 | yes | todo |
| RD-25 | `org-template-editor` to `sample-letter.ts` + labels + 2 children | 02 | yes | todo |