feat(dx): WP-43 — plop generators (value-object, form-machine)
CI / frontend (push) Successful in 3m11s
CI / backend (push) Successful in 2m27s
CI / storybook-a11y (push) Successful in 9m28s
CI / e2e (push) Successful in 4m38s
CI / semgrep (push) Successful in 1m20s
CI / api-client-drift (push) Successful in 2m13s

Runnable `npm run gen:value-object` / `gen:form-machine` (plop) that scaffold the two
pure-TS house patterns with a co-located spec: a branded value object + parseX (mirrors
postcode/bsn), and an Elm-style form/wizard machine (Draft/Valid/Errors + Editing/
Submitting/Submitted/Failed union + initial/pure reduce/assertNever). Prompts take
context + PascalCase name (positional-arg bypass); a post-action reminds to add the
English target for the generated $localize id. Templates in plop-templates/ (prettier-
ignored). Skills (value-object, form-machine) point at the generators. ui-component +
bff-endpoint stay skill-driven (Angular {{}} / backend + gen:api).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-23 18:04:54 +02:00
co-authored by Claude Opus 4.8
parent 00c5faacb9
commit deb5d77e04
12 changed files with 1130 additions and 53 deletions
+74
View File
@@ -0,0 +1,74 @@
import { Result, assertNever } from '@shared/kernel/fp';
/** What the user is typing (raw, possibly invalid). */
export interface Draft {
veld: string;
}
/** After parsing — replace `string` with branded value objects (see value-object generator). */
export interface Valid {
veld: string;
}
export type Errors = Partial<Record<keyof Draft, string>>;
/**
* The {{name}} form as one tagged union — the house form idiom (Model/Msg/pure reduce, same
* shape as the wizards). `draft`/`errors` exist only while Editing; Submitting/Submitted/Failed
* carry the parsed `Valid`. Illegal states (submitting an invalid draft, a success screen with
* errors) are unrepresentable. Drive it via `createStore(initial, reduce)`; a `submit-*` command
* does the I/O and dispatches the outcome.
*/
export type {{pascalCase name}}State =
| { tag: 'Editing'; draft: Draft; errors: Errors }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid; referentie: string }
| { tag: 'Failed'; data: Valid; error: string };
export const initial: {{pascalCase name}}State = {
tag: 'Editing',
draft: { veld: '' },
errors: {},
};
/** Parse via value objects; on success a Valid, else per-field errors. */
function validate(draft: Draft): Result<Errors, Valid> {
const veld = draft.veld.trim();
if (!veld) {
return { ok: false, error: { veld: $localize`:@@{{camelCase name}}.validation.veld:Vul dit veld in.` } };
}
return { ok: true, value: { veld } };
}
export type {{pascalCase name}}Msg =
| { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed'; referentie: string }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Reset' }
| { tag: 'Seed'; state: {{pascalCase name}}State };
export function reduce(s: {{pascalCase name}}State, m: {{pascalCase name}}Msg): {{pascalCase name}}State {
switch (m.tag) {
case 'SetField':
return s.tag === 'Editing' ? { ...s, draft: { ...s.draft, [m.key]: m.value } } : s;
case 'Submit': {
if (s.tag !== 'Editing') return s;
const r = validate(s.draft);
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
}
case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed':
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data, referentie: m.referentie } : s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'Reset':
return initial;
case 'Seed':
return m.state;
default:
return assertNever(m);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { {{pascalCase name}}State, reduce, initial } from './{{kebabCase name}}.machine';
type Editing = Extract<{{pascalCase name}}State, { tag: 'Editing' }>;
describe('{{camelCase name}} reduce', () => {
it('SetField updates the draft while editing', () => {
const s = reduce(initial, { tag: 'SetField', key: 'veld', value: 'x' });
expect((s as Editing).draft.veld).toBe('x');
});
it('Submit with an invalid draft stays Editing and reports field errors', () => {
const s = reduce(initial, { tag: 'Submit' });
expect(s.tag).toBe('Editing');
expect((s as Editing).errors.veld).toBeTruthy();
});
it('Submit with a valid draft moves to Submitting', () => {
const editing = reduce(initial, { tag: 'SetField', key: 'veld', value: 'x' });
expect(reduce(editing, { tag: 'Submit' }).tag).toBe('Submitting');
});
it('Reset returns to the initial editing state', () => {
const editing = reduce(initial, { tag: 'SetField', key: 'veld', value: 'x' });
expect(reduce(editing, { tag: 'Reset' })).toEqual(initial);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { Brand, Result, ok, err } from '@shared/kernel/fp';
/**
* Value object: {{pascalCase name}}. "Parse, don't validate" — a {{pascalCase name}} is a
* distinct type from a raw string, mintable only via parse{{pascalCase name}}, so holding one
* is proof it is well-formed. Format-only check; the backend re-validates (ADR-0001).
*/
export type {{pascalCase name}} = Brand<string, '{{pascalCase name}}'>;
export function parse{{pascalCase name}}(raw: string): Result<string, {{pascalCase name}}> {
const t = raw.trim();
// TODO: replace with the real format rule for {{pascalCase name}}.
if (t.length === 0) {
return err($localize`:@@validation.{{camelCase name}}:Voer een geldige waarde in.`);
}
return ok(t as {{pascalCase name}});
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, it, expect } from 'vitest';
import { parse{{pascalCase name}} } from './{{kebabCase name}}';
describe('parse{{pascalCase name}}', () => {
it('accepts a valid value', () => {
// TODO: use a real valid example for {{pascalCase name}}.
expect(parse{{pascalCase name}}('geldig').ok).toBe(true);
});
it('rejects an empty value', () => {
expect(parse{{pascalCase name}}('').ok).toBe(false);
});
});