Drive herregistratie as a state-machine wizard

Model the multi-step form as one tagged union: step/errors exist only while
Editing, and Submitting/Submitted/Failed carry a parsed Valid payload. So
"submitting while a field is invalid" and "success screen with errors set"
are unrepresentable by construction.

Pure transitions (next/back/submit/resolve) with a spec covering the key
invariants; illegal events are no-ops. The page becomes pure composition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 16:56:04 +02:00
parent ad50b3fa8f
commit 80c1b627d0
5 changed files with 226 additions and 39 deletions

View File

@@ -0,0 +1,98 @@
import { Component, computed, input, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '../../molecules/form-field/form-field.component';
import { TextInputComponent } from '../../atoms/text-input/text-input.component';
import { ButtonComponent } from '../../atoms/button/button.component';
import { AlertComponent } from '../../atoms/alert/alert.component';
import { SpinnerComponent } from '../../atoms/spinner/spinner.component';
import { ok } from '../../core/fp';
import { WizardState, Draft, initial, next, back, submit, resolve } from './wizard.machine';
/** Organism: multi-step herregistratie wizard driven entirely by a state
machine (wizard.machine.ts). The signal IS the union, so the UI just folds
over its tag — no booleans like `submitting`/`submitted` that could contradict
each other. Composition-only: reuses existing form-field/input/button/alert. */
@Component({
selector: 'app-herregistratie-wizard',
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, AlertComponent, SpinnerComponent],
template: `
@switch (state().tag) {
@case ('Editing') {
<p class="rhc-paragraph" style="color:var(--rhc-color-grijs-700)">Stap {{ step() }} van 2</p>
<form (ngSubmit)="onPrimary()" style="max-width:28rem">
@if (step() === 1) {
<app-form-field label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" [error]="errUren()">
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="set('uren', $event)"
name="uren" [invalid]="!!errUren()" placeholder="bijv. 4160" />
</app-form-field>
<app-button type="submit" variant="primary">Volgende</app-button>
} @else {
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="errPunten()">
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="set('punten', $event)"
name="punten" [invalid]="!!errPunten()" placeholder="bijv. 200" />
</app-form-field>
<div style="display:flex;gap:0.5rem">
<app-button type="button" variant="secondary" (click)="state.set(back(state()))">Vorige</app-button>
<app-button type="submit" variant="primary">Herregistratie aanvragen</app-button>
</div>
}
</form>
}
@case ('Submitting') {
<app-spinner /> <span>Aanvraag wordt verwerkt…</span>
}
@case ('Submitted') {
<app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
}
@case ('Failed') {
<app-alert type="error">Indienen mislukt. Probeer het opnieuw.</app-alert>
<div style="margin-top:1rem">
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
</div>
}
}
`,
})
export class HerregistratieWizardComponent {
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<WizardState>(initial);
state = signal<WizardState>(initial);
back = back;
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<WizardState, { tag: 'Editing' }>) : null));
protected step = computed(() => this.editing()?.step ?? 1);
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', punten: '' });
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
constructor() {
queueMicrotask(() => this.state.set(this.seed()));
}
set(key: keyof Draft, value: string) {
const s = this.state();
if (s.tag !== 'Editing') return;
this.state.set({ ...s, draft: { ...s.draft, [key]: value } });
}
onPrimary() {
const s = this.state();
if (s.tag !== 'Editing') return;
this.state.set(s.step === 1 ? next(s) : submit(s));
this.runIfSubmitting();
}
onRetry() {
const s = this.state();
if (s.tag !== 'Failed') return;
this.state.set({ tag: 'Submitting', data: s.data });
this.runIfSubmitting();
}
// ponytail: fake the backend with a timer; swap for a real httpResource call later.
private runIfSubmitting() {
if (this.state().tag !== 'Submitting') return;
setTimeout(() => this.state.set(resolve(this.state(), ok(undefined))), 800);
}
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
import { WizardState } from './wizard.machine';
import { Uren } from '../../core/parse';
const validData = { uren: 4160 as Uren, punten: 200 };
const meta: Meta<HerregistratieWizardComponent> = {
title: 'Organisms/Herregistratie Wizard',
component: HerregistratieWizardComponent,
};
export default meta;
type Story = StoryObj<HerregistratieWizardComponent>;
// Each story seeds one state of the machine — one render per union variant.
export const Step1: Story = { args: { seed: { tag: 'Editing', step: 1, draft: { uren: '', punten: '' }, errors: {} } } };
export const Step1Error: Story = {
args: { seed: { tag: 'Editing', step: 1, draft: { uren: 'abc', punten: '' }, errors: { uren: 'Vul een geheel aantal uren in (0 of meer).' } } satisfies WizardState },
};
export const Step2: Story = { args: { seed: { tag: 'Editing', step: 2, draft: { uren: '4160', punten: '' }, errors: {} } } };
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } };

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '../../core/fp';
import { initial, next, back, submit, resolve, WizardState } from './wizard.machine';
const editing1 = (uren: string, punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, punten }, errors: {} });
const editing2 = (uren: string, punten: string): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, punten }, errors: {} });
describe('wizard.machine', () => {
it('next advances only when step 1 parses', () => {
expect(next(initial).tag).toBe('Editing'); // empty uren -> stays, with error
expect((next(initial) as any).errors.uren).toBeTruthy();
expect((next(editing1('4160')) as any).step).toBe(2);
});
it('submit reaches Submitting ONLY with fully valid data', () => {
expect(submit(editing2('4160', 'x')).tag).toBe('Editing'); // invalid punten -> no Submitting
const good = submit(editing2('4160', '200'));
expect(good.tag).toBe('Submitting');
expect((good as any).data).toEqual({ uren: 4160, punten: 200 });
});
it('back / resolve are no-ops from illegal states', () => {
expect(back(initial)).toBe(initial); // step 1, nothing to go back to
expect(resolve(initial, ok(undefined))).toBe(initial); // not Submitting
});
it('resolve maps Submitting to Submitted / Failed', () => {
const submitting = submit(editing2('4160', '200'));
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
});
});

