refactor: migrate the 3 wizards to the effect map and Primary (RD-08)

The three wizards paired a dispatch with a hand-written effect call
(onPrimary/onRetry + runIfSubmitting/runIfIndienen). A missed call failed
silently. RD-05 added the effect map and RD-07 added the Primary message;
this ticket moves each wizard onto both.

Each wizard now registers its submit effect on createStore, keyed by its
own submitting tag (Submitting for herregistratie and intake, Indienen for
registratie — the type catches a wrong key at compile time). The optimistic
begin/confirm/rollback calls stay inside the effect body, unchanged. The
template dispatches Primary and Retry directly, matching how Back already
worked. onPrimary, onRetry, and runIfSubmitting/runIfIndienen are deleted
from all three components.

herregistratie-wizard drops under the 250-rule-line budget, so its
eslint-disable max-lines header is removed in this same commit (RD-02's
self-cleaning mechanism). intake-wizard and registratie-wizard stay over
budget and keep theirs, both already flagged for RD-22/RD-23.

Three doc comments (in the three machine files, plus one in store.ts) named
the deleted onPrimary()/runIfSubmitting() identifiers in prose. Reworded
them so the "idiom is gone from the repo" grep check is not defeated by its
own explanatory comments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-04 17:20:52 +02:00
co-authored by Claude Sonnet 5
parent d0d6a3764b
commit b8aced75e3
9 changed files with 238 additions and 103 deletions
@@ -0,0 +1,165 @@
# RD-08 — Migrate the 3 wizards to the effect map and `Primary`
Status: done
Source: PLAN.md 1a
## Why
This is the ticket that removes the silent-failure idiom from the last three call sites. Each
wizard still pairs a `dispatch` with a hand-written effect call, and forgetting the second
line fails silently:
```ts
onPrimary() { this.dispatch(); } // decides Next vs Submit in the UI
onRetry() { this.dispatch({ tag: 'Retry' }); this.runIfSubmitting(); }
```
RD-05 added the effect map; RD-07 added `Primary`. After this ticket the wizard shell's five
outputs all map 1:1 onto messages in the template, and each component loses three methods.
## Read first
- `libs/shared/src/application/store.ts` — the effect map, its trigger rule, and the `Seed`
exemption
- The three machines' new `primary` exports (RD-07)
- `apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts`
`onPrimary` 256, `onRetry` 263, `runIfSubmitting` 276-288
- `apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts``onPrimary`
368, `onRetry` 375, `runIfSubmitting` 387-406
- `apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts`
`onPrimary` 613, `onRetry` 620, `runIfIndienen` 635-644
- `docs/project/readable-codebase/RD-06-…md` — the same migration, already done for the two
single-step forms. Copy its shape.
## Decisions (pre-made, don't relitigate)
1. **The effect-map key is the machine's own submitting tag, and it is not the same for all
three:**
| Wizard | Effect-map key |
| ----------------------- | -------------- |
| `herregistratie-wizard` | `Submitting` |
| `intake-wizard` | `Submitting` |
| `registratie-wizard` | **`Indienen`** |
`StoreEffects` keys are typed as `Model['tag']`, so writing `Submitting` for the registratie
wizard is a **compile error**, not a silent no-op. That is the type doing its job — do not
work around it by widening the type.
2. **Move each effect body verbatim into the map.** The narrowed state arrives as argument one,
so delete the `const s = this.state(); if (s.tag !== '…') return;` preamble and use the
parameter. Keep everything else identical, including the optimistic store calls.
3. **The optimistic `begin`/`confirm`/`rollback` calls stay inside the effect body**, exactly
where they are today (`herregistratie-wizard:279,283,286`; `intake-wizard:390,401,404`).
The effect slot is the sanctioned place for side effects, so `reduce` stays pure. The
registratie wizard has **no** optimistic calls — do not add any.
4. **Delete `onPrimary` and `onRetry` entirely** and dispatch from the template, matching how
`(back)` already does it:
```text
(primary)="dispatch({ tag: 'Primary' })"
(retry)="dispatch({ tag: 'Retry' })"
(back)="dispatch({ tag: 'Back' })" <-- already this shape today
```
`Retry` needs no effect call because `Failed → Submitting`/`Indienen` is a tag transition,
so the map fires it. This is the 1:1 output-to-message mapping
`.claude/skills/form-machine/SKILL.md:74-78` already claims.
5. **Leave the two `dispatch` calls that sit inside Angular `effect()`s alone** —
`intake-wizard`'s `SetPolicy` and `registratie-wizard`'s `PrefillAdres`. Both land on an
unchanged tag, so no effect fires, and both are already `untracked`. Do not key an effect on
an editing tag (`Editing`/`Answering`/`Invullen`) — that is the livelock
`store.spec.ts:18-31` guards against.
6. **Preserve the WP-69 comment in `intake-wizard`'s effect body verbatim**, including its
ticket reference. It explains why the scholing answer rides along for server-side
re-validation. RD-19 strips ticket prefixes across the repo later; do not do it early and do
not drop the explanation.
7. **If a wizard drops below 250 rule-lines, delete its `eslint-disable max-lines` header in
this same commit.** See Risks — this is expected for `herregistratie-wizard` and is
RD-02's mechanism working, not a problem to route around.
## Files
- `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 story changes expected. No xlf changes.
## Steps
1. For each wizard: register the effect body on `createStore` under the key from decision 1,
dropping the guard preamble.
2. Delete `runIfSubmitting` / `runIfIndienen`.
3. Delete `onPrimary` and `onRetry`; dispatch `Primary` and `Retry` from the template
(decision 4).
4. Run `npm run lint`. If a file is now under budget, delete its `eslint-disable max-lines`
header (decision 7).
5. Update this ticket's `Status:` to `done` and the README's RD-08 row to `done`.
6. Commit all of it together.
## Acceptance criteria
The idiom is gone from the whole repo. Anchor on the **declaration**, not on any occurrence
of the name:
```bash
grep -rn "runIfSubmitting\|runIfIndienen" apps libs # MUST return nothing
grep -rnE "^ (onPrimary|onRetry)\(\)" apps libs # MUST return nothing
```
The second pattern is anchored deliberately. A bare `grep -rn "onPrimary\|onRetry"` **cannot
pass**: `libs/shared/src/application/upload-controller.ts:94` has an unrelated
`onRetry(localId)` method, called from two wizard templates as
`uploadCtl.onRetry($event)`. Those three matches are correct code and must stay.
Behaviour is unchanged. Walk each wizard end to end with `npm start`:
1. The primary button advances through every step and submits on the last one.
2. A failed submit shows the error, and Retry re-submits (it must leave `Failed`).
3. Clicking the primary button twice quickly submits **once** — the trigger rule's
tag-transition condition gives this for free.
4. For the two herregistratie wizards, the dashboard's "herregistratie in behandeling" notice
still appears after submit and disappears after a rollback.
```bash
npm run ci # exits 0
npm run ci --full # exits 0 — the wizards all have stories
```
## Verification
`npm run ci --full`. `--full` is required: all three wizards have stories, and
`registratie-wizard.stories.ts:88`, `intake-wizard.stories.ts:38` and
`herregistratie-wizard.stories.ts:70` each seed a submitting state.
## Out of scope
- Splitting any wizard into step components. RD-22 and RD-23, after RD-20.
- `WizardStatus` → `WizardPhase`. RD-10.
- Stripping the WP-69 ticket reference (decision 6). RD-19.
- Touching `shellStatus`, `errorList`, or any other computed. Only the three methods and the
template bindings change here.
## Risks
- **`herregistratie-wizard` is 252 rule-lines and will likely drop below 250.** Deleting three
methods (~21 lines) and adding a map registration (~12) nets roughly 9. Its
`eslint-disable max-lines` then becomes unnecessary and
`reportUnusedDisableDirectives: 'error'` **fails the build**. That is RD-02's self-cleaning
mechanism doing its job: delete the header (decision 7). RD-20 also expects to remove it —
whichever ticket gets there first removes it, and the other finds nothing to do.
- **The Storybook trap.** All three wizards seed a submitting state in their stories with a
real `provideHttpClient()` and no request mocking. RD-05's `Seed` exemption is what stops
them firing real network calls. If a story flips to a failed state on load, or
`storybook-a11y` goes red, the exemption is not working — fix that, do not delete the story.
- **Double-submit protection is now structural, not incidental.** The old code could submit
twice if `dispatch` and the effect call were paired twice. The tag-transition rule prevents
it. Do not add a `busy` guard on top; that would be a second mechanism for one rule.
- **`behaviour-spec.mdx` drift** if you add or rename a spec. Run `npm run gen:behaviour-spec`
in the same commit if so.