Files
atomic-design-poc/apps/ssp/src/app/brief/domain/org-template.machine.ts
T
ehoandClaude Sonnet 5 dd11eafe50 refactor: strip WP-/RB- ticket refs from apps and libs (RD-18)
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>
2026-09-04 21:23:07 +02:00

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);
}
}