Add second question (jaren werkzaam) to herregistratie wizard step 1
Step 1 was a single field, making the wizard feel thin. Add "Aantal jaren werkzaam" beside "Gewerkte uren" on the same step (no new step): Draft/Valid gain `jaren`, `next` validates both step-1 fields before advancing, and `validate` parses it for the submitted payload. Verified live: an empty jaren blocks advancing with an inline error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { ok, err } from '@shared/kernel/fp';
|
import { ok, err } from '@shared/kernel/fp';
|
||||||
import { initial, next, back, submit, resolve, reduce, WizardState } from './herregistratie.machine';
|
import { initial, next, back, submit, resolve, reduce, WizardState } from './herregistratie.machine';
|
||||||
|
|
||||||
const editing1 = (uren: string, punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, punten }, errors: {} });
|
const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, jaren, punten }, errors: {} });
|
||||||
const editing2 = (uren: string, punten: string): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, punten }, errors: {} });
|
const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, jaren, punten }, errors: {} });
|
||||||
|
|
||||||
describe('wizard.machine', () => {
|
describe('wizard.machine', () => {
|
||||||
it('next advances only when step 1 parses', () => {
|
it('next advances only when step 1 parses', () => {
|
||||||
@@ -16,7 +16,13 @@ describe('wizard.machine', () => {
|
|||||||
expect(submit(editing2('4160', 'x')).tag).toBe('Editing'); // invalid punten -> no Submitting
|
expect(submit(editing2('4160', 'x')).tag).toBe('Editing'); // invalid punten -> no Submitting
|
||||||
const good = submit(editing2('4160', '200'));
|
const good = submit(editing2('4160', '200'));
|
||||||
expect(good.tag).toBe('Submitting');
|
expect(good.tag).toBe('Submitting');
|
||||||
expect((good as any).data).toEqual({ uren: 4160, punten: 200 });
|
expect((good as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('next requires BOTH step-1 fields (uren and jaren)', () => {
|
||||||
|
expect((next(editing1('4160', '')) as any).errors.jaren).toBeTruthy(); // jaren empty -> stays
|
||||||
|
expect((next(editing1('4160', '')) as any).step).toBe(1);
|
||||||
|
expect((next(editing1('4160', '5')) as any).step).toBe(2); // both valid -> advance
|
||||||
});
|
});
|
||||||
|
|
||||||
it('back / resolve are no-ops from illegal states', () => {
|
it('back / resolve are no-ops from illegal states', () => {
|
||||||
@@ -35,6 +41,7 @@ describe('reduce (message-driven)', () => {
|
|||||||
it('drives the full happy path via messages', () => {
|
it('drives the full happy path via messages', () => {
|
||||||
let s: WizardState = initial;
|
let s: WizardState = initial;
|
||||||
s = reduce(s, { tag: 'SetField', key: 'uren', value: '4160' });
|
s = reduce(s, { tag: 'SetField', key: 'uren', value: '4160' });
|
||||||
|
s = reduce(s, { tag: 'SetField', key: 'jaren', value: '5' });
|
||||||
s = reduce(s, { tag: 'Next' });
|
s = reduce(s, { tag: 'Next' });
|
||||||
expect(s.tag === 'Editing' && s.step).toBe(2);
|
expect(s.tag === 'Editing' && s.step).toBe(2);
|
||||||
s = reduce(s, { tag: 'SetField', key: 'punten', value: '200' });
|
s = reduce(s, { tag: 'SetField', key: 'punten', value: '200' });
|
||||||
@@ -49,7 +56,7 @@ describe('reduce (message-driven)', () => {
|
|||||||
expect(s.tag).toBe('Failed');
|
expect(s.tag).toBe('Failed');
|
||||||
s = reduce(s, { tag: 'Retry' });
|
s = reduce(s, { tag: 'Retry' });
|
||||||
expect(s.tag).toBe('Submitting');
|
expect(s.tag).toBe('Submitting');
|
||||||
expect((s as any).data).toEqual({ uren: 4160, punten: 200 });
|
expect((s as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Seed mounts an arbitrary state', () => {
|
it('Seed mounts an arbitrary state', () => {
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
|
|||||||
/** What the user is typing (raw, possibly invalid). */
|
/** What the user is typing (raw, possibly invalid). */
|
||||||
export interface Draft {
|
export interface Draft {
|
||||||
uren: string;
|
uren: string;
|
||||||
|
jaren: string;
|
||||||
punten: string;
|
punten: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What we have AFTER parsing — branded/typed, guaranteed valid. */
|
/** What we have AFTER parsing — branded/typed, guaranteed valid. */
|
||||||
export interface Valid {
|
export interface Valid {
|
||||||
uren: Uren;
|
uren: Uren;
|
||||||
|
jaren: number;
|
||||||
punten: number;
|
punten: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,26 +27,32 @@ export type WizardState =
|
|||||||
| { tag: 'Submitted'; data: Valid }
|
| { tag: 'Submitted'; data: Valid }
|
||||||
| { tag: 'Failed'; data: Valid; error: string };
|
| { tag: 'Failed'; data: Valid; error: string };
|
||||||
|
|
||||||
export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', punten: '' }, errors: {} };
|
export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {} };
|
||||||
|
|
||||||
/** Parse every field; on success hand back a Valid, else the per-field 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> {
|
function validate(draft: Draft): Result<Partial<Record<keyof Draft, string>>, Valid> {
|
||||||
const uren = parseUren(draft.uren);
|
const uren = parseUren(draft.uren);
|
||||||
|
const jaren = parseUren(draft.jaren);
|
||||||
const punten = parseUren(draft.punten);
|
const punten = parseUren(draft.punten);
|
||||||
const errors: Partial<Record<keyof Draft, string>> = {};
|
const errors: Partial<Record<keyof Draft, string>> = {};
|
||||||
if (!uren.ok) errors.uren = uren.error;
|
if (!uren.ok) errors.uren = uren.error;
|
||||||
|
if (!jaren.ok) errors.jaren = jaren.error;
|
||||||
if (!punten.ok) errors.punten = punten.error;
|
if (!punten.ok) errors.punten = punten.error;
|
||||||
if (uren.ok && punten.ok) return { ok: true, value: { uren: uren.value, punten: punten.value } };
|
if (uren.ok && jaren.ok && punten.ok) {
|
||||||
|
return { ok: true, value: { uren: uren.value, jaren: jaren.value, punten: punten.value } };
|
||||||
|
}
|
||||||
return { ok: false, error: errors };
|
return { ok: false, error: errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Step 1 → 2: only advance if the uren field parses. Illegal elsewhere = no-op. */
|
/** Step 1 → 2: advance only when BOTH step-1 fields parse. Illegal elsewhere = no-op. */
|
||||||
export function next(s: WizardState): WizardState {
|
export function next(s: WizardState): WizardState {
|
||||||
if (s.tag !== 'Editing' || s.step !== 1) return s;
|
if (s.tag !== 'Editing' || s.step !== 1) return s;
|
||||||
const uren = parseUren(s.draft.uren);
|
const uren = parseUren(s.draft.uren);
|
||||||
return uren.ok
|
const jaren = parseUren(s.draft.jaren);
|
||||||
? { ...s, step: 2, errors: {} }
|
const errors: Partial<Record<keyof Draft, string>> = {};
|
||||||
: { ...s, errors: { uren: uren.error } };
|
if (!uren.ok) errors.uren = uren.error;
|
||||||
|
if (!jaren.ok) errors.jaren = jaren.error;
|
||||||
|
return Object.keys(errors).length === 0 ? { ...s, step: 2, errors: {} } : { ...s, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function back(s: WizardState): WizardState {
|
export function back(s: WizardState): WizardState {
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ import { submitHerregistratie } from '@herregistratie/application/submit-herregi
|
|||||||
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
|
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
|
||||||
name="uren" [invalid]="!!errUren()" placeholder="bijv. 4160" />
|
name="uren" [invalid]="!!errUren()" placeholder="bijv. 4160" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
|
<app-form-field label="Aantal jaren werkzaam" fieldId="jaren" [error]="errJaren()">
|
||||||
|
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
|
||||||
|
name="jaren" [invalid]="!!errJaren()" placeholder="bijv. 5" />
|
||||||
|
</app-form-field>
|
||||||
<app-button type="submit" variant="primary">Volgende</app-button>
|
<app-button type="submit" variant="primary">Volgende</app-button>
|
||||||
} @else {
|
} @else {
|
||||||
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="errPunten()">
|
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="errPunten()">
|
||||||
@@ -64,13 +68,14 @@ export class HerregistratieWizardComponent {
|
|||||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||||
seed = input<WizardState>(initial);
|
seed = input<WizardState>(initial);
|
||||||
|
|
||||||
protected state = this.store.model;
|
readonly state = this.store.model; // public so the showcase can highlight the live state
|
||||||
protected dispatch = this.store.dispatch;
|
protected dispatch = this.store.dispatch;
|
||||||
|
|
||||||
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<WizardState, { tag: 'Editing' }>) : null));
|
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<WizardState, { tag: 'Editing' }>) : null));
|
||||||
protected step = computed(() => this.editing()?.step ?? 1);
|
protected step = computed(() => this.editing()?.step ?? 1);
|
||||||
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', punten: '' });
|
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });
|
||||||
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
|
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
|
||||||
|
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
|
||||||
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
||||||
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<WizardState, { tag: 'Failed' }>).error : ''));
|
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<WizardState, { tag: 'Failed' }>).error : ''));
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { HerregistratieWizardComponent } from './herregistratie-wizard.component
|
|||||||
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
|
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
|
||||||
import { Uren } from '@registratie/domain/value-objects/uren';
|
import { Uren } from '@registratie/domain/value-objects/uren';
|
||||||
|
|
||||||
const validData = { uren: 4160 as Uren, punten: 200 };
|
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200 };
|
||||||
|
|
||||||
const meta: Meta<HerregistratieWizardComponent> = {
|
const meta: Meta<HerregistratieWizardComponent> = {
|
||||||
title: 'Herregistratie/Wizard',
|
title: 'Herregistratie/Wizard',
|
||||||
@@ -18,11 +18,11 @@ export default meta;
|
|||||||
type Story = StoryObj<HerregistratieWizardComponent>;
|
type Story = StoryObj<HerregistratieWizardComponent>;
|
||||||
|
|
||||||
// Each story seeds one state of the machine — one render per union variant.
|
// 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 Step1: Story = { args: { seed: { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {} } } };
|
||||||
export const Step1Error: Story = {
|
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 },
|
args: { seed: { tag: 'Editing', step: 1, draft: { uren: 'abc', jaren: '', punten: '' }, errors: { uren: 'Vul een geheel aantal in (0 of meer).', jaren: 'Vul een geheel aantal in (0 of meer).' } } satisfies WizardState },
|
||||||
};
|
};
|
||||||
export const Step2: Story = { args: { seed: { tag: 'Editing', step: 2, draft: { uren: '4160', punten: '' }, errors: {} } } };
|
export const Step2: Story = { args: { seed: { tag: 'Editing', step: 2, draft: { uren: '4160', jaren: '5', punten: '' }, errors: {} } } };
|
||||||
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
|
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
|
||||||
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
|
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
|
||||||
export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } };
|
export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } };
|
||||||
|
|||||||
Reference in New Issue
Block a user