@cosmicdrift/kumiko-document-designer 0.388.0 → 0.390.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/feature.js +11 -3
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/list-definition.d.ts +3 -1
  5. package/dist/list-definition.js +10 -3
  6. package/dist/web/client-plugin.js +6 -2
  7. package/dist/web/constants.d.ts +1 -0
  8. package/dist/web/constants.js +1 -0
  9. package/dist/web/i18n.js +1 -0
  10. package/dist/web/layout-editor-body.d.ts +3 -14
  11. package/dist/web/layout-editor-body.js +25 -172
  12. package/dist/web/layout-editor-center.d.ts +7 -0
  13. package/dist/web/layout-editor-center.js +23 -0
  14. package/dist/web/layout-editor-logic.d.ts +97 -0
  15. package/dist/web/layout-editor-logic.js +143 -0
  16. package/dist/web/layout-editor-paper.d.ts +4 -0
  17. package/dist/web/layout-editor-paper.js +24 -0
  18. package/dist/web/layout-editor.d.ts +3 -1
  19. package/dist/web/layout-editor.js +7 -3
  20. package/dist/web/mail-editor-body.d.ts +4 -16
  21. package/dist/web/mail-editor-body.js +27 -204
  22. package/dist/web/mail-editor-logic.d.ts +70 -0
  23. package/dist/web/mail-editor-logic.js +81 -0
  24. package/dist/web/mail-editor-model.d.ts +105 -0
  25. package/dist/web/mail-editor-model.js +114 -0
  26. package/dist/web/mail-editor-render.d.ts +11 -0
  27. package/dist/web/mail-editor-render.js +51 -0
  28. package/dist/web/mail-preview-render.d.ts +5 -0
  29. package/dist/web/mail-preview-render.js +22 -0
  30. package/dist/web/pdf-editor-actions.d.ts +27 -0
  31. package/dist/web/pdf-editor-actions.js +146 -0
  32. package/dist/web/pdf-editor-chrome.d.ts +6 -0
  33. package/dist/web/pdf-editor-chrome.js +60 -0
  34. package/dist/web/pdf-editor-context.d.ts +22 -0
  35. package/dist/web/pdf-editor-context.js +1 -0
  36. package/dist/web/pdf-editor-draft.d.ts +23 -0
  37. package/dist/web/pdf-editor-draft.js +33 -0
  38. package/dist/web/pdf-editor-panes.d.ts +6 -0
  39. package/dist/web/pdf-editor-panes.js +40 -0
  40. package/dist/web/pdf-editor-view.d.ts +52 -0
  41. package/dist/web/pdf-editor-view.js +89 -0
  42. package/dist/web/pdf-template-editor.d.ts +1 -1
  43. package/dist/web/pdf-template-editor.js +40 -342
  44. package/dist/web/use-layout-editor-state.d.ts +62 -0
  45. package/dist/web/use-layout-editor-state.js +155 -0
  46. package/dist/web/use-mail-editor-parts.d.ts +42 -0
  47. package/dist/web/use-mail-editor-parts.js +74 -0
  48. package/dist/web/use-mail-editor-state.d.ts +89 -0
  49. package/dist/web/use-mail-editor-state.js +55 -0
  50. package/dist/web/use-pdf-editor-state.d.ts +77 -0
  51. package/dist/web/use-pdf-editor-state.js +161 -0
  52. package/package.json +6 -6
  53. package/src/changes.json +30 -0
