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:
@@ -10,6 +10,6 @@ import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
|||||||
*/
|
*/
|
||||||
export function submitHerregistratie(client: ApiClient, data: Valid): Promise<Result<string, void>> {
|
export function submitHerregistratie(client: ApiClient, data: Valid): Promise<Result<string, void>> {
|
||||||
return runSubmit<void>(async () => {
|
return runSubmit<void>(async () => {
|
||||||
await client.herregistraties({ uren: data.uren });
|
await client.herregistraties({ uren: data.uren, documents: data.documents });
|
||||||
}, SUBMIT_FAILED);
|
}, SUBMIT_FAILED);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { ok, err } from '@shared/kernel/fp';
|
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';
|
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 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: {} });
|
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', () => {
|
describe('wizard.machine', () => {
|
||||||
it('next advances only when step 1 parses', () => {
|
it('next advances only when step 1 parses', () => {
|
||||||
@@ -12,11 +14,18 @@ describe('wizard.machine', () => {
|
|||||||
expect((next(editing1('4160')) as any).step).toBe(2);
|
expect((next(editing1('4160')) as any).step).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('submit reaches Submitting ONLY with fully valid data', () => {
|
it('next advances step 2 → 3 only when punten parses', () => {
|
||||||
expect(submit(editing2('4160', 'x')).tag).toBe('Editing'); // invalid punten -> no Submitting
|
expect((next(editing2('4160', 'x')) as any).step).toBe(2); // invalid punten -> stays
|
||||||
const good = submit(editing2('4160', '200'));
|
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.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)', () => {
|
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
|
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(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
|
expect(resolve(initial, ok(undefined))).toBe(initial); // not Submitting
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolve maps Submitting to Submitted / Failed', () => {
|
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, ok(undefined)).tag).toBe('Submitted');
|
||||||
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
|
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
|
||||||
});
|
});
|
||||||
@@ -45,18 +56,32 @@ describe('reduce (message-driven)', () => {
|
|||||||
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' });
|
||||||
|
s = reduce(s, { tag: 'Next' });
|
||||||
|
expect(s.tag === 'Editing' && s.step).toBe(3);
|
||||||
s = reduce(s, { tag: 'Submit' });
|
s = reduce(s, { tag: 'Submit' });
|
||||||
expect(s.tag).toBe('Submitting');
|
expect(s.tag).toBe('Submitting');
|
||||||
s = reduce(s, { tag: 'SubmitConfirmed' });
|
s = reduce(s, { tag: 'SubmitConfirmed' });
|
||||||
expect(s.tag).toBe('Submitted');
|
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', () => {
|
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');
|
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, jaren: 5, punten: 200 });
|
expect((s as any).data).toEqual({ uren: 4160, jaren: 5, punten: 200, documents: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Seed mounts an arbitrary state', () => {
|
it('Seed mounts an arbitrary state', () => {
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { Result, assertNever } from '@shared/kernel/fp';
|
import { Result, assertNever } from '@shared/kernel/fp';
|
||||||
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
|
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). */
|
/** What the user is typing (raw, possibly invalid). */
|
||||||
export interface Draft {
|
export interface Draft {
|
||||||
@@ -8,11 +16,14 @@ export interface Draft {
|
|||||||
punten: string;
|
punten: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StepErrors = Partial<Record<keyof Draft | 'documenten', 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;
|
jaren: number;
|
||||||
punten: 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.
|
* errors set" are unrepresentable — the bug class is gone by construction.
|
||||||
*/
|
*/
|
||||||
export type WizardState =
|
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: 'Submitting'; data: Valid }
|
||||||
| { 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: '', 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. */
|
/** 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 uren = parseUren(draft.uren);
|
||||||
const jaren = parseUren(draft.jaren);
|
const jaren = parseUren(draft.jaren);
|
||||||
const punten = parseUren(draft.punten);
|
const punten = parseUren(draft.punten);
|
||||||
const errors: Partial<Record<keyof Draft, string>> = {};
|
const errors: StepErrors = {};
|
||||||
if (!uren.ok) errors.uren = uren.error;
|
if (!uren.ok) errors.uren = uren.error;
|
||||||
if (!jaren.ok) errors.jaren = jaren.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 && jaren.ok && punten.ok) {
|
if (!requiredCategoriesSatisfied(upload)) {
|
||||||
return { ok: true, value: { uren: uren.value, jaren: jaren.value, punten: punten.value } };
|
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 };
|
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 {
|
export function next(s: WizardState): WizardState {
|
||||||
if (s.tag !== 'Editing' || s.step !== 1) return s;
|
if (s.tag !== 'Editing') return s;
|
||||||
|
const errors: StepErrors = {};
|
||||||
|
if (s.step === 1) {
|
||||||
const uren = parseUren(s.draft.uren);
|
const uren = parseUren(s.draft.uren);
|
||||||
const jaren = parseUren(s.draft.jaren);
|
const jaren = parseUren(s.draft.jaren);
|
||||||
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 (!jaren.ok) errors.jaren = jaren.error;
|
||||||
return Object.keys(errors).length === 0 ? { ...s, step: 2, errors: {} } : { ...s, errors };
|
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 {
|
export function back(s: WizardState): WizardState {
|
||||||
if (s.tag !== 'Editing' || s.step !== 2) return s;
|
if (s.tag !== 'Editing' || s.step === 1) return s;
|
||||||
return { ...s, step: 1, errors: {} };
|
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 {
|
export function submit(s: WizardState): WizardState {
|
||||||
if (s.tag !== 'Editing' || s.step !== 2) return s;
|
if (s.tag !== 'Editing' || s.step !== 3) return s;
|
||||||
const result = validate(s.draft);
|
const result = validate(s.draft, s.upload);
|
||||||
return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
|
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. */
|
/** Resolve the async submit. Only meaningful while Submitting. */
|
||||||
export function resolve(s: WizardState, r: Result<string, void>): WizardState {
|
export function resolve(s: WizardState, r: Result<string, void>): WizardState {
|
||||||
if (s.tag !== 'Submitting') return s;
|
if (s.tag !== 'Submitting') return s;
|
||||||
@@ -92,6 +120,7 @@ export type WizardMsg =
|
|||||||
| { tag: 'Retry' }
|
| { tag: 'Retry' }
|
||||||
| { tag: 'SubmitConfirmed' }
|
| { tag: 'SubmitConfirmed' }
|
||||||
| { tag: 'SubmitFailed'; error: string }
|
| { tag: 'SubmitFailed'; error: string }
|
||||||
|
| { tag: 'Upload'; msg: UploadMsg }
|
||||||
| { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)
|
| { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)
|
||||||
|
|
||||||
export function reduce(s: WizardState, m: WizardMsg): WizardState {
|
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;
|
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
|
||||||
case 'SubmitFailed':
|
case 'SubmitFailed':
|
||||||
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
|
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
|
||||||
|
case 'Upload':
|
||||||
|
return upload(s, m.msg);
|
||||||
case 'Seed':
|
case 'Seed':
|
||||||
return m.state;
|
return m.state;
|
||||||
default:
|
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 { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine';
|
||||||
import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie';
|
import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie';
|
||||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
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
|
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
|
||||||
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
|
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. */
|
the dashboard shows "in behandeling" immediately. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-herregistratie-wizard',
|
selector: 'app-herregistratie-wizard',
|
||||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, AlertComponent, WizardShellComponent],
|
imports: [FormsModule, FormFieldComponent, TextInputComponent, AlertComponent, WizardShellComponent, DocumentUploadComponent],
|
||||||
template: `
|
template: `
|
||||||
<app-wizard-shell
|
<app-wizard-shell
|
||||||
[steps]="stepLabels"
|
[steps]="stepLabels"
|
||||||
@@ -27,7 +30,7 @@ import { ApiClient } from '@shared/infrastructure/api-client';
|
|||||||
[stepTitle]="stepTitle()"
|
[stepTitle]="stepTitle()"
|
||||||
[status]="shellStatus()"
|
[status]="shellStatus()"
|
||||||
[primaryLabel]="primaryLabel()"
|
[primaryLabel]="primaryLabel()"
|
||||||
[canGoBack]="step() === 2"
|
[canGoBack]="step() > 1"
|
||||||
[errors]="errorList()"
|
[errors]="errorList()"
|
||||||
[errorMessage]="errorMessage()"
|
[errorMessage]="errorMessage()"
|
||||||
(primary)="onPrimary()"
|
(primary)="onPrimary()"
|
||||||
@@ -35,7 +38,8 @@ import { ApiClient } from '@shared/infrastructure/api-client';
|
|||||||
(cancel)="restart()"
|
(cancel)="restart()"
|
||||||
(retry)="onRetry()">
|
(retry)="onRetry()">
|
||||||
|
|
||||||
@if (step() === 1) {
|
@switch (step()) {
|
||||||
|
@case (1) {
|
||||||
<app-form-field i18n-label="@@herregWizard.urenLabel" label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" required [error]="errUren()">
|
<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 })"
|
<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" />
|
name="uren" [invalid]="!!errUren()" i18n-placeholder="@@herregWizard.urenPlaceholder" placeholder="bijv. 4160" />
|
||||||
@@ -44,12 +48,26 @@ import { ApiClient } from '@shared/infrastructure/api-client';
|
|||||||
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
|
<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" />
|
name="jaren" [invalid]="!!errJaren()" i18n-placeholder="@@herregWizard.jarenPlaceholder" placeholder="bijv. 5" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
} @else {
|
}
|
||||||
|
@case (2) {
|
||||||
<app-form-field i18n-label="@@herregWizard.puntenLabel" label="Behaalde nascholingspunten" fieldId="punten" required [error]="errPunten()">
|
<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 })"
|
<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" />
|
name="punten" [invalid]="!!errPunten()" i18n-placeholder="@@herregWizard.puntenPlaceholder" placeholder="bijv. 200" />
|
||||||
</app-form-field>
|
</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>
|
<div wizardSuccess>
|
||||||
<app-alert type="ok" i18n="@@herregWizard.success">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
|
<app-alert type="ok" i18n="@@herregWizard.success">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
|
||||||
@@ -69,20 +87,27 @@ export class HerregistratieWizardComponent {
|
|||||||
protected dispatch = this.store.dispatch;
|
protected dispatch = this.store.dispatch;
|
||||||
|
|
||||||
// Stepper labels + per-step heading titles (presentational only).
|
// Stepper labels + per-step heading titles (presentational only).
|
||||||
readonly stepLabels = [$localize`:@@herregWizard.step.werkervaring:Werkervaring`, $localize`:@@herregWizard.step.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`];
|
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'));
|
private editing = computed(() => whenTag(this.state(), 'Editing'));
|
||||||
protected step = computed(() => this.editing()?.step ?? 1);
|
protected step = computed(() => this.editing()?.step ?? 1);
|
||||||
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });
|
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 errUren = computed(() => this.editing()?.errors.uren ?? '');
|
||||||
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
|
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
|
||||||
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
||||||
|
protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? '');
|
||||||
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
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 ---------------------
|
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||||
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
|
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 errorMessage = computed(() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`);
|
||||||
protected shellStatus = computed<WizardStatus>(() => {
|
protected shellStatus = computed<WizardStatus>(() => {
|
||||||
switch (this.state().tag) {
|
switch (this.state().tag) {
|
||||||
@@ -107,7 +132,7 @@ export class HerregistratieWizardComponent {
|
|||||||
onPrimary() {
|
onPrimary() {
|
||||||
const s = this.state();
|
const s = this.state();
|
||||||
if (s.tag !== 'Editing') return;
|
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();
|
this.runIfSubmitting();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { applicationConfig } from '@storybook/angular';
|
|||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
|
import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
|
||||||
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
|
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
|
||||||
|
import { initialUpload } from '@shared/upload/upload.machine';
|
||||||
import { Uren } from '@registratie/domain/value-objects/uren';
|
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> = {
|
const meta: Meta<HerregistratieWizardComponent> = {
|
||||||
title: 'Herregistratie/Wizard',
|
title: 'Herregistratie/Wizard',
|
||||||
@@ -18,11 +19,12 @@ 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: '', jaren: '', punten: '' }, errors: {} } } };
|
export const Step1: Story = { args: { seed: { tag: 'Editing', step: 1, draft: { uren: '', jaren: '', punten: '' }, errors: {}, upload: initialUpload } } };
|
||||||
export const Step1Error: Story = {
|
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 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' } } };
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
|||||||
*/
|
*/
|
||||||
export function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise<Result<string, string>> {
|
export function submitRegistratie(client: ApiClient, data: ValidRegistratie): Promise<Result<string, string>> {
|
||||||
return runSubmit(async () => {
|
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 ?? '';
|
return res.referentie ?? '';
|
||||||
}, SUBMIT_FAILED);
|
}, SUBMIT_FAILED);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { ok, err } from '@shared/kernel/fp';
|
import { ok, err } from '@shared/kernel/fp';
|
||||||
|
import { initialUpload } from '@shared/upload/upload.machine';
|
||||||
import {
|
import {
|
||||||
Draft,
|
Draft,
|
||||||
RegistratieState,
|
RegistratieState,
|
||||||
@@ -25,6 +26,7 @@ const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({
|
|||||||
draft: { antwoorden: {}, ...draft },
|
draft: { antwoorden: {}, ...draft },
|
||||||
cursor,
|
cursor,
|
||||||
errors: {},
|
errors: {},
|
||||||
|
upload: initialUpload,
|
||||||
});
|
});
|
||||||
|
|
||||||
const validAdres = { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', correspondentie: 'post' as const, adresHerkomst: 'brp' as const };
|
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');
|
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' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { Result, ok, err, assertNever } from '@shared/kernel/fp';
|
import { Result, ok, err, assertNever } from '@shared/kernel/fp';
|
||||||
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
|
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
|
||||||
import { Email, parseEmail } from '@registratie/domain/value-objects/email';
|
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
|
* A FIXED 3-step registration wizard. The steps never change in number (always
|
||||||
@@ -49,6 +58,7 @@ export interface ValidRegistratie {
|
|||||||
diplomaHerkomst: DiplomaHerkomst;
|
diplomaHerkomst: DiplomaHerkomst;
|
||||||
beroep: string;
|
beroep: string;
|
||||||
antwoorden: Record<string, string>;
|
antwoorden: Record<string, string>;
|
||||||
|
documents: Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Text fields settable via SetField. */
|
/** Text fields settable via SetField. */
|
||||||
@@ -63,17 +73,18 @@ export interface Errors {
|
|||||||
email?: string;
|
email?: string;
|
||||||
correspondentie?: string;
|
correspondentie?: string;
|
||||||
diploma?: string;
|
diploma?: string;
|
||||||
|
documenten?: string;
|
||||||
antwoorden?: Record<string, string>;
|
antwoorden?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RegistratieState =
|
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: 'Indienen'; data: ValidRegistratie }
|
||||||
| { tag: 'Ingediend'; data: ValidRegistratie; referentie: string }
|
| { tag: 'Ingediend'; data: ValidRegistratie; referentie: string }
|
||||||
| { tag: 'Mislukt'; data: ValidRegistratie; error: string };
|
| { tag: 'Mislukt'; data: ValidRegistratie; error: string };
|
||||||
|
|
||||||
const emptyDraft: Draft = { antwoorden: {} };
|
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). */
|
/** Which step the cursor currently points at (clamped to the fixed list). */
|
||||||
export function currentStep(s: Extract<RegistratieState, { tag: 'Invullen' }>): StepId {
|
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. */
|
/** 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 = {};
|
const errors: Errors = {};
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 'adres': {
|
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 (!(d.antwoorden[id] ?? '').trim()) open[id] = $localize`:@@validation.beantwoordVraag:Beantwoord deze vraag.`;
|
||||||
}
|
}
|
||||||
if (Object.keys(open).length > 0) errors.antwoorden = open;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case 'controle':
|
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). */
|
/** 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 = {};
|
const errors: Errors = {};
|
||||||
for (const step of STEPS) {
|
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 (!r.ok) Object.assign(errors, r.error);
|
||||||
}
|
}
|
||||||
if (Object.keys(errors).length > 0) return err(errors);
|
if (Object.keys(errors).length > 0) return err(errors);
|
||||||
@@ -147,6 +162,7 @@ function validateAll(d: Draft): Result<Errors, ValidRegistratie> {
|
|||||||
diplomaHerkomst: d.diplomaHerkomst ?? 'handmatig',
|
diplomaHerkomst: d.diplomaHerkomst ?? 'handmatig',
|
||||||
beroep: d.beroep,
|
beroep: d.beroep,
|
||||||
antwoorden,
|
antwoorden,
|
||||||
|
documents: deliveryRefs(upload),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +213,7 @@ export function setAntwoord(s: RegistratieState, vraagId: string, value: string)
|
|||||||
|
|
||||||
export function next(s: RegistratieState): RegistratieState {
|
export function next(s: RegistratieState): RegistratieState {
|
||||||
if (s.tag !== 'Invullen') return s;
|
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 };
|
if (!r.ok) return { ...s, errors: r.error };
|
||||||
return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };
|
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 {
|
export function submit(s: RegistratieState): RegistratieState {
|
||||||
if (s.tag !== 'Invullen') return s;
|
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 };
|
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 {
|
export function resolve(s: RegistratieState, r: Result<string, string>): RegistratieState {
|
||||||
if (s.tag !== 'Indienen') return s;
|
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 };
|
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: 'Retry' }
|
||||||
| { tag: 'SubmitConfirmed'; referentie: string }
|
| { tag: 'SubmitConfirmed'; referentie: string }
|
||||||
| { tag: 'SubmitFailed'; error: string }
|
| { tag: 'SubmitFailed'; error: string }
|
||||||
|
| { tag: 'Upload'; msg: UploadMsg }
|
||||||
| { tag: 'Seed'; state: RegistratieState };
|
| { tag: 'Seed'; state: RegistratieState };
|
||||||
|
|
||||||
export function reduce(s: RegistratieState, m: RegistratieMsg): 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;
|
return s.tag === 'Indienen' ? { tag: 'Ingediend', data: s.data, referentie: m.referentie } : s;
|
||||||
case 'SubmitFailed':
|
case 'SubmitFailed':
|
||||||
return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s;
|
return s.tag === 'Indienen' ? { tag: 'Mislukt', data: s.data, error: m.error } : s;
|
||||||
|
case 'Upload':
|
||||||
|
return upload(s, m.msg);
|
||||||
case 'Seed':
|
case 'Seed':
|
||||||
return m.state;
|
return m.state;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -29,8 +29,11 @@ import {
|
|||||||
} from '@registratie/domain/registratie-wizard.machine';
|
} from '@registratie/domain/registratie-wizard.machine';
|
||||||
import { submitRegistratie } from '@registratie/application/submit-registratie';
|
import { submitRegistratie } from '@registratie/application/submit-registratie';
|
||||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
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 = [
|
const KANALEN = [
|
||||||
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
|
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
|
||||||
{ value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },
|
{ value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },
|
||||||
@@ -48,7 +51,7 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
|
|||||||
imports: [
|
imports: [
|
||||||
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,
|
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,
|
||||||
AlertComponent, SkeletonComponent, DataRowComponent, WizardShellComponent,
|
AlertComponent, SkeletonComponent, DataRowComponent, WizardShellComponent,
|
||||||
AddressFieldsComponent, ...ASYNC,
|
AddressFieldsComponent, DocumentUploadComponent, ...ASYNC,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<app-wizard-shell
|
<app-wizard-shell
|
||||||
@@ -133,6 +136,18 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
|
|||||||
<app-skeleton height="2.5rem" [count]="3" />
|
<app-skeleton height="2.5rem" [count]="3" />
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-async>
|
</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') {
|
@case ('controle') {
|
||||||
<app-alert type="info" i18n="@@regWizard.controleer">Controleer uw gegevens en dien de registratie in.</app-alert>
|
<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'));
|
private invullen = computed(() => whenTag(this.state(), 'Invullen'));
|
||||||
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
|
protected cursor = computed(() => this.invullen()?.cursor ?? 0);
|
||||||
protected draft = computed<Draft>(() => this.invullen()?.draft ?? { antwoorden: {} });
|
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 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 stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]);
|
||||||
protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
|
protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
|
||||||
@@ -252,7 +273,7 @@ export class RegistratieWizardComponent {
|
|||||||
|
|
||||||
readonly jaNee = JA_NEE;
|
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 vraagErr = (id: string) => this.invullen()?.errors.antwoorden?.[id] ?? '';
|
||||||
protected set = (key: DraftField, value: string) => this.dispatch({ tag: 'SetField', key, value });
|
protected set = (key: DraftField, value: string) => this.dispatch({ tag: 'SetField', key, value });
|
||||||
protected setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
|
protected setKanaal = (value: string) => this.dispatch({ tag: 'SetCorrespondentie', value: value as Correspondentie });
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { applicationConfig } from '@storybook/angular';
|
|||||||
import { provideHttpClient } from '@angular/common/http';
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
import { RegistratieWizardComponent } from './registratie-wizard.component';
|
import { RegistratieWizardComponent } from './registratie-wizard.component';
|
||||||
import { Draft, RegistratieState, ValidRegistratie } from '@registratie/domain/registratie-wizard.machine';
|
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';
|
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 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 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 = {
|
const validData: ValidRegistratie = {
|
||||||
adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' },
|
adres: { straat: 'Lange Voorhout 9', postcode: '2514 EA' as Postcode, woonplaats: 'Den Haag' },
|
||||||
@@ -18,6 +19,7 @@ const validData: ValidRegistratie = {
|
|||||||
diplomaHerkomst: 'duo',
|
diplomaHerkomst: 'duo',
|
||||||
beroep: 'Arts',
|
beroep: 'Arts',
|
||||||
antwoorden: {},
|
antwoorden: {},
|
||||||
|
documents: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const meta: Meta<RegistratieWizardComponent> = {
|
const meta: Meta<RegistratieWizardComponent> = {
|
||||||
|
|||||||
88
src/app/shared/upload/upload-controller.ts
Normal file
88
src/app/shared/upload/upload-controller.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { DestroyRef, effect, inject } from '@angular/core';
|
||||||
|
import { UploadAdapter } from './upload.adapter';
|
||||||
|
import { UploadShellService } from './upload-shell.service';
|
||||||
|
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||||
|
import { SUBMIT_FAILED } from '@shared/application/submit';
|
||||||
|
import { DeliveryChannel, UploadMsg, UploadState, inFlight, rejectReason } from './upload.machine';
|
||||||
|
|
||||||
|
export interface UploadControllerDeps {
|
||||||
|
wizardId: string;
|
||||||
|
getUpload: () => UploadState;
|
||||||
|
dispatch: (m: UploadMsg) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The effectful glue between the `<app-document-upload>` organism's events and the
|
||||||
|
* pure upload reducer + transport. Both wizards instantiate one (in a field
|
||||||
|
* initializer, like `createStore`). Holds the only state a reducer can't: the live
|
||||||
|
* `File` blobs keyed by localId (needed to retry an upload). Loads categories,
|
||||||
|
* reports background-sync availability, and polls on tab refocus.
|
||||||
|
*/
|
||||||
|
export function createUploadController(deps: UploadControllerDeps) {
|
||||||
|
const adapter = inject(UploadAdapter);
|
||||||
|
const shell = inject(UploadShellService);
|
||||||
|
const files = new Map<string, File>();
|
||||||
|
const categoriesRes = adapter.categoriesResource(deps.wizardId);
|
||||||
|
|
||||||
|
// Runs after the host's `Seed`/restore microtask (effects fire in CD, not field
|
||||||
|
// init), so these dispatches aren't wiped by a state reseed.
|
||||||
|
effect(() => {
|
||||||
|
deps.dispatch({ type: 'BackgroundSyncAvailability', available: shell.backgroundSyncAvailable });
|
||||||
|
const status = categoriesRes.status();
|
||||||
|
if (status === 'resolved' || status === 'local') {
|
||||||
|
deps.dispatch({ type: 'CategoriesLoaded', categories: categoriesRes.value() ?? [] });
|
||||||
|
} else if (status === 'error') {
|
||||||
|
deps.dispatch({ type: 'CategoriesLoadFailed', reason: problemDetail(categoriesRes.error(), SUBMIT_FAILED) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFocus = () => void shell.pollReturning(inFlight(deps.getUpload()), deps.dispatch);
|
||||||
|
window.addEventListener('focus', onFocus);
|
||||||
|
inject(DestroyRef).onDestroy(() => window.removeEventListener('focus', onFocus));
|
||||||
|
|
||||||
|
function start(categoryId: string, file: File) {
|
||||||
|
const localId = crypto.randomUUID();
|
||||||
|
files.set(localId, file);
|
||||||
|
deps.dispatch({ type: 'FileSelected', categoryId, localId, fileName: file.name, fileSizeMb: file.size / 1e6 });
|
||||||
|
shell.upload({ localId, categoryId, wizardId: deps.wizardId, file }, deps.dispatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
onFileSelected(categoryId: string, selected: File[]) {
|
||||||
|
const cat = deps.getUpload().categories.find((c) => c.categoryId === categoryId);
|
||||||
|
if (!cat) return;
|
||||||
|
if (!cat.multiple && selected.length > 1) {
|
||||||
|
deps.dispatch({ type: 'FileRejected', categoryId, reason: 'multiple' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const file of selected) {
|
||||||
|
const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 });
|
||||||
|
if (reason) deps.dispatch({ type: 'FileRejected', categoryId, reason });
|
||||||
|
else start(categoryId, file);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRemove(localId: string) {
|
||||||
|
shell.cancel([localId]);
|
||||||
|
files.delete(localId);
|
||||||
|
deps.dispatch({ type: 'UploadRemoved', localId });
|
||||||
|
},
|
||||||
|
onRetry(localId: string) {
|
||||||
|
const file = files.get(localId);
|
||||||
|
const up = deps.getUpload().uploads.find((u) => u.localId === localId);
|
||||||
|
if (!file || !up) return;
|
||||||
|
deps.dispatch({ type: 'UploadRetried', localId });
|
||||||
|
shell.upload({ localId, categoryId: up.categoryId, wizardId: deps.wizardId, file }, deps.dispatch);
|
||||||
|
},
|
||||||
|
onDelete(e: { localId: string; documentId: string }) {
|
||||||
|
shell.delete(e.localId, e.documentId, deps.dispatch);
|
||||||
|
},
|
||||||
|
onChannelChange(categoryId: string, channel: DeliveryChannel) {
|
||||||
|
const ids = deps
|
||||||
|
.getUpload()
|
||||||
|
.uploads.filter((u) => u.categoryId === categoryId)
|
||||||
|
.map((u) => u.localId);
|
||||||
|
shell.cancel(ids);
|
||||||
|
deps.dispatch({ type: 'DeliveryChannelChanged', categoryId, channel });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
requiredCategoriesSatisfied,
|
requiredCategoriesSatisfied,
|
||||||
deliveryRefs,
|
deliveryRefs,
|
||||||
inFlight,
|
inFlight,
|
||||||
|
rejectReason,
|
||||||
} from './upload.machine';
|
} from './upload.machine';
|
||||||
|
|
||||||
const cat = (over: Partial<DocumentCategory> = {}): DocumentCategory => ({
|
const cat = (over: Partial<DocumentCategory> = {}): DocumentCategory => ({
|
||||||
@@ -217,6 +218,21 @@ describe('deliveryRefs', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('rejectReason', () => {
|
||||||
|
it('rejects a disallowed type', () => {
|
||||||
|
expect(rejectReason(cat({ acceptedTypes: ['application/pdf'] }), { type: 'image/png', sizeMb: 1 })).toBe('type');
|
||||||
|
});
|
||||||
|
it('rejects an oversized file', () => {
|
||||||
|
expect(rejectReason(cat({ maxSizeMb: 10 }), { type: 'application/pdf', sizeMb: 11 })).toBe('size');
|
||||||
|
});
|
||||||
|
it('accepts a valid file', () => {
|
||||||
|
expect(rejectReason(cat({ acceptedTypes: ['application/pdf'], maxSizeMb: 10 }), { type: 'application/pdf', sizeMb: 1 })).toBeNull();
|
||||||
|
});
|
||||||
|
it('allows any type when the category lists none', () => {
|
||||||
|
expect(rejectReason(cat({ acceptedTypes: [], maxSizeMb: 10 }), { type: 'image/png', sizeMb: 1 })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('inFlight', () => {
|
describe('inFlight', () => {
|
||||||
it('returns only queued/uploading uploads', () => {
|
it('returns only queued/uploading uploads', () => {
|
||||||
let s = select(stateWith([cat({ multiple: true })]), 'diploma', 'u1');
|
let s = select(stateWith([cat({ multiple: true })]), 'diploma', 'u1');
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ export function requiredCategoriesSatisfied(s: UploadState): boolean {
|
|||||||
return s.categories.filter((c) => c.required).every((c) => categorySatisfied(s, c.categoryId));
|
return s.categories.filter((c) => c.required).every((c) => categorySatisfied(s, c.categoryId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FE format-validation (never authority — the server re-validates). Returns the
|
||||||
|
* rejection reason for one file, or null if it passes the category's type/size.
|
||||||
|
*/
|
||||||
|
export function rejectReason(cat: DocumentCategory, file: { type: string; sizeMb: number }): 'type' | 'size' | null {
|
||||||
|
if (cat.acceptedTypes.length > 0 && !cat.acceptedTypes.includes(file.type)) return 'type';
|
||||||
|
if (cat.maxSizeMb > 0 && file.sizeMb > cat.maxSizeMb) return 'size';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Map one upload's status, leaving the rest of the list untouched. */
|
/** Map one upload's status, leaving the rest of the list untouched. */
|
||||||
function mapUpload(s: UploadState, localId: string, f: (u: Upload) => Upload): UploadState {
|
function mapUpload(s: UploadState, localId: string, f: (u: Upload) => Upload): UploadState {
|
||||||
return { ...s, uploads: s.uploads.map((u) => (u.localId === localId ? f(u) : u)) };
|
return { ...s, uploads: s.uploads.map((u) => (u.localId === localId ? f(u) : u)) };
|
||||||
|
|||||||
Reference in New Issue
Block a user