View File

@@ -0,0 +1,66 @@
import { Result } from '../../core/fp';
import { Uren, parseUren } from '../../core/parse';
/** What the user is typing (raw, possibly invalid). */
export interface Draft {
uren: string;
punten: string;
}
/** What we have AFTER parsing — branded/typed, guaranteed valid. */
export interface Valid {
uren: Uren;
punten: number;
}
/**
* The whole wizard as one tagged union. `step` and `errors` exist ONLY while
* Editing; Submitting/Submitted/Failed carry a `Valid` payload and nothing else.
* So "submitting while a field is invalid" or "showing the success screen with
* errors set" are unrepresentable — the bug class is gone by construction.
*/
export type WizardState =
| { tag: 'Editing'; step: 1 | 2; draft: Draft; errors: Partial<Record<keyof Draft, string>> }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid }
| { tag: 'Failed'; data: Valid; error: string };
export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', punten: '' }, errors: {} };
/** Parse every field; on success hand back a Valid, else the per-field errors. */
function validate(draft: Draft): Result<Partial<Record<keyof Draft, string>>, Valid> {
const uren = parseUren(draft.uren);
const punten = parseUren(draft.punten);
const errors: Partial<Record<keyof Draft, string>> = {};
if (!uren.ok) errors.uren = uren.error;
if (!punten.ok) errors.punten = punten.error;
if (uren.ok && punten.ok) return { ok: true, value: { uren: uren.value, punten: punten.value } };
return { ok: false, error: errors };
}
/** Step 1 → 2: only advance if the uren field parses. Illegal elsewhere = no-op. */
export function next(s: WizardState): WizardState {
if (s.tag !== 'Editing' || s.step !== 1) return s;
const uren = parseUren(s.draft.uren);
return uren.ok
? { ...s, step: 2, errors: {} }
: { ...s, errors: { uren: uren.error } };
}
export function back(s: WizardState): WizardState {
if (s.tag !== 'Editing' || s.step !== 2) return s;
return { ...s, step: 1, errors: {} };
}
/** Step 2 submit: parse everything; move to Submitting only with Valid data. */
export function submit(s: WizardState): WizardState {
if (s.tag !== 'Editing' || s.step !== 2) return s;
const result = validate(s.draft);
return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
}
/** Resolve the async submit. Only meaningful while Submitting. */
export function resolve(s: WizardState, r: Result<string, void>): WizardState {
if (s.tag !== 'Submitting') return s;
return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error };
}

View File

@@ -1,54 +1,22 @@
import { Component, signal } from '@angular/core'; import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { PageShellComponent } from '../../templates/page-shell/page-shell.component'; import { PageShellComponent } from '../../templates/page-shell/page-shell.component';
import { AlertComponent } from '../../atoms/alert/alert.component'; import { AlertComponent } from '../../atoms/alert/alert.component';
import { ButtonComponent } from '../../atoms/button/button.component'; import { HerregistratieWizardComponent } from '../../organisms/herregistratie-wizard/herregistratie-wizard.component';
import { FormFieldComponent } from '../../molecules/form-field/form-field.component';
import { TextInputComponent } from '../../atoms/text-input/text-input.component';
/** A whole new page built from existing building blocks — no new components. /** A whole new page built from existing building blocks — no new components.
This is the atomic-design payoff: a new flow is just composition. */ This is the atomic-design payoff: a new flow is just composition. */
@Component({ @Component({
selector: 'app-herregistratie-page', selector: 'app-herregistratie-page',
imports: [ imports: [PageShellComponent, AlertComponent, HerregistratieWizardComponent],
FormsModule, PageShellComponent, AlertComponent,
ButtonComponent, FormFieldComponent, TextInputComponent,
],
template: ` template: `
<app-page-shell heading="Herregistratie aanvragen" backLink="/dashboard"> <app-page-shell heading="Herregistratie aanvragen" backLink="/dashboard">
<app-alert type="info"> <app-alert type="info">
Uw huidige registratie verloopt op 1 september 2027. Vraag tijdig herregistratie aan. Uw huidige registratie verloopt op 1 september 2027. Vraag tijdig herregistratie aan.
</app-alert> </app-alert>
@if (submitted()) {
<div style="margin-top:1.5rem"> <div style="margin-top:1.5rem">
<app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert> <app-herregistratie-wizard />
</div> </div>
} @else {
<form (ngSubmit)="onSubmit()" style="max-width:28rem;margin-top:1.5rem">
<app-form-field label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" [error]="urenError()">
<app-text-input inputId="uren" [(ngModel)]="uren" name="uren" [invalid]="!!urenError()" placeholder="bijv. 4160" />
</app-form-field>
<app-form-field label="Behaalde nascholingspunten" fieldId="punten">
<app-text-input inputId="punten" [(ngModel)]="punten" name="punten" placeholder="bijv. 200" />
</app-form-field>
<div style="margin-top:1rem">
<app-button type="submit" variant="primary">Herregistratie aanvragen</app-button>
</div>
</form>
}
</app-page-shell> </app-page-shell>
`, `,
}) })
export class HerregistratiePage { export class HerregistratiePage {}
uren = '';
punten = '';
urenError = signal('');
submitted = signal(false);
onSubmit() {
if (!this.uren.trim()) { this.urenError.set('Vul het aantal gewerkte uren in.'); return; }
this.urenError.set('');
this.submitted.set(true);
}
}