Files
atomic-design-poc/docs/project/readable-codebase/RD-23-registratie-steps.md
T
ehoandClaude Sonnet 5 11e3191099 refactor: split registratie-wizard into three steps (RD-23)
Move the adres, beroep and controle cases out of registratie-wizard.component.ts
into adres.step.ts, beroep.step.ts and controle.step.ts, matching RD-22's
*.step.ts convention. The parent drops from ~568 to 274 lines and loses its
`eslint-disable max-lines`.

The upload controller moves into beroep.step.ts and emits `uploadMsg` instead of
dispatching directly; the parent maps that back onto the machine's `Upload`
message. `onDiplomaKeuze` stays in the parent (message construction from the DUO
payload belongs in the container) and now takes only the chosen id, reading its
own `duoData` computed instead of receiving the DUO payload as an argument. Each
step injects `RegistratieLookupStore` directly for its own async presentation
(adresStatus, the DUO lookup, samenvattingVragen) — the sanctioned exception,
since it is a root singleton. Markup moved verbatim; the `@@` id count across
the directory stays 43.

Two of the ticket's acceptance numbers do not hold against correct code and are
corrected in the ticket file: `createUploadController` is 2 lines (import +
call), not 1 — `git grep -c` counts lines, and the same shape gives 2 for
`createStore` and 3 for `createDraftSync` elsewhere in this codebase. `dispatch`
is 1, not 0 — decision 4's mandated `UploadControllerDeps.dispatch` property
name is that string even though it is not the machine's dispatch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 23:17:25 +02:00

12 KiB

RD-23 — Split registratie-wizard into three steps, and move the upload controller

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

Why

registratie-wizard.component.ts measures ~568 effective lines against a limit of 250 — the largest file in the arc, and more than twice the budget. It carries /* eslint-disable max-lines */.

It is the same shape as RD-22's intake wizard: one @switch, three @case blocks, three screens in one file. It is harder in one way that PLAN calls out — moving the upload controller is what gets the parent under 250, and the controller is a stateful thing, not markup.

RD-22 already set the *.step.ts convention. Follow it.

Read first

  • apps/ssp/src/app/herregistratie/ui/intake-wizard/buitenland.step.ts and review.step.ts — the shape to copy. RD-22 built them one ticket ago; match their header comments, their input.required style and their output naming.
  • registratie-wizard.component.ts:100-348 — the three @case blocks.
  • registratie-wizard.component.ts:411-422 — createUploadController, and the dispatch callback that decision 4 rewires.
  • registratie-wizard.component.ts:568-585 — onDiplomaKeuze, which decision 5 reshapes.
  • apps/ssp/src/app/registratie/ui/address-fields/address-fields.component.ts:13-18 — the contract the whole family follows.

