204 WP-NN/RB-NN comments named a closed ticket instead of the code they sit next to. git blame already records history and stays correct when code moves; the comment does not. This sweep removes the reference and keeps the sentence, across 95 files in apps/ and libs/ plus the behaviour-spec generator's header text. Eleven references stay: five story files justify an a11y disable per the README's rule, and one line in a11y.mdx documents that convention. Two sentences needed a rewrite, not a deletion, so the reference's meaning survives its removal. behaviour-spec.mdx is regenerated, not hand-edited. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
146 lines
6.3 KiB
TypeScript
146 lines
6.3 KiB
TypeScript
import { assertNever } from '@shared/kernel/fp';
|
|
import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template';
|
|
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine';
|
|
|
|
/**
|
|
* The admin org-template editor as one Elm-style machine (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`.
|
|
*
|
|
* `action` (RD-13) owns the one-shot action lifecycle AND the publish impact-confirm
|
|
* gate as ONE four-variant union, replacing two independent store-level signals
|
|
* (`actionState` + `pendingPublish`). Before RD-13, `pendingPublish === true && busy
|
|
* === true` was representable and meaningless — the confirm dialog could show while a
|
|
* publish was already in flight. A single field with one tag at a time makes that
|
|
* combination unrepresentable.
|
|
*/
|
|
|
|
/** The org-identity text fields editable directly on the letter canvas. */
|
|
export type OrgTemplateTextField =
|
|
| 'orgName'
|
|
| 'returnAddress'
|
|
| 'footerContact'
|
|
| 'footerLegal'
|
|
| 'signatureName'
|
|
| 'signatureRole'
|
|
| 'signatureClosing';
|
|
|
|
/** The one-shot action lifecycle (publish/rollback/proefbrief), plus the publish
|
|
impact-confirm gate, owned by the reducer instead of two independent store-level
|
|
signals (RD-13). `ConfirmingPublish` is a variant of this SAME union, so
|
|
"confirming a publish while one is already in flight" is unrepresentable — no
|
|
state can ever carry both at once. */
|
|
export type OrgTemplateActionState =
|
|
| { tag: 'Idle' }
|
|
| { tag: 'ConfirmingPublish' }
|
|
| { tag: 'Busy' }
|
|
| { tag: 'Failed'; error: string };
|
|
|
|
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;
|
|
action: OrgTemplateActionState;
|
|
};
|
|
|
|
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 }
|
|
| { tag: 'PublishRequested' } // opens the publish impact-confirm gate
|
|
| { tag: 'PublishCancelled' } // closes it without publishing
|
|
| { tag: 'ActionStarted' } // a one-shot action (publish/rollback/proefbrief) began
|
|
| { tag: 'ActionFinished' } // it completed successfully
|
|
| { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show
|
|
|
|
/** 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,
|
|
// A fresh load clears a stale action error rather than letting it outlive
|
|
// the reload (RD-13, same as brief's RD-12).
|
|
action: { tag: 'Idle' },
|
|
};
|
|
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 };
|
|
}
|
|
|
|
// The action lifecycle (RD-13): a no-op unless a template is loaded, since there
|
|
// is nothing to attach the action state to otherwise. `ConfirmingPublish` and
|
|
// `Busy` are variants of one field, so ActionStarted overwriting it to `Busy` is
|
|
// what makes the two mutually exclusive by construction — not by convention.
|
|
case 'PublishRequested':
|
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'ConfirmingPublish' } } : s;
|
|
case 'PublishCancelled':
|
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
|
|
case 'ActionStarted':
|
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s;
|
|
case 'ActionFinished':
|
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
|
|
case 'ActionFailed':
|
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s;
|
|
|
|
default:
|
|
return assertNever(m);
|
|
}
|
|
}
|