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:
@@ -122,7 +122,7 @@ export function submit(s: WizardState): WizardState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The primary button's action: advance, or submit from the last step. No-op
|
/** The primary button's action: advance, or submit from the last step. No-op
|
||||||
outside Editing — this is the one decision `onPrimary()` used to make. */
|
outside Editing — this is the one decision the old component-side handler used to make. */
|
||||||
export function primary(s: WizardState): WizardState {
|
export function primary(s: WizardState): WizardState {
|
||||||
if (s.tag !== 'Editing') return s;
|
if (s.tag !== 'Editing') return s;
|
||||||
return s.step === 3 ? submit(s) : next(s);
|
return s.step === 3 ? submit(s) : next(s);
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export function submit(s: IntakeState): IntakeState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The primary button's action: advance, or submit from the review step. No-op
|
/** The primary button's action: advance, or submit from the review step. No-op
|
||||||
outside Answering — this is the one decision `onPrimary()` used to make. */
|
outside Answering — this is the one decision the old component-side handler used to make. */
|
||||||
export function primary(s: IntakeState): IntakeState {
|
export function primary(s: IntakeState): IntakeState {
|
||||||
if (s.tag !== 'Answering') return s;
|
if (s.tag !== 'Answering') return s;
|
||||||
return currentStep(s) === 'review' ? submit(s) : next(s);
|
return currentStep(s) === 'review' ? submit(s) : next(s);
|
||||||
|
|||||||
+17
-32
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable max-lines */ // single-step wizard shell — removed by RD-20
|
|
||||||
import { Component, computed, inject, input } from '@angular/core';
|
import { Component, computed, inject, input } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||||
@@ -56,10 +55,10 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.
|
|||||||
[canGoBack]="step() > 1"
|
[canGoBack]="step() > 1"
|
||||||
[errors]="errorList()"
|
[errors]="errorList()"
|
||||||
[errorMessage]="errorMessage()"
|
[errorMessage]="errorMessage()"
|
||||||
(primary)="onPrimary()"
|
(primary)="dispatch({ tag: 'Primary' })"
|
||||||
(back)="dispatch({ tag: 'Back' })"
|
(back)="dispatch({ tag: 'Back' })"
|
||||||
(cancel)="restart()"
|
(cancel)="restart()"
|
||||||
(retry)="onRetry()"
|
(retry)="dispatch({ tag: 'Retry' })"
|
||||||
(goToStep)="goToStep($event)"
|
(goToStep)="goToStep($event)"
|
||||||
>
|
>
|
||||||
@switch (step()) {
|
@switch (step()) {
|
||||||
@@ -149,7 +148,21 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.
|
|||||||
})
|
})
|
||||||
export class HerregistratieWizardComponent {
|
export class HerregistratieWizardComponent {
|
||||||
private profile = inject(BigProfileStore);
|
private profile = inject(BigProfileStore);
|
||||||
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
// Effect fires once, on Editing -> Submitting (RD-05's tag-transition rule; `Seed` is
|
||||||
|
// exempt, so a story mounting straight into `Submitting` does not call the network).
|
||||||
|
private store = createStore<WizardState, WizardMsg>(initial, reduce, {
|
||||||
|
Submitting: async (s, store) => {
|
||||||
|
this.profile.beginHerregistratie();
|
||||||
|
const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents });
|
||||||
|
if (r.ok) {
|
||||||
|
store.dispatch({ tag: 'SubmitConfirmed' });
|
||||||
|
this.profile.confirmHerregistratie();
|
||||||
|
} else {
|
||||||
|
store.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||||
|
this.profile.rollbackHerregistratie();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/** Preview/download link for a completed upload; delegates to the upload
|
/** Preview/download link for a completed upload; delegates to the upload
|
||||||
controller (application layer), which knows the dev-simulation `demo-*` ids
|
controller (application layer), which knows the dev-simulation `demo-*` ids
|
||||||
@@ -253,37 +266,9 @@ export class HerregistratieWizardComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onPrimary() {
|
|
||||||
const s = this.state();
|
|
||||||
if (s.tag !== 'Editing') return;
|
|
||||||
this.dispatch(s.step < 3 ? { tag: 'Next' } : { tag: 'Submit' });
|
|
||||||
this.runIfSubmitting();
|
|
||||||
}
|
|
||||||
|
|
||||||
onRetry() {
|
|
||||||
this.dispatch({ tag: 'Retry' });
|
|
||||||
this.runIfSubmitting();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reset the wizard to a fresh, empty start. */
|
/** Reset the wizard to a fresh, empty start. */
|
||||||
restart() {
|
restart() {
|
||||||
this.draftSync.reset();
|
this.draftSync.reset();
|
||||||
this.dispatch({ tag: 'Seed', state: initial });
|
this.dispatch({ tag: 'Seed', state: initial });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The effect: when we entered Submitting, submit through the aanvraag lifecycle,
|
|
||||||
flip the optimistic cross-page flag, then dispatch the result (commit/rollback). */
|
|
||||||
private async runIfSubmitting() {
|
|
||||||
const s = this.state();
|
|
||||||
if (s.tag !== 'Submitting') return;
|
|
||||||
this.profile.beginHerregistratie();
|
|
||||||
const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents });
|
|
||||||
if (r.ok) {
|
|
||||||
this.dispatch({ tag: 'SubmitConfirmed' });
|
|
||||||
this.profile.confirmHerregistratie();
|
|
||||||
} else {
|
|
||||||
this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
|
||||||
this.profile.rollbackHerregistratie();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,10 +64,10 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
|
|||||||
[canGoBack]="cursor() > 0"
|
[canGoBack]="cursor() > 0"
|
||||||
[errors]="errorList()"
|
[errors]="errorList()"
|
||||||
[errorMessage]="errorMessage()"
|
[errorMessage]="errorMessage()"
|
||||||
(primary)="onPrimary()"
|
(primary)="dispatch({ tag: 'Primary' })"
|
||||||
(back)="dispatch({ tag: 'Back' })"
|
(back)="dispatch({ tag: 'Back' })"
|
||||||
(cancel)="restart()"
|
(cancel)="restart()"
|
||||||
(retry)="onRetry()"
|
(retry)="dispatch({ tag: 'Retry' })"
|
||||||
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
|
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
|
||||||
>
|
>
|
||||||
@switch (step()) {
|
@switch (step()) {
|
||||||
@@ -269,7 +269,28 @@ export class IntakeWizardComponent {
|
|||||||
// Server-owned policy (scholing threshold): fetched from the backend via the
|
// Server-owned policy (scholing threshold): fetched from the backend via the
|
||||||
// application facade, not hardcoded. The backend stays the authority on submit.
|
// application facade, not hardcoded. The backend stays the authority on submit.
|
||||||
private policyStore = inject(IntakePolicyStore);
|
private policyStore = inject(IntakePolicyStore);
|
||||||
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
|
// Effect fires once, on Answering -> Submitting (RD-05's tag-transition rule; `Seed` is
|
||||||
|
// exempt, so a story mounting straight into `Submitting` does not call the network).
|
||||||
|
private store = createStore<IntakeState, IntakeMsg>(initial, reduce, {
|
||||||
|
Submitting: async (s, store) => {
|
||||||
|
this.profile.beginHerregistratie();
|
||||||
|
// WP-69: the scholing answer rides along so the server can re-validate it as the
|
||||||
|
// authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by
|
||||||
|
// JSON.stringify, so a wizard above the threshold sends neither field.
|
||||||
|
const r = await this.draftSync.submit({
|
||||||
|
uren: s.data.uren,
|
||||||
|
aanvullendeScholing: s.data.aanvullendeScholing,
|
||||||
|
scholingPunten: s.data.punten,
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
store.dispatch({ tag: 'SubmitConfirmed' });
|
||||||
|
this.profile.confirmHerregistratie();
|
||||||
|
} else {
|
||||||
|
store.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||||
|
this.profile.rollbackHerregistratie();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||||
seed = input<IntakeState>(initial);
|
seed = input<IntakeState>(initial);
|
||||||
@@ -365,43 +386,8 @@ export class IntakeWizardComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onPrimary() {
|
|
||||||
const s = this.state();
|
|
||||||
if (s.tag !== 'Answering') return;
|
|
||||||
this.dispatch(this.step() === 'review' ? { tag: 'Submit' } : { tag: 'Next' });
|
|
||||||
this.runIfSubmitting();
|
|
||||||
}
|
|
||||||
|
|
||||||
onRetry() {
|
|
||||||
this.dispatch({ tag: 'Retry' });
|
|
||||||
this.runIfSubmitting();
|
|
||||||
}
|
|
||||||
|
|
||||||
restart() {
|
restart() {
|
||||||
this.draftSync.reset();
|
this.draftSync.reset();
|
||||||
this.dispatch({ tag: 'Seed', state: initial });
|
this.dispatch({ tag: 'Seed', state: initial });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The effect: when we enter Submitting, submit through the aanvraag lifecycle,
|
|
||||||
flip the optimistic cross-page flag, then dispatch the outcome (commit/rollback). */
|
|
||||||
private async runIfSubmitting() {
|
|
||||||
const s = this.state();
|
|
||||||
if (s.tag !== 'Submitting') return;
|
|
||||||
this.profile.beginHerregistratie();
|
|
||||||
// WP-69: the scholing answer rides along so the server can re-validate it as the
|
|
||||||
// authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by
|
|
||||||
// JSON.stringify, so a wizard above the threshold sends neither field.
|
|
||||||
const r = await this.draftSync.submit({
|
|
||||||
uren: s.data.uren,
|
|
||||||
aanvullendeScholing: s.data.aanvullendeScholing,
|
|
||||||
scholingPunten: s.data.punten,
|
|
||||||
});
|
|
||||||
if (r.ok) {
|
|
||||||
this.dispatch({ tag: 'SubmitConfirmed' });
|
|
||||||
this.profile.confirmHerregistratie();
|
|
||||||
} else {
|
|
||||||
this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
|
||||||
this.profile.rollbackHerregistratie();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ export function submit(s: RegistratieState): RegistratieState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The primary button's action: advance, or submit from the controle step.
|
/** The primary button's action: advance, or submit from the controle step.
|
||||||
No-op outside Invullen — this is the one decision `onPrimary()` used to make. */
|
No-op outside Invullen — this is the one decision the old component-side handler used to make. */
|
||||||
export function primary(s: RegistratieState): RegistratieState {
|
export function primary(s: RegistratieState): RegistratieState {
|
||||||
if (s.tag !== 'Invullen') return s;
|
if (s.tag !== 'Invullen') return s;
|
||||||
return currentStep(s) === 'controle' ? submit(s) : next(s);
|
return currentStep(s) === 'controle' ? submit(s) : next(s);
|
||||||
|
|||||||
+14
-28
@@ -90,10 +90,10 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
|
|||||||
[errorMessage]="errorMessage()"
|
[errorMessage]="errorMessage()"
|
||||||
i18n-submittingLabel="@@regWizard.submitting"
|
i18n-submittingLabel="@@regWizard.submitting"
|
||||||
submittingLabel="Uw registratie wordt verwerkt…"
|
submittingLabel="Uw registratie wordt verwerkt…"
|
||||||
(primary)="onPrimary()"
|
(primary)="dispatch({ tag: 'Primary' })"
|
||||||
(back)="dispatch({ tag: 'Back' })"
|
(back)="dispatch({ tag: 'Back' })"
|
||||||
(cancel)="restart()"
|
(cancel)="restart()"
|
||||||
(retry)="onRetry()"
|
(retry)="dispatch({ tag: 'Retry' })"
|
||||||
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
|
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
|
||||||
>
|
>
|
||||||
@switch (step()) {
|
@switch (step()) {
|
||||||
@@ -368,7 +368,18 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
|
|||||||
})
|
})
|
||||||
export class RegistratieWizardComponent {
|
export class RegistratieWizardComponent {
|
||||||
private lookup = inject(RegistratieLookupStore);
|
private lookup = inject(RegistratieLookupStore);
|
||||||
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
|
// Effect fires once, on Invullen -> Indienen (RD-05's tag-transition rule; `Seed` is
|
||||||
|
// exempt, so a story mounting straight into `Indienen` does not call the network).
|
||||||
|
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce, {
|
||||||
|
Indienen: async (s, store) => {
|
||||||
|
const r = await this.draftSync.submit({
|
||||||
|
diplomaHerkomst: s.data.diplomaHerkomst,
|
||||||
|
documents: s.data.documents,
|
||||||
|
});
|
||||||
|
if (r.ok) store.dispatch({ tag: 'SubmitConfirmed', referentie: r.value.referentie ?? '' });
|
||||||
|
else store.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/** Preview/download link for a completed upload; delegates to the upload
|
/** Preview/download link for a completed upload; delegates to the upload
|
||||||
controller (application layer), which knows the dev-simulation `demo-*` ids
|
controller (application layer), which knows the dev-simulation `demo-*` ids
|
||||||
@@ -610,18 +621,6 @@ export class RegistratieWizardComponent {
|
|||||||
// failed submit) now lives in the shared WizardShellComponent.
|
// failed submit) now lives in the shared WizardShellComponent.
|
||||||
}
|
}
|
||||||
|
|
||||||
onPrimary() {
|
|
||||||
const s = this.state();
|
|
||||||
if (s.tag !== 'Invullen') return;
|
|
||||||
this.dispatch(this.step() === 'controle' ? { tag: 'Submit' } : { tag: 'Next' });
|
|
||||||
this.runIfIndienen();
|
|
||||||
}
|
|
||||||
|
|
||||||
onRetry() {
|
|
||||||
this.dispatch({ tag: 'Retry' });
|
|
||||||
this.runIfIndienen();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reset the wizard to a fresh start. Reload the BRP lookup so the address
|
/** Reset the wizard to a fresh start. Reload the BRP lookup so the address
|
||||||
re-prefills, keeping the form and the"vooraf ingevuld" note consistent. */
|
re-prefills, keeping the form and the"vooraf ingevuld" note consistent. */
|
||||||
restart() {
|
restart() {
|
||||||
@@ -629,17 +628,4 @@ export class RegistratieWizardComponent {
|
|||||||
this.dispatch({ tag: 'Seed', state: initial });
|
this.dispatch({ tag: 'Seed', state: initial });
|
||||||
this.lookup.reloadAdres();
|
this.lookup.reloadAdres();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The effect: when we enter Indienen, submit through the aanvraag lifecycle
|
|
||||||
(duo → auto-approve, handmatig → manual), then dispatch the outcome. */
|
|
||||||
private async runIfIndienen() {
|
|
||||||
const s = this.state();
|
|
||||||
if (s.tag !== 'Indienen') return;
|
|
||||||
const r = await this.draftSync.submit({
|
|
||||||
diplomaHerkomst: s.data.diplomaHerkomst,
|
|
||||||
documents: s.data.documents,
|
|
||||||
});
|
|
||||||
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value.referentie ?? '' });
|
|
||||||
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -102,7 +102,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
|
|||||||
| RD-05 | `createStore` gains the effect map + specs | 02 | | done |
|
| RD-05 | `createStore` gains the effect map + specs | 02 | | done |
|
||||||
| RD-06 | **Bug fix:** 2 single-step forms to the effect map + retry affordance | 05 | yes | done |
|
| RD-06 | **Bug fix:** 2 single-step forms to the effect map + retry affordance | 05 | yes | done |
|
||||||
| RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | | done |
|
| RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | | done |
|
||||||
| RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | yes | todo |
|
| RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | yes | done |
|
||||||
| RD-09 | **Docs + generator:** `form-machine.hbs`, ARCHITECTURE, fp-tea, skill | 08 | | todo |
|
| RD-09 | **Docs + generator:** `form-machine.hbs`, ARCHITECTURE, fp-tea, skill | 08 | | todo |
|
||||||
| RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | todo |
|
| RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | todo |
|
||||||
| RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | todo |
|
| RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | todo |
|
||||||
@@ -178,3 +178,16 @@ Three rules when you write a ticket file, because the agent reads its ticket and
|
|||||||
Risks section is a trap that fires.
|
Risks section is a trap that fires.
|
||||||
3. **State acceptance as a command, not a sentence.** "Lands about 230 lines" is a design
|
3. **State acceptance as a command, not a sentence.** "Lands about 230 lines" is a design
|
||||||
estimate and nothing can check it. `npm run lint` has an exit code.
|
estimate and nothing can check it. `npm run lint` has an exit code.
|
||||||
|
4. **Run every acceptance command against the tree before you hand the ticket over.** A
|
||||||
|
command that cannot pass is worse than no command: the agent either wastes a cycle or,
|
||||||
|
worse, "fixes" correct code to satisfy it. Three real misses so far, all in tickets written
|
||||||
|
by the supervisor:
|
||||||
|
- RD-06 grepped only `runIfSubmitting`, missing that one wizard spells it `runIfIndienen`.
|
||||||
|
- RD-08 grepped bare `onPrimary\|onRetry`, which can never return nothing — an unrelated
|
||||||
|
`uploadCtl.onRetry` exists in `upload-controller.ts`.
|
||||||
|
- RD-08 said "no machine changes" while also requiring a repo-wide grep to come back
|
||||||
|
clean, which forced comment edits in three machines. The two instructions contradicted
|
||||||
|
each other.
|
||||||
|
|
||||||
|
Anchor greps on a declaration (`^ onRetry\(\)`) rather than a name, and make the Files
|
||||||
|
list agree with the Acceptance commands.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export interface Store<Model, Msg> {
|
|||||||
* An effect body must never throw: end it in a `Result` from
|
* An effect body must never throw: end it in a `Result` from
|
||||||
* `runSubmit`/`runResult` (`submit.ts`) and let the failure travel as a
|
* `runSubmit`/`runResult` (`submit.ts`) and let the failure travel as a
|
||||||
* dispatched message, not an exception. `dispatch` floats the effect's
|
* dispatched message, not an exception. `dispatch` floats the effect's
|
||||||
* promise, exactly as the `runIfSubmitting()` call it replaces did.
|
* promise, exactly as the hand-written effect method it replaces did.
|
||||||
*/
|
*/
|
||||||
export type StoreEffects<Model, Msg> = Model extends { tag: string }
|
export type StoreEffects<Model, Msg> = Model extends { tag: string }
|
||||||
? {
|
? {
|
||||||
|
|||||||
Reference in New Issue
Block a user