feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { hasProgress, initial, WizardState } from './herregistratie.machine';
|
||||
|
||||
const editing = initial as Extract<WizardState, { tag: 'Editing' }>;
|
||||
|
||||
describe('herregistratie hasProgress', () => {
|
||||
it('is false for a fresh form', () => {
|
||||
expect(hasProgress(editing)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true once a field is filled or the user advances', () => {
|
||||
expect(hasProgress({ ...editing, draft: { uren: '40', jaren: '', punten: '' } })).toBe(true);
|
||||
expect(hasProgress({ ...editing, step: 2 })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ok, err } from '@shared/kernel/fp';
|
||||
import { initialUpload } from '@shared/upload/upload.machine';
|
||||
import {
|
||||
initial,
|
||||
next,
|
||||
back,
|
||||
gaNaarStap,
|
||||
submit,
|
||||
resolve,
|
||||
reduce,
|
||||
WizardState,
|
||||
} from './herregistratie.machine';
|
||||
|
||||
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', () => {
|
||||
expect(next(initial).tag).toBe('Editing'); // empty uren -> stays, with error
|
||||
expect((next(initial) as any).errors.uren).toBeTruthy();
|
||||
expect((next(editing1('4160')) as any).step).toBe(2);
|
||||
});
|
||||
|
||||
it('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, documents: [] });
|
||||
});
|
||||
|
||||
it('next requires BOTH step-1 fields (uren and jaren)', () => {
|
||||
expect((next(editing1('4160', '')) as any).errors.jaren).toBeTruthy(); // jaren empty -> stays
|
||||
expect((next(editing1('4160', '')) as any).step).toBe(1);
|
||||
expect((next(editing1('4160', '5')) as any).step).toBe(2); // both valid -> advance
|
||||
});
|
||||
|
||||
it('back 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(editing3('4160', '200'));
|
||||
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
|
||||
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
|
||||
});
|
||||
|
||||
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
|
||||
expect((gaNaarStap(editing3('4160', '200'), 1) as any).step).toBe(1);
|
||||
});
|
||||
|
||||
it('gaNaarStap ignores a same/forward jump and jumps outside Editing', () => {
|
||||
const e3 = editing3('4160', '200');
|
||||
expect(gaNaarStap(e3, 3)).toBe(e3); // same step -> no-op
|
||||
const submitting = submit(e3);
|
||||
expect(gaNaarStap(submitting, 1)).toBe(submitting); // not Editing -> no-op
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduce (message-driven)', () => {
|
||||
it('drives the full happy path via messages', () => {
|
||||
let s: WizardState = initial;
|
||||
s = reduce(s, { tag: 'SetField', key: 'uren', value: '4160' });
|
||||
s = reduce(s, { tag: 'SetField', key: 'jaren', value: '5' });
|
||||
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(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, documents: [] });
|
||||
});
|
||||
|
||||
it('Seed mounts an arbitrary state', () => {
|
||||
expect(reduce(initial, { tag: 'Seed', state: editing2('1', '2') }).tag).toBe('Editing');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
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 {
|
||||
uren: string;
|
||||
jaren: string;
|
||||
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 }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole wizard as one tagged union. `step` and `errors` exist ONLY while
|
||||
* Editing; Submitting/Submitted/Failed carry a `Valid` payload and nothing else.
|
||||
* So "submitting while a field is invalid" or "showing the success screen with
|
||||
* errors set" are unrepresentable — the bug class is gone by construction.
|
||||
*/
|
||||
export type WizardState =
|
||||
| { tag: 'Editing'; step: 1 | 2 | 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: {},
|
||||
upload: initialUpload,
|
||||
};
|
||||
|
||||
/** Has the user meaningfully started, so it's worth persisting as a Concept? */
|
||||
export function hasProgress(s: Extract<WizardState, { tag: 'Editing' }>): boolean {
|
||||
return (
|
||||
s.step > 1 ||
|
||||
!!s.draft.uren ||
|
||||
!!s.draft.jaren ||
|
||||
!!s.draft.punten ||
|
||||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Parse every field; on success hand back a Valid, else the per-field errors. */
|
||||
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: StepErrors = {};
|
||||
if (!uren.ok) errors.uren = uren.error;
|
||||
if (!jaren.ok) errors.jaren = jaren.error;
|
||||
if (!punten.ok) errors.punten = punten.error;
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Advance one step, gating on that step's fields. Illegal elsewhere = no-op. */
|
||||
export function next(s: WizardState): WizardState {
|
||||
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 === 1) return s;
|
||||
return { ...s, step: (s.step - 1) as 1 | 2, errors: {} };
|
||||
}
|
||||
|
||||
/** Jump back to an earlier step to correct data (controle → step N). Forward
|
||||
jumps are not allowed (would skip validation). */
|
||||
export function gaNaarStap(s: WizardState, step: 1 | 2 | 3): WizardState {
|
||||
if (s.tag !== 'Editing' || step >= s.step) return s;
|
||||
return { ...s, step, errors: {} };
|
||||
}
|
||||
|
||||
/** Step 3 submit: parse everything + require documents; Submitting only with Valid. */
|
||||
export function submit(s: WizardState): WizardState {
|
||||
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;
|
||||
return r.ok
|
||||
? { tag: 'Submitted', data: s.data }
|
||||
: { tag: 'Failed', data: s.data, error: r.error };
|
||||
}
|
||||
|
||||
/** Update one draft field while editing; ignored in any other state. */
|
||||
export function setField(s: WizardState, key: keyof Draft, value: string): WizardState {
|
||||
if (s.tag !== 'Editing') return s;
|
||||
return { ...s, draft: { ...s.draft, [key]: value } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every event that can happen to the wizard, as one message type. The component
|
||||
* sends a WizardMsg; `reduce` decides the next state. This is the Elm
|
||||
* Model+Msg+update pattern: ONE pure function describes all state changes.
|
||||
*/
|
||||
export type WizardMsg =
|
||||
| { tag: 'SetField'; key: keyof Draft; value: string }
|
||||
| { tag: 'Next' }
|
||||
| { tag: 'Back' }
|
||||
| { tag: 'GaNaarStap'; step: 1 | 2 | 3 }
|
||||
| { tag: 'Submit' }
|
||||
| { 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 {
|
||||
switch (m.tag) {
|
||||
case 'SetField':
|
||||
return setField(s, m.key, m.value);
|
||||
case 'Next':
|
||||
return next(s);
|
||||
case 'Back':
|
||||
return back(s);
|
||||
case 'GaNaarStap':
|
||||
return gaNaarStap(s, m.step);
|
||||
case 'Submit':
|
||||
return submit(s);
|
||||
case 'Retry':
|
||||
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||
case 'SubmitConfirmed':
|
||||
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:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { hasProgress, initial, IntakeState } from './intake.machine';
|
||||
|
||||
const answering = initial as Extract<IntakeState, { tag: 'Answering' }>;
|
||||
|
||||
describe('intake hasProgress', () => {
|
||||
it('is false for a fresh questionnaire', () => {
|
||||
expect(hasProgress(answering)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true once an answer is given or the user advances', () => {
|
||||
expect(hasProgress({ ...answering, answers: { buitenlandGewerkt: 'ja' } })).toBe(true);
|
||||
expect(hasProgress({ ...answering, cursor: 1 })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ok, err } from '@shared/kernel/fp';
|
||||
import {
|
||||
Answers,
|
||||
initial,
|
||||
STEPS,
|
||||
lageUren,
|
||||
currentStep,
|
||||
next,
|
||||
back,
|
||||
gaNaarStap,
|
||||
submit,
|
||||
resolve,
|
||||
reduce,
|
||||
IntakeState,
|
||||
} from './intake.machine';
|
||||
|
||||
const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({
|
||||
tag: 'Answering',
|
||||
answers,
|
||||
cursor,
|
||||
errors: {},
|
||||
scholingThreshold,
|
||||
});
|
||||
|
||||
describe('STEPS (fixed) and inline questions', () => {
|
||||
it('always has the same three steps', () => {
|
||||
expect(STEPS).toEqual(['buitenland', 'werk', 'review']);
|
||||
});
|
||||
|
||||
it('reveals the buitenland detail questions inline only when worked abroad', () => {
|
||||
// No new step; instead these fields become required within the buitenland step.
|
||||
expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked
|
||||
expect((next(answering({ buitenlandGewerkt: 'ja' })) as any).errors.land).toBeTruthy();
|
||||
expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves)
|
||||
expect((next(answering({ buitenlandGewerkt: 'nee' })) as any).cursor).toBe(1);
|
||||
});
|
||||
|
||||
it('reveals the scholing question only when NL-hours are below the threshold', () => {
|
||||
expect(lageUren({ uren: '500' })).toBe(true);
|
||||
expect(lageUren({ uren: '4160' })).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the (server-owned) threshold passed in, not a hardcoded constant', () => {
|
||||
// Same hours, different threshold → different visibility. Proves de-hardcoding.
|
||||
expect(lageUren({ uren: '1500' }, 1000)).toBe(false);
|
||||
expect(lageUren({ uren: '1500' }, 2000)).toBe(true);
|
||||
// And the threshold from state flows through submit:
|
||||
const lowThreshold = submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000),
|
||||
);
|
||||
expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked
|
||||
expect((lowThreshold as any).errors.scholingGevolgd).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation', () => {
|
||||
it('Next is a no-op (sets an error) when the current step is invalid', () => {
|
||||
const s = next(initial); // buitenland unanswered
|
||||
expect(s.tag).toBe('Answering');
|
||||
expect((s as any).cursor).toBe(0);
|
||||
expect((s as any).errors.buitenlandGewerkt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Next advances once the step is valid', () => {
|
||||
const s = next(answering({ buitenlandGewerkt: 'nee' }));
|
||||
expect((s as any).cursor).toBe(1);
|
||||
expect(currentStep(s as any)).toBe('werk');
|
||||
});
|
||||
|
||||
it('editing an answer leaves the cursor fixed (steps never collapse)', () => {
|
||||
const edited = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), {
|
||||
tag: 'SetAnswer',
|
||||
key: 'buitenlandGewerkt',
|
||||
value: 'nee',
|
||||
});
|
||||
expect((edited as any).cursor).toBe(1); // cursor untouched; only inline questions change
|
||||
});
|
||||
|
||||
it('Back never goes below the first step', () => {
|
||||
expect(back(initial)).toBe(initial);
|
||||
});
|
||||
|
||||
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
|
||||
const s = answering({ buitenlandGewerkt: 'nee' }, 2);
|
||||
expect((gaNaarStap(s, 0) as any).cursor).toBe(0);
|
||||
});
|
||||
|
||||
it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => {
|
||||
const s = answering({ buitenlandGewerkt: 'nee' }, 1);
|
||||
expect(gaNaarStap(s, 1)).toBe(s); // same step -> no-op
|
||||
expect(gaNaarStap(s, 2)).toBe(s); // forward -> no-op
|
||||
const submitting = submit(answering({ buitenlandGewerkt: 'nee', uren: '4160' }, 2));
|
||||
expect(gaNaarStap(submitting, 0)).toBe(submitting); // not Answering -> no-op
|
||||
});
|
||||
});
|
||||
|
||||
describe('submit', () => {
|
||||
// High hours: no scholing question, so no punten is asked or collected.
|
||||
const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160' };
|
||||
|
||||
it('reaches Submitting ONLY with valid answers', () => {
|
||||
// Bad punten only blocks when scholing was followed (otherwise punten is ignored).
|
||||
expect(
|
||||
submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' }),
|
||||
).tag,
|
||||
).toBe('Answering');
|
||||
const good = submit(answering(complete));
|
||||
expect(good.tag).toBe('Submitting');
|
||||
expect((good as any).data.uren).toBe(4160);
|
||||
expect((good as any).data.punten).toBeUndefined(); // not collected without scholing
|
||||
});
|
||||
|
||||
it('punten is required only when aanvullende scholing was gevolgd', () => {
|
||||
// scholing = ja but punten missing -> blocked on punten.
|
||||
const missing = submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' }),
|
||||
);
|
||||
expect(missing.tag).toBe('Answering');
|
||||
expect((missing as any).errors.punten).toBeTruthy();
|
||||
// scholing = nee -> punten not required, submits without it.
|
||||
expect(
|
||||
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag,
|
||||
).toBe('Submitting');
|
||||
});
|
||||
|
||||
it('low hours requires the scholing answer before submit', () => {
|
||||
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' }));
|
||||
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
|
||||
const withScholing = submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }),
|
||||
);
|
||||
expect(withScholing.tag).toBe('Submitting');
|
||||
expect((withScholing as any).data.aanvullendeScholing).toBe(true);
|
||||
expect((withScholing as any).data.punten).toBe(200);
|
||||
});
|
||||
|
||||
it('resolve maps Submitting to Submitted on a successful submit', () => {
|
||||
const submitting = submit(answering(complete));
|
||||
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
|
||||
});
|
||||
|
||||
it('resolve maps Submitting to Failed on a failed submit', () => {
|
||||
const submitting = submit(answering(complete));
|
||||
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduce (message-driven happy path)', () => {
|
||||
it('drives abroad branch end to end', () => {
|
||||
let s: IntakeState = initial;
|
||||
// Step 1: buitenland — the country/hours questions reveal inline (same step).
|
||||
s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' });
|
||||
s = reduce(s, { tag: 'SetAnswer', key: 'land', value: 'België' });
|
||||
s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandseUren', value: '800' });
|
||||
s = reduce(s, { tag: 'Next' });
|
||||
expect(currentStep(s as any)).toBe('werk');
|
||||
// Step 2: werk — uren + punten (no inline scholing, hours are high).
|
||||
s = reduce(s, { tag: 'SetAnswer', key: 'uren', value: '4160' });
|
||||
s = reduce(s, { tag: 'SetAnswer', key: 'punten', value: '200' });
|
||||
s = reduce(s, { tag: 'Next' });
|
||||
expect(currentStep(s as any)).toBe('review');
|
||||
s = reduce(s, { tag: 'Submit' });
|
||||
expect(s.tag).toBe('Submitting');
|
||||
s = reduce(s, { tag: 'SubmitConfirmed' });
|
||||
expect(s.tag).toBe('Submitted');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { Result, ok, err, assertNever } from '@shared/kernel/fp';
|
||||
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
|
||||
|
||||
/**
|
||||
* A FIXED 3-step wizard with progressive disclosure. The steps never change in
|
||||
* number (always `STEPS`); instead, follow-up questions appear *inline within a
|
||||
* step* depending on earlier answers — answer "buiten Nederland gewerkt? → ja"
|
||||
* and the country/hours questions reveal in the same step; report few hours and
|
||||
* the scholing-question reveals inside the 'werk' step. "Is this field required
|
||||
* right now" is a pure function (`validateStep`/`lageUren`), so it's trivial to
|
||||
* test and impossible to get out of sync with the data.
|
||||
*/
|
||||
|
||||
export type JaNee = 'ja' | 'nee';
|
||||
|
||||
/** The three fixed steps. Each step groups one or more questions. */
|
||||
export type StepId = 'buitenland' | 'werk' | 'review';
|
||||
|
||||
/** One record carried across every step (and persisted). All optional: the user
|
||||
fills it in gradually, and branches may never ask some fields. */
|
||||
export interface Answers {
|
||||
buitenlandGewerkt?: JaNee; // Q1
|
||||
land?: string; // Q1a — only when buitenlandGewerkt === 'ja'
|
||||
buitenlandseUren?: string; // Q1b — only when buitenlandGewerkt === 'ja'
|
||||
uren?: string; // Q2 — uren in NL
|
||||
scholingGevolgd?: JaNee; // Q3 — only when total hours are below the threshold
|
||||
punten?: string; // Q4
|
||||
}
|
||||
|
||||
/** What we have after the review step parses — guaranteed valid/typed. */
|
||||
export interface ValidIntake {
|
||||
werktBuitenland: boolean;
|
||||
land?: string;
|
||||
buitenlandseUren?: Uren;
|
||||
uren: Uren;
|
||||
aanvullendeScholing?: boolean;
|
||||
punten?: Uren; // only collected when aanvullende scholing is gevolgd (scholingGevolgd === 'ja')
|
||||
}
|
||||
|
||||
/** Demo fallback only — the real threshold is a SERVER-OWNED policy value fetched
|
||||
at runtime (see IntakePolicyDto / SetPolicy). ponytail: default is the offline
|
||||
fallback; the server value wins. */
|
||||
export const SCHOLING_THRESHOLD_DEFAULT = 1000;
|
||||
|
||||
/** The server-owned intake policy (domain-side, parsed from the wire at the boundary). */
|
||||
export interface IntakePolicy {
|
||||
readonly scholingThreshold: number;
|
||||
}
|
||||
|
||||
/** True when NL-hours are low enough that the scholing question must be answered.
|
||||
The threshold is passed in (server-owned), not hardcoded. */
|
||||
export function lageUren(a: Answers, scholingThreshold = SCHOLING_THRESHOLD_DEFAULT): boolean {
|
||||
const r = parseUren(a.uren ?? '');
|
||||
return r.ok && r.value < scholingThreshold;
|
||||
}
|
||||
|
||||
// #region showcase:steps
|
||||
/** The fixed step list. Number of steps never changes; questions reveal inline. */
|
||||
export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
|
||||
// #endregion showcase:steps
|
||||
|
||||
/** Per-field error map: one message per question, since a step holds several. */
|
||||
type Errors = Partial<Record<keyof Answers, string>>;
|
||||
|
||||
export type IntakeState =
|
||||
| {
|
||||
tag: 'Answering';
|
||||
answers: Answers;
|
||||
cursor: number;
|
||||
errors: Errors;
|
||||
scholingThreshold: number;
|
||||
}
|
||||
| { tag: 'Submitting'; data: ValidIntake }
|
||||
| { tag: 'Submitted'; data: ValidIntake }
|
||||
| { tag: 'Failed'; data: ValidIntake; error: string };
|
||||
|
||||
export const initial: IntakeState = {
|
||||
tag: 'Answering',
|
||||
answers: {},
|
||||
cursor: 0,
|
||||
errors: {},
|
||||
scholingThreshold: SCHOLING_THRESHOLD_DEFAULT,
|
||||
};
|
||||
|
||||
/** Which step the cursor currently points at (clamped to the fixed list). */
|
||||
export function currentStep(s: Extract<IntakeState, { tag: 'Answering' }>): StepId {
|
||||
return STEPS[Math.min(s.cursor, STEPS.length - 1)];
|
||||
}
|
||||
|
||||
/** Has the user meaningfully started, so it's worth persisting as a Concept?
|
||||
(No auto-prefill here — pristine means truly untouched.) */
|
||||
export function hasProgress(s: Extract<IntakeState, { tag: 'Answering' }>): boolean {
|
||||
return s.cursor > 0 || Object.keys(s.answers).length > 0;
|
||||
}
|
||||
|
||||
/** Validate every question currently visible in ONE step. Errors keyed per field. */
|
||||
function validateStep(step: StepId, a: Answers, scholingThreshold: number): Result<Errors, void> {
|
||||
const errors: Errors = {};
|
||||
switch (step) {
|
||||
case 'buitenland':
|
||||
if (!a.buitenlandGewerkt)
|
||||
errors.buitenlandGewerkt = $localize`:@@validation.maakKeuze:Maak een keuze.`;
|
||||
else if (a.buitenlandGewerkt === 'ja') {
|
||||
if (!a.land || a.land.trim() === '')
|
||||
errors.land = $localize`:@@validation.land:Vul een land in.`;
|
||||
const u = parseUren(a.buitenlandseUren ?? '');
|
||||
if (!u.ok) errors.buitenlandseUren = u.error;
|
||||
}
|
||||
break;
|
||||
case 'werk': {
|
||||
const u = parseUren(a.uren ?? '');
|
||||
if (!u.ok) errors.uren = u.error;
|
||||
if (lageUren(a, scholingThreshold) && !a.scholingGevolgd)
|
||||
errors.scholingGevolgd = $localize`:@@validation.maakKeuze:Maak een keuze.`;
|
||||
// Nascholingspunten are only asked (and required) when scholing was followed.
|
||||
if (a.scholingGevolgd === 'ja') {
|
||||
const p = parseUren(a.punten ?? '');
|
||||
if (!p.ok) errors.punten = p.error;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'review':
|
||||
break; // review shows a summary; no own fields
|
||||
default:
|
||||
return assertNever(step);
|
||||
}
|
||||
return Object.keys(errors).length > 0 ? err(errors) : ok(undefined);
|
||||
}
|
||||
|
||||
/** Parse the whole questionnaire into a ValidIntake (called on submit). */
|
||||
function validateAll(a: Answers, scholingThreshold: number): Result<Errors, ValidIntake> {
|
||||
const errors: Errors = {};
|
||||
for (const step of STEPS) {
|
||||
const r = validateStep(step, a, scholingThreshold);
|
||||
if (!r.ok) Object.assign(errors, r.error);
|
||||
}
|
||||
if (Object.keys(errors).length > 0) return err(errors);
|
||||
|
||||
const uren = parseUren(a.uren ?? '');
|
||||
// validateStep guaranteed uren parses, but keep the compiler happy.
|
||||
if (!uren.ok) return err(errors);
|
||||
|
||||
const werktBuitenland = a.buitenlandGewerkt === 'ja';
|
||||
const buitenland = parseUren(a.buitenlandseUren ?? '');
|
||||
// Punten are only collected when aanvullende scholing was gevolgd.
|
||||
const punten = a.scholingGevolgd === 'ja' ? parseUren(a.punten ?? '') : undefined;
|
||||
return ok({
|
||||
werktBuitenland,
|
||||
land: werktBuitenland ? a.land : undefined,
|
||||
buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,
|
||||
uren: uren.value,
|
||||
aanvullendeScholing: lageUren(a, scholingThreshold) ? a.scholingGevolgd === 'ja' : undefined,
|
||||
punten: punten?.ok ? punten.value : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function setAnswer(s: IntakeState, key: keyof Answers, value: string): IntakeState {
|
||||
if (s.tag !== 'Answering') return s;
|
||||
// Steps are fixed, so editing an answer never moves the cursor — it only
|
||||
// reveals/hides inline questions within the current step.
|
||||
return { ...s, answers: { ...s.answers, [key]: value } };
|
||||
}
|
||||
|
||||
export function next(s: IntakeState): IntakeState {
|
||||
if (s.tag !== 'Answering') return s;
|
||||
const r = validateStep(currentStep(s), s.answers, s.scholingThreshold);
|
||||
if (!r.ok) return { ...s, errors: r.error };
|
||||
return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} };
|
||||
}
|
||||
|
||||
/** Apply a server-owned policy value (e.g. the scholing threshold). */
|
||||
export function setPolicy(s: IntakeState, scholingThreshold: number): IntakeState {
|
||||
return s.tag === 'Answering' ? { ...s, scholingThreshold } : s;
|
||||
}
|
||||
|
||||
export function back(s: IntakeState): IntakeState {
|
||||
if (s.tag !== 'Answering' || s.cursor === 0) return s;
|
||||
return { ...s, cursor: s.cursor - 1, errors: {} };
|
||||
}
|
||||
|
||||
/** Jump back to an earlier step to correct answers (review → step N). Forward
|
||||
jumps are not allowed (would skip validation). */
|
||||
export function gaNaarStap(s: IntakeState, cursor: number): IntakeState {
|
||||
if (s.tag !== 'Answering' || cursor < 0 || cursor >= s.cursor) return s;
|
||||
return { ...s, cursor, errors: {} };
|
||||
}
|
||||
|
||||
export function submit(s: IntakeState): IntakeState {
|
||||
if (s.tag !== 'Answering') return s;
|
||||
const r = validateAll(s.answers, s.scholingThreshold);
|
||||
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
|
||||
}
|
||||
|
||||
export function resolve(s: IntakeState, r: Result<string, void>): IntakeState {
|
||||
if (s.tag !== 'Submitting') return s;
|
||||
return r.ok
|
||||
? { tag: 'Submitted', data: s.data }
|
||||
: { tag: 'Failed', data: s.data, error: r.error };
|
||||
}
|
||||
|
||||
export type IntakeMsg =
|
||||
| { tag: 'SetAnswer'; key: keyof Answers; value: string }
|
||||
| { tag: 'Next' }
|
||||
| { tag: 'Back' }
|
||||
| { tag: 'GaNaarStap'; cursor: number }
|
||||
| { tag: 'Submit' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed' }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
| { tag: 'SetPolicy'; scholingThreshold: number }
|
||||
| { tag: 'Seed'; state: IntakeState };
|
||||
|
||||
export function reduce(s: IntakeState, m: IntakeMsg): IntakeState {
|
||||
switch (m.tag) {
|
||||
case 'SetAnswer':
|
||||
return setAnswer(s, m.key, m.value);
|
||||
case 'Next':
|
||||
return next(s);
|
||||
case 'Back':
|
||||
return back(s);
|
||||
case 'GaNaarStap':
|
||||
return gaNaarStap(s, m.cursor);
|
||||
case 'Submit':
|
||||
return submit(s);
|
||||
case 'Retry':
|
||||
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||
case 'SubmitConfirmed':
|
||||
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 'SetPolicy':
|
||||
return setPolicy(s, m.scholingThreshold);
|
||||
case 'Seed':
|
||||
return m.state;
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user