Add branching intake wizard (derived steps + radio-group atom)
A second wizard demonstrating a BRANCHING flow: the visible steps are derived from the answers by a pure `visibleSteps` function rather than stored, so answering "buiten Nederland gewerkt? -> ja" or reporting few hours adds steps and the progress denominator changes live. Same Elm-style store + RemoteData patterns as the fixed wizard; answers persist to localStorage. - intake.machine.ts: IntakeState union + Answers + visibleSteps + pure reduce (+spec) - intake-wizard organism, intake.page, submit-intake command - new radio-group atom (ControlValueAccessor) in shared/ui - /intake route + dashboard link + concepts showcase section - tighten Aantekening.type to a 'Specialisme' | 'Aantekening' union - README + ARCHITECTURE updated Verified live end-to-end (branches add steps 4->5->6, review, submit) with no console errors; build, unit tests, and Storybook all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
190
src/app/herregistratie/domain/intake.machine.ts
Normal file
190
src/app/herregistratie/domain/intake.machine.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Result, ok, err, assertNever } from '@shared/kernel/fp';
|
||||
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
|
||||
|
||||
/**
|
||||
* A BRANCHING wizard. Unlike herregistratie.machine (fixed 2 steps), the set of
|
||||
* steps here is NOT stored — it's *derived* from the answers by `visibleSteps`.
|
||||
* Answer "buiten Nederland gewerkt? → ja" and two extra steps appear; report few
|
||||
* hours and a scholing-question appears. The progress bar's denominator changes
|
||||
* as you type. "Which question comes next" is a pure function, so it's trivial
|
||||
* to test and impossible to get out of sync with the data.
|
||||
*/
|
||||
|
||||
export type JaNee = 'ja' | 'nee';
|
||||
|
||||
/** Every possible question, as a union — never a bare string. */
|
||||
export type StepId = 'buitenland' | 'buitenlandDetails' | 'uren' | 'scholing' | 'punten' | '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;
|
||||
}
|
||||
|
||||
/** Below this many NL-hours we ask whether extra scholing was followed. */
|
||||
const LAGE_UREN_DREMPEL = 1000;
|
||||
|
||||
function lageUren(a: Answers): boolean {
|
||||
const r = parseUren(a.uren ?? '');
|
||||
return r.ok && r.value < LAGE_UREN_DREMPEL;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE branching, as one pure function. The step list is recomputed on every
|
||||
* transition, so changing an earlier answer immediately adds/removes later steps.
|
||||
*/
|
||||
export function visibleSteps(a: Answers): StepId[] {
|
||||
const steps: StepId[] = ['buitenland'];
|
||||
if (a.buitenlandGewerkt === 'ja') steps.push('buitenlandDetails');
|
||||
steps.push('uren');
|
||||
if (lageUren(a)) steps.push('scholing');
|
||||
steps.push('punten', 'review');
|
||||
return steps;
|
||||
}
|
||||
|
||||
export type IntakeState =
|
||||
| { tag: 'Answering'; answers: Answers; cursor: number; errors: Partial<Record<StepId, string>> }
|
||||
| { tag: 'Submitting'; data: ValidIntake }
|
||||
| { tag: 'Submitted'; data: ValidIntake }
|
||||
| { tag: 'Failed'; data: ValidIntake; error: string };
|
||||
|
||||
export const initial: IntakeState = { tag: 'Answering', answers: {}, cursor: 0, errors: {} };
|
||||
|
||||
/** Which step the cursor currently points at (clamped to the live step list). */
|
||||
export function currentStep(s: Extract<IntakeState, { tag: 'Answering' }>): StepId {
|
||||
const steps = visibleSteps(s.answers);
|
||||
return steps[Math.min(s.cursor, steps.length - 1)];
|
||||
}
|
||||
|
||||
/** Validate ONE step's fields. Returns the per-field error keyed by StepId. */
|
||||
function validateStep(step: StepId, a: Answers): Result<Partial<Record<StepId, string>>, void> {
|
||||
switch (step) {
|
||||
case 'buitenland':
|
||||
return a.buitenlandGewerkt ? ok(undefined) : err({ buitenland: 'Maak een keuze.' });
|
||||
case 'buitenlandDetails': {
|
||||
if (!a.land || a.land.trim() === '') return err({ buitenlandDetails: 'Vul een land in.' });
|
||||
const u = parseUren(a.buitenlandseUren ?? '');
|
||||
return u.ok ? ok(undefined) : err({ buitenlandDetails: u.error });
|
||||
}
|
||||
case 'uren': {
|
||||
const u = parseUren(a.uren ?? '');
|
||||
return u.ok ? ok(undefined) : err({ uren: u.error });
|
||||
}
|
||||
case 'scholing':
|
||||
return a.scholingGevolgd ? ok(undefined) : err({ scholing: 'Maak een keuze.' });
|
||||
case 'punten': {
|
||||
const p = parseUren(a.punten ?? '');
|
||||
return p.ok ? ok(undefined) : err({ punten: p.error });
|
||||
}
|
||||
case 'review':
|
||||
return ok(undefined); // review shows a summary; no own fields
|
||||
default:
|
||||
return assertNever(step);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the whole questionnaire into a ValidIntake (called on submit). */
|
||||
function validateAll(a: Answers): Result<Partial<Record<StepId, string>>, ValidIntake> {
|
||||
const errors: Partial<Record<StepId, string>> = {};
|
||||
for (const step of visibleSteps(a)) {
|
||||
const r = validateStep(step, a);
|
||||
if (!r.ok) Object.assign(errors, r.error);
|
||||
}
|
||||
if (Object.keys(errors).length > 0) return err(errors);
|
||||
|
||||
const uren = parseUren(a.uren ?? '');
|
||||
const punten = parseUren(a.punten ?? '');
|
||||
// visibleSteps guaranteed these parse, but keep the compiler happy.
|
||||
if (!uren.ok || !punten.ok) return err(errors);
|
||||
|
||||
const werktBuitenland = a.buitenlandGewerkt === 'ja';
|
||||
const buitenland = parseUren(a.buitenlandseUren ?? '');
|
||||
return ok({
|
||||
werktBuitenland,
|
||||
land: werktBuitenland ? a.land : undefined,
|
||||
buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,
|
||||
uren: uren.value,
|
||||
aanvullendeScholing: lageUren(a) ? a.scholingGevolgd === 'ja' : undefined,
|
||||
punten: punten.value,
|
||||
});
|
||||
}
|
||||
|
||||
export function setAnswer(s: IntakeState, key: keyof Answers, value: string): IntakeState {
|
||||
if (s.tag !== 'Answering') return s;
|
||||
const answers = { ...s.answers, [key]: value };
|
||||
// Editing an earlier answer can shrink the step list; clamp the cursor.
|
||||
const cursor = Math.min(s.cursor, visibleSteps(answers).length - 1);
|
||||
return { ...s, answers, cursor };
|
||||
}
|
||||
|
||||
export function next(s: IntakeState): IntakeState {
|
||||
if (s.tag !== 'Answering') return s;
|
||||
const r = validateStep(currentStep(s), s.answers);
|
||||
if (!r.ok) return { ...s, errors: r.error };
|
||||
const last = visibleSteps(s.answers).length - 1;
|
||||
return { ...s, cursor: Math.min(s.cursor + 1, last), errors: {} };
|
||||
}
|
||||
|
||||
export function back(s: IntakeState): IntakeState {
|
||||
if (s.tag !== 'Answering' || s.cursor === 0) return s;
|
||||
return { ...s, cursor: s.cursor - 1, errors: {} };
|
||||
}
|
||||
|
||||
export function submit(s: IntakeState): IntakeState {
|
||||
if (s.tag !== 'Answering') return s;
|
||||
const r = validateAll(s.answers);
|
||||
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: 'Submit' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed' }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
| { 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 '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 'Seed':
|
||||
return m.state;
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user