From 7463efdc2d4732e0fa4476b950d2ddc1774c4caa Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 26 Jun 2026 09:38:26 +0200 Subject: [PATCH] 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 --- README.md | 15 +- docs/ARCHITECTURE.md | 187 ++++++++++++++- src/app/app.routes.ts | 1 + .../application/submit-intake.ts | 14 ++ .../domain/intake.machine.spec.ts | 106 +++++++++ .../herregistratie/domain/intake.machine.ts | 190 +++++++++++++++ .../intake-wizard/intake-wizard.component.ts | 186 +++++++++++++++ .../ui/intake-wizard/intake-wizard.stories.ts | 27 +++ src/app/herregistratie/ui/intake.page.ts | 24 ++ src/app/registratie/domain/registration.ts | 6 +- src/app/registratie/ui/dashboard.page.ts | 3 + .../ui/radio-group/radio-group.component.ts | 50 ++++ .../ui/radio-group/radio-group.stories.ts | 17 ++ src/app/showcase/concepts.page.ts | 219 +++++++++++------- 14 files changed, 960 insertions(+), 85 deletions(-) create mode 100644 src/app/herregistratie/application/submit-intake.ts create mode 100644 src/app/herregistratie/domain/intake.machine.spec.ts create mode 100644 src/app/herregistratie/domain/intake.machine.ts create mode 100644 src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts create mode 100644 src/app/herregistratie/ui/intake-wizard/intake-wizard.stories.ts create mode 100644 src/app/herregistratie/ui/intake.page.ts create mode 100644 src/app/shared/ui/radio-group/radio-group.component.ts create mode 100644 src/app/shared/ui/radio-group/radio-group.stories.ts diff --git a/README.md b/README.md index 1d6514f..c79091c 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,14 @@ npm start # app → http://localhost:4200 npm run storybook # component library, organized by atomic layer ``` -Flow: **Login → Dashboard → Mijn gegevens (wijziging) → Herregistratie**. +Flow: **Login → Dashboard → Mijn gegevens (wijziging) → Herregistratie → Intake**. + +> **New here:** a **branching intake questionnaire** (`/intake`) where later questions +> appear based on earlier answers and progress survives a page reload, plus a visual +> walkthrough of the state-management ideas. See +> **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** for diagrams (atomic-design pyramid, +> the dispatch→reduce→view loop, RemoteData states, and "why not just signals") and a +> section on **connecting to a .NET backend**. ### See every data state (scenario toggle) @@ -71,8 +78,10 @@ Change a component once and every screen that uses it updates. **2. A whole new page = composition, no new components.** `pages/herregistratie/herregistratie.page.ts` is a complete new flow assembled entirely -from existing atoms/molecules/templates — zero new building blocks. That's the payoff: -new screens cost almost nothing. +from existing atoms/molecules/templates — zero new building blocks. The branching +**intake wizard** went further: it needed only **one new atom** (`radio-group`) and **one +new organism** (`intake-wizard`); the form fields, buttons, alerts, spinner and page shell +were all reused. That's the payoff: new screens cost almost nothing. **3. Templates remove per-page boilerplate.** Every page used to repeat its own back-link + heading + intro markup. `page-shell` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5d0f4b6..7d965f3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,6 +25,29 @@ src/app/ showcase/ a teaching page; not a real feature ``` +### The atomic-design hierarchy, visualised + +The UI is built bottom-up: tiny **atoms** combine into **molecules**, which combine +into **organisms**, which fill **templates**, which become **pages**. Each level only +ever uses the level(s) below it — so anything you build is reusable by everything above. + +```mermaid +graph TD + P["Pages
dashboard.page · login.page · intake.page"] + T["Templates
page-shell · shell"] + O["Organisms
login-form · registration-table · intake-wizard"] + M["Molecules
form-field · data-row · async"] + A["Atoms
button · text-input · radio-group · alert · heading"] + P --> T --> O --> M --> A + classDef l fill:#e5f1fb,stroke:#007bc7,color:#00567d; + class P,T,O,M,A l; +``` + +Adding the branching intake wizard needed **one new atom** (`radio-group`) and **one new +organism** (`intake-wizard`) — everything else (`form-field`, `text-input`, `button`, +`alert`, `spinner`, the page shell) was reused unchanged. That is the payoff of the +hierarchy. + Inside a context you'll see the same four folders. They answer four different questions: @@ -61,6 +84,41 @@ that's shown while an error is also showing, a "submit" that fires while a field is invalid. The whole strategy here is: **make those impossible by choosing better types.** Three tools do the work. +### Why not "just signals"? + +You *can* track a network call with three signals — `isLoading`, `error`, `data`. The +problem is the **state space**: three booleans is 2³ = **8** combinations, and most are +nonsense the compiler still lets you write. A single discriminated union has **exactly +the 4 states that are real** — the illegal ones can't be expressed at all. + +```mermaid +graph LR + subgraph bad["3 booleans = 8 states (most illegal)"] + direction TB + b1["loading ✓ · error ✗ · data ✗ ✅"] + b2["loading ✗ · error ✓ · data ✗ ✅"] + b3["loading ✗ · error ✗ · data ✓ ✅"] + b4["loading ✓ · error ✓ · data ✓ ❌ nonsense"] + b5["loading ✓ · error ✗ · data ✓ ❌ nonsense"] + b6["… 3 more illegal combos ❌"] + end + subgraph good["1 union = 4 legal states"] + direction TB + g1["Loading"] + g2["Empty"] + g3["Failure (carries error)"] + g4["Success (carries value)"] + end + bad -->|"choose a better type"| good + classDef ok fill:#e8f5e9,stroke:#39870c; classDef no fill:#fdecea,stroke:#d52b1e; + class b1,b2,b3,g1,g2,g3,g4 ok; class b4,b5,b6 no; +``` + +The same argument applies to forms (a `submitting` boolean that can be true *with* +validation errors) and to the branching wizard (don't store "which step is next" — it can +drift out of sync with the answers; **derive** it instead, see §5). Signals are still the +engine underneath; we just give them types that can't lie. + ### 2a. `RemoteData` — one value instead of three booleans The naive way to track a network call: @@ -96,6 +154,25 @@ To use it, you handle every case once. The `` component one. There's also `foldRemote(rd, { loading, empty, failure, success })` for doing the same in TypeScript — the compiler makes you cover all four. +```mermaid +stateDiagram-v2 + [*] --> Loading: fetch starts + Loading --> Success: data arrived + Loading --> Empty: arrived, but no rows + Loading --> Failure: request failed + Failure --> Loading: reload() + note right of Success + value lives ONLY here + end note + note right of Failure + error lives ONLY here + end note +``` + +`map2` (§2b) combines two of these into one: **Failure if either failed, Loading if either +is still loading, Success only when both succeeded** — so a page renders one state, never a +contradictory mix. + > **FP term:** a *pure function* is one whose output depends only on its inputs > and which changes nothing else (no network, no writing to variables outside > it). Pure functions are easy to test and reason about. We push impure things @@ -135,6 +212,23 @@ This is the "Elm-style" pattern. The idea in one sentence: Why bother? Because to understand *every* way the screen can change, you read *one* function. No state is mutated anywhere else. +```mermaid +sequenceDiagram + actor User + participant View as View (template) + participant Store as createStore (signal) + participant Reduce as reduce() — PURE + User->>View: clicks / types + View->>Store: dispatch(msg) + Store->>Reduce: reduce(model, msg) + Reduce-->>Store: next model + Store-->>View: signal updates → re-render + Note over Reduce: the ONLY place state changes;
no HTTP, no timers, no mutation +``` + +Side effects (HTTP) sit *outside* this loop: a command does the I/O, then `dispatch`es a +message describing the outcome (§2d). So the reducer stays pure and testable. + The wizard (`herregistratie/domain/herregistratie.machine.ts`) is the clearest example. Its Model is a discriminated union: @@ -256,9 +350,100 @@ baked into the type. Same idea for `Uren` and `BigNummer`. If you're tempted to add a third boolean to track state — stop and model it as a discriminated union instead. +> **Worked example — the branching intake wizard** (`herregistratie/`). Domain first: +> `intake.machine.ts` is one tagged union plus a pure `reduce` and a pure +> `visibleSteps(answers)`. A command `submit-intake.ts` does the I/O. UI last: +> `intake-wizard.component.ts` (organism) is built from `form-field`, `text-input` and the +> new `radio-group` atom; `intake.page.ts` assembles it. No new state library, no booleans. + --- -## 5. Mini-glossary +## 5. Branching by *deriving*, not storing + +The intake wizard (`herregistratie/domain/intake.machine.ts`) shows the most important +state-management habit: **don't store what you can derive.** Naively you'd track "which +step is next" in a field and update it by hand on every answer — and the moment an earlier +answer changes, that field is stale. Instead, the set of steps is a pure function of the +answers: + +```ts +function visibleSteps(a: Answers): StepId[] { + const steps: StepId[] = ['buitenland']; + if (a.buitenlandGewerkt === 'ja') steps.push('buitenlandDetails'); // branch appears + steps.push('uren'); + if (lageUren(a)) steps.push('scholing'); // branch appears + steps.push('punten', 'review'); + return steps; +} +``` + +The state keeps only the raw `answers` and a numeric `cursor`; the visible step is +`visibleSteps(answers)[cursor]`. Change "buiten Nederland gewerkt?" to *ja* and the country +question simply exists; change it back and it's gone — the cursor is clamped to the new +list. There's no synchronisation code to get wrong, and `visibleSteps` is a one-line unit +test. Answers persist to `localStorage` (an `effect` in the component) so a reload resumes +where the user left off. + +```mermaid +stateDiagram-v2 + [*] --> Answering + Answering --> Answering: SetAnswer / Next / Back
(steps re-derived each time) + Answering --> Submitting: Submit (all answers valid) + Submitting --> Submitted: ok + Submitting --> Failed: error + Failed --> Submitting: Retry +``` + +See it live on `/concepts` (section 5) — the step list and the "stap N van M" counter +update as you type. + +--- + +## 6. Connecting to a .NET backend + +Today the adapters read static JSON (`mock/*.json`). Because `infrastructure/` is the only +layer that touches the network — the **anti-corruption boundary** — pointing the app at a +real ASP.NET API touches *only these files*. Domain, application and UI don't change. + +The one concrete change per adapter: a **DTO** type matching the .NET response, a +`toDomain` mapper, and a real URL. + +```ts +// infrastructure/big-register.adapter.ts + +// 1) Shape exactly as ASP.NET serialises it (camelCase via the default JsonSerializer). +interface RegistrationDto { + bigNumber: string; + name: string; + status: 'Registered' | 'Suspended' | 'StruckOff'; + reregistrationDate?: string; + // … +} + +// 2) Map the wire shape to our domain union (this is the anti-corruption layer). +function toDomain(dto: RegistrationDto): Registration { /* build the tagged union */ } + +// 3) Same httpResource, real endpoint instead of mock/registration.json. +registrationResource() { + return httpResource(() => `${environment.apiBaseUrl}/registrations/me`, { parse: toDomain }); +} +``` + +Practical notes, kept lazy: + +- **Base URL** via Angular environments (`environment.apiBaseUrl`); `proxy.conf.json` in dev + to avoid CORS, or enable CORS on the .NET side for the SPA origin. +- **Auth**: send the bearer/cookie with an `HttpInterceptor` (the existing + `scenario.interceptor.ts` shows the pattern — replace or disable it for the real API). +- **The contract**: start with **hand-written DTOs** (shown above) — zero tooling. When the + API surface grows, generate a typed client from the .NET **OpenAPI/Swagger** document + (e.g. NSwag) so the DTOs stay in sync automatically. Either way, keep `toDomain` as the + single place the wire format meets our types. +- Nothing else moves: ``, the stores, and every page keep working unchanged. + +--- + +## 7. Mini-glossary - **Pure function** — output depends only on inputs; no side effects. Easy to test. - **Discriminated / tagged union (sum type)** — a value that is exactly one of several labelled shapes (`{ tag: 'A'; ... } | { tag: 'B'; ... }`). The `tag` says which; each shape carries only the data that makes sense for it. diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 7406f30..21ca4ea 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -12,6 +12,7 @@ export const routes: Routes = [ { path: 'dashboard', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/dashboard.page').then(m => m.DashboardPage) }, { path: 'registratie', canActivate: [authGuard], loadComponent: () => import('@registratie/ui/registration-detail.page').then(m => m.RegistrationDetailPage) }, { path: 'herregistratie', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/herregistratie.page').then(m => m.HerregistratiePage) }, + { path: 'intake', canActivate: [authGuard], loadComponent: () => import('@herregistratie/ui/intake.page').then(m => m.IntakePage) }, { path: 'concepts', loadComponent: () => import('./showcase/concepts.page').then(m => m.ConceptsPage) }, { path: '**', redirectTo: 'login' }, ], diff --git a/src/app/herregistratie/application/submit-intake.ts b/src/app/herregistratie/application/submit-intake.ts new file mode 100644 index 0000000..cc6e20e --- /dev/null +++ b/src/app/herregistratie/application/submit-intake.ts @@ -0,0 +1,14 @@ +import { Result, ok, err } from '@shared/kernel/fp'; +import { ValidIntake } from '../domain/intake.machine'; + +/** + * Command: send the intake questionnaire to the backend. Returns a Result so the + * caller branches on success/failure without try/catch. ponytail: faked with a + * timer; swap for a real POST when there's an API. The "uren must be > 0" rule + * lets the demo show the failure path. + */ +export async function submitIntake(data: ValidIntake): Promise> { + await new Promise((r) => setTimeout(r, 800)); + if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.'); + return ok(undefined); +} diff --git a/src/app/herregistratie/domain/intake.machine.spec.ts b/src/app/herregistratie/domain/intake.machine.spec.ts new file mode 100644 index 0000000..b2eaf48 --- /dev/null +++ b/src/app/herregistratie/domain/intake.machine.spec.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { ok, err } from '@shared/kernel/fp'; +import { + Answers, + initial, + visibleSteps, + currentStep, + next, + back, + submit, + resolve, + reduce, + IntakeState, +} from './intake.machine'; + +const answering = (answers: Answers, cursor = 0): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {} }); + +describe('visibleSteps (the branching)', () => { + it('asks only buitenland/uren/punten/review by default', () => { + expect(visibleSteps({})).toEqual(['buitenland', 'uren', 'punten', 'review']); + }); + + it('adds the buitenlandDetails step when worked abroad', () => { + expect(visibleSteps({ buitenlandGewerkt: 'ja' })).toContain('buitenlandDetails'); + expect(visibleSteps({ buitenlandGewerkt: 'nee' })).not.toContain('buitenlandDetails'); + }); + + it('adds the scholing step only when NL-hours are below the threshold', () => { + expect(visibleSteps({ uren: '500' })).toContain('scholing'); + expect(visibleSteps({ uren: '4160' })).not.toContain('scholing'); + }); +}); + +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.buitenland).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('uren'); + }); + + it('keeps the cursor in range when an answer collapses a branch', () => { + // Worked abroad, cursor sitting on the extra detail step (index 1)... + const collapsed = reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }); + // ...the detail step is gone; cursor must not point past the new shorter list. + expect((collapsed as any).cursor).toBeLessThan(visibleSteps((collapsed as any).answers).length); + }); + + it('Back never goes below the first step', () => { + expect(back(initial)).toBe(initial); + }); +}); + +describe('submit', () => { + const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160', punten: '200' }; + + it('reaches Submitting ONLY with valid answers', () => { + expect(submit(answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: 'x' })).tag).toBe('Answering'); + const good = submit(answering(complete)); + expect(good.tag).toBe('Submitting'); + expect((good as any).data.uren).toBe(4160); + }); + + it('low hours requires the scholing answer before submit', () => { + const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500', punten: '200' })); + expect(noScholing.tag).toBe('Answering'); // scholing step 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); + }); + + it('resolve maps Submitting to Submitted / Failed', () => { + const submitting = submit(answering(complete)); + expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted'); + 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; + s = reduce(s, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' }); + s = reduce(s, { tag: 'Next' }); + expect(currentStep(s as any)).toBe('buitenlandDetails'); + 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('uren'); + s = reduce(s, { tag: 'SetAnswer', key: 'uren', value: '4160' }); + s = reduce(s, { tag: 'Next' }); + expect(currentStep(s as any)).toBe('punten'); + 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'); + }); +}); diff --git a/src/app/herregistratie/domain/intake.machine.ts b/src/app/herregistratie/domain/intake.machine.ts new file mode 100644 index 0000000..d6db0eb --- /dev/null +++ b/src/app/herregistratie/domain/intake.machine.ts @@ -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> } + | { 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): 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>, 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>, ValidIntake> { + const errors: Partial> = {}; + 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): 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); + } +} diff --git a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts new file mode 100644 index 0000000..4a42f1e --- /dev/null +++ b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts @@ -0,0 +1,186 @@ +import { Component, computed, effect, inject, input } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; +import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; +import { RadioGroupComponent } from '@shared/ui/radio-group/radio-group.component'; +import { ButtonComponent } from '@shared/ui/button/button.component'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; +import { SpinnerComponent } from '@shared/ui/spinner/spinner.component'; +import { createStore } from '@shared/application/store'; +import { BigProfileStore } from '@registratie/application/big-profile.store'; +import { + IntakeState, + IntakeMsg, + Answers, + StepId, + initial, + reduce, + visibleSteps, +} from '@herregistratie/domain/intake.machine'; +import { submitIntake } from '@herregistratie/application/submit-intake'; + +const STORAGE_KEY = 'intake-v1'; // ponytail: bump the suffix if the Answers shape changes; no migration. +const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }]; + +/** Organism: a BRANCHING intake questionnaire. All state lives in one signal + driven by the pure `reduce` (intake.machine.ts). Which step renders is derived + from the answers via `visibleSteps`, never stored — so editing an earlier + answer immediately changes the remaining steps. Answers are persisted to + localStorage so a page reload keeps the user's progress. */ +@Component({ + selector: 'app-intake-wizard', + imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, AlertComponent, SpinnerComponent], + template: ` + @switch (state().tag) { + @case ('Answering') { +

