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:
2026-06-30 08:38:37 +02:00
parent 9521739ac1
commit bfd957a6d4
13 changed files with 341 additions and 66 deletions

View File

@@ -11,7 +11,7 @@ import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
*/
export function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise<Result<string, string>> {
return runSubmit(async () => {
const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst });
const res = await client.registrations({ diplomaHerkomst: data.diplomaHerkomst, documents: data.documents });
return res.referentie ?? '';
}, SUBMIT_FAILED);
}

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import { initialUpload } from '@shared/upload/upload.machine';
import {
Draft,
RegistratieState,
@@ -25,6 +26,7 @@ const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({
draft: { antwoorden: {}, ...draft },
cursor,
errors: {},
upload: initialUpload,
});
const validAdres = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', correspondentie: 'post' as const, adresHerkomst: 'brp' as const };
@@ -204,3 +206,31 @@ describe('reduce (message-driven happy path)', () => {
expect((s as any).data.beroep).toBe('Arts');
});
});
describe('inline document upload (beroep step)', () => {
const cat = { categoryId: 'diploma', label: 'Diploma', description: '', required: true, acceptedTypes: [], maxSizeMb: 10, multiple: false, allowPostDelivery: true };
it('routes Upload messages through the upload reducer', () => {
const s = reduce(invullen(validDraft), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } });
expect((s as any).upload.categories).toHaveLength(1);
});
it('blocks the beroep step until a required category is satisfied', () => {
let s = reduce(invullen(validDraft, 1), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } });
s = reduce(s, { tag: 'Next' }); // beroep → controle blocked
expect(currentStep(s as any)).toBe('beroep');
expect((s as any).errors.documenten).toBeTruthy();
// choosing post delivery satisfies the requirement
s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' } });
s = reduce(s, { tag: 'Next' });
expect(currentStep(s as any)).toBe('controle');
});
it('includes delivery refs in the submitted data', () => {
let s = reduce(invullen(validDraft), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] } });
s = reduce(s, { tag: 'Upload', msg: { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' } });
const done = submit(s as any);
expect(done.tag).toBe('Indienen');
expect((done as any).data.documents).toEqual([{ categoryId: 'diploma', channel: 'post' }]);
});
});

View File

