@cosmicdrift/kumiko-document-designer 0.389.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 (42) hide show
  1. package/dist/web/layout-editor-body.d.ts +3 -14
  2. package/dist/web/layout-editor-body.js +25 -172
  3. package/dist/web/layout-editor-center.d.ts +7 -0
  4. package/dist/web/layout-editor-center.js +23 -0
  5. package/dist/web/layout-editor-logic.d.ts +97 -0
  6. package/dist/web/layout-editor-logic.js +143 -0
  7. package/dist/web/layout-editor-paper.d.ts +4 -0
  8. package/dist/web/layout-editor-paper.js +24 -0
  9. package/dist/web/mail-editor-body.d.ts +4 -16
  10. package/dist/web/mail-editor-body.js +27 -218
  11. package/dist/web/mail-editor-logic.d.ts +70 -0
  12. package/dist/web/mail-editor-logic.js +81 -0
  13. package/dist/web/mail-editor-model.d.ts +105 -0
  14. package/dist/web/mail-editor-model.js +114 -0
  15. package/dist/web/mail-editor-render.d.ts +11 -0
  16. package/dist/web/mail-editor-render.js +51 -0
  17. package/dist/web/mail-preview-render.d.ts +5 -0
  18. package/dist/web/mail-preview-render.js +22 -0
  19. package/dist/web/pdf-editor-actions.d.ts +27 -0
  20. package/dist/web/pdf-editor-actions.js +146 -0
  21. package/dist/web/pdf-editor-chrome.d.ts +6 -0
  22. package/dist/web/pdf-editor-chrome.js +60 -0
  23. package/dist/web/pdf-editor-context.d.ts +22 -0
  24. package/dist/web/pdf-editor-context.js +1 -0
  25. package/dist/web/pdf-editor-draft.d.ts +23 -0
  26. package/dist/web/pdf-editor-draft.js +33 -0
  27. package/dist/web/pdf-editor-panes.d.ts +6 -0
  28. package/dist/web/pdf-editor-panes.js +40 -0
  29. package/dist/web/pdf-editor-view.d.ts +52 -0
  30. package/dist/web/pdf-editor-view.js +89 -0
  31. package/dist/web/pdf-template-editor.d.ts +1 -1
  32. package/dist/web/pdf-template-editor.js +40 -342
  33. package/dist/web/use-layout-editor-state.d.ts +62 -0
  34. package/dist/web/use-layout-editor-state.js +155 -0
  35. package/dist/web/use-mail-editor-parts.d.ts +42 -0
  36. package/dist/web/use-mail-editor-parts.js +74 -0
  37. package/dist/web/use-mail-editor-state.d.ts +89 -0
  38. package/dist/web/use-mail-editor-state.js +55 -0
  39. package/dist/web/use-pdf-editor-state.d.ts +77 -0
  40. package/dist/web/use-pdf-editor-state.js +161 -0
  41. package/package.json +6 -6
  42. package/src/changes.json +18 -0
