All three wizard containers read `this.seed()` in the constructor. Angular
binds component inputs after the constructor runs, so the value was always the
`initial` default, `seeded !== initial` was always false, and every mount took
the `draftSync.resume()` branch. The `seed` input was dead code.
The two single-step forms built on the same idiom read the input inside the
microtask and work correctly. That contrast is the diagnosis.
Impact: 21 seeded wizard stories rendered step 1 instead of the state they
asked for. Storybook is this repo's UI test surface, so the states with no
other coverage were exactly the ones not rendering — Submitting, Submitted,
Failed, Ingediend, Mislukt. The a11y runner checks that whatever rendered is
accessible, never that the right thing rendered, so nothing caught it.
Production was unaffected: no route binds `seed`.
Read the input inside the microtask, matching the two forms. Turn the spec's
old `componentInstance.dispatch(...)` workaround into a real regression test
through `componentRef.setInput('seed', ...)`.
Verified: with the intake fix reverted the two spec cases fail; with it, 319
pass. `npm run ci --full` is green, and the newly rendered markup produced no
axe violations. A browser check of seven seeded stories across all three
wizards asserts text only reachable from a seed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
236 lines
9.4 KiB
TypeScript
236 lines
9.4 KiB
TypeScript
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
|
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
|
import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component';
|
|
import {
|
|
WizardShellComponent,
|
|
WizardError,
|
|
WizardPhase,
|
|
naarStapLabel,
|
|
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
|
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
|
|
import { createStore } from '@shared/application/store';
|
|
import { whenTag } from '@shared/kernel/fp';
|
|
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
|
import {
|
|
IntakeState,
|
|
IntakeMsg,
|
|
Answers,
|
|
Errors,
|
|
StepId,
|
|
initial,
|
|
reduce,
|
|
STEPS,
|
|
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
|
|
from the answers via `visibleSteps`, never stored — so editing an earlier
|
|
answer immediately changes the remaining steps. The draft persists to the
|
|
backend as a Concept aanvraag (createDraftSync), so a reload — or a "Verder
|
|
gaan" from the dashboard via `?aanvraag=<id>` — resumes progress. */
|
|
@Component({
|
|
selector: 'app-intake-wizard',
|
|
imports: [
|
|
ButtonComponent,
|
|
ConfirmationComponent,
|
|
WizardShellComponent,
|
|
BuitenlandStep,
|
|
WerkStep,
|
|
ReviewStep,
|
|
],
|
|
template: `
|
|
<app-wizard-shell
|
|
[steps]="stepLabels"
|
|
[current]="cursor()"
|
|
[stepTitle]="stepTitle()"
|
|
i18n-processName="@@intake.processName"
|
|
processName="Herregistratie-intake"
|
|
[phase]="phase()"
|
|
[primaryLabel]="primaryLabel()"
|
|
[canGoBack]="cursor() > 0"
|
|
[errors]="errorList()"
|
|
(primary)="dispatch({ tag: 'Primary' })"
|
|
(back)="dispatch({ tag: 'Back' })"
|
|
(cancel)="restart()"
|
|
(retry)="dispatch({ tag: 'Retry' })"
|
|
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
|
|
>
|
|
@switch (step()) {
|
|
@case ('buitenland') {
|
|
<app-intake-buitenland-step
|
|
[answers]="answers()"
|
|
[errors]="errors()"
|
|
(answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
|
|
/>
|
|
}
|
|
@case ('werk') {
|
|
<app-intake-werk-step
|
|
[answers]="answers()"
|
|
[errors]="errors()"
|
|
[scholingThreshold]="scholingThreshold()"
|
|
(answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
|
|
/>
|
|
}
|
|
@case ('review') {
|
|
<app-intake-review-step
|
|
[answers]="answers()"
|
|
[scholingThreshold]="scholingThreshold()"
|
|
(edit)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
|
|
/>
|
|
}
|
|
}
|
|
|
|
<div wizardSuccess>
|
|
<app-confirmation
|
|
i18n-title="@@intake.success.title"
|
|
title="Uw aanvraag tot herregistratie is ontvangen"
|
|
>
|
|
<div class="app-section">
|
|
<app-button variant="secondary" (click)="restart()" i18n="@@intake.opnieuw"
|
|
>Opnieuw beginnen</app-button
|
|
>
|
|
</div>
|
|
</app-confirmation>
|
|
</div>
|
|
</app-wizard-shell>
|
|
`,
|
|
})
|
|
export class IntakeWizardComponent {
|
|
private profile = inject(BigProfileStore);
|
|
// Server-owned policy (scholing threshold): fetched from the backend via the
|
|
// application facade, not hardcoded. The backend stays the authority on submit.
|
|
private policyStore = inject(IntakePolicyStore);
|
|
|
|
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
|
seed = input<IntakeState>(initial);
|
|
|
|
// --- The store: all state in one signal, changed only by a pure reduce -----
|
|
// The effect fires once, on the `Answering -> Submitting` transition. `Seed` is exempt,
|
|
// so a story that mounts straight into `Submitting` does not call the network.
|
|
// `draftSync` is declared below (both callbacks are deferred, so the cycle is safe).
|
|
private store = createStore<IntakeState, IntakeMsg>(initial, reduce, {
|
|
Submitting: async (s, store) => {
|
|
this.profile.beginHerregistratie();
|
|
// 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();
|
|
}
|
|
},
|
|
});
|
|
|
|
readonly state = this.store.model;
|
|
readonly dispatch = this.store.dispatch;
|
|
|
|
// --- Static copy: stepper labels and per-step headings ---------------------
|
|
readonly stepLabels = [
|
|
$localize`:@@intake.step.buitenland:Buitenland`,
|
|
$localize`:@@intake.step.werk:Werk`,
|
|
$localize`:@@intake.step.controle:Controle`,
|
|
];
|
|
private stepTitles: Record<StepId, string> = {
|
|
buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,
|
|
werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,
|
|
review: $localize`:@@intake.title.review:Controleren en indienen`,
|
|
};
|
|
|
|
// --- State projections: one narrow, then read-only views of it -------------
|
|
private answering = computed(() => whenTag(this.state(), 'Answering'));
|
|
/** Public so the showcase can render the (fixed) step list next to the wizard. */
|
|
readonly steps = STEPS;
|
|
protected cursor = computed(() => this.answering()?.cursor ?? 0);
|
|
protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
|
|
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
|
|
/** Server-owned threshold from the policy endpoint (mirrored into machine state). */
|
|
protected scholingThreshold = computed(
|
|
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
|
|
);
|
|
protected errors = computed<Errors>(() => this.answering()?.errors ?? {});
|
|
|
|
// --- Controllers: persistence and uploads ----------------------------------
|
|
// Create a Concept on first progress, then debounced-sync the snapshot.
|
|
// `?aanvraag=<id>` resumes it. The intake has no uploads.
|
|
private draftSync = createDraftSync({
|
|
type: 'intake',
|
|
snapshot: () => {
|
|
const s = this.state();
|
|
if (s.tag !== 'Answering' || !hasProgress(s)) return null;
|
|
return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds: [] };
|
|
},
|
|
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as IntakeState }),
|
|
enabled: () => this.seed() === initial,
|
|
});
|
|
|
|
// --- Presentational wiring for the shared wizard shell ---------------------
|
|
protected stepTitle = computed(() => this.stepTitles[this.step()]);
|
|
protected primaryLabel = computed(() => {
|
|
if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`;
|
|
const next = this.cursor() + 1;
|
|
return naarStapLabel(next + 1, this.stepLabels[next]);
|
|
});
|
|
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
|
|
composing the localized failure prefix so the `Failed` message arrives intact. */
|
|
protected phase = computed<WizardPhase>(() => {
|
|
const s = this.state();
|
|
switch (s.tag) {
|
|
case 'Answering':
|
|
return { tag: 'Editing' };
|
|
case 'Submitting':
|
|
return { tag: 'Submitting' };
|
|
case 'Submitted':
|
|
return { tag: 'Submitted' };
|
|
case 'Failed':
|
|
return {
|
|
tag: 'Failed',
|
|
message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`,
|
|
};
|
|
}
|
|
});
|
|
/** Current step's field errors, flattened for the shell's error summary. The
|
|
field ids match the answer keys, so the summary anchors jump to the field. */
|
|
protected errorList = computed<WizardError[]>(() =>
|
|
toWizardErrors(this.answering()?.errors ?? {}),
|
|
);
|
|
|
|
constructor() {
|
|
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
|
|
// (`?aanvraag=<id>`) or start fresh. Persistence is the draftSync controller's job.
|
|
// Read `seed()` INSIDE the microtask: Angular binds inputs after the constructor
|
|
// runs, so an eager read here always returns the `initial` default.
|
|
queueMicrotask(() => {
|
|
const seeded = this.seed();
|
|
if (seeded !== initial) this.dispatch({ tag: 'Seed', state: seeded });
|
|
else void this.draftSync.resume();
|
|
});
|
|
// Apply the server-owned threshold into machine state as it arrives. Track
|
|
// only the policy value; untrack the dispatch (it reads the state signal
|
|
// internally, which would otherwise make this effect loop on its own write).
|
|
effect(() => {
|
|
const scholingThreshold = this.policyStore.scholingThreshold();
|
|
untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));
|
|
});
|
|
}
|
|
|
|
restart() {
|
|
this.draftSync.reset();
|
|
this.dispatch({ tag: 'Seed', state: initial });
|
|
}
|
|
}
|