Decisions (pre-made, don't relitigate)

  1. Three new files beside the parent, named as RD-22 named its own:

    File Class Selector Case
    adres.step.ts AdresStep app-reg-adres-step lines 100-175
    beroep.step.ts BeroepStep app-reg-beroep-step lines 176-285
    controle.step.ts ControleStep app-reg-controle-step lines 286-348
  2. A step may inject RegistratieLookupStore directly. This is the sanctioned exception. It is providedIn: 'root', so every injection is the same instance, and PLAN names this "the one place the dashboard's axis does apply": the step owns its own async presentation rather than making the parent a pass-through for four lookup signals.

    • adres injects it for adresStatus (the BRP lookup banner).
    • beroep injects it for the DUO lookup, and owns its own <app-async> over it.
    • controle injects it to build samenvattingVragen.

    The parent keeps its own injection too — the BRP prefill effect needs it (decision 6).

  3. Inputs down, narrow outputs up, dispatch never passed down:

    Step Inputs Outputs
    adres draft, errors fieldChange: { key: DraftField; value: string }, kanaalChange: string
    beroep draft, errors, upload uploadMsg: UploadMsg, antwoordChange: { vraagId: string; value: string }, diplomaChosen: string, beroepDeclared: string
    controle draft edit: number

    Four outputs on beroep is correct: they are four distinct user intents, and each maps to one message in the parent. That is not the same thing as handing the step a dispatch.

  4. The upload controller moves into beroep.step.ts and emits instead of dispatching. createUploadController takes a dispatch callback, so the step builds its own:

    protected uploadCtl = createUploadController({
      wizardId: 'registratie',
      getUpload: () => this.upload(),
      dispatch: (msg) => this.uploadMsg.emit(msg),
      getCategoryParams: () => ({ … }),   // unchanged, reads this.draft()
    });
    

    The parent maps it back with (uploadMsg)="dispatch({ tag: 'Upload', msg: $event })". This is what collapses five template handlers into one output. previewUrlFor moves with the controller — it is uploadCtl.previewUrlFor and the child takes it as a function reference.

  5. onDiplomaKeuze stays in the parent, and loses its data parameter. The step emits only the chosen id (diplomaChosen). The parent keeps its duoData computed and reads it inside the method instead of receiving it as an argument:

    protected onDiplomaKeuze(id: string) {
      const data = this.duoData();
      if (!data) return;
      …                       // body otherwise unchanged
    }
    

    Building a KiesDiploma/KiesHandmatig message needs the DUO payload to map an id to a beroep and its question ids. That is machine-message construction, and it belongs in the container.

  6. The BRP prefill effect stays in the parent, exactly as written, including its untracked call. It writes to the machine, so it belongs where the machine lives. Do not move it into adres.step.ts.

  7. The parent keeps the shell wiring, the store and its effect map, draftSync, the seed constructor, phase, primaryLabel, stepTitle, stepLabels, errorList, referentie, cursor, step, draft, upload, duoData, onDiplomaKeuze, and the wizardSuccess block. It gains one computed, as RD-22's parent did:

    protected errors = computed<Errors>(() => this.invullen()?.errors ?? {});
    

    Everything else in the list below moves out with the markup that used it: uploadCtl, previewUrlFor, kanalen, err, vraagErr, antwoord, set, setKanaal, handmatigActief, diplomaKeuze, diplomaOptions, beroepOptions, actieveVragen, samenvattingVragen, adresSamenvatting, adresHerkomstLabel, correspondentieLabel, diplomaHerkomstLabel, adresStatus, lookupRd.

  8. Delete /* eslint-disable max-lines */ from the parent. Mandatory: reportUnusedDisableDirectives is error, so the two rules pin each other in both directions.

  9. No stories for the new steps (PLAN's corollary). registratie-wizard.stories.ts already mounts every step by seeding the machine, and the parent's public API does not move.

  10. Move the markup, do not improve it. Every i18n id, label, placeholder, fieldId and aria string stays byte-identical. Errors is already exported from the machine — RD-20 made it a type alias — so no machine change is needed this time.

Files

  • apps/ssp/src/app/registratie/ui/registratie-wizard/adres.step.ts (new)
  • apps/ssp/src/app/registratie/ui/registratie-wizard/beroep.step.ts (new)
  • apps/ssp/src/app/registratie/ui/registratie-wizard/controle.step.ts (new)
  • apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts

No machine file changes. No shared-library changes.

Steps

  1. Write adres.step.ts — the smallest, and the one that proves the injection pattern.
  2. Write controle.step.ts — read-only markup plus one output.
  3. Write beroep.step.ts last: it carries the async lookup, the policy questions and the upload controller.
  4. Replace the @switch with the three elements and wire the outputs per decision 3.
  5. Delete the members listed in decision 7, add the errors computed, reshape onDiplomaKeuze per decision 5, prune imports:.
  6. Delete the disable (decision 8).
  7. git add -A, then run the acceptance commands.
  8. Update this ticket's Status: to done and the README's RD-23 row to done.
  9. Commit all of it together.

Acceptance criteria

Measured against the tree before handover. Run after git add -A.

D=apps/ssp/src/app/registratie/ui/registratie-wizard
P=$D/registratie-wizard.component.ts
git ls-files "$D/*.step.ts" | wc -l                  # is 0 -> MUST be 3
git grep -c "eslint-disable max-lines" -- $P         # is 1 -> MUST be 0

The upload controller moved, and the store did not follow the markup down:

git grep -c "uploadCtl" -- $P                                    # is 7 -> MUST be 0
git grep -c "createUploadController" -- $D/beroep.step.ts        # MUST be 2
git grep -c "dispatch" -- "$D/*.step.ts" | awk -F: '{s+=$NF} END {print s+0}'   # MUST be 1

Two corrections found while running these before handover (recorded here per the README's rule 4 on ticket-writing misses):

  • createUploadController is 2, not 1: git grep -c counts matching lines, and an import plus its one call site are always two lines (same shape as createStore in intake-wizard.component.ts, which is 2, and createDraftSync in this same parent, which is 3). A count of 1 is unreachable without an import alias that would exist only to dodge the check.
  • dispatch is 1, not 0: decision 4's mandated snippet is dispatch: (msg) => this.uploadMsg.emit(msg), — the UploadControllerDeps.dispatch property name is not the machine's dispatch, but it is the same string. Satisfying decision 4 verbatim and satisfying a target of 0 are mutually exclusive.

The three per-step concerns left the parent:

git grep -c "adresStatus" -- $P            # is 3 -> MUST be 0
git grep -c "samenvattingVragen" -- $P     # is 2 -> MUST be 0
git grep -c "previewUrlFor" -- $P          # is 3 -> MUST be 0

The copy did not drift (decision 10):

git grep -ho "@@[a-zA-Z0-9_.]*" -- $D/ | sort -u | wc -l   # is 43 -> MUST still be 43
npm run ci --full   # exits 0

Verification

The @@ id count is 43 across the whole registratie-wizard/ directory, so the three new files are included. ng build --localize fails on an id that is added without a translation but never on one silently lost; the count is the only check that catches a loss.

--full is required. The existing story mounts all three steps, and the axe run over it is what proves the projected markup kept its labels, its error wiring and its aria strings.

Do not add a line-count command. npm run lint is the exact check (decision 8).

If dotnet test fails with SQLite Error 1: 'no such table: …', that is the stale-database trap, not your change. See the README's Troubleshooting section — RD-22 hit it.

Out of scope

  • herregistratie-wizard. PLAN: do not split it for symmetry.
  • Changing the upload controller itself, or upload.machine.ts.
  • Moving the BRP prefill effect (decision 6).
  • Adding stories (decision 9).
  • Any validation, message or i18n change.

Risks

  • The upload controller is the hard part, and the reason this ticket exists. Five template handlers become one uploadMsg output. Get the dispatch: (msg) => this.uploadMsg.emit(msg) wiring right and the rest is markup movement.
  • previewUrlFor is passed to a child as a function reference, not called in the template. Keep it an arrow property on the step, or the binding silently loses its this.
  • onDiplomaKeuze must not move into the step (decision 5). It builds machine messages from the DUO payload.
  • Four outputs on beroep is the design, not a smell (decision 3). Do not collapse them into a single message-shaped output — that is passing dispatch up under another name, and it moves message construction into the step.
  • Deleting the disable is mandatory (decision 8); its failure message reads like an unrelated error.
  • This is the largest single diff in the arc. Work step by step in the order given, and let the type-checker confirm each before moving on.