feat(fp): WP-26 — admin org-template editor
CI / frontend (push) Failing after 57s
CI / storybook-a11y (push) Successful in 4m27s
CI / backend (push) Successful in 1m15s
CI / codeql (csharp) (push) Failing after 1m42s
CI / codeql (javascript-typescript) (push) Failing after 1m19s
CI / api-client-drift (push) Successful in 1m40s
CI / e2e (push) Failing after 3h11m33s
CI / frontend (push) Failing after 57s
CI / storybook-a11y (push) Successful in 4m27s
CI / backend (push) Successful in 1m15s
CI / codeql (csharp) (push) Failing after 1m42s
CI / codeql (javascript-typescript) (push) Failing after 1m19s
CI / api-client-drift (push) Successful in 1m40s
CI / e2e (push) Failing after 3h11m33s
Edit the letter's org identity in place on the same canvas the drafter composes on (editableRegions='template'): letterhead/signature/footer become inline controls, content a read-only sample. Margins (bounded), logo upload (reuses the shared upload transport + single-upload), version history + rollback, proefbrief, and publish-with-impact-confirmation. House form-machine idiom (org-template.machine.ts) + root store with debounced save. Capability-gated (orgtemplate:edit) with a deny-by-default alert; route /brief/huisstijl. Backend + generated client were already in place (WP-23). Also fixes a pre-existing red check:tokens (WP-24 canvas hex fallbacks) and threads the published logo through to the drafter's canvas. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { OrgTemplate, OrgTemplateAdminView } from './org-template';
|
||||
import { OrgTemplateState, reduce } from './org-template.machine';
|
||||
import { DocumentCategory } from '@shared/upload/upload.machine';
|
||||
|
||||
const template: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG',
|
||||
returnAddress: 'Postbus 1\n2500 AA Den Haag',
|
||||
footerContact: 'info@cibg.nl',
|
||||
footerLegal: 'CIBG is onderdeel van VWS',
|
||||
signatureName: 'A. de Vries',
|
||||
signatureRole: 'Hoofd Registratie',
|
||||
signatureClosing: 'Met vriendelijke groet,',
|
||||
margins: { topMm: 25, rightMm: 20, bottomMm: 25, leftMm: 20 },
|
||||
version: 3,
|
||||
};
|
||||
|
||||
const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView => ({
|
||||
draft: template,
|
||||
publishedVersion: 3,
|
||||
history: [],
|
||||
unsentBriefs: 2,
|
||||
...over,
|
||||
});
|
||||
|
||||
const loaded = (): OrgTemplateState => reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
|
||||
|
||||
const logoCategory: DocumentCategory = {
|
||||
categoryId: 'org-logo',
|
||||
label: 'Logo',
|
||||
description: '',
|
||||
required: false,
|
||||
acceptedTypes: ['image/png'],
|
||||
maxSizeMb: 2,
|
||||
multiple: false,
|
||||
allowPostDelivery: false,
|
||||
};
|
||||
|
||||
describe('org-template.machine', () => {
|
||||
it('DraftLoaded moves to loaded with the draft, clean', () => {
|
||||
const s = loaded();
|
||||
expect(s.tag).toBe('loaded');
|
||||
if (s.tag !== 'loaded') return;
|
||||
expect(s.draft.orgName).toBe('CIBG');
|
||||
expect(s.subOrgId).toBe('cibg-registers');
|
||||
expect(s.unsentBriefs).toBe(2);
|
||||
expect(s.dirty).toBe(false);
|
||||
});
|
||||
|
||||
it('LoadFailed carries the reason', () => {
|
||||
const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' });
|
||||
expect(s).toEqual({ tag: 'failed', reason: 'boom' });
|
||||
});
|
||||
|
||||
it('FieldEdited edits the draft and marks dirty', () => {
|
||||
const s = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' });
|
||||
expect(s.tag === 'loaded' && s.draft.orgName).toBe('CIBG Nieuw');
|
||||
expect(s.tag === 'loaded' && s.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('MarginEdited edits one edge and marks dirty', () => {
|
||||
const s = reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 });
|
||||
expect(s.tag === 'loaded' && s.draft.margins.topMm).toBe(40);
|
||||
expect(s.tag === 'loaded' && s.draft.margins.leftMm).toBe(20);
|
||||
expect(s.tag === 'loaded' && s.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('DraftSaved clears dirty when the saved draft is the current one', () => {
|
||||
const edited = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' });
|
||||
const savedDraft = edited.tag === 'loaded' ? edited.draft : template;
|
||||
const s = reduce(edited, { tag: 'DraftSaved', savedDraft });
|
||||
expect(s.tag === 'loaded' && s.dirty).toBe(false);
|
||||
expect(s.tag === 'loaded' && s.draft.orgName).toBe('X');
|
||||
});
|
||||
|
||||
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
|
||||
const editing = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' });
|
||||
const savedDraft = editing.tag === 'loaded' ? editing.draft : template;
|
||||
// a further edit changes the draft reference before the save resolves
|
||||
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
|
||||
const s = reduce(raced, { tag: 'DraftSaved', savedDraft });
|
||||
expect(s.tag === 'loaded' && s.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('edits are no-ops in non-loaded states', () => {
|
||||
expect(reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' })).toEqual({
|
||||
tag: 'loading',
|
||||
});
|
||||
});
|
||||
|
||||
it('a completed logo upload sets logoDocumentId + dirty', () => {
|
||||
const withCat = reduce(loaded(), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'CategoriesLoaded', categories: [logoCategory] },
|
||||
});
|
||||
const selected = reduce(withCat, {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'FileSelected', categoryId: 'org-logo', localId: 'a', fileName: 'l.png', fileSizeMb: 0.1 },
|
||||
});
|
||||
const done = reduce(selected, {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
|
||||
});
|
||||
expect(done.tag === 'loaded' && done.draft.logoDocumentId).toBe('doc-1');
|
||||
expect(done.tag === 'loaded' && done.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('removing the logo clears logoDocumentId + dirty', () => {
|
||||
const withLogo = reduce(loaded(), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
|
||||
});
|
||||
const removed = reduce(withLogo, {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'UploadRemoved', localId: 'a' },
|
||||
});
|
||||
expect(removed.tag === 'loaded' && removed.draft.logoDocumentId).toBeUndefined();
|
||||
expect(removed.tag === 'loaded' && removed.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('DraftLoaded (sub-org switch) keeps the loaded logo category, drops uploads', () => {
|
||||
const withCat = reduce(loaded(), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'CategoriesLoaded', categories: [logoCategory] },
|
||||
});
|
||||
const switched = reduce(withCat, {
|
||||
tag: 'DraftLoaded',
|
||||
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
|
||||
});
|
||||
expect(switched.tag === 'loaded' && switched.upload.categories).toHaveLength(1);
|
||||
expect(switched.tag === 'loaded' && switched.upload.uploads).toHaveLength(0);
|
||||
expect(switched.tag === 'loaded' && switched.subOrgId).toBe('cibg-vakbekwaamheid');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { assertNever } from '@shared/kernel/fp';
|
||||
import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template';
|
||||
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/upload.machine';
|
||||
|
||||
/**
|
||||
* The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) —
|
||||
* the same idiom as the wizards. The DRAFT org template is form state (edited in
|
||||
* place on the canvas); publish/rollback are effects that come back as `DraftLoaded`.
|
||||
* `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is
|
||||
* the composable upload sub-machine folded in, exactly like the wizards fold
|
||||
* `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`.
|
||||
*/
|
||||
|
||||
/** The org-identity text fields editable directly on the letter canvas. */
|
||||
export type OrgTemplateTextField =
|
||||
| 'orgName'
|
||||
| 'returnAddress'
|
||||
| 'footerContact'
|
||||
| 'footerLegal'
|
||||
| 'signatureName'
|
||||
| 'signatureRole'
|
||||
| 'signatureClosing';
|
||||
|
||||
export type OrgTemplateState =
|
||||
| { tag: 'loading' }
|
||||
| { tag: 'failed'; reason: string }
|
||||
| {
|
||||
tag: 'loaded';
|
||||
subOrgId: string;
|
||||
draft: OrgTemplate;
|
||||
publishedVersion: number;
|
||||
history: readonly OrgTemplateVersion[];
|
||||
unsentBriefs: number;
|
||||
dirty: boolean;
|
||||
/** Logo upload sub-state (single file, `org-logo` category). */
|
||||
upload: UploadState;
|
||||
};
|
||||
|
||||
export const initial: OrgTemplateState = { tag: 'loading' };
|
||||
|
||||
export type OrgTemplateMsg =
|
||||
| { tag: 'Loading' }
|
||||
| { tag: 'DraftLoaded'; view: OrgTemplateAdminView }
|
||||
| { tag: 'LoadFailed'; reason: string }
|
||||
| { tag: 'FieldEdited'; field: OrgTemplateTextField; value: string }
|
||||
| { tag: 'MarginEdited'; edge: keyof Margins; value: number }
|
||||
/** Carries the draft that was saved: clears `dirty` only if no edit landed during
|
||||
the round-trip (reference-equal), so a concurrent edit keeps its pending save. */
|
||||
| { tag: 'DraftSaved'; savedDraft: OrgTemplate }
|
||||
| { 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 {
|
||||
return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
|
||||
}
|
||||
|
||||
export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState {
|
||||
switch (m.tag) {
|
||||
case 'Loading':
|
||||
return { tag: 'loading' };
|
||||
case 'LoadFailed':
|
||||
return { tag: 'failed', reason: m.reason };
|
||||
case 'DraftLoaded':
|
||||
return {
|
||||
tag: 'loaded',
|
||||
subOrgId: m.view.draft.subOrgId,
|
||||
draft: m.view.draft,
|
||||
publishedVersion: m.view.publishedVersion,
|
||||
history: m.view.history,
|
||||
unsentBriefs: m.view.unsentBriefs,
|
||||
dirty: false,
|
||||
// Keep the loaded logo category across sub-org switches (it's the same
|
||||
// `org-logo` category, loaded once); drop only any in-flight/finished uploads.
|
||||
upload: s.tag === 'loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
|
||||
};
|
||||
case 'FieldEdited':
|
||||
return editDraft(s, (d) => ({ ...d, [m.field]: m.value }));
|
||||
case 'MarginEdited':
|
||||
return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } }));
|
||||
case 'DraftSaved':
|
||||
return s.tag === 'loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
|
||||
case 'Upload': {
|
||||
if (s.tag !== 'loaded') return s;
|
||||
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 };
|
||||
if (m.msg.type === 'UploadRemoved') {
|
||||
const { logoDocumentId: _dropped, ...rest } = s.draft;
|
||||
return { ...s, upload, draft: rest, dirty: true };
|
||||
}
|
||||
return { ...s, upload };
|
||||
}
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
}
|
||||
@@ -29,3 +29,39 @@ export interface OrgTemplate {
|
||||
/** 0 = draft; n>0 = the published snapshot this letter renders with. */
|
||||
readonly version: number;
|
||||
}
|
||||
|
||||
// --- admin editor (WP-26) ---
|
||||
|
||||
/** A published snapshot in the version history: who is faked, `publishedAt` is real. */
|
||||
export interface OrgTemplateVersion {
|
||||
readonly version: number;
|
||||
readonly publishedAt: string;
|
||||
readonly template: OrgTemplate;
|
||||
}
|
||||
|
||||
/** The admin editor's view of one sub-org: the editable draft plus publish metadata. */
|
||||
export interface OrgTemplateAdminView {
|
||||
readonly draft: OrgTemplate;
|
||||
readonly publishedVersion: number;
|
||||
readonly history: readonly OrgTemplateVersion[];
|
||||
/** How many not-yet-sent letters a publish would re-render (the impact count). */
|
||||
readonly unsentBriefs: number;
|
||||
}
|
||||
|
||||
/** One row in the sub-org switcher. */
|
||||
export interface SubOrgSummary {
|
||||
readonly subOrgId: string;
|
||||
readonly orgName: string;
|
||||
readonly publishedVersion: number;
|
||||
}
|
||||
|
||||
/** Publish outcome: the new version and how many unsent letters it touched. */
|
||||
export interface PublishResult {
|
||||
readonly version: number;
|
||||
readonly affectedUnsentBriefs: number;
|
||||
}
|
||||
|
||||
/** Margin bounds (server-owned, `OrgTemplateRules`): the FE mirrors them for instant
|
||||
feedback via `<input min max>`; the server re-validates and stays the authority. */
|
||||
export const MARGIN_MIN_MM = 10;
|
||||
export const MARGIN_MAX_MM = 50;
|
||||
|
||||
Reference in New Issue
Block a user