diff --git a/src/app/organisms/herregistratie-wizard/herregistratie-wizard.component.ts b/src/app/organisms/herregistratie-wizard/herregistratie-wizard.component.ts new file mode 100644 index 0000000..37e6991 --- /dev/null +++ b/src/app/organisms/herregistratie-wizard/herregistratie-wizard.component.ts @@ -0,0 +1,98 @@ +import { Component, computed, input, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { FormFieldComponent } from '../../molecules/form-field/form-field.component'; +import { TextInputComponent } from '../../atoms/text-input/text-input.component'; +import { ButtonComponent } from '../../atoms/button/button.component'; +import { AlertComponent } from '../../atoms/alert/alert.component'; +import { SpinnerComponent } from '../../atoms/spinner/spinner.component'; +import { ok } from '../../core/fp'; +import { WizardState, Draft, initial, next, back, submit, resolve } from './wizard.machine'; + +/** Organism: multi-step herregistratie wizard driven entirely by a state + machine (wizard.machine.ts). The signal IS the union, so the UI just folds + over its tag — no booleans like `submitting`/`submitted` that could contradict + each other. Composition-only: reuses existing form-field/input/button/alert. */ +@Component({ + selector: 'app-herregistratie-wizard', + imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, AlertComponent, SpinnerComponent], + template: ` + @switch (state().tag) { + @case ('Editing') { +

Stap {{ step() }} van 2

+
+ @if (step() === 1) { + + + + Volgende + } @else { + + + +
+ Vorige + Herregistratie aanvragen +
+ } +
+ } + @case ('Submitting') { + Aanvraag wordt verwerkt… + } + @case ('Submitted') { + Uw aanvraag tot herregistratie is ontvangen. + } + @case ('Failed') { + Indienen mislukt. Probeer het opnieuw. +
+ Opnieuw proberen +
+ } + } + `, +}) +export class HerregistratieWizardComponent { + /** Optional seed so Storybook / the showcase can mount any state directly. */ + seed = input(initial); + state = signal(initial); + + back = back; + + private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract) : null)); + protected step = computed(() => this.editing()?.step ?? 1); + protected draft = computed(() => this.editing()?.draft ?? { uren: '', punten: '' }); + protected errUren = computed(() => this.editing()?.errors.uren ?? ''); + protected errPunten = computed(() => this.editing()?.errors.punten ?? ''); + + constructor() { + queueMicrotask(() => this.state.set(this.seed())); + } + + set(key: keyof Draft, value: string) { + const s = this.state(); + if (s.tag !== 'Editing') return; + this.state.set({ ...s, draft: { ...s.draft, [key]: value } }); + } + + onPrimary() { + const s = this.state(); + if (s.tag !== 'Editing') return; + this.state.set(s.step === 1 ? next(s) : submit(s)); + this.runIfSubmitting(); + } + + onRetry() { + const s = this.state(); + if (s.tag !== 'Failed') return; + this.state.set({ tag: 'Submitting', data: s.data }); + this.runIfSubmitting(); + } + + // ponytail: fake the backend with a timer; swap for a real httpResource call later. + private runIfSubmitting() { + if (this.state().tag !== 'Submitting') return; + setTimeout(() => this.state.set(resolve(this.state(), ok(undefined))), 800); + } +} diff --git a/src/app/organisms/herregistratie-wizard/herregistratie-wizard.stories.ts b/src/app/organisms/herregistratie-wizard/herregistratie-wizard.stories.ts new file mode 100644 index 0000000..dcb3af1 --- /dev/null +++ b/src/app/organisms/herregistratie-wizard/herregistratie-wizard.stories.ts @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { HerregistratieWizardComponent } from './herregistratie-wizard.component'; +import { WizardState } from './wizard.machine'; +import { Uren } from '../../core/parse'; + +const validData = { uren: 4160 as Uren, punten: 200 }; + +const meta: Meta = { + title: 'Organisms/Herregistratie Wizard', + component: HerregistratieWizardComponent, +}; +export default meta; +type Story = StoryObj; + +// Each story seeds one state of the machine — one render per union variant. +export const Step1: Story = { args: { seed: { tag: 'Editing', step: 1, draft: { uren: '', punten: '' }, errors: {} } } }; +export const Step1Error: Story = { + args: { seed: { tag: 'Editing', step: 1, draft: { uren: 'abc', punten: '' }, errors: { uren: 'Vul een geheel aantal uren in (0 of meer).' } } satisfies WizardState }, +}; +export const Step2: Story = { args: { seed: { tag: 'Editing', step: 2, draft: { uren: '4160', punten: '' }, errors: {} } } }; +export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } }; +export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } }; +export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } }; diff --git a/src/app/organisms/herregistratie-wizard/wizard.machine.spec.ts b/src/app/organisms/herregistratie-wizard/wizard.machine.spec.ts new file mode 100644 index 0000000..767516d --- /dev/null +++ b/src/app/organisms/herregistratie-wizard/wizard.machine.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { ok, err } from '../../core/fp'; +import { initial, next, back, submit, resolve, WizardState } from './wizard.machine'; + +const editing1 = (uren: string, punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, punten }, errors: {} }); +const editing2 = (uren: string, punten: string): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, punten }, errors: {} }); + +describe('wizard.machine', () => { + it('next advances only when step 1 parses', () => { + expect(next(initial).tag).toBe('Editing'); // empty uren -> stays, with error + expect((next(initial) as any).errors.uren).toBeTruthy(); + expect((next(editing1('4160')) as any).step).toBe(2); + }); + + it('submit reaches Submitting ONLY with fully valid data', () => { + expect(submit(editing2('4160', 'x')).tag).toBe('Editing'); // invalid punten -> no Submitting + const good = submit(editing2('4160', '200')); + expect(good.tag).toBe('Submitting'); + expect((good as any).data).toEqual({ uren: 4160, punten: 200 }); + }); + + it('back / resolve are no-ops from illegal states', () => { + expect(back(initial)).toBe(initial); // step 1, nothing to go back to + expect(resolve(initial, ok(undefined))).toBe(initial); // not Submitting + }); + + it('resolve maps Submitting to Submitted / Failed', () => { + const submitting = submit(editing2('4160', '200')); + expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted'); + expect(resolve(submitting, err('boom')).tag).toBe('Failed'); + }); +}); diff --git a/src/app/organisms/herregistratie-wizard/wizard.machine.ts b/src/app/organisms/herregistratie-wizard/wizard.machine.ts new file mode 100644 index 0000000..5da2e47 --- /dev/null +++ b/src/app/organisms/herregistratie-wizard/wizard.machine.ts @@ -0,0 +1,66 @@ +import { Result } from '../../core/fp'; +import { Uren, parseUren } from '../../core/parse'; + +/** What the user is typing (raw, possibly invalid). */ +export interface Draft { + uren: string; + punten: string; +} + +/** What we have AFTER parsing — branded/typed, guaranteed valid. */ +export interface Valid { + uren: Uren; + punten: number; +} + +/** + * The whole wizard as one tagged union. `step` and `errors` exist ONLY while + * Editing; Submitting/Submitted/Failed carry a `Valid` payload and nothing else. + * So "submitting while a field is invalid" or "showing the success screen with + * errors set" are unrepresentable — the bug class is gone by construction. + */ +export type WizardState = + | { tag: 'Editing'; step: 1 | 2; draft: Draft; errors: Partial> } + | { tag: 'Submitting'; data: Valid } + | { tag: 'Submitted'; data: Valid } + | { tag: 'Failed'; data: Valid; error: string }; + +export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', punten: '' }, errors: {} }; + +/** Parse every field; on success hand back a Valid, else the per-field errors. */ +function validate(draft: Draft): Result>, Valid> { + const uren = parseUren(draft.uren); + const punten = parseUren(draft.punten); + const errors: Partial> = {}; + if (!uren.ok) errors.uren = uren.error; + if (!punten.ok) errors.punten = punten.error; + if (uren.ok && punten.ok) return { ok: true, value: { uren: uren.value, punten: punten.value } }; + return { ok: false, error: errors }; +} + +/** Step 1 → 2: only advance if the uren field parses. Illegal elsewhere = no-op. */ +export function next(s: WizardState): WizardState { + if (s.tag !== 'Editing' || s.step !== 1) return s; + const uren = parseUren(s.draft.uren); + return uren.ok + ? { ...s, step: 2, errors: {} } + : { ...s, errors: { uren: uren.error } }; +} + +export function back(s: WizardState): WizardState { + if (s.tag !== 'Editing' || s.step !== 2) return s; + return { ...s, step: 1, errors: {} }; +} + +/** Step 2 submit: parse everything; move to Submitting only with Valid data. */ +export function submit(s: WizardState): WizardState { + if (s.tag !== 'Editing' || s.step !== 2) return s; + const result = validate(s.draft); + return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error }; +} + +/** Resolve the async submit. Only meaningful while Submitting. */ +export function resolve(s: WizardState, r: Result): WizardState { + if (s.tag !== 'Submitting') return s; + return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error }; +} diff --git a/src/app/pages/herregistratie/herregistratie.page.ts b/src/app/pages/herregistratie/herregistratie.page.ts index b3e047b..a43d17a 100644 --- a/src/app/pages/herregistratie/herregistratie.page.ts +++ b/src/app/pages/herregistratie/herregistratie.page.ts @@ -1,54 +1,22 @@ -import { Component, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { Component } from '@angular/core'; import { PageShellComponent } from '../../templates/page-shell/page-shell.component'; import { AlertComponent } from '../../atoms/alert/alert.component'; -import { ButtonComponent } from '../../atoms/button/button.component'; -import { FormFieldComponent } from '../../molecules/form-field/form-field.component'; -import { TextInputComponent } from '../../atoms/text-input/text-input.component'; +import { HerregistratieWizardComponent } from '../../organisms/herregistratie-wizard/herregistratie-wizard.component'; /** A whole new page built from existing building blocks — no new components. This is the atomic-design payoff: a new flow is just composition. */ @Component({ selector: 'app-herregistratie-page', - imports: [ - FormsModule, PageShellComponent, AlertComponent, - ButtonComponent, FormFieldComponent, TextInputComponent, - ], + imports: [PageShellComponent, AlertComponent, HerregistratieWizardComponent], template: ` Uw huidige registratie verloopt op 1 september 2027. Vraag tijdig herregistratie aan. - - @if (submitted()) { -
- Uw aanvraag tot herregistratie is ontvangen. -
- } @else { -
- - - - - - -
- Herregistratie aanvragen -
-
- } +
+ +
`, }) -export class HerregistratiePage { - uren = ''; - punten = ''; - urenError = signal(''); - submitted = signal(false); - - onSubmit() { - if (!this.uren.trim()) { this.urenError.set('Vul het aantal gewerkte uren in.'); return; } - this.urenError.set(''); - this.submitted.set(true); - } -} +export class HerregistratiePage {}