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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' } } };
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
66
src/app/organisms/herregistratie-wizard/wizard.machine.ts
Normal file
66
src/app/organisms/herregistratie-wizard/wizard.machine.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user