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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user