feat(brief): letter composition + two-person approval (teaching slice)
New `brief` context — a letter-composition feature with a drafter/approver approval workflow, built as a teaching vertical slice on the repo's existing FP + Elm + atomic-design patterns (see plan in ~/.claude/plans). Domain (pure): - Rich text as a serialisable value tree (placeholders are first-class nodes), moved to @shared/kernel/rich-text.ts so the shared editor can use it. - lintPlaceholders: a pure, total content -> Diagnostic[] linter, derived never stored. - brief.machine.ts: status sum-type with guarded transitions; frozen-snapshot = deep value copy; derived diagnostics/editability. Full specs. Backend (.NET stub): - BriefStore + seed, GET/PUT /brief and submit/approve/reject/send endpoints, role via X-Role header (mirrors X-Admin), transition + approver!=drafter guards, audit logging. Regenerated typed client via gen:api. +6 backend tests. Seam: - brief.adapter.ts maps flat wire unions <-> domain discriminated unions at the parse boundary (+ spec). UI (atomic): - shared atoms: checkbox, placeholder-chip; molecule: rich-text-editor (no-dep contenteditable, DOM<->RichTextBlock round-trip tested). - brief/ui: letter-block, passage-picker, diagnostics-panel, rejection-comments, letter-section, letter-composer, letter-preview, brief.page + /brief route. - Dev-only ?role=drafter|approver toggle + roleInterceptor; dashboard nav link. Enforcement: @brief/* alias + eslint layer boundary (brief depends only on shared). Also included (same session): - Value-object specs (postcode/uren/big-nummer) — closes the "domain must have a spec" gap. - src/docs/ Storybook MDX foundation pages (atomic design, tokens, FP-in-UI). - .storybook/tsconfig.json: add @angular/localize to types (Storybook was fully broken — $localize unresolved — dev + build). Verified: 168 FE tests, 68 backend tests, lint/build/check:tokens green, Storybook boots, end-to-end HTTP smoke (self-approve 403, approver 200, full flow). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseBigNummer } from './big-nummer';
|
||||
|
||||
describe('parseBigNummer', () => {
|
||||
it('accepts exactly 11 digits, trimming whitespace', () => {
|
||||
const r = parseBigNummer(' 12345678901 ');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value).toBe('12345678901');
|
||||
});
|
||||
|
||||
it('rejects wrong length or non-digits', () => {
|
||||
expect(parseBigNummer('').ok).toBe(false);
|
||||
expect(parseBigNummer('1234567890').ok).toBe(false); // 10 digits
|
||||
expect(parseBigNummer('123456789012').ok).toBe(false); // 12 digits
|
||||
expect(parseBigNummer('1234567890a').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parsePostcode } from './postcode';
|
||||
|
||||
describe('parsePostcode', () => {
|
||||
it('normalises to "1234 AB" (uppercase, single space, trimmed)', () => {
|
||||
for (const raw of ['1234ab', '1234 AB', ' 1234ab ', '1234AB']) {
|
||||
const r = parsePostcode(raw);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value).toBe('1234 AB');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects malformed postcodes', () => {
|
||||
expect(parsePostcode('').ok).toBe(false);
|
||||
expect(parsePostcode('0234AB').ok).toBe(false); // leading zero
|
||||
expect(parsePostcode('123AB').ok).toBe(false); // 3 digits
|
||||
expect(parsePostcode('1234A').ok).toBe(false); // 1 letter
|
||||
expect(parsePostcode('1234ABC').ok).toBe(false); // 3 letters
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseUren } from './uren';
|
||||
|
||||
describe('parseUren', () => {
|
||||
it('accepts non-negative whole numbers, including 0', () => {
|
||||
for (const [raw, n] of [['0', 0], [' 40 ', 40], ['1000', 1000]] as const) {
|
||||
const r = parseUren(raw);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value).toBe(n);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects empty, negative, and non-integer input', () => {
|
||||
expect(parseUren('').ok).toBe(false);
|
||||
expect(parseUren('-1').ok).toBe(false);
|
||||
expect(parseUren('1.5').ok).toBe(false);
|
||||
expect(parseUren('abc').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -165,6 +165,7 @@ export class DashboardPage {
|
||||
{ label: $localize`:@@dashboard.nav.herregistratie:Herregistratie`, to: '/herregistratie' },
|
||||
{ label: $localize`:@@dashboard.nav.inschrijven:Inschrijven`, to: '/registreren' },
|
||||
{ label: $localize`:@@dashboard.nav.concepten:Functionele patronen`, to: '/concepts' },
|
||||
{ label: $localize`:@@dashboard.nav.brief:Brief opstellen`, to: '/brief' },
|
||||
];
|
||||
|
||||
/** Primary transactional actions, as a card grid. */
|
||||
|
||||
Reference in New Issue
Block a user