@@ -1,230 +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 { useEffect, useRef, 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
- // A pick belongs to the variables it was made for: after the declared set changes it is dropped,
88
- // so a re-added variable does not revive an old record value.
89
- const declaredVariables = validation?.variables;
90
- const declaredVariableNames = declaredVariables === undefined ? null : Object.keys(declaredVariables).sort().join("|");
91
- const previousDeclaredNames = useRef(declaredVariableNames);
92
- useEffect(() => {
93
- if (declaredVariableNames === null)
94
- return;
95
- if (previousDeclaredNames.current !== null &&
96
- previousDeclaredNames.current !== declaredVariableNames) {
97
- setPicked(null);
98
- }
99
- previousDeclaredNames.current = declaredVariableNames;
100
- }, [declaredVariableNames]);
101
- const update = (patch) => {
102
- const next = { ...draft, ...patch };
103
- setDraft(next);
104
- settle(next);
105
- };
106
- const reset = () => {
107
- setDraft(baseline);
108
- settle(baseline);
109
- setEditorEpoch((epoch) => epoch + 1);
110
- persistence.clearError();
111
- };
112
- const dirty = isDraftDirty(draft, baseline);
113
- useUnsavedInputSource(() => dirty);
114
- const busy = persistence.busy;
115
- const canSave = !locked && !busy && validation?.canSave === true && (dirty || creating);
116
- const canPublish = !locked &&
117
- !busy &&
118
- validation?.canPublish === true &&
119
- (dirty || creating || row?.status !== "active");
120
- const saveAndReload = async (publish) => {
121
- if ((await persistence.save(draft, publish)) !== "failed")
122
- await onReload();
123
- };
124
- const saveDraft = () => void saveAndReload(false);
125
- const perform = (target) => props.onLocaleChange(target.locale);
126
- const leave = (target) => {
127
- if (target.locale === locale)
128
- return;
129
- if (dirty)
130
- setDialog({ kind: "leave", target });
131
- else
132
- perform(target);
133
- };
134
- const resources = parseLinkedResources(row?.linkedResources);
135
- const pdfTitles = new Map(pdfTemplates.rows.map((template) => [template.slug, templateTitleText(t, template.title)]));
136
- const resourcePreviews = Object.fromEntries(resources.flatMap((resource) => resource.kind === "image"
137
- ? [
138
- [
139
- resource.key,
140
- {
141
- src: fileUrl(resource.fileRefId),
142
- caption: t("documentDesigner.mail.resource.caption", {
143
- key: resource.key,
144
- size: SIZE_TOKEN,
145
- }),
146
- },
147
- ],
148
- ]
149
- : []));
150
- const uploadTemplateId = row?.scope === "tenant" ? row.id : null;
151
- const takenKeys = resources.map((resource) => resource.key);
152
- const removeResource = async (key) => {
153
- if (uploadTemplateId === null)
154
- return;
155
- setResourceError(null);
156
- const result = await resourceRemove.mutate({ templateId: uploadTemplateId, key });
157
- if (result.isSuccess)
158
- await onRefreshRow();
159
- else
160
- setResourceError(dispatcherErrorText(result.error, t));
161
- };
162
- const sendTestMail = () => {
163
- void testMail.send({
164
- slug,
165
- locale,
166
- content: draft.body,
167
- meta: buildMeta(draft),
168
- variableSchema,
169
- includeAttachments,
170
- prefixSubject,
171
- ...(pickedVariables !== null && { variables: pickedVariables }),
172
- });
173
- };
174
- // Nothing is written yet in a missing language, so no placeholder may read as missing.
175
- const railValidation = locked && validation != null ? { ...validation, missingRequired: [] } : (validation ?? null);
176
- const names = placeholderNames(validation ?? null);
177
- const vocabulary = {
178
- names,
179
- labels: {
180
- ...systemLabelMap(t, validation?.systemPlaceholders ?? []),
181
- ...variableLabelMap(t, validation?.variables ?? {}),
182
- },
183
- system: validation?.systemPlaceholders ?? [],
184
- };
185
- const attachments = resources.filter((resource) => resource.kind !== "image");
186
- const footer = locked || validation === undefined || validation === null
187
- ? null
188
- : footerMessage(t, validation, mode === "preview" && testMail.availability.canSend);
189
- const fieldErrors = locked ? {} : metaFieldMessages(t, validation ?? null);
190
- const publish = () => void saveAndReload(true);
191
- 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);
192
20
  const origin = row === null ? null : originOf(row.scope !== "tenant", props.systemVersion !== null);