@@ -0,0 +1,143 @@
1
+ import { MAIL_DOCUMENT_BLOCKS, PDF_DOCUMENT_BLOCKS, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
2
+ import { dispatcherErrorText } from "@cosmicdrift/kumiko-renderer";
3
+ import { pluralKey } from "@cosmicdrift/kumiko-renderer/headless";
4
+ import { composeLayoutHtml } from "./compose-layout.js";
5
+ import { fileUrl } from "./file-name.js";
6
+ import { isHexColor, metaToSave } from "./layout-meta.js";
7
+ import { asRowStatus } from "./status-label.js";
8
+ export const REGION_EDITOR_IDS = {
9
+ header: "document-designer-region-header",
10
+ signature: "document-designer-region-signature",
11
+ footer: "document-designer-region-footer",
12
+ };
13
+ export const PAPER_WIDTH_CLASS = {
14
+ 560: "max-w-[560px]",
15
+ 600: "max-w-[600px]",
16
+ 640: "max-w-[640px]",
17
+ };
18
+ export function issueText(t, issue) {
19
+ const translated = t(issue.i18nKey, issue.params);
20
+ return translated === issue.i18nKey ? issue.code : translated;
21
+ }
22
+ export function sameAppearance(a, b) {
23
+ return (a.width === b.width &&
24
+ a.font === b.font &&
25
+ a.outerBackground === b.outerBackground &&
26
+ a.accentLine === b.accentLine &&
27
+ a.buttonStyle === b.buttonStyle);
28
+ }
29
+ export function initialTab(showMjmlTab, source) {
30
+ return showMjmlTab && source.effectiveFormat === "mjml" ? "mjml" : "draft";
31
+ }
32
+ export function layoutDirty(input) {
33
+ const { source } = input;
34
+ const appearanceDirty = input.appearanceEditable && !sameAppearance(input.appearance, input.storedAppearance);
35
+ const htmlDirty = input.header !== source.regions.header ||
36
+ input.signature !== source.regions.signature ||
37
+ input.footer !== source.regions.footer ||
38
+ appearanceDirty;
39
+ return input.mjmlMode ? input.mjml !== (source.mjmlSource ?? "") : htmlDirty;
40
+ }
41
+ export function layoutContent(input) {
42
+ return input.mjmlMode
43
+ ? input.mjml
44
+ : composeLayoutHtml({
45
+ header: input.header,
46
+ signature: input.isMail ? input.signature : "",
47
+ footer: input.footer,
48
+ });
49
+ }
50
+ export function footerMessageOf(input) {
51
+ const { t, checked, userCount } = input;
52
+ if (checked === null)
53
+ return null; // skip: nothing validated yet, no footer message
54
+ if (!input.appearanceValid) {
55
+ return { tone: "bad", text: t("documentDesigner.appearance.backgroundInvalid") };
56
+ }
57
+ const blockingIssue = [...checked.issues, ...checked.publishIssues][0];
58
+ if (blockingIssue !== undefined)
59
+ return { tone: "bad", text: issueText(t, blockingIssue) };
60
+ return {
61
+ tone: "ok",
62
+ text: t(pluralKey(input.isMail
63
+ ? "documentDesigner.editor.footer.okMail"
64
+ : "documentDesigner.editor.footer.okPdf", userCount), { count: userCount }),
65
+ };
66
+ }
67
+ // A layout region has no per-mail variables, so a steps block there could never show a stage and an images block no photos.
68
+ export function layoutBlocks(isMail) {
69
+ return (isMail ? MAIL_DOCUMENT_BLOCKS : PDF_DOCUMENT_BLOCKS).filter((block) => block !== "steps" && block !== "images");
70
+ }
71
+ // The layout's "logo" resource is the branding logo, so the editor shows it instead of a chip.
72
+ export function resourcePreviewsOf(branding, isMail, t) {
73
+ const logoFile = branding?.logoFile ?? "";
74
+ if (logoFile === "")
75
+ return undefined; // skip: no branding logo to preview
76
+ return {
77
+ logo: {
78
+ src: fileUrl(logoFile),
79
+ caption: isMail ? t("documentDesigner.editor.logoCaption") : "",
80
+ },
81
+ };
82
+ }
83
+ function metaOf(input) {
84
+ if (input.appearanceEditable)
85
+ return metaToSave(input.appearance, input.source.meta);
86
+ return input.isMail ? input.source.meta : {};
87
+ }
88
+ export async function saveLayout(input) {
89
+ const { t, setSaveError } = input;
90
+ setSaveError(null);
91
+ const base = {
92
+ slug: input.slug,
93
+ kind: input.kind,
94
+ locale: input.source.locale,
95
+ content: input.content,
96
+ contentFormat: input.mjmlMode ? "mjml" : "html",
97
+ meta: metaOf(input),
98
+ };
99
+ const saved = input.isSystemAdmin
100
+ ? await input.upsertSystem.mutate(base)
101
+ : await input.upsertTenant.mutate({ ...base, status: input.publish ? "active" : "draft" });
102
+ if (!saved.isSuccess) {
103
+ setSaveError(dispatcherErrorText(saved.error, t));
104
+ // skip: the failure is shown through saveError
105
+ return;
106
+ }
107
+ if (input.isSystemAdmin && input.publish) {
108
+ const published = await input.publishSystem.mutate({ id: saved.data.id });
109
+ if (!published.isSuccess) {
110
+ setSaveError(dispatcherErrorText(published.error, t));
111
+ // skip: the failure is shown through saveError
112
+ return;
113
+ }
114
+ }
115
+ await input.onReload();
116
+ }
117
+ export function accentLineShown(input) {
118
+ return (input.isMail &&
119
+ input.appearanceEditable &&
120
+ input.appearance.accentLine === "branding" &&
121
+ isHexColor(input.accentColor));
122
+ }
123
+ export function appearanceFlags(input) {
124
+ // An mjml layout brings its own look; the appearance fields only drive structured (html) layouts.
125
+ const appearanceFromMjml = input.source.effectiveFormat === "mjml" || input.mjmlMode;
126
+ const appearanceEditable = input.isMail && !appearanceFromMjml;
127
+ const appearanceValid = !appearanceEditable || isHexColor(input.appearance.outerBackground);
128
+ return { appearanceFromMjml, appearanceEditable, appearanceValid };
129
+ }
130
+ export function shownRowOf(isSystemAdmin, source) {
131
+ return isSystemAdmin ? source.system : (source.tenant ?? source.system);
132
+ }
133
+ export function layoutSaveFlags(input) {
134
+ const { checked, appearanceValid, busy } = input;
135
+ const shownStatus = input.shownRow === null ? null : asRowStatus(input.shownRow.status);
136
+ return {
137
+ canSave: input.dirty && checked?.canSave === true && appearanceValid && !busy,
138
+ canPublish: checked?.canPublish === true &&
139
+ appearanceValid &&
140
+ (input.dirty || shownStatus === "draft") &&
141
+ !busy,
142
+ };
143
+ }
@@ -0,0 +1,4 @@
1
+ import type { ReactNode } from "react";
2
+ import type { Translate } from "./layout-editor-logic.ts";
3
+ import type { LayoutEditorModel } from "./use-layout-editor-state.ts";
4
+ export declare function renderPaper(t: Translate, m: LayoutEditorModel): ReactNode;
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { DocumentContentEditor } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
3
+ import { cn } from "@cosmicdrift/kumiko-renderer/web";
4
+ import { PAPER_WIDTH_CLASS, REGION_EDITOR_IDS } from "./layout-editor-logic.js";
5
+ // A dashed, labelled area of the paper; the focused one owns the shared toolbar.
6
+ function PaperRegion({ label, testId, children, onFocus, }) {
7
+ return (_jsxs("div", { className: "relative rounded-sm border border-dashed border-input p-3", "data-testid": testId, onFocusCapture: onFocus, children: [_jsx("span", { className: "absolute -top-2.5 left-2.5 bg-card px-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground", children: label }), children] }));
8
+ }
9
+ function renderRegionEditor(m, region, value, onChange) {
10
+ const { resourcePreviews } = m;
11
+ return (_jsx(DocumentContentEditor, { id: REGION_EDITOR_IDS[region], value: value, onChange: onChange, variables: m.placeholders, variableLabels: m.placeholderLabels, systemVariables: m.placeholders, readOnly: false, blocks: m.blocks, ...(resourcePreviews && { resourcePreviews }), chrome: "canvas", toolbarHost: m.activeRegion === region ? m.toolbarHost : null }));
12
+ }
13
+ function renderContentSlot(t, isMail) {
14
+ return (_jsxs("div", { className: "rounded-sm border-2 border-dashed border-primary bg-primary/10 px-3 py-9 text-center font-medium text-primary", "data-testid": "document-designer-region-content", children: ["\u229E ", t("documentDesigner.editor.region.content"), _jsx("small", { className: "mt-1 block text-xs font-normal opacity-85", children: t(isMail ? "documentDesigner.editor.slot.mail" : "documentDesigner.editor.slot.pdf") })] }));
15
+ }
16
+ function renderSignatureRegion(t, m) {
17
+ return (_jsx(PaperRegion, { label: t("documentDesigner.editor.region.signature"), testId: "document-designer-region-signature", onFocus: () => m.setActiveRegion("signature"), children: renderRegionEditor(m, "signature", m.signature, m.setSignature) }));
18
+ }
19
+ function renderFooterRegion(t, m) {
20
+ return (_jsx("div", { className: cn(m.isMail && "-mx-8 -mb-6 bg-muted px-8 py-4"), children: _jsx(PaperRegion, { label: t("documentDesigner.editor.region.footer"), testId: "document-designer-region-footer", onFocus: () => m.setActiveRegion("footer"), children: renderRegionEditor(m, "footer", m.footer, m.setFooter) }) }));
21
+ }
22
+ export function renderPaper(t, m) {
23
+ return (_jsxs("div", { className: cn("mx-auto flex w-full flex-col gap-5 bg-card px-8 py-6 shadow-md", m.isMail ? PAPER_WIDTH_CLASS[m.appearance.width] : "min-h-[842px] max-w-[595px]", m.showAccentLine && "border-t-4 border-t-(--layout-accent)"), ref: (element) => element?.style.setProperty("--layout-accent", m.accentColor), "data-testid": "document-designer-paper", children: [_jsx(PaperRegion, { label: t("documentDesigner.editor.region.header"), testId: "document-designer-region-header", onFocus: () => m.setActiveRegion("header"), children: renderRegionEditor(m, "header", m.header, m.setHeader) }), renderContentSlot(t, m.isMail), m.isMail && renderSignatureRegion(t, m), renderFooterRegion(t, m)] }));
24
+ }
@@ -1,2 +1,4 @@
1
1
  import { type ReactNode } from "react";
2
- export declare function LayoutEditor(): ReactNode;
2
+ export declare function LayoutEditor({ scope, }: {
3
+ readonly scope?: "tenant" | "system";
4
+ }): ReactNode;
@@ -11,7 +11,11 @@ function parseLayoutKind(raw) {
11
11
  return raw === "pdf-layout" ? "pdf-layout" : "mail-layout";
12
12
  }
13
13
  function LayoutEditorLoader({ kind, slug, isSystemAdmin, onKindChange, }) {
14
- const source = useQuery(TemplateResolverQueries.layoutSource, { kind, slug });
14
+ const source = useQuery(TemplateResolverQueries.layoutSource, {
15
+ kind,
16
+ slug,
17
+ scope: isSystemAdmin ? "system" : "tenant",
18
+ });
15
19
  const overview = useQuery(TemplateResolverQueries.overview, {
16
20
  limit: 200,
17
21
  layoutSlug: slug,
@@ -38,10 +42,10 @@ function LayoutEditorLoader({ kind, slug, isSystemAdmin, onKindChange, }) {
38
42
  const bodyKey = [kind, slug, data.tenant?.id, data.tenant?.version, data.system?.version].join("|");
39
43
  return (_jsx(LayoutEditorBody, { kind: kind, slug: slug, isSystemAdmin: isSystemAdmin, source: data, overviewRows: rows, usedByCount: overview.data?.total ?? rows.length, overrides: isSystemAdmin ? overrides.data : null, onKindChange: onKindChange, onReload: reload }, bodyKey));
40
44
  }
41
- export function LayoutEditor() {
45
+ export function LayoutEditor({ scope = "tenant", }) {
42
46
  const nav = useNav();
43
47
  const roles = useUserRoles();
44
48
  const [kind, setKind] = useState(() => parseLayoutKind(nav.searchParams["kind"]));
45
49
  const slug = nav.searchParams["slug"] ?? DEFAULT_LAYOUT_SLUG;
46
- return (_jsx(LayoutEditorLoader, { kind: kind, slug: slug, isSystemAdmin: roles?.includes("SystemAdmin") === true, onKindChange: setKind }, kind));
50
+ return (_jsx(LayoutEditorLoader, { kind: kind, slug: slug, isSystemAdmin: scope === "system" && roles?.includes("SystemAdmin") === true, onKindChange: setKind }, kind));
47
51
  }
@@ -1,17 +1,5 @@
1
- import { type ReactNode } from "react";
2
- import type { MailValidation, TemplateRowView } from "./mail-types.ts";
3
- export type MailEditorBodyProps = {
4
- readonly slug: string;
5
- readonly locale: string;
6
- readonly locales: readonly string[];
7
- readonly title: string;
8
- readonly isSystemAdmin: boolean;
9
- readonly row: TemplateRowView | null;
10
- readonly systemVersion: number | null;
11
- readonly fallbackLocale: string | null;
12
- readonly onLocaleChange: (locale: string) => void;
13
- readonly onReload: () => Promise<void>;
14
- readonly onRefreshRow: () => Promise<void>;
15
- };
16
- export declare function placeholderNames(validation: MailValidation | null): string[];
1
+ import type { ReactNode } from "react";
2
+ import type { MailEditorBodyProps } from "./mail-editor-logic.ts";
3
+ export type { MailEditorBodyProps } from "./mail-editor-logic.ts";
4
+ export { placeholderNames } from "./mail-editor-logic.ts";
17
5
  export declare function MailEditorBody(props: MailEditorBodyProps): ReactNode;
@@ -1,216 +1,39 @@
1
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { TemplateResolverHandlers, TemplateResolverQueries, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
3
- import { dispatcherErrorText, useMutation, usePrimitives, useQuery, useTranslation, useUnsavedInputSource, } from "@cosmicdrift/kumiko-renderer";
4
- import { useState } from "react";
5
- import { EditorHeader, EditorLocaleSwitch, EditorModeSwitch, originOf, } from "./editor-header.js";
6
- import { EditorBody, EditorFooter, EditorFrame } from "./editor-shell.js";
7
- import { fileUrl } from "./file-name.js";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
3
+ import { EditorHeader, EditorModeSwitch, originOf } from "./editor-header.js";
4
+ import { EditorFooter, EditorFrame } from "./editor-shell.js";
8
5
  import { MailConfirmDialogs } from "./mail-dialogs.js";
9
- import { buildMeta, isDraftDirty, loadedMailOf, parseJsonRecord, parseLinkedResources, pickDeclaredVariables, systemLabelMap, variableLabelMap, } from "./mail-draft.js";
10
- import { MailEditCenter } from "./mail-edit-center.js";
11
- import { footerMessage, metaFieldMessages } from "./mail-footer.js";
12
- import { PlaceholderSection, ResourceSection, SystemPlaceholderSection, } from "./mail-left-rail.js";
13
- import { MissingLocaleCanvas } from "./mail-missing-locale.js";
6
+ import { renderMailBanners, renderMailControls, renderMailEditBody, renderMailModals, } from "./mail-editor-render.js";
14
7
  import { MailOriginBar } from "./mail-origin-bar.js";
15
- import { PdfLinkModal } from "./mail-pdf-link-modal.js";
16
- import { MailPreviewMode } from "./mail-preview.js";
17
- import { AttachmentSection, ChromeSection, PropertiesSection, UsageSection, } from "./mail-right-rail.js";
18
- import { UploadModal } from "./mail-upload-modal.js";
19
- import { VersionsSection } from "./mail-versions.js";
20
- import { useFilesCapability } from "./use-files-capability.js";
21
- import { useInsertTargets } from "./use-insert-targets.js";
22
- import { useMailPersistence } from "./use-mail-persistence.js";
23
- import { useOverviewRows } from "./use-overview-rows.js";
24
- import { useOwnAddress } from "./use-own-address.js";
25
- import { useSettledValue } from "./use-settled-value.js";
26
- import { useTestMail } from "./use-test-mail.js";
27
- import { templateTitleText } from "./used-by-label.js";
28
- // The editor replaces this token with the decoded image size once the preview image loaded.
29
- const SIZE_TOKEN = "{size}";
30
- function uniqueValues(values) {
31
- return [...new Set(values)];
32
- }
33
- export function placeholderNames(validation) {
34
- if (validation === null)
35
- return [];
36
- const own = Object.entries(validation.variables).flatMap(([name, variable]) => [
37
- name,
38
- ...Object.keys(variable.item ?? {}).map((item) => `${name}.${item}`),
39
- ]);
40
- return uniqueValues([...own, ...validation.systemPlaceholders]);
41
- }
8
+ import { renderMailPreview } from "./mail-preview-render.js";
9
+ import { useMailEditorState } from "./use-mail-editor-state.js";
10
+ export { placeholderNames } from "./mail-editor-logic.js";
42
11
  export function MailEditorBody(props) {
43
- const { slug, locale, title, isSystemAdmin, row, onReload, onRefreshRow } = props;
12
+ const { locale, title, isSystemAdmin, row, onReload } = props;
44
13
  const t = useTranslation();
45
14
  const { Banner } = usePrimitives();
46
- const [baseline] = useState(() => loadedMailOf(row));
47
- const [draft, setDraft] = useState(baseline);
48
- const [settled, settle] = useSettledValue(baseline);
49
- const [mode, setMode] = useState("edit");
50
- const [creating, setCreating] = useState(false);
51
- const [dialog, setDialog] = useState({ kind: "none" });
52
- const [uploadOpen, setUploadOpen] = useState(false);
53
- const [pdfOpen, setPdfOpen] = useState(false);
54
- const [resourceError, setResourceError] = useState(null);
55
- const [resetError, setResetError] = useState(null);
56
- const [editorEpoch, setEditorEpoch] = useState(0);
57
- const [includeAttachments, setIncludeAttachments] = useState(true);
58
- const [prefixSubject, setPrefixSubject] = useState(true);
59
- const [picked, setPicked] = useState(null);
60
- const insert = useInsertTargets();
61
- const persistence = useMailPersistence({
62
- slug,
63
- locale,
64
- title,
65
- isSystemAdmin,
66
- row,
67
- });
68
- const testMail = useTestMail();
69
- const own = useOwnAddress();
70
- const filesAvailable = useFilesCapability();
71
- const resourceRemove = useMutation(TemplateResolverHandlers.resourceRemove);
72
- const layouts = useOverviewRows("mail-layout");
73
- const pdfTemplates = useOverviewRows("document-pdf");
74
- const locked = row === null && !creating;
75
- const variableSchema = parseJsonRecord(row?.variableSchema);
76
- const validation = useQuery(TemplateResolverQueries.validate, {
77
- slug,
78
- kind: "mail",
79
- contentFormat: "html",
80
- content: settled.body,
81
- meta: buildMeta(settled),
82
- variableSchema,
83
- locale,
84
- scope: isSystemAdmin ? "system" : "tenant",
85
- }).data;
86
- const pickedVariables = pickDeclaredVariables(picked, validation?.variables);
87
- const update = (patch) => {
88
- const next = { ...draft, ...patch };
89
- setDraft(next);
90
- settle(next);
91
- };
92
- const reset = () => {
93
- setDraft(baseline);
94
- settle(baseline);
95
- setEditorEpoch((epoch) => epoch + 1);
96
- persistence.clearError();
97
- };
98
- const dirty = isDraftDirty(draft, baseline);
99
- useUnsavedInputSource(() => dirty);
100
- const busy = persistence.busy;
101
- const canSave = !locked && !busy && validation?.canSave === true && (dirty || creating);
102
- const canPublish = !locked &&
103
- !busy &&
104
- validation?.canPublish === true &&
105
- (dirty || creating || row?.status !== "active");
106
- const saveAndReload = async (publish) => {
107
- if ((await persistence.save(draft, publish)) !== "failed")
108
- await onReload();
109
- };
110
- const saveDraft = () => void saveAndReload(false);
111
- const perform = (target) => props.onLocaleChange(target.locale);
112
- const leave = (target) => {
113
- if (target.locale === locale)
114
- return;
115
- if (dirty)
116
- setDialog({ kind: "leave", target });
117
- else
118
- perform(target);
119
- };
120
- const resources = parseLinkedResources(row?.linkedResources);
121
- const pdfTitles = new Map(pdfTemplates.rows.map((template) => [template.slug, templateTitleText(t, template.title)]));
122
- const resourcePreviews = Object.fromEntries(resources.flatMap((resource) => resource.kind === "image"
123
- ? [
124
- [
125
- resource.key,
126
- {
127
- src: fileUrl(resource.fileRefId),
128
- caption: t("documentDesigner.mail.resource.caption", {
129
- key: resource.key,
130
- size: SIZE_TOKEN,
131
- }),
132
- },
133
- ],
134
- ]
135
- : []));
136
- const uploadTemplateId = row?.scope === "tenant" ? row.id : null;
137
- const takenKeys = resources.map((resource) => resource.key);
138
- const removeResource = async (key) => {
139
- if (uploadTemplateId === null)
140
- return;
141
- setResourceError(null);
142
- const result = await resourceRemove.mutate({ templateId: uploadTemplateId, key });
143
- if (result.isSuccess)
144
- await onRefreshRow();
145
- else
146
- setResourceError(dispatcherErrorText(result.error, t));
147
- };
148
- const sendTestMail = () => {
149
- void testMail.send({
150
- slug,
151
- locale,
152
- content: draft.body,
153
- meta: buildMeta(draft),
154
- variableSchema,
155
- includeAttachments,
156
- prefixSubject,
157
- ...(pickedVariables !== null && { variables: pickedVariables }),
158
- });
159
- };
160
- // Nothing is written yet in a missing language, so no placeholder may read as missing.
161
- const railValidation = locked && validation != null ? { ...validation, missingRequired: [] } : (validation ?? null);
162
- const names = placeholderNames(validation ?? null);
163
- const vocabulary = {
164
- names,
165
- labels: {
166
- ...systemLabelMap(t, validation?.systemPlaceholders ?? []),
167
- ...variableLabelMap(t, validation?.variables ?? {}),
168
- },
169
- system: validation?.systemPlaceholders ?? [],
170
- };
171
- const attachments = resources.filter((resource) => resource.kind !== "image");
172
- const footer = locked || validation === undefined || validation === null
173
- ? null
174
- : footerMessage(t, validation, mode === "preview" && testMail.availability.canSend);
175
- const fieldErrors = locked ? {} : metaFieldMessages(t, validation ?? null);
176
- const publish = () => void saveAndReload(true);
177
- const controls = (_jsx(EditorLocaleSwitch, { testIdPrefix: "mail", locales: props.locales, locale: locale, onLocale: (next) => leave({ kind: "locale", locale: next }) }));
15
+ const m = useMailEditorState(props, t);
16
+ const { mode, setMode, setDialog, setResetError } = m.ui;
17
+ const saveDraft = () => void m.saveAndReload(false);
18
+ const publish = () => void m.saveAndReload(true);
19
+ const controls = renderMailControls(props, m);
178
20
  const origin = row === null ? null : originOf(row.scope !== "tenant", props.systemVersion !== null);
179
- return (_jsxs(EditorFrame, { testId: "document-designer-mail-editor", children: [_jsx(EditorHeader, { title: title, stored: row === null ? null : { status: row.status, version: row.version }, actions: { busy, canSave, canPublish, onSave: saveDraft, onPublish: publish }, children: _jsx(EditorModeSwitch, { testIdPrefix: "mail", mode: mode, onMode: setMode }) }), mode === "edit" && (_jsx(MailOriginBar, { row: row, systemVersion: props.systemVersion, locale: locale, isSystemAdmin: isSystemAdmin, creating: creating, controls: controls, onReload: () => {
21
+ return (_jsxs(EditorFrame, { testId: "document-designer-mail-editor", children: [_jsx(EditorHeader, { title: title, stored: row === null ? null : { status: row.status, version: row.version }, actions: {
22
+ busy: m.busy,
23
+ canSave: m.canSave,
24
+ canPublish: m.canPublish,
25
+ onSave: saveDraft,
26
+ onPublish: publish,
27
+ }, children: _jsx(EditorModeSwitch, { testIdPrefix: "mail", mode: mode, onMode: setMode }) }), mode === "edit" && (_jsx(MailOriginBar, { row: row, systemVersion: props.systemVersion, locale: locale, isSystemAdmin: isSystemAdmin, creating: m.ui.creating, controls: controls, onReload: () => {
180
28
  setResetError(null);
181
29
  void onReload();
182
- }, onResetError: setResetError })), testMail.state.kind === "sent" && (_jsx(Banner, { variant: "primary", testId: "mail-test-sent", children: t("documentDesigner.mail.testMail.sent", { subject: testMail.state.subject }) })), testMail.state.kind === "error" && (_jsx(Banner, { variant: "error", testId: "mail-test-error", children: testMail.state.text })), resetError !== null && (_jsx(Banner, { variant: "error", testId: "mail-reset-error", children: resetError })), resourceError !== null && (_jsx(Banner, { variant: "error", testId: "mail-resource-error", children: resourceError })), mode === "edit" ? (_jsx(EditorBody, { left: _jsxs(_Fragment, { children: [_jsx(PlaceholderSection, { validation: railValidation, onInsert: insert.insertPlaceholder }), _jsx(SystemPlaceholderSection, { validation: railValidation, onInsert: insert.insertPlaceholder }), _jsx(ResourceSection, { resources: resources, pdfTitles: pdfTitles, canUpload: uploadTemplateId !== null, filesAvailable: filesAvailable, onUpload: () => setUploadOpen(true), onInsert: insert.insertResource, onRemove: (key) => setDialog({ kind: "remove", key }) })] }), center: locked ? (_jsx(MissingLocaleCanvas, { slug: slug, locale: locale, fallbackLocale: props.fallbackLocale, copyScope: isSystemAdmin ? "system" : "tenant", onCreateBlank: () => setCreating(true), onCopied: () => void onReload() })) : (_jsx(MailEditCenter, { draft: draft, vocabulary: vocabulary, resourcePreviews: resourcePreviews, insert: insert, readOnly: false, fieldErrors: fieldErrors, onChange: update }, editorEpoch)), right: _jsxs(_Fragment, { children: [_jsx(PropertiesSection, { slug: slug, draft: draft, layouts: layouts.rows, onChange: update }), _jsx(ChromeSection, { draft: draft, vocabulary: vocabulary, insert: insert, onChange: update }), _jsx(AttachmentSection, { attachments: attachments, pdfTitles: pdfTitles, canEdit: uploadTemplateId !== null, onAddPdf: () => setPdfOpen(true), onRemove: (key) => setDialog({ kind: "remove", key }) }), _jsx(UsageSection, { slug: slug }), _jsx(VersionsSection, { rowId: row?.id ?? null, currentVersion: row?.version ?? 0, currentStatus: row?.status ?? "draft" })] }) })) : (_jsx(MailPreviewMode, { payload: {
183
- slug,
184
- kind: "mail",
185
- locale,
186
- content: settled.body,
187
- meta: buildMeta(settled),
188
- variableSchema,
189
- ...(pickedVariables !== null && { variables: pickedVariables }),
190
- }, picked: picked, onPick: setPicked, validation: validation ?? null, slug: slug, locale: locale, fromName: settled.senderName, own: own, origin: origin, controls: controls, testMail: {
191
- availability: testMail.availability,
192
- pending: testMail.pending,
193
- onSend: sendTestMail,
194
- includeAttachments,
195
- onIncludeAttachments: setIncludeAttachments,
196
- prefixSubject,
197
- onPrefixSubject: setPrefixSubject,
198
- } })), !locked && (_jsx(EditorFooter, { testIdPrefix: "mail-editor", message: footer, saveError: persistence.error, modifiedAt: row?.modifiedAt ?? null, dirty: dirty, busy: busy, canSave: canSave, canPublish: canPublish, onDiscard: () => setDialog({ kind: "discard" }), onSave: saveDraft, onPublish: publish })), uploadTemplateId !== null && (_jsxs(_Fragment, { children: [_jsx(UploadModal, { open: uploadOpen, onOpenChange: setUploadOpen, target: {
199
- templateId: uploadTemplateId,
200
- takenKeys,
201
- onUploaded: (uploaded) => {
202
- if (uploaded.purpose === "image")
203
- insert.insertResource(uploaded.key);
204
- void onRefreshRow();
205
- },
206
- } }), _jsx(PdfLinkModal, { open: pdfOpen, onOpenChange: setPdfOpen, templates: pdfTemplates.rows, variableLabels: vocabulary.labels, templateId: uploadTemplateId, takenKeys: takenKeys, onLinked: () => void onRefreshRow() })] })), _jsx(MailConfirmDialogs, { dialog: dialog, onClose: () => setDialog({ kind: "none" }), onDiscard: () => {
207
- reset();
30
+ }, onResetError: setResetError })), renderMailBanners(Banner, t, m), mode === "edit"
31
+ ? renderMailEditBody(props, m)
32
+ : renderMailPreview(props, m, controls, origin), !m.locked && (_jsx(EditorFooter, { testIdPrefix: "mail-editor", message: m.footer, saveError: m.services.persistence.error, modifiedAt: row?.modifiedAt ?? null, dirty: m.dirty, busy: m.busy, canSave: m.canSave, canPublish: m.canPublish, onDiscard: () => setDialog({ kind: "discard" }), onSave: saveDraft, onPublish: publish })), renderMailModals(props, m), _jsx(MailConfirmDialogs, { dialog: m.ui.dialog, onClose: () => setDialog({ kind: "none" }), onDiscard: () => {
33
+ m.reset();
208
34
  setDialog({ kind: "none" });
209
- }, onRemove: removeResource, onLeaveSave: async (target) => {
210
- if ((await persistence.save(draft, false)) === "saved")
211
- perform(target);
212
- }, onLeaveDiscard: (target) => {
35
+ }, onRemove: m.removeResource, onLeaveSave: m.leaveSave, onLeaveDiscard: (target) => {
213
36
  setDialog({ kind: "none" });
214
- perform(target);
37
+ m.perform(target);
215
38
  } })] }));
216
39
  }
@@ -0,0 +1,70 @@
1
+ import type { EditorVocabulary } from "./mail-edit-center.tsx";
2
+ import type { LinkedResourceView, MailDraft, MailValidation, PickedValues, TemplateRowView } from "./mail-types.ts";
3
+ type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
4
+ export type MailEditorBodyProps = {
5
+ readonly slug: string;
6
+ readonly locale: string;
7
+ readonly locales: readonly string[];
8
+ readonly title: string;
9
+ readonly isSystemAdmin: boolean;
10
+ readonly row: TemplateRowView | null;
11
+ readonly systemVersion: number | null;
12
+ readonly fallbackLocale: string | null;
13
+ readonly onLocaleChange: (locale: string) => void;
14
+ readonly onReload: () => Promise<void>;
15
+ readonly onRefreshRow: () => Promise<void>;
16
+ };
17
+ export declare function placeholderNames(validation: MailValidation | null): string[];
18
+ export declare function mailSaveFlags(input: {
19
+ readonly locked: boolean;
20
+ readonly busy: boolean;
21
+ readonly validation: MailValidation | null;
22
+ readonly dirty: boolean;
23
+ readonly creating: boolean;
24
+ readonly row: TemplateRowView | null;
25
+ }): {
26
+ readonly canSave: boolean;
27
+ readonly canPublish: boolean;
28
+ };
29
+ export declare function resourcePreviewsOfMail(resources: readonly LinkedResourceView[], t: Translate): {
30
+ [k: string]: {
31
+ src: string;
32
+ caption: string;
33
+ };
34
+ };
35
+ export declare function railValidationOf(locked: boolean, validation: MailValidation | null): MailValidation | null;
36
+ export declare function vocabularyOf(t: Translate, validation: MailValidation | null): EditorVocabulary;
37
+ export declare function testMailPayload(input: {
38
+ readonly slug: string;
39
+ readonly locale: string;
40
+ readonly draft: MailDraft;
41
+ readonly variableSchema: Readonly<Record<string, unknown>>;
42
+ readonly includeAttachments: boolean;
43
+ readonly prefixSubject: boolean;
44
+ readonly pickedVariables: PickedValues | null;
45
+ }): {
46
+ slug: string;
47
+ locale: string;
48
+ content: string;
49
+ meta: Record<string, unknown>;
50
+ variableSchema: Readonly<Record<string, unknown>>;
51
+ includeAttachments: boolean;
52
+ prefixSubject: boolean;
53
+ variables?: Readonly<Record<string, unknown>> | undefined;
54
+ };
55
+ export declare function previewPayload(input: {
56
+ readonly slug: string;
57
+ readonly locale: string;
58
+ readonly settled: MailDraft;
59
+ readonly variableSchema: Readonly<Record<string, unknown>>;
60
+ readonly pickedVariables: PickedValues | null;
61
+ }): {
62
+ slug: string;
63
+ kind: "mail";
64
+ locale: string;
65
+ content: string;
66
+ meta: Record<string, unknown>;
67
+ variableSchema: Readonly<Record<string, unknown>>;
68
+ variables?: Readonly<Record<string, unknown>> | undefined;
69
+ };
70
+ export {};
@@ -0,0 +1,81 @@
1
+ import { fileUrl } from "./file-name.js";
2
+ import { buildMeta, systemLabelMap, variableLabelMap } from "./mail-draft.js";
3
+ // The editor replaces this token with the decoded image size once the preview image loaded.
4
+ const SIZE_TOKEN = "{size}";
5
+ function uniqueValues(values) {
6
+ return [...new Set(values)];
7
+ }
8
+ export function placeholderNames(validation) {
9
+ if (validation === null)
10
+ return [];
11
+ const own = Object.entries(validation.variables).flatMap(([name, variable]) => [
12
+ name,
13
+ ...Object.keys(variable.item ?? {}).map((item) => `${name}.${item}`),
14
+ ]);
15
+ return uniqueValues([...own, ...validation.systemPlaceholders]);
16
+ }
17
+ export function mailSaveFlags(input) {
18
+ const { locked, busy, validation, dirty, creating, row } = input;
19
+ return {
20
+ canSave: !locked && !busy && validation?.canSave === true && (dirty || creating),
21
+ canPublish: !locked &&
22
+ !busy &&
23
+ validation?.canPublish === true &&
24
+ (dirty || creating || row?.status !== "active"),
25
+ };
26
+ }
27
+ export function resourcePreviewsOfMail(resources, t) {
28
+ return Object.fromEntries(resources.flatMap((resource) => resource.kind === "image"
29
+ ? [
30
+ [
31
+ resource.key,
32
+ {
33
+ src: fileUrl(resource.fileRefId),
34
+ caption: t("documentDesigner.mail.resource.caption", {
35
+ key: resource.key,
36
+ size: SIZE_TOKEN,
37
+ }),
38
+ },
39
+ ],
40
+ ]
41
+ : []));
42
+ }
43
+ // Nothing is written yet in a missing language, so no placeholder may read as missing.
44
+ export function railValidationOf(locked, validation) {
45
+ return locked && validation != null
46
+ ? { ...validation, missingRequired: [] }
47
+ : (validation ?? null);
48
+ }
49
+ export function vocabularyOf(t, validation) {
50
+ return {
51
+ names: placeholderNames(validation ?? null),
52
+ labels: {
53
+ ...systemLabelMap(t, validation?.systemPlaceholders ?? []),
54
+ ...variableLabelMap(t, validation?.variables ?? {}),
55
+ },
56
+ system: validation?.systemPlaceholders ?? [],
57
+ };
58
+ }
59
+ export function testMailPayload(input) {
60
+ return {
61
+ slug: input.slug,
62
+ locale: input.locale,
63
+ content: input.draft.body,
64
+ meta: buildMeta(input.draft),
65
+ variableSchema: input.variableSchema,
66
+ includeAttachments: input.includeAttachments,
67
+ prefixSubject: input.prefixSubject,
68
+ ...(input.pickedVariables !== null && { variables: input.pickedVariables }),
69
+ };
70
+ }
71
+ export function previewPayload(input) {
72
+ return {
73
+ slug: input.slug,
74
+ kind: "mail",
75
+ locale: input.locale,
76
+ content: input.settled.body,
77
+ meta: buildMeta(input.settled),
78
+ variableSchema: input.variableSchema,
79
+ ...(input.pickedVariables !== null && { variables: input.pickedVariables }),
80
+ };
81
+ }