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:
15
README.md
15
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`
|
||||
|
||||
@@ -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["<b>Pages</b><br/>dashboard.page · login.page · intake.page"]
|
||||
T["<b>Templates</b><br/>page-shell · shell"]
|
||||
O["<b>Organisms</b><br/>login-form · registration-table · intake-wizard"]
|
||||
M["<b>Molecules</b><br/>form-field · data-row · async"]
|
||||
A["<b>Atoms</b><br/>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 `<app-async>` 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;<br/>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<br/>(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: `<app-async>`, 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.
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
14
src/app/herregistratie/application/submit-intake.ts
Normal file
14
src/app/herregistratie/application/submit-intake.ts
Normal file
@@ -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<Result<string, void>> {
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.');
|
||||
return ok(undefined);
|
||||
}
|
||||
106
src/app/herregistratie/domain/intake.machine.spec.ts
Normal file
106
src/app/herregistratie/domain/intake.machine.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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') {
|
||||
<p class="rhc-paragraph" style="color:var(--rhc-color-grijs-700)">Stap {{ cursor() + 1 }} van {{ steps().length }}</p>
|
||||
<form (ngSubmit)="onPrimary()" style="max-width:30rem">
|
||||
@switch (step()) {
|
||||
@case ('buitenland') {
|
||||
<app-form-field label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" fieldId="buitenland" [error]="err('buitenland')">
|
||||
<app-radio-group name="buitenland" [options]="jaNee"
|
||||
[ngModel]="answers().buitenlandGewerkt ?? ''" (ngModelChange)="set('buitenlandGewerkt', $event)" />
|
||||
</app-form-field>
|
||||
}
|
||||
@case ('buitenlandDetails') {
|
||||
<app-form-field label="In welk land?" fieldId="land" [error]="err('buitenlandDetails')">
|
||||
<app-text-input inputId="land" [ngModel]="answers().land ?? ''" (ngModelChange)="set('land', $event)" name="land" placeholder="bijv. België" />
|
||||
</app-form-field>
|
||||
<app-form-field label="Hoeveel uur heeft u daar gewerkt?" fieldId="buitenlandseUren">
|
||||
<app-text-input inputId="buitenlandseUren" [ngModel]="answers().buitenlandseUren ?? ''" (ngModelChange)="set('buitenlandseUren', $event)" name="buitenlandseUren" placeholder="bijv. 800" />
|
||||
</app-form-field>
|
||||
}
|
||||
@case ('uren') {
|
||||
<app-form-field label="Gewerkte uren in Nederland (afgelopen 5 jaar)" fieldId="uren" [error]="err('uren')">
|
||||
<app-text-input inputId="uren" [ngModel]="answers().uren ?? ''" (ngModelChange)="set('uren', $event)" name="uren" placeholder="bijv. 4160" />
|
||||
</app-form-field>
|
||||
}
|
||||
@case ('scholing') {
|
||||
<app-form-field label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholing" [error]="err('scholing')">
|
||||
<app-radio-group name="scholing" [options]="jaNee"
|
||||
[ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" />
|
||||
</app-form-field>
|
||||
}
|
||||
@case ('punten') {
|
||||
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="err('punten')">
|
||||
<app-text-input inputId="punten" [ngModel]="answers().punten ?? ''" (ngModelChange)="set('punten', $event)" name="punten" placeholder="bijv. 200" />
|
||||
</app-form-field>
|
||||
}
|
||||
@case ('review') {
|
||||
<app-alert type="info">Controleer uw antwoorden en dien de aanvraag in.</app-alert>
|
||||
<dl class="rhc-data-summary rhc-data-summary--row" style="margin:1rem 0">
|
||||
<div><dt>Buiten NL gewerkt</dt><dd>{{ answers().buitenlandGewerkt ?? '—' }}</dd></div>
|
||||
@if (answers().buitenlandGewerkt === 'ja') {
|
||||
<div><dt>Land</dt><dd>{{ answers().land }}</dd></div>
|
||||
<div><dt>Buitenlandse uren</dt><dd>{{ answers().buitenlandseUren }}</dd></div>
|
||||
}
|
||||
<div><dt>Uren NL</dt><dd>{{ answers().uren }}</dd></div>
|
||||
@if (steps().includes('scholing')) {
|
||||
<div><dt>Aanvullende scholing</dt><dd>{{ answers().scholingGevolgd }}</dd></div>
|
||||
}
|
||||
<div><dt>Nascholingspunten</dt><dd>{{ answers().punten }}</dd></div>
|
||||
</dl>
|
||||
}
|
||||
}
|
||||
<div style="display:flex;gap:0.5rem;margin-top:1rem">
|
||||
@if (cursor() > 0) {
|
||||
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
|
||||
}
|
||||
<app-button type="submit" variant="primary">{{ step() === 'review' ? 'Aanvraag indienen' : 'Volgende' }}</app-button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
@case ('Submitting') {
|
||||
<app-spinner /> <span>Aanvraag wordt verwerkt…</span>
|
||||
}
|
||||
@case ('Submitted') {
|
||||
<app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
|
||||
<div style="margin-top:1rem">
|
||||
<app-button variant="secondary" (click)="restart()">Opnieuw beginnen</app-button>
|
||||
</div>
|
||||
}
|
||||
@case ('Failed') {
|
||||
<app-alert type="error">Indienen mislukt: {{ failedError() }}</app-alert>
|
||||
<div style="margin-top:1rem">
|
||||
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class IntakeWizardComponent {
|
||||
private profile = inject(BigProfileStore);
|
||||
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
|
||||
|
||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||
seed = input<IntakeState>(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<IntakeState, { tag: 'Answering' }>) : null));
|
||||
/** Public so the showcase can render the live step list next to the wizard. */
|
||||
readonly steps = computed<StepId[]>(() => visibleSteps(this.answering()?.answers ?? {}));
|
||||
protected cursor = computed(() => this.answering()?.cursor ?? 0);
|
||||
protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
|
||||
protected step = computed<StepId>(() => this.steps()[Math.min(this.cursor(), this.steps().length - 1)]);
|
||||
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<IntakeState, { tag: 'Failed' }>).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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<IntakeWizardComponent> = {
|
||||
title: 'Herregistratie/IntakeWizard',
|
||||
component: IntakeWizardComponent,
|
||||
// Injects BigProfileStore (optimistic flag) which creates httpResources.
|
||||
decorators: [applicationConfig({ providers: [provideHttpClient()] })],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<IntakeWizardComponent>;
|
||||
|
||||
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' } } };
|
||||
24
src/app/herregistratie/ui/intake.page.ts
Normal file
24
src/app/herregistratie/ui/intake.page.ts
Normal file
@@ -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: `
|
||||
<app-page-shell heading="Herregistratie — intake" backLink="/dashboard">
|
||||
<app-alert type="info">
|
||||
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.
|
||||
</app-alert>
|
||||
<div style="margin-top:1.5rem">
|
||||
<app-intake-wizard />
|
||||
</div>
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class IntakePage {}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,9 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
<p>
|
||||
<app-link to="/herregistratie">Herregistratie aanvragen →</app-link>
|
||||
</p>
|
||||
<p>
|
||||
<app-link to="/intake">Herregistratie-intake (vragenlijst met vertakkingen) →</app-link>
|
||||
</p>
|
||||
<p>
|
||||
<app-link to="/concepts">Functionele patronen (impossible states) →</app-link>
|
||||
</p>
|
||||
|
||||
50
src/app/shared/ui/radio-group/radio-group.component.ts
Normal file
50
src/app/shared/ui/radio-group/radio-group.component.ts
Normal file
@@ -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: `
|
||||
<div class="utrecht-form-field-radio-group" role="radiogroup">
|
||||
@for (opt of options(); track opt.value) {
|
||||
<label class="utrecht-form-label utrecht-form-label--radio-button" style="display:flex;align-items:center;gap:0.5rem;padding:0.25rem 0">
|
||||
<input
|
||||
class="utrecht-radio-button"
|
||||
type="radio"
|
||||
[name]="name()"
|
||||
[value]="opt.value"
|
||||
[checked]="value === opt.value"
|
||||
[disabled]="disabled"
|
||||
(change)="select(opt.value)"
|
||||
(blur)="onTouched()" />
|
||||
{{ opt.label }}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RadioGroupComponent), multi: true }],
|
||||
})
|
||||
export class RadioGroupComponent implements ControlValueAccessor {
|
||||
options = input.required<RadioOption[]>();
|
||||
name = input.required<string>();
|
||||
|
||||
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; }
|
||||
}
|
||||
17
src/app/shared/ui/radio-group/radio-group.stories.ts
Normal file
17
src/app/shared/ui/radio-group/radio-group.stories.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { RadioGroupComponent } from './radio-group.component';
|
||||
|
||||
const meta: Meta<RadioGroupComponent> = {
|
||||
title: 'Atoms/RadioGroup',
|
||||
component: RadioGroupComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-radio-group [options]="options" [name]="name" />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<RadioGroupComponent>;
|
||||
|
||||
export const JaNee: Story = {
|
||||
args: { name: 'voorbeeld', options: [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }] },
|
||||
};
|
||||
@@ -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<T>(status: string, value?: T, error?: Error): Resource<T>
|
||||
@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: `
|
||||
<app-page-shell heading="Onmogelijke toestanden onmogelijk maken" backLink="/dashboard">
|
||||
<app-alert type="info">
|
||||
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).
|
||||
</app-alert>
|
||||
<p class="lead">
|
||||
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).
|
||||
</p>
|
||||
|
||||
<!-- 1. Discriminated unions -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2">1 · Discriminated unions</app-heading>
|
||||
<p class="lead">Laat elke variant precies de gegevens dragen die kloppen — niets meer.</p>
|
||||
<div class="cols">
|
||||
<div class="col">
|
||||
<div class="card card--bad">
|
||||
<p class="tag bad">Fout — vlakke interface</p>
|
||||
<pre>{{ unionBad }}</pre>
|
||||
<p class="rhc-paragraph">Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.</p>
|
||||
<pre [innerHTML]="unionBad"></pre>
|
||||
<p class="note">Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.</p>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card card--good">
|
||||
<p class="tag good">Goed — sum type</p>
|
||||
<app-registration-summary [reg]="doorgehaald" />
|
||||
<p class="rhc-paragraph">De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat simpelweg niet.</p>
|
||||
<p class="note">De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat simpelweg niet.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. RemoteData fold -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2">2 · RemoteData fold</app-heading>
|
||||
<p class="lead">Eén waarde met vier elkaar uitsluitende toestanden in plaats van drie losse booleans.</p>
|
||||
<div class="cols">
|
||||
<div class="col">
|
||||
<p class="tag good">Eén molecuul, vier elkaar uitsluitende toestanden</p>
|
||||
<p class="tag">Loading</p>
|
||||
<div class="card card--good">
|
||||
<p class="tag good">Vier toestanden, één molecuul</p>
|
||||
<p class="tag plain">Loading</p>
|
||||
<app-async [resource]="loadingRes"><ng-template appAsyncLoaded let-v>{{ v }}</ng-template><ng-template appAsyncLoading><app-skeleton [count]="2" height="1.2rem" [delay]="0" /></ng-template></app-async>
|
||||
<p class="tag">Empty</p>
|
||||
<p class="tag plain">Empty</p>
|
||||
<app-async [resource]="emptyRes" [isEmpty]="isEmpty"><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async>
|
||||
<p class="tag">Failure</p>
|
||||
<p class="tag plain">Failure</p>
|
||||
<app-async [resource]="errorRes"><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async>
|
||||
<p class="tag">Success</p>
|
||||
<p class="tag plain">Success</p>
|
||||
<app-async [resource]="successRes" [isEmpty]="isEmpty"><ng-template appAsyncLoaded let-v><ul class="rhc-unordered-list">@for (i of v; track i) {<li>{{ i }}</li>}</ul></ng-template></app-async>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card card--good">
|
||||
<p class="tag good">De exhaustieve fold</p>
|
||||
<pre>{{ foldCode }}</pre>
|
||||
<p class="rhc-paragraph">Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem afhandelt.</p>
|
||||
<pre [innerHTML]="foldCode"></pre>
|
||||
<p class="note">Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem afhandelt.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. Parse, don't validate -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2">3 · Parse, don't validate</app-heading>
|
||||
<p class="lead">Na het parsen onthoudt het <em>type</em> dat de waarde geldig is.</p>
|
||||
<div class="cols">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<p class="tag good">Smart constructor → Result</p>
|
||||
<app-text-input inputId="pc" [ngModel]="raw()" (ngModelChange)="raw.set($event)" name="pc" placeholder="Typ een postcode, bijv. 1234 AB" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card" [class.card--good]="parsed().ok" [class.card--bad]="!parsed().ok">
|
||||
@if (parsed().ok) {
|
||||
<p class="tag good">ok</p>
|
||||
<pre>Postcode = "{{ parsed().ok ? $any(parsed()).value : '' }}"</pre>
|
||||
<p class="rhc-paragraph">Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.</p>
|
||||
<pre>Postcode = "{{ $any(parsed()).value }}"</pre>
|
||||
<p class="note">Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.</p>
|
||||
} @else {
|
||||
<p class="tag bad">err</p>
|
||||
<pre>{{ $any(parsed()).error }}</pre>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. State machine / wizard -->
|
||||
<!-- 4. State machine / wizard (live state diagram) -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2">4 · Form als state machine</app-heading>
|
||||
<p class="lead">Eén tagged union stuurt de UI. Speel met de wizard — de gemarkeerde toestand is de huidige.</p>
|
||||
<div class="cols">
|
||||
<div class="col">
|
||||
<div class="card card--bad">
|
||||
<p class="tag bad">Fout — losse booleans</p>
|
||||
<pre>{{ machineBad }}</pre>
|
||||
<p class="rhc-paragraph">Niets verhindert "submitting" mét validatiefouten of een successcherm met errors.</p>
|
||||
<pre [innerHTML]="machineBad"></pre>
|
||||
<p class="note">Niets verhindert "submitting" mét validatiefouten of een successcherm met errors.</p>
|
||||
</div>
|
||||
<div class="col">
|
||||
<p class="tag good">Goed — één tagged union stuurt de UI</p>
|
||||
<app-herregistratie-wizard />
|
||||
<div class="card card--good">
|
||||
<p class="tag good">Goed — één tagged union</p>
|
||||
<div class="machine">
|
||||
@for (n of ['Editing','Submitting','Submitted','Failed']; track n) {
|
||||
<span class="node" [class.on]="w.state().tag === n">{{ n }}</span>
|
||||
}
|
||||
</div>
|
||||
<app-herregistratie-wizard #w />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5. Branching: steps derived from answers -->
|
||||
<section class="section">
|
||||
<app-heading [level]="2">5 · Vertakkende vragenlijst — "afleiden, niet opslaan"</app-heading>
|
||||
<p class="lead">
|
||||
Welke stappen bestaan is geen opgeslagen toestand maar een <em>pure functie</em> van de antwoorden
|
||||
(<code>visibleSteps(answers)</code>). Antwoord "ja" op buitenland of vul weinig uren in, en de
|
||||
lijst hieronder groeit mee.
|
||||
</p>
|
||||
<div class="cols">
|
||||
<div class="card card--good">
|
||||
<p class="tag good">Live afgeleide stappen</p>
|
||||
<div class="steplist">
|
||||
@for (s of iw.steps(); track s; let last = $last) {
|
||||
<span class="pill" [class.extra]="s === 'buitenlandDetails' || s === 'scholing'">{{ s }}</span>
|
||||
@if (!last) { <span class="arrow">→</span> }
|
||||
}
|
||||
</div>
|
||||
<p class="note">De gestippelde stappen verschijnen alleen door eerdere antwoorden. De voortgang "van N" verandert mee.</p>
|
||||
</div>
|
||||
<div class="card card--good">
|
||||
<p class="tag good">De wizard</p>
|
||||
<app-intake-wizard #iw />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
@@ -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 = `<span class="k">interface</span> Registration {
|
||||
status: <span class="s">'Geregistreerd'</span> | <span class="s">'Doorgehaald'</span>;
|
||||
herregistratieDatum: string; <span class="c">// altijd aanwezig 😬</span>
|
||||
}`;
|
||||
|
||||
foldCode = `foldRemote(rd, {
|
||||
foldCode = `<span class="k">foldRemote</span>(rd, {
|
||||
loading: () => spinner,
|
||||
empty: () => 'geen data',
|
||||
empty: () => <span class="s">'geen data'</span>,
|
||||
failure: (e) => alert(e),
|
||||
success: (v) => render(v),
|
||||
}); // mist er één → compile-fout`;
|
||||
}); <span class="c">// mist er één → compile-fout</span>`;
|
||||
|
||||
machineBad = `submitting = signal(false);
|
||||
submitted = signal(false);
|
||||
errors = signal<...>({});
|
||||
// submitting === true && errors.size > 0 ? 🤷`;
|
||||
machineBad = `submitting = <span class="k">signal</span>(false);
|
||||
submitted = <span class="k">signal</span>(false);
|
||||
errors = <span class="k">signal</span><...>({});
|
||||
<span class="c">// submitting === true && errors.size > 0 ? 🤷</span>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user