style: format the repo with prettier (green format:check)
`npm run format:check` (a CI gate) had drifted red across 44 files — pre-existing files plus recently-added ones committed without formatting. Ran `prettier --write .`; no logic changes. Also regenerates documentation.json (compodoc reflects the reformatted component sources). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -64,8 +64,7 @@ export const routes: Routes = [
|
||||
// via the OrgAdmin gate — the guard just avoids loading a page that would 403.
|
||||
canActivate: [capabilityGuard('orgtemplate:edit')],
|
||||
canDeactivate: [flushPendingGuard],
|
||||
loadComponent: () =>
|
||||
import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage),
|
||||
loadComponent: () => import('@brief/ui/org-template.page').then((m) => m.OrgTemplatePage),
|
||||
},
|
||||
{
|
||||
path: 'beheer/stamdata',
|
||||
|
||||
@@ -47,11 +47,17 @@ describe('stamdata-editor reduce', () => {
|
||||
|
||||
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');
|
||||
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: 'LoadFailed', reason: 'boom' })).toEqual({
|
||||
tag: 'failed',
|
||||
reason: 'boom',
|
||||
});
|
||||
expect(reduce(seedLoaded(), { tag: 'Loading' })).toEqual({ tag: 'loading' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,8 +28,12 @@ describe('activeOn (valid-time, half-open [van, tot))', () => {
|
||||
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);
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +59,11 @@ describe('changeCounts (diff against the loaded snapshot, by key)', () => {
|
||||
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 });
|
||||
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]];
|
||||
@@ -81,6 +89,8 @@ describe('toJson (draft → file shape)', () => {
|
||||
{ name: 'jaar', type: 'number', isKey: false, options: [] },
|
||||
],
|
||||
};
|
||||
expect(JSON.parse(toJson(table, [{ code: 'x', jaar: '2020' }]))).toEqual([{ code: 'x', jaar: 2020 }]);
|
||||
expect(JSON.parse(toJson(table, [{ code: 'x', jaar: '2020' }]))).toEqual([
|
||||
{ code: 'x', jaar: 2020 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,9 +116,13 @@ export function toJson(table: StamTable, rows: readonly StamRow[]): string {
|
||||
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;
|
||||
cell === ''
|
||||
? c.type === 'text' || c.type === 'enum'
|
||||
? ''
|
||||
: null
|
||||
: c.type === 'number'
|
||||
? Number(cell)
|
||||
: cell;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
@@ -30,7 +30,11 @@ describe('parseStamdataTable', () => {
|
||||
});
|
||||
|
||||
it('falls back to text for an unknown column type', () => {
|
||||
const r = parseStamdataTable({ ...wire, columns: [{ name: 'x', type: 'weird', isKey: true }], rows: [] });
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -67,7 +67,12 @@ function parseColumn(dto: StamdataColumnDto): Result<string, StamColumn> {
|
||||
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> {
|
||||
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[] = [];
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
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';
|
||||
import { ChangeCounts, StamColumn, StamRow, StamTable, activeOn } from '@beheer/domain/stamdata';
|
||||
|
||||
interface DisplayRow {
|
||||
row: StamRow;
|
||||
@@ -94,7 +88,9 @@ interface DisplayRow {
|
||||
/>
|
||||
</div>
|
||||
@if (previewing()) {
|
||||
<app-button variant="subtle" (click)="previewDateChanged.emit('')">{{ showAll }}</app-button>
|
||||
<app-button variant="subtle" (click)="previewDateChanged.emit('')">{{
|
||||
showAll
|
||||
}}</app-button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -123,7 +119,9 @@ interface DisplayRow {
|
||||
[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) })"
|
||||
(change)="
|
||||
cellEdited.emit({ row: item.index, column: col.name, value: asValue($event) })
|
||||
"
|
||||
>
|
||||
<option value=""></option>
|
||||
@for (opt of col.options; track opt) {
|
||||
@@ -138,7 +136,9 @@ interface DisplayRow {
|
||||
[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) })"
|
||||
(input)="
|
||||
cellEdited.emit({ row: item.index, column: col.name, value: asValue($event) })
|
||||
"
|
||||
/>
|
||||
}
|
||||
</td>
|
||||
|
||||
@@ -17,7 +17,12 @@ const table: StamTable = {
|
||||
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' },
|
||||
{
|
||||
program: 'fysiotherapie',
|
||||
beroep: 'Fysiotherapeut',
|
||||
geldigVan: '2000-01-01',
|
||||
geldigTot: '2020-01-01',
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<StamdataTableEditorComponent> = {
|
||||
@@ -48,11 +53,14 @@ export const Dirty: Story = {
|
||||
|
||||
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)],
|
||||
),
|
||||
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),
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,13 @@ import { StamdataTableEditorComponent } from '@beheer/ui/stamdata-table-editor/s
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-stamdata-page',
|
||||
imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, StamdataTableEditorComponent],
|
||||
imports: [
|
||||
PageShellComponent,
|
||||
AlertComponent,
|
||||
ButtonComponent,
|
||||
...ASYNC,
|
||||
StamdataTableEditorComponent,
|
||||
],
|
||||
template: `
|
||||
<app-page-shell [heading]="heading" [intro]="intro" backLink="/dashboard">
|
||||
@if (!access.ready()) {
|
||||
|
||||
@@ -133,7 +133,11 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
||||
// --- WP-27: undo/redo history + rejection diff ---
|
||||
|
||||
function block(id: string, text: string): LetterBlock {
|
||||
return { type: 'freeText', blockId: id, content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] } };
|
||||
return {
|
||||
type: 'freeText',
|
||||
blockId: id,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] },
|
||||
};
|
||||
}
|
||||
const kern = (blocks: LetterBlock[]) => ({
|
||||
sectionKey: 'kern',
|
||||
@@ -152,7 +156,8 @@ function loadedBrief(store: BriefStore): Brief {
|
||||
}
|
||||
|
||||
async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> {
|
||||
const ok = (v: BriefView): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: v });
|
||||
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: v });
|
||||
const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over });
|
||||
await store.load();
|
||||
return store;
|
||||
@@ -203,12 +208,16 @@ describe('BriefStore undo/redo history', () => {
|
||||
|
||||
describe('BriefStore rejection diff', () => {
|
||||
it('captures the rejected letter and diffs a subsequent edit against it', async () => {
|
||||
const submitted: Brief = { ...filledBrief, status: { tag: 'submitted', submittedBy: 'u', submittedAt: 't' } };
|
||||
const submitted: Brief = {
|
||||
...filledBrief,
|
||||
status: { tag: 'submitted', submittedBy: 'u', submittedAt: 't' },
|
||||
};
|
||||
const rejected: Brief = {
|
||||
...filledBrief,
|
||||
status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' },
|
||||
};
|
||||
const ok = (v: BriefView): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: v });
|
||||
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: v });
|
||||
const store = setup({
|
||||
load: () => ok({ ...filledView, brief: submitted }),
|
||||
save: () => ok(filledView),
|
||||
@@ -272,7 +281,10 @@ describe('BriefStore.revealBigNummer (PRD-0002 §5c)', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
// Loaded with a MASKED BIG-nummer, as the server ships it by default.
|
||||
const maskedView: BriefView = { ...view, caseContext: { ...caseContext, bigNummer: '********601' } };
|
||||
const maskedView: BriefView = {
|
||||
...view,
|
||||
caseContext: { ...caseContext, bigNummer: '********601' },
|
||||
};
|
||||
|
||||
it('swaps the masked value for the revealed one on success', async () => {
|
||||
const store = setup({ load: () => Promise.resolve({ ok: true, value: maskedView }) });
|
||||
|
||||
@@ -88,9 +88,12 @@ export class OrgTemplateStore implements PendingSave {
|
||||
readonly draftValid = computed(() => {
|
||||
const d = this.draft();
|
||||
if (!d) return false;
|
||||
const marginsOk = [d.margins.topMm, d.margins.rightMm, d.margins.bottomMm, d.margins.leftMm].every(
|
||||
(v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM,
|
||||
);
|
||||
const marginsOk = [
|
||||
d.margins.topMm,
|
||||
d.margins.rightMm,
|
||||
d.margins.bottomMm,
|
||||
d.margins.leftMm,
|
||||
].every((v) => v >= MARGIN_MIN_MM && v <= MARGIN_MAX_MM);
|
||||
return d.orgName.trim().length > 0 && d.signatureName.trim().length > 0 && marginsOk;
|
||||
});
|
||||
|
||||
@@ -107,7 +110,10 @@ export class OrgTemplateStore implements PendingSave {
|
||||
if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;
|
||||
const status = this.categoriesRes.status();
|
||||
if (status === 'resolved' || status === 'local')
|
||||
this.dispatchUpload({ type: 'CategoriesLoaded', categories: this.categoriesRes.value() ?? [] });
|
||||
this.dispatchUpload({
|
||||
type: 'CategoriesLoaded',
|
||||
categories: this.categoriesRes.value() ?? [],
|
||||
});
|
||||
});
|
||||
// Flush a pending debounced edit before navigation/unload (see pending-saves.ts).
|
||||
registerPendingSave(this);
|
||||
@@ -259,8 +265,9 @@ export class OrgTemplateStore implements PendingSave {
|
||||
fileName: file.name,
|
||||
fileSizeMb: file.size / 1e6,
|
||||
});
|
||||
this.shell.upload({ localId, categoryId: cat.categoryId, wizardId: 'org-template', file }, (m) =>
|
||||
this.onUploadMsg(m),
|
||||
this.shell.upload(
|
||||
{ localId, categoryId: cat.categoryId, wizardId: 'org-template', file },
|
||||
(m) => this.onUploadMsg(m),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ import { describe, it, expect } from 'vitest';
|
||||
import { Besluit, LetterBlock, LibraryPassage } from './brief';
|
||||
import { inferSelection, passagesForBesluit, redenenFor } from './besluit';
|
||||
|
||||
const block = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
|
||||
const block = (t: string): LibraryPassage['content'] => ({
|
||||
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
|
||||
});
|
||||
|
||||
const p = (over: Partial<LibraryPassage>): LibraryPassage => ({
|
||||
passageId: over.passageId ?? 'x',
|
||||
@@ -18,8 +20,18 @@ const lib: LibraryPassage[] = [
|
||||
p({ passageId: 'intro', besluit: undefined }), // shared, any besluit
|
||||
p({ passageId: 'pos', besluit: 'positief' }),
|
||||
p({ passageId: 'neg', besluit: 'negatief' }),
|
||||
p({ passageId: 'neg-scholing', besluit: 'negatief', reason: 'onvoldoende_scholing', label: 'Onvoldoende scholing' }),
|
||||
p({ passageId: 'neg-gegevens', besluit: 'negatief', reason: 'onjuiste_gegevens', label: 'Onjuiste gegevens' }),
|
||||
p({
|
||||
passageId: 'neg-scholing',
|
||||
besluit: 'negatief',
|
||||
reason: 'onvoldoende_scholing',
|
||||
label: 'Onvoldoende scholing',
|
||||
}),
|
||||
p({
|
||||
passageId: 'neg-gegevens',
|
||||
besluit: 'negatief',
|
||||
reason: 'onjuiste_gegevens',
|
||||
label: 'Onjuiste gegevens',
|
||||
}),
|
||||
p({ passageId: 'slot-x', sectionKey: 'slot', besluit: undefined }), // not kern → never offered
|
||||
];
|
||||
|
||||
@@ -35,17 +47,24 @@ describe('passagesForBesluit', () => {
|
||||
});
|
||||
|
||||
it('negatief with a reden ticked includes that reason-specific passage only', () => {
|
||||
const ids = passagesForBesluit(lib, 'negatief', ['onvoldoende_scholing']).map((x) => x.passageId);
|
||||
const ids = passagesForBesluit(lib, 'negatief', ['onvoldoende_scholing']).map(
|
||||
(x) => x.passageId,
|
||||
);
|
||||
expect(ids).toEqual(['intro', 'neg', 'neg-scholing']);
|
||||
});
|
||||
|
||||
it('preserves library order (= reading order)', () => {
|
||||
const ids = passagesForBesluit(lib, 'negatief', ['onjuiste_gegevens', 'onvoldoende_scholing']).map((x) => x.passageId);
|
||||
const ids = passagesForBesluit(lib, 'negatief', [
|
||||
'onjuiste_gegevens',
|
||||
'onvoldoende_scholing',
|
||||
]).map((x) => x.passageId);
|
||||
expect(ids).toEqual(['intro', 'neg', 'neg-scholing', 'neg-gegevens']);
|
||||
});
|
||||
|
||||
it('never offers non-kern passages', () => {
|
||||
expect(passagesForBesluit(lib, 'positief', []).some((x) => x.sectionKey !== 'kern')).toBe(false);
|
||||
expect(passagesForBesluit(lib, 'positief', []).some((x) => x.sectionKey !== 'kern')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ export function inferSelection(
|
||||
const source = byId.get(b.sourcePassageId);
|
||||
if (!source) continue;
|
||||
if (source.besluit !== undefined) besluit = source.besluit;
|
||||
if (source.reason !== undefined && !reasons.includes(source.reason)) reasons.push(source.reason);
|
||||
if (source.reason !== undefined && !reasons.includes(source.reason))
|
||||
reasons.push(source.reason);
|
||||
}
|
||||
return { besluit, reasons };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ import { Brief, LetterBlock } from './brief';
|
||||
import { diffBlocks, changedBlocks } from './brief-diff';
|
||||
|
||||
function block(id: string, text: string): LetterBlock {
|
||||
return { type: 'freeText', blockId: id, content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] } };
|
||||
return {
|
||||
type: 'freeText',
|
||||
blockId: id,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] },
|
||||
};
|
||||
}
|
||||
|
||||
function brief(blocks: LetterBlock[]): Brief {
|
||||
|
||||
@@ -24,7 +24,8 @@ const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView =>
|
||||
...over,
|
||||
});
|
||||
|
||||
const loaded = (): OrgTemplateState => reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
|
||||
const loaded = (): OrgTemplateState =>
|
||||
reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
|
||||
|
||||
const logoCategory: DocumentCategory = {
|
||||
categoryId: 'org-logo',
|
||||
@@ -84,7 +85,9 @@ describe('org-template.machine', () => {
|
||||
});
|
||||
|
||||
it('edits are no-ops in non-loaded states', () => {
|
||||
expect(reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' })).toEqual({
|
||||
expect(
|
||||
reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
|
||||
).toEqual({
|
||||
tag: 'loading',
|
||||
});
|
||||
});
|
||||
@@ -96,7 +99,13 @@ describe('org-template.machine', () => {
|
||||
});
|
||||
const selected = reduce(withCat, {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'FileSelected', categoryId: 'org-logo', localId: 'a', fileName: 'l.png', fileSizeMb: 0.1 },
|
||||
msg: {
|
||||
type: 'FileSelected',
|
||||
categoryId: 'org-logo',
|
||||
localId: 'a',
|
||||
fileName: 'l.png',
|
||||
fileSizeMb: 0.1,
|
||||
},
|
||||
});
|
||||
const done = reduce(selected, {
|
||||
tag: 'Upload',
|
||||
|
||||
@@ -50,10 +50,7 @@ export type OrgTemplateMsg =
|
||||
| { tag: 'Upload'; msg: UploadMsg };
|
||||
|
||||
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */
|
||||
function editDraft(
|
||||
s: OrgTemplateState,
|
||||
f: (draft: OrgTemplate) => OrgTemplate,
|
||||
): OrgTemplateState {
|
||||
function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState {
|
||||
return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
|
||||
}
|
||||
|
||||
@@ -87,7 +84,12 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
|
||||
const upload = reduceUpload(s.upload, m.msg);
|
||||
// A completed/removed logo upload also updates the draft's logoDocumentId.
|
||||
if (m.msg.type === 'UploadComplete')
|
||||
return { ...s, upload, draft: { ...s.draft, logoDocumentId: m.msg.documentId }, dirty: true };
|
||||
return {
|
||||
...s,
|
||||
upload,
|
||||
draft: { ...s.draft, logoDocumentId: m.msg.documentId },
|
||||
dirty: true,
|
||||
};
|
||||
if (m.msg.type === 'UploadRemoved') {
|
||||
const { logoDocumentId: _dropped, ...rest } = s.draft;
|
||||
return { ...s, upload, draft: rest, dirty: true };
|
||||
|
||||
@@ -62,7 +62,13 @@ const view: BriefViewDto = {
|
||||
reason: 'onvoldoende_scholing',
|
||||
},
|
||||
],
|
||||
decisions: { canEdit: false, canApprove: true, canReject: true, canSend: false, canRevealBigNummer: false },
|
||||
decisions: {
|
||||
canEdit: false,
|
||||
canApprove: true,
|
||||
canReject: true,
|
||||
canSend: false,
|
||||
canRevealBigNummer: false,
|
||||
},
|
||||
orgTemplate: {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG — Registers',
|
||||
@@ -126,8 +132,10 @@ describe('brief.adapter parse boundary', () => {
|
||||
it('rejects a view whose case context is missing or malformed', () => {
|
||||
expect(parseBriefView({ ...view, caseContext: undefined }).ok).toBe(false);
|
||||
expect(
|
||||
parseBriefView({ ...view, caseContext: { ...view.caseContext!, bigNummer: undefined as never } })
|
||||
.ok,
|
||||
parseBriefView({
|
||||
...view,
|
||||
caseContext: { ...view.caseContext!, bigNummer: undefined as never },
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -47,7 +47,9 @@ describe('parseOrgTemplateAdminView', () => {
|
||||
it('rejects a malformed history entry', () => {
|
||||
const r = parseOrgTemplateAdminView({
|
||||
...view,
|
||||
history: [{ version: 2, publishedAt: '2026-06-01', template: { ...draft, orgName: undefined } }],
|
||||
history: [
|
||||
{ version: 2, publishedAt: '2026-06-01', template: { ...draft, orgName: undefined } },
|
||||
],
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
@@ -65,10 +65,7 @@ export class OrgTemplateAdapter {
|
||||
return r.ok ? parsePublish(r.value) : r;
|
||||
}
|
||||
|
||||
async rollback(
|
||||
subOrgId: string,
|
||||
version: number,
|
||||
): Promise<Result<string, OrgTemplateAdminView>> {
|
||||
async rollback(subOrgId: string, version: number): Promise<Result<string, OrgTemplateAdminView>> {
|
||||
const r = await runSubmit(() => this.client.orgTemplateRollback(subOrgId, version), FAILED);
|
||||
return r.ok ? parseAdminView(r.value) : r;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,11 @@ export class RevealBigNummerAdapter {
|
||||
if (!res.ok) return err(await errorMessage(res));
|
||||
const body: unknown = await res.json().catch(() => null);
|
||||
// Trust boundary: validate the shape before handing back a plain string.
|
||||
if (typeof body === 'object' && body !== null && typeof (body as { bigNummer?: unknown }).bigNummer === 'string') {
|
||||
if (
|
||||
typeof body === 'object' &&
|
||||
body !== null &&
|
||||
typeof (body as { bigNummer?: unknown }).bigNummer === 'string'
|
||||
) {
|
||||
return ok((body as { bigNummer: string }).bigNummer);
|
||||
}
|
||||
return err(REVEAL_FAILED);
|
||||
|
||||
@@ -126,12 +126,9 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com
|
||||
|
||||
<div class="bar">
|
||||
<app-button variant="subtle" (click)="openPreview()">{{ previewLabel() }}</app-button>
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="!canSubmit() || busy()"
|
||||
(click)="submit.emit()"
|
||||
>{{ submitLabel() }}</app-button
|
||||
>
|
||||
<app-button variant="primary" [disabled]="!canSubmit() || busy()" (click)="submit.emit()">{{
|
||||
submitLabel()
|
||||
}}</app-button>
|
||||
@if (!canSubmit()) {
|
||||
<span class="app-text-subtle">{{ submitHint() }}</span>
|
||||
}
|
||||
@@ -149,7 +146,9 @@ import { BesluitPanelComponent } from '@brief/ui/besluit-panel/besluit-panel.com
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-bar">
|
||||
<app-button variant="secondary" (click)="preview.emit()">{{ openDocumentLabel() }}</app-button>
|
||||
<app-button variant="secondary" (click)="preview.emit()">{{
|
||||
openDocumentLabel()
|
||||
}}</app-button>
|
||||
<app-button variant="primary" (click)="closePreview()">{{ closeLabel() }}</app-button>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { Brief, BriefStatus, CaseContext, LibraryPassage, allDiagnostics } from '@brief/domain/brief';
|
||||
import {
|
||||
Brief,
|
||||
BriefStatus,
|
||||
CaseContext,
|
||||
LibraryPassage,
|
||||
allDiagnostics,
|
||||
} from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BehandelSchermComponent } from './behandel-scherm.component';
|
||||
|
||||
const text = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
|
||||
const text = (t: string): LibraryPassage['content'] => ({
|
||||
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
|
||||
});
|
||||
|
||||
const orgTemplate: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
@@ -26,9 +34,34 @@ const caseContext: CaseContext = {
|
||||
};
|
||||
|
||||
const passages: LibraryPassage[] = [
|
||||
{ passageId: 'p-kern-positief', scope: 'global', sectionKey: 'kern', label: 'Toewijzing', version: 1, besluit: 'positief', content: text('Uw aanvraag is toegewezen.') },
|
||||
{ passageId: 'p-kern-negatief', scope: 'global', sectionKey: 'kern', label: 'Afwijzing', version: 1, besluit: 'negatief', content: text('Uw aanvraag is afgewezen.') },
|
||||
{ passageId: 'p-kern-scholing', scope: 'global', sectionKey: 'kern', label: 'Onvoldoende scholing', version: 1, besluit: 'negatief', reason: 'onvoldoende_scholing', content: text('Onvoldoende scholing.') },
|
||||
{
|
||||
passageId: 'p-kern-positief',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Toewijzing',
|
||||
version: 1,
|
||||
besluit: 'positief',
|
||||
content: text('Uw aanvraag is toegewezen.'),
|
||||
},
|
||||
{
|
||||
passageId: 'p-kern-negatief',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Afwijzing',
|
||||
version: 1,
|
||||
besluit: 'negatief',
|
||||
content: text('Uw aanvraag is afgewezen.'),
|
||||
},
|
||||
{
|
||||
passageId: 'p-kern-scholing',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Onvoldoende scholing',
|
||||
version: 1,
|
||||
besluit: 'negatief',
|
||||
reason: 'onvoldoende_scholing',
|
||||
content: text('Onvoldoende scholing.'),
|
||||
},
|
||||
];
|
||||
|
||||
function brief(status: BriefStatus, kernBlocks: Brief['sections'][number]['blocks'] = []): Brief {
|
||||
@@ -43,9 +76,27 @@ function brief(status: BriefStatus, kernBlocks: Brief['sections'][number]['block
|
||||
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
|
||||
],
|
||||
sections: [
|
||||
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: true, blocks: [{ type: 'freeText', blockId: 'aanhef-1', content: text('Geachte heer/mevrouw,') }] },
|
||||
{ sectionKey: 'kern', title: 'Kern van het besluit', required: true, locked: false, blocks: kernBlocks },
|
||||
{ sectionKey: 'slot', title: 'Slot', required: false, locked: true, blocks: [{ type: 'freeText', blockId: 'slot-1', content: text('Met vriendelijke groet,') }] },
|
||||
{
|
||||
sectionKey: 'aanhef',
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [{ type: 'freeText', blockId: 'aanhef-1', content: text('Geachte heer/mevrouw,') }],
|
||||
},
|
||||
{
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern van het besluit',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: kernBlocks,
|
||||
},
|
||||
{
|
||||
sectionKey: 'slot',
|
||||
title: 'Slot',
|
||||
required: false,
|
||||
locked: true,
|
||||
blocks: [{ type: 'freeText', blockId: 'slot-1', content: text('Met vriendelijke groet,') }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -66,8 +117,22 @@ export const EmptyKern: Story = {
|
||||
|
||||
// A besluit-sourced kern block (carries provenance), so the panel re-seeds itself from it.
|
||||
const negatiefScholingKern: Brief['sections'][number]['blocks'] = [
|
||||
{ type: 'passage', blockId: 'local-1', sourcePassageId: 'p-kern-negatief', sourceVersion: 1, edited: false, content: text('Uw aanvraag is afgewezen.') },
|
||||
{ type: 'passage', blockId: 'local-2', sourcePassageId: 'p-kern-scholing', sourceVersion: 1, edited: false, content: text('Onvoldoende scholing.') },
|
||||
{
|
||||
type: 'passage',
|
||||
blockId: 'local-1',
|
||||
sourcePassageId: 'p-kern-negatief',
|
||||
sourceVersion: 1,
|
||||
edited: false,
|
||||
content: text('Uw aanvraag is afgewezen.'),
|
||||
},
|
||||
{
|
||||
type: 'passage',
|
||||
blockId: 'local-2',
|
||||
sourcePassageId: 'p-kern-scholing',
|
||||
sourceVersion: 1,
|
||||
edited: false,
|
||||
content: text('Onvoldoende scholing.'),
|
||||
},
|
||||
];
|
||||
|
||||
/** Draft with a negatief besluit: the kern is filled from the selection and the besluit
|
||||
@@ -96,7 +161,12 @@ export const MaskedBigNummer: Story = {
|
||||
export const Rejected: Story = {
|
||||
render: (args) => {
|
||||
const b = brief(
|
||||
{ tag: 'rejected', rejectedBy: 'demo-approver', rejectedAt: '2026-07-01', comments: 'Graag de reden concreter.' },
|
||||
{
|
||||
tag: 'rejected',
|
||||
rejectedBy: 'demo-approver',
|
||||
rejectedAt: '2026-07-01',
|
||||
comments: 'Graag de reden concreter.',
|
||||
},
|
||||
negatiefScholingKern,
|
||||
);
|
||||
return { props: { ...args, brief: b, diagnostics: allDiagnostics(b), canSubmit: true } };
|
||||
|
||||
@@ -2,13 +2,49 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LibraryPassage } from '@brief/domain/brief';
|
||||
import { BesluitPanelComponent } from './besluit-panel.component';
|
||||
|
||||
const text = (t: string): LibraryPassage['content'] => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
|
||||
const text = (t: string): LibraryPassage['content'] => ({
|
||||
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
|
||||
});
|
||||
|
||||
const passages: LibraryPassage[] = [
|
||||
{ passageId: 'p-kern-positief', scope: 'global', sectionKey: 'kern', label: 'Toewijzing', version: 1, besluit: 'positief', content: text('Toegewezen.') },
|
||||
{ passageId: 'p-kern-negatief', scope: 'global', sectionKey: 'kern', label: 'Afwijzing', version: 1, besluit: 'negatief', content: text('Afgewezen.') },
|
||||
{ passageId: 'p-kern-scholing', scope: 'global', sectionKey: 'kern', label: 'Onvoldoende scholing', version: 1, besluit: 'negatief', reason: 'onvoldoende_scholing', content: text('Onvoldoende scholing.') },
|
||||
{ passageId: 'p-kern-gegevens', scope: 'global', sectionKey: 'kern', label: 'Onjuiste gegevens', version: 1, besluit: 'negatief', reason: 'onjuiste_gegevens', content: text('Onjuiste gegevens.') },
|
||||
{
|
||||
passageId: 'p-kern-positief',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Toewijzing',
|
||||
version: 1,
|
||||
besluit: 'positief',
|
||||
content: text('Toegewezen.'),
|
||||
},
|
||||
{
|
||||
passageId: 'p-kern-negatief',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Afwijzing',
|
||||
version: 1,
|
||||
besluit: 'negatief',
|
||||
content: text('Afgewezen.'),
|
||||
},
|
||||
{
|
||||
passageId: 'p-kern-scholing',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Onvoldoende scholing',
|
||||
version: 1,
|
||||
besluit: 'negatief',
|
||||
reason: 'onvoldoende_scholing',
|
||||
content: text('Onvoldoende scholing.'),
|
||||
},
|
||||
{
|
||||
passageId: 'p-kern-gegevens',
|
||||
scope: 'global',
|
||||
sectionKey: 'kern',
|
||||
label: 'Onjuiste gegevens',
|
||||
version: 1,
|
||||
besluit: 'negatief',
|
||||
reason: 'onjuiste_gegevens',
|
||||
content: text('Onjuiste gegevens.'),
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<BesluitPanelComponent> = {
|
||||
|
||||
@@ -178,7 +178,9 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
(click)="zoomBy(0.1)"
|
||||
>+</app-button
|
||||
>
|
||||
<app-button variant="subtle" (click)="zoomLevel.set(1)">{{ zoomResetLabel() }}</app-button>
|
||||
<app-button variant="subtle" (click)="zoomLevel.set(1)">{{
|
||||
zoomResetLabel()
|
||||
}}</app-button>
|
||||
</div>
|
||||
@if (editableRegions() === 'none') {
|
||||
<app-button
|
||||
@@ -399,7 +401,10 @@ export class LetterCanvasComponent {
|
||||
protected editing = computed(() => this.editableRegions() === 'template');
|
||||
|
||||
protected emitEdit(field: OrgTemplateTextField, event: Event) {
|
||||
this.templateEdit.emit({ field, value: (event.target as HTMLInputElement | HTMLTextAreaElement).value });
|
||||
this.templateEdit.emit({
|
||||
field,
|
||||
value: (event.target as HTMLInputElement | HTMLTextAreaElement).value,
|
||||
});
|
||||
}
|
||||
|
||||
protected marginStyle = computed(() => {
|
||||
|
||||
@@ -227,7 +227,9 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
|
||||
<ul class="history-list">
|
||||
@for (v of history(); track v.version) {
|
||||
<li class="history-row">
|
||||
<span>{{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}</span>
|
||||
<span
|
||||
>{{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}</span
|
||||
>
|
||||
<app-button variant="subtle" [disabled]="busy()" (click)="rollback.emit(v.version)">
|
||||
{{ rollbackLabel() }}
|
||||
</app-button>
|
||||
@@ -326,12 +328,15 @@ export class OrgTemplateEditorComponent {
|
||||
}
|
||||
}
|
||||
|
||||
protected impactText = computed(() =>
|
||||
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
|
||||
protected impactText = computed(
|
||||
() =>
|
||||
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
|
||||
);
|
||||
|
||||
protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);
|
||||
protected marginsLegend = input($localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`);
|
||||
protected marginsLegend = input(
|
||||
$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`,
|
||||
);
|
||||
protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`);
|
||||
protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);
|
||||
protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`);
|
||||
|
||||
@@ -61,8 +61,12 @@ import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-te
|
||||
[saveText]="saveText()"
|
||||
[previewUrlFor]="previewUrlFor"
|
||||
(selectSubOrg)="store.selectSubOrg($event)"
|
||||
(templateEdit)="store.edit({ tag: 'FieldEdited', field: $event.field, value: $event.value })"
|
||||
(marginEdit)="store.edit({ tag: 'MarginEdited', edge: $event.edge, value: $event.value })"
|
||||
(templateEdit)="
|
||||
store.edit({ tag: 'FieldEdited', field: $event.field, value: $event.value })
|
||||
"
|
||||
(marginEdit)="
|
||||
store.edit({ tag: 'MarginEdited', edge: $event.edge, value: $event.value })
|
||||
"
|
||||
(logoSelected)="store.onLogoSelected($event)"
|
||||
(logoRemoved)="store.onLogoRemoved($event)"
|
||||
(logoRetry)="store.onLogoRetry($event)"
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
DestroyRef,
|
||||
ENVIRONMENT_INITIALIZER,
|
||||
Injectable,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { DestroyRef, ENVIRONMENT_INITIALIZER, Injectable, inject } from '@angular/core';
|
||||
import { CanDeactivateFn } from '@angular/router';
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,8 +3,4 @@
|
||||
* 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';
|
||||
'brief:approve' | 'brief:reject' | 'brief:send' | 'orgtemplate:edit' | 'stamdata:edit';
|
||||
|
||||
@@ -53,7 +53,9 @@ export function deepCopyBlock(block: RichTextBlock): RichTextBlock {
|
||||
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 : '')))
|
||||
.flatMap((p) =>
|
||||
p.nodes.map((n) => (n.type === 'text' ? n.text : n.type === 'placeholder' ? n.key : '')),
|
||||
)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
@@ -108,13 +108,13 @@ the next person doesn't spend an afternoon re-deciding. (Deliberate CIBG-specifi
|
||||
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]>`. |
|
||||
| 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
|
||||
|
||||
+13
-13
@@ -5,10 +5,10 @@ import { Meta } from '@storybook/addon-docs/blocks';
|
||||
# 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
|
||||
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*.
|
||||
[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
|
||||
|
||||
@@ -25,20 +25,20 @@ describe('parsePostcode', () => {
|
||||
});
|
||||
```
|
||||
|
||||
Read top-to-bottom it *is* the spec: "parsePostcode — normalises to 1234 AB; rejects
|
||||
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.
|
||||
_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 |
|
||||
| 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.
|
||||
@@ -46,8 +46,8 @@ 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
|
||||
[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
|
||||
|
||||
+8
-8
@@ -11,13 +11,13 @@ third-party i18n library. The source locale is **`nl`**; a second locale is a
|
||||
|
||||
## 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` |
|
||||
| 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).
|
||||
@@ -70,7 +70,7 @@ forgotten target breaks the `en` build rather than silently shipping Dutch.
|
||||
|
||||
**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:
|
||||
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
|
||||
|
||||
+22
-22
@@ -6,7 +6,7 @@ import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -33,7 +33,7 @@ below is a way to stop the compiler letting you build a state that can't actuall
|
||||
**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
|
||||
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
|
||||
@@ -61,13 +61,13 @@ Code is organised first by **bounded context** — a business capability with it
|
||||
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 |
|
||||
| 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`,
|
||||
@@ -120,7 +120,7 @@ Four tools do the heavy lifting. **Pure functions:** output depends only on inpu
|
||||
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"
|
||||
**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
|
||||
@@ -149,13 +149,13 @@ returns the next model, the view re-renders. All wiring goes through one idiom,
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -172,7 +172,7 @@ does?
|
||||
**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
|
||||
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
|
||||
@@ -202,12 +202,12 @@ boundary `parse*` adapters in `infrastructure/` (the FE⇄BE seam, where untrust
|
||||
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`
|
||||
**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
|
||||
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
|
||||
@@ -223,7 +223,7 @@ re-validate its format? Why not?
|
||||
|
||||
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
|
||||
_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.
|
||||
@@ -258,11 +258,11 @@ which gets a Storybook story instead?
|
||||
|
||||
`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
|
||||
_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.
|
||||
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
|
||||
@@ -338,7 +338,7 @@ live swatches; then skim the [CIBG gap register](?path=/docs/foundations-cibg-ga
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -358,8 +358,8 @@ skills in pipeline order:
|
||||
|
||||
`/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
|
||||
**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`;
|
||||
|
||||
@@ -15,7 +15,7 @@ cross-linked take on one idea; the long-form source lives in `docs/reference/`
|
||||
## Architecture & domain
|
||||
|
||||
- [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs) — bounded
|
||||
contexts + layers, dependencies pointing inward (the folder structure *is* the architecture).
|
||||
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.
|
||||
|
||||
|
||||
+11
-11
@@ -6,21 +6,21 @@ import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
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
|
||||
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. |
|
||||
| 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user