feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { RemoteData } from '@shared/application/remote-data';
|
||||
import { AuditEntry } from '@beheer/domain/audit-entry';
|
||||
import { AuditAdapter, parseAuditEntries } from '@beheer/infrastructure/audit.adapter';
|
||||
|
||||
type Err = Error | undefined;
|
||||
|
||||
/**
|
||||
* Admin view of the persisted authz/PII-reveal audit trail (WP-41/42). One root singleton
|
||||
* owning the list as a RemoteData signal, parsed at the trust boundary. Read-only.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuditStore {
|
||||
private adapter = inject(AuditAdapter);
|
||||
|
||||
private state = signal<RemoteData<Err, AuditEntry[]>>({ tag: 'Loading' });
|
||||
readonly entries = this.state.asReadonly();
|
||||
|
||||
async load() {
|
||||
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
|
||||
try {
|
||||
const parsed = parseAuditEntries(await this.adapter.list());
|
||||
this.state.set(
|
||||
parsed.ok
|
||||
? { tag: 'Success', value: parsed.value }
|
||||
: { tag: 'Failure', error: new Error(parsed.error) },
|
||||
);
|
||||
} catch (e) {
|
||||
this.state.set({ tag: 'Failure', error: e as Error });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Result, ok } from '@shared/kernel/fp';
|
||||
import { StamRow, StamTable } from '@beheer/domain/stamdata';
|
||||
import { StamdataAdapter } from '@beheer/infrastructure/stamdata.adapter';
|
||||
import { StamdataStore } from './stamdata.store';
|
||||
|
||||
const table: StamTable = {
|
||||
id: 'professions',
|
||||
label: 'Opleiding → beroep',
|
||||
temporal: false,
|
||||
columns: [
|
||||
{ name: 'program', type: 'text', isKey: true, options: [] },
|
||||
{ name: 'beroep', type: 'text', isKey: false, options: [] },
|
||||
],
|
||||
};
|
||||
const rows: StamRow[] = [{ program: 'geneeskunde', beroep: 'Arts' }];
|
||||
|
||||
function setup(): StamdataStore {
|
||||
const adapter: Partial<StamdataAdapter> = {
|
||||
list: (): Promise<Result<string, StamTable[]>> => Promise.resolve(ok([table])),
|
||||
load: (): Promise<Result<string, { table: StamTable; rows: StamRow[] }>> =>
|
||||
Promise.resolve(ok({ table, rows: rows.map((r) => ({ ...r })) })),
|
||||
};
|
||||
TestBed.configureTestingModule({ providers: [{ provide: StamdataAdapter, useValue: adapter }] });
|
||||
return TestBed.inject(StamdataStore);
|
||||
}
|
||||
|
||||
describe('StamdataStore undo/redo (WP-32)', () => {
|
||||
it('records a cell edit, undoes and redoes it', async () => {
|
||||
const store = setup();
|
||||
await store.load();
|
||||
expect(store.canUndo()).toBe(false);
|
||||
|
||||
store.editCell(0, 'beroep', 'Chirurg');
|
||||
expect(store.rows()[0]['beroep']).toBe('Chirurg');
|
||||
expect(store.canUndo()).toBe(true);
|
||||
|
||||
store.undo();
|
||||
expect(store.rows()[0]['beroep']).toBe('Arts');
|
||||
expect(store.canRedo()).toBe(true);
|
||||
|
||||
store.redo();
|
||||
expect(store.rows()[0]['beroep']).toBe('Chirurg');
|
||||
expect(store.canRedo()).toBe(false);
|
||||
});
|
||||
|
||||
it('records addRow and undoes it', async () => {
|
||||
const store = setup();
|
||||
await store.load();
|
||||
store.addRow();
|
||||
expect(store.rows().length).toBe(2);
|
||||
store.undo();
|
||||
expect(store.rows().length).toBe(1);
|
||||
});
|
||||
|
||||
it('clears history when switching table', async () => {
|
||||
const store = setup();
|
||||
await store.load();
|
||||
store.addRow();
|
||||
expect(store.canUndo()).toBe(true);
|
||||
await store.selectTable('professions');
|
||||
expect(store.canUndo()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { machineRemoteData } from '@shared/application/machine-remote-data';
|
||||
import { createHistory } from '@shared/application/history';
|
||||
import {
|
||||
ChangeCounts,
|
||||
StamRow,
|
||||
StamTable,
|
||||
changeCounts,
|
||||
isValid,
|
||||
rowErrors,
|
||||
toJson,
|
||||
} from '@beheer/domain/stamdata';
|
||||
import {
|
||||
StamdataEditorMsg,
|
||||
StamdataEditorState,
|
||||
initial,
|
||||
reduce,
|
||||
} from '@beheer/domain/stamdata-editor.machine';
|
||||
import { StamdataAdapter } from '@beheer/infrastructure/stamdata.adapter';
|
||||
|
||||
type LoadedState = Extract<StamdataEditorState, { tag: 'loaded' }>;
|
||||
|
||||
/**
|
||||
* Root singleton for the stamdata maintenance editor (ADR-0004). The Elm machine owns the
|
||||
* draft rows; commands here load the catalog + a selected table and produce the download.
|
||||
* There is deliberately NO save command — the reducer stays pure and the edit leaves as a
|
||||
* downloaded JSON file that the admin drops into the repo (the CI build is the authority).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StamdataStore {
|
||||
private adapter = inject(StamdataAdapter);
|
||||
private store = createStore<StamdataEditorState, StamdataEditorMsg>(initial, reduce);
|
||||
|
||||
readonly model = this.store.model;
|
||||
readonly tables = signal<readonly StamTable[]>([]);
|
||||
readonly selectedTableId = signal<string | null>(null);
|
||||
|
||||
/** Preview: show only rows valid on this date ('' = show all, editable). A local filter,
|
||||
so toggling it never round-trips or drops unsaved edits (see domain `activeOn`). */
|
||||
readonly previewDate = signal<string>('');
|
||||
|
||||
readonly remoteData = computed(() => machineRemoteData(this.model()));
|
||||
|
||||
private loaded = computed<LoadedState | null>(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s : null;
|
||||
});
|
||||
readonly table = computed<StamTable | null>(() => this.loaded()?.table ?? null);
|
||||
readonly rows = computed<readonly StamRow[]>(() => this.loaded()?.rows ?? []);
|
||||
readonly errors = computed<readonly string[]>(() => {
|
||||
const s = this.loaded();
|
||||
return s ? rowErrors(s.table, s.rows) : [];
|
||||
});
|
||||
readonly counts = computed<ChangeCounts>(() => {
|
||||
const s = this.loaded();
|
||||
return s ? changeCounts(s.table, s.original, s.rows) : { added: 0, removed: 0, edited: 0 };
|
||||
});
|
||||
readonly dirty = computed(() => {
|
||||
const c = this.counts();
|
||||
return c.added + c.removed + c.edited > 0;
|
||||
});
|
||||
/** Download is blocked while previewing (the filtered view is not the full file) or while
|
||||
any row has a format error (the CI gate would reject it anyway — fail fast here). */
|
||||
readonly canDownload = computed(() => {
|
||||
const s = this.loaded();
|
||||
return this.previewDate() === '' && this.dirty() && s !== null && isValid(s.table, s.rows);
|
||||
});
|
||||
|
||||
async load() {
|
||||
this.store.dispatch({ tag: 'Loading' });
|
||||
const list = await this.adapter.list();
|
||||
if (!list.ok) {
|
||||
this.store.dispatch({ tag: 'LoadFailed', reason: list.error });
|
||||
return;
|
||||
}
|
||||
this.tables.set(list.value);
|
||||
const first = list.value[0];
|
||||
if (!first) {
|
||||
this.store.dispatch({ tag: 'LoadFailed', reason: NO_TABLES });
|
||||
return;
|
||||
}
|
||||
await this.selectTable(first.id);
|
||||
}
|
||||
|
||||
async selectTable(tableId: string) {
|
||||
this.selectedTableId.set(tableId);
|
||||
this.previewDate.set('');
|
||||
this.history.clear(); // undo history is per-table, not across tables
|
||||
this.store.dispatch({ tag: 'Loading' });
|
||||
const r = await this.adapter.load(tableId);
|
||||
if (r.ok) this.store.dispatch({ tag: 'Loaded', table: r.value.table, rows: r.value.rows });
|
||||
else this.store.dispatch({ tag: 'LoadFailed', reason: r.error });
|
||||
}
|
||||
|
||||
setPreviewDate(date: string) {
|
||||
this.previewDate.set(date);
|
||||
}
|
||||
|
||||
/** Undo/redo over the edited rows (WP-32): the document snapshot is `rows`; restore via
|
||||
the existing `Seed` msg. Only real edits are recorded (a no-op reduce leaves no step). */
|
||||
private history = createHistory<readonly StamRow[]>(50);
|
||||
readonly canUndo = this.history.canUndo;
|
||||
readonly canRedo = this.history.canRedo;
|
||||
|
||||
private recordThenDispatch(msg: StamdataEditorMsg) {
|
||||
const before = this.rows();
|
||||
this.store.dispatch(msg);
|
||||
if (this.loaded() && this.rows() !== before) this.history.record(before);
|
||||
}
|
||||
editCell(row: number, column: string, value: string) {
|
||||
this.recordThenDispatch({ tag: 'CellEdited', row, column, value });
|
||||
}
|
||||
addRow() {
|
||||
this.recordThenDispatch({ tag: 'RowAdded' });
|
||||
}
|
||||
removeRow(row: number) {
|
||||
this.recordThenDispatch({ tag: 'RowRemoved', row });
|
||||
}
|
||||
|
||||
undo() {
|
||||
this.restore((rows) => this.history.undo(rows));
|
||||
}
|
||||
redo() {
|
||||
this.restore((rows) => this.history.redo(rows));
|
||||
}
|
||||
private restore(step: (current: readonly StamRow[]) => readonly StamRow[] | undefined) {
|
||||
const s = this.loaded();
|
||||
if (!s) return;
|
||||
const target = step(s.rows);
|
||||
if (target === undefined) return;
|
||||
// copy readonly history snapshot into the machine's mutable rows shape
|
||||
this.store.dispatch({ tag: 'Seed', state: { ...s, rows: [...target] } });
|
||||
}
|
||||
|
||||
/** Emit the edited data-file for the admin to drop into the repo (see domain `toJson`). */
|
||||
download() {
|
||||
const s = this.loaded();
|
||||
if (!s || !this.canDownload()) return;
|
||||
const blob = new Blob([toJson(s.table, s.rows)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${s.table.id}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
const NO_TABLES = $localize`:@@beheer.noTables:Er is geen stamdata om te beheren.`;
|
||||
@@ -0,0 +1,30 @@
|
||||
// Wire DTOs for the stamdata maintenance reads (ADR-0004). Generic by design: a table is
|
||||
// a reflected column schema + opaque JSON rows, so ONE contract serves every table. Field
|
||||
// names mirror the backend Contracts/Dtos.cs 1:1; this file imports NOTHING (the wire seam).
|
||||
|
||||
export interface StamdataColumnDto {
|
||||
name: string;
|
||||
type: string;
|
||||
isKey: boolean;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
export interface StamdataTableSummaryDto {
|
||||
id: string;
|
||||
label: string;
|
||||
columns: StamdataColumnDto[];
|
||||
temporal: boolean;
|
||||
}
|
||||
|
||||
// A cell is whatever JSON the data-file holds for that column (string/date, number, or
|
||||
// null for an open-ended geldigTot). The adapter narrows each to editable text.
|
||||
export type StamdataCellDto = string | number | boolean | null;
|
||||
export type StamdataRowDto = Record<string, StamdataCellDto>;
|
||||
|
||||
export interface StamdataTableDto {
|
||||
id: string;
|
||||
label: string;
|
||||
columns: StamdataColumnDto[];
|
||||
temporal: boolean;
|
||||
rows: StamdataRowDto[];
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** One authz/PII-reveal audit row as the FE sees it (WP-41 backend → WP-42 view). Pure
|
||||
type; data-minimised (no PII) by construction on the server. */
|
||||
export interface AuditEntry {
|
||||
at: string; // ISO timestamp
|
||||
action: string;
|
||||
resource: string;
|
||||
decision: 'allow' | 'deny';
|
||||
role: string;
|
||||
correlationId: string;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StamTable } from './stamdata';
|
||||
import { StamdataEditorState, initial, reduce } from './stamdata-editor.machine';
|
||||
|
||||
const table: StamTable = {
|
||||
id: 'professions',
|
||||
label: 'Opleiding → beroep',
|
||||
temporal: true,
|
||||
columns: [
|
||||
{ name: 'program', type: 'text', isKey: true, options: [] },
|
||||
{ name: 'beroep', type: 'text', isKey: false, options: [] },
|
||||
{ name: 'geldigVan', type: 'date', isKey: false, options: [] },
|
||||
{ name: 'geldigTot', type: 'date', isKey: false, options: [] },
|
||||
],
|
||||
};
|
||||
|
||||
const seedLoaded = (): StamdataEditorState =>
|
||||
reduce(initial, {
|
||||
tag: 'Loaded',
|
||||
table,
|
||||
rows: [{ program: 'geneeskunde', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: '' }],
|
||||
});
|
||||
|
||||
describe('stamdata-editor reduce', () => {
|
||||
it('Loaded snapshots original independently of rows', () => {
|
||||
const s = seedLoaded();
|
||||
expect(s.tag).toBe('loaded');
|
||||
if (s.tag !== 'loaded') return;
|
||||
const edited = reduce(s, { tag: 'CellEdited', row: 0, column: 'beroep', value: 'Chirurg' });
|
||||
if (edited.tag !== 'loaded') return;
|
||||
expect(edited.rows[0]['beroep']).toBe('Chirurg');
|
||||
expect(edited.original[0]['beroep']).toBe('Arts'); // snapshot untouched → diff works
|
||||
});
|
||||
|
||||
it('RowAdded appends an empty row shaped by the schema', () => {
|
||||
const s = reduce(seedLoaded(), { tag: 'RowAdded' });
|
||||
if (s.tag !== 'loaded') return;
|
||||
expect(s.rows).toHaveLength(2);
|
||||
expect(s.rows[1]).toEqual({ program: '', beroep: '', geldigVan: '', geldigTot: '' });
|
||||
});
|
||||
|
||||
it('RowRemoved drops the row at the index', () => {
|
||||
const s = reduce(reduce(seedLoaded(), { tag: 'RowAdded' }), { tag: 'RowRemoved', row: 0 });
|
||||
if (s.tag !== 'loaded') return;
|
||||
expect(s.rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('edit messages are ignored unless loaded', () => {
|
||||
expect(reduce(initial, { tag: 'RowAdded' })).toBe(initial);
|
||||
expect(
|
||||
reduce({ tag: 'failed', reason: 'x' }, { tag: 'CellEdited', row: 0, column: 'a', value: 'b' })
|
||||
.tag,
|
||||
).toBe('failed');
|
||||
});
|
||||
|
||||
it('LoadFailed and Loading transition regardless of prior state', () => {
|
||||
expect(reduce(seedLoaded(), { tag: 'LoadFailed', reason: 'boom' })).toEqual({
|
||||
tag: 'failed',
|
||||
reason: 'boom',
|
||||
});
|
||||
expect(reduce(seedLoaded(), { tag: 'Loading' })).toEqual({ tag: 'loading' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { assertNever } from '@shared/kernel/fp';
|
||||
import { StamRow, StamTable, emptyRow } from '@beheer/domain/stamdata';
|
||||
|
||||
/**
|
||||
* The stamdata table editor as one Elm-style tagged union (the house form idiom). While
|
||||
* `loaded`, the draft `rows` are the edit state and `original` is the loaded snapshot the
|
||||
* diff compares against — no separate `dirty` flag (derive it, don't store it). Loading and
|
||||
* failure are states here too, so the page can render them via `<app-async>`.
|
||||
*
|
||||
* There is no submit/save Msg: an edit stays local until the admin downloads the file (the
|
||||
* apply path is a reviewed PR, not a runtime write — ADR-0004).
|
||||
*/
|
||||
export type StamdataEditorState =
|
||||
| { tag: 'loading' }
|
||||
| { tag: 'failed'; reason: string }
|
||||
| { tag: 'loaded'; table: StamTable; rows: StamRow[]; original: readonly StamRow[] };
|
||||
|
||||
export type StamdataEditorMsg =
|
||||
| { tag: 'Loading' }
|
||||
| { tag: 'Loaded'; table: StamTable; rows: StamRow[] }
|
||||
| { tag: 'LoadFailed'; reason: string }
|
||||
| { tag: 'CellEdited'; row: number; column: string; value: string }
|
||||
| { tag: 'RowAdded' }
|
||||
| { tag: 'RowRemoved'; row: number }
|
||||
| { tag: 'Seed'; state: StamdataEditorState }; // mount a specific state (stories/tests)
|
||||
|
||||
export const initial: StamdataEditorState = { tag: 'loading' };
|
||||
|
||||
const copy = (rows: readonly StamRow[]): StamRow[] => rows.map((r) => ({ ...r }));
|
||||
|
||||
export function reduce(s: StamdataEditorState, m: StamdataEditorMsg): StamdataEditorState {
|
||||
switch (m.tag) {
|
||||
case 'Loading':
|
||||
return { tag: 'loading' };
|
||||
case 'Loaded':
|
||||
// original is an independent snapshot so later edits never mutate it (drives the diff).
|
||||
return { tag: 'loaded', table: m.table, rows: copy(m.rows), original: copy(m.rows) };
|
||||
case 'LoadFailed':
|
||||
return { tag: 'failed', reason: m.reason };
|
||||
case 'CellEdited':
|
||||
if (s.tag !== 'loaded') return s;
|
||||
return {
|
||||
...s,
|
||||
rows: s.rows.map((r, i) => (i === m.row ? { ...r, [m.column]: m.value } : r)),
|
||||
};
|
||||
case 'RowAdded':
|
||||
return s.tag === 'loaded' ? { ...s, rows: [...s.rows, emptyRow(s.table)] } : s;
|
||||
case 'RowRemoved':
|
||||
return s.tag === 'loaded' ? { ...s, rows: s.rows.filter((_, i) => i !== m.row) } : s;
|
||||
case 'Seed':
|
||||
return m.state;
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StamTable, activeOn, changeCounts, isValid, rowErrors, toJson } from './stamdata';
|
||||
|
||||
const professions: StamTable = {
|
||||
id: 'professions',
|
||||
label: 'Opleiding → beroep',
|
||||
temporal: true,
|
||||
columns: [
|
||||
{ name: 'program', type: 'text', isKey: true, options: [] },
|
||||
{ name: 'beroep', type: 'text', isKey: false, options: [] },
|
||||
{ name: 'geldigVan', type: 'date', isKey: false, options: [] },
|
||||
{ name: 'geldigTot', type: 'date', isKey: false, options: [] },
|
||||
],
|
||||
};
|
||||
|
||||
const row = (program: string, beroep: string, van: string, tot = ''): Record<string, string> => ({
|
||||
program,
|
||||
beroep,
|
||||
geldigVan: van,
|
||||
geldigTot: tot,
|
||||
});
|
||||
|
||||
describe('activeOn (valid-time, half-open [van, tot))', () => {
|
||||
it('includes a row whose window covers the date', () => {
|
||||
expect(activeOn(professions, row('a', 'A', '2000-01-01'), '2020-01-01')).toBe(true);
|
||||
});
|
||||
it('excludes a row before its geldigVan', () => {
|
||||
expect(activeOn(professions, row('a', 'A', '2000-01-01'), '1999-01-01')).toBe(false);
|
||||
});
|
||||
it('excludes on the geldigTot boundary (half-open)', () => {
|
||||
expect(activeOn(professions, row('a', 'A', '2000-01-01', '2020-01-01'), '2020-01-01')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(activeOn(professions, row('a', 'A', '2000-01-01', '2020-01-01'), '2019-12-31')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rowErrors / isValid (format only)', () => {
|
||||
it('flags a blank key', () => {
|
||||
expect(rowErrors(professions, [row('', 'A', '2000-01-01')])[0]).not.toBe('');
|
||||
});
|
||||
it('flags a missing geldigVan on a temporal table', () => {
|
||||
expect(rowErrors(professions, [row('a', 'A', '')])[0]).not.toBe('');
|
||||
});
|
||||
it('flags geldigTot on or before geldigVan', () => {
|
||||
expect(rowErrors(professions, [row('a', 'A', '2020-01-01', '2020-01-01')])[0]).not.toBe('');
|
||||
});
|
||||
it('passes a well-formed row', () => {
|
||||
expect(isValid(professions, [row('a', 'A', '2000-01-01')])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeCounts (diff against the loaded snapshot, by key)', () => {
|
||||
const original = [row('a', 'A', '2000-01-01'), row('b', 'B', '2000-01-01')];
|
||||
it('counts an added key', () => {
|
||||
const draft = [...original, row('c', 'C', '2000-01-01')];
|
||||
expect(changeCounts(professions, original, draft)).toEqual({ added: 1, removed: 0, edited: 0 });
|
||||
});
|
||||
it('counts a removed key', () => {
|
||||
expect(changeCounts(professions, original, [original[0]])).toEqual({
|
||||
added: 0,
|
||||
removed: 1,
|
||||
edited: 0,
|
||||
});
|
||||
});
|
||||
it('counts an edited cell', () => {
|
||||
const draft = [row('a', 'CHANGED', '2000-01-01'), original[1]];
|
||||
expect(changeCounts(professions, original, draft)).toEqual({ added: 0, removed: 0, edited: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toJson (draft → file shape)', () => {
|
||||
it('reconstructs an open-ended geldigTot as null and pretty-prints', () => {
|
||||
const json = toJson(professions, [row('a', 'Arts', '2000-01-01')]);
|
||||
expect(JSON.parse(json)).toEqual([
|
||||
{ program: 'a', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: null },
|
||||
]);
|
||||
expect(json.endsWith('\n')).toBe(true);
|
||||
});
|
||||
it('coerces a number column', () => {
|
||||
const table: StamTable = {
|
||||
id: 't',
|
||||
label: 't',
|
||||
temporal: false,
|
||||
columns: [
|
||||
{ name: 'code', type: 'text', isKey: true, options: [] },
|
||||
{ name: 'jaar', type: 'number', isKey: false, options: [] },
|
||||
],
|
||||
};
|
||||
expect(JSON.parse(toJson(table, [{ code: 'x', jaar: '2020' }]))).toEqual([
|
||||
{ code: 'x', jaar: 2020 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
// Domain model for stamdata maintenance (ADR-0004). Pure TS, no Angular. A table is a
|
||||
// reflected column schema + editable rows; the pure functions below cover validation
|
||||
// (FORMAT only — the CI build + StamdataValidationTests stay the authority), the
|
||||
// valid-time preview filter, the change diff, and serialization back to the file shape.
|
||||
|
||||
export type ColumnType = 'text' | 'date' | 'number' | 'enum';
|
||||
|
||||
export interface StamColumn {
|
||||
name: string;
|
||||
type: ColumnType;
|
||||
isKey: boolean;
|
||||
options: readonly string[];
|
||||
}
|
||||
|
||||
export interface StamTable {
|
||||
id: string;
|
||||
label: string;
|
||||
columns: readonly StamColumn[];
|
||||
temporal: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* An editable row: every cell is the text the user edits ('' = empty). Typed values
|
||||
* (number, null geldigTot) are reconstructed at export time — see {@link toJson}.
|
||||
*/
|
||||
export type StamRow = Record<string, string>;
|
||||
|
||||
export interface ChangeCounts {
|
||||
added: number;
|
||||
removed: number;
|
||||
edited: number;
|
||||
}
|
||||
|
||||
const GELDIG_VAN = 'geldigVan';
|
||||
const GELDIG_TOT = 'geldigTot';
|
||||
|
||||
export function keyColumn(table: StamTable): StamColumn {
|
||||
return table.columns.find((c) => c.isKey) ?? table.columns[0];
|
||||
}
|
||||
|
||||
export function keyOf(table: StamTable, row: StamRow): string {
|
||||
return row[keyColumn(table).name] ?? '';
|
||||
}
|
||||
|
||||
export function emptyRow(table: StamTable): StamRow {
|
||||
const row: StamRow = {};
|
||||
for (const c of table.columns) row[c.name] = '';
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid-time membership, half-open [van, tot) — the same rule the backend applies, done
|
||||
* client-side so the editor's "geldig op" preview is instant and never drops unsaved edits.
|
||||
* ISO yyyy-MM-dd strings compare correctly lexicographically. Non-temporal tables: all rows.
|
||||
*/
|
||||
export function activeOn(table: StamTable, row: StamRow, on: string): boolean {
|
||||
if (!table.temporal) return true;
|
||||
const van = row[GELDIG_VAN] ?? '';
|
||||
const tot = row[GELDIG_TOT] ?? '';
|
||||
return van !== '' && van <= on && (tot === '' || on < tot);
|
||||
}
|
||||
|
||||
/** Per-row FORMAT error (index-aligned; '' = valid). Cross-row overlap is the CI gate's job. */
|
||||
export function rowErrors(table: StamTable, rows: readonly StamRow[]): string[] {
|
||||
const key = keyColumn(table).name;
|
||||
return rows.map((row) => {
|
||||
if ((row[key] ?? '').trim() === '')
|
||||
return $localize`:@@beheer.validation.key:Vul de sleutelkolom in.`;
|
||||
if (table.temporal) {
|
||||
const van = row[GELDIG_VAN] ?? '';
|
||||
const tot = row[GELDIG_TOT] ?? '';
|
||||
if (van === '') return $localize`:@@beheer.validation.van:Vul een 'geldig van'-datum in.`;
|
||||
if (tot !== '' && tot <= van)
|
||||
return $localize`:@@beheer.validation.range:'Geldig tot' moet ná 'geldig van' liggen.`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
}
|
||||
|
||||
export function isValid(table: StamTable, rows: readonly StamRow[]): boolean {
|
||||
return rowErrors(table, rows).every((e) => e === '');
|
||||
}
|
||||
|
||||
/** Diff draft against the loaded snapshot, matched by key value. */
|
||||
export function changeCounts(
|
||||
table: StamTable,
|
||||
original: readonly StamRow[],
|
||||
draft: readonly StamRow[],
|
||||
): ChangeCounts {
|
||||
const origByKey = new Map(original.map((r) => [keyOf(table, r), r]));
|
||||
const draftKeys = new Set(draft.map((r) => keyOf(table, r)));
|
||||
let added = 0;
|
||||
let edited = 0;
|
||||
for (const row of draft) {
|
||||
const prev = origByKey.get(keyOf(table, row));
|
||||
if (!prev) added++;
|
||||
else if (!sameRow(table, prev, row)) edited++;
|
||||
}
|
||||
const removed = original.filter((r) => !draftKeys.has(keyOf(table, r))).length;
|
||||
return { added, removed, edited };
|
||||
}
|
||||
|
||||
function sameRow(table: StamTable, a: StamRow, b: StamRow): boolean {
|
||||
return table.columns.every((c) => (a[c.name] ?? '') === (b[c.name] ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the draft back to the data-file's JSON shape, reconstructing typed values per
|
||||
* column: an empty date/number cell becomes null (an open-ended geldigTot), a number cell
|
||||
* becomes a number, everything else a string. This is the file the admin drops into the
|
||||
* repo — the existing CI build re-validates it (a bad edit fails the build, never prod).
|
||||
*/
|
||||
export function toJson(table: StamTable, rows: readonly StamRow[]): string {
|
||||
const objects = rows.map((row) => {
|
||||
const out: Record<string, string | number | null> = {};
|
||||
for (const c of table.columns) {
|
||||
const cell = (row[c.name] ?? '').trim();
|
||||
out[c.name] =
|
||||
cell === ''
|
||||
? c.type === 'text' || c.type === 'enum'
|
||||
? ''
|
||||
: null
|
||||
: c.type === 'number'
|
||||
? Number(cell)
|
||||
: cell;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
return JSON.stringify(objects, null, 2) + '\n';
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
import type { AuthzAuditDto } from '@shared/infrastructure/api-client';
|
||||
import { AuditEntry } from '@beheer/domain/audit-entry';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the admin authz/PII-reveal audit trail (`GET /admin/audit`,
|
||||
* WP-41). The single place the ApiClient lives for audit; the store parses at the boundary.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuditAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
list(): Promise<AuthzAuditDto[]> {
|
||||
return this.client.audit();
|
||||
}
|
||||
}
|
||||
|
||||
/** Trust-boundary parse of the audit rows. */
|
||||
export function parseAuditEntries(json: unknown): Result<string, AuditEntry[]> {
|
||||
if (!Array.isArray(json)) return err('audit: not an array');
|
||||
const out: AuditEntry[] = [];
|
||||
for (const item of json) {
|
||||
if (typeof item !== 'object' || item === null) return err('audit: row not an object');
|
||||
const d = item as AuthzAuditDto;
|
||||
if (
|
||||
typeof d.at !== 'string' ||
|
||||
typeof d.action !== 'string' ||
|
||||
typeof d.resource !== 'string' ||
|
||||
typeof d.role !== 'string' ||
|
||||
typeof d.correlationId !== 'string'
|
||||
)
|
||||
return err('audit: missing fields');
|
||||
out.push({
|
||||
at: d.at,
|
||||
action: d.action,
|
||||
resource: d.resource,
|
||||
decision: d.decision === 'allow' ? 'allow' : 'deny',
|
||||
role: d.role,
|
||||
correlationId: d.correlationId,
|
||||
});
|
||||
}
|
||||
return ok(out);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseStamdataTable } from './stamdata.adapter';
|
||||
|
||||
const wire = {
|
||||
id: 'professions',
|
||||
label: 'Opleiding → beroep',
|
||||
temporal: true,
|
||||
columns: [
|
||||
{ name: 'program', type: 'text', isKey: true },
|
||||
{ name: 'beroep', type: 'text', isKey: false },
|
||||
{ name: 'geldigVan', type: 'date', isKey: false },
|
||||
{ name: 'geldigTot', type: 'date', isKey: false },
|
||||
],
|
||||
rows: [{ program: 'geneeskunde', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: null }],
|
||||
};
|
||||
|
||||
describe('parseStamdataTable', () => {
|
||||
it('maps schema + rows and turns a null cell into empty text', () => {
|
||||
const r = parseStamdataTable(wire);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value.table.temporal).toBe(true);
|
||||
expect(r.value.table.columns[0]).toMatchObject({ name: 'program', isKey: true, type: 'text' });
|
||||
expect(r.value.rows[0]).toEqual({
|
||||
program: 'geneeskunde',
|
||||
beroep: 'Arts',
|
||||
geldigVan: '2000-01-01',
|
||||
geldigTot: '', // null → '' so the editor renders an empty (open-ended) cell
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to text for an unknown column type', () => {
|
||||
const r = parseStamdataTable({
|
||||
...wire,
|
||||
columns: [{ name: 'x', type: 'weird', isKey: true }],
|
||||
rows: [],
|
||||
});
|
||||
if (!r.ok) return;
|
||||
expect(r.value.table.columns[0].type).toBe('text');
|
||||
});
|
||||
|
||||
it('rejects a response with no columns', () => {
|
||||
expect(parseStamdataTable({ id: 't', columns: [], rows: [] }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { runSubmit } from '@shared/application/submit';
|
||||
import { ApiClient, StamdataColumnDto } from '@shared/infrastructure/api-client';
|
||||
import { ColumnType, StamColumn, StamRow, StamTable } from '@beheer/domain/stamdata';
|
||||
|
||||
/** A loaded table: its schema plus the rows for editing (or the peildatum-filtered view). */
|
||||
export interface LoadedTable {
|
||||
table: StamTable;
|
||||
rows: StamRow[];
|
||||
}
|
||||
|
||||
const FAILED = $localize`:@@beheer.load.failed:De stamdata kon niet worden geladen.`;
|
||||
const COLUMN_TYPES: readonly ColumnType[] = ['text', 'date', 'number', 'enum'];
|
||||
|
||||
/**
|
||||
* The only place stamdata HTTP lives (ADR-0001 boundary). Both endpoints are reads; the
|
||||
* generic `parse*` narrows the untrusted wire shape (schema + opaque rows) into the domain
|
||||
* model. There is no write method — the edit is downloaded and lands as a PR.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StamdataAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
/** The tables in the catalog (schema only, no rows) — for the table switcher. */
|
||||
async list(): Promise<Result<string, StamTable[]>> {
|
||||
const r = await runSubmit(() => this.client.stamdataTables(), FAILED);
|
||||
if (!r.ok) return r;
|
||||
const out: StamTable[] = [];
|
||||
for (const t of r.value ?? []) {
|
||||
const parsed = parseTable(t);
|
||||
if (!parsed.ok) return parsed;
|
||||
out.push(parsed.value);
|
||||
}
|
||||
return ok(out);
|
||||
}
|
||||
|
||||
/** One table's schema + rows. `peildatum` (yyyy-MM-dd) asks the server for only the rows
|
||||
valid on that date; the editor uses it for a server-side cross-check, previewing
|
||||
locally for instant feedback (see `activeOn`). */
|
||||
async load(tableId: string, peildatum?: string): Promise<Result<string, LoadedTable>> {
|
||||
const r = await runSubmit(() => this.client.stamdataTable(tableId, peildatum), FAILED);
|
||||
return r.ok ? parseStamdataTable(r.value) : r;
|
||||
}
|
||||
}
|
||||
|
||||
// --- parse: wire → domain, validating at the boundary ---
|
||||
|
||||
/** Trust-boundary parse for one table response: schema + rows → domain. Exported so its
|
||||
spec can exercise it without HTTP (the house `parse*` seam, ADR-0001). */
|
||||
export function parseStamdataTable(dto: {
|
||||
id?: string;
|
||||
label?: string;
|
||||
columns?: StamdataColumnDto[];
|
||||
temporal?: boolean;
|
||||
rows?: readonly unknown[];
|
||||
}): Result<string, LoadedTable> {
|
||||
const table = parseTable(dto);
|
||||
if (!table.ok) return table;
|
||||
return ok({ table: table.value, rows: parseRows(dto.rows ?? [], table.value.columns) });
|
||||
}
|
||||
|
||||
function parseColumn(dto: StamdataColumnDto): Result<string, StamColumn> {
|
||||
if (typeof dto.name !== 'string' || dto.name === '') return err('stamdata column: bad name');
|
||||
const raw = dto.type ?? '';
|
||||
const type = (COLUMN_TYPES as string[]).includes(raw) ? (raw as ColumnType) : 'text';
|
||||
return ok({ name: dto.name, type, isKey: dto.isKey === true, options: dto.options ?? [] });
|
||||
}
|
||||
|
||||
function parseTable(dto: {
|
||||
id?: string;
|
||||
label?: string;
|
||||
columns?: StamdataColumnDto[];
|
||||
temporal?: boolean;
|
||||
}): Result<string, StamTable> {
|
||||
if (typeof dto.id !== 'string' || !Array.isArray(dto.columns))
|
||||
return err('stamdata table: bad shape');
|
||||
const columns: StamColumn[] = [];
|
||||
for (const c of dto.columns) {
|
||||
const parsed = parseColumn(c);
|
||||
if (!parsed.ok) return parsed;
|
||||
columns.push(parsed.value);
|
||||
}
|
||||
if (columns.length === 0) return err('stamdata table: no columns');
|
||||
return ok({ id: dto.id, label: dto.label ?? dto.id, columns, temporal: dto.temporal === true });
|
||||
}
|
||||
|
||||
/** Every cell becomes editable text: null → '' (open-ended), number/bool → its string form. */
|
||||
function parseRows(rows: readonly unknown[], columns: readonly StamColumn[]): StamRow[] {
|
||||
return rows.map((raw) => {
|
||||
const row: StamRow = {};
|
||||
const obj = (raw ?? {}) as Record<string, unknown>;
|
||||
for (const c of columns) {
|
||||
const v = obj[c.name];
|
||||
row[c.name] = v === null || v === undefined ? '' : String(v);
|
||||
}
|
||||
return row;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// ponytail: see libs/shared/src/test-entry.ts's comment — same reason, same fix.
|
||||
export {};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Component, computed, effect, inject } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { AuditStore } from '@beheer/application/audit.store';
|
||||
|
||||
/**
|
||||
* Admin page: the persisted authz/PII-reveal audit trail (WP-41/42) — data-minimised, no PII.
|
||||
* Deny-by-default capability gate (`cases:manage`, reused for admin audit read). Read-only table.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-audit-page',
|
||||
imports: [PageShellComponent, AlertComponent, ButtonComponent, DatePipe, ...ASYNC],
|
||||
styles: [
|
||||
`
|
||||
.scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: var(--rhc-space-max-sm) var(--rhc-space-max-md);
|
||||
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-cool-grey-200);
|
||||
white-space: nowrap;
|
||||
}
|
||||
th {
|
||||
font-weight: var(--rhc-text-font-weight-semi-bold);
|
||||
}
|
||||
.deny {
|
||||
color: var(--rhc-color-rood-600, #a30000);
|
||||
font-weight: var(--rhc-text-font-weight-semi-bold);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<app-page-shell [heading]="heading" [intro]="intro" backLink="/dashboard">
|
||||
@if (!access.ready()) {
|
||||
<!-- wait for /me before deciding — avoids flashing the denial to an admin -->
|
||||
} @else if (!canRead()) {
|
||||
<app-alert type="error">{{ deniedText }}</app-alert>
|
||||
} @else {
|
||||
<app-async [data]="store.entries()">
|
||||
<ng-template appAsyncError>
|
||||
<app-alert type="error">{{ failedText }}</app-alert>
|
||||
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoaded>
|
||||
@if (entries().length === 0) {
|
||||
<app-alert type="info">{{ emptyText }}</app-alert>
|
||||
} @else {
|
||||
<div class="scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ colTijd }}</th>
|
||||
<th>{{ colActie }}</th>
|
||||
<th>{{ colResource }}</th>
|
||||
<th>{{ colBesluit }}</th>
|
||||
<th>{{ colRol }}</th>
|
||||
<th>{{ colCid }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (e of entries(); track e.at + e.action + e.correlationId) {
|
||||
<tr>
|
||||
<td>{{ e.at | date: 'short' }}</td>
|
||||
<td>{{ e.action }}</td>
|
||||
<td>{{ e.resource }}</td>
|
||||
<td [class.deny]="e.decision === 'deny'">{{ e.decision }}</td>
|
||||
<td>{{ e.role }}</td>
|
||||
<td>{{ e.correlationId }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-async>
|
||||
}
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class AuditPage {
|
||||
protected store = inject(AuditStore);
|
||||
protected access = inject(AccessStore);
|
||||
|
||||
protected canRead = computed(() => this.access.can('cases:manage'));
|
||||
protected entries = computed(() => {
|
||||
const rd = this.store.entries();
|
||||
return rd.tag === 'Success' ? rd.value : [];
|
||||
});
|
||||
|
||||
protected heading = $localize`:@@audit.heading:Auditlog`;
|
||||
protected intro = $localize`:@@audit.intro:Toegangs- en inzagebeslissingen (autorisatie en het tonen van afgeschermde gegevens). Vastgelegd zonder persoonsgegevens.`;
|
||||
protected deniedText = $localize`:@@audit.denied:U hebt geen rechten om de auditlog te bekijken.`;
|
||||
protected failedText = $localize`:@@audit.failed:De auditlog kon niet worden geladen.`;
|
||||
protected emptyText = $localize`:@@audit.empty:Nog geen auditregels.`;
|
||||
protected retryText = $localize`:@@audit.retry:Opnieuw proberen`;
|
||||
protected colTijd = $localize`:@@audit.col.tijd:Tijd`;
|
||||
protected colActie = $localize`:@@audit.col.actie:Actie`;
|
||||
protected colResource = $localize`:@@audit.col.resource:Resource`;
|
||||
protected colBesluit = $localize`:@@audit.col.besluit:Besluit`;
|
||||
protected colRol = $localize`:@@audit.col.rol:Rol`;
|
||||
protected colCid = $localize`:@@audit.col.cid:Correlatie-id`;
|
||||
|
||||
private loadRequested = false;
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.canRead() && !this.loadRequested) {
|
||||
this.loadRequested = true;
|
||||
void this.store.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected reload() {
|
||||
void this.store.load();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
|
||||
|
||||
/**
|
||||
* Admin page: toggle runtime feature flags (WP-47). Deny-by-default capability gate
|
||||
* (`flags:manage`). The catalog is server-owned (code); this only flips the on/off state, which
|
||||
* the whole app reads via the same `FeatureFlagStore`.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-feature-flags-page',
|
||||
imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC],
|
||||
styles: [
|
||||
`
|
||||
.flag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--rhc-space-max-lg);
|
||||
padding: var(--rhc-space-max-md) 0;
|
||||
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-cool-grey-200);
|
||||
}
|
||||
.flag .meta {
|
||||
min-inline-size: 0;
|
||||
}
|
||||
.flag .key {
|
||||
font-family: monospace;
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
color: var(--rhc-color-grijs-700);
|
||||
}
|
||||
.state {
|
||||
font-weight: var(--rhc-text-font-weight-semi-bold);
|
||||
margin-inline-end: var(--rhc-space-max-md);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<app-page-shell [heading]="heading" [intro]="intro" backLink="/dashboard">
|
||||
@if (!access.ready()) {
|
||||
<!-- wait for /me before deciding -->
|
||||
} @else if (!canManage()) {
|
||||
<app-alert type="error">{{ deniedText }}</app-alert>
|
||||
} @else {
|
||||
<app-async [data]="store.flags()">
|
||||
<ng-template appAsyncError>
|
||||
<app-alert type="error">{{ failedText }}</app-alert>
|
||||
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoaded>
|
||||
@for (f of store.all(); track f.key) {
|
||||
<div class="flag">
|
||||
<div class="meta">
|
||||
<div>{{ f.description }}</div>
|
||||
<div class="key">{{ f.key }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="state">{{ f.enabled ? onText : offText }}</span>
|
||||
<app-button
|
||||
[variant]="f.enabled ? 'secondary' : 'primary'"
|
||||
(click)="toggle(f.key, !f.enabled)"
|
||||
>{{ f.enabled ? disableText : enableText }}</app-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-async>
|
||||
}
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class FeatureFlagsPage {
|
||||
protected store = inject(FeatureFlagStore);
|
||||
protected access = inject(AccessStore);
|
||||
|
||||
protected canManage = computed(() => this.access.can('flags:manage'));
|
||||
|
||||
protected heading = $localize`:@@flags.heading:Functievlaggen`;
|
||||
protected intro = $localize`:@@flags.intro:Zet functionaliteit aan of uit tijdens runtime. De catalogus staat vast in code; hier beheert u de status.`;
|
||||
protected deniedText = $localize`:@@flags.denied:U hebt geen rechten om functievlaggen te beheren.`;
|
||||
protected failedText = $localize`:@@flags.failed:De functievlaggen konden niet worden geladen.`;
|
||||
protected retryText = $localize`:@@flags.retry:Opnieuw proberen`;
|
||||
protected onText = $localize`:@@flags.on:Aan`;
|
||||
protected offText = $localize`:@@flags.off:Uit`;
|
||||
protected enableText = $localize`:@@flags.enable:Aanzetten`;
|
||||
protected disableText = $localize`:@@flags.disable:Uitzetten`;
|
||||
|
||||
protected toggle(key: string, enabled: boolean) {
|
||||
void this.store.set(key, enabled);
|
||||
}
|
||||
protected reload() {
|
||||
void this.store.load();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { ChangeCounts, StamColumn, StamRow, StamTable, activeOn } from '@beheer/domain/stamdata';
|
||||
|
||||
interface DisplayRow {
|
||||
row: StamRow;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Organism: the GENERIC stamdata grid. It renders entirely from the reflected column
|
||||
* schema — one input per column type (native `date`/`number`, `enum` select, text) — so a
|
||||
* new stamdata table needs zero UI code here. The "geldig op" control filters to the rows
|
||||
* valid on a date (read-only preview); edits and download work on the full set. Emits
|
||||
* intent; the store owns state (CLAUDE.md §1).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-stamdata-table-editor',
|
||||
imports: [ButtonComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
margin-block-end: 1rem;
|
||||
}
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.85em;
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
}
|
||||
table {
|
||||
inline-size: 100%;
|
||||
}
|
||||
.err {
|
||||
color: var(--rhc-color-rood-500);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
.counts {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
}
|
||||
.hint {
|
||||
margin-block-start: 0.5rem;
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="toolbar">
|
||||
@if (tables().length > 1) {
|
||||
<div class="field">
|
||||
<label for="stamdata-table">{{ tableLabel }}</label>
|
||||
<select
|
||||
id="stamdata-table"
|
||||
class="form-select"
|
||||
[value]="selectedTableId()"
|
||||
(change)="selectTable.emit(asValue($event))"
|
||||
>
|
||||
@for (t of tables(); track t.id) {
|
||||
<option [value]="t.id">{{ t.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (table().temporal) {
|
||||
<div class="field">
|
||||
<label for="stamdata-peildatum">{{ peildatumLabel }}</label>
|
||||
<input
|
||||
id="stamdata-peildatum"
|
||||
type="date"
|
||||
class="form-control"
|
||||
[value]="previewDate()"
|
||||
(input)="previewDateChanged.emit(asValue($event))"
|
||||
/>
|
||||
</div>
|
||||
@if (previewing()) {
|
||||
<app-button variant="subtle" (click)="previewDateChanged.emit('')">{{
|
||||
showAll
|
||||
}}</app-button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (previewing()) {
|
||||
<p class="hint">{{ previewNote }}</p>
|
||||
}
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@for (col of table().columns; track col.name) {
|
||||
<th scope="col">{{ col.name }}</th>
|
||||
}
|
||||
<th scope="col">{{ previewing() ? '' : actionsLabel }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (item of display(); track item.index) {
|
||||
<tr>
|
||||
@for (col of table().columns; track col.name) {
|
||||
<td>
|
||||
@if (col.type === 'enum') {
|
||||
<select
|
||||
class="form-select"
|
||||
[value]="item.row[col.name]"
|
||||
[disabled]="previewing()"
|
||||
[attr.aria-label]="cellLabel(col, item.index)"
|
||||
(change)="
|
||||
cellEdited.emit({ row: item.index, column: col.name, value: asValue($event) })
|
||||
"
|
||||
>
|
||||
<option value=""></option>
|
||||
@for (opt of col.options; track opt) {
|
||||
<option [value]="opt">{{ opt }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else {
|
||||
<input
|
||||
class="form-control"
|
||||
[type]="inputType(col)"
|
||||
[value]="item.row[col.name]"
|
||||
[class.is-invalid]="!!errors()[item.index]"
|
||||
[disabled]="previewing()"
|
||||
[attr.aria-label]="cellLabel(col, item.index)"
|
||||
(input)="
|
||||
cellEdited.emit({ row: item.index, column: col.name, value: asValue($event) })
|
||||
"
|
||||
/>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td>
|
||||
@if (!previewing()) {
|
||||
@if (table().temporal) {
|
||||
<app-button variant="subtle" (click)="onExpire(item.index)">{{
|
||||
expireLabel
|
||||
}}</app-button>
|
||||
}
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[attr.aria-label]="removeLabel"
|
||||
(click)="onRemove(item.index)"
|
||||
>{{ removeLabel }}</app-button
|
||||
>
|
||||
}
|
||||
@if (errors()[item.index]) {
|
||||
<span class="err">{{ errors()[item.index] }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (!previewing()) {
|
||||
<div class="footer">
|
||||
<app-button variant="subtle" [disabled]="!canUndo()" (click)="undo.emit()">{{
|
||||
undoLabel
|
||||
}}</app-button>
|
||||
<app-button variant="subtle" [disabled]="!canRedo()" (click)="redo.emit()">{{
|
||||
redoLabel
|
||||
}}</app-button>
|
||||
<app-button variant="secondary" (click)="rowAdded.emit()">{{ addRowLabel }}</app-button>
|
||||
<span class="counts">{{ countsLabel() }}</span>
|
||||
<app-button variant="primary" [disabled]="!canDownload()" (click)="download.emit()">{{
|
||||
downloadLabel
|
||||
}}</app-button>
|
||||
</div>
|
||||
<p class="hint">{{ applyHint }}</p>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class StamdataTableEditorComponent {
|
||||
table = input.required<StamTable>();
|
||||
rows = input.required<readonly StamRow[]>();
|
||||
errors = input.required<readonly string[]>();
|
||||
counts = input.required<ChangeCounts>();
|
||||
previewDate = input('');
|
||||
canDownload = input(false);
|
||||
canUndo = input(false);
|
||||
canRedo = input(false);
|
||||
tables = input<readonly StamTable[]>([]);
|
||||
selectedTableId = input<string | null>(null);
|
||||
|
||||
selectTable = output<string>();
|
||||
cellEdited = output<{ row: number; column: string; value: string }>();
|
||||
rowAdded = output<void>();
|
||||
rowRemoved = output<number>();
|
||||
previewDateChanged = output<string>();
|
||||
download = output<void>();
|
||||
undo = output<void>();
|
||||
redo = output<void>();
|
||||
|
||||
protected previewing = computed(() => this.previewDate() !== '');
|
||||
|
||||
protected display = computed<DisplayRow[]>(() =>
|
||||
this.rows()
|
||||
.map((row, index) => ({ row, index }))
|
||||
.filter(({ row }) => !this.previewing() || activeOn(this.table(), row, this.previewDate())),
|
||||
);
|
||||
|
||||
protected inputType(col: StamColumn): string {
|
||||
return col.type === 'date' ? 'date' : col.type === 'number' ? 'number' : 'text';
|
||||
}
|
||||
|
||||
protected cellLabel(col: StamColumn, index: number): string {
|
||||
return `${col.name} — rij ${index + 1}`;
|
||||
}
|
||||
|
||||
protected asValue(e: Event): string {
|
||||
return (e.target as HTMLInputElement | HTMLSelectElement).value;
|
||||
}
|
||||
|
||||
private addedWord = $localize`:@@beheer.added:toegevoegd`;
|
||||
private editedWord = $localize`:@@beheer.edited:gewijzigd`;
|
||||
private removedWord = $localize`:@@beheer.removed:verwijderd`;
|
||||
protected countsLabel = computed(() => {
|
||||
const c = this.counts();
|
||||
return `${c.added} ${this.addedWord} · ${c.edited} ${this.editedWord} · ${c.removed} ${this.removedWord}`;
|
||||
});
|
||||
|
||||
protected tableLabel = $localize`:@@beheer.table:Tabel`;
|
||||
protected peildatumLabel = $localize`:@@beheer.peildatum:Toon geldig op`;
|
||||
protected showAll = $localize`:@@beheer.showAll:Toon alles`;
|
||||
protected previewNote = $localize`:@@beheer.previewNote:Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit.`;
|
||||
protected actionsLabel = $localize`:@@beheer.actions:Acties`;
|
||||
protected removeLabel = $localize`:@@beheer.remove:Verwijderen`;
|
||||
protected expireLabel = $localize`:@@beheer.expire:Sluiten per vandaag`;
|
||||
private removeConfirm = $localize`:@@beheer.removeConfirm:Rij verwijderen? Als andere gegevens ernaar verwijzen, faalt de build-controle (CI). Bij een tabel met een geldigheidsperiode kunt u de rij beter sluiten (geldig tot) in plaats van verwijderen.`;
|
||||
|
||||
/** Deletions can orphan a reference (the CI gate catches it); confirm first (WP-48). */
|
||||
protected onRemove(index: number) {
|
||||
if (confirm(this.removeConfirm)) this.rowRemoved.emit(index);
|
||||
}
|
||||
|
||||
/** Steer temporal tables toward expiring (close the validity per today) over hard delete —
|
||||
preserves history and can't orphan a reference that was valid earlier (WP-48). */
|
||||
protected onExpire(index: number) {
|
||||
const col = this.table().columns.find((c) => /geldigtot/i.test(c.name));
|
||||
if (col) this.cellEdited.emit({ row: index, column: col.name, value: this.today });
|
||||
}
|
||||
private today = new Date().toISOString().slice(0, 10);
|
||||
protected undoLabel = $localize`:@@beheer.undo:Ongedaan maken`;
|
||||
protected redoLabel = $localize`:@@beheer.redo:Opnieuw uitvoeren`;
|
||||
protected addRowLabel = $localize`:@@beheer.addRow:Rij toevoegen`;
|
||||
protected downloadLabel = $localize`:@@beheer.download:Download JSON`;
|
||||
protected applyHint = $localize`:@@beheer.applyHint:Wijzigingen worden als JSON-bestand gedownload en via een pull request toegepast — de build (CI) controleert ze.`;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { StamdataTableEditorComponent } from './stamdata-table-editor.component';
|
||||
import { StamRow, StamTable, changeCounts, rowErrors } from '@beheer/domain/stamdata';
|
||||
|
||||
const table: StamTable = {
|
||||
id: 'professions',
|
||||
label: 'Opleiding → beroep',
|
||||
temporal: true,
|
||||
columns: [
|
||||
{ name: 'program', type: 'text', isKey: true, options: [] },
|
||||
{ name: 'beroep', type: 'text', isKey: false, options: [] },
|
||||
{ name: 'geldigVan', type: 'date', isKey: false, options: [] },
|
||||
{ name: 'geldigTot', type: 'date', isKey: false, options: [] },
|
||||
],
|
||||
};
|
||||
|
||||
const rows: StamRow[] = [
|
||||
{ program: 'geneeskunde', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: '' },
|
||||
{ program: 'verpleegkunde', beroep: 'Verpleegkundige', geldigVan: '2000-01-01', geldigTot: '' },
|
||||
{
|
||||
program: 'fysiotherapie',
|
||||
beroep: 'Fysiotherapeut',
|
||||
geldigVan: '2000-01-01',
|
||||
geldigTot: '2020-01-01',
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<StamdataTableEditorComponent> = {
|
||||
title: 'Domein/Beheer/Stamdata Table Editor',
|
||||
component: StamdataTableEditorComponent,
|
||||
args: {
|
||||
table,
|
||||
rows,
|
||||
errors: rowErrors(table, rows),
|
||||
counts: changeCounts(table, rows, rows),
|
||||
previewDate: '',
|
||||
canDownload: false,
|
||||
tables: [table],
|
||||
selectedTableId: 'professions',
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<StamdataTableEditorComponent>;
|
||||
|
||||
export const Editing: Story = {};
|
||||
|
||||
export const Dirty: Story = {
|
||||
args: {
|
||||
counts: { added: 1, edited: 1, removed: 0 },
|
||||
canDownload: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Invalid: Story = {
|
||||
args: {
|
||||
rows: [
|
||||
{ program: '', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: '' },
|
||||
...rows.slice(1),
|
||||
],
|
||||
errors: rowErrors(table, [
|
||||
{ program: '', beroep: 'Arts', geldigVan: '2000-01-01', geldigTot: '' },
|
||||
...rows.slice(1),
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
export const PeildatumPreview: Story = {
|
||||
args: { previewDate: '2021-01-01' },
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Component, computed, effect, inject } from '@angular/core';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { StamdataStore } from '@beheer/application/stamdata.store';
|
||||
import { StamdataTableEditorComponent } from '@beheer/ui/stamdata-table-editor/stamdata-table-editor.component';
|
||||
|
||||
/**
|
||||
* Page: thin container for the stamdata maintenance editor (ADR-0004). Deny-by-default
|
||||
* capability gate (`stamdata:edit`) — a denial alert for non-admins, the generic grid for
|
||||
* admins. Loads once the capability resolves; wires store commands to the organism.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-stamdata-page',
|
||||
host: { '(document:keydown)': 'onKeydown($event)' },
|
||||
imports: [
|
||||
PageShellComponent,
|
||||
AlertComponent,
|
||||
ButtonComponent,
|
||||
...ASYNC,
|
||||
StamdataTableEditorComponent,
|
||||
],
|
||||
template: `
|
||||
<app-page-shell [heading]="heading" [intro]="intro" backLink="/dashboard">
|
||||
@if (!access.ready()) {
|
||||
<!-- wait for /me before deciding — avoids flashing the denial to an admin -->
|
||||
} @else if (!canEdit()) {
|
||||
<app-alert type="error">{{ deniedText }}</app-alert>
|
||||
} @else {
|
||||
<app-async [data]="store.remoteData()">
|
||||
<ng-template appAsyncError>
|
||||
<app-alert type="error">{{ failedText }}</app-alert>
|
||||
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoaded>
|
||||
@if (store.table(); as table) {
|
||||
<app-stamdata-table-editor
|
||||
[table]="table"
|
||||
[rows]="store.rows()"
|
||||
[errors]="store.errors()"
|
||||
[counts]="store.counts()"
|
||||
[previewDate]="store.previewDate()"
|
||||
[canDownload]="store.canDownload()"
|
||||
[canUndo]="store.canUndo()"
|
||||
[canRedo]="store.canRedo()"
|
||||
[tables]="store.tables()"
|
||||
[selectedTableId]="store.selectedTableId()"
|
||||
(selectTable)="store.selectTable($event)"
|
||||
(cellEdited)="store.editCell($event.row, $event.column, $event.value)"
|
||||
(rowAdded)="store.addRow()"
|
||||
(rowRemoved)="store.removeRow($event)"
|
||||
(previewDateChanged)="store.setPreviewDate($event)"
|
||||
(download)="store.download()"
|
||||
(undo)="store.undo()"
|
||||
(redo)="store.redo()"
|
||||
/>
|
||||
}
|
||||
</ng-template>
|
||||
</app-async>
|
||||
}
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class StamdataPage {
|
||||
protected store = inject(StamdataStore);
|
||||
protected access = inject(AccessStore);
|
||||
|
||||
protected canEdit = computed(() => this.access.can('stamdata:edit'));
|
||||
|
||||
protected heading = $localize`:@@beheer.page.heading:Stamdata onderhouden`;
|
||||
protected intro = $localize`:@@beheer.page.intro:Beheer de business-tabellen die de registratie stuurt. Wijzigingen worden als JSON gedownload en via een pull request toegepast; de build blijft de bewaker.`;
|
||||
protected deniedText = $localize`:@@beheer.page.denied:U hebt geen rechten om stamdata te onderhouden.`;
|
||||
protected failedText = $localize`:@@beheer.page.failed:De stamdata kon niet worden geladen.`;
|
||||
protected retryText = $localize`:@@beheer.page.retry:Opnieuw proberen`;
|
||||
|
||||
private loadRequested = false;
|
||||
constructor() {
|
||||
// Load once the capability resolves to `allowed` (a 403 GET would be wasted otherwise).
|
||||
// Depends only on canEdit() + a plain flag — never on the store model, so dispatching
|
||||
// `Loading` inside load() can't retrigger this effect (the WP-26 runaway-loop lesson).
|
||||
effect(() => {
|
||||
if (this.canEdit() && !this.loadRequested) {
|
||||
this.loadRequested = true;
|
||||
void this.store.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected reload() {
|
||||
void this.store.load();
|
||||
}
|
||||
|
||||
/** Ctrl/Cmd+Z undo, Ctrl/Cmd+Shift+Z redo (WP-32). Ignored while focus is in a grid
|
||||
cell input so the browser's native text-undo still works there (mirrors brief.page). */
|
||||
protected onKeydown(e: KeyboardEvent) {
|
||||
if (!this.canEdit() || !(e.ctrlKey || e.metaKey) || (e.key !== 'z' && e.key !== 'Z')) return;
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (t && (t.isContentEditable || ['INPUT', 'SELECT', 'TEXTAREA'].includes(t.tagName))) return;
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) this.store.redo();
|
||||
else this.store.undo();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* Only exists to satisfy the unit-test builder's mandatory buildTarget lookup — see
|
||||
test-entry.ts's comment. Not a real, servable build. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/beheer-app",
|
||||
"types": ["@angular/localize"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@shared/*": ["../shared/src/*"],
|
||||
"@beheer/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/test-entry.ts"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* Standalone test config for the beheer library — see libs/shared/tsconfig.spec.json's
|
||||
comment for why. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../out-tsc/beheer-spec",
|
||||
"types": ["vitest/globals", "@angular/localize"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@shared/*": ["../shared/src/*"],
|
||||
"@beheer/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
|
||||
import * as AlertStories from '../src/ui/alert/alert.stories';
|
||||
import * as FormFieldStories from '../src/ui/form-field/form-field.stories';
|
||||
|
||||
<Meta title="Foundations/Accessibility" />
|
||||
|
||||
# Accessibility
|
||||
|
||||
No single tool catches every a11y class of bug, so this repo layers four, each catching
|
||||
what the ones below/above it can't.
|
||||
|
||||
## The layers
|
||||
|
||||
1. **Axe on every story** (WP-01) — `@storybook/addon-a11y` in the panel, plus
|
||||
`@storybook/test-runner` + `axe-playwright` gating CI (`npm run test-storybook:ci`).
|
||||
Catches structural/contrast/ARIA-shape violations on every component, automatically,
|
||||
as soon as a story exists. Escape hatch: `parameters: { a11y: { disable: true } }`,
|
||||
only with an inline justification comment + a cross-reference to the WP that will fix
|
||||
it (see e.g. `task-list.stories.ts`).
|
||||
2. **Template a11y lint** (WP-17) — `angular-eslint`'s `templateAccessibility` config
|
||||
(`alt-text`, `label-has-associated-control`, `click`/`mouse-events-have-key-events`,
|
||||
`interactive-supports-focus`, `valid-aria`, `no-autofocus`, …) running on every inline
|
||||
template via `angular.processInlineTemplates` (this repo has no `.html` files — every
|
||||
template is a string in the `@Component` decorator; the processor extracts each one
|
||||
into a virtual file the template rules can lint). Catches missing alt text, unlabelled
|
||||
controls, and interactive elements that can't be reached by keyboard — at lint time,
|
||||
before a story even exists.
|
||||
3. **Play tests** (WP-16) — Storybook stories assert the wiring axe/lint can't see:
|
||||
`form-field.stories.ts`'s canonical composition asserts `aria-describedby` joins
|
||||
`-desc`/`-error` in the right order; `alert.stories.ts` asserts `role="alert"` for
|
||||
errors vs `role="status"` for info/ok/warning. These run as part of the same
|
||||
`test-storybook:ci` gate as the axe checks, so a regression fails CI, not just a panel.
|
||||
4. **Manual WCAG checklist** (`docs/reference/wcag-checklist.md`) — what none of the above can see:
|
||||
tab order across a whole page, focus traps, 200%-zoom reflow, and how a real screen
|
||||
reader narrates a flow. A living per-page checklist, not a one-time audit — it already
|
||||
caught a real bug (a dashboard alert overflowing at 320px) that no automated layer here
|
||||
would have flagged.
|
||||
|
||||
## Component wiring this protects
|
||||
|
||||
<Canvas of={FormFieldStories.WithDescriptionAndError} />
|
||||
|
||||
The description (`-desc`) and error (`-error`) ids are joined in a pinned order so a
|
||||
screen reader announces the hint, then the error, never neither. See
|
||||
`text-input.component.ts`'s `describedBy()`.
|
||||
|
||||
<Canvas of={AlertStories.Error} />
|
||||
|
||||
Errors are `role="alert"` (assertive — interrupts, because the user needs to know
|
||||
_now_); info/ok/warning stay `role="status"` (polite) so they don't interrupt whatever
|
||||
the user is doing. See `alert.component.ts`.
|
||||
|
||||
## Route-change focus
|
||||
|
||||
Client-side routing has no page (re)load, so a screen reader/keyboard user's focus stays
|
||||
wherever it was — usually the link they just clicked, now detached from any content that
|
||||
matters. `shared/layout/route-focus.ts` moves focus to the new page's `<h1>` (every page
|
||||
has exactly one via `page-shell`) on every navigation after the initial load, deferred via
|
||||
`afterNextRender` so it doesn't race the view-transition DOM swap. Scroll position resets
|
||||
the same way (`withInMemoryScrolling`), both wired once in `app.config.ts` — not per page.
|
||||
|
||||
## Where the skip register lives
|
||||
|
||||
`npm run lint` fails the build on a real template a11y violation, and `test-storybook:ci`
|
||||
fails it on a real axe violation. Both can be locally disabled — the lint rule via a
|
||||
normal ESLint disable comment, axe via `parameters: { a11y: { disable: true } }` — but
|
||||
only with a comment naming _why_ and a cross-reference to the WP expected to remove the
|
||||
skip (see `docs/project/backlog/WP-13-cibg-gap-register.md`'s marker convention, reused here).
|
||||
Grep `a11y: { disable: true }` in `*.stories.ts` for the current list.
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
|
||||
import * as ButtonStories from '../src/ui/button/button.stories';
|
||||
import * as FormFieldStories from '../src/ui/form-field/form-field.stories';
|
||||
import * as PageShellStories from '../src/layout/page-shell/page-shell.stories';
|
||||
import * as DocumentUploadStories from '../src/ui/upload/document-upload/document-upload.stories';
|
||||
|
||||
<Meta title="Foundations/Atomic Design" />
|
||||
|
||||
# Atomic design
|
||||
|
||||
Every screen in this app is built from a small set of layers, each composed **only from
|
||||
the layer below it**. Read a screen top-down and you always land on the same handful of
|
||||
atoms — that is the whole point: fewer things to understand, nothing bespoke per page.
|
||||
|
||||
<div style={{ display: 'grid', gap: '0.5rem', maxWidth: '32rem', margin: '1.5rem 0' }}>
|
||||
{[
|
||||
[
|
||||
'Templates',
|
||||
'shared/layout',
|
||||
'shell, page-shell, wizard-shell — the page skeleton',
|
||||
'#1e3a5f',
|
||||
],
|
||||
[
|
||||
'Organisms',
|
||||
'shared/ui/upload/document-upload …',
|
||||
'self-contained sections that own a bit of behaviour',
|
||||
'#2a5a8a',
|
||||
],
|
||||
['Molecules', 'shared/ui/form-field, async …', 'a label + control + error, grouped', '#3f7cb5'],
|
||||
[
|
||||
'Atoms',
|
||||
'shared/ui/button, text-input …',
|
||||
'thin wrappers over CIBG Huisstijl (Bootstrap) CSS classes',
|
||||
'#6aa6d8',
|
||||
],
|
||||
].map(([name, where, why, bg], i) => (
|
||||
<div
|
||||
key={name}
|
||||
style={{
|
||||
background: bg,
|
||||
color: '#fff',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '6px',
|
||||
marginLeft: `${i * 1.5}rem`,
|
||||
}}
|
||||
>
|
||||
<strong>{name}</strong> <span style={{ opacity: 0.85 }}>— {why}</span>
|
||||
<div
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.75rem', opacity: 0.8, marginTop: '0.2rem' }}
|
||||
>
|
||||
{where}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
## The rule, enforced
|
||||
|
||||
**Each layer only uses layers below it, and dependencies point inward.** This is not a
|
||||
convention you have to remember — `eslint.config.mjs` fails the build if `domain/` imports
|
||||
Angular, or if a context imports "upward". See [the FP-in-the-UI primer](?path=/docs/foundations-fp-in-the-ui--docs)
|
||||
for how the same discipline shapes state and effects.
|
||||
|
||||
## A composition chain, live
|
||||
|
||||
Here is one real chain from atom → molecule → template. Each is a published Storybook
|
||||
story below; click through to the sidebar entries to explore every variant.
|
||||
|
||||
### Atom — `button`
|
||||
|
||||
A thin wrapper: we own a typed `variant` input, the CIBG CSS owns the pixels.
|
||||
|
||||
<Canvas of={ButtonStories.Primary} />
|
||||
|
||||
### Molecule — `form-field`
|
||||
|
||||
Label + control + error text, grouped so the error is announced via `role="alert"`. It
|
||||
composes atoms; it adds no new visual primitives of its own.
|
||||
|
||||
<Canvas of={FormFieldStories.WithError} />
|
||||
|
||||
### Organism — `document-upload`
|
||||
|
||||
`shared/ui/upload/document-upload` composes molecules (a file input, alert, progress bar,
|
||||
chips) into a section that owns real upload behaviour.
|
||||
|
||||
<Canvas of={DocumentUploadStories.Default} />
|
||||
|
||||
### Template — `page-shell`
|
||||
|
||||
The page skeleton — title, optional back-link, content slot. Pages drop composed
|
||||
organisms into it; the template never knows what they are.
|
||||
|
||||
<Canvas of={PageShellStories.WithBackLink} />
|
||||
|
||||
## Why bother
|
||||
|
||||
A new page should be **composition of existing blocks**. Adding a new building block is the
|
||||
exception, not the reflex — if you reach for one, that is a signal to check whether an
|
||||
existing atom/molecule already covers it. Fewer primitives → less to test, less to learn,
|
||||
one place to fix a bug.
|
||||
|
||||
## Convergence decisions — pairs that look duplicated but stay separate
|
||||
|
||||
Periodically we audit for near-duplicate blocks. Some collapse into one; a few **look**
|
||||
similar but earn their separation. This table records the "don't merge these" verdicts so
|
||||
the next person doesn't spend an afternoon re-deciding. (Deliberate CIBG-specific deviations
|
||||
live in [CIBG gaps](?path=/docs/foundations-cibg-gap-register--docs); the FE⇄DS "same shape, different
|
||||
context" cases in [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs).)
|
||||
|
||||
| Pair | Why kept separate |
|
||||
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `choice-link` vs `application-link` | Share the same `to`/`clickable`/`activate` navigation triad, but bind **different vendored patterns** — CIBG _Keuzelijst_ (`.keuzelijst__link`, `.stretched-link`) vs _Aanvragen_ (`.dashboard-block.applications li a`) — with different list/host semantics (`app-choice-link` renders an inner `<li>`; `application-link` **is** the `<li>`). Merging would fight the vendored CSS. Extract the shared triad into a mixin only if it grows. |
|
||||
| `text-input` / `radio-group` / `checkbox` | Share only the standard Angular **ControlValueAccessor** boilerplate (the `writeValue`/`registerOn*`/`setDisabledState` block). They render genuinely different controls, so they stay three atoms. A base CVA class is the only DRY move — a refactor, not a component merge, and not worth it at three. |
|
||||
| `button variant="subtle"` (`.btn-link`) vs `app-link` | A subtle button _looks_ like a link but is an **action** (`<button>`, emits click); `app-link` is **navigation** (`<a routerLink>`). Different semantics and a11y roles → keep both. |
|
||||
| `shell` / `page-shell` / `wizard-shell` | Three distinct jobs that **compose**, not overlap: persistent app chrome (mounted once) → routed page body → the wizard form/step frame. |
|
||||
| Raw `<h3>` in `application-link` vs the `heading` atom | The vendored `.applications li a h3` chain styles the **bare `<h3>`**; wrapping it in the `app-heading` host element would sit between the anchor and the h3 and can break that selector. This is the one sanctioned raw-heading; everywhere else uses `<app-heading [level]>`. |
|
||||
|
||||
Single-consumer shared blocks (e.g. `placeholder-chip`, `rich-text-editor`, `checkbox`, the
|
||||
`task-list`/`choice-list`/`choice-link` family) currently have one consumer each. They stay in
|
||||
`shared` as design-system primitives; relocate one into its consuming context only if it stays
|
||||
single-consumer long-term. That is a watch-item, not a merge.
|
||||
|
||||
The last audit also **removed** a genuinely dead block — a generic white `app-card` with zero
|
||||
consumers (superseded by the grey `app-data-block` as the single data surface).
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/BDD" />
|
||||
|
||||
# Behaviour-driven tests
|
||||
|
||||
Tests here read as **specifications of behaviour**, not checks of implementation. A test
|
||||
says what the system _does_ — in the domain's own words — so a failing test names a broken
|
||||
behaviour, and the suite doubles as living documentation. This is the BDD half of the
|
||||
[Testing strategy](?path=/docs/foundations-testing-strategy--docs) (which owns _what to
|
||||
test, by layer_); BDD owns _how each test is phrased and scoped_.
|
||||
|
||||
## Three rules
|
||||
|
||||
### 1. `describe` = the subject, `it` = one observable behaviour
|
||||
|
||||
The `describe()` block names the unit under test; each `it()` states a single behaviour in
|
||||
**declarative present tense** — the implicit subject is "it". No `should`, no
|
||||
Given/When/Then ceremony: present-tense declaration already reads as a spec.
|
||||
|
||||
```ts
|
||||
describe('parsePostcode', () => {
|
||||
it('normalises to "1234 AB" (uppercase, single space, trimmed)', () => { … });
|
||||
it('rejects malformed input', () => { … });
|
||||
});
|
||||
```
|
||||
|
||||
Read top-to-bottom it _is_ the spec: "parsePostcode — normalises to 1234 AB; rejects
|
||||
malformed input."
|
||||
|
||||
### 2. One behaviour per test
|
||||
|
||||
A test asserts **one behaviour**, not one `expect()`. Several assertions that pin down the
|
||||
_same_ behaviour belong together; assertions about _different_ behaviours belong apart.
|
||||
|
||||
| Keep together (one behaviour) | Split apart (separate behaviours) |
|
||||
| -------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| A `Result`'s `.ok` then its `.value` | The `ok` branch **and** the `err` branch of a transition |
|
||||
| A whole-object `toEqual` | An invalid-input case **and** a valid-input case |
|
||||
| A loop asserting one rule over many inputs | Two independent state transitions |
|
||||
| A truth-table (`draft` → true, `approver` → false) of one rule | An authorization check **and** a rendering check |
|
||||
|
||||
A title that needs `/`, `;`, "then" or "and" to join two behaviours is the smell — split it,
|
||||
and each half gets its own present-tense name.
|
||||
|
||||
### 3. Speak the ubiquitous language (the DDD tie-in)
|
||||
|
||||
Test names use the **domain vocabulary**, not technical jargon — the same words as the
|
||||
[bounded contexts](?path=/docs/foundations-domain-driven-design--docs): a _behandelaar_
|
||||
drafts, a _beoordelaar_ approves, a _herregistratie_ is _ingediend_. The test name is
|
||||
readable by someone who knows the domain but not the code.
|
||||
|
||||
```ts
|
||||
it('drafter cannot approve or reject even when submitted', …);
|
||||
it('confirmed dutch proficiency requires taalvaardigheid proof', …);
|
||||
```
|
||||
|
||||
## How it fits TDD & DDD
|
||||
|
||||
- **TDD** — the loop is red → green → refactor: write the behaviour as a failing `it`, make
|
||||
it pass, then clean up. Because tests describe behaviour (not internals), a refactor that
|
||||
preserves behaviour keeps them green. Pure domain logic is tested directly — no `TestBed`
|
||||
(see [Testing strategy](?path=/docs/foundations-testing-strategy--docs)).
|
||||
- **DDD** — behaviour is expressed in the ubiquitous language, so the spec and the code
|
||||
share one vocabulary. Domain rules (reducers, value-object parsers, policies) are the
|
||||
richest specs; the wire boundary is tested as "rejects malformed input", the UI as
|
||||
Storybook stories.
|
||||
|
||||
## Where to look
|
||||
|
||||
Canonical behaviour specs in the repo: `registratie/domain/value-objects/postcode.spec.ts`
|
||||
(parser behaviour), `registratie/domain/registratie-wizard.machine.spec.ts` (one transition
|
||||
per test), and backend `AuthzTests.cs` (rule truth-tables). The
|
||||
[Testing strategy](?path=/docs/foundations-testing-strategy--docs) page maps which layer
|
||||
gets which kind of test.
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/CIBG Gap Register" />
|
||||
|
||||
# CIBG gap register
|
||||
|
||||
CIBG Huisstijl (ADR-0003) is the design system of record — a component wraps a vendored class
|
||||
before it hand-rolls anything. **Grep the vendored CSS
|
||||
(`public/cibg-huisstijl/css/huisstijl.min.css`) before adding new surface CSS to a component.**
|
||||
When no vendored pattern exists, the component is a **CIBG-gap extension**: allowed, but only
|
||||
marked so every deviation from the design system is auditable.
|
||||
|
||||
## Marker format
|
||||
|
||||
```ts
|
||||
// CIBG-GAP EXTENSION: <closest CIBG concept, or "n/a"> — <why hand-rolled>
|
||||
```
|
||||
|
||||
placed above the `@Component` decorator, plus `parameters: { cibgGap: true }` and a
|
||||
"CIBG-gap extension" line in the story's `docs.description.component`.
|
||||
|
||||
## The register
|
||||
|
||||
| Component | Closest CIBG concept | Why hand-rolled |
|
||||
| --------------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `skeleton` | Laadindicatie | No loading-skeleton class in the vendored build. |
|
||||
| `spinner` | Laadindicatie | No loading-spinner class in the vendored build. |
|
||||
| `rich-text-editor` | Tekstgebied | No rich-text/WYSIWYG pattern; toolbar buttons still use vendored `.btn-ghost` (WP-10). |
|
||||
| `wizard-shell` (error summary only) | Foutmelding | No error-summary/Veldvalidatie list class; renders inside a vendored `.feedback-error` alert. |
|
||||
| `application-link` (non-navigating row) | Aanvragen | The vendored `.dashboard-block.applications li a` chain only styles `<a>`; `.static-row` mirrors it from tokens for the informational (non-link) case. |
|
||||
| `debug-state` | n/a | Dev-only tool, deliberately off-theme — see the component's own `ponytail:` note. |
|
||||
| `status-badge` | n/a | Deliberate custom status dot, not Bootstrap's `.badge` (pill padding/colour don't fit). |
|
||||
| `placeholder-chip` | n/a | No vendored inline-chip/tag class. |
|
||||
|
||||
Not a gap: `confirmation` renders entirely with vendored `.confirmation*` classes (no `styles:
|
||||
[...]` block) — its header comment names the pattern, no marker needed. The `upload/` suite
|
||||
renders entirely with vendored classes (`.file-picker-drop-area`, `.btn-upload`, …) — reworked
|
||||
onto them rather than marked (see WP-11's correction note). `task-list`, `application-list`, and
|
||||
`choice-list` each wrap a distinct vendored pattern (Keuzelijst / Aanvragen / Keuzelijst) and name
|
||||
it in their own header comment — no marker needed, they don't hand-roll surface CSS.
|
||||
|
||||
## Hygiene
|
||||
|
||||
`upload-status-banner` (a 23-line near-identity wrapper over `app-alert` with one consumer) was
|
||||
deleted; its consumer (`document-upload`) now uses `<app-alert>` directly.
|
||||
|
||||
`card` (`.app-card`, a generic white surface) was deleted — it had zero consumers; the grey
|
||||
vendored **Datablock** (`app-data-block`) is the single data surface. The convergence verdicts
|
||||
for the pairs we deliberately keep separate live in
|
||||
[Atomic Design → Convergence decisions](?path=/docs/foundations-atomic-design--docs).
|
||||
|
||||
## Keeping this register honest
|
||||
|
||||
No automated check diffs this table against the markers in code (skipped as not worth a CI
|
||||
script for a table this small — reviewed at PR time instead, same as any other doc). If markers
|
||||
and this table drift, trust the code and fix the table.
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
import { useState, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
<Meta title="Foundations/Design Tokens" />
|
||||
|
||||
# Design tokens
|
||||
|
||||
We do not hand-write colours or spacing. `src/styles.scss` defines a semantic `--rhc-*` token
|
||||
vocabulary and redefines every one of those tokens onto the vendored **CIBG Huisstijl**
|
||||
(Bootstrap 5.2) values — `--bs-*`/`--ro-*` custom properties where one exists, CIBG palette hex
|
||||
otherwise (that one file is exempt from `npm run check:tokens`, which fails the build on any
|
||||
_other_ hardcoded hex colour in atoms/molecules/chrome). The `--rhc-*` names are an internal
|
||||
alias set now; the values are CIBG's.
|
||||
|
||||
**Prefer a CIBG class over a token where one exists** — `.btn`, `.form-control`, `.card`,
|
||||
`.stepper`, `.confirmation`, `.applications`, … are already themed by the vendored CSS (see
|
||||
`public/cibg-huisstijl/`). Reach for a `--rhc-*` token only where CIBG has no ready-made class
|
||||
(an `alert` surface, a skeleton loader, a status badge — see ADR-0003).
|
||||
|
||||
> Resolved values below are read live from the running theme via `getComputedStyle`, so they
|
||||
> can't drift from what ships. `body.brand--cibg` (set in `index.html` and Storybook's
|
||||
> `preview.ts`) activates CIBG's robijn/lintblauw palette; no extra wrapper class is needed.
|
||||
|
||||
export const Resolved = ({ token }) => {
|
||||
const ref = useRef(null);
|
||||
const [val, setVal] = useState('');
|
||||
useLayoutEffect(() => {
|
||||
if (ref.current) setVal(getComputedStyle(ref.current).getPropertyValue(token).trim());
|
||||
}, [token]);
|
||||
return (
|
||||
<span ref={ref} style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: '#666' }}>
|
||||
{val || '…'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
## When to use which token
|
||||
|
||||
- **CIBG class first** (see above) — a token is for the gaps a CIBG class doesn't cover.
|
||||
- **Semantic first** — reach for a role token (`--rhc-color-foreground-default`,
|
||||
`--rhc-color-border-default`, `--rhc-color-foreground-link`) before a raw palette step
|
||||
(`--rhc-color-lintblauw-500`). Roles survive a theme swap; palette steps don't.
|
||||
- **`--rhc-space-max-*`** for all spacing/gaps — never a raw `rem`.
|
||||
- **`--app-*`** (in `src/styles.scss`) only for app measures CIBG has no token for
|
||||
(`--app-content-max`, `--app-form-narrow`). If you're tempted to add one, check CIBG first.
|
||||
|
||||
## Spacing scale — `--rhc-space-max-*`
|
||||
|
||||
<div style={{ display: 'grid', gap: '0.4rem', margin: '1rem 0' }}>
|
||||
{['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl'].map((step) => {
|
||||
const token = `--rhc-space-max-${step}`;
|
||||
return (
|
||||
<div key={step} style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<code style={{ width: '12rem', fontSize: '0.78rem' }}>{token}</code>
|
||||
<div
|
||||
style={{
|
||||
height: '1rem',
|
||||
width: `var(${token})`,
|
||||
background: 'var(--rhc-color-lintblauw-500)',
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
/>
|
||||
<Resolved token={token} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
## Semantic colours
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(14rem, 1fr))',
|
||||
gap: '0.75rem',
|
||||
margin: '1rem 0',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
'--rhc-color-foreground-default',
|
||||
'--rhc-color-foreground-subtle',
|
||||
'--rhc-color-foreground-link',
|
||||
'--rhc-color-layout',
|
||||
'--rhc-color-lintblauw-500',
|
||||
'--rhc-color-lintblauw-700',
|
||||
'--rhc-color-border-default',
|
||||
'--rhc-color-border-strong',
|
||||
'--rhc-color-cool-grey-100',
|
||||
].map((token) => (
|
||||
<div key={token} style={{ border: '1px solid #ddd', borderRadius: '6px', overflow: 'hidden' }}>
|
||||
<div style={{ height: '3rem', background: `var(${token})` }} />
|
||||
<div style={{ padding: '0.4rem 0.5rem' }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.72rem', wordBreak: 'break-all' }}>
|
||||
{token}
|
||||
</div>
|
||||
<Resolved token={token} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
|
||||
import * as AsyncStories from '../src/ui/async/async.stories';
|
||||
|
||||
<Meta title="Foundations/FP in the UI" />
|
||||
|
||||
# Functional programming in the UI
|
||||
|
||||
The components in this library are the _view_. Behind them, three small functional tools do
|
||||
the heavy lifting — all so that **illegal states can't be represented**. This page is the
|
||||
Storybook front door; the full narrative lives in `docs/reference/fp-tea-atomic-design.md`, and a
|
||||
side-by-side "before/after" runs at the app's **`/concepts`** route.
|
||||
|
||||
## 1. `RemoteData<E,T>` — async has four states, not a boolean soup
|
||||
|
||||
`src/app/shared/application/remote-data.ts`. Instead of juggling `loading`, `error`, and
|
||||
`data` flags (which permit "loading **and** error" nonsense), one tagged union:
|
||||
`Loading | Empty | Failure | Success`. You combine sources with `map`/`map2`/`andThen` and
|
||||
render it through the `async` molecule — exactly one of four templates shows, by
|
||||
construction:
|
||||
|
||||
<Canvas of={AsyncStories.Loading} />
|
||||
<Canvas of={AsyncStories.ErrorState} />
|
||||
|
||||
## 2. The Elm-style store — all state in one Model, changed only by pure `reduce`
|
||||
|
||||
`src/app/shared/application/store.ts` + the `*.machine.ts` files. State is one tagged-union
|
||||
value; the template never mutates it, it `dispatch`es a message and a **pure**
|
||||
`reduce(model, msg)` returns the next state. Side effects live in a _command_, never in the
|
||||
reducer:
|
||||
|
||||
```ts
|
||||
// reducer = "what the new state is" — pure, testable, no I/O
|
||||
function reduce(model: Model, msg: Msg): Model { … }
|
||||
|
||||
// command = "go do it, then say what happened"
|
||||
async function submit(...) {
|
||||
const res = await http(...);
|
||||
dispatch(res.ok ? { tag: 'Submitted' } : { tag: 'Failed', error: res.error });
|
||||
}
|
||||
```
|
||||
|
||||
Because state is one value, the whole thing is inspectable and every transition has a spec.
|
||||
|
||||
## 3. Parse, don't validate — raw input becomes a branded type once
|
||||
|
||||
`src/app/registratie/domain/value-objects/`. A `Postcode` is a distinct type from `string`,
|
||||
mintable only through `parsePostcode`, which returns a `Result`. Once you hold the type, you
|
||||
never re-check it — the type _is_ the proof. Compose the parse pipeline with the `Result`
|
||||
combinators in `src/app/shared/kernel/fp.ts` (`map`, `mapErr`, `andThen`, `fold`) rather than
|
||||
hand-branching `r.ok ? … : …` at every step.
|
||||
|
||||
```ts
|
||||
parsePostcode(raw) // Result<string, Postcode>
|
||||
|> mapErr(toLocalizedMessage) // swap raw msg → UI copy
|
||||
|> map(toDomain) // only runs on success
|
||||
```
|
||||
|
||||
## How it connects to atomic design
|
||||
|
||||
Atoms and molecules are pure view functions of their inputs; pages are the TEA runtime (the
|
||||
"shell") that holds the store and wires effects. Same inward-pointing discipline as the
|
||||
[layer rule](?path=/docs/foundations-atomic-design--docs), applied to state and effects
|
||||
instead of imports.
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/Internationalization" />
|
||||
|
||||
# Internationalization (the locale seam)
|
||||
|
||||
Every user-visible string goes through Angular's first-party **`$localize`** — no
|
||||
third-party i18n library. The source locale is **`nl`**; a second locale is a
|
||||
**translation file, not a code change**. That's the seam: adding English touched
|
||||
`src/locale/messages.en.xlf`, not the components.
|
||||
|
||||
## How it's wired
|
||||
|
||||
| Piece | Where | What |
|
||||
| -------------------------- | ----------------------------------------- | ------------------------------------------------------------ |
|
||||
| Source locale | `angular.json` → `i18n.sourceLocale` | `nl` — the language the code is written in |
|
||||
| Locales | `angular.json` → `i18n.locales.en` | points at `src/locale/messages.en.xlf` |
|
||||
| Missing-translation policy | `angular.json` → `i18nMissingTranslation` | `error` — a missing `<target>` fails the build |
|
||||
| Runtime global | `angular.json` → `polyfills` | `@angular/localize/init` provides `$localize` |
|
||||
| English build/serve | `angular.json` → `configurations.en` | `ng build --configuration=en`, `ng serve --configuration=en` |
|
||||
|
||||
Locale switching is **build-time**, not runtime: each locale is its own bundle. There is
|
||||
no in-app language picker (out of scope for the POC).
|
||||
|
||||
## Authoring copy
|
||||
|
||||
Two forms, same custom-id rule. The id is **stable** and shaped `@@<context>.<key>`, so
|
||||
translations survive copy edits.
|
||||
|
||||
**In TS logic / value objects — tagged template:**
|
||||
|
||||
```ts
|
||||
// src/app/registratie/domain/value-objects/postcode.ts
|
||||
return err($localize`:@@validation.postcode:Voer een geldige postcode in, bijv. 1234 AB.`);
|
||||
```
|
||||
|
||||
With placeholders (named, so translators can reorder):
|
||||
|
||||
```ts
|
||||
$localize`:@@aanvraag.row.ingediend:ingediend op ${formatDatumNl(a.submittedAt)}:datum:`;
|
||||
```
|
||||
|
||||
**In inline component templates — the `i18n` attribute:**
|
||||
|
||||
```html
|
||||
<!-- src/app/auth/ui/login-form/login-form.component.ts -->
|
||||
<app-button type="submit" i18n="@@login.submit">Inloggen met DigiD</app-button>
|
||||
```
|
||||
|
||||
**Shared/English components never hardcode Dutch.** They expose copy as `input()`s with
|
||||
localizable defaults; the domain caller may override. See
|
||||
`shared/ui/async/async.component.ts`:
|
||||
|
||||
```ts
|
||||
errorText = input($localize`:@@async.error:Er ging iets mis bij het laden van de gegevens.`);
|
||||
```
|
||||
|
||||
## Extract & translate loop
|
||||
|
||||
```bash
|
||||
npm run extract-i18n # ng extract-i18n → src/locale/messages.xlf (source, nl)
|
||||
```
|
||||
|
||||
Then a translator fills `<target>`s in `src/locale/messages.en.xlf`. Both files carry the
|
||||
same trans-units (currently 690 = 690, no drift); the `.en.xlf` header is
|
||||
`source-language="nl" target-language="en"`. Because `i18nMissingTranslation: error`, a
|
||||
forgotten target breaks the `en` build rather than silently shipping Dutch.
|
||||
|
||||
## Testing languages without coupling to the strings
|
||||
|
||||
**Rule: never assert on rendered copy.** Copy is the thing that changes per locale and per
|
||||
edit — a test that reads `"Voer een geldige postcode in"` breaks the moment a translator or
|
||||
a product owner touches the wording, in every locale. Assert on what's _invariant_ instead:
|
||||
|
||||
- **Parsers / value objects** — assert on the `Result` discriminant and the parsed value,
|
||||
not the error message. This is the existing house pattern
|
||||
(`registratie/domain/value-objects/postcode.spec.ts`):
|
||||
|
||||
```ts
|
||||
expect(parsePostcode('0234AB').ok).toBe(false); // rejects — never inspects the $localize string
|
||||
```
|
||||
|
||||
- **The seam itself** — if you must verify that translation works, check that a known
|
||||
**id flips**, not that a specific phrase appears. Build/serve the `en` configuration and
|
||||
confirm the target for a stable id renders, e.g. `login.submit`: `nl` "Inloggen met
|
||||
DigiD" → `en` "Log in with DigiD". You're testing the wiring, not the wording.
|
||||
|
||||
```bash
|
||||
ng serve --configuration=en # then eyeball, or point an e2e at the en bundle
|
||||
```
|
||||
|
||||
See [Testing strategy](?path=/docs/foundations-testing-strategy--docs) for how this fits the
|
||||
rest of the test pyramid.
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/Domain-Driven Design" />
|
||||
|
||||
# Domain-driven design: bounded contexts & layers
|
||||
|
||||
This project is **domain-driven**: the code is organised first by **bounded context**
|
||||
(a business capability with its own language) and then by **layer** inside each context,
|
||||
with dependencies pointing inward. The Storybook sidebar is laid out to **be** that
|
||||
architecture, not just document it: **Foundations** (this curriculum) → **Design System**
|
||||
(reusable, domain-free) → **Domein** (the six DDD contexts). If a component lives under a context's `ui/`, it's in Domein; everything else
|
||||
in `shared/ui`/`shared/layout` is Design System. See [Atomic design](?path=/docs/foundations-atomic-design--docs)
|
||||
for the Atoms → Molecules → Organisms → Templates ladder inside Design System.
|
||||
|
||||
## Six contexts, one direction
|
||||
|
||||
```
|
||||
src/app/<context>/<layer>/
|
||||
```
|
||||
|
||||
Contexts: `shared` (the base layer — depends on nothing), `auth`, `registratie`,
|
||||
`herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching page,
|
||||
sanctioned to read every context — nothing imports it).
|
||||
|
||||
**Dependencies only point inward and in one declared direction between contexts:**
|
||||
|
||||
```
|
||||
herregistratie → registratie → shared
|
||||
auth → shared
|
||||
brief → shared
|
||||
```
|
||||
|
||||
Never the other way — `registratie` may not import `herregistratie`, and no context but
|
||||
`shared` is imported by everyone.
|
||||
|
||||
## Five layers, one direction
|
||||
|
||||
| Layer | Job | Angular allowed? |
|
||||
| ----------------- | ----------------------------------------- | ----------------- |
|
||||
| `domain/` | business rules + data types | **No — pure TS.** |
|
||||
| `application/` | coordinate state/tasks (stores, commands) | yes (signals) |
|
||||
| `infrastructure/` | where data comes from (HTTP adapters) | yes (HTTP) |
|
||||
| `contracts/` | wire DTOs (the FE⇄BE seam) | no |
|
||||
| `ui/` | how it looks (components, pages) | yes |
|
||||
|
||||
`ui → application → domain`; `ui`/`layout` never import `infrastructure/` directly — they
|
||||
reach data through an application store or command.
|
||||
|
||||
## This is enforced, not just written down
|
||||
|
||||
`eslint.config.mjs` fails the build on every rule above:
|
||||
|
||||
- `domain/` importing `@angular/*` at all (any context).
|
||||
- `shared/` importing a feature context (`@auth/*`, `@registratie/*`, `@herregistratie/*`,
|
||||
`@brief/*`) — the base layer depends on nothing.
|
||||
- `registratie/` importing `@herregistratie/*`/`@brief/*`, `auth/`/`brief/` importing a
|
||||
sibling context — the cross-context direction above.
|
||||
- `contracts/**` importing **anything** — not Angular, not an alias, not even a relative
|
||||
path (ADR-0001's wire seam has to stay a pure DTO shape).
|
||||
- `ui/**`/`layout/**` importing `*/infrastructure/*` — the anti-corruption boundary
|
||||
(ADR-0001) stays behind a store/command, so a page can never bypass it and hand-recompute
|
||||
a business rule the backend already decided.
|
||||
- The generated `ApiClient` imported as a value outside an `infrastructure/` adapter
|
||||
(type-only DTO imports are exempt — they grant no network access).
|
||||
|
||||
Two components get a documented exemption from the "nothing reaches across" rule:
|
||||
`shared/ui/debug-state` (reads every root store, for the dev-only state panel) and
|
||||
`showcase/` (reads every context, for side-by-side teaching pages). Both exemptions live
|
||||
next to the rule they break, in `eslint.config.mjs`, so they can't rot silently.
|
||||
|
||||
## The English/Dutch seam
|
||||
|
||||
Shared/reusable UI is named in **English** (language-agnostic: `button`, `wizard-shell`);
|
||||
domain contexts are named in **Dutch** (`registratie`, `herregistratie`, `*.machine.ts`).
|
||||
Pick the language by which side of the seam the code is on — it's the same seam this
|
||||
sidebar's Design System/Domein split makes visible.
|
||||
|
||||
## See it in the sidebar
|
||||
|
||||
Compare a Design System primitive with the same shape reused across contexts:
|
||||
|
||||
- [Design System → Molecules → Application Link](?path=/story/design-system-molecules-application-link--navigatie) —
|
||||
domain-free, the caller supplies heading/subtitle/cta.
|
||||
- [Domein → Registratie → Aanvraag Block](?path=/story/domein-registratie-aanvraag-block--concept) —
|
||||
a context-specific organism composed from Design System atoms/molecules.
|
||||
@@ -0,0 +1,375 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/Learning Path" />
|
||||
|
||||
# Learning path
|
||||
|
||||
A paced, hands-on route through this codebase for a developer who is a **strong
|
||||
programmer but new to frontend functional programming**. The [Overview](?path=/docs/foundations-overview--docs)
|
||||
is the _map_ — every idea, cross-linked. This is the _route_: what to read first, what
|
||||
to **do** to make it stick, and how to know you understood it. Work through it over
|
||||
roughly three days.
|
||||
|
||||
Each lesson has the same shape:
|
||||
|
||||
- **Goal** — one sentence: what you'll be able to do.
|
||||
- **~time** — a rough budget so a day stays a day.
|
||||
- a few paragraphs that **teach the idea** (self-contained — you can read straight
|
||||
through), then
|
||||
- **Do** — a hands-on exercise. Most reuse the repo's invocable skills (`/new-feature`,
|
||||
`/form-machine`, …), which scaffold real code the house way.
|
||||
- **Check yourself** — a question; if you can answer it, move on.
|
||||
- **Go deeper** — the deep-dive page and the long-form source in `docs/reference/`.
|
||||
|
||||
The one idea underneath everything: **make illegal states unrepresentable.** Every rule
|
||||
below is a way to stop the compiler letting you build a state that can't actually happen.
|
||||
|
||||
---
|
||||
|
||||
## Day 1 — Orient: the shape of the codebase
|
||||
|
||||
### 1.1 Why this exists — state that can lie · ~15 min
|
||||
|
||||
**Goal:** name the failure mode this whole architecture is designed to prevent.
|
||||
|
||||
Most UI bugs are not wrong algorithms — they're **impossible states that the types
|
||||
allowed anyway**. `isLoading` true _and_ `error` set _and_ `data` present: three
|
||||
booleans give eight combinations, but only four are real. The extra four are bugs
|
||||
waiting to be rendered. The reflex this codebase trains: when you reach for a second or
|
||||
third boolean to track one thing, model a **discriminated union** instead, so the
|
||||
illegal combinations can't be typed.
|
||||
|
||||
The second big idea is structural. The folder layout is not filing — **the folder
|
||||
structure _is_ the architecture**. Where a file lives declares what it's allowed to
|
||||
depend on, and that rule is enforced by lint, not hoped for. You'll meet the same
|
||||
"compose small honest pieces, forbid the illegal combinations" principle at three
|
||||
scales today and tomorrow: in the domain model, in the component tree, and in state.
|
||||
|
||||
**Do:** open `src/app/` and read the top of `CLAUDE.md` ("The decisions"). Just get the
|
||||
lay of the land — six contexts, five layers.
|
||||
|
||||
**Check yourself:** three booleans model how many states, and how many are real for a
|
||||
"fetch"? Why is that gap the enemy?
|
||||
|
||||
**Go deeper:** `docs/reference/fp-tea-atomic-design.md` Part 1.
|
||||
|
||||
### 1.2 Domain-driven design: contexts then layers · ~25 min
|
||||
|
||||
**Goal:** predict which imports are legal before the linter tells you.
|
||||
|
||||
Code is organised first by **bounded context** — a business capability with its own
|
||||
language: `shared`, `auth`, `registratie`, `herregistratie`, `brief`, `showcase`. Inside
|
||||
each context are five **layers**, and dependencies only ever point **inward**:
|
||||
|
||||
| Layer | Job | Angular? |
|
||||
| ----------------- | ----------------------------------------- | -------------------------------- |
|
||||
| `domain/` | business rules + data types | **No — pure TS**, has `.spec.ts` |
|
||||
| `application/` | coordinate state/tasks (stores, commands) | yes (signals) |
|
||||
| `infrastructure/` | where data comes from (HTTP) | yes |
|
||||
| `contracts/` | wire DTOs (the FE⇄BE seam) | no |
|
||||
| `ui/` | how it looks | yes |
|
||||
|
||||
`ui → application → domain`, never the reverse; `ui` never touches `infrastructure`
|
||||
directly. Cross-context is one-directional too: `herregistratie → registratie → shared`,
|
||||
`auth → shared`, `brief → shared`. A context downstream may lean on one upstream; the
|
||||
upstream never learns the downstream exists. This keeps the domain pure and testable and
|
||||
stops the dependency graph rotting into a ball of mud.
|
||||
|
||||
**Do:** open `eslint.config.mjs` and find the import-boundary rules. Then pick any file
|
||||
in `herregistratie/` and trace one import back into `registratie` or `shared`.
|
||||
|
||||
**Check yourself:** why may `herregistratie` import from `registratie`, but `registratie`
|
||||
may **not** import from `herregistratie`? What breaks if you invert it?
|
||||
|
||||
**Go deeper:** [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs);
|
||||
`docs/reference/architecture/ARCHITECTURE.md` §1.
|
||||
|
||||
### 1.3 Atomic design: composition is the default · ~20 min
|
||||
|
||||
**Goal:** decide, for a new screen, whether to add a building block or just compose.
|
||||
|
||||
The design system is a ladder: **Atoms → Molecules → Organisms → Templates**, each level
|
||||
built only from the level below. Atoms (`button`, `form-field`) are thin typed wrappers
|
||||
over CIBG Huisstijl CSS classes; molecules compose atoms; organisms compose molecules;
|
||||
templates lay out organisms; a context's `ui/` page composes templates. A new page should
|
||||
be **composition of existing blocks** — adding a block is the exception, not the reflex.
|
||||
|
||||
Notice this is the same shape as 1.2: small honest pieces, each only allowed to reach
|
||||
one level down, illegal combinations forbidden by structure. That's not a coincidence —
|
||||
you'll see why tomorrow.
|
||||
|
||||
**Do:** trace a real composition chain in Storybook: **Atoms → Button**, then find where
|
||||
it's used up through `form-field → document-upload → page-shell`. Watch each level only
|
||||
reach one level down.
|
||||
|
||||
**Check yourself:** you need a new "application summary" screen. What's the first
|
||||
question you ask before writing a component?
|
||||
|
||||
**Go deeper:** [Atomic design](?path=/docs/foundations-atomic-design--docs). Adding a
|
||||
block (only when composition truly can't do it): the `/ui-component` skill.
|
||||
|
||||
---
|
||||
|
||||
## Day 2 — The functional core
|
||||
|
||||
### 2.1 FP fundamentals · ~25 min
|
||||
|
||||
**Goal:** read code as "functional core, imperative shell" and spot which is which.
|
||||
|
||||
Four tools do the heavy lifting. **Pure functions:** output depends only on input, no
|
||||
side effects — trivially testable, no mocks. **Immutability:** you compute new values,
|
||||
you don't mutate old ones, so nothing changes under you. **Unidirectional flow:** data
|
||||
moves one way (state → view → message → new state), never a tangle of two-way bindings.
|
||||
**Sum and product types:** a _product_ is "A and B" (a record); a _sum_ is "A **or** B"
|
||||
(a discriminated union) — sums are how you make illegal states unrepresentable.
|
||||
|
||||
Put together: the **functional core** is pure logic (all of `domain/`, the reducers, the
|
||||
parsers) that knows nothing about Angular or HTTP; the **imperative shell** (components,
|
||||
adapters) does the messy I/O and hands data in and out of the core. Bugs hide in the
|
||||
shell; the core stays provable.
|
||||
|
||||
**Do:** open any `domain/` file next to its `.spec.ts` and confirm the spec uses no
|
||||
Angular `TestBed` — it calls the function directly. That's the core being pure.
|
||||
|
||||
**Check yourself:** which of these is a sum type and why — "a form field's value" vs. "a
|
||||
form's submission state (idle / submitting / failed / done)"?
|
||||
|
||||
**Go deeper:** [FP in the UI](?path=/docs/foundations-fp-in-the-ui--docs);
|
||||
`docs/reference/fp-tea-atomic-design.md` Part 2.
|
||||
|
||||
### 2.2 State machines — The Elm Architecture · ~30 min
|
||||
|
||||
**Goal:** model a form as `Model → Msg → reduce`, with effects kept out of the reducer.
|
||||
|
||||
Every form and wizard here is one state machine: a **Model** (a tagged union — the
|
||||
current state), a **Msg** union (everything that can happen), and a **pure** `reduce(model,
|
||||
msg): model`. The template never mutates state; it **dispatches a message**, `reduce`
|
||||
returns the next model, the view re-renders. All wiring goes through one idiom,
|
||||
`createStore(initial, reduce)` — you never hand-roll `signal(model)` + a local dispatch.
|
||||
|
||||
The rule that keeps `reduce` pure: **side effects live in commands, not the reducer.** A
|
||||
command (`application/submit-*.ts`) does the HTTP, then dispatches a message describing
|
||||
the _outcome_. Reducer = "what the new state is"; command = "go do it, then say what
|
||||
happened." And **derive, don't store** anything you can compute — e.g. a wizard's visible
|
||||
steps are `visibleSteps(answers)`, not a stored field.
|
||||
|
||||
A field's value lands in the Model on **every keystroke** (not on blur — blur only marks
|
||||
the field "touched"); a separate 600 ms debounce off the model snapshot autosaves the
|
||||
draft to the backend, an effect that lives _outside_ the reducer. See
|
||||
`docs/reference/architecture/ARCHITECTURE.md` §2g.
|
||||
|
||||
**Do:** run `/form-machine` for a toy single field (say a "nickname" field with a max
|
||||
length). Read the generated Model / Msg / reduce and its spec.
|
||||
|
||||
**Check yourself:** why can't `reduce` make the HTTP call itself? What goes wrong if it
|
||||
does?
|
||||
|
||||
**Go deeper:** [State machines (TEA)](?path=/docs/foundations-state-machines-tea--docs);
|
||||
`docs/reference/fp-tea-atomic-design.md` Parts 3–4.
|
||||
|
||||
### 2.3 RemoteData & async · ~20 min
|
||||
|
||||
**Goal:** replace loading/error/empty booleans with one four-state value.
|
||||
|
||||
`RemoteData<E,T>` is a sum type with exactly four cases: `Loading | Empty |
|
||||
Failure{error} | Success{value}`. That's the four _real_ states from lesson 1.1, and no
|
||||
others — you literally cannot construct "loading and error." Combine sources with
|
||||
`map` / `map2` / `andThen` (precedence: Failure > Loading > Empty > Success), and render
|
||||
it with the `<app-async>` molecule, which picks one of four mutually-exclusive templates
|
||||
by construction. The default spinner is delay-gated (~250 ms) so fast connections don't
|
||||
flash.
|
||||
|
||||
**Do:** open a data page in the running app with `?scenario=slow`, then `?scenario=empty`,
|
||||
then `?scenario=error` (the dev-only scenario toggle). Watch `<app-async>` switch
|
||||
templates without any `*ngIf` soup.
|
||||
|
||||
**Check yourself:** a page combines two independent fetches with `map2`. One is still
|
||||
loading, the other has failed — what does the combined value show, and why that
|
||||
precedence?
|
||||
|
||||
**Go deeper:** [RemoteData & Async](?path=/docs/foundations-remotedata-async--docs);
|
||||
`docs/reference/architecture/ARCHITECTURE.md` §2.
|
||||
|
||||
### 2.4 Parse, don't validate · ~20 min
|
||||
|
||||
**Goal:** turn untrusted input into a domain type once, then trust it forever.
|
||||
|
||||
Raw input (`unknown`, a string, a wire DTO) becomes a **branded value object** only by
|
||||
passing through a **parser** that returns `Result<E,T>` — `parsePostcode`, `parseUren`,
|
||||
`parseBigNummer`. Once you hold a `Postcode`, its shape is guaranteed by the type system;
|
||||
you **never re-check it**. This happens in two places: value objects (form fields) and
|
||||
boundary `parse*` adapters in `infrastructure/` (the FE⇄BE seam, where untrusted JSON
|
||||
becomes domain types). "Validate" scatters `if`-checks everywhere and forgets one;
|
||||
"parse" concentrates the check at the door and lets the compiler enforce the rest.
|
||||
|
||||
**Why "brand"?** TypeScript is _structurally_ typed, so a bare `type Postcode = string`
|
||||
would accept any string and lose all proof of validation. Intersecting a phantom marker —
|
||||
`string & { readonly __brand: 'Postcode' }` — makes the type **nominal**: no plain string
|
||||
satisfies it, so the only way to hold a `Postcode` is to go through the parser that stamps
|
||||
the brand. The brand is compile-time proof the value was validated (it exists only in the
|
||||
types, never at runtime). The DDD name for the concept is a _value object_; "brand" is just
|
||||
the TypeScript trick that makes it enforceable.
|
||||
|
||||
**Do:** run `/value-object` for a small field (e.g. a Dutch phone number). Read the parser
|
||||
and its spec — note it returns `Result`, not a boolean, and note the branded type.
|
||||
|
||||
**Check yourself:** you're three functions deep and you hold a `Postcode`. Should you
|
||||
re-validate its format? Why not?
|
||||
|
||||
**Go deeper:** [Parse, don't validate](?path=/docs/foundations-parse-dont-validate--docs);
|
||||
`docs/reference/architecture/ARCHITECTURE.md` §3.
|
||||
|
||||
### Day 2 closer — one principle, two scales
|
||||
|
||||
You've now seen it twice: **small honest pieces, each only allowed to reach one level
|
||||
down, with illegal combinations forbidden by structure.** Atomic design applies it to
|
||||
_components_ (atoms compose upward); The Elm Architecture applies it to _state_ (pure
|
||||
`reduce` composes messages into models). They are the same principle at two scales — that
|
||||
is the thesis of this codebase. Read `docs/reference/fp-tea-atomic-design.md` Part 5; it's
|
||||
the "aha" that ties Day 1 and Day 2 together.
|
||||
|
||||
---
|
||||
|
||||
## Day 3 — Quality & shipping
|
||||
|
||||
### 3.1 Testing strategy — what to test, by layer · ~20 min
|
||||
|
||||
**Goal:** know where a test goes and what kind it is, given any change.
|
||||
|
||||
Test grain follows the layer. **Domain and pure logic must have a spec** — reducers,
|
||||
combinators, `visibleSteps`, parsers, boundary `parse*` adapters — tested **directly, no
|
||||
TestBed**, because they're pure. **UI is exercised via Storybook stories** (co-located
|
||||
`*.stories.ts`, a11y addon on), not heavy component tests. Backend rules have their own
|
||||
`dotnet test`. The GREEN gate before you push: `npm run lint && npm test && npm run build`
|
||||
(plus `cd backend && dotnet test`).
|
||||
|
||||
**Do:** run `/test-strategy` and read where it says each layer's test belongs. Then run
|
||||
`npm test` and watch the pure specs fly (no browser, no mocks).
|
||||
|
||||
**Check yourself:** you add a new parser and a new page. Which gets a `.spec.ts`, and
|
||||
which gets a Storybook story instead?
|
||||
|
||||
**Go deeper:** [Testing strategy](?path=/docs/foundations-testing-strategy--docs);
|
||||
`CLAUDE.md` §5.
|
||||
|
||||
### 3.2 BDD — one behaviour per test · ~15 min
|
||||
|
||||
**Goal:** write test names that read as a specification in the domain's language.
|
||||
|
||||
`describe` names the subject; each `it` states **one observable behaviour** in
|
||||
present tense — no `should`, no Given/When/Then ceremony. One behaviour per test means one
|
||||
_behaviour_, not one `expect`: assertions pinning down the same behaviour stay together
|
||||
(a `Result`'s `.ok` then its `.value`); assertions about different behaviours split apart
|
||||
(the ok branch **and** the err branch). If a title needs "and"/"then"/"/" to join two
|
||||
things, that's the smell — split it. And speak the **ubiquitous language**: a _behandelaar_
|
||||
drafts, a _beoordelaar_ approves — the same words as the bounded contexts.
|
||||
|
||||
**Do:** read `registratie/domain/registratie-wizard.machine.spec.ts` — one transition per
|
||||
test, each named as a behaviour. (You saw this style get enforced when the specs were
|
||||
recently split.)
|
||||
|
||||
**Check yourself:** `it('submits and then shows the reference')` — what's wrong with this
|
||||
name?
|
||||
|
||||
**Go deeper:** [BDD](?path=/docs/foundations-bdd--docs).
|
||||
|
||||
### 3.3 Accessibility — four layered tools · ~15 min
|
||||
|
||||
**Goal:** know which a11y bug each tool catches, and what only a human catches.
|
||||
|
||||
Four layers, each a different bug class: **axe on every story** (CI-gated, catches
|
||||
contrast/roles/labels), **template a11y lint** (catches missing alt/labels at author
|
||||
time), **Storybook play tests** (catches keyboard/focus interaction), and a **manual WCAG
|
||||
checklist** for what automation can't — tab order across a page, focus traps, 200% zoom,
|
||||
screen-reader narration. a11y is a build gate here, not a nice-to-have.
|
||||
|
||||
**Do:** open any story and check the **Accessibility** tab (axe results). Then skim
|
||||
`docs/reference/wcag-checklist.md` — note the honest empty "Screen reader" column: some
|
||||
things only a human pass finds.
|
||||
|
||||
**Check yourself:** axe passes on a form. Name one real a11y bug it still can't catch.
|
||||
|
||||
**Go deeper:** [Accessibility](?path=/docs/foundations-accessibility--docs).
|
||||
|
||||
### 3.4 Internationalization — the locale seam · ~15 min
|
||||
|
||||
**Goal:** wrap user-facing copy so a second language is a translation file, not a code
|
||||
change.
|
||||
|
||||
Every user-visible string is wrapped in Angular's first-party `$localize` with a stable
|
||||
custom id — `` $localize`:@@context.key:Tekst` ``. Source locale is `nl`; English is a
|
||||
translation file, not edited code — that's the seam. Shared/English components must **not**
|
||||
hardcode Dutch: they expose copy as `input()`s with localizable defaults, and the Dutch
|
||||
domain caller supplies the text.
|
||||
|
||||
**Do:** grep for `$localize` in a `ui/` component; note the `@@`-prefixed stable ids. Find
|
||||
one `shared/ui` component that takes copy as an `input()` rather than hardcoding it.
|
||||
|
||||
**Check yourself:** why must a shared English atom take its label as an `input()` instead
|
||||
of writing the Dutch word directly?
|
||||
|
||||
**Go deeper:** [Internationalization](?path=/docs/foundations-internationalization--docs).
|
||||
|
||||
### 3.5 The design-system track (parallel) · ~15 min
|
||||
|
||||
**Goal:** style via semantic tokens and the CIBG Huisstijl, never hand-written colours.
|
||||
|
||||
This strand is largely independent of the FP/state spine — learn it whenever. The app
|
||||
speaks a semantic `--rhc-*` token vocabulary; `src/styles.scss` is a **token bridge** that
|
||||
maps those onto the vendored **CIBG Huisstijl** (a customized Bootstrap 5.2) `--bs-*`
|
||||
values. Rule: reach for a **CIBG class first, then a token** — no hand-written hex. Where
|
||||
CIBG lacks a class (e.g. `alert`), the atom is hand-rolled from tokens and recorded in the
|
||||
**CIBG gap register** so the divergence stays honest.
|
||||
|
||||
**Do:** open [Design tokens](?path=/docs/foundations-design-tokens--docs) and read the
|
||||
live swatches; then skim the [CIBG gap register](?path=/docs/foundations-cibg-gap-register--docs).
|
||||
|
||||
**Check yourself:** you need a warning colour. Where does it come from, and where does it
|
||||
**not**?
|
||||
|
||||
**Go deeper:** `docs/reference/architecture/0003-cibg-huisstijl.md` (ADR-0003).
|
||||
|
||||
---
|
||||
|
||||
## Capstone — add a feature end-to-end
|
||||
|
||||
**Goal:** ship one small vertical slice the house way, and name which layer owns each rule.
|
||||
|
||||
Two framing ideas first. **BFF-lite + decision DTOs (ADR-0001):** each screen gets one
|
||||
screen-shaped endpoint returning a **decision-enriched** DTO — the backend computes the
|
||||
business rules, and **the FE renders decisions, it does not recompute them.** Per rule you
|
||||
pick a _decision flag_ (server sends the boolean) or a _config value_ (server sends the
|
||||
threshold, FE applies it for instant feedback, server re-validates as authority). The FE
|
||||
keeps only **format** validation, never as authority.
|
||||
|
||||
Then the house pipeline, always in this order: **domain** (types + pure rules + spec, no
|
||||
Angular) → **infrastructure** (adapter with a `parse*` boundary, or a command returning
|
||||
`Result`) → **application** (a store if state is shared; union + pure `reduce`) → **ui**
|
||||
last (compose `shared/ui` atoms, wrap async in `<app-async>`, dispatch messages).
|
||||
|
||||
**Do:** build a tiny slice — e.g. a one-field "update phone number" action — using the
|
||||
skills in pipeline order:
|
||||
|
||||
1. `/value-object` — the field's parser + branded type (domain).
|
||||
2. `/bff-endpoint` — a screen-shaped read with a decision DTO + `parse*` boundary.
|
||||
3. `/form-machine` — the form's Model/Msg/reduce.
|
||||
4. `/mutation-command` — the write, returning `Result`, keeping the reducer pure.
|
||||
5. `/ui-component` **only if** no existing block composes — otherwise just compose.
|
||||
|
||||
`/new-feature` walks the whole pipeline if you'd rather do it in one guided pass.
|
||||
|
||||
**Check yourself:** for your slice, name for each business rule whether it's a _decision
|
||||
flag_ or a _config value_, and which layer owns it. If a rule lives in two layers, which
|
||||
one is the **authority**?
|
||||
|
||||
**Go deeper:** `docs/reference/architecture/0001-bff-lite-decision-dtos.md`;
|
||||
`docs/reference/fp-tea-atomic-design.md` Part 7 (the copy-paste recipes);
|
||||
`docs/reference/architecture/ARCHITECTURE.md` §4 (the recipe) and §6a (the full FE⇄BE
|
||||
request lifecycle, read + write, with file links). For how contexts scale to a second
|
||||
app and actor-based authorization, ADR-0002 (the advanced read).
|
||||
|
||||
---
|
||||
|
||||
You've done the route. From here the [Overview](?path=/docs/foundations-overview--docs)
|
||||
map is your reference, the deep-dive pages hold the detail, and the skills scaffold each
|
||||
new piece the house way.
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/State Machines (TEA)" />
|
||||
|
||||
# State machines (The Elm Architecture, in Angular)
|
||||
|
||||
Every form or wizard with validation or submission in this app is wired the **same
|
||||
way**: one Model, one Msg union, one pure `reduce`, one command per side effect. Pick any
|
||||
one — `herregistratie.machine.ts` is the fullest worked example — and the shape
|
||||
transfers everywhere else.
|
||||
|
||||
## Model / Msg / reduce
|
||||
|
||||
```ts
|
||||
// Model — everything the UI needs to render, as ONE tagged union
|
||||
export type WizardState = { tag: 'step1'; draft: Draft } | { tag: 'step2'; valid: Valid } | …;
|
||||
|
||||
// Msg — every way the Model is allowed to change
|
||||
export type WizardMsg = { tag: 'FieldChanged'; field: string; value: string } | { tag: 'NextStep' } | …;
|
||||
|
||||
// reduce — PURE: (current, message) -> next. No I/O, no Date.now(), no randomness.
|
||||
export function reduce(s: WizardState, m: WizardMsg): WizardState { … }
|
||||
```
|
||||
|
||||
Because the whole state is one value, a bug reproduces from a message log; because
|
||||
`reduce` is pure, every transition is a one-line assertion in a spec — no `TestBed`, no
|
||||
mocked HTTP, just `expect(reduce(state, msg)).toEqual(next)`.
|
||||
|
||||
## Commands: side effects stay OUT of the reducer
|
||||
|
||||
`reduce` only ever answers "what is the new state" — it never calls `fetch`. A
|
||||
**command** (an `application/submit-*.ts` file, or a store method) does the I/O, then
|
||||
dispatches a message describing the outcome:
|
||||
|
||||
```ts
|
||||
// command = "go do it, then say what happened" — reduce never sees the HTTP call itself
|
||||
async function submit(store: Store<WizardState, WizardMsg>) {
|
||||
const r = await adapter.submit(toDto(store.model()));
|
||||
store.dispatch(
|
||||
r.ok
|
||||
? { tag: 'SubmitConfirmed', referentie: r.value }
|
||||
: { tag: 'SubmitFailed', error: r.error },
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This is also how a machine receives **server-owned config** without becoming aware of
|
||||
HTTP: `intake.machine.ts`'s scholing threshold has an offline fallback
|
||||
(`SCHOLING_THRESHOLD_DEFAULT`) baked into the model, and a plain `SetPolicy` message
|
||||
that overwrites it once the real value arrives — the machine doesn't know or care that
|
||||
the value came from a `resource()` fetch.
|
||||
|
||||
## `createStore`: the one wiring idiom
|
||||
|
||||
```ts
|
||||
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
||||
readonly model = this.store.model; // Signal<WizardState> — template reads this
|
||||
dispatch = this.store.dispatch; // template calls this, on click/input/etc — never mutates
|
||||
```
|
||||
|
||||
A page or component **never** hand-rolls `signal(initialModel)` plus its own local
|
||||
`dispatch` function that calls `reduce` inline — that's the same idea reinvented with a
|
||||
worse name, and it's the thing a newcomer copies if two idioms are visible side by side.
|
||||
Wire every machine through `createStore`, full stop.
|
||||
|
||||
`dispatch` uses `model.update(…)`, not `model.set(reduce(model(), msg))` — the latter
|
||||
reads `model()` _inside_ the call, which means an `effect()` that both reads `model` and
|
||||
calls `dispatch` would subscribe to its own write and livelock. `.update()`'s callback
|
||||
receives the current value directly, untracked.
|
||||
|
||||
## Naming
|
||||
|
||||
- A top-level machine's types are **context-prefixed**: `ChangeRequestState`,
|
||||
`ChangeRequestMsg`, `WizardState`, `WizardMsg` — never bare `State`/`Msg`. A bare name
|
||||
reads fine in the one file that defines it and then collides (or forces an import
|
||||
alias) the moment two machines are open side by side.
|
||||
- A top-level machine exports `initial` (the starting Model) and `reduce` — unprefixed,
|
||||
since the file/module already disambiguates them at the import site
|
||||
(`import { initial, reduce } from './herregistratie.machine'`).
|
||||
- A **composable sub-machine** — one embedded _inside_ a parent Model, like
|
||||
`upload.machine.ts`'s upload-widget state living inside the registratie wizard's own
|
||||
Model — keeps **prefixed value exports** instead: `initialUpload`, `reduceUpload`.
|
||||
The parent machine already imports several machines' `initial`/`reduce`; prefixing the
|
||||
sub-machine's exports avoids a wall of `as` import aliases at the composition site.
|
||||
|
||||
## Derive, don't store
|
||||
|
||||
If a value can be computed from the Model, it is **not** a field on the Model. The
|
||||
wizard's visible steps are `visibleSteps(answers)`, a pure function of the current
|
||||
answers — not a `visibleSteps: Step[]` field someone has to remember to keep in sync
|
||||
every time an answer changes. The reflex: before adding a field, ask "could this just be
|
||||
a function of what I already have?"
|
||||
|
||||
## Where RemoteData fits in
|
||||
|
||||
A machine owns the **domain** lifecycle of what it holds once it exists (draft →
|
||||
submitted → approved, in the brief's case). It should generally _not_ also own the
|
||||
**fetch** lifecycle (loading/failed) for the initial GET that produces it — that's a
|
||||
generic concern `RemoteData` already models once, consistently, across the app (see
|
||||
[Foundations/RemoteData & Async](?path=/docs/foundations-remotedata-async--docs)). Where
|
||||
a machine's own state happens to have `loading`/`failed` tags that purely mirror that
|
||||
fetch, project them onto a `RemoteData` at the store layer for `<app-async>` to render
|
||||
(`BriefStore.remoteData` is the worked example) rather than teaching every consumer to
|
||||
hand-roll a `@switch` over the machine's own tags.
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/Overview" />
|
||||
|
||||
# Foundations
|
||||
|
||||
The **why** behind this codebase, as a short curriculum. Each page is a condensed,
|
||||
cross-linked take on one idea; the long-form source lives in `docs/reference/`
|
||||
(see the repo's `docs/README.md`). Read them in roughly this order.
|
||||
|
||||
> **New here?** Follow the [Learning Path](?path=/docs/foundations-learning-path--docs)
|
||||
> for a paced 3-day route with exercises and self-checks. This page is the map; the
|
||||
> Learning Path is the route through it.
|
||||
|
||||
## Architecture & domain
|
||||
|
||||
- [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs) — bounded
|
||||
contexts + layers, dependencies pointing inward (the folder structure _is_ the architecture).
|
||||
- [Atomic design](?path=/docs/foundations-atomic-design--docs) — Atoms → Molecules →
|
||||
Organisms → Templates; a new page is composition, not new building blocks.
|
||||
|
||||
## Functional core
|
||||
|
||||
- [FP in the UI](?path=/docs/foundations-fp-in-the-ui--docs) — the three functional tools behind the view.
|
||||
- [State machines (TEA)](?path=/docs/foundations-state-machines-tea--docs) — every form/wizard as Model → Msg → pure `reduce`.
|
||||
- [RemoteData & Async](?path=/docs/foundations-remotedata-async--docs) — the four async states as one value.
|
||||
- [Parse, don't validate](?path=/docs/foundations-parse-dont-validate--docs) — narrow untrusted `unknown` at the boundary into domain types.
|
||||
|
||||
## Design system
|
||||
|
||||
- [Design tokens](?path=/docs/foundations-design-tokens--docs) — semantic `--rhc-*` tokens; no hand-written colours.
|
||||
- [CIBG gap register](?path=/docs/foundations-cibg-gap-register--docs) — where we diverge from the CIBG Huisstijl (ADR-0003).
|
||||
|
||||
## Quality & process
|
||||
|
||||
- [Accessibility](?path=/docs/foundations-accessibility--docs) — four layered a11y tools, each catching a different bug class.
|
||||
- [Testing strategy](?path=/docs/foundations-testing-strategy--docs) — what to test, by layer grain.
|
||||
- [BDD](?path=/docs/foundations-bdd--docs) — how each test is phrased and scoped: one behaviour, in the domain's language.
|
||||
- [Internationalization](?path=/docs/foundations-internationalization--docs) — `$localize` for every user-visible string; the locale seam.
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/Parse, don't validate" />
|
||||
|
||||
# Parse, don't validate
|
||||
|
||||
The wire is untrusted. A `boolean`/`string` field coming back from `fetch` is typed `unknown`
|
||||
until something checks it — casting it away with `as` doesn't check anything, it just tells the
|
||||
compiler to stop complaining. This repo's rule: every response crosses the FE⇄BE seam through a
|
||||
hand-written `parse*` function that returns a `Result<string, T>` (`src/app/shared/kernel/fp.ts`).
|
||||
Once you hold the parsed value, you never re-check it — the type _is_ the proof.
|
||||
|
||||
## Two places this shows up
|
||||
|
||||
**Value objects** (`src/app/registratie/domain/value-objects/`) parse a single user-entered
|
||||
field — `Postcode`, `Uren`, `BigNummer` — from a raw string into a branded type.
|
||||
|
||||
**Boundary parsers** (`*.adapter.ts` in every `infrastructure/`) parse a whole DTO — or one
|
||||
enum-ish field inside it — from the generated `ApiClient`'s response into the domain shape the
|
||||
rest of the app trusts.
|
||||
|
||||
```ts
|
||||
parsePostcode(raw) // Result<string, Postcode>
|
||||
|> mapErr(toLocalizedMessage) // swap raw msg → UI copy
|
||||
|> map(toDomain) // only runs on success
|
||||
```
|
||||
|
||||
## The failure mode this closes: the silent `as` cast
|
||||
|
||||
An `as SomeUnion` cast on a wire value compiles even when the value doesn't match — the tag
|
||||
just gets forwarded as-is, and something far away breaks on an "impossible" case. A validated
|
||||
parse turns that into an explicit `Failure` at the boundary, right where the untrusted data
|
||||
enters.
|
||||
|
||||
### Before/after: `big-register.adapter.ts`
|
||||
|
||||
```ts
|
||||
// before — the wire's `type` string is trusted outright
|
||||
function toAantekening(n: AantekeningDto): Aantekening {
|
||||
return {
|
||||
type: n.type as AantekeningType,
|
||||
omschrijving: n.omschrijving ?? '',
|
||||
datum: n.datum ?? '',
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// after — an unrecognized type is a Result you can spec, not a silently-wrong tag
|
||||
const AANTEKENING_TYPES: readonly AantekeningType[] = ['Specialisme', 'Aantekening'];
|
||||
|
||||
export function parseAantekening(n: AantekeningDto): Result<string, Aantekening> {
|
||||
if (!n.type || !AANTEKENING_TYPES.includes(n.type as AantekeningType))
|
||||
return err(`aantekening: unknown type ${n.type}`);
|
||||
return ok({
|
||||
type: n.type as AantekeningType,
|
||||
omschrijving: n.omschrijving ?? '',
|
||||
datum: n.datum ?? '',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The resource loader throws on `Failure`, which Angular's `resource()` turns into its error
|
||||
state — the same `Failure` a `RemoteData` consumer already renders, no new plumbing.
|
||||
|
||||
### Before/after: `brief.adapter.ts`
|
||||
|
||||
```ts
|
||||
// before — `dto.scope` is checked, then re-cast anyway
|
||||
if (typeof dto.passageId !== 'string' || (dto.scope !== 'global' && dto.scope !== 'beroep'))
|
||||
return err('passage: bad shape');
|
||||
// … scope: dto.scope as PassageScope
|
||||
```
|
||||
|
||||
```ts
|
||||
// after — split the guard so TS narrows `scope` on its own; no cast needed
|
||||
if (dto.scope !== 'global' && dto.scope !== 'beroep')
|
||||
return err(`passage: unknown scope ${dto.scope}`);
|
||||
// … scope: dto.scope // already narrowed to PassageScope
|
||||
```
|
||||
|
||||
Splitting a compound `if` into two single-condition guards is often enough to make the cast
|
||||
disappear entirely — the compiler was already able to prove the narrowing, the `||` was just
|
||||
hiding it.
|
||||
|
||||
### Before/after: `intake-policy.adapter.ts`
|
||||
|
||||
```ts
|
||||
// before — the resource exposes the raw DTO; consumers reach into it with `?.`
|
||||
policyResource() {
|
||||
return resource({ loader: () => this.client.policy() });
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// after — a domain-side type + a validated parse; the resource never surfaces raw wire shape
|
||||
export interface IntakePolicy {
|
||||
readonly scholingThreshold: number;
|
||||
}
|
||||
|
||||
export function parseIntakePolicy(json: unknown): Result<string, IntakePolicy> {
|
||||
if (typeof json !== 'object' || json === null) return err('intake-policy: not an object');
|
||||
const dto = json as { scholingThreshold?: unknown };
|
||||
if (typeof dto.scholingThreshold !== 'number')
|
||||
return err('intake-policy: missing scholingThreshold');
|
||||
return ok({ scholingThreshold: dto.scholingThreshold });
|
||||
}
|
||||
```
|
||||
|
||||
## The sanctioned exception
|
||||
|
||||
Narrowing `unknown` to `Partial<Dto>` so you can _start_ checking fields is fine — that's not a
|
||||
trust decision, it's just giving the compiler a shape to probe (`const dto = json as
|
||||
Partial<DashboardViewDto>`, see `dashboard-view.adapter.ts`). What's never fine is casting a
|
||||
field to its final domain type without having checked it first.
|
||||
|
||||
## Spec every parser like a decision table
|
||||
|
||||
Each parser gets a spec covering: a valid shape, a missing required field, and — for
|
||||
tagged/enum-ish values — an unknown tag. See `big-register.adapter.spec.ts`,
|
||||
`intake-policy.adapter.spec.ts`, and the scope-rejection case in `brief.adapter.spec.ts`.
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
|
||||
import * as AsyncStories from '../src/ui/async/async.stories';
|
||||
|
||||
<Meta title="Foundations/RemoteData & Async" />
|
||||
|
||||
# RemoteData & Async
|
||||
|
||||
An async fetch has exactly four states: still loading, loaded-but-empty, failed, or
|
||||
loaded-with-a-value. Modeling that as `loading`/`error`/`data` booleans permits nonsense
|
||||
combinations ("loading **and** error", "data **and** error" — which one does the UI
|
||||
believe?). `src/app/shared/application/remote-data.ts` closes that off with one tagged
|
||||
union instead:
|
||||
|
||||
```ts
|
||||
type RemoteData<E, T> =
|
||||
| { tag: 'Loading' }
|
||||
| { tag: 'Empty' }
|
||||
| { tag: 'Failure'; error: E }
|
||||
| { tag: 'Success'; value: T };
|
||||
```
|
||||
|
||||
## Combining sources
|
||||
|
||||
Two or more independent fetches often need to render as ONE state (e.g. a registration
|
||||
call and a BRP call feeding the same page). `map`/`map2`/`andThen` combine them with one
|
||||
precedence rule: **Failure beats Loading beats Empty beats Success** — if either source
|
||||
failed, the combined result is a failure; only when every source succeeded do you get a
|
||||
combined value.
|
||||
|
||||
```ts
|
||||
map2(registration, person, (reg, p) => ({ registration: reg, person: p }));
|
||||
```
|
||||
|
||||
## Rendering it: `<app-async>`
|
||||
|
||||
<Canvas of={AsyncStories.Loading} />
|
||||
<Canvas of={AsyncStories.ErrorState} />
|
||||
|
||||
`shared/ui/async` renders exactly one of the four templates — never two at once, by
|
||||
construction, since the component switches on the union's tag. Feed it either:
|
||||
|
||||
- **`[resource]`** — a raw Angular `resource()` (the common case; the component projects
|
||||
it into a `RemoteData` internally via `fromResource`), or
|
||||
- **`[data]`** — an already-combined `RemoteData` (e.g. from a store's `computed()` using
|
||||
`map`/`map2`).
|
||||
|
||||
The default loading UI is a spinner, delay-gated (~250ms) so a fast response never
|
||||
flashes it; override with an `appAsyncLoading` template. `appAsyncEmpty` and
|
||||
`appAsyncError` are likewise optional — omit them and you get a sensible default (a
|
||||
"geen gegevens" message / an alert with a retry button).
|
||||
|
||||
## The `appAsyncLoaded` slot isn't generically typed to your value
|
||||
|
||||
This is a real Angular constraint, not an oversight: a structural directive's type
|
||||
parameter can only be inferred from an **input bound on that same element** (this is how
|
||||
`*ngFor="let x of items"` and `*ngIf="x as y"` work — the type comes from `ngForOf`/`ngIf`,
|
||||
inputs on the very same tag). `<ng-template appAsyncLoaded let-p>` sits on a _different_
|
||||
node than `<app-async [data]="…">`, so `p` cannot inherit a type from that sibling input,
|
||||
even though they're nested in the same template. Angular types it `unknown`, and
|
||||
`ngTemplateContextGuard` can't fix that without an input to seed it from — the shared
|
||||
`AsyncComponent`/`AsyncLoadedDirective` pair is properly generic internally, but that
|
||||
genericity stops at the component's own boundary.
|
||||
|
||||
The idiom this repo uses instead — see `brief.page.ts`, `dashboard.page.ts`,
|
||||
`registration-detail.page.ts` — is a small **typed `computed()`** that unwraps the
|
||||
`Success` value, narrowed locally in the template with `@if (x(); as p)`:
|
||||
|
||||
```ts
|
||||
// in the component class
|
||||
protected readonly loaded = computed(() => {
|
||||
const s = this.model(); // or store.someRemoteData()
|
||||
return s.tag === 'loaded' ? s : undefined;
|
||||
});
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- in the template, inside <ng-template appAsyncLoaded> -->
|
||||
@if (loaded(); as s) {
|
||||
<app-letter-composer [brief]="s.brief" ... />
|
||||
}
|
||||
```
|
||||
|
||||
No `$any()`, no cast — `loaded()` is a real, checked `T | undefined`, and `@if (…; as s)`
|
||||
narrows it the same way any other nullable signal would.
|
||||
|
||||
## The `?scenario=` dev toggle
|
||||
|
||||
Any data page can be forced through all four states without touching the backend:
|
||||
`?scenario=slow|loading|empty|error` (dev-only, `scenario.interceptor.ts`) rewrites the
|
||||
timing/outcome of `/api/*` calls. Try it on `/brief` or `/dashboard`.
|
||||
|
||||
## Where the fetch ends and the domain begins
|
||||
|
||||
A store's own state machine (its `*.machine.ts`) should own the **domain** lifecycle of
|
||||
what it holds (draft → submitted → approved, in the brief's case) — not the network
|
||||
fetch's loading/failure, which is a generic concern `RemoteData` already models. Where a
|
||||
machine's own `loading`/`failed` tags purely mirror the fetch (nothing extra beyond "not
|
||||
loaded yet" / "the GET failed"), project them onto a `RemoteData` computed at the store
|
||||
layer for `<app-async>` to render, the way `BriefStore.remoteData` does — the machine
|
||||
keeps deciding what the _letter_ is doing, `RemoteData` keeps deciding what the _fetch_ is
|
||||
doing.
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Foundations/Testing strategy" />
|
||||
|
||||
# Testing strategy
|
||||
|
||||
Tests follow the same grain as the architecture: **push the logic down to where it's pure,
|
||||
test it there directly, and keep the layers above thin.** No single tool covers everything,
|
||||
so each layer gets the cheapest test that catches its class of bug. This page owns _what to
|
||||
test, by layer_; how each test is **phrased and scoped** — one behaviour, in the domain's
|
||||
language — is [BDD](?path=/docs/foundations-bdd--docs).
|
||||
|
||||
## What gets tested where
|
||||
|
||||
| Layer | Test kind | Tool | Rule |
|
||||
| -------------------------- | ------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| `domain/` | pure-function unit spec | Vitest | **Required.** Call the function directly — **no `TestBed`**. Reducers, combinators, `visibleSteps`, parsers, policies. |
|
||||
| `domain/value-objects/` | parser spec | Vitest | Happy path + normalisation + **each** rejection. Assert on the `Result`, never the message. |
|
||||
| `infrastructure/` `parse*` | trust-boundary spec | Vitest | Accept a valid DTO; **reject `null` / `{}` / malformed**. Name it `describe('… (trust boundary)')`. |
|
||||
| `application/` | store / command spec | Vitest | Reducer purity, optimistic begin→confirm/rollback, command `Result`. |
|
||||
| `ui/` | Storybook story | Storybook + a11y | Kept thin. Axe runs on every story; add a `play` only for wiring axe can't see. |
|
||||
| flows | e2e smoke | Playwright | One happy path + one error state per critical journey. |
|
||||
| backend | rule + endpoint + golden | xUnit | Mirror of the FE domain rules, plus `WebApplicationFactory` integration. |
|
||||
|
||||
## Tooling
|
||||
|
||||
Vitest runs through Angular's built-in `@angular/build:unit-test` builder — **there is no
|
||||
`vitest.config.ts`**; config is implicit via `tsconfig.spec.json`.
|
||||
|
||||
```bash
|
||||
npm test # ng test → Vitest, all *.spec.ts co-located next to their unit
|
||||
```
|
||||
|
||||
Specs import `{ describe, it, expect }` from `vitest` and are co-located with the unit
|
||||
they cover.
|
||||
|
||||
## The house style
|
||||
|
||||
Pure and direct. A value-object parser spec
|
||||
(`registratie/domain/value-objects/postcode.spec.ts`):
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parsePostcode } from './postcode';
|
||||
|
||||
describe('parsePostcode', () => {
|
||||
it('normalises to "1234 AB"', () => {
|
||||
const r = parsePostcode(' 1234ab ');
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value).toBe('1234 AB');
|
||||
});
|
||||
it('rejects malformed input', () => {
|
||||
expect(parsePostcode('0234AB').ok).toBe(false); // asserts the tag, not the copy
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
A trust-boundary adapter (`registratie/infrastructure/brp.adapter.spec.ts`) additionally
|
||||
proves the untrusted shape is rejected:
|
||||
|
||||
```ts
|
||||
expect(parseBrpAddress(null).ok).toBe(false);
|
||||
expect(parseBrpAddress({}).ok).toBe(false); // missing required field
|
||||
```
|
||||
|
||||
Elm-style machines test the pure `reduce` with inline state fixtures — no Angular
|
||||
(`registratie/domain/registratie-wizard.machine.spec.ts`).
|
||||
|
||||
## UI = Storybook, not heavy component tests
|
||||
|
||||
`@storybook/addon-a11y` runs the `wcag2a/2aa/21a/21aa` rule sets on **every** story;
|
||||
`@storybook/test-runner` + `axe-playwright` turn that into a CI gate:
|
||||
|
||||
```bash
|
||||
npm run test-storybook # axe over every story against a running Storybook
|
||||
npm run test-storybook:ci # builds storybook-static, serves :6006, runs the gate
|
||||
```
|
||||
|
||||
Disabling a11y on a story needs an inline justification + a WP cross-reference (see
|
||||
[Accessibility](?path=/docs/foundations-accessibility--docs)).
|
||||
|
||||
## Don't assert on copy
|
||||
|
||||
Localized strings change per locale and per edit. Tests assert on the `Result`
|
||||
discriminant, the value object, or the message **id** — never the rendered Dutch/English
|
||||
text. Full detail in [Internationalization](?path=/docs/foundations-internationalization--docs).
|
||||
|
||||
## The GREEN gate
|
||||
|
||||
```bash
|
||||
npm run lint && npm run check:tokens && npm test && npm run build && npm run build-storybook
|
||||
cd backend && dotnet test
|
||||
```
|
||||
|
||||
Everything above must pass before a work package is done.
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable, computed, inject } from '@angular/core';
|
||||
import { toObservable } from '@angular/core/rxjs-interop';
|
||||
import { filter, firstValueFrom } from 'rxjs';
|
||||
import { RemoteData, fromResource } from '@shared/application/remote-data';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
import { MeAdapter, parseMe } from '@shared/infrastructure/me.adapter';
|
||||
|
||||
type Err = Error | undefined;
|
||||
|
||||
/**
|
||||
* The current principal's capabilities (PRD-0002 §6) — one root singleton, like
|
||||
* `SessionStore`/`BigProfileStore`. Global capabilities load once from `GET /me`;
|
||||
* a screen's own decision DTO (e.g. `BriefViewDto.decisions`) covers anything tied
|
||||
* to a specific resource's live status — no extra round-trip needed for that.
|
||||
*
|
||||
* `can()` is deny-by-default: loading, failed, or an unrecognized capability all
|
||||
* resolve to `false`. This store never derives a capability from a role — it only
|
||||
* mirrors what the server already resolved.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AccessStore {
|
||||
private adapter = inject(MeAdapter);
|
||||
private meRes = this.adapter.meResource();
|
||||
|
||||
private capabilities = computed<RemoteData<Err, Capability[]>>(() => {
|
||||
const rd = fromResource(this.meRes);
|
||||
if (rd.tag !== 'Success') return rd;
|
||||
const parsed = parseMe(rd.value);
|
||||
return parsed.ok
|
||||
? { tag: 'Success', value: parsed.value }
|
||||
: { tag: 'Failure', error: new Error(parsed.error) };
|
||||
});
|
||||
|
||||
can(capability: Capability): boolean {
|
||||
const rd = this.capabilities();
|
||||
return rd.tag === 'Success' && rd.value.includes(capability);
|
||||
}
|
||||
|
||||
/** True once `/me` has resolved (success or failure) — lets a page-level gate tell
|
||||
"still loading" apart from "denied", so an admin doesn't flash the denial alert. */
|
||||
readonly ready = computed(() => {
|
||||
const tag = this.capabilities().tag;
|
||||
return tag === 'Success' || tag === 'Failure';
|
||||
});
|
||||
|
||||
private ready$ = toObservable(this.ready);
|
||||
/** Resolves once `/me` has settled (success or failure). The `capabilityGuard` awaits
|
||||
this before deciding — otherwise it reads `can()` while `/me` is still loading and
|
||||
wrongly denies (deny-by-default), bouncing even an entitled user. */
|
||||
async whenReady(): Promise<void> {
|
||||
if (this.ready()) return;
|
||||
await firstValueFrom(this.ready$.pipe(filter((r) => r)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Transient state of a one-shot action (submit/approve/publish/reset/…): one tagged
|
||||
union instead of a busy boolean + a nullable error sitting side by side. Shared by the
|
||||
editor stores (WP-31). */
|
||||
export type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
|
||||
|
||||
/** Debounced-autosave indicator, shown in a small status line near a toolbar — a separate
|
||||
concern from ActionState (a stale autosave error doesn't block submit/approve), but
|
||||
tag-aligned with it for one consistent idiom. */
|
||||
export type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createDebouncedSave } from './debounced-save';
|
||||
|
||||
describe('createDebouncedSave', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('flushes after the delay when canSave is true', async () => {
|
||||
const flush = vi.fn().mockResolvedValue(undefined);
|
||||
const d = createDebouncedSave({ delayMs: 600, canSave: () => true, flush });
|
||||
d.schedule();
|
||||
expect(d.hasPendingSave()).toBe(true);
|
||||
expect(flush).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
expect(flush).toHaveBeenCalledTimes(1);
|
||||
expect(d.hasPendingSave()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not schedule when canSave is false', () => {
|
||||
const flush = vi.fn().mockResolvedValue(undefined);
|
||||
const d = createDebouncedSave({ canSave: () => false, flush });
|
||||
d.schedule();
|
||||
expect(d.hasPendingSave()).toBe(false);
|
||||
});
|
||||
|
||||
it('coalesces rapid schedules into a single flush', async () => {
|
||||
const flush = vi.fn().mockResolvedValue(undefined);
|
||||
const d = createDebouncedSave({ delayMs: 100, canSave: () => true, flush });
|
||||
d.schedule();
|
||||
d.schedule();
|
||||
d.schedule();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(flush).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('flushPending runs the save immediately and clears; no-op when idle', async () => {
|
||||
const flush = vi.fn().mockResolvedValue(undefined);
|
||||
const d = createDebouncedSave({ delayMs: 600, canSave: () => true, flush });
|
||||
await d.flushPending();
|
||||
expect(flush).not.toHaveBeenCalled(); // idle
|
||||
d.schedule();
|
||||
await d.flushPending();
|
||||
expect(flush).toHaveBeenCalledTimes(1);
|
||||
expect(d.hasPendingSave()).toBe(false);
|
||||
});
|
||||
|
||||
it('cancel drops a scheduled save without running it', async () => {
|
||||
const flush = vi.fn().mockResolvedValue(undefined);
|
||||
const d = createDebouncedSave({ delayMs: 600, canSave: () => true, flush });
|
||||
d.schedule();
|
||||
d.cancel();
|
||||
expect(d.hasPendingSave()).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
expect(flush).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface DebouncedSave {
|
||||
/** (Re)arm the debounce timer; no-op when `canSave()` is false. */
|
||||
schedule(): void;
|
||||
/** True while a scheduled save hasn't run yet — implements `PendingSave.hasPendingSave`. */
|
||||
hasPendingSave(): boolean;
|
||||
/** Run a scheduled save now and await it; no-op when nothing is scheduled. */
|
||||
flushPending(): Promise<void>;
|
||||
/** Drop a scheduled save without running it (e.g. before an authoritative transition,
|
||||
which flushes explicitly, or a reset that discards the draft). */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The debounced-autosave timer shared by the editor stores (WP-31). It owns ONLY the timer
|
||||
* bookkeeping; the actual write + save-state transitions live in the caller's `flush`
|
||||
* (store-specific — it touches that store's SaveState/ActionState + adapter). The handle is
|
||||
* nulled the moment it fires, so `hasPendingSave()` means "a write is still owed". Integrates
|
||||
* with the `PendingSave` seam (pending-saves.ts): a store delegates hasPendingSave/flushPending
|
||||
* here so the CanDeactivate guard / beforeunload handler can flush a pending edit.
|
||||
*/
|
||||
export function createDebouncedSave(opts: {
|
||||
delayMs?: number;
|
||||
canSave: () => boolean;
|
||||
flush: () => Promise<void>;
|
||||
}): DebouncedSave {
|
||||
const delay = opts.delayMs ?? 600;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
return {
|
||||
schedule() {
|
||||
if (!opts.canSave()) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
void opts.flush();
|
||||
}, delay);
|
||||
},
|
||||
hasPendingSave: () => timer !== undefined,
|
||||
async flushPending() {
|
||||
if (timer === undefined) return;
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
await opts.flush();
|
||||
},
|
||||
cancel() {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { RemoteData } from '@shared/application/remote-data';
|
||||
import { FeatureFlag } from '@shared/domain/feature-flag';
|
||||
import { FeatureFlagsAdapter, parseFlags } from '@shared/infrastructure/feature-flags.adapter';
|
||||
|
||||
type Err = Error | undefined;
|
||||
|
||||
/**
|
||||
* Runtime feature-flag state (WP-47) — one root singleton, mirroring `AccessStore`. Loads the
|
||||
* resolved flag set once from `GET /flags`; `enabled(key)` gates a feature (deny-by-default:
|
||||
* false until loaded / unknown key). `set()` is the admin toggle (PUT + reload). The catalog is
|
||||
* server-owned; the FE only mirrors + renders it.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FeatureFlagStore {
|
||||
private adapter = inject(FeatureFlagsAdapter);
|
||||
private state = signal<RemoteData<Err, FeatureFlag[]>>({ tag: 'Loading' });
|
||||
|
||||
readonly flags = this.state.asReadonly();
|
||||
/** The resolved list (empty until loaded) — for the admin toggle UI. */
|
||||
readonly all = computed(() => {
|
||||
const rd = this.state();
|
||||
return rd.tag === 'Success' ? rd.value : [];
|
||||
});
|
||||
|
||||
constructor() {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
|
||||
try {
|
||||
const parsed = parseFlags(await this.adapter.list());
|
||||
this.state.set(
|
||||
parsed.ok
|
||||
? { tag: 'Success', value: parsed.value }
|
||||
: { tag: 'Failure', error: new Error(parsed.error) },
|
||||
);
|
||||
} catch (e) {
|
||||
this.state.set({ tag: 'Failure', error: e as Error });
|
||||
}
|
||||
}
|
||||
|
||||
/** Deny-by-default: false while loading/failed or for an unknown key. Reactive (reads the signal). */
|
||||
enabled(key: string): boolean {
|
||||
const rd = this.state();
|
||||
return rd.tag === 'Success' && (rd.value.find((f) => f.key === key)?.enabled ?? false);
|
||||
}
|
||||
|
||||
/** Admin toggle: persist then reload so the state reflects the server. */
|
||||
async set(key: string, enabled: boolean) {
|
||||
try {
|
||||
await this.adapter.set(key, enabled);
|
||||
} finally {
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createHistory } from './history';
|
||||
|
||||
describe('createHistory', () => {
|
||||
it('starts empty; undo/redo are no-ops', () => {
|
||||
const h = createHistory<number>();
|
||||
expect(h.canUndo()).toBe(false);
|
||||
expect(h.canRedo()).toBe(false);
|
||||
expect(h.undo(1)).toBeUndefined();
|
||||
expect(h.redo(1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('records pre-edit snapshots, then undoes and redoes through them', () => {
|
||||
const h = createHistory<string>();
|
||||
// document went a -> b (record a) -> c (record b); current is 'c'
|
||||
h.record('a');
|
||||
h.record('b');
|
||||
expect(h.canUndo()).toBe(true);
|
||||
|
||||
expect(h.undo('c')).toBe('b'); // current 'c' pushed to redo
|
||||
expect(h.canRedo()).toBe(true);
|
||||
expect(h.undo('b')).toBe('a');
|
||||
expect(h.canUndo()).toBe(false);
|
||||
|
||||
expect(h.redo('a')).toBe('b');
|
||||
expect(h.redo('b')).toBe('c');
|
||||
expect(h.canRedo()).toBe(false);
|
||||
});
|
||||
|
||||
it('record() clears the redo stack (no dead redo after a fresh edit)', () => {
|
||||
const h = createHistory<string>();
|
||||
h.record('a');
|
||||
h.undo('b'); // redo now holds 'b'
|
||||
expect(h.canRedo()).toBe(true);
|
||||
h.record('x');
|
||||
expect(h.canRedo()).toBe(false);
|
||||
});
|
||||
|
||||
it('caps the stack depth', () => {
|
||||
const h = createHistory<number>(3);
|
||||
for (let i = 0; i < 5; i++) h.record(i);
|
||||
let undos = 0;
|
||||
let cur = 99;
|
||||
while (h.canUndo()) {
|
||||
cur = h.undo(cur)!;
|
||||
undos++;
|
||||
}
|
||||
expect(undos).toBe(3);
|
||||
});
|
||||
|
||||
it('clear() empties both stacks', () => {
|
||||
const h = createHistory<number>();
|
||||
h.record(1);
|
||||
h.undo(2);
|
||||
h.clear();
|
||||
expect(h.canUndo()).toBe(false);
|
||||
expect(h.canRedo()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Signal, computed, signal } from '@angular/core';
|
||||
|
||||
export interface History<T> {
|
||||
readonly canUndo: Signal<boolean>;
|
||||
readonly canRedo: Signal<boolean>;
|
||||
/** Push a pre-edit snapshot onto the undo stack and drop the redo stack. */
|
||||
record(snapshot: T): void;
|
||||
/** Undo: pop the last recorded snapshot and return it (moving `current` onto the redo
|
||||
stack); returns undefined and changes nothing when there's nothing to undo. */
|
||||
undo(current: T): T | undefined;
|
||||
/** Redo: mirror of undo. */
|
||||
redo(current: T): T | undefined;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic undo/redo history over an immutable "document" value `T`. Elm-store editors
|
||||
* restore a returned snapshot by re-dispatching a `Seed`-style Msg — this helper only
|
||||
* shuffles references, it never mutates them, so the caller must hold copy-on-write state
|
||||
* (every edit produces a fresh value). Both stacks are capped so a long session can't grow
|
||||
* unbounded. Extracted from BriefStore's WP-27 undo/redo (WP-31); reused by the stamdata
|
||||
* editor (WP-32).
|
||||
*/
|
||||
export function createHistory<T>(cap = 50): History<T> {
|
||||
const past = signal<readonly T[]>([]);
|
||||
const future = signal<readonly T[]>([]);
|
||||
return {
|
||||
canUndo: computed(() => past().length > 0),
|
||||
canRedo: computed(() => future().length > 0),
|
||||
record(snapshot) {
|
||||
past.update((p) => [...p, snapshot].slice(-cap));
|
||||
future.set([]);
|
||||
},
|
||||
undo(current) {
|
||||
const p = past();
|
||||
if (p.length === 0) return undefined;
|
||||
past.set(p.slice(0, -1));
|
||||
future.update((f) => [...f, current].slice(-cap));
|
||||
return p[p.length - 1];
|
||||
},
|
||||
redo(current) {
|
||||
const f = future();
|
||||
if (f.length === 0) return undefined;
|
||||
future.set(f.slice(0, -1));
|
||||
past.update((p) => [...p, current].slice(-cap));
|
||||
return f[f.length - 1];
|
||||
},
|
||||
clear() {
|
||||
past.set([]);
|
||||
future.set([]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { machineRemoteData } from './machine-remote-data';
|
||||
|
||||
describe('machineRemoteData', () => {
|
||||
it('maps loading → Loading', () => {
|
||||
expect(machineRemoteData({ tag: 'loading' })).toEqual({ tag: 'Loading' });
|
||||
});
|
||||
|
||||
it('maps failed → Failure carrying an Error with the reason', () => {
|
||||
const rd = machineRemoteData({ tag: 'failed', reason: 'boom' });
|
||||
expect(rd.tag).toBe('Failure');
|
||||
if (rd.tag === 'Failure') expect(rd.error.message).toBe('boom');
|
||||
});
|
||||
|
||||
it('maps loaded → Success carrying the whole loaded state', () => {
|
||||
const loaded = { tag: 'loaded', foo: 42 } as const;
|
||||
expect(machineRemoteData(loaded)).toEqual({ tag: 'Success', value: loaded });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { RemoteData } from '@shared/application/remote-data';
|
||||
|
||||
/** The standard load-lifecycle tags an editor machine exposes. */
|
||||
export type LoadLifecycle =
|
||||
{ tag: 'loading' } | { tag: 'failed'; reason: string } | { tag: 'loaded' };
|
||||
|
||||
/**
|
||||
* Project an Elm-machine state onto `RemoteData` for the `<app-async>` seam. The machine
|
||||
* keeps owning its own domain lifecycle (draft/submitted/…); this is purely the
|
||||
* loading/failed/loaded → async mapping, which was byte-identical across BriefStore,
|
||||
* OrgTemplateStore and StamdataStore (WP-31). Wrap the call in a `computed`.
|
||||
*/
|
||||
export function machineRemoteData<S extends LoadLifecycle>(
|
||||
s: S,
|
||||
): RemoteData<Error, Extract<S, { tag: 'loaded' }>> {
|
||||
switch (s.tag) {
|
||||
case 'loading':
|
||||
return { tag: 'Loading' };
|
||||
case 'failed':
|
||||
return { tag: 'Failure', error: new Error(s.reason) };
|
||||
default: // 'loaded'
|
||||
return { tag: 'Success', value: s as Extract<S, { tag: 'loaded' }> };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { PendingSave, PendingSaves, flushPendingGuard } from './pending-saves';
|
||||
|
||||
/** A fake autosave owner whose pending-ness and flush are controllable. */
|
||||
function fakeOwner(pending: boolean): PendingSave & { flushPending: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
hasPendingSave: () => pending,
|
||||
flushPending: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('PendingSaves registry', () => {
|
||||
it('hasPending is true only while some registered owner has a pending write', () => {
|
||||
const reg = new PendingSaves();
|
||||
const idle = fakeOwner(false);
|
||||
reg.register(idle);
|
||||
expect(reg.hasPending()).toBe(false);
|
||||
|
||||
const dirty = fakeOwner(true);
|
||||
reg.register(dirty);
|
||||
expect(reg.hasPending()).toBe(true);
|
||||
});
|
||||
|
||||
it('unregister removes an owner so it no longer counts', () => {
|
||||
const reg = new PendingSaves();
|
||||
const dirty = fakeOwner(true);
|
||||
const off = reg.register(dirty);
|
||||
expect(reg.hasPending()).toBe(true);
|
||||
off();
|
||||
expect(reg.hasPending()).toBe(false);
|
||||
});
|
||||
|
||||
it('flushAll flushes only the pending owners', async () => {
|
||||
const reg = new PendingSaves();
|
||||
const idle = fakeOwner(false);
|
||||
const dirty = fakeOwner(true);
|
||||
reg.register(idle);
|
||||
reg.register(dirty);
|
||||
|
||||
await reg.flushAll();
|
||||
|
||||
expect(dirty.flushPending).toHaveBeenCalledTimes(1);
|
||||
expect(idle.flushPending).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushAll awaits every owner and swallows a rejected flush', async () => {
|
||||
const reg = new PendingSaves();
|
||||
const failing = fakeOwner(true);
|
||||
failing.flushPending.mockRejectedValue(new Error('save failed'));
|
||||
const ok = fakeOwner(true);
|
||||
reg.register(failing);
|
||||
reg.register(ok);
|
||||
|
||||
await expect(reg.flushAll()).resolves.toBeUndefined(); // never rejects
|
||||
expect(ok.flushPending).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flushPendingGuard', () => {
|
||||
it('flushes then allows navigation when a write is pending', async () => {
|
||||
const dirty = fakeOwner(true);
|
||||
TestBed.configureTestingModule({});
|
||||
const reg = TestBed.inject(PendingSaves);
|
||||
reg.register(dirty);
|
||||
|
||||
const result = TestBed.runInInjectionContext(() =>
|
||||
// the guard ignores its route args
|
||||
(flushPendingGuard as (...a: unknown[]) => boolean | Promise<boolean>)(),
|
||||
);
|
||||
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(dirty.flushPending).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows navigation immediately when nothing is pending', () => {
|
||||
TestBed.configureTestingModule({});
|
||||
TestBed.inject(PendingSaves).register(fakeOwner(false));
|
||||
|
||||
const result = TestBed.runInInjectionContext(() =>
|
||||
(flushPendingGuard as (...a: unknown[]) => boolean | Promise<boolean>)(),
|
||||
);
|
||||
|
||||
expect(result).toBe(true); // synchronous, not a Promise
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { DestroyRef, ENVIRONMENT_INITIALIZER, Injectable, inject } from '@angular/core';
|
||||
import { CanDeactivateFn } from '@angular/router';
|
||||
|
||||
/**
|
||||
* A source of debounced, not-yet-flushed writes (autosave). The two autosave owners in
|
||||
* this app have different lifetimes — root singleton stores (`BriefStore`,
|
||||
* `OrgTemplateStore`) and per-wizard `createDraftSync` controllers living inside child
|
||||
* organisms — so both register here instead of the guard/unload handler needing to know
|
||||
* which page or store owns the pending write.
|
||||
*/
|
||||
export interface PendingSave {
|
||||
/** True while a debounced edit hasn't been written to the backend yet. */
|
||||
hasPendingSave(): boolean;
|
||||
/** Flush that pending write now and await it. No-op when nothing is pending. */
|
||||
flushPending(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Registry of every active autosave owner. The `CanDeactivate` guard and the
|
||||
`beforeunload` handler flush through this — one seam, both callers. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PendingSaves {
|
||||
private readonly owners = new Set<PendingSave>();
|
||||
|
||||
/** Register an owner; returns an unregister function. */
|
||||
register(owner: PendingSave): () => void {
|
||||
this.owners.add(owner);
|
||||
return () => this.owners.delete(owner);
|
||||
}
|
||||
|
||||
hasPending(): boolean {
|
||||
return [...this.owners].some((o) => o.hasPendingSave());
|
||||
}
|
||||
|
||||
/** Flush every owner that has a pending write, awaiting all. Best-effort: a rejected
|
||||
flush is swallowed (a failed autosave surfaces its own error state; navigation must
|
||||
not be blocked by it). */
|
||||
async flushAll(): Promise<void> {
|
||||
await Promise.allSettled(
|
||||
[...this.owners].filter((o) => o.hasPendingSave()).map((o) => o.flushPending()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the current injection context's owner for the life of its `DestroyRef`.
|
||||
Call from a constructor or field initializer (root store, or `createDraftSync`). */
|
||||
export function registerPendingSave(owner: PendingSave): void {
|
||||
const unregister = inject(PendingSaves).register(owner);
|
||||
inject(DestroyRef).onDestroy(unregister);
|
||||
}
|
||||
|
||||
/** `CanDeactivate` guard: flush any pending debounced write before an in-app route change,
|
||||
then allow navigation. Awaitable, so the write lands before the page tears down (which
|
||||
would otherwise drop a sub-debounce edit). We never block leaving — the flush is a
|
||||
guarantee of effort, not a gate. */
|
||||
export const flushPendingGuard: CanDeactivateFn<unknown> = () => {
|
||||
const pending = inject(PendingSaves);
|
||||
return pending.hasPending() ? pending.flushAll().then(() => true) : true;
|
||||
};
|
||||
|
||||
/** Wire a `beforeunload` handler that guards the last-mile save on a hard tab-close/reload.
|
||||
ponytail: the HTTP seam is Angular `HttpClient` (no `keepalive`/`sendBeacon`), so an
|
||||
async flush can't be guaranteed to finish as the page tears down — we fire it best-effort
|
||||
AND trigger the browser's native "unsaved changes" prompt, which lets the ~600ms debounce
|
||||
land if the user stays. Upgrade path: a `sendBeacon`/keepalive last-mile if this ever
|
||||
needs to be guaranteed. */
|
||||
export function provideUnloadFlush() {
|
||||
return {
|
||||
provide: ENVIRONMENT_INITIALIZER,
|
||||
multi: true,
|
||||
useValue: () => {
|
||||
const pending = inject(PendingSaves);
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (!pending.hasPending()) return;
|
||||
void pending.flushAll();
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RemoteData, map2, map } from './remote-data';
|
||||
|
||||
const loading: RemoteData<string, number> = { tag: 'Loading' };
|
||||
const failure: RemoteData<string, number> = { tag: 'Failure', error: 'x' };
|
||||
const ok = (n: number): RemoteData<string, number> => ({ tag: 'Success', value: n });
|
||||
|
||||
describe('RemoteData combinators', () => {
|
||||
it('map only touches Success', () => {
|
||||
const times10 = (n: number) => n * 10;
|
||||
expect(map(ok(2), times10)).toEqual({ tag: 'Success', value: 20 });
|
||||
expect(map(loading, times10)).toEqual(loading);
|
||||
});
|
||||
|
||||
it('map2 precedence: Failure > Loading > Success', () => {
|
||||
const add = (a: number, b: number) => a + b;
|
||||
expect(map2(failure, ok(1), add)).toEqual(failure); // a failed
|
||||
expect(map2(ok(1), failure, add)).toEqual(failure); // b failed
|
||||
expect(map2(loading, ok(1), add)).toEqual({ tag: 'Loading' });
|
||||
expect(map2(ok(2), ok(3), add)).toEqual({ tag: 'Success', value: 5 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Resource } from '@angular/core';
|
||||
import { assertNever } from '@shared/kernel/fp';
|
||||
|
||||
/**
|
||||
* The four mutually-exclusive states of an async fetch, as a tagged union.
|
||||
* Crucially the data lives ON the state: only `Failure` has an `error`, only
|
||||
* `Success` has a `value`. "Loaded but no value" or "error with stale value"
|
||||
* are unrepresentable — Richard Feldman's RemoteData.
|
||||
*/
|
||||
export type RemoteData<E, T> =
|
||||
| { tag: 'Loading' }
|
||||
| { tag: 'Empty' }
|
||||
| { tag: 'Failure'; error: E }
|
||||
| { tag: 'Success'; value: T };
|
||||
|
||||
/** Project Angular's loosely-typed Resource into a RemoteData value. */
|
||||
export function fromResource<T>(
|
||||
r: Resource<T>,
|
||||
isEmpty: (v: T) => boolean = () => false,
|
||||
): RemoteData<Error | undefined, T> {
|
||||
if (r.status() === 'error') return { tag: 'Failure', error: r.error() };
|
||||
if (r.status() === 'loading') return { tag: 'Loading' };
|
||||
if (r.hasValue()) {
|
||||
const v = r.value();
|
||||
return isEmpty(v) ? { tag: 'Empty' } : { tag: 'Success', value: v };
|
||||
}
|
||||
return { tag: 'Loading' };
|
||||
}
|
||||
|
||||
// #region showcase:fold
|
||||
/** Exhaustive fold: you must handle every case, checked at compile time. */
|
||||
export function foldRemote<E, T, R>(
|
||||
rd: RemoteData<E, T>,
|
||||
h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R },
|
||||
): R {
|
||||
switch (rd.tag) {
|
||||
case 'Loading':
|
||||
return h.loading();
|
||||
case 'Empty':
|
||||
return h.empty();
|
||||
case 'Failure':
|
||||
return h.failure(rd.error);
|
||||
case 'Success':
|
||||
return h.success(rd.value);
|
||||
default:
|
||||
return assertNever(rd); // add a variant → compile error until handled
|
||||
}
|
||||
}
|
||||
// #endregion showcase:fold
|
||||
|
||||
// --- Combinators -----------------------------------------------------------
|
||||
// Let several independent async sources be treated as one. When you combine
|
||||
// two streams the result is: a failure if EITHER failed, still loading if
|
||||
// either is loading, empty if either is empty, and only Success when BOTH
|
||||
// succeeded. Precedence: Failure > Loading > Empty > Success.
|
||||
|
||||
/** Transform the value inside a Success; pass other states through unchanged. */
|
||||
export function map<E, A, B>(rd: RemoteData<E, A>, f: (a: A) => B): RemoteData<E, B> {
|
||||
return rd.tag === 'Success' ? { tag: 'Success', value: f(rd.value) } : rd;
|
||||
}
|
||||
|
||||
/** Combine two sources into one. Use this to merge e.g. a BIG-register call
|
||||
and a BRP call into a single state the page can render. */
|
||||
export function map2<E, A, B, R>(
|
||||
a: RemoteData<E, A>,
|
||||
b: RemoteData<E, B>,
|
||||
f: (a: A, b: B) => R,
|
||||
): RemoteData<E, R> {
|
||||
if (a.tag === 'Failure') return a;
|
||||
if (b.tag === 'Failure') return b;
|
||||
if (a.tag === 'Loading' || b.tag === 'Loading') return { tag: 'Loading' };
|
||||
if (a.tag === 'Empty' || b.tag === 'Empty') return { tag: 'Empty' };
|
||||
return { tag: 'Success', value: f(a.value, b.value) };
|
||||
}
|
||||
|
||||
/** Chain a second source that depends on the first one's value. */
|
||||
export function andThen<E, A, B>(
|
||||
rd: RemoteData<E, A>,
|
||||
f: (a: A) => RemoteData<E, B>,
|
||||
): RemoteData<E, B> {
|
||||
return rd.tag === 'Success' ? f(rd.value) : rd;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { InjectionToken, Signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* A shared seam for the chrome to show "who is logged in" + log out, WITHOUT
|
||||
* shared/ depending on the auth context (the import-direction rule forbids that).
|
||||
* Auth provides this token at the app root (see app.config.ts); the shared header
|
||||
* injects it. SessionStore satisfies this shape structurally.
|
||||
*/
|
||||
export interface SessionPort {
|
||||
readonly session: Signal<{ naam: string } | null>;
|
||||
logout(): void;
|
||||
}
|
||||
|
||||
export const SESSION_PORT = new InjectionToken<SessionPort>('SESSION_PORT');
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ApplicationRef, effect } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createStore } from './store';
|
||||
|
||||
describe('createStore', () => {
|
||||
it('applies the pure update on dispatch', () => {
|
||||
const store = createStore(0, (n: number, m: number) => n + m);
|
||||
store.dispatch(5);
|
||||
store.dispatch(3);
|
||||
expect(store.model()).toBe(8);
|
||||
});
|
||||
|
||||
// Regression: an effect that dispatches must NOT re-run because of its own write.
|
||||
// dispatch used to read `model()` reactively (`set(update(model(), msg))`), so an
|
||||
// effect calling dispatch subscribed to `model` and looped forever, livelocking the
|
||||
// main thread (crashed the upload wizards). With `.update` the read is untracked.
|
||||
it('dispatch from inside an effect does not self-loop', () => {
|
||||
const store = createStore(0, (n: number, _m: 'inc') => n + 1);
|
||||
let runs = 0;
|
||||
TestBed.runInInjectionContext(() => {
|
||||
effect(() => {
|
||||
runs++;
|
||||
if (runs < 100) store.dispatch('inc'); // bounded so the buggy version can't hang the test
|
||||
});
|
||||
});
|
||||
TestBed.inject(ApplicationRef).tick(); // flush effects
|
||||
|
||||
expect(runs).toBe(1); // effect ran once; its own dispatch did not retrigger it
|
||||
expect(store.model()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Signal, signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* A tiny "Elm-style" store. The whole idea: all state lives in ONE value
|
||||
* (the Model). The only way to change it is to send a message (Msg) to a PURE
|
||||
* function `update(model, msg)` that returns the next Model. Nothing else
|
||||
* mutates state, so to understand the app you only read the update function.
|
||||
*
|
||||
* Side effects (HTTP, timers) do NOT go in `update` — that stays pure and easy
|
||||
* to test. Instead, effectful "command" functions call the network and then
|
||||
* `dispatch` a message describing what happened (e.g. Loaded / Failed).
|
||||
*/
|
||||
export interface Store<Model, Msg> {
|
||||
/** The current state, as a read-only Angular signal. */
|
||||
readonly model: Signal<Model>;
|
||||
/** Send a message; the model becomes update(model, msg). */
|
||||
dispatch(msg: Msg): void;
|
||||
}
|
||||
|
||||
export function createStore<Model, Msg>(
|
||||
init: Model,
|
||||
update: (model: Model, msg: Msg) => Model,
|
||||
): Store<Model, Msg> {
|
||||
const model = signal(init);
|
||||
return {
|
||||
model: model.asReadonly(),
|
||||
// Use `.update` (raw current value, no tracked read) not `set(update(model(), …))`:
|
||||
// dispatch is a command and must never subscribe its caller to `model`. Reading
|
||||
// `model()` here inside an effect that also dispatches makes the effect depend on
|
||||
// its own write and livelock the main thread (crashed the upload wizards).
|
||||
dispatch: (msg) => model.update((m) => update(m, msg)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runSubmit } from './submit';
|
||||
|
||||
describe('runSubmit', () => {
|
||||
it('folds a resolved call into ok(value)', async () => {
|
||||
const r = await runSubmit(async () => 'BIG-123', 'fallback');
|
||||
expect(r).toEqual({ ok: true, value: 'BIG-123' });
|
||||
});
|
||||
|
||||
it('maps a ProblemDetails rejection to err(detail)', async () => {
|
||||
const r = await runSubmit(async () => {
|
||||
throw { detail: 'Aanvraag afgewezen.' };
|
||||
}, 'fallback');
|
||||
expect(r).toEqual({ ok: false, error: 'Aanvraag afgewezen.' });
|
||||
});
|
||||
|
||||
it('falls back when the rejection has no detail', async () => {
|
||||
const r = await runSubmit(async () => {
|
||||
throw new Error('network');
|
||||
}, 'fallback');
|
||||
expect(r).toEqual({ ok: false, error: 'fallback' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { withIdempotencyKey } from '@shared/infrastructure/api-client.provider';
|
||||
|
||||
/**
|
||||
* Run a mutating API call and fold it into a `Result` — the one place the
|
||||
* try/catch + ProblemDetails-mapping lives, so every `submit-*` command is just
|
||||
* its own payload mapping. The backend re-validates and returns a 422
|
||||
* ProblemDetails on rejection, surfaced here as the error string.
|
||||
*
|
||||
* Also the one place a logical submit's Idempotency-Key is minted — once per
|
||||
* `runSubmit` call, not per HTTP attempt — so a retry of this same submit
|
||||
* dedupes on the backend (see `withIdempotencyKey`).
|
||||
*/
|
||||
export async function runSubmit<T>(
|
||||
fn: () => Promise<T>,
|
||||
fallback: string,
|
||||
): Promise<Result<string, T>> {
|
||||
try {
|
||||
return ok(await withIdempotencyKey(crypto.randomUUID(), fn));
|
||||
} catch (e) {
|
||||
return err(problemDetail(e, fallback));
|
||||
}
|
||||
}
|
||||
|
||||
// Single shared default for a failed submit; the @@id dedupes it at the
|
||||
// translation layer.
|
||||
export const SUBMIT_FAILED = $localize`:@@submit.failed:Het indienen is niet gelukt. Probeer het later opnieuw.`;
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* A stable, namespaced capability string (PRD-0002 §5a), e.g. `brief:approve`.
|
||||
* Server-resolved and opaque to the FE — never derived from a role client-side.
|
||||
*/
|
||||
export type Capability =
|
||||
| 'brief:approve'
|
||||
| 'brief:reject'
|
||||
| 'brief:send'
|
||||
| 'orgtemplate:edit'
|
||||
| 'stamdata:edit'
|
||||
| 'cases:manage'
|
||||
| 'flags:manage';
|
||||
@@ -0,0 +1,9 @@
|
||||
/** A runtime feature flag as the FE sees it (resolved: catalog default + admin override). */
|
||||
export interface FeatureFlag {
|
||||
key: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Known flag keys the FE gates on — must match the backend `FeatureFlags` catalog. */
|
||||
export const FLAG_INSCHRIJVING_OPEN = 'inschrijving-open';
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* The letter workflow's acting role: drafter or approver for the two-person
|
||||
* compose/review flow, admin for org-template management (WP-23, Brief v2).
|
||||
* A pure domain type (no framework, no reading mechanism) — the `?role=` reader and
|
||||
* the X-Role header live in shared/infrastructure/role.ts. Consumers (brief.store,
|
||||
* letter-composer) depend on this type, not on how the role is obtained.
|
||||
*/
|
||||
export type Role = 'drafter' | 'approver' | 'admin';
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Production environment. `apiBaseUrl` stays relative ('') for a same-origin /
|
||||
* reverse-proxy deployment; set it to the API origin (e.g. 'https://api.example.nl')
|
||||
* when the SPA and backend are served from different hosts. This is the single
|
||||
* place the deployed API location is configured.
|
||||
*/
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiBaseUrl: '',
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Default (development) environment. `apiBaseUrl` is empty so requests are
|
||||
* relative to the current origin — in dev the ng-serve proxy forwards /api to the
|
||||
* backend (proxy.conf.json). Swapped for environment.prod.ts in production builds
|
||||
* (angular.json fileReplacements).
|
||||
*/
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiBaseUrl: '',
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { HttpClient, HttpErrorResponse, HttpResponse } from '@angular/common/http';
|
||||
import { currentIdempotencyKey, httpClientFetch, withIdempotencyKey } from './api-client.provider';
|
||||
|
||||
/** Minimal stand-in for HttpClient — only `.request(...)` is ever called by the
|
||||
* adapter under test, so no TestBed/HttpClientTestingModule needed. */
|
||||
function fakeHttpClient(
|
||||
request: (method: string, url: string, options: { headers: Record<string, string> }) => unknown,
|
||||
): HttpClient {
|
||||
return { request } as unknown as HttpClient;
|
||||
}
|
||||
|
||||
describe('withIdempotencyKey / currentIdempotencyKey', () => {
|
||||
it('threads the key to every read made inside the wrapped fn', async () => {
|
||||
const seen: string[] = [];
|
||||
await withIdempotencyKey('fixed-key', async () => {
|
||||
seen.push(currentIdempotencyKey());
|
||||
seen.push(currentIdempotencyKey());
|
||||
});
|
||||
expect(seen).toEqual(['fixed-key', 'fixed-key']);
|
||||
});
|
||||
|
||||
it('clears the key once the wrapped fn settles', async () => {
|
||||
await withIdempotencyKey('fixed-key', async () => undefined);
|
||||
expect(currentIdempotencyKey()).not.toBe('fixed-key');
|
||||
});
|
||||
|
||||
it('falls back to a generated uuid-shaped key when none is pending', () => {
|
||||
expect(currentIdempotencyKey()).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('httpClientFetch', () => {
|
||||
it('sends the pending idempotency key as a header for a write, not a fresh one per attempt', async () => {
|
||||
let sentHeaders: Record<string, string> | undefined;
|
||||
const http = fakeHttpClient((_method, _url, opts) => {
|
||||
sentHeaders = opts.headers;
|
||||
return of(new HttpResponse({ status: 200, body: '' }));
|
||||
});
|
||||
|
||||
await withIdempotencyKey('logical-submit-key', () =>
|
||||
httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' }),
|
||||
);
|
||||
|
||||
expect(sentHeaders?.['Idempotency-Key']).toBe('logical-submit-key');
|
||||
});
|
||||
|
||||
// `http.request(...)` itself is only called once per `fetch()` — it returns a
|
||||
// cold Observable, and `retry` resubscribes to *that*, not to `.request()`
|
||||
// again (exactly how Angular's real HttpClient triggers a fresh network call
|
||||
// per subscription). So attempts are counted where the resubscription lands:
|
||||
// the `throwError` factory, not the outer mock call.
|
||||
it('retries a failing GET twice before giving up', async () => {
|
||||
let attempts = 0;
|
||||
const http = fakeHttpClient(() =>
|
||||
throwError(() => {
|
||||
attempts++;
|
||||
return new HttpErrorResponse({ status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await httpClientFetch(http).fetch('/api/v1/notes', { method: 'GET' });
|
||||
|
||||
expect(attempts).toBe(3); // 1 original + 2 retries
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it('never retries a failing write', async () => {
|
||||
let attempts = 0;
|
||||
const http = fakeHttpClient(() =>
|
||||
throwError(() => {
|
||||
attempts++;
|
||||
return new HttpErrorResponse({ status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' });
|
||||
|
||||
expect(attempts).toBe(1);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Provider } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { firstValueFrom, retry, timeout, TimeoutError } from 'rxjs';
|
||||
import { ApiClient, ProblemDetails } from './api-client';
|
||||
import { environment } from '@shared/environments/environment';
|
||||
|
||||
/** Single place every API call passes through: the seam for cross-cutting concerns. */
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* A stable Idempotency-Key threaded down from the command layer (one per logical
|
||||
* submit — see `runSubmit`) rather than minted per HTTP attempt, so a retried
|
||||
* submit dedupes on the backend instead of double-submitting. The NSwag-generated
|
||||
* `ApiClient` has no per-call header hook, so `withIdempotencyKey` bridges it here:
|
||||
* every non-GET call made synchronously inside `fn` picks up the same key.
|
||||
* ponytail: a module-level variable, not a proper async-context primitive — holds
|
||||
* up because every submit command calls its adapter synchronously (no await
|
||||
* before reaching this file); swap for `AsyncLocal`-equivalent if concurrent
|
||||
* submits ever become possible.
|
||||
*/
|
||||
let pendingIdempotencyKey: string | undefined;
|
||||
|
||||
export function withIdempotencyKey<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
pendingIdempotencyKey = key;
|
||||
return fn().finally(() => (pendingIdempotencyKey = undefined));
|
||||
}
|
||||
|
||||
export function currentIdempotencyKey(): string {
|
||||
return pendingIdempotencyKey ?? crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts Angular's HttpClient to the fetch-shaped interface the NSwag-generated
|
||||
* client expects, so every API call flows through HttpClient interceptors (the
|
||||
* `?scenario=` toggle) and the cross-cutting concerns below. The generated client
|
||||
* is the only place HTTP shapes are known; this is the only place it meets
|
||||
* Angular's HTTP stack — i.e. the one seam to add:
|
||||
* - timeout (done — REQUEST_TIMEOUT_MS),
|
||||
* - correlation id (done — X-Correlation-Id, echoed in backend logs),
|
||||
* - idempotency key for writes (done — Idempotency-Key, stable per logical
|
||||
* submit via `withIdempotencyKey`/`runSubmit`, so a retry dedupes),
|
||||
* - auth: attach `Authorization: Bearer …` here (one line) when real DigiD lands,
|
||||
* - retry/backoff (done — GET only, `retry({ count: 2, delay: 500 })`; writes are
|
||||
* never auto-retried, which is exactly what makes the idempotency key above
|
||||
* matter only for a future/manual retry, not routine traffic).
|
||||
*/
|
||||
export function httpClientFetch(http: HttpClient) {
|
||||
return {
|
||||
async fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
const headers: Record<string, string> = {
|
||||
...((init?.headers ?? {}) as Record<string, string>),
|
||||
'X-Correlation-Id': crypto.randomUUID(),
|
||||
};
|
||||
if (method !== 'GET') headers['Idempotency-Key'] = currentIdempotencyKey();
|
||||
try {
|
||||
const request$ = http
|
||||
.request(method, url as string, {
|
||||
body: init?.body as string | undefined,
|
||||
headers,
|
||||
observe: 'response',
|
||||
responseType: 'text',
|
||||
})
|
||||
.pipe(timeout(REQUEST_TIMEOUT_MS));
|
||||
const res = await firstValueFrom(
|
||||
method === 'GET' ? request$.pipe(retry({ count: 2, delay: 500 })) : request$,
|
||||
);
|
||||
// 204/205/304 are null-body statuses — new Response(body, …) throws for any non-null body.
|
||||
const nullBody = res.status === 204 || res.status === 205 || res.status === 304;
|
||||
return new Response(nullBody ? null : (res.body ?? ''), { status: res.status || 200 });
|
||||
} catch (e) {
|
||||
if (e instanceof TimeoutError) return new Response('', { status: 504 });
|
||||
const err = e as HttpErrorResponse;
|
||||
const body = typeof err.error === 'string' ? err.error : JSON.stringify(err.error ?? {});
|
||||
// ponytail: clamp to a Response-constructible status (an aborted/interceptor
|
||||
// request reports status 0, which `new Response` rejects).
|
||||
const status = err.status >= 200 && err.status <= 599 ? err.status : 500;
|
||||
return new Response(body, { status });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Provide a root ApiClient that talks through HttpClient. Base URL comes from the
|
||||
* environment (relative '' in dev → proxy; configurable per deployment). */
|
||||
export function provideApiClient(): Provider {
|
||||
return {
|
||||
provide: ApiClient,
|
||||
useFactory: (http: HttpClient) => new ApiClient(environment.apiBaseUrl, httpClientFetch(http)),
|
||||
deps: [HttpClient],
|
||||
};
|
||||
}
|
||||
|
||||
export type { ProblemDetails };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { problemDetail, problemFieldErrors } from './api-error';
|
||||
|
||||
describe('problemDetail', () => {
|
||||
it('extracts the detail from an RFC-7807 ProblemDetails', () => {
|
||||
expect(problemDetail({ detail: 'Afgewezen: 0 uren.', status: 422 }, 'fallback')).toBe(
|
||||
'Afgewezen: 0 uren.',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back when there is no detail', () => {
|
||||
expect(problemDetail(new Error('boom'), 'fallback')).toBe('fallback');
|
||||
expect(problemDetail({ status: 500 }, 'fallback')).toBe('fallback');
|
||||
expect(problemDetail(undefined, 'fallback')).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('problemFieldErrors (G4 seam)', () => {
|
||||
it('maps a ValidationProblemDetails errors dict to first-message-per-field', () => {
|
||||
expect(
|
||||
problemFieldErrors({ errors: { straat: ['Verplicht.'], postcode: ['Ongeldig.', 'x'] } }),
|
||||
).toEqual({ straat: 'Verplicht.', postcode: 'Ongeldig.' });
|
||||
});
|
||||
|
||||
it('returns {} when there is no errors envelope (the current backend shape)', () => {
|
||||
expect(problemFieldErrors({ detail: 'one banner' })).toEqual({});
|
||||
expect(problemFieldErrors(new Error('boom'))).toEqual({});
|
||||
expect(problemFieldErrors(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ProblemDetails } from './api-client';
|
||||
|
||||
/**
|
||||
* Extract a human-readable message from a rejected API call. A 4xx/5xx with a
|
||||
* ProblemDetails body (RFC 7807) is thrown by the generated client as the parsed
|
||||
* object; anything else falls back to the given message.
|
||||
*/
|
||||
export function problemDetail(e: unknown, fallback: string): string {
|
||||
if (e && typeof e === 'object' && 'detail' in e) {
|
||||
const detail = (e as ProblemDetails).detail;
|
||||
if (typeof detail === 'string' && detail) return detail;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* SEAM (G4): map a server validation envelope to field-level errors.
|
||||
*
|
||||
* ASP.NET's ValidationProblemDetails carries `errors: { field: string[] }`. The
|
||||
* backend today returns only `detail` (one banner message), so this returns `{}`.
|
||||
* When the backend starts sending `errors`, a machine's `SubmitFailed` handler can
|
||||
* merge this into its own `errors` map — the field-keyed shape the wizards already
|
||||
* render — so a rejection shows inline per field, not just as a banner. The
|
||||
* consumer hook is the only thing left to wire; the contract boundary lives here.
|
||||
*/
|
||||
export function problemFieldErrors(e: unknown): Record<string, string> {
|
||||
if (!e || typeof e !== 'object' || !('errors' in e)) return {};
|
||||
const errors = (e as { errors?: unknown }).errors;
|
||||
if (!errors || typeof errors !== 'object') return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [field, msgs] of Object.entries(errors as Record<string, unknown>)) {
|
||||
const first = Array.isArray(msgs) ? msgs[0] : msgs;
|
||||
if (typeof first === 'string') out[field] = first;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { stripDevParams } from './dev-params';
|
||||
|
||||
describe('stripDevParams (WP-37)', () => {
|
||||
it('removes ?scenario and ?role so the stored dev value wins on reload', () => {
|
||||
expect(stripDevParams('http://localhost:4200/dashboard?scenario=slow&role=admin')).toBe(
|
||||
'http://localhost:4200/dashboard',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps unrelated query params and the path/hash', () => {
|
||||
expect(stripDevParams('http://localhost:4200/beheer/zaken?scenario=error&tab=2#top')).toBe(
|
||||
'http://localhost:4200/beheer/zaken?tab=2#top',
|
||||
);
|
||||
});
|
||||
|
||||
it('is a no-op when neither param is present', () => {
|
||||
expect(stripDevParams('http://localhost:4200/dashboard')).toBe(
|
||||
'http://localhost:4200/dashboard',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Remove the dev-only `?scenario=` and `?role=` params from a URL (WP-37). Once the
|
||||
* dev switcher (debug-state) has been used, sessionStorage is the authoritative source
|
||||
* for both — `currentScenario()`/`currentRole()` read the URL FIRST, so a stale param
|
||||
* left in the address bar would override the switcher on reload (the "stuck on slow"
|
||||
* bug). Stripping the params before reload lets the stored value win. Pure: returns the
|
||||
* rewritten href, mutates nothing.
|
||||
*/
|
||||
export function stripDevParams(href: string): string {
|
||||
const url = new URL(href);
|
||||
url.searchParams.delete('scenario');
|
||||
url.searchParams.delete('role');
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
import { FeatureFlag } from '@shared/domain/feature-flag';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for feature flags (WP-47): `GET /flags` (resolved set, drives FE gating)
|
||||
* and the admin `PUT /admin/flags/{key}`. The single place the ApiClient lives for flags; the
|
||||
* store parses at the boundary.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FeatureFlagsAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
list() {
|
||||
return this.client.flagsAll();
|
||||
}
|
||||
set(key: string, enabled: boolean) {
|
||||
return this.client.flags(key, { enabled });
|
||||
}
|
||||
}
|
||||
|
||||
/** Trust-boundary parse of the flag set. */
|
||||
export function parseFlags(json: unknown): Result<string, FeatureFlag[]> {
|
||||
if (!Array.isArray(json)) return err('flags: not an array');
|
||||
const out: FeatureFlag[] = [];
|
||||
for (const f of json) {
|
||||
if (typeof f !== 'object' || f === null) return err('flags: row not an object');
|
||||
const d = f as Partial<FeatureFlag>;
|
||||
if (typeof d.key !== 'string' || typeof d.enabled !== 'boolean') return err('flags: bad shape');
|
||||
out.push({
|
||||
key: d.key,
|
||||
description: typeof d.description === 'string' ? d.description : '',
|
||||
enabled: d.enabled,
|
||||
});
|
||||
}
|
||||
return ok(out);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseMe } from './me.adapter';
|
||||
|
||||
describe('parseMe (trust boundary)', () => {
|
||||
it('parses a known capability list', () => {
|
||||
const r = parseMe({ capabilities: ['brief:approve', 'brief:reject', 'brief:send'] });
|
||||
expect(r).toEqual({ ok: true, value: ['brief:approve', 'brief:reject', 'brief:send'] });
|
||||
});
|
||||
|
||||
it('parses an empty list (drafter — no capabilities)', () => {
|
||||
expect(parseMe({ capabilities: [] })).toEqual({ ok: true, value: [] });
|
||||
});
|
||||
|
||||
it('recognizes the admin org-template capability (WP-23)', () => {
|
||||
expect(parseMe({ capabilities: ['orgtemplate:edit'] })).toEqual({
|
||||
ok: true,
|
||||
value: ['orgtemplate:edit'],
|
||||
});
|
||||
});
|
||||
|
||||
it('drops unrecognized capability strings instead of rejecting the response', () => {
|
||||
const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] });
|
||||
expect(r).toEqual({ ok: true, value: ['brief:approve'] });
|
||||
});
|
||||
|
||||
it('rejects malformed responses instead of trusting them', () => {
|
||||
expect(parseMe(null).ok).toBe(false);
|
||||
expect(parseMe({}).ok).toBe(false);
|
||||
expect(parseMe({ capabilities: 'brief:approve' }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable, inject, resource } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
|
||||
const KNOWN: readonly Capability[] = [
|
||||
'brief:approve',
|
||||
'brief:reject',
|
||||
'brief:send',
|
||||
'orgtemplate:edit',
|
||||
'stamdata:edit',
|
||||
'cases:manage',
|
||||
'flags:manage',
|
||||
];
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for `GET /me` (PRD-0002 §6): the current principal's
|
||||
* coarse, role-derived capabilities — nav/menu-level, not tied to any one screen's
|
||||
* live status (contrast a screen's own decision DTO, e.g. `BriefViewDto.decisions`).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MeAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
meResource() {
|
||||
return resource({ loader: () => this.client.me() });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust-boundary parse. An unrecognized capability string is dropped rather than
|
||||
* rejecting the whole response — deny-by-default already covers it (AccessStore.can
|
||||
* returns false for anything not in the set), and it lets the backend grow the
|
||||
* capability list without breaking an older FE build.
|
||||
*/
|
||||
export function parseMe(json: unknown): Result<string, Capability[]> {
|
||||
if (typeof json !== 'object' || json === null) return err('me: not an object');
|
||||
const dto = json as { capabilities?: unknown };
|
||||
if (!Array.isArray(dto.capabilities)) return err('me: missing/invalid capabilities');
|
||||
return ok(dto.capabilities.filter((c): c is Capability => KNOWN.includes(c as Capability)));
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { roleInterceptor } from './role.interceptor';
|
||||
|
||||
// currentRole() reads window.location.search; set it via the real URL rather than
|
||||
// vi.mock (the Angular unit-test system forbids mocking relative imports).
|
||||
beforeEach(() => window.history.replaceState({}, '', '/?role=admin'));
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
sessionStorage.clear(); // currentRole() now persists the dev role; don't leak across tests
|
||||
});
|
||||
|
||||
// Minimal stand-in for HttpRequest — the interceptor only reads `url` and calls
|
||||
// `clone({ setHeaders })`. Avoids importing @angular/common/http (its XHR chunk needs
|
||||
// the JIT compiler under vitest).
|
||||
function fakeReq(url: string) {
|
||||
const make = (headers: Map<string, string>) => ({
|
||||
url,
|
||||
headers,
|
||||
clone(opts: { setHeaders: Record<string, string> }) {
|
||||
const next = new Map(headers);
|
||||
for (const [k, v] of Object.entries(opts.setHeaders)) next.set(k, v);
|
||||
return make(next);
|
||||
},
|
||||
});
|
||||
return make(new Map());
|
||||
}
|
||||
|
||||
/** Run the interceptor and return the request it forwarded to `next`. */
|
||||
function forward(url: string) {
|
||||
let seen!: ReturnType<typeof fakeReq>;
|
||||
const next = (r: ReturnType<typeof fakeReq>) => {
|
||||
seen = r;
|
||||
return undefined;
|
||||
};
|
||||
// Cast: the fake matches the shape the interceptor actually touches.
|
||||
(roleInterceptor as unknown as (req: unknown, next: unknown) => unknown)(fakeReq(url), next);
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe('roleInterceptor', () => {
|
||||
it.each([
|
||||
'/api/v1/brief',
|
||||
'/api/v1/admin/org-template',
|
||||
'/api/v1/stamdata', // WP-29: the admin stamdata reads 403 without X-Role
|
||||
'/api/v1/stamdata/professions?peildatum=1999-01-01',
|
||||
'/api/v1/me',
|
||||
])('stamps X-Role on the role-aware endpoint %s', (url) => {
|
||||
expect(forward(url).headers.get('X-Role')).toBe('admin');
|
||||
});
|
||||
|
||||
it('leaves an unrelated endpoint untouched', () => {
|
||||
expect(forward('/api/v1/duo/diplomas').headers.has('X-Role')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { currentRole } from './role';
|
||||
|
||||
/**
|
||||
* Dev-only: stamps role-aware requests with the current `?role=` as an `X-Role`
|
||||
* header so the backend can enforce the drafter/approver/admin rules. Only the
|
||||
* brief, org-template, stamdata and /me endpoints carry it (WP-23 widened the set —
|
||||
* /me must see the role or `AccessStore` could never learn a capability; WP-29 added
|
||||
* /stamdata, whose admin-only reads 403 without it); everything else is untouched.
|
||||
* A new admin-gated endpoint MUST be added here or its page silently 403s.
|
||||
*/
|
||||
const ROLE_AWARE = [
|
||||
'/api/v1/brief',
|
||||
'/api/v1/admin/org-template',
|
||||
'/api/v1/admin/cases',
|
||||
'/api/v1/admin/audit',
|
||||
'/api/v1/admin/flags',
|
||||
'/api/v1/stamdata',
|
||||
'/api/v1/me',
|
||||
];
|
||||
|
||||
export const roleInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (!ROLE_AWARE.some((prefix) => req.url.includes(prefix))) return next(req);
|
||||
return next(req.clone({ setHeaders: { 'X-Role': currentRole() } }));
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Role } from '@shared/domain/role';
|
||||
|
||||
/**
|
||||
* Dev-only role stand-in (the reading MECHANISM; the `Role` type is domain). This
|
||||
* POC has one faked self-service user and no real identities, so the two-person
|
||||
* letter workflow (drafter vs approver) plus admin is driven by a `?role=` query
|
||||
* param. The backend receives it as an `X-Role` header (see role.interceptor),
|
||||
* resolves it into a `Principal` server-side, and is the sole authority on what that
|
||||
* principal may do (PRD-0002 phase P1, `Authz.Can`) — the FE only renders the
|
||||
* resulting decision flags, it no longer derives permission from this value itself.
|
||||
*
|
||||
* **Sticky within the tab (sessionStorage):** the interceptor reads this per request,
|
||||
* but navigation drops the query param (login redirects to /dashboard, RouterLinks
|
||||
* don't carry it), which would silently revert an admin to drafter mid-session and
|
||||
* 403 the admin endpoints. So a `?role=` seen in the URL is remembered for the tab;
|
||||
* later requests use the remembered value. Set `?role=drafter` (or a fresh tab) to
|
||||
* reset. Dev-only — the interceptor itself is only wired under `isDevMode()`.
|
||||
*/
|
||||
const STORAGE_KEY = 'dev-role';
|
||||
export const ROLES: readonly Role[] = ['drafter', 'approver', 'admin'];
|
||||
const isRole = (v: string | null): v is Role => !!v && ROLES.includes(v as Role);
|
||||
|
||||
export function currentRole(): Role {
|
||||
const fromUrl = new URLSearchParams(window.location.search).get('role');
|
||||
if (isRole(fromUrl)) {
|
||||
sessionStorage.setItem(STORAGE_KEY, fromUrl);
|
||||
return fromUrl;
|
||||
}
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
return isRole(stored) ? stored : 'drafter';
|
||||
}
|
||||
|
||||
/** Dev switcher entry point: persist the chosen role for the tab (WP-33). */
|
||||
export function setRole(r: Role): void {
|
||||
sessionStorage.setItem(STORAGE_KEY, r);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn, HttpResponse } from '@angular/common/http';
|
||||
import { of, switchMap, throwError, timer } from 'rxjs';
|
||||
import { delay } from 'rxjs/operators';
|
||||
import { currentScenario } from './scenario';
|
||||
|
||||
/**
|
||||
* Demo-only: rewrites the timing/outcome of API data requests based on
|
||||
* ?scenario= so loading / empty / error states can be shown on demand.
|
||||
* Non-API requests are untouched.
|
||||
*/
|
||||
export const scenarioInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (!req.url.includes('/api/')) return next(req);
|
||||
|
||||
switch (currentScenario()) {
|
||||
case 'slow':
|
||||
return next(req).pipe(delay(2500));
|
||||
case 'loading':
|
||||
return next(req).pipe(delay(600_000)); // effectively never resolves
|
||||
case 'empty':
|
||||
// '[]' so the typed client parses it to an empty array (notes → Empty state).
|
||||
return of(new HttpResponse({ status: 200, body: '[]' })).pipe(delay(400));
|
||||
case 'error':
|
||||
return timer(400).pipe(
|
||||
switchMap(() =>
|
||||
throwError(
|
||||
() => new HttpErrorResponse({ status: 500, statusText: 'Demo-fout', url: req.url }),
|
||||
),
|
||||
),
|
||||
);
|
||||
default:
|
||||
return next(req);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { currentScenario, setScenario } from './scenario';
|
||||
|
||||
const setUrl = (search: string) => history.pushState({}, '', search || '/');
|
||||
|
||||
describe('scenario (dev mechanism)', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
setUrl('/');
|
||||
});
|
||||
|
||||
it('reads a valid ?scenario= from the URL and persists it for the tab', () => {
|
||||
setUrl('?scenario=error');
|
||||
expect(currentScenario()).toBe('error');
|
||||
setUrl('/'); // navigation drops the query param — value stays sticky
|
||||
expect(currentScenario()).toBe('error');
|
||||
});
|
||||
|
||||
it('falls back to default when nothing is set or the value is invalid', () => {
|
||||
expect(currentScenario()).toBe('default');
|
||||
setUrl('?scenario=nonsense');
|
||||
expect(currentScenario()).toBe('default');
|
||||
});
|
||||
|
||||
it('setScenario persists the chosen scenario', () => {
|
||||
setScenario('slow');
|
||||
expect(currentScenario()).toBe('slow');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
export type Scenario =
|
||||
| 'default'
|
||||
| 'slow'
|
||||
| 'loading'
|
||||
| 'empty'
|
||||
| 'error'
|
||||
// upload-only (the multipart POST is hand-written XHR, so it bypasses the HTTP
|
||||
// interceptor — these are simulated in upload.adapter.ts instead):
|
||||
| 'upload-slow'
|
||||
| 'upload-fail';
|
||||
|
||||
export const SCENARIOS: readonly Scenario[] = [
|
||||
'default',
|
||||
'slow',
|
||||
'loading',
|
||||
'empty',
|
||||
'error',
|
||||
'upload-slow',
|
||||
'upload-fail',
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'dev-scenario';
|
||||
const isScenario = (v: string | null): v is Scenario => !!v && SCENARIOS.includes(v as Scenario);
|
||||
|
||||
/**
|
||||
* Reads the active demo scenario so a demo can force each async state.
|
||||
* Sticky within the tab (sessionStorage), mirroring `role.ts`: a `?scenario=` in the
|
||||
* URL sets it; later navigation (which drops the query param) keeps the remembered
|
||||
* value. Set `?scenario=default`, use the dev switcher, or open a fresh tab to reset.
|
||||
* Dev-only — the interceptor that consumes this is wired only under `isDevMode()`.
|
||||
*/
|
||||
export function currentScenario(): Scenario {
|
||||
const fromUrl = new URLSearchParams(window.location.search).get('scenario');
|
||||
if (isScenario(fromUrl)) {
|
||||
sessionStorage.setItem(STORAGE_KEY, fromUrl);
|
||||
return fromUrl;
|
||||
}
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
return isScenario(stored) ? stored : 'default';
|
||||
}
|
||||
|
||||
/** Dev switcher entry point: persist the chosen scenario for the tab (WP-33). */
|
||||
export function setScenario(s: Scenario): void {
|
||||
sessionStorage.setItem(STORAGE_KEY, s);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseBsn } from './bsn';
|
||||
|
||||
describe('parseBsn (elfproef)', () => {
|
||||
it('accepts a valid BSN (passes the elfproef)', () => {
|
||||
const r = parseBsn('123456782'); // Σ d·w = 154, divisible by 11
|
||||
expect(r.ok && r.value).toBe('123456782');
|
||||
});
|
||||
|
||||
it('rejects a 9-digit number that fails the elfproef', () => {
|
||||
expect(parseBsn('123456789').ok).toBe(false); // sum 147, not divisible
|
||||
});
|
||||
|
||||
it('rejects wrong length / non-digits / all zeros', () => {
|
||||
expect(parseBsn('12345').ok).toBe(false);
|
||||
expect(parseBsn('abcdefghi').ok).toBe(false);
|
||||
expect(parseBsn('000000000').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Brand, Result, ok, err } from '@shared/kernel/fp';
|
||||
|
||||
/**
|
||||
* Value object: a Dutch **BSN** (burgerservicenummer) — art. 9 GDPR/AVG special-category
|
||||
* data. "Parse, don't validate": a `Bsn` is a distinct type from a raw string, mintable only
|
||||
* via `parseBsn`, so holding one is proof it passed the **elfproef** (11-test) checksum, not
|
||||
* just a 9-digit shape. Format/checksum only — identity is still faked in this POC (DigiD stub).
|
||||
*/
|
||||
export type Bsn = Brand<string, 'Bsn'>;
|
||||
|
||||
// Positional weights for the elfproef: 9·d1 + 8·d2 + … + 2·d8 − 1·d9 ≡ 0 (mod 11).
|
||||
const WEIGHTS = [9, 8, 7, 6, 5, 4, 3, 2, -1];
|
||||
|
||||
// #region showcase:parseBsn
|
||||
export function parseBsn(raw: string): Result<string, Bsn> {
|
||||
const t = raw.trim();
|
||||
if (!/^\d{9}$/.test(t)) {
|
||||
return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`);
|
||||
}
|
||||
const sum = [...t].reduce((acc, ch, i) => acc + Number(ch) * WEIGHTS[i], 0);
|
||||
if (t === '000000000' || sum % 11 !== 0) {
|
||||
return err(
|
||||
$localize`:@@validation.bsnElfproef:Dit is geen geldig BSN (klopt niet met de elfproef).`,
|
||||
);
|
||||
}
|
||||
return ok(t as Bsn); // holding a Bsn is proof it passed the elfproef
|
||||
}
|
||||
// #endregion showcase:parseBsn
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatDatumNl } from './datum';
|
||||
|
||||
describe('formatDatumNl', () => {
|
||||
it('formats a Date in long Dutch form', () => {
|
||||
expect(formatDatumNl(new Date(2026, 6, 2))).toBe('2 juli 2026');
|
||||
});
|
||||
|
||||
it('formats an ISO string the same way', () => {
|
||||
expect(formatDatumNl('2026-07-02')).toBe('2 juli 2026');
|
||||
});
|
||||
|
||||
it('is empty-safe: undefined, null, and empty string all yield the empty string', () => {
|
||||
expect(formatDatumNl(undefined)).toBe('');
|
||||
expect(formatDatumNl(null)).toBe('');
|
||||
expect(formatDatumNl('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty for an unparseable string rather than "Invalid Date"', () => {
|
||||
expect(formatDatumNl('not-a-date')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* The one hand-written date formatter for pure TS (non-template) code — a domain
|
||||
* rule or a `$localize` string can't reach for Angular's `DatePipe`, so this covers
|
||||
* that gap. Templates use `DatePipe` (`| date: 'longDate'`) instead; don't add a
|
||||
* second hand-rolled formatter for either case.
|
||||
*/
|
||||
export function formatDatumNl(d: Date | string | undefined | null): string {
|
||||
if (!d) return '';
|
||||
const date = typeof d === 'string' ? new Date(d) : d;
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return new Intl.DateTimeFormat('nl-NL', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Tiny native-TS functional toolkit. No dependency — this is the whole "library".
|
||||
* Reused by every "impossible states" concept in the POC.
|
||||
*/
|
||||
|
||||
/** Exhaustiveness guard: put in the `default` arm of a union switch. Adding a
|
||||
new variant without handling it then fails to compile (x is no longer never). */
|
||||
export function assertNever(x: never): never {
|
||||
throw new Error('Unexpected variant: ' + JSON.stringify(x));
|
||||
}
|
||||
|
||||
/** A computation that either succeeded with a value or failed with an error.
|
||||
Plain objects (no classes) to match the signal/httpResource ergonomics. */
|
||||
export type Result<E, T> =
|
||||
{ readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: E };
|
||||
|
||||
export const ok = <T>(value: T): Result<never, T> => ({ ok: true, value });
|
||||
export const err = <E>(error: E): Result<E, never> => ({ ok: false, error });
|
||||
|
||||
/** Nominal typing: Brand<string, 'Postcode'> is assignable from a plain string
|
||||
only through an explicit cast — so a smart constructor is the only minter. */
|
||||
export type Brand<T, B extends string> = T & { readonly __brand: B };
|
||||
|
||||
/** Narrow a tagged union to one variant by its `tag`, or null. The single place
|
||||
the cast lives — TS can't narrow through a runtime tag argument, so callers get
|
||||
`whenTag(state, 'Editing')?.foo` instead of repeating `as Extract<…>`. */
|
||||
export function whenTag<U extends { tag: string }, K extends U['tag']>(
|
||||
u: U,
|
||||
tag: K,
|
||||
): Extract<U, { tag: K }> | null {
|
||||
return u.tag === tag ? (u as Extract<U, { tag: K }>) : null;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { maskBsn, maskTail } from './pii';
|
||||
|
||||
describe('pii maskers', () => {
|
||||
it('maskBsn keeps the last 3 digits', () => {
|
||||
expect(maskBsn('123456789')).toBe('******789');
|
||||
});
|
||||
|
||||
it('maskTail keeps the requested tail length', () => {
|
||||
expect(maskTail('abcdef', 2)).toBe('****ef');
|
||||
});
|
||||
|
||||
it('masks the whole value when it is not longer than the kept tail', () => {
|
||||
expect(maskBsn('12')).toBe('**');
|
||||
expect(maskBsn('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* PII masking — pure functional core (WP-40). Data-minimisation helpers shared by the app
|
||||
* (dev state panel, the masked-value atom, anywhere sensitive data is shown). No framework,
|
||||
* no domain imports. The backend keeps a `MaskTail` twin in sync (see Program.cs).
|
||||
*/
|
||||
export const REDACTED = '‹redacted›';
|
||||
|
||||
// #region showcase:mask
|
||||
/** Keep the last `keep` characters, mask the rest with `*`. */
|
||||
export function maskTail(value: string, keep: number): string {
|
||||
if (value.length <= keep) return '*'.repeat(value.length);
|
||||
return '*'.repeat(value.length - keep) + value.slice(-keep);
|
||||
}
|
||||
|
||||
/** Mask a BSN / BIG-nummer for display: keep the last 3 digits, mask the rest. */
|
||||
export function maskBsn(value: string): string {
|
||||
return maskTail(value, 3);
|
||||
}
|
||||
// #endregion showcase:mask
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
RichTextBlock,
|
||||
deepCopyBlock,
|
||||
emptyBlock,
|
||||
isBlockEmpty,
|
||||
placeholderKeysIn,
|
||||
} from './rich-text';
|
||||
|
||||
const block = (): RichTextBlock => ({
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Beste ' },
|
||||
{ type: 'placeholder', key: 'naam' },
|
||||
],
|
||||
},
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'placeholder', key: 'datum' },
|
||||
{ type: 'placeholder', key: 'naam' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('rich-text', () => {
|
||||
it('emptyBlock is one empty paragraph and reads as empty', () => {
|
||||
expect(emptyBlock()).toEqual({ paragraphs: [{ nodes: [] }] });
|
||||
expect(isBlockEmpty(emptyBlock())).toBe(true);
|
||||
});
|
||||
|
||||
it('isBlockEmpty is false when any placeholder or non-blank text exists', () => {
|
||||
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'text', text: ' ' }] }] })).toBe(true);
|
||||
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'placeholder', key: 'x' }] }] })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'text', text: 'hoi' }] }] })).toBe(false);
|
||||
});
|
||||
|
||||
it('placeholderKeysIn walks in document order, keeping duplicates', () => {
|
||||
expect(placeholderKeysIn(block())).toEqual(['naam', 'datum', 'naam']);
|
||||
});
|
||||
|
||||
it('deepCopyBlock is an independent value copy (frozen snapshot)', () => {
|
||||
const original = block();
|
||||
const copy = deepCopyBlock(original);
|
||||
expect(copy).toEqual(original);
|
||||
expect(copy).not.toBe(original);
|
||||
expect(copy.paragraphs[0]).not.toBe(original.paragraphs[0]);
|
||||
// Mutating the copy must not touch the original — proves no shared reference.
|
||||
(copy.paragraphs[0].nodes as { type: 'text'; text: string }[])[0] = {
|
||||
type: 'text',
|
||||
text: 'CHANGED',
|
||||
};
|
||||
expect(placeholderKeysIn(original)).toEqual(['naam', 'datum', 'naam']);
|
||||
expect((original.paragraphs[0].nodes[0] as { text: string }).text).toBe('Beste ');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Rich text as a *serialisable value*, not opaque HTML.
|
||||
*
|
||||
* A block is a node tree. Because a placeholder is a first-class NODE (not a
|
||||
* `{{token}}` substring hidden inside a string), highlighting it, inserting it,
|
||||
* and linting it are all pure functions over data — no regex over markup. This
|
||||
* is the whole reason the letter feature stays in the "impossible states" style:
|
||||
* the value the app holds is always well-shaped, and the imperative editor is
|
||||
* quarantined behind one component that converts to/from this tree.
|
||||
*/
|
||||
|
||||
export type Mark = 'bold' | 'italic' | 'underline';
|
||||
|
||||
export type RichTextNode =
|
||||
| { readonly type: 'text'; readonly text: string; readonly marks?: readonly Mark[] }
|
||||
| { readonly type: 'placeholder'; readonly key: string } // resolved to a value at send
|
||||
| { readonly type: 'lineBreak' };
|
||||
|
||||
export interface Paragraph {
|
||||
readonly nodes: readonly RichTextNode[];
|
||||
// A line can be a plain paragraph (undefined) or an item in a bullet/numbered list.
|
||||
// Consecutive lines with the same list kind render as one <ul>/<ol>.
|
||||
readonly list?: 'bullet' | 'number';
|
||||
}
|
||||
|
||||
export interface RichTextBlock {
|
||||
readonly paragraphs: readonly Paragraph[];
|
||||
}
|
||||
|
||||
/** An empty editable block is one empty paragraph — never zero paragraphs, so the
|
||||
editor always has a caret line. */
|
||||
export function emptyBlock(): RichTextBlock {
|
||||
return { paragraphs: [{ nodes: [] }] };
|
||||
}
|
||||
|
||||
/** True when the block carries no visible content (used for "required section empty"). */
|
||||
export function isBlockEmpty(block: RichTextBlock): boolean {
|
||||
return block.paragraphs.every((p) =>
|
||||
p.nodes.every((n) => (n.type === 'text' ? n.text.trim() === '' : false)),
|
||||
);
|
||||
}
|
||||
|
||||
/** The frozen-snapshot primitive: a deep VALUE copy of a block. Inserting a library
|
||||
passage into a letter copies its tree through here, so the letter never shares a
|
||||
reference with the library — later library edits can't mutate an existing letter. */
|
||||
export function deepCopyBlock(block: RichTextBlock): RichTextBlock {
|
||||
// ponytail: structuredClone is exactly a deep value copy of a JSON-shaped tree;
|
||||
// a hand-rolled walk would be more code for the same result.
|
||||
return structuredClone(block) as RichTextBlock;
|
||||
}
|
||||
|
||||
/** All visible text of a block as one lowercased string — for client-side search over
|
||||
passages. Placeholders contribute their key so "naam" matches a `naam_zorgverlener` chip. */
|
||||
export function textOf(block: RichTextBlock): string {
|
||||
return block.paragraphs
|
||||
.flatMap((p) =>
|
||||
p.nodes.map((n) => (n.type === 'text' ? n.text : n.type === 'placeholder' ? n.key : '')),
|
||||
)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** Every placeholder key used in a block, in document order (duplicates kept — the
|
||||
caller dedupes when it wants a set). */
|
||||
export function placeholderKeysIn(block: RichTextBlock): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const p of block.paragraphs) {
|
||||
for (const n of p.nodes) {
|
||||
if (n.type === 'placeholder') keys.push(n.key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { BreadcrumbItem } from './breadcrumb.component';
|
||||
|
||||
/** Route → breadcrumb label + parent. The app has a small fixed route set
|
||||
(see app.routes.ts), so a static map is enough — no per-page wiring.
|
||||
ponytail: static map, not a breadcrumb service; revisit if routes go dynamic. */
|
||||
interface Crumb {
|
||||
label: string;
|
||||
parent?: string;
|
||||
}
|
||||
|
||||
const ROUTES: Record<string, Crumb> = {
|
||||
'/dashboard': { label: $localize`:@@crumb.dashboard:Mijn overzicht` },
|
||||
'/registratie': { label: $localize`:@@crumb.registratie:Mijn gegevens`, parent: '/dashboard' },
|
||||
'/registreren': { label: $localize`:@@crumb.registreren:Inschrijven`, parent: '/dashboard' },
|
||||
'/herregistratie': {
|
||||
label: $localize`:@@crumb.herregistratie:Herregistratie`,
|
||||
parent: '/dashboard',
|
||||
},
|
||||
'/intake': { label: $localize`:@@crumb.intake:Herregistratie-intake`, parent: '/dashboard' },
|
||||
'/concepts': { label: $localize`:@@crumb.concepts:Functionele patronen`, parent: '/dashboard' },
|
||||
};
|
||||
|
||||
/** Build the breadcrumb trail for a router url (query/fragment stripped).
|
||||
Returns [] for unknown routes (e.g. /login) so the bar can hide itself. */
|
||||
export function trailFor(url: string): BreadcrumbItem[] {
|
||||
const path = url.split(/[?#]/)[0];
|
||||
const trail: BreadcrumbItem[] = [];
|
||||
let cursor: string | undefined = path;
|
||||
while (cursor) {
|
||||
const node: Crumb | undefined = ROUTES[cursor];
|
||||
if (!node) break;
|
||||
trail.unshift({ label: node.label, link: cursor });
|
||||
cursor = node.parent;
|
||||
}
|
||||
// The current (last) page is not a link.
|
||||
if (trail.length) delete trail[trail.length - 1].link;
|
||||
return trail;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
link?: string; // omit on the current (last) page
|
||||
}
|
||||
|
||||
/** Chrome: breadcrumb navigation, styled for the CIBG titlebar (`.titlebar .title`) —
|
||||
plain links with a chevron `::after` from the CIBG Icons font, current page as an
|
||||
unlinked, bold span. Domain-free — the caller supplies the trail. */
|
||||
@Component({
|
||||
selector: 'app-breadcrumb',
|
||||
imports: [RouterLink],
|
||||
// CIBG's global "header nav" background rule matches ANY nav inside a <header>
|
||||
// — including this one, wherever it's mounted. Override it so the breadcrumb
|
||||
// never carries its own background (it should show whatever's behind it, e.g.
|
||||
// the titlebar's robijn fill).
|
||||
styles: [
|
||||
`
|
||||
nav {
|
||||
background: none;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<nav i18n-aria-label="@@breadcrumb.aria" aria-label="Kruimelpad">
|
||||
<span class="visually-hidden" i18n="@@breadcrumb.hier">U bevindt zich hier:</span>
|
||||
@for (item of items(); track item.label; let last = $last) {
|
||||
@if (item.link && !last) {
|
||||
<a [routerLink]="item.link">{{ item.label }}</a>
|
||||
} @else {
|
||||
<span aria-current="page">{{ item.label }}</span>
|
||||
}
|
||||
}
|
||||
</nav>
|
||||
`,
|
||||
})
|
||||
export class BreadcrumbComponent {
|
||||
items = input.required<BreadcrumbItem[]>();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { BreadcrumbComponent } from './breadcrumb.component';
|
||||
|
||||
const meta: Meta<BreadcrumbComponent> = {
|
||||
title: 'Design System/Molecules/Breadcrumb',
|
||||
component: BreadcrumbComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
// Rendered inside a mock .titlebar .title so the story reflects the real chrome.
|
||||
template: `<div class="titlebar" style="padding: 1rem"><div class="title"><app-breadcrumb [items]="items" /></div></div>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<BreadcrumbComponent>;
|
||||
|
||||
export const TweeNiveaus: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ label: 'Mijn omgeving', link: '/dashboard' },
|
||||
{ label: 'Inschrijven in het BIG-register' },
|
||||
],
|
||||
},
|
||||
};
|
||||
export const DrieNiveaus: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ label: 'Mijn omgeving', link: '/dashboard' },
|
||||
{ label: 'Registratie', link: '/registratie' },
|
||||
{ label: 'Inschrijven' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { EMPTY, filter } from 'rxjs';
|
||||
import { Locale, localeLinks } from './locale-links';
|
||||
|
||||
// CIBG-GAP EXTENSION: "Taal instellen" (designsystem.cibg.nl/componenten/taal-instellen) — no
|
||||
// vendored Huisstijl class ships for it, so this is a small hand-rolled surface built from the
|
||||
// token bridge. See cibg-gaps.mdx.
|
||||
/**
|
||||
* Organism: CIBG "Taal instellen" language switcher. A `<nav>` region (screenreader heading +
|
||||
* aria-label) with one link per locale — the endonym, tagged with its `lang`/`hreflang`, the
|
||||
* active one marked `aria-current` and rendered as text (not a link).
|
||||
*
|
||||
* Compile-time $localize means each locale is a separate bundle under `/<locale>/`, so switching
|
||||
* is a plain full-page navigation to the sibling bundle (not a runtime toggle). The active locale
|
||||
* is read from the baked `<base href>` (`/en/` → en, else nl) — the deployment truth, independent
|
||||
* of the app-config `LOCALE_ID`. Only functional where both locale bundles are served (the
|
||||
* localized build, e.g. `npm run serve:i18n`), not under plain `ng serve` (nl-only at `/`).
|
||||
*
|
||||
* The shell (and this switcher within it) is a persistent parent — only the routed child
|
||||
* swaps — so `location.pathname` must be re-read on every completed navigation (same
|
||||
* `toSignal(router.events...)` idiom as `site-header.component.ts`'s breadcrumb `url`), or the
|
||||
* target link freezes at whichever route was active when the switcher was first constructed.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-language-switcher',
|
||||
styles: [
|
||||
`
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--rhc-space-max-md);
|
||||
padding: var(--rhc-space-max-sm) var(--rhc-space-max-2xl);
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
}
|
||||
a {
|
||||
color: var(--rhc-color-hemelblauw-700);
|
||||
}
|
||||
[aria-current] {
|
||||
font-weight: var(--rhc-text-font-weight-semi-bold);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<nav [attr.aria-label]="navLabel">
|
||||
<h2 class="sr-only">{{ heading }}</h2>
|
||||
@for (l of links(); track l.locale) {
|
||||
@if (l.active) {
|
||||
<span [attr.lang]="l.locale" aria-current="true">{{ l.label }}</span>
|
||||
} @else {
|
||||
<a [attr.lang]="l.locale" [attr.hreflang]="l.locale" [href]="l.href">{{ l.label }}</a>
|
||||
}
|
||||
}
|
||||
</nav>
|
||||
`,
|
||||
})
|
||||
export class LanguageSwitcherComponent {
|
||||
/** Override the detected locale (stories/tests); the app detects it from the base href. */
|
||||
activeLocale = input<Locale | undefined>(undefined);
|
||||
|
||||
private readonly detected: Locale = /\/en\//.test(document.baseURI) ? 'en' : 'nl';
|
||||
private readonly loc =
|
||||
typeof location !== 'undefined'
|
||||
? location
|
||||
: ({ pathname: '/', search: '', hash: '' } as Location);
|
||||
|
||||
private router = inject(Router, { optional: true });
|
||||
private nav = toSignal(
|
||||
this.router?.events.pipe(filter((e) => e instanceof NavigationEnd)) ?? EMPTY,
|
||||
{ initialValue: null },
|
||||
);
|
||||
|
||||
protected links = computed(() => {
|
||||
this.nav(); // recompute on every completed navigation — loc.pathname is read fresh below
|
||||
return localeLinks(
|
||||
this.loc.pathname,
|
||||
this.activeLocale() ?? this.detected,
|
||||
this.loc.search,
|
||||
this.loc.hash,
|
||||
);
|
||||
});
|
||||
|
||||
protected navLabel = $localize`:@@lang.navLabel:Taal / Language`;
|
||||
protected heading = $localize`:@@lang.heading:Kies een taal`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LanguageSwitcherComponent } from './language-switcher.component';
|
||||
|
||||
const meta: Meta<LanguageSwitcherComponent> = {
|
||||
title: 'Design System/Organisms/Language Switcher',
|
||||
component: LanguageSwitcherComponent,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LanguageSwitcherComponent>;
|
||||
|
||||
/** Dutch active (the source locale). */
|
||||
export const NederlandsActive: Story = { args: { activeLocale: 'nl' } };
|
||||
|
||||
/** English active. */
|
||||
export const EnglishActive: Story = { args: { activeLocale: 'en' } };
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { localeLinks } from './locale-links';
|
||||
|
||||
describe('localeLinks (nl at root, en under /en/)', () => {
|
||||
it('an nl route (no prefix) links nl to the bare path, en under /en, marks active', () => {
|
||||
const links = localeLinks('/dashboard', 'nl');
|
||||
expect(links.map((l) => [l.locale, l.href, l.active])).toEqual([
|
||||
['nl', '/dashboard', true],
|
||||
['en', '/en/dashboard', false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('an en route strips the /en prefix for the nl target (deep path, en active)', () => {
|
||||
const links = localeLinks('/en/beheer/audit', 'en');
|
||||
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/beheer/audit');
|
||||
expect(links.find((l) => l.locale === 'en')!.active).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps query + hash on both targets', () => {
|
||||
const links = localeLinks('/registreren', 'nl', '?scenario=slow', '#top');
|
||||
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/registreren?scenario=slow#top');
|
||||
expect(links.find((l) => l.locale === 'en')!.href).toBe('/en/registreren?scenario=slow#top');
|
||||
});
|
||||
|
||||
it('the root maps nl → / and en → /en/', () => {
|
||||
const links = localeLinks('/', 'nl');
|
||||
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/');
|
||||
expect(links.find((l) => l.locale === 'en')!.href).toBe('/en/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/** The app's two locales (Angular $localize: source `nl` + translation `en`). */
|
||||
export type Locale = 'nl' | 'en';
|
||||
|
||||
export interface LocaleLink {
|
||||
readonly locale: Locale;
|
||||
/** Endonym — each language named in its own language (CIBG "Taal instellen"), not a code. */
|
||||
readonly label: string;
|
||||
/** Absolute path into the other locale's bundle, preserving the current route. */
|
||||
readonly href: string;
|
||||
readonly active: boolean;
|
||||
}
|
||||
|
||||
const LOCALES: readonly { locale: Locale; label: string }[] = [
|
||||
{ locale: 'nl', label: 'Nederlands' },
|
||||
{ locale: 'en', label: 'English' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Build the two language links for the switcher. Compile-time i18n serves the source locale
|
||||
* (nl) at the ROOT (`subPath: ''`) and en under `/en/`, so switching is a full navigation to the
|
||||
* sibling bundle at the same route. Strips a leading `/en` from the current path, then targets nl
|
||||
* at the bare path and en under `/en`. Keeps query + hash. Pure — no DOM (the component passes
|
||||
* `location.*` in).
|
||||
*/
|
||||
export function localeLinks(
|
||||
pathname: string,
|
||||
active: Locale,
|
||||
search = '',
|
||||
hash = '',
|
||||
): LocaleLink[] {
|
||||
const rest = pathname.replace(/^\/en(?=\/|$)/, '') || '/';
|
||||
const href = (locale: Locale) => `${locale === 'en' ? `/en${rest}` : rest}${search}${hash}`;
|
||||
return LOCALES.map(({ locale, label }) => ({
|
||||
locale,
|
||||
label,
|
||||
href: href(locale),
|
||||
active: locale === active,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { LinkComponent } from '@shared/ui/link/link.component';
|
||||
|
||||
/** Template: standard page body — optional back-link, a heading, optional intro,
|
||||
and projected content. The breadcrumb lives in the site header (blue bar), so
|
||||
it's not repeated here. Rendered inside the persistent ShellComponent via the
|
||||
router outlet, so it owns only the content (not chrome). */
|
||||
@Component({
|
||||
selector: 'app-page-shell',
|
||||
imports: [HeadingComponent, LinkComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.body--narrow {
|
||||
max-inline-size: var(--app-form-narrow);
|
||||
}
|
||||
.back {
|
||||
margin: 0 0 var(--rhc-space-max-lg);
|
||||
}
|
||||
.intro {
|
||||
margin-block: var(--rhc-space-max-md) var(--rhc-space-max-2xl);
|
||||
max-inline-size: 42rem;
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div [class.body--narrow]="width() === 'narrow'">
|
||||
@if (backLink()) {
|
||||
<p class="back">
|
||||
<app-link [to]="backLink()!">← {{ backLabel() }}</app-link>
|
||||
</p>
|
||||
}
|
||||
<app-heading [level]="1">{{ heading() }}</app-heading>
|
||||
@if (intro()) {
|
||||
<p class="intro">{{ intro() }}</p>
|
||||
}
|
||||
<ng-content />
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class PageShellComponent {
|
||||
heading = input.required<string>();
|
||||
intro = input<string>();
|
||||
backLink = input<string>();
|
||||
backLabel = input($localize`:@@pageShell.backLabel:Terug naar overzicht`);
|
||||
width = input<'default' | 'narrow'>('default');
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig, moduleMetadata } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { PageShellComponent } from './page-shell.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
|
||||
const meta: Meta<PageShellComponent> = {
|
||||
title: 'Design System/Templates/PageShell',
|
||||
component: PageShellComponent,
|
||||
decorators: [
|
||||
applicationConfig({ providers: [provideRouter([])] }),
|
||||
moduleMetadata({ imports: [ButtonComponent] }),
|
||||
],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<app-page-shell [heading]="heading" [intro]="intro" [backLink]="backLink" [width]="width">
|
||||
<p class="rhc-paragraph">Pagina-inhoud wordt hier geprojecteerd.</p>
|
||||
<app-button variant="primary">Een actie</app-button>
|
||||
</app-page-shell>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<PageShellComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { heading: 'Mijn BIG-registratie', intro: 'Overzicht van uw registratie.' },
|
||||
};
|
||||
export const WithBackLink: Story = {
|
||||
args: { heading: 'Mijn gegevens', backLink: '/dashboard' },
|
||||
};
|
||||
export const Narrow: Story = {
|
||||
args: { heading: 'Inloggen', width: 'narrow', intro: 'Log in op uw omgeving.' },
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
DOCUMENT,
|
||||
ENVIRONMENT_INITIALIZER,
|
||||
EnvironmentInjector,
|
||||
afterNextRender,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
|
||||
/** Template-layer wiring (not a component): on every route change after the
|
||||
initial load, moves focus to the new page's `<h1>` (page-shell always
|
||||
renders one) so screen-reader/keyboard users land on the new content
|
||||
instead of wherever focus happened to be. Falls back to `#main` (the
|
||||
shell's landmark) if a page has no heading. Deferred via `afterNextRender`
|
||||
so it doesn't race Angular's view-transition DOM swap. */
|
||||
export function provideRouteFocus() {
|
||||
return {
|
||||
provide: ENVIRONMENT_INITIALIZER,
|
||||
multi: true,
|
||||
useValue: () => {
|
||||
const router = inject(Router);
|
||||
const document = inject(DOCUMENT);
|
||||
const injector = inject(EnvironmentInjector);
|
||||
let isInitialLoad = true;
|
||||
|
||||
router.events.subscribe((event) => {
|
||||
if (!(event instanceof NavigationEnd)) return;
|
||||
if (isInitialLoad) {
|
||||
isInitialLoad = false;
|
||||
return;
|
||||
}
|
||||
afterNextRender(
|
||||
() => {
|
||||
const target =
|
||||
document.querySelector<HTMLElement>('#main h1') ?? document.getElementById('main');
|
||||
if (!target) return;
|
||||
target.setAttribute('tabindex', '-1');
|
||||
target.focus({ preventScroll: true });
|
||||
},
|
||||
{ injector },
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Component, InjectionToken, Type, inject, isDevMode } from '@angular/core';
|
||||
import { NgComponentOutlet } from '@angular/common';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { SiteHeaderComponent } from '@shared/layout/site-header/site-header.component';
|
||||
import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.component';
|
||||
import { LanguageSwitcherComponent } from '@shared/layout/language-switcher/language-switcher.component';
|
||||
|
||||
/** Each app may register its own dev-only "show the Model" panel component here (it's
|
||||
inherently app-specific — it inspects that app's own root stores). No provider →
|
||||
no panel, which is exactly today's behaviour for an app that never had one. */
|
||||
export const DEBUG_PANEL = new InjectionToken<Type<unknown> | null>('DEBUG_PANEL', {
|
||||
factory: () => null,
|
||||
});
|
||||
|
||||
/** Template: persistent app chrome. Header + footer mount once; only the routed
|
||||
content inside <router-outlet> changes (and cross-fades — see styles.scss). */
|
||||
@Component({
|
||||
selector: 'app-shell',
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
SiteHeaderComponent,
|
||||
SiteFooterComponent,
|
||||
LanguageSwitcherComponent,
|
||||
NgComponentOutlet,
|
||||
],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: var(--app-skip-link-offset);
|
||||
z-index: 1030;
|
||||
}
|
||||
.skip:focus {
|
||||
left: var(--rhc-space-max-md);
|
||||
top: var(--rhc-space-max-md);
|
||||
background: var(--rhc-color-wit);
|
||||
padding: var(--rhc-space-max-sm) var(--rhc-space-max-md);
|
||||
border-radius: var(--rhc-border-radius-sm);
|
||||
}
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-block-size: 100vh;
|
||||
}
|
||||
.main {
|
||||
flex: 1;
|
||||
inline-size: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.content {
|
||||
max-inline-size: var(--app-content-max);
|
||||
margin-inline: auto;
|
||||
padding: var(--rhc-space-max-3xl) var(--rhc-space-max-2xl);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<a href="#main" class="skip" i18n="@@shell.skipLink">Naar de inhoud</a>
|
||||
<app-language-switcher />
|
||||
<div class="layout">
|
||||
<app-site-header />
|
||||
<main id="main" class="main">
|
||||
<div class="content">
|
||||
<router-outlet />
|
||||
</div>
|
||||
</main>
|
||||
<app-site-footer />
|
||||
</div>
|
||||
@if (isDev && debugPanel) {
|
||||
<ng-container *ngComponentOutlet="debugPanel" />
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ShellComponent {
|
||||
protected readonly isDev = isDevMode();
|
||||
protected readonly debugPanel = inject(DEBUG_PANEL);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
|
||||
import { ShellComponent } from './shell.component';
|
||||
|
||||
const meta: Meta<ShellComponent> = {
|
||||
title: 'Design System/Templates/Shell',
|
||||
component: ShellComponent,
|
||||
// The persistent header injects AccessStore (for its capability-gated admin links) and
|
||||
// FeatureFlagStore (WP-47, for the Inschrijven nav gate); stub both so the story needs no
|
||||
// HTTP/ApiClient. `can` false → no admin links; `enabled` true → Inschrijven stays visible.
|
||||
decorators: [
|
||||
applicationConfig({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AccessStore, useValue: { can: () => false } },
|
||||
{ provide: FeatureFlagStore, useValue: { enabled: () => true } },
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<ShellComponent>;
|
||||
|
||||
// No route matches, so <router-outlet> renders nothing — this story is about the
|
||||
// persistent chrome (skip-link, header, footer), not routed page content.
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
/** Organism: Rijksoverheid-style site footer — dark-blue, with the
|
||||
"De Rijksoverheid. Voor Nederland." tagline, responsible-ministry attribution,
|
||||
and a small "Over deze site" link column. ponytail: links point at the real
|
||||
rijksoverheid.nl pages, not a fabricated dead-link forest. */
|
||||
@Component({
|
||||
selector: 'app-site-footer',
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.bar {
|
||||
background: var(--rhc-color-layout);
|
||||
color: var(--rhc-color-foreground-on-primary);
|
||||
margin-block-start: var(--rhc-space-max-5xl);
|
||||
inline-size: 100%;
|
||||
}
|
||||
.inner {
|
||||
max-inline-size: var(--app-content-max);
|
||||
margin-inline: auto;
|
||||
padding: var(--rhc-space-max-3xl) var(--rhc-space-max-2xl);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
gap: var(--rhc-space-max-3xl);
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.tagline {
|
||||
font-style: italic;
|
||||
font-weight: var(--rhc-text-font-weight-semi-bold);
|
||||
font-size: var(--rhc-text-font-size-lg);
|
||||
max-inline-size: 18rem;
|
||||
}
|
||||
.ministry {
|
||||
margin-block-start: var(--rhc-space-max-md);
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
color: var(--rhc-color-foreground-on-primary);
|
||||
}
|
||||
/* CIBG's vendored h2 tag rule (dark navy, for light backgrounds) beats inherited
|
||||
color regardless of specificity — restate on-primary explicitly for this dark bar. */
|
||||
.col h2 {
|
||||
margin: 0 0 var(--rhc-space-max-md);
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
font-weight: var(--rhc-text-font-weight-bold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--rhc-color-foreground-on-primary);
|
||||
}
|
||||
.links {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
}
|
||||
.links a {
|
||||
color: var(--rhc-color-foreground-on-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* CIBG's vendored .meta class (unrelated component, coincidental name) sets a
|
||||
dark grey — override rather than rename to keep the CIBG-mirroring class name. */
|
||||
.meta {
|
||||
inline-size: 100%;
|
||||
border-block-start: var(--rhc-border-width-sm) solid
|
||||
color-mix(in srgb, var(--rhc-color-foreground-on-primary) 25%, transparent);
|
||||
margin-block-start: var(--rhc-space-max-xl);
|
||||
padding-block-start: var(--rhc-space-max-lg);
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
opacity: 0.85;
|
||||
color: var(--rhc-color-foreground-on-primary);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<footer class="bar">
|
||||
<div class="inner">
|
||||
<div>
|
||||
<div class="tagline" i18n="@@footer.tagline">De Rijksoverheid. Voor Nederland.</div>
|
||||
<div class="ministry" i18n="@@footer.ministry">
|
||||
CIBG — Ministerie van Volksgezondheid, Welzijn en Sport
|
||||
</div>
|
||||
</div>
|
||||
<nav class="col" i18n-aria-label="@@footer.overSiteAria" aria-label="Over deze site">
|
||||
<h2 i18n="@@footer.overSite">Over deze site</h2>
|
||||
<ul class="links">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.rijksoverheid.nl/privacy"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
i18n="@@footer.privacy"
|
||||
>Privacy</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.rijksoverheid.nl/cookies"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
i18n="@@footer.cookies"
|
||||
>Cookies</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.rijksoverheid.nl/toegankelijkheid"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
i18n="@@footer.toegankelijkheid"
|
||||
>Toegankelijkheid</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="meta" i18n="@@footer.demo">Demo / POC — geen echte gegevens.</div>
|
||||
</div>
|
||||
</footer>
|
||||
`,
|
||||
})
|
||||
export class SiteFooterComponent {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { SiteFooterComponent } from './site-footer.component';
|
||||
|
||||
const meta: Meta<SiteFooterComponent> = {
|
||||
title: 'Design System/Organisms/Site Footer',
|
||||
component: SiteFooterComponent,
|
||||
render: () => ({ template: `<app-site-footer />` }),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<SiteFooterComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
|
||||
export interface HeaderNavItem {
|
||||
readonly label: string;
|
||||
readonly to: string;
|
||||
/** Hidden when this feature flag is off (e.g. WP-47's Inschrijven gate). Omit for an
|
||||
always-visible item. */
|
||||
readonly flag?: string;
|
||||
}
|
||||
|
||||
/** One admin page: its label, a short description, its route, and the capability that
|
||||
gates it. Consumed by the site header's admin nav AND (per app) a dashboard's own
|
||||
Beheer section, both filtered by `AccessStore.can`. Capability-gated, never
|
||||
role-derived (PRD-0002 §6): the FE only mirrors server-resolved capabilities. */
|
||||
export interface AdminLink {
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
readonly to: string;
|
||||
readonly cap: Capability;
|
||||
}
|
||||
|
||||
/** Each app supplies its own primary nav — the set of top-level routes differs per app
|
||||
(e.g. the SSP's "Herregistratie"/"Inschrijven" vs. behandelportal's own). */
|
||||
export const HEADER_NAV_ITEMS = new InjectionToken<readonly HeaderNavItem[]>('HEADER_NAV_ITEMS', {
|
||||
factory: () => [],
|
||||
});
|
||||
|
||||
/** Each app supplies its own admin links — which admin pages exist differs per app
|
||||
(e.g. only the SSP has a brief/huisstijl editor). */
|
||||
export const HEADER_ADMIN_LINKS = new InjectionToken<readonly AdminLink[]>('HEADER_ADMIN_LINKS', {
|
||||
factory: () => [],
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
import { SESSION_PORT } from '@shared/application/session.port';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
|
||||
import { BreadcrumbComponent } from '@shared/layout/breadcrumb/breadcrumb.component';
|
||||
import { trailFor } from '@shared/layout/breadcrumb/breadcrumb-trail';
|
||||
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from './nav-config';
|
||||
|
||||
/** Organism: CIBG Huisstijl site header — logo block, robijn titlebar (breadcrumb +
|
||||
user menu), horizontal nav. ponytail: text wordmark, not the licensed Rijksoverheid
|
||||
beeldmerk; no search box (no search feature yet). */
|
||||
@Component({
|
||||
selector: 'app-site-header',
|
||||
imports: [RouterLink, RouterLinkActive, BreadcrumbComponent],
|
||||
styles: [
|
||||
`
|
||||
.logout {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
/* CIBG's header nav has no bg by default in this build — the grey bar is ours.
|
||||
(.titlebar keeps its own robijn fill — --ro-layout — untouched; the breadcrumb
|
||||
inside it has no background of its own, so the bar's colour shows through.) */
|
||||
nav {
|
||||
background-color: var(--rhc-color-cool-grey-200);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<header>
|
||||
<div class="logo">
|
||||
<div class="logo__wrapper">
|
||||
<a routerLink="/dashboard" class="logo__link">
|
||||
<figure class="logo__figure">
|
||||
<figcaption class="logo__text">
|
||||
<span class="logo__sender" i18n="@@header.sender">BIG-register</span>
|
||||
<span class="logo__ministry" i18n="@@header.ministry"
|
||||
>Ministerie van Volksgezondheid, Welzijn en Sport</span
|
||||
>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="titlebar">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="title col-md-7">
|
||||
@if (trail().length) {
|
||||
<app-breadcrumb [items]="trail()" />
|
||||
}
|
||||
</div>
|
||||
<div class="user-menu col-md-5">
|
||||
@if (session(); as s) {
|
||||
<div>
|
||||
<span class="login-name">{{ s.naam }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="logout" (click)="logout()" i18n="@@header.uitloggen">
|
||||
Uitloggen
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav i18n-aria-label="@@header.navAria" aria-label="Hoofdnavigatie">
|
||||
<div class="container">
|
||||
<ul>
|
||||
@for (item of navItems(); track item.to) {
|
||||
<li routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
|
||||
<a [routerLink]="item.to">{{ item.label }}</a>
|
||||
</li>
|
||||
}
|
||||
@for (item of adminItems(); track item.to) {
|
||||
<li routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
|
||||
<a [routerLink]="item.to">{{ item.label }}</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
`,
|
||||
})
|
||||
export class SiteHeaderComponent {
|
||||
private access = inject(AccessStore);
|
||||
private flags = inject(FeatureFlagStore);
|
||||
private rawNavItems = inject(HEADER_NAV_ITEMS);
|
||||
private rawAdminLinks = inject(HEADER_ADMIN_LINKS);
|
||||
|
||||
/** Hides an item whose `flag` is off (e.g. the SSP's Inschrijven gate, WP-47) — which
|
||||
items exist, and which carry a flag, is entirely up to the app that provided them. */
|
||||
protected readonly navItems = computed(() =>
|
||||
this.rawNavItems.filter((i) => !i.flag || this.flags.enabled(i.flag)),
|
||||
);
|
||||
|
||||
private router = inject(Router);
|
||||
private sessionPort = inject(SESSION_PORT, { optional: true });
|
||||
/** Injecting AccessStore here also warms `/me` at app start (the header renders on
|
||||
every page), so the admin routes' guard usually finds caps already resolved. */
|
||||
protected adminItems = computed(() => this.rawAdminLinks.filter((i) => this.access.can(i.cap)));
|
||||
|
||||
readonly session = computed(() => this.sessionPort?.session() ?? null);
|
||||
private url = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((e) => e instanceof NavigationEnd),
|
||||
map(() => this.router.url),
|
||||
),
|
||||
{ initialValue: this.router.url },
|
||||
);
|
||||
protected trail = computed(() => trailFor(this.url()));
|
||||
|
||||
logout() {
|
||||
this.sessionPort?.logout();
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from './nav-config';
|
||||
import { SiteHeaderComponent } from './site-header.component';
|
||||
|
||||
// The header injects AccessStore for the capability-gated admin links and FeatureFlagStore
|
||||
// (WP-47, for the Inschrijven nav gate); stub both so the story needs no HTTP/ApiClient.
|
||||
// `can` decides which admin links appear; `enabled` true keeps Inschrijven visible. Nav/admin
|
||||
// links are app-provided (HEADER_NAV_ITEMS/HEADER_ADMIN_LINKS) — this story supplies a
|
||||
// representative sample rather than importing a real app's config, keeping the story
|
||||
// decoupled from any one app.
|
||||
const withCaps = (caps: Capability[]) =>
|
||||
applicationConfig({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AccessStore, useValue: { can: (c: Capability) => caps.includes(c) } },
|
||||
{ provide: FeatureFlagStore, useValue: { enabled: () => true } },
|
||||
{
|
||||
provide: HEADER_NAV_ITEMS,
|
||||
useValue: [
|
||||
{ label: 'Overzicht', to: '/dashboard' },
|
||||
{ label: 'Mijn gegevens', to: '/registratie' },
|
||||
],
|
||||
},
|
||||
{
|
||||
provide: HEADER_ADMIN_LINKS,
|
||||
useValue: [
|
||||
{ label: 'Huisstijl', description: '', to: '/brief/huisstijl', cap: 'orgtemplate:edit' },
|
||||
{ label: 'Stamdata', description: '', to: '/beheer/stamdata', cap: 'stamdata:edit' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const meta: Meta<SiteHeaderComponent> = {
|
||||
title: 'Design System/Organisms/Site Header',
|
||||
component: SiteHeaderComponent,
|
||||
decorators: [withCaps([])],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-site-header />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<SiteHeaderComponent>;
|
||||
|
||||
/** Standard user — no admin links. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Admin — the capability-gated Huisstijl + Stamdata links appear. */
|
||||
export const AsAdmin: Story = {
|
||||
decorators: [withCaps(['orgtemplate:edit', 'stamdata:edit'])],
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Component, ElementRef, effect, input, output, untracked, viewChild } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
||||
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
|
||||
|
||||
/** CIBG procesnavigatie primary-button copy for a non-final step: "Naar stap 2 - Werk".
|
||||
Shared so every wizard's `primaryLabel` reads the same way. */
|
||||
export const naarStapLabel = (stepNumber: number, stepLabel: string) =>
|
||||
$localize`:@@wizard.naarStap:Naar stap ${stepNumber}:nummer: - ${stepLabel}:label:`;
|
||||
|
||||
/** A flat validation error pointing at a field: `id` matches the field's anchor. */
|
||||
export interface WizardError {
|
||||
readonly id: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
|
||||
|
||||
/**
|
||||
* Template: the canonical shell every wizard renders into, so they cannot drift.
|
||||
* It owns the consistent outline — CIBG stappenindicator (title merged in) + error
|
||||
* summary + the horizontal <form> + the CIBG procesnavigatie button row + the
|
||||
* submitting/submitted/failed states — and the a11y focus management.
|
||||
*
|
||||
* Presentational and unidirectional: all state stays in the wizard container
|
||||
* (the Elm-style store). Inputs flow down; the container reacts to the outputs
|
||||
* and dispatches messages. The step's own fields are projected as the default
|
||||
* slot; the success screen is projected via [wizardSuccess].
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-wizard-shell',
|
||||
imports: [FormsModule, ButtonComponent, AlertComponent, SpinnerComponent, StepperComponent],
|
||||
// CIBG-GAP EXTENSION: Foutmelding — the vendored build has no error-summary/
|
||||
// Veldvalidatie list pattern (verified absent from huisstijl.min.css); the
|
||||
// .es-title/.es-list rules below are the hand-rolled surface, see cibg-gaps.mdx.
|
||||
// They render inside a vendored `.feedback-error` alert (app-alert).
|
||||
styles: [
|
||||
`
|
||||
.es-title {
|
||||
margin: 0 0 var(--rhc-space-max-sm);
|
||||
}
|
||||
.es-list {
|
||||
margin: 0;
|
||||
padding-inline-start: var(--rhc-space-max-xl);
|
||||
}
|
||||
/* Default link color doesn't meet contrast on the error-alert's light-red surface. */
|
||||
.es-list a {
|
||||
color: var(--rhc-color-lintblauw-700);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@switch (status()) {
|
||||
@case ('editing') {
|
||||
<app-stepper
|
||||
class="app-section"
|
||||
[steps]="steps()"
|
||||
[current]="current()"
|
||||
[processName]="processName()"
|
||||
[stepTitle]="stepTitle()"
|
||||
(stepSelected)="goToStep.emit($event)"
|
||||
/>
|
||||
@if (errors().length) {
|
||||
<div
|
||||
#errorSummary
|
||||
tabindex="-1"
|
||||
role="alert"
|
||||
aria-labelledby="wizard-error-title"
|
||||
class="app-section"
|
||||
>
|
||||
<app-alert type="error">
|
||||
<h3 id="wizard-error-title" class="es-title" i18n="@@wizard.errorTitle">
|
||||
Er ging iets mis met uw invoer
|
||||
</h3>
|
||||
<ul class="es-list">
|
||||
@for (e of errors(); track e.id) {
|
||||
<li>
|
||||
<a [href]="'#' + e.id" (click)="goToField($event, e.id)">{{ e.message }}</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</app-alert>
|
||||
</div>
|
||||
}
|
||||
<form (ngSubmit)="primary.emit()" class="form-horizontal app-section">
|
||||
<div class="form-header">
|
||||
<div class="form-action">
|
||||
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Wizard pages wrap their field groups in <fieldset>s; CIBG's
|
||||
".form-horizontal fieldset" gives each a grey #f1f5f9 surface with a 1.25em token-ok: hex named in prose, not a style value
|
||||
gap. The shell stays group-agnostic and does NOT add its own fieldset (an
|
||||
outer grey fieldset would hide the white gaps between the page groups). -->
|
||||
<ng-content />
|
||||
<hr />
|
||||
<div class="d-flex flex-column flex-sm-row-reverse">
|
||||
<div class="m-0">
|
||||
<app-button type="submit" variant="primary">{{ primaryLabel() }}</app-button>
|
||||
</div>
|
||||
@if (canGoBack()) {
|
||||
<app-button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
class="me-auto"
|
||||
(click)="back.emit()"
|
||||
i18n="@@wizard.terugVorige"
|
||||
>Terug naar vorige stap</app-button
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<div class="app-section">
|
||||
<app-button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
(click)="cancel.emit()"
|
||||
i18n="@@wizard.annuleren"
|
||||
>Annuleren</app-button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
@case ('submitting') {
|
||||
<app-spinner /> <span>{{ submittingLabel() }}</span>
|
||||
}
|
||||
@case ('submitted') {
|
||||
<ng-content select="[wizardSuccess]" />
|
||||
}
|
||||
@case ('failed') {
|
||||
<app-alert type="error">{{ errorMessage() }}</app-alert>
|
||||
<div class="app-section">
|
||||
<app-button variant="secondary" (click)="retry.emit()" i18n="@@wizard.opnieuwProberen"
|
||||
>Opnieuw proberen</app-button
|
||||
>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class WizardShellComponent {
|
||||
steps = input.required<string[]>();
|
||||
current = input.required<number>();
|
||||
stepTitle = input.required<string>();
|
||||
/** Overall process name, shown above the step title (e.g. "Herregistratie aanvragen"). */
|
||||
processName = input('');
|
||||
status = input.required<WizardStatus>();
|
||||
primaryLabel = input.required<string>();
|
||||
canGoBack = input(false);
|
||||
errors = input<readonly WizardError[]>([]);
|
||||
errorMessage = input('');
|
||||
submittingLabel = input($localize`:@@wizard.submitting:Aanvraag wordt verwerkt…`);
|
||||
|
||||
primary = output<void>();
|
||||
back = output<void>();
|
||||
cancel = output<void>();
|
||||
retry = output<void>();
|
||||
/** A visited step number was clicked in the stepper — back-navigation only. */
|
||||
goToStep = output<number>();
|
||||
|
||||
/** Error-summary link: focus the field instead of letting the browser navigate.
|
||||
A fragment href resolves against <base href="/">, not the current route, so
|
||||
a real navigation would reload to "/" and bounce to login. */
|
||||
protected goToField(ev: Event, id: string) {
|
||||
ev.preventDefault();
|
||||
document.getElementById(id)?.focus(); // focus() scrolls the input into view
|
||||
}
|
||||
|
||||
private stepper = viewChild(StepperComponent);
|
||||
private errorSummary = viewChild<ElementRef<HTMLElement>>('errorSummary');
|
||||
|
||||
constructor() {
|
||||
// A11y: move focus to the step title when the step changes (skip first run
|
||||
// so we don't grab focus on initial load). Tracks current(), which is value-
|
||||
// stable across keystrokes, so typing never steals focus.
|
||||
let firstStep = true;
|
||||
effect(() => {
|
||||
this.current();
|
||||
if (firstStep) {
|
||||
firstStep = false;
|
||||
return;
|
||||
}
|
||||
untracked(() => queueMicrotask(() => this.stepper()?.focusTitle()));
|
||||
});
|
||||
// A11y: when validation errors first appear (after a failed submit), move
|
||||
// focus to the error summary so it's announced. Only on the rising edge
|
||||
// (none → some): typing rebuilds the errors array each keystroke, and
|
||||
// re-focusing then would scroll the page up mid-edit. The summary keeps
|
||||
// role="alert", so content changes are still announced without the jump.
|
||||
let firstErr = true;
|
||||
let hadErrors = false;
|
||||
effect(() => {
|
||||
const has = this.errors().length > 0;
|
||||
if (firstErr) {
|
||||
firstErr = false;
|
||||
hadErrors = has;
|
||||
return;
|
||||
}
|
||||
if (has && !hadErrors)
|
||||
untracked(() => queueMicrotask(() => this.errorSummary()?.nativeElement.focus()));
|
||||
hadErrors = has;
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user