Stap {{ cursor() + 1 }} van {{ steps().length }}

+
+ @switch (step()) { + @case ('buitenland') { + + + + } + @case ('buitenlandDetails') { + + + + + + + } + @case ('uren') { + + + + } + @case ('scholing') { + + + + } + @case ('punten') { + + + + } + @case ('review') { + Controleer uw antwoorden en dien de aanvraag in. +
+
Buiten NL gewerkt
{{ answers().buitenlandGewerkt ?? '—' }}
+ @if (answers().buitenlandGewerkt === 'ja') { +
Land
{{ answers().land }}
+
Buitenlandse uren
{{ answers().buitenlandseUren }}
+ } +
Uren NL
{{ answers().uren }}
+ @if (steps().includes('scholing')) { +
Aanvullende scholing
{{ answers().scholingGevolgd }}
+ } +
Nascholingspunten
{{ answers().punten }}
+
+ } + } +
+ @if (cursor() > 0) { + Vorige + } + {{ step() === 'review' ? 'Aanvraag indienen' : 'Volgende' }} +
+
+ } + @case ('Submitting') { + Aanvraag wordt verwerkt… + } + @case ('Submitted') { + Uw aanvraag tot herregistratie is ontvangen. +
+ Opnieuw beginnen +
+ } + @case ('Failed') { + Indienen mislukt: {{ failedError() }} +
+ Opnieuw proberen +
+ } + } + `, +}) +export class IntakeWizardComponent { + private profile = inject(BigProfileStore); + private store = createStore(initial, reduce); + + /** Optional seed so Storybook / the showcase can mount any state directly. */ + seed = input(initial); + + readonly jaNee = JA_NEE; + readonly state = this.store.model; + readonly dispatch = this.store.dispatch; + + private answering = computed(() => (this.state().tag === 'Answering' ? (this.state() as Extract) : null)); + /** Public so the showcase can render the live step list next to the wizard. */ + readonly steps = computed(() => visibleSteps(this.answering()?.answers ?? {})); + protected cursor = computed(() => this.answering()?.cursor ?? 0); + protected answers = computed(() => this.answering()?.answers ?? {}); + protected step = computed(() => this.steps()[Math.min(this.cursor(), this.steps().length - 1)]); + protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract).error : '')); + + protected err = (k: StepId) => this.answering()?.errors[k] ?? ''; + protected set = (key: keyof Answers, value: string) => this.dispatch({ tag: 'SetAnswer', key, value }); + + constructor() { + // An explicit seed (stories) wins; otherwise resume from localStorage. + const seeded = this.seed(); + queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) })); + // Persist only while answering; clear once the flow is done. + effect(() => { + const s = this.state(); + if (s.tag === 'Answering') localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); + else localStorage.removeItem(STORAGE_KEY); + }); + } + + private restore(): IntakeState | null { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + try { + return JSON.parse(raw) as IntakeState; + } catch { + return null; // ponytail: corrupt entry -> start fresh, no migration. + } + } + + onPrimary() { + const s = this.state(); + if (s.tag !== 'Answering') return; + this.dispatch(this.step() === 'review' ? { tag: 'Submit' } : { tag: 'Next' }); + this.runIfSubmitting(); + } + + onRetry() { + this.dispatch({ tag: 'Retry' }); + this.runIfSubmitting(); + } + + restart() { + this.dispatch({ tag: 'Seed', state: initial }); + } + + /** The effect: when we enter Submitting, call the backend, flip the optimistic + cross-page flag, then dispatch the outcome (and commit/rollback). */ + private async runIfSubmitting() { + const s = this.state(); + if (s.tag !== 'Submitting') return; + this.profile.beginHerregistratie(); + const r = await submitIntake(s.data); + if (r.ok) { + this.dispatch({ tag: 'SubmitConfirmed' }); + this.profile.confirmHerregistratie(); + } else { + this.dispatch({ tag: 'SubmitFailed', error: r.error }); + this.profile.rollbackHerregistratie(); + } + } +} diff --git a/src/app/herregistratie/ui/intake-wizard/intake-wizard.stories.ts b/src/app/herregistratie/ui/intake-wizard/intake-wizard.stories.ts new file mode 100644 index 0000000..a6b0b46 --- /dev/null +++ b/src/app/herregistratie/ui/intake-wizard/intake-wizard.stories.ts @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { applicationConfig } from '@storybook/angular'; +import { provideHttpClient } from '@angular/common/http'; +import { IntakeWizardComponent } from './intake-wizard.component'; +import { IntakeState, Answers } from '@herregistratie/domain/intake.machine'; +import { Uren } from '@registratie/domain/value-objects/uren'; + +const validData = { werktBuitenland: false, uren: 4160 as Uren, punten: 200 as Uren }; + +const meta: Meta = { + title: 'Herregistratie/IntakeWizard', + component: IntakeWizardComponent, + // Injects BigProfileStore (optimistic flag) which creates httpResources. + decorators: [applicationConfig({ providers: [provideHttpClient()] })], +}; +export default meta; +type Story = StoryObj; + +const answering = (answers: Answers, cursor = 0): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {} }); + +export const Start: Story = { args: { seed: answering({}) } }; +export const AbroadBranch: Story = { args: { seed: answering({ buitenlandGewerkt: 'ja' }, 1) } }; +export const LowHoursScholing: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '500' }, 2) } }; +export const Review: Story = { args: { seed: answering({ buitenlandGewerkt: 'nee', uren: '4160', punten: '200' }, 3) } }; +export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } }; +export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } }; +export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } }; diff --git a/src/app/herregistratie/ui/intake.page.ts b/src/app/herregistratie/ui/intake.page.ts new file mode 100644 index 0000000..058820d --- /dev/null +++ b/src/app/herregistratie/ui/intake.page.ts @@ -0,0 +1,24 @@ +import { Component } from '@angular/core'; +import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; +import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component'; + +/** Page: the branching intake questionnaire. Built entirely from existing + building blocks (page shell + alert + the intake-wizard organism). */ +@Component({ + selector: 'app-intake-page', + imports: [PageShellComponent, AlertComponent, IntakeWizardComponent], + template: ` + + + Een paar vragen bepalen welke gegevens we nodig hebben. Afhankelijk van uw + antwoorden verschijnen er extra vragen. Uw antwoorden blijven bewaard als u + de pagina herlaadt. + +
+ +
+
+ `, +}) +export class IntakePage {} diff --git a/src/app/registratie/domain/registration.ts b/src/app/registratie/domain/registration.ts index fa5dd4d..bc8cfe0 100644 --- a/src/app/registratie/domain/registration.ts +++ b/src/app/registratie/domain/registration.ts @@ -22,8 +22,12 @@ export interface Registration { status: RegistrationStatus; } +/** A note is either a recognised specialism or a plain annotation — a closed set, + not an open string, so a typo can't slip through. */ +export type AantekeningType = 'Specialisme' | 'Aantekening'; + export interface Aantekening { - type: string; // specialisme of aantekening + type: AantekeningType; omschrijving: string; datum: string; } diff --git a/src/app/registratie/ui/dashboard.page.ts b/src/app/registratie/ui/dashboard.page.ts index f0725a4..d395e4b 100644 --- a/src/app/registratie/ui/dashboard.page.ts +++ b/src/app/registratie/ui/dashboard.page.ts @@ -61,6 +61,9 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';

Herregistratie aanvragen →

+

+ Herregistratie-intake (vragenlijst met vertakkingen) → +

Functionele patronen (impossible states) →

diff --git a/src/app/shared/ui/radio-group/radio-group.component.ts b/src/app/shared/ui/radio-group/radio-group.component.ts new file mode 100644 index 0000000..93031a0 --- /dev/null +++ b/src/app/shared/ui/radio-group/radio-group.component.ts @@ -0,0 +1,50 @@ +import { Component, forwardRef, input } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +export interface RadioOption { + value: string; + label: string; +} + +/** Atom: a radio group. Thin wrapper over the Utrecht/RHC radio CSS, wired as a + form control so it works with ngModel just like the text-input atom. */ +@Component({ + selector: 'app-radio-group', + template: ` +
+ @for (opt of options(); track opt.value) { + + } +
+ `, + providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RadioGroupComponent), multi: true }], +}) +export class RadioGroupComponent implements ControlValueAccessor { + options = input.required(); + name = input.required(); + + value = ''; + disabled = false; + onChange: (v: string) => void = () => {}; + onTouched: () => void = () => {}; + + select(v: string) { + this.value = v; + this.onChange(v); + } + writeValue(v: string) { this.value = v ?? ''; } + registerOnChange(fn: (v: string) => void) { this.onChange = fn; } + registerOnTouched(fn: () => void) { this.onTouched = fn; } + setDisabledState(d: boolean) { this.disabled = d; } +} diff --git a/src/app/shared/ui/radio-group/radio-group.stories.ts b/src/app/shared/ui/radio-group/radio-group.stories.ts new file mode 100644 index 0000000..bf895e4 --- /dev/null +++ b/src/app/shared/ui/radio-group/radio-group.stories.ts @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { RadioGroupComponent } from './radio-group.component'; + +const meta: Meta = { + title: 'Atoms/RadioGroup', + component: RadioGroupComponent, + render: (args) => ({ + props: args, + template: ``, + }), +}; +export default meta; +type Story = StoryObj; + +export const JaNee: Story = { + args: { name: 'voorbeeld', options: [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }] }, +}; diff --git a/src/app/showcase/concepts.page.ts b/src/app/showcase/concepts.page.ts index 1fd8e93..7505782 100644 --- a/src/app/showcase/concepts.page.ts +++ b/src/app/showcase/concepts.page.ts @@ -3,12 +3,12 @@ import { FormsModule } from '@angular/forms'; import type { Resource } from '@angular/core'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component'; -import { AlertComponent } from '@shared/ui/alert/alert.component'; import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { ASYNC } from '@shared/ui/async/async.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component'; import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component'; +import { IntakeWizardComponent } from '@herregistratie/ui/intake-wizard/intake-wizard.component'; import { Registration } from '@registratie/domain/registration'; import { parsePostcode } from '@registratie/domain/value-objects/postcode'; @@ -22,92 +22,151 @@ function fakeResource(status: string, value?: T, error?: Error): Resource @Component({ selector: 'app-concepts-page', imports: [ - FormsModule, PageShellComponent, HeadingComponent, AlertComponent, TextInputComponent, - ...ASYNC, SkeletonComponent, RegistrationSummaryComponent, HerregistratieWizardComponent, + FormsModule, PageShellComponent, HeadingComponent, TextInputComponent, + ...ASYNC, SkeletonComponent, RegistrationSummaryComponent, HerregistratieWizardComponent, IntakeWizardComponent, ], styles: [` - .cols { display:flex; flex-wrap:wrap; gap:1.5rem; margin:1rem 0 2.5rem } - .col { flex:1 1 20rem; min-width:18rem } - .tag { font-weight:700; font-size:0.8rem; text-transform:uppercase; letter-spacing:0.04em } - .bad { color:var(--rhc-color-rood-500) } - .good { color:var(--rhc-color-groen-500) } - pre { background:var(--rhc-color-grijs-100,#f3f3f3); padding:1rem; border-radius:4px; overflow:auto; font-size:0.85rem } + .section { margin: 0 0 3rem } + .lead { color: var(--rhc-color-grijs-700); max-width: 46rem; margin: 0.25rem 0 1.25rem } + .cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 1.5rem; align-items: start } + .card { border: 1px solid var(--rhc-color-grijs-200, #e5e5e5); border-radius: 10px; padding: 1.25rem; background: #fff } + .card--bad { border-color: var(--rhc-color-rood-300, #f0b4b4) } + .card--good { border-color: var(--rhc-color-groen-300, #b4e0b4) } + .tag { display: inline-flex; align-items: center; gap: 0.4rem; font-weight: 700; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 0.75rem } + .tag::before { content: ''; width: 0.6rem; height: 0.6rem; border-radius: 50% } + .tag.bad { color: var(--rhc-color-rood-600, #a30000) } .tag.bad::before { background: var(--rhc-color-rood-500, #d52b1e) } + .tag.good { color: var(--rhc-color-groen-700, #277337) } .tag.good::before { background: var(--rhc-color-groen-500, #39870c) } + .tag.plain { color: var(--rhc-color-grijs-700) } .tag.plain::before { display: none } + pre { background: #1e2430; color: #e6e9ef; padding: 1rem; border-radius: 8px; overflow: auto; font-size: 0.82rem; line-height: 1.55; margin: 0 } + pre .k { color: #c792ea } pre .s { color: #c3e88d } pre .c { color: #7e8aa0; font-style: italic } + .note { font-size: 0.9rem; color: var(--rhc-color-grijs-700); margin: 0.75rem 0 0 } + /* live state diagram */ + .machine { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0 0 1rem } + .node { padding: 0.4rem 0.8rem; border-radius: 999px; border: 1px solid var(--rhc-color-grijs-300, #ccc); font-size: 0.82rem; color: var(--rhc-color-grijs-700); transition: all .15s } + .node.on { background: var(--rhc-color-hemelblauw-100, #e5f1fb); border-color: var(--rhc-color-hemelblauw-500, #007bc7); color: var(--rhc-color-hemelblauw-700, #00567d); font-weight: 700 } + .steplist { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; margin: 0 0 1rem } + .pill { padding: 0.3rem 0.7rem; border-radius: 8px; background: var(--rhc-color-grijs-100, #f3f3f3); font-size: 0.8rem } + .pill.extra { background: var(--rhc-color-geel-100, #fff6d6); border: 1px dashed var(--rhc-color-geel-600, #c79a00) } + .arrow { color: var(--rhc-color-grijs-400, #999) } `], template: ` - - Vier functionele patronen die met atomic design makkelijker te tonen zijn — telkens "fout" - (de oude vorm liet het toe) naast "goed" (het type maakt het onmogelijk). - +

+ Vijf functionele patronen die atomic design makkelijker maakt om te tonen — telkens + "fout" (de oude vorm liet het toe) naast "goed" (het type maakt het onmogelijk). +

- 1 · Discriminated unions -
-
-

Fout — vlakke interface

-
{{ unionBad }}
-

Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.

+
+ 1 · Discriminated unions +

Laat elke variant precies de gegevens dragen die kloppen — niets meer.

+
+
+

Fout — vlakke interface

+

+            

Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.

+
+
+

Goed — sum type

+ +

De variant Doorgehaald kent geen herregistratiedatum, dus de rij bestaat simpelweg niet.

+
-
-

Goed — sum type

- -

De variant Doorgehaald kent geen herregistratiedatum, dus de rij bestaat simpelweg niet.

-
-
+ - 2 · RemoteData fold -
-
-

Eén molecuul, vier elkaar uitsluitende toestanden

-

Loading

- {{ v }} -

Empty

- {{ v }} -

Failure

- {{ v }} -

Success

-
    @for (i of v; track i) {
  • {{ i }}
  • }
+
+ 2 · RemoteData fold +

Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.

+
+
+

Vier toestanden, één molecuul

+

Loading

+ {{ v }} +

Empty

+ {{ v }} +

Failure

+ {{ v }} +

Success

+
    @for (i of v; track i) {
  • {{ i }}
  • }
+
+
+

De exhaustieve fold

+

+            

Een nieuwe variant toevoegen breekt de compile via assertNever tot je hem afhandelt.

+
-
-

De exhaustieve fold

-
{{ foldCode }}
-

Een nieuwe variant toevoegen breekt de compile via assertNever tot je hem afhandelt.

-
-
+ - 3 · Parse, don't validate -
-
-

Smart constructor → Result

- +
+ 3 · Parse, don't validate +

Na het parsen onthoudt het type dat de waarde geldig is.

+
+
+

Smart constructor → Result

+ +
+
+ @if (parsed().ok) { +

ok

+
Postcode = "{{ $any(parsed()).value }}"
+

Een gevalideerde Postcode is een ander type dan een ruwe string.

+ } @else { +

err

+
{{ $any(parsed()).error }}
+ } +
-
- @if (parsed().ok) { -

ok

-
Postcode = "{{ parsed().ok ? $any(parsed()).value : '' }}"
-

Een gevalideerde Postcode is een ander type dan een ruwe string.

- } @else { -

err

-
{{ $any(parsed()).error }}
- } -
-
+ - - 4 · Form als state machine -
-
-

Fout — losse booleans

-
{{ machineBad }}
-

Niets verhindert "submitting" mét validatiefouten of een successcherm met errors.

+ +
+ 4 · Form als state machine +

Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de huidige.

+
+
+

Fout — losse booleans

+

+            

Niets verhindert "submitting" mét validatiefouten of een successcherm met errors.

+
+
+

Goed — één tagged union

+
+ @for (n of ['Editing','Submitting','Submitted','Failed']; track n) { + {{ n }} + } +
+ +
-
-

Goed — één tagged union stuurt de UI

- +
+ + +
+ 5 · Vertakkende vragenlijst — "afleiden, niet opslaan" +

+ Welke stappen bestaan is geen opgeslagen toestand maar een pure functie van de antwoorden + (visibleSteps(answers)). Antwoord "ja" op buitenland of vul weinig uren in, en de + lijst hieronder groeit mee. +

+
+
+

Live afgeleide stappen

+
+ @for (s of iw.steps(); track s; let last = $last) { + {{ s }} + @if (!last) { } + } +
+

De gestippelde stappen verschijnen alleen door eerdere antwoorden. De voortgang "van N" verandert mee.

+
+
+

De wizard

+ +
-
+ `, }) @@ -128,20 +187,20 @@ export class ConceptsPage { raw = signal(''); parsed = computed(() => parsePostcode(this.raw())); - unionBad = `interface Registration { - status: 'Geregistreerd' | 'Doorgehaald'; - herregistratieDatum: string; // altijd aanwezig 😬 + unionBad = `interface Registration { + status: 'Geregistreerd' | 'Doorgehaald'; + herregistratieDatum: string; // altijd aanwezig 😬 }`; - foldCode = `foldRemote(rd, { + foldCode = `foldRemote(rd, { loading: () => spinner, - empty: () => 'geen data', + empty: () => 'geen data', failure: (e) => alert(e), success: (v) => render(v), -}); // mist er één → compile-fout`; +}); // mist er één → compile-fout`; - machineBad = `submitting = signal(false); -submitted = signal(false); -errors = signal<...>({}); -// submitting === true && errors.size > 0 ? 🤷`; + machineBad = `submitting = signal(false); +submitted = signal(false); +errors = signal<...>({}); +// submitting === true && errors.size > 0 ? 🤷`; }