193
- 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: () => {
194
28
  setResetError(null);
195
29
  void onReload();
196
- }, 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: {
197
- slug,
198
- kind: "mail",
199
- locale,
200
- content: settled.body,
201
- meta: buildMeta(settled),
202
- variableSchema,
203
- ...(pickedVariables !== null && { variables: pickedVariables }),
204
- }, picked: picked, onPick: setPicked, validation: validation ?? null, slug: slug, locale: locale, fromName: settled.senderName, own: own, origin: origin, controls: controls, testMail: {
205
- availability: testMail.availability,
206
- pending: testMail.pending,
207
- onSend: sendTestMail,
208
- includeAttachments,
209
- onIncludeAttachments: setIncludeAttachments,
210
- prefixSubject,
211
- onPrefixSubject: setPrefixSubject,
212
- } })), !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: {
213
- templateId: uploadTemplateId,
214
- takenKeys,
215
- onUploaded: (uploaded) => {
216
- if (uploaded.purpose === "image")
217
- insert.insertResource(uploaded.key);
218
- void onRefreshRow();
219
- },
220
- } }), _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: () => {
221
- 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();
222
34
  setDialog({ kind: "none" });
223
- }, onRemove: removeResource, onLeaveSave: async (target) => {
224
- if ((await persistence.save(draft, false)) === "saved")
225
- perform(target);
226
- }, onLeaveDiscard: (target) => {
35
+ }, onRemove: m.removeResource, onLeaveSave: m.leaveSave, onLeaveDiscard: (target) => {
227
36
  setDialog({ kind: "none" });
228
- perform(target);
37
+ m.perform(target);
229
38
  } })] }));