@@ -1,6 +1,15 @@
import { Result, ok, err, assertNever } from '@shared/kernel/fp';
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
import { Email, parseEmail } from '@registratie/domain/value-objects/email';
import {
UploadState,
UploadMsg,
DeliveryChannel,
initialUpload,
reduceUpload,
requiredCategoriesSatisfied,
deliveryRefs,
} from '@shared/upload/upload.machine';
/**
* A FIXED 3-step registration wizard. The steps never change in number (always
@@ -49,6 +58,7 @@ export interface ValidRegistratie {
diplomaHerkomst: DiplomaHerkomst;
beroep: string;
antwoorden: Record<string, string>;
documents: Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }>;
}
/** Text fields settable via SetField. */
@@ -63,17 +73,18 @@ export interface Errors {
email?: string;
correspondentie?: string;
diploma?: string;
documenten?: string;
antwoorden?: Record<string, string>;
}
export type RegistratieState =
| { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors }
| { tag: 'Invullen'; draft: Draft; cursor: number; errors: Errors; upload: UploadState }
| { tag: 'Indienen'; data: ValidRegistratie }
| { tag: 'Ingediend'; data: ValidRegistratie; referentie: string }
| { tag: 'Mislukt'; data: ValidRegistratie; error: string };
const emptyDraft: Draft = { antwoorden: {} };
export const initial: RegistratieState = { tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {} };
export const initial: RegistratieState = { tag: 'Invullen', draft: emptyDraft, cursor: 0, errors: {}, upload: initialUpload };
/** Which step the cursor currently points at (clamped to the fixed list). */
export function currentStep(s: Extract<RegistratieState, { tag: 'Invullen' }>): StepId {
@@ -81,7 +92,7 @@ export function currentStep(s: Extract<RegistratieState, { tag: 'Invullen' }>):
}
/** Validate every question currently visible in ONE step. Errors keyed per field. */
function validateStep(step: StepId, d: Draft): Result<Errors, void> {
function validateStep(step: StepId, d: Draft, upload: UploadState): Result<Errors, void> {
const errors: Errors = {};
switch (step) {
case 'adres': {
@@ -111,6 +122,10 @@ function validateStep(step: StepId, d: Draft): Result<Errors, void> {
if (!(d.antwoorden[id] ?? '').trim()) open[id] = $localize`:@@validation.beantwoordVraag:Beantwoord deze vraag.`;
}
if (Object.keys(open).length > 0) errors.antwoorden = open;
// Required documents for this wizard attach to the beroep step (inline upload).
if (!requiredCategoriesSatisfied(upload)) {
errors.documenten = $localize`:@@validation.documenten:Lever de verplichte documenten aan (upload of kies "per post nasturen").`;
}
break;
}
case 'controle':
@@ -122,10 +137,10 @@ function validateStep(step: StepId, d: Draft): Result<Errors, void> {
}
/** Parse the whole wizard into a ValidRegistratie (called on submit). */
function validateAll(d: Draft): Result<Errors, ValidRegistratie> {
function validateAll(d: Draft, upload: UploadState): Result<Errors, ValidRegistratie> {
const errors: Errors = {};
for (const step of STEPS) {
const r = validateStep(step, d);
const r = validateStep(step, d, upload);
if (!r.ok) Object.assign(errors, r.error);
}
if (Object.keys(errors).length > 0) return err(errors);
@@ -147,6 +162,7 @@ function validateAll(d: Draft): Result<Errors, ValidRegistratie> {
diplomaHerkomst: d.diplomaHerkomst ?? 'handmatig',
beroep: d.beroep,
antwoorden,
documents: deliveryRefs(upload),
});
}
@@ -197,7 +213,7 @@ export function setAntwoord(s: RegistratieState, vraagId: string, value: string)
export function next(s: RegistratieState): RegistratieState {
if (s.tag !== 'Invullen') return s;
const r = validateStep(currentStep(s), s.draft);
const r = validateStep(currentStep(s), s.draft, s.upload);
if (!r.ok) return { ...s, errors: r.error };
return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };
}
@@ -216,10 +232,16 @@ export function gaNaarStap(s: RegistratieState, cursor: number): RegistratieStat
export function submit(s: RegistratieState): RegistratieState {
if (s.tag !== 'Invullen') return s;
const r = validateAll(s.draft);
const r = validateAll(s.draft, s.upload);
return r.ok ? { tag: 'Indienen', data: r.value } : { ...s, errors: r.error };
}
/** Route an upload sub-message through the pure upload reducer (Invullen only). */
export function upload(s: RegistratieState, msg: UploadMsg): RegistratieState {
if (s.tag !== 'Invullen') return s;
return { ...s, upload: reduceUpload(s.upload, msg) };
}
export function resolve(s: RegistratieState, r: Result<string, string>): RegistratieState {
if (s.tag !== 'Indienen') return s;
return r.ok ? { tag: 'Ingediend', data: s.data, referentie: r.value } : { tag: 'Mislukt', data: s.data, error: r.error };
@@ -240,6 +262,7 @@ export type RegistratieMsg =
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed'; referentie: string }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Upload'; msg: UploadMsg }
| { tag: 'Seed'; state: RegistratieState };
export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState {
@@ -272,6 +295,8 @@ export function reduce(s: RegistratieState, m: RegistratieMsg): RegistratieState
return s.tag === 'Indienen' ? { tag: 'Ingediend', data: s.data, referentie: m.referentie } : s;
case 'SubmitFailed':
return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s;
case 'Upload':
return upload(s, m.msg);
case 'Seed':
return m.state;
default:

View File

@@ -29,8 +29,11 @@ import {
} from '@registratie/domain/registratie-wizard.machine';
import { submitRegistratie } from '@registratie/application/submit-registratie';
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';
const STORAGE_KEY = 'registratie-v1'; // ponytail: bump the suffix if the persisted shape changes; no migration.
const STORAGE_KEY = 'registratie-v2'; // ponytail: bump the suffix if the persisted shape changes; no migration.
const KANALEN = [
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
{ value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },
@@ -48,7 +51,7 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
imports: [
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,
AlertComponent, SkeletonComponent, DataRowComponent, WizardShellComponent,
AddressFieldsComponent, ...ASYNC,
AddressFieldsComponent, DocumentUploadComponent, ...ASYNC,
],
template: `
<app-wizard-shell
@@ -133,6 +136,18 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
<app-skeleton height="2.5rem" [count]="3" />
</ng-template>
</app-async>
<app-document-upload
class="app-section"
[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 (err('documenten')) {
<app-alert type="warning">{{ err('documenten') }}</app-alert>
}
}
@case ('controle') {
<app-alert type="info" i18n="@@regWizard.controleer">Controleer uw gegevens en dien de registratie in.</app-alert>
@@ -186,6 +201,12 @@ export class RegistratieWizardComponent {
private invullen = computed(() => whenTag(this.state(), 'Invullen'));
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
protected upload = computed<UploadState>(() => this.invullen()?.upload ?? initialUpload);
protected uploadCtl = createUploadController({
wizardId: 'registratie',
getUpload: () => this.upload(),
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
});
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]);
protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
@@ -252,7 +273,7 @@ export class RegistratieWizardComponent {
readonly jaNee = JA_NEE;
protected err = (k: DraftField | 'correspondentie' | 'diploma') => this.invullen()?.errors[k] ?? '';
protected err = (k: DraftField | 'correspondentie' | 'diploma' | 'documenten') => this.invullen()?.errors[k] ?? '';
protected vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? '';
protected set = (key: DraftField, value: string) => this.dispatch({ tag: 'SetField', key, value });
protected setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });

View File

@@ -3,12 +3,13 @@ import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { RegistratieWizardComponent } from './registratie-wizard.component';
import { Draft, RegistratieState, ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
import { initialUpload } from '@shared/upload/upload.machine';
import { Postcode } from '@registratie/domain/value-objects/postcode';
const adres: Partial<Draft> = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', adresHerkomst: 'brp', correspondentie: 'post' };
const filled: Partial<Draft> = { ...adres, diplomaId: 'd1', beroep: 'Arts', diplomaHerkomst: 'duo', vraagIds: [] };
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({ tag: 'Invullen', draft: { antwoorden: {}, ...draft }, cursor, errors: {} });
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({ tag: 'Invullen', draft: { antwoorden: {}, ...draft }, cursor, errors: {}, upload: initialUpload });
const validData: ValidRegistratie = {
adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' },
@@ -18,6 +19,7 @@ const validData: ValidRegistratie = {
diplomaHerkomst: 'duo',
beroep: 'Arts',
antwoorden: {},
documents: [],
};
const meta: Meta<RegistratieWizardComponent> = {