Upload feature (e): wire inline upload (registratie beroep) + documenten step (herregistratie)

- Fold UploadState into both wizard machines; route via { tag: 'Upload', msg }
- Gate step validation on requiredCategoriesSatisfied; include deliveryRefs in submit
- Shared createUploadController (effectful glue: categories, transport, focus-poll, File map)
- rejectReason pure format validator + specs; bump registratie storage key to v2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-06-30 08:38:37 +02:00
co-authored by Claude Opus 4.8
parent 9521739ac1
commit bfd957a6d4
13 changed files with 341 additions and 66 deletions
@@ -10,6 +10,6 @@ import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
*/
export function submitHerregistratie(client: ApiClient, data: Valid): Promise<Result<string, void>> {
return runSubmit<void>(async () => {
await client.herregistraties({ uren: data.uren });
await client.herregistraties({ uren: data.uren, documents: data.documents });
}, SUBMIT_FAILED);
}
@@ -1,9 +1,11 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import { initialUpload } from '@shared/upload/upload.machine';
import { initial, next, back, submit, resolve, reduce, WizardState } from './herregistratie.machine';
const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, jaren, punten }, errors: {} });
const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, jaren, punten }, errors: {} });
const editing1 = (uren: string, jaren = '5', punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload });
const editing2 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload });
const editing3 = (uren: string, punten: string, jaren = '5'): WizardState => ({ tag: 'Editing', step: 3, draft: { uren, jaren, punten }, errors: {}, upload: initialUpload });
describe('wizard.machine', () => {
it('next advances only when step 1 parses', () => {
@@ -12,11 +14,18 @@ describe('wizard.machine', () => {
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'));
it('next advances step 2 → 3 only when punten parses', () => {
expect((next(editing2('4160', 'x')) as any).step).toBe(2); // invalid punten -> stays
expect((next(editing2('4160', 'x')) as any).errors.punten).toBeTruthy();
expect((next(editing2('4160', '200')) as any).step).toBe(3);
});
it('submit reaches Submitting ONLY from step 3 with fully valid data', () => {
expect(submit(editing2('4160', '200')).tag).toBe('Editing'); // not on step 3 -> no Submitting
expect(submit(editing3('4160', 'x')).tag).toBe('Editing'); // invalid punten
const good = submit(editing3('4160', '200'));
expect(good.tag).toBe('Submitting');
expect((good as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200 });
expect((good as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] });
});
it('next requires BOTH step-1 fields (uren and jaren)', () => {
@@ -25,13 +34,15 @@ describe('wizard.machine', () => {
expect((next(editing1('4160', '5')) as any).step).toBe(2); // both valid -> advance
});
it('back / resolve are no-ops from illegal states', () => {
it('back steps down one (3 → 2 → 1) and is a no-op from step 1', () => {
expect(back(initial)).toBe(initial); // step 1, nothing to go back to
expect((back(editing3('1', '2')) as any).step).toBe(2);
expect((back(editing2('1', '2')) as any).step).toBe(1);
expect(resolve(initial, ok(undefined))).toBe(initial); // not Submitting
});
it('resolve maps Submitting to Submitted / Failed', () => {
const submitting = submit(editing2('4160', '200'));
const submitting = submit(editing3('4160', '200'));
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
});
@@ -45,18 +56,32 @@ describe('reduce (message-driven)', () => {
s = reduce(s, { tag: 'Next' });
expect(s.tag === 'Editing' && s.step).toBe(2);
s = reduce(s, { tag: 'SetField', key: 'punten', value: '200' });
s = reduce(s, { tag: 'Next' });
expect(s.tag === 'Editing' && s.step).toBe(3);
s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
s = reduce(s, { tag: 'SubmitConfirmed' });
expect(s.tag).toBe('Submitted');
});
it('blocks submit until required documents are satisfied', () => {
const cat = { categoryId: 'bewijs', label: 'Bewijs', description: '', required: true, acceptedTypes: [], maxSizeMb: 10, multiple: false, allowPostDelivery: true };
let s = reduce(editing3('4160', '200'), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } });
s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Editing');
expect((s as any).errors.documenten).toBeTruthy();
s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'bewijs', channel: 'post' } });
s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
expect((s as any).data.documents).toEqual([{ categoryId: 'bewijs', channel: 'post' }]);
});
it('SubmitFailed then Retry returns to Submitting with the same data', () => {
let s = reduce(reduce(editing2('4160', '200'), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' });
let s = reduce(reduce(editing3('4160', '200'), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' });
expect(s.tag).toBe('Failed');
s = reduce(s, { tag: 'Retry' });
expect(s.tag).toBe('Submitting');
expect((s as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200 });
expect((s as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] });
});
it('Seed mounts an arbitrary state', () => {
@@ -1,5 +1,13 @@
import { Result, assertNever } from '@shared/kernel/fp';
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
import {
UploadState,
UploadMsg,
initialUpload,
reduceUpload,
requiredCategoriesSatisfied,
deliveryRefs,
} from '@shared/upload/upload.machine';
/** What the user is typing (raw, possibly invalid). */
export interface Draft {
@@ -8,11 +16,14 @@ export interface Draft {
punten: string;
}
export type StepErrors = Partial<Record<keyof Draft | 'documenten', string>>;
/** What we have AFTER parsing — branded/typed, guaranteed valid. */
export interface Valid {
uren: Uren;
jaren: number;
punten: number;
documents: Array<{ categoryId: string; channel: 'digital' | 'post'; documentId?: string }>;
}
/**
@@ -22,51 +33,68 @@ export interface Valid {
* 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: 'Editing'; step: 1 | 2 | 3; draft: Draft; errors: StepErrors; upload: UploadState }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid }
| { tag: 'Failed'; data: Valid; error: string };
export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {} };
export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {}, upload: initialUpload };
/** 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, upload: UploadState): Result<StepErrors, Valid> {
const uren = parseUren(draft.uren);
const jaren = parseUren(draft.jaren);
const punten = parseUren(draft.punten);
const errors: Partial<Record<keyof Draft, string>> = {};
const errors: StepErrors = {};
if (!uren.ok) errors.uren = uren.error;
if (!jaren.ok) errors.jaren = jaren.error;
if (!punten.ok) errors.punten = punten.error;
if (uren.ok && jaren.ok && punten.ok) {
return { ok: true, value: { uren: uren.value, jaren: jaren.value, punten: punten.value } };
if (!requiredCategoriesSatisfied(upload)) {
errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`;
}
if (uren.ok && jaren.ok && punten.ok && !errors.documenten) {
return { ok: true, value: { uren: uren.value, jaren: jaren.value, punten: punten.value, documents: deliveryRefs(upload) } };
}
return { ok: false, error: errors };
}
/** Step 1 → 2: advance only when BOTH step-1 fields parse. Illegal elsewhere = no-op. */
/** Advance one step, gating on that step's fields. 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);
const jaren = parseUren(s.draft.jaren);
const errors: Partial<Record<keyof Draft, string>> = {};
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 };
if (s.tag !== 'Editing') return s;
const errors: StepErrors = {};
if (s.step === 1) {
const uren = parseUren(s.draft.uren);
const jaren = parseUren(s.draft.jaren);
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 };
}
if (s.step === 2) {
const punten = parseUren(s.draft.punten);
if (!punten.ok) errors.punten = punten.error;
return punten.ok ? { ...s, step: 3, errors: {} } : { ...s, errors };
}
return s;
}
export function back(s: WizardState): WizardState {
if (s.tag !== 'Editing' || s.step !== 2) return s;
return { ...s, step: 1, errors: {} };
if (s.tag !== 'Editing' || s.step === 1) return s;
return { ...s, step: (s.step - 1) as 1 | 2, errors: {} };
}
/** Step 2 submit: parse everything; move to Submitting only with Valid data. */
/** Step 3 submit: parse everything + require documents; Submitting only with Valid. */
export function submit(s: WizardState): WizardState {
if (s.tag !== 'Editing' || s.step !== 2) return s;
const result = validate(s.draft);
if (s.tag !== 'Editing' || s.step !== 3) return s;
const result = validate(s.draft, s.upload);
return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
}
/** Route an upload sub-message through the pure upload reducer (Editing only). */
export function upload(s: WizardState, msg: UploadMsg): WizardState {
if (s.tag !== 'Editing') return s;
return { ...s, upload: reduceUpload(s.upload, msg) };
}
/** Resolve the async submit. Only meaningful while Submitting. */
export function resolve(s: WizardState, r: Result<string, void>): WizardState {
if (s.tag !== 'Submitting') return s;
@@ -92,6 +120,7 @@ export type WizardMsg =
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Upload'; msg: UploadMsg }
| { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)
export function reduce(s: WizardState, m: WizardMsg): WizardState {
@@ -110,6 +139,8 @@ export function reduce(s: WizardState, m: WizardMsg): WizardState {
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'Upload':
return upload(s, m.msg);
case 'Seed':
return m.state;
default:
@@ -10,6 +10,9 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
import { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine';
import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie';
import { ApiClient } from '@shared/infrastructure/api-client';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/upload/upload-controller';
import { UploadState, initialUpload } from '@shared/upload/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
@@ -19,7 +22,7 @@ import { ApiClient } from '@shared/infrastructure/api-client';
the dashboard shows "in behandeling" immediately. */
@Component({
selector: 'app-herregistratie-wizard',
imports: [FormsModule, FormFieldComponent, TextInputComponent, AlertComponent, WizardShellComponent],
imports: [FormsModule, FormFieldComponent, TextInputComponent, AlertComponent, WizardShellComponent, DocumentUploadComponent],
template: `
<app-wizard-shell
[steps]="stepLabels"
@@ -27,7 +30,7 @@ import { ApiClient } from '@shared/infrastructure/api-client';
[stepTitle]="stepTitle()"
[status]="shellStatus()"
[primaryLabel]="primaryLabel()"
[canGoBack]="step() === 2"
[canGoBack]="step() > 1"
[errors]="errorList()"
[errorMessage]="errorMessage()"
(primary)="onPrimary()"
@@ -35,20 +38,35 @@ import { ApiClient } from '@shared/infrastructure/api-client';
(cancel)="restart()"
(retry)="onRetry()">
@if (step() === 1) {
<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>
} @else {
<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>
@switch (step()) {
@case (1) {
<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>
}
@case (2) {
<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>
}
@case (3) {
<app-document-upload
[state]="upload()"
(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>
@@ -69,20 +87,27 @@ export class HerregistratieWizardComponent {
protected dispatch = this.store.dispatch;
// Stepper labels + per-step heading titles (presentational only).
readonly stepLabels = [$localize`:@@herregWizard.step.werkervaring:Werkervaring`, $localize`:@@herregWizard.step.nascholing:Nascholing`];
private stepTitles = [$localize`:@@herregWizard.title.werkervaring:Werkervaring (afgelopen 5 jaar)`, $localize`:@@herregWizard.title.nascholing:Nascholing`];
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`];
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 ?? '');
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
protected uploadCtl = createUploadController({
wizardId: 'herregistratie',
getUpload: () => this.upload(),
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
});
// --- Presentational wiring for the shared wizard shell ---------------------
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
protected primaryLabel = computed(() => (this.step() === 1 ? $localize`:@@wizard.volgende:Volgende` : $localize`:@@herregWizard.indienen:Herregistratie aanvragen`));
protected primaryLabel = computed(() => (this.step() < 3 ? $localize`:@@wizard.volgende:Volgende` : $localize`:@@herregWizard.indienen:Herregistratie aanvragen`));
protected errorMessage = computed(() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`);
protected shellStatus = computed<WizardStatus>(() => {
switch (this.state().tag) {
@@ -107,7 +132,7 @@ export class HerregistratieWizardComponent {
onPrimary() {
const s = this.state();
if (s.tag !== 'Editing') return;
this.dispatch(s.step === 1 ? { tag: 'Next' } : { tag: 'Submit' });
this.dispatch(s.step < 3 ? { tag: 'Next' } : { tag: 'Submit' });
this.runIfSubmitting();
}
@@ -3,9 +3,10 @@ import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
import { initialUpload } from '@shared/upload/upload.machine';
import { Uren } from '@registratie/domain/value-objects/uren';
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200 };
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] };
const meta: Meta<HerregistratieWizardComponent> = {
title: 'Herregistratie/Wizard',
@@ -18,11 +19,12 @@ 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: '', jaren: '', punten: '' }, errors: {} } } };
export const Step1: Story = { args: { seed: { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {}, upload: initialUpload } } };
export const Step1Error: Story = {
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 },
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).' }, upload: initialUpload } satisfies WizardState },
};
export const Step2: Story = { args: { seed: { tag: 'Editing', step: 2, draft: { uren: '4160', jaren: '5', punten: '' }, errors: {} } } };
export const Step2: Story = { args: { seed: { tag: 'Editing', step: 2, draft: { uren: '4160', jaren: '5', punten: '' }, errors: {}, upload: initialUpload } } };
export const Step3: Story = { args: { seed: { tag: 'Editing', step: 3, draft: { uren: '4160', jaren: '5', punten: '200' }, errors: {}, upload: initialUpload } } };
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' } } };