diff --git a/apps/ssp/src/app/herregistratie/domain/intake.machine.ts b/apps/ssp/src/app/herregistratie/domain/intake.machine.ts index 54d9a23..f977a9a 100644 --- a/apps/ssp/src/app/herregistratie/domain/intake.machine.ts +++ b/apps/ssp/src/app/herregistratie/domain/intake.machine.ts @@ -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>; +export type Errors = Partial>; export type IntakeState = | { diff --git a/apps/ssp/src/app/herregistratie/ui/intake-wizard/buitenland.step.ts b/apps/ssp/src/app/herregistratie/ui/intake-wizard/buitenland.step.ts new file mode 100644 index 0000000..caa6d70 --- /dev/null +++ b/apps/ssp/src/app/herregistratie/ui/intake-wizard/buitenland.step.ts @@ -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: ` +
+ + + +
+ @if (answers().buitenlandGewerkt === 'ja') { +
+ + + + + + +
+ } + `, +}) +export class BuitenlandStep { + answers = input.required(); + errors = input.required(); + answerChange = output<{ key: keyof Answers; value: string }>(); + + readonly jaNee = JA_NEE; + protected err = (k: keyof Answers) => this.errors()[k] ?? ''; +} 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 d6b87e9..e0146db 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 @@ -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: ` @switch (step()) { @case ('buitenland') { -
- - - -
- @if (answers().buitenlandGewerkt === 'ja') { -
- - - - - - -
- } + } @case ('werk') { -
- - - -
- @if (scholingZichtbaar()) { -
- - - -
- } - @if (answers().scholingGevolgd === 'ja') { -
- - - -
- } + } @case ('review') { - Controleer uw antwoorden en dien de aanvraag in. - -
- @if (answers().buitenlandGewerkt === 'ja') { -
-
- } -
- -
- @if (scholingZichtbaar()) { -
- } - @if (answers().scholingGevolgd === 'ja') { -
- } -
+ } } @@ -295,7 +132,6 @@ export class IntakeWizardComponent { /** Optional seed so Storybook / the showcase can mount any state directly. */ seed = input(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(() => 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=`) or start fresh. Persistence is the draftSync controller's job. diff --git a/apps/ssp/src/app/herregistratie/ui/intake-wizard/review.step.ts b/apps/ssp/src/app/herregistratie/ui/intake-wizard/review.step.ts new file mode 100644 index 0000000..7b32097 --- /dev/null +++ b/apps/ssp/src/app/herregistratie/ui/intake-wizard/review.step.ts @@ -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: ` + Controleer uw antwoorden en dien de aanvraag in. + +
+ @if (answers().buitenlandGewerkt === 'ja') { +
+
+ } +
+ +
+ @if (scholingZichtbaar()) { +
+ } + @if (answers().scholingGevolgd === 'ja') { +
+ } +
+ `, +}) +export class ReviewStep { + answers = input.required(); + scholingThreshold = input.required(); + edit = output(); + + protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold())); +} diff --git a/apps/ssp/src/app/herregistratie/ui/intake-wizard/werk.step.ts b/apps/ssp/src/app/herregistratie/ui/intake-wizard/werk.step.ts new file mode 100644 index 0000000..4eb586b --- /dev/null +++ b/apps/ssp/src/app/herregistratie/ui/intake-wizard/werk.step.ts @@ -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: ` +
+ + + +
+ @if (scholingZichtbaar()) { +
+ + + +
+ } + @if (answers().scholingGevolgd === 'ja') { +
+ + + +
+ } + `, +}) +export class WerkStep { + answers = input.required(); + errors = input.required(); + scholingThreshold = input.required(); + 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())); +} diff --git a/docs/project/readable-codebase/RD-22-intake-steps.md b/docs/project/readable-codebase/RD-22-intake-steps.md new file mode 100644 index 0000000..591cbdb --- /dev/null +++ b/docs/project/readable-codebase/RD-22-intake-steps.md @@ -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 `
` and the + `` 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()`. 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`. + +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(() => 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 ``.** The shell renders `` and `` 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. diff --git a/docs/project/readable-codebase/README.md b/docs/project/readable-codebase/README.md index 187786f..f056f86 100644 --- a/docs/project/readable-codebase/README.md +++ b/docs/project/readable-codebase/README.md @@ -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 |