230
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
+ }
@@ -0,0 +1,105 @@
1
+ import type { LeaveTarget } from "./mail-dialogs.tsx";
2
+ import { type MailEditorBodyProps } from "./mail-editor-logic.ts";
3
+ import type { MailDraft, MailValidation, PickedValues } from "./mail-types.ts";
4
+ import type { MailServices, MailUiState } from "./use-mail-editor-parts.ts";
5
+ type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
6
+ export type MailModelInput = {
7
+ readonly props: MailEditorBodyProps;
8
+ readonly t: Translate;
9
+ readonly ui: MailUiState;
10
+ readonly picked: PickedValues | null;
11
+ readonly setPicked: (next: PickedValues | null) => void;
12
+ readonly services: MailServices;
13
+ readonly validation: MailValidation | null;
14
+ readonly variableSchema: Readonly<Record<string, unknown>>;
15
+ readonly pickedVariables: PickedValues | null;
16
+ readonly locked: boolean;
17
+ readonly dirty: boolean;
18
+ };
19
+ export declare function buildMailModel(input: MailModelInput): {
20
+ update: (patch: Partial<MailDraft>) => void;
21
+ reset: () => void;
22
+ saveAndReload: (publish: boolean) => Promise<void>;
23
+ perform: (target: LeaveTarget) => void;
24
+ leave: (target: LeaveTarget) => void;
25
+ leaveSave: (target: LeaveTarget) => Promise<void>;
26
+ removeResource: (key: string) => Promise<void>;
27
+ sendTestMail: () => void;
28
+ ui: {
29
+ baseline: MailDraft;
30
+ draft: MailDraft;
31
+ setDraft: import("react").Dispatch<import("react").SetStateAction<MailDraft>>;
32
+ settled: MailDraft;
33
+ settle: (next: MailDraft) => void;
34
+ mode: import("./editor-header.tsx").EditorMode;
35
+ setMode: import("react").Dispatch<import("react").SetStateAction<import("./editor-header.tsx").EditorMode>>;
36
+ creating: boolean;
37
+ setCreating: import("react").Dispatch<import("react").SetStateAction<boolean>>;
38
+ dialog: import("./mail-dialogs.tsx").ConfirmDialog;
39
+ setDialog: import("react").Dispatch<import("react").SetStateAction<import("./mail-dialogs.tsx").ConfirmDialog>>;
40
+ uploadOpen: boolean;
41
+ setUploadOpen: import("react").Dispatch<import("react").SetStateAction<boolean>>;
42
+ pdfOpen: boolean;
43
+ setPdfOpen: import("react").Dispatch<import("react").SetStateAction<boolean>>;
44
+ resourceError: string | null;
45
+ setResourceError: import("react").Dispatch<import("react").SetStateAction<string | null>>;
46
+ resetError: string | null;
47
+ setResetError: import("react").Dispatch<import("react").SetStateAction<string | null>>;
48
+ editorEpoch: number;
49
+ setEditorEpoch: import("react").Dispatch<import("react").SetStateAction<number>>;
50
+ includeAttachments: boolean;
51
+ setIncludeAttachments: import("react").Dispatch<import("react").SetStateAction<boolean>>;
52
+ prefixSubject: boolean;
53
+ setPrefixSubject: import("react").Dispatch<import("react").SetStateAction<boolean>>;
54
+ };
55
+ services: {
56
+ insert: import("./use-insert-targets.ts").InsertTargets;
57
+ persistence: import("./use-mail-persistence.ts").MailPersistence;
58
+ testMail: import("./use-test-mail.ts").TestMail;
59
+ own: import("./use-own-address.ts").OwnAddress;
60
+ filesAvailable: boolean;
61
+ resourceRemove: import("@cosmicdrift/kumiko-renderer").UseMutationResult<unknown>;
62
+ layouts: import("./use-overview-rows.ts").OverviewRows;
63
+ pdfTemplates: import("./use-overview-rows.ts").OverviewRows;
64
+ };
65
+ validation: MailValidation | null;
66
+ picked: Readonly<Record<string, unknown>> | null;
67
+ setPicked: (next: PickedValues | null) => void;
68
+ pickedVariables: Readonly<Record<string, unknown>> | null;
69
+ variableSchema: Readonly<Record<string, unknown>>;
70
+ locked: boolean;
71
+ dirty: boolean;
72
+ busy: boolean;
73
+ canSave: boolean;
74
+ canPublish: boolean;
75
+ resources: readonly import("./mail-types.ts").LinkedResourceView[];
76
+ pdfTitles: Map<string, string>;
77
+ uploadTemplateId: string | null;
78
+ takenKeys: string[];
79
+ attachments: ({
80
+ readonly kind: "file";
81
+ readonly key: string;
82
+ readonly fileRefId: string;
83
+ readonly fileName: string;
84
+ } | {
85
+ readonly kind: "pdf";
86
+ readonly key: string;
87
+ readonly templateSlug: string;
88
+ readonly fileName: string;
89
+ })[];
90
+ resourcePreviews: {
91
+ [k: string]: {
92
+ src: string;
93
+ caption: string;
94
+ };
95
+ };
96
+ railValidation: MailValidation | null;
97
+ vocabulary: import("./mail-edit-center.tsx").EditorVocabulary;
98
+ footer: {
99
+ readonly tone: "bad" | "warn" | "ok";
100
+ readonly text: string;
101
+ } | null;
102
+ fieldErrors: Partial<Record<"previewText" | "subject", string>>;
103
+ };
104
+ export type MailEditorModel = ReturnType<typeof buildMailModel>;
105
+ export {};
@@ -0,0 +1,114 @@
1
+ import { dispatcherErrorText } from "@cosmicdrift/kumiko-renderer";
2
+ import { parseLinkedResources } from "./mail-draft.js";
3
+ import { mailSaveFlags, railValidationOf, resourcePreviewsOfMail, testMailPayload, vocabularyOf, } from "./mail-editor-logic.js";
4
+ import { footerMessage, metaFieldMessages } from "./mail-footer.js";
5
+ import { templateTitleText } from "./used-by-label.js";
6
+ function mailActions(input) {
7
+ const { props, ui, services } = input;
8
+ const { draft, baseline, settle } = ui;
9
+ const { persistence } = services;
10
+ const update = (patch) => {
11
+ const next = { ...draft, ...patch };
12
+ ui.setDraft(next);
13
+ settle(next);
14
+ };
15
+ const reset = () => {
16
+ ui.setDraft(baseline);
17
+ settle(baseline);
18
+ ui.setEditorEpoch((epoch) => epoch + 1);
19
+ persistence.clearError();
20
+ };
21
+ const saveAndReload = async (publish) => {
22
+ if ((await persistence.save(draft, publish)) !== "failed")
23
+ await props.onReload();
24
+ };
25
+ const perform = (target) => props.onLocaleChange(target.locale);
26
+ const leave = (target) => {
27
+ // skip: the target locale is already open
28
+ if (target.locale === props.locale)
29
+ return;
30
+ if (input.dirty)
31
+ ui.setDialog({ kind: "leave", target });
32
+ else
33
+ perform(target);
34
+ };
35
+ const leaveSave = async (target) => {
36
+ if ((await persistence.save(draft, false)) === "saved")
37
+ perform(target);
38
+ };
39
+ return { update, reset, saveAndReload, perform, leave, leaveSave };
40
+ }
41
+ function resourceActions(input, uploadTemplateId) {
42
+ const { props, t, ui, services } = input;
43
+ const removeResource = async (key) => {
44
+ // skip: only tenant rows own removable resources
45
+ if (uploadTemplateId === null)
46
+ return;
47
+ ui.setResourceError(null);
48
+ const result = await services.resourceRemove.mutate({ templateId: uploadTemplateId, key });
49
+ if (result.isSuccess)
50
+ await props.onRefreshRow();
51
+ else
52
+ ui.setResourceError(dispatcherErrorText(result.error, t));
53
+ };
54
+ const sendTestMail = () => {
55
+ void services.testMail.send(testMailPayload({
56
+ slug: props.slug,
57
+ locale: props.locale,
58
+ draft: ui.draft,
59
+ variableSchema: input.variableSchema,
60
+ includeAttachments: ui.includeAttachments,
61
+ prefixSubject: ui.prefixSubject,
62
+ pickedVariables: input.pickedVariables,
63
+ }));
64
+ };
65
+ return { removeResource, sendTestMail };
66
+ }
67
+ export function buildMailModel(input) {
68
+ const { props, t, ui, services, validation, locked } = input;
69
+ const { row } = props;
70
+ const busy = services.persistence.busy;
71
+ const { canSave, canPublish } = mailSaveFlags({
72
+ locked,
73
+ busy,
74
+ validation,
75
+ dirty: input.dirty,
76
+ creating: ui.creating,
77
+ row,
78
+ });
79
+ const resources = parseLinkedResources(row?.linkedResources);
80
+ const pdfTitles = new Map(services.pdfTemplates.rows.map((template) => [
81
+ template.slug,
82
+ templateTitleText(t, template.title),
83
+ ]));
84
+ const uploadTemplateId = row?.scope === "tenant" ? row.id : null;
85
+ const footer = locked || validation === undefined || validation === null
86
+ ? null
87
+ : footerMessage(t, validation, ui.mode === "preview" && services.testMail.availability.canSend);
88
+ return {
89
+ ui,
90
+ services,
91
+ validation,
92
+ picked: input.picked,
93
+ setPicked: input.setPicked,
94
+ pickedVariables: input.pickedVariables,
95
+ variableSchema: input.variableSchema,
96
+ locked,
97
+ dirty: input.dirty,
98
+ busy,
99
+ canSave,
100
+ canPublish,
101
+ ...mailActions(input),
102
+ ...resourceActions(input, uploadTemplateId),
103
+ resources,
104
+ pdfTitles,
105
+ uploadTemplateId,
106
+ takenKeys: resources.map((resource) => resource.key),
107
+ attachments: resources.filter((resource) => resource.kind !== "image"),
108
+ resourcePreviews: resourcePreviewsOfMail(resources, t),
109
+ railValidation: railValidationOf(locked, validation),
110
+ vocabulary: vocabularyOf(t, validation),
111
+ footer,
112
+ fieldErrors: locked ? {} : metaFieldMessages(t, validation),
113
+ };
114
+ }
@@ -0,0 +1,11 @@
1
+ import type { usePrimitives } from "@cosmicdrift/kumiko-renderer";
2
+ import type { ReactNode } from "react";
3
+ import type { MailEditorBodyProps } from "./mail-editor-logic.ts";
4
+ import type { MailEditorModel } from "./mail-editor-model.ts";
5
+ type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
6
+ type Banner = ReturnType<typeof usePrimitives>["Banner"];
7
+ export declare function renderMailControls(props: MailEditorBodyProps, m: MailEditorModel): ReactNode;
8
+ export declare function renderMailBanners(Banner: Banner, t: Translate, m: MailEditorModel): ReactNode;
9
+ export declare function renderMailEditBody(props: MailEditorBodyProps, m: MailEditorModel): ReactNode;
10
+ export declare function renderMailModals(props: MailEditorBodyProps, m: MailEditorModel): ReactNode;
11
+ export {};