Files
atomic-design-poc/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts
T
ehoandClaude Opus 5 f3e5745145 fix: the wizards' seed input never arrived (RD-39)
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>
2026-09-07 11:53:03 +02:00

281 lines
11 KiB
TypeScript

import { Component, computed, inject, input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
import {
WizardShellComponent,
WizardError,
WizardPhase,
naarStapLabel,
} from '@shared/layout/wizard-shell/wizard-shell.component';
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import {
WizardState,
WizardMsg,
Draft,
initial,
reduce,
hasProgress,
} from '@herregistratie/domain/herregistratie.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
import { DocumentUploadComponent } from '@shared/ui/organisms/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/application/upload-controller';
import { UploadState, initialUpload, digitalDocumentIds } from '@shared/domain/upload.machine';
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
Elm-style store. The UI just sends messages and folds over the state's tag —
no booleans like `submitting`/`submitted` that could contradict each other.
Submitting also flips an optimistic flag on the shared BigProfileStore, so
the dashboard shows "in behandeling" immediately. */
@Component({
selector: 'app-herregistratie-wizard',
imports: [
FormsModule,
FormFieldComponent,
TextInputComponent,
AlertComponent,
ConfirmationComponent,
WizardShellComponent,
DocumentUploadComponent,
],
template: `
<app-wizard-shell
[steps]="stepLabels"
[current]="step() - 1"
[stepTitle]="stepTitle()"
i18n-processName="@@herregWizard.processName"
processName="Herregistratie aanvragen"
[phase]="phase()"
[primaryLabel]="primaryLabel()"
[canGoBack]="step() > 1"
[errors]="errorList()"
(primary)="dispatch({ tag: 'Primary' })"
(back)="dispatch({ tag: 'Back' })"
(cancel)="restart()"
(retry)="dispatch({ tag: 'Retry' })"
(goToStep)="goToStep($event)"
>
@switch (step()) {
@case (1) {
<fieldset>
<app-form-field
i18n-label="@@herregWizard.urenLabel"
label="Gewerkte uren (afgelopen 5 jaar)"
fieldId="uren"
required
[error]="errUren()"
>
<app-text-input
inputId="uren"
[ngModel]="draft().uren"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
name="uren"
[invalid]="!!errUren()"
i18n-placeholder="@@herregWizard.urenPlaceholder"
placeholder="bijv. 4160"
/>
</app-form-field>
<app-form-field
i18n-label="@@herregWizard.jarenLabel"
label="Aantal jaren werkzaam"
fieldId="jaren"
required
[error]="errJaren()"
>
<app-text-input
inputId="jaren"
[ngModel]="draft().jaren"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
name="jaren"
[invalid]="!!errJaren()"
i18n-placeholder="@@herregWizard.jarenPlaceholder"
placeholder="bijv. 5"
/>
</app-form-field>
</fieldset>
}
@case (2) {
<fieldset>
<app-form-field
i18n-label="@@herregWizard.puntenLabel"
label="Behaalde nascholingspunten"
fieldId="punten"
required
[error]="errPunten()"
>
<app-text-input
inputId="punten"
[ngModel]="draft().punten"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
name="punten"
[invalid]="!!errPunten()"
i18n-placeholder="@@herregWizard.puntenPlaceholder"
placeholder="bijv. 200"
/>
</app-form-field>
</fieldset>
}
@case (3) {
<app-document-upload
[state]="upload()"
[previewUrlFor]="previewUrlFor"
(fileSelected)="uploadCtl.onFileSelected($event.categoryId, $event.files)"
(removeUpload)="uploadCtl.onRemove($event)"
(retryUpload)="uploadCtl.onRetry($event)"
(deleteUpload)="uploadCtl.onDelete($event)"
(channelChange)="uploadCtl.onChannelChange($event.categoryId, $event.channel)"
/>
@if (errDocumenten()) {
<app-alert type="warning">{{ errDocumenten() }}</app-alert>
}
}
}
<div wizardSuccess>
<app-confirmation
i18n-title="@@herregWizard.success.title"
title="Uw aanvraag tot herregistratie is ontvangen"
/>
</div>
</app-wizard-shell>
`,
})
export class HerregistratieWizardComponent {
private profile = inject(BigProfileStore);
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<WizardState>(initial);
// --- The store: all state in one signal, changed only by a pure reduce -----
// The effect fires once, on the `Editing -> 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<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();
}
},
});
readonly state = this.store.model; // public so the showcase can highlight the live state
protected dispatch = this.store.dispatch;
// --- Static copy: stepper labels and per-step headings ---------------------
readonly stepLabels = [
$localize`:@@herregWizard.step.werkervaring:Werkervaring`,
$localize`:@@herregWizard.step.nascholing:Nascholing`,
$localize`:@@herregWizard.step.documenten:Documenten`,
];
private stepTitles = [
$localize`:@@herregWizard.title.werkervaring:Werkervaring (afgelopen 5 jaar)`,
$localize`:@@herregWizard.title.nascholing:Nascholing`,
$localize`:@@herregWizard.title.documenten:Documenten aanleveren`,
];
// --- State projections: one narrow, then read-only views of it -------------
private editing = computed(() => whenTag(this.state(), 'Editing'));
protected step = computed(() => this.editing()?.step ?? 1);
protected draft = computed<Draft>(
() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' },
);
protected upload = computed<UploadState>(() => this.editing()?.upload ?? initialUpload);
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? '');
// --- Controllers: persistence and uploads ----------------------------------
// Create a Concept on first progress, then debounced-sync the snapshot.
// `?aanvraag=<id>` resumes it.
private draftSync = createDraftSync({
type: 'herregistratie',
snapshot: () => {
const s = this.state();
if (s.tag !== 'Editing' || !hasProgress(s)) return null;
return {
draft: s,
stepIndex: s.step - 1,
stepCount: this.stepLabels.length,
documentIds: digitalDocumentIds(s.upload),
};
},
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }),
enabled: () => this.seed() === initial,
});
protected uploadCtl = createUploadController({
wizardId: 'herregistratie',
getUpload: () => this.upload(),
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
});
/** Preview/download link for a completed upload; delegates to the upload
controller (application layer), which knows the dev-simulation `demo-*` ids
have no stored bytes and returns no link for them. */
protected previewUrlFor = (documentId: string): string | undefined =>
this.uploadCtl.previewUrlFor(documentId);
// --- Presentational wiring for the shared wizard shell ---------------------
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
protected primaryLabel = computed(() => {
const step = this.step();
return step < 3
? naarStapLabel(step + 1, this.stepLabels[step])
: $localize`:@@herregWizard.indienen:Herregistratie aanvragen`;
});
/** 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 'Editing':
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. */
protected errorList = computed<WizardError[]>(() => toWizardErrors(this.editing()?.errors ?? {}));
// --- Event handlers: narrow a child event into a message -------------------
/** Stepper emits a 0-based index for an earlier (visited) step. */
protected goToStep(index: number) {
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 });
}
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();
});
}
/** Reset the wizard to a fresh, empty start. */
restart() {
this.draftSync.reset();
this.dispatch({ tag: 'Seed', state: initial });
}
}