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:
@@ -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 -->
|
||||
<app-heading [level]="2">1 · Discriminated unions</app-heading>
|
||||
<div class="cols">
|
||||
<div class="col">
|
||||
<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>
|
||||
<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="card card--bad">
|
||||
<p class="tag bad">Fout — vlakke interface</p>
|
||||
<pre [innerHTML]="unionBad"></pre>
|
||||
<p class="note">Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.</p>
|
||||
</div>
|
||||
<div class="card card--good">
|
||||
<p class="tag good">Goed — sum type</p>
|
||||
<app-registration-summary [reg]="doorgehaald" />
|
||||
<p class="note">De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat simpelweg niet.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. RemoteData fold -->
|
||||
<app-heading [level]="2">2 · RemoteData fold</app-heading>
|
||||
<div class="cols">
|
||||
<div class="col">
|
||||
<p class="tag good">Eén molecuul, vier elkaar uitsluitende toestanden</p>
|
||||
<p class="tag">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>
|
||||
<app-async [resource]="emptyRes" [isEmpty]="isEmpty"><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async>
|
||||
<p class="tag">Failure</p>
|
||||
<app-async [resource]="errorRes"><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async>
|
||||
<p class="tag">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>
|
||||
<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="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 plain">Empty</p>
|
||||
<app-async [resource]="emptyRes" [isEmpty]="isEmpty"><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async>
|
||||
<p class="tag plain">Failure</p>
|
||||
<app-async [resource]="errorRes"><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async>
|
||||
<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="card card--good">
|
||||
<p class="tag good">De exhaustieve fold</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>
|
||||
<div class="col">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. Parse, don't validate -->
|
||||
<app-heading [level]="2">3 · Parse, don't validate</app-heading>
|
||||
<div class="cols">
|
||||
<div class="col">
|
||||
<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" />
|
||||
<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="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="card" [class.card--good]="parsed().ok" [class.card--bad]="!parsed().ok">
|
||||
@if (parsed().ok) {
|
||||
<p class="tag good">ok</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>
|
||||
<div class="col">
|
||||
@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>
|
||||
} @else {
|
||||
<p class="tag bad">err</p>
|
||||
<pre>{{ $any(parsed()).error }}</pre>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. State machine / wizard -->
|
||||
<app-heading [level]="2">4 · Form als state machine</app-heading>
|
||||
<div class="cols">
|
||||
<div class="col">
|
||||
<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>
|
||||
<!-- 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="card card--bad">
|
||||
<p class="tag bad">Fout — losse booleans</p>
|
||||
<pre [innerHTML]="machineBad"></pre>
|
||||
<p class="note">Niets verhindert "submitting" mét validatiefouten of een successcherm met errors.</p>
|
||||
</div>
|
||||
<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>
|
||||
<div class="col">
|
||||
<p class="tag good">Goed — één tagged union stuurt de UI</p>
|
||||
<app-herregistratie-wizard />
|
||||
</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>
|
||||
</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