Add /concepts showcase page

A teaching page pairing each pattern's impossible-state-permitting "before"
with the "after" the type system enforces: discriminated unions, the
RemoteData fold, parse-don't-validate (live), and the wizard state machine.
Composition-only — no new atoms. Linked from the dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 16:56:11 +02:00
parent 80c1b627d0
commit 727253e5f5
3 changed files with 151 additions and 0 deletions

View File

@@ -11,6 +11,7 @@ export const routes: Routes = [
{ path: 'dashboard', loadComponent: () => import('./pages/dashboard/dashboard.page').then(m => m.DashboardPage) },
{ path: 'registratie', loadComponent: () => import('./pages/registration-detail/registration-detail.page').then(m => m.RegistrationDetailPage) },
{ path: 'herregistratie', loadComponent: () => import('./pages/herregistratie/herregistratie.page').then(m => m.HerregistratiePage) },
{ path: 'concepts', loadComponent: () => import('./pages/concepts/concepts.page').then(m => m.ConceptsPage) },
{ path: '**', redirectTo: 'login' },
],
},

View File

@@ -0,0 +1,147 @@
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import type { Resource } from '@angular/core';
import { PageShellComponent } from '../../templates/page-shell/page-shell.component';
import { HeadingComponent } from '../../atoms/heading/heading.component';
import { AlertComponent } from '../../atoms/alert/alert.component';
import { TextInputComponent } from '../../atoms/text-input/text-input.component';
import { ASYNC } from '../../molecules/async/async.component';
import { SkeletonComponent } from '../../atoms/skeleton/skeleton.component';
import { RegistrationSummaryComponent } from '../../organisms/registration-summary/registration-summary.component';
import { HerregistratieWizardComponent } from '../../organisms/herregistratie-wizard/herregistratie-wizard.component';
import { Registration } from '../../core/models';
import { parsePostcode } from '../../core/parse';
/** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
return { value: () => value as T, status: () => status, error: () => error, hasValue: () => value !== undefined, reload: () => {} } as unknown as Resource<T>;
}
/** Teaching showcase: each section pairs the impossible-state-permitting "before"
with the "after" where the type system rules it out. Composition-only. */
@Component({
selector: 'app-concepts-page',
imports: [
FormsModule, PageShellComponent, HeadingComponent, AlertComponent, TextInputComponent,
...ASYNC, SkeletonComponent, RegistrationSummaryComponent, HerregistratieWizardComponent,
],
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 }
`],
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>
<!-- 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>
</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>
<!-- 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>
</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>
<!-- 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" />
</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>
<!-- 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>
</div>
<div class="col">
<p class="tag good">Goed — één tagged union stuurt de UI</p>
<app-herregistratie-wizard />
</div>
</div>
</app-page-shell>
`,
})
export class ConceptsPage {
isEmpty = (v: string[]) => !v || v.length === 0;
doorgehaald: Registration = {
bigNummer: '19012345601', naam: 'Dr. A. (Anna) de Vries', beroep: 'Arts',
registratiedatum: '2012-09-01', geboortedatum: '1985-03-14',
status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' },
};
loadingRes = fakeResource<string[]>('loading');
emptyRes = fakeResource<string[]>('resolved', []);
errorRes = fakeResource<string[]>('error', undefined, new Error('Demo'));
successRes = fakeResource<string[]>('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']);
raw = signal('');
parsed = computed(() => parsePostcode(this.raw()));
unionBad = `interface Registration {
status: 'Geregistreerd' | 'Doorgehaald';
herregistratieDatum: string; // altijd aanwezig 😬
}`;
foldCode = `foldRemote(rd, {
loading: () => spinner,
empty: () => 'geen data',
failure: (e) => alert(e),
success: (v) => render(v),
}); // mist er één → compile-fout`;
machineBad = `submitting = signal(false);
submitted = signal(false);
errors = signal<...>({});
// submitting === true && errors.size > 0 ? 🤷`;
}

View File

@@ -48,6 +48,9 @@ import { Aantekening } from '../../core/models';
<p>
<app-link to="/herregistratie">Herregistratie aanvragen →</app-link>
</p>
<p>
<app-link to="/concepts">Functionele patronen (impossible states) →</app-link>
</p>
</app-page-shell>
`,
})