@cosmicdrift/kumiko-document-designer 0.0.0-stage → 0.363.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.
- package/LICENSE +57 -0
- package/README.md +37 -2
- package/dist/constants.d.ts +24 -0
- package/dist/constants.js +33 -0
- package/dist/feature.d.ts +2 -0
- package/dist/feature.js +85 -0
- package/dist/i18n.d.ts +6 -0
- package/dist/i18n.js +670 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/list-definition.d.ts +6 -0
- package/dist/list-definition.js +109 -0
- package/dist/web/cells.d.ts +10 -0
- package/dist/web/cells.js +87 -0
- package/dist/web/client-plugin.d.ts +12 -0
- package/dist/web/client-plugin.js +43 -0
- package/dist/web/compose-layout.d.ts +6 -0
- package/dist/web/compose-layout.js +13 -0
- package/dist/web/errors.d.ts +6 -0
- package/dist/web/errors.js +24 -0
- package/dist/web/field-issue-text.d.ts +4 -0
- package/dist/web/field-issue-text.js +12 -0
- package/dist/web/file-name.d.ts +2 -0
- package/dist/web/file-name.js +17 -0
- package/dist/web/i18n.d.ts +2 -0
- package/dist/web/i18n.js +170 -0
- package/dist/web/index.d.ts +6 -0
- package/dist/web/index.js +7 -0
- package/dist/web/layout-editor-body.d.ts +14 -0
- package/dist/web/layout-editor-body.js +142 -0
- package/dist/web/layout-editor.d.ts +2 -0
- package/dist/web/layout-editor.js +41 -0
- package/dist/web/layout-html.d.ts +7 -0
- package/dist/web/layout-html.js +81 -0
- package/dist/web/layout-rails.d.ts +18 -0
- package/dist/web/layout-rails.js +107 -0
- package/dist/web/layout-types.d.ts +62 -0
- package/dist/web/layout-types.js +1 -0
- package/dist/web/layout-users.d.ts +3 -0
- package/dist/web/layout-users.js +8 -0
- package/dist/web/mail-dialogs.d.ts +29 -0
- package/dist/web/mail-dialogs.js +15 -0
- package/dist/web/mail-draft.d.ts +17 -0
- package/dist/web/mail-draft.js +132 -0
- package/dist/web/mail-edit-center.d.ts +17 -0
- package/dist/web/mail-edit-center.js +11 -0
- package/dist/web/mail-editor-body.d.ts +18 -0
- package/dist/web/mail-editor-body.js +193 -0
- package/dist/web/mail-editor.d.ts +2 -0
- package/dist/web/mail-editor.js +71 -0
- package/dist/web/mail-footer.d.ts +21 -0
- package/dist/web/mail-footer.js +44 -0
- package/dist/web/mail-header.d.ts +23 -0
- package/dist/web/mail-header.js +16 -0
- package/dist/web/mail-left-rail.d.ts +18 -0
- package/dist/web/mail-left-rail.js +53 -0
- package/dist/web/mail-origin-bar.d.ts +17 -0
- package/dist/web/mail-origin-bar.js +84 -0
- package/dist/web/mail-pdf-link-modal.d.ts +11 -0
- package/dist/web/mail-pdf-link-modal.js +54 -0
- package/dist/web/mail-preview-side.d.ts +12 -0
- package/dist/web/mail-preview-side.js +48 -0
- package/dist/web/mail-preview.d.ts +20 -0
- package/dist/web/mail-preview.js +43 -0
- package/dist/web/mail-right-rail.d.ts +19 -0
- package/dist/web/mail-right-rail.js +47 -0
- package/dist/web/mail-types.d.ts +112 -0
- package/dist/web/mail-types.js +1 -0
- package/dist/web/mail-upload-modal.d.ts +16 -0
- package/dist/web/mail-upload-modal.js +56 -0
- package/dist/web/mail-versions.d.ts +6 -0
- package/dist/web/mail-versions.js +43 -0
- package/dist/web/origin-bar.d.ts +13 -0
- package/dist/web/origin-bar.js +82 -0
- package/dist/web/overrides-drawer.d.ts +9 -0
- package/dist/web/overrides-drawer.js +19 -0
- package/dist/web/overview-row.d.ts +21 -0
- package/dist/web/overview-row.js +47 -0
- package/dist/web/page-canvas.d.ts +18 -0
- package/dist/web/page-canvas.js +38 -0
- package/dist/web/pdf-meta.d.ts +23 -0
- package/dist/web/pdf-meta.js +101 -0
- package/dist/web/pdf-preview.d.ts +4 -0
- package/dist/web/pdf-preview.js +99 -0
- package/dist/web/pdf-template-editor.d.ts +8 -0
- package/dist/web/pdf-template-editor.js +326 -0
- package/dist/web/placeholder-panel.d.ts +21 -0
- package/dist/web/placeholder-panel.js +35 -0
- package/dist/web/preview-record-picker.d.ts +12 -0
- package/dist/web/preview-record-picker.js +7 -0
- package/dist/web/rail.d.ts +18 -0
- package/dist/web/rail.js +14 -0
- package/dist/web/settings-panel.d.ts +17 -0
- package/dist/web/settings-panel.js +25 -0
- package/dist/web/status-label.d.ts +7 -0
- package/dist/web/status-label.js +18 -0
- package/dist/web/template-rows.d.ts +9 -0
- package/dist/web/template-rows.js +34 -0
- package/dist/web/types.d.ts +103 -0
- package/dist/web/types.js +3 -0
- package/dist/web/use-insert-targets.d.ts +9 -0
- package/dist/web/use-insert-targets.js +17 -0
- package/dist/web/use-mail-persistence.d.ts +15 -0
- package/dist/web/use-mail-persistence.js +49 -0
- package/dist/web/use-overview-rows.d.ts +7 -0
- package/dist/web/use-overview-rows.js +19 -0
- package/dist/web/use-settled-value.d.ts +2 -0
- package/dist/web/use-settled-value.js +18 -0
- package/dist/web/use-test-mail.d.ts +15 -0
- package/dist/web/use-test-mail.js +27 -0
- package/dist/web/used-by-label.d.ts +9 -0
- package/dist/web/used-by-label.js +32 -0
- package/package.json +63 -4
- package/src/changes.json +20 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
export const EMPTY_DRAFT = {
|
|
2
|
+
subject: "",
|
|
3
|
+
previewText: "",
|
|
4
|
+
layoutSlug: "",
|
|
5
|
+
category: "",
|
|
6
|
+
body: "",
|
|
7
|
+
};
|
|
8
|
+
export const PLACEHOLDER_SYMBOL = {
|
|
9
|
+
text: "T",
|
|
10
|
+
money: "€",
|
|
11
|
+
date: "D",
|
|
12
|
+
link: "↗",
|
|
13
|
+
list: "≡",
|
|
14
|
+
};
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
// Stored JSON columns come back as a JSON string or an already parsed object.
|
|
19
|
+
export function parseJsonRecord(value) {
|
|
20
|
+
if (isRecord(value))
|
|
21
|
+
return value;
|
|
22
|
+
if (typeof value !== "string" || value === "")
|
|
23
|
+
return {};
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(value);
|
|
26
|
+
return isRecord(parsed) ? parsed : {};
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function categoryOf(value) {
|
|
33
|
+
return value === "transactional" || value === "marketing" ? value : "";
|
|
34
|
+
}
|
|
35
|
+
function textOf(value) {
|
|
36
|
+
return typeof value === "string" ? value : "";
|
|
37
|
+
}
|
|
38
|
+
export function loadedMailOf(row) {
|
|
39
|
+
if (row === null)
|
|
40
|
+
return { draft: EMPTY_DRAFT, sender: undefined };
|
|
41
|
+
const meta = parseJsonRecord(row.meta);
|
|
42
|
+
return {
|
|
43
|
+
draft: {
|
|
44
|
+
subject: textOf(meta["subject"]),
|
|
45
|
+
previewText: textOf(meta["previewText"]),
|
|
46
|
+
layoutSlug: textOf(meta["layoutSlug"]),
|
|
47
|
+
category: categoryOf(meta["category"]),
|
|
48
|
+
body: row.content,
|
|
49
|
+
},
|
|
50
|
+
sender: meta["sender"],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function buildMeta(draft, sender) {
|
|
54
|
+
return {
|
|
55
|
+
subject: draft.subject,
|
|
56
|
+
...(draft.previewText !== "" && { previewText: draft.previewText }),
|
|
57
|
+
...(draft.layoutSlug !== "" && { layoutSlug: draft.layoutSlug }),
|
|
58
|
+
...(draft.category !== "" && { category: draft.category }),
|
|
59
|
+
...(sender !== undefined && { sender }),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function isDraftDirty(current, saved) {
|
|
63
|
+
return (current.subject !== saved.subject ||
|
|
64
|
+
current.previewText !== saved.previewText ||
|
|
65
|
+
current.layoutSlug !== saved.layoutSlug ||
|
|
66
|
+
current.category !== saved.category ||
|
|
67
|
+
current.body !== saved.body);
|
|
68
|
+
}
|
|
69
|
+
export function parseLinkedResources(value) {
|
|
70
|
+
return Object.entries(parseJsonRecord(value)).flatMap(([key, entry]) => {
|
|
71
|
+
if (typeof entry === "string")
|
|
72
|
+
return [{ kind: "image", key, fileRefId: entry }];
|
|
73
|
+
if (!isRecord(entry))
|
|
74
|
+
return [];
|
|
75
|
+
const fileName = textOf(entry["fileName"]);
|
|
76
|
+
if (entry["kind"] === "file") {
|
|
77
|
+
return [{ kind: "file", key, fileRefId: textOf(entry["fileRefId"]), fileName }];
|
|
78
|
+
}
|
|
79
|
+
if (entry["kind"] === "rendered-pdf") {
|
|
80
|
+
return [{ kind: "pdf", key, templateSlug: textOf(entry["templateSlug"]), fileName }];
|
|
81
|
+
}
|
|
82
|
+
return [];
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const KEY_MAX_LENGTH = 64;
|
|
86
|
+
// Resource keys allow letters, digits, dot, dash and underscore; a taken key gets a numeric suffix.
|
|
87
|
+
export function uniqueResourceKey(source, taken) {
|
|
88
|
+
const withoutExtension = source.replace(/\.[A-Za-z0-9]{1,5}$/, "");
|
|
89
|
+
const base = withoutExtension
|
|
90
|
+
.toLowerCase()
|
|
91
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
92
|
+
.replace(/^-+|-+$/g, "")
|
|
93
|
+
.slice(0, KEY_MAX_LENGTH - 4) || "resource";
|
|
94
|
+
if (!taken.includes(base))
|
|
95
|
+
return base;
|
|
96
|
+
for (let n = 2;; n += 1) {
|
|
97
|
+
const candidate = `${base}-${n}`;
|
|
98
|
+
if (!taken.includes(candidate))
|
|
99
|
+
return candidate;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export function lastSlugSegment(slug) {
|
|
103
|
+
const parts = slug.split(":");
|
|
104
|
+
return parts[parts.length - 1] ?? slug;
|
|
105
|
+
}
|
|
106
|
+
export function formatKilobytes(bytes) {
|
|
107
|
+
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
108
|
+
}
|
|
109
|
+
// Labels for the chip editors; list item fields are addressed as "list.item".
|
|
110
|
+
export function variableLabelMap(variables) {
|
|
111
|
+
const labels = {};
|
|
112
|
+
for (const [name, variable] of Object.entries(variables)) {
|
|
113
|
+
if (variable.label !== undefined)
|
|
114
|
+
labels[name] = variable.label;
|
|
115
|
+
for (const [itemName, item] of Object.entries(variable.item ?? {})) {
|
|
116
|
+
if (item.label !== undefined)
|
|
117
|
+
labels[`${name}.${itemName}`] = item.label;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return labels;
|
|
121
|
+
}
|
|
122
|
+
export function displayValue(t, value) {
|
|
123
|
+
if (Array.isArray(value))
|
|
124
|
+
return t("documentDesigner.mail.preview.entries", { count: value.length });
|
|
125
|
+
if (isRecord(value)) {
|
|
126
|
+
const { amount, currency } = value;
|
|
127
|
+
if (typeof amount === "number" && typeof currency === "string")
|
|
128
|
+
return `${amount} ${currency}`;
|
|
129
|
+
return JSON.stringify(value);
|
|
130
|
+
}
|
|
131
|
+
return String(value ?? "");
|
|
132
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type DocumentResourcePreview } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import type { MailDraft } from "./mail-types.js";
|
|
4
|
+
import type { InsertTargets } from "./use-insert-targets.js";
|
|
5
|
+
export type EditorVocabulary = {
|
|
6
|
+
readonly names: readonly string[];
|
|
7
|
+
readonly labels: Readonly<Record<string, string>>;
|
|
8
|
+
readonly system: readonly string[];
|
|
9
|
+
};
|
|
10
|
+
export declare function MailEditCenter({ draft, vocabulary, resourcePreviews, insert, readOnly, onChange, }: {
|
|
11
|
+
readonly draft: MailDraft;
|
|
12
|
+
readonly vocabulary: EditorVocabulary;
|
|
13
|
+
readonly resourcePreviews: Readonly<Record<string, DocumentResourcePreview>>;
|
|
14
|
+
readonly insert: InsertTargets;
|
|
15
|
+
readonly readOnly: boolean;
|
|
16
|
+
readonly onChange: (patch: Partial<MailDraft>) => void;
|
|
17
|
+
}): ReactNode;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// @runtime client
|
|
3
|
+
import { DocumentContentEditor, MAIL_DOCUMENT_BLOCKS, PlaceholderLineEditor, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
|
|
4
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
function MetaLine({ label, testId, onFocusCapture, children, }) {
|
|
6
|
+
return (_jsxs("div", { className: "grid grid-cols-[96px_minmax(0,1fr)] items-center gap-2", "data-testid": testId, onFocusCapture: onFocusCapture, children: [_jsx("span", { className: "text-sm text-muted-foreground", children: label }), children] }));
|
|
7
|
+
}
|
|
8
|
+
export function MailEditCenter({ draft, vocabulary, resourcePreviews, insert, readOnly, onChange, }) {
|
|
9
|
+
const t = useTranslation();
|
|
10
|
+
return (_jsxs("div", { className: "flex min-w-0 flex-col gap-3", "data-testid": "mail-editor-center", children: [_jsxs("div", { className: "flex flex-col gap-2 rounded-md border bg-muted p-3", children: [_jsx(MetaLine, { label: t("documentDesigner.mail.meta.subject"), testId: "mail-meta-subject", onFocusCapture: insert.markFocused("subject"), children: _jsx(PlaceholderLineEditor, { id: "mail-subject", value: draft.subject, onChange: (subject) => onChange({ subject }), variableLabels: vocabulary.labels, systemVariables: vocabulary.system, readOnly: readOnly, onEditorReady: insert.register("subject"), "aria-label": t("documentDesigner.mail.meta.subject") }) }), _jsx(MetaLine, { label: t("documentDesigner.mail.meta.previewText"), testId: "mail-meta-preview-text", onFocusCapture: insert.markFocused("previewText"), children: _jsx(PlaceholderLineEditor, { id: "mail-preview-text", value: draft.previewText, onChange: (previewText) => onChange({ previewText }), variableLabels: vocabulary.labels, systemVariables: vocabulary.system, readOnly: readOnly, onEditorReady: insert.register("previewText"), "aria-label": t("documentDesigner.mail.meta.previewText") }) })] }), _jsx("div", { onFocusCapture: insert.markFocused("body"), "data-testid": "mail-content", children: _jsx(DocumentContentEditor, { id: "mail-body", value: draft.body, onChange: (body) => onChange({ body }), variables: vocabulary.names, readOnly: readOnly, blocks: MAIL_DOCUMENT_BLOCKS, variableLabels: vocabulary.labels, systemVariables: vocabulary.system, resourcePreviews: resourcePreviews, chrome: "canvas", onEditorReady: insert.register("body") }) })] }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { MailValidation, TemplateRowView } from "./mail-types.js";
|
|
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
|
+
readonly onBack: () => void;
|
|
16
|
+
};
|
|
17
|
+
export declare function placeholderNames(validation: MailValidation | null): string[];
|
|
18
|
+
export declare function MailEditorBody(props: MailEditorBodyProps): ReactNode;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// @runtime client
|
|
3
|
+
import { TemplateResolverHandlers, TemplateResolverQueries, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
|
|
4
|
+
import { dispatcherErrorText, useMutation, usePrimitives, useQuery, useTranslation, } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { fileUrl } from "./file-name.js";
|
|
7
|
+
import { MailConfirmDialogs } from "./mail-dialogs.js";
|
|
8
|
+
import { buildMeta, isDraftDirty, loadedMailOf, parseJsonRecord, parseLinkedResources, variableLabelMap, } from "./mail-draft.js";
|
|
9
|
+
import { MailEditCenter } from "./mail-edit-center.js";
|
|
10
|
+
import { MailFooter } from "./mail-footer.js";
|
|
11
|
+
import { MailHeader } from "./mail-header.js";
|
|
12
|
+
import { PlaceholderSection, ResourceSection, SystemPlaceholderSection } from "./mail-left-rail.js";
|
|
13
|
+
import { MailOriginBar } from "./mail-origin-bar.js";
|
|
14
|
+
import { PdfLinkModal } from "./mail-pdf-link-modal.js";
|
|
15
|
+
import { MailPreviewMode } from "./mail-preview.js";
|
|
16
|
+
import { AttachmentSection, PropertiesSection, UsageSection } from "./mail-right-rail.js";
|
|
17
|
+
import { UploadModal } from "./mail-upload-modal.js";
|
|
18
|
+
import { VersionsSection } from "./mail-versions.js";
|
|
19
|
+
import { useInsertTargets } from "./use-insert-targets.js";
|
|
20
|
+
import { useMailPersistence } from "./use-mail-persistence.js";
|
|
21
|
+
import { useOverviewRows } from "./use-overview-rows.js";
|
|
22
|
+
import { useSettledValue } from "./use-settled-value.js";
|
|
23
|
+
import { useTestMail } from "./use-test-mail.js";
|
|
24
|
+
function sortedUnique(values) {
|
|
25
|
+
return [...new Set(values)];
|
|
26
|
+
}
|
|
27
|
+
export function placeholderNames(validation) {
|
|
28
|
+
if (validation === null)
|
|
29
|
+
return [];
|
|
30
|
+
const own = Object.entries(validation.variables).flatMap(([name, variable]) => [
|
|
31
|
+
name,
|
|
32
|
+
...Object.keys(variable.item ?? {}).map((item) => `${name}.${item}`),
|
|
33
|
+
]);
|
|
34
|
+
return sortedUnique([...own, ...validation.systemPlaceholders]);
|
|
35
|
+
}
|
|
36
|
+
export function MailEditorBody(props) {
|
|
37
|
+
const { slug, locale, title, isSystemAdmin, row, onReload, onRefreshRow } = props;
|
|
38
|
+
const t = useTranslation();
|
|
39
|
+
const { Banner } = usePrimitives();
|
|
40
|
+
const [baseline] = useState(() => loadedMailOf(row));
|
|
41
|
+
const [draft, setDraft] = useState(baseline.draft);
|
|
42
|
+
const [settled, settle] = useSettledValue(baseline.draft);
|
|
43
|
+
const [mode, setMode] = useState("edit");
|
|
44
|
+
const [creating, setCreating] = useState(false);
|
|
45
|
+
const [dialog, setDialog] = useState({ kind: "none" });
|
|
46
|
+
const [uploadOpen, setUploadOpen] = useState(false);
|
|
47
|
+
const [pdfOpen, setPdfOpen] = useState(false);
|
|
48
|
+
const [resourceError, setResourceError] = useState(null);
|
|
49
|
+
const [editorEpoch, setEditorEpoch] = useState(0);
|
|
50
|
+
const insert = useInsertTargets();
|
|
51
|
+
const persistence = useMailPersistence({
|
|
52
|
+
slug,
|
|
53
|
+
locale,
|
|
54
|
+
title,
|
|
55
|
+
isSystemAdmin,
|
|
56
|
+
row,
|
|
57
|
+
sender: baseline.sender,
|
|
58
|
+
});
|
|
59
|
+
const testMail = useTestMail();
|
|
60
|
+
const resourceRemove = useMutation(TemplateResolverHandlers.resourceRemove);
|
|
61
|
+
const layouts = useOverviewRows("mail-layout");
|
|
62
|
+
const pdfTemplates = useOverviewRows("document-pdf");
|
|
63
|
+
const locked = row === null && !creating;
|
|
64
|
+
const variableSchema = parseJsonRecord(row?.variableSchema);
|
|
65
|
+
const validation = useQuery(TemplateResolverQueries.validate, {
|
|
66
|
+
slug,
|
|
67
|
+
kind: "mail",
|
|
68
|
+
contentFormat: "html",
|
|
69
|
+
content: settled.body,
|
|
70
|
+
meta: buildMeta(settled, baseline.sender),
|
|
71
|
+
variableSchema,
|
|
72
|
+
locale,
|
|
73
|
+
scope: isSystemAdmin ? "system" : "tenant",
|
|
74
|
+
}).data;
|
|
75
|
+
const update = (patch) => {
|
|
76
|
+
const next = { ...draft, ...patch };
|
|
77
|
+
setDraft(next);
|
|
78
|
+
settle(next);
|
|
79
|
+
};
|
|
80
|
+
const reset = () => {
|
|
81
|
+
setDraft(baseline.draft);
|
|
82
|
+
settle(baseline.draft);
|
|
83
|
+
setEditorEpoch((epoch) => epoch + 1);
|
|
84
|
+
persistence.clearError();
|
|
85
|
+
};
|
|
86
|
+
const dirty = isDraftDirty(draft, baseline.draft);
|
|
87
|
+
const isActiveTenantRow = row?.scope === "tenant" && row.status === "active";
|
|
88
|
+
const busy = persistence.busy;
|
|
89
|
+
const canSave = !locked && !busy && validation?.canSave === true && (dirty || creating);
|
|
90
|
+
const canPublish = !locked &&
|
|
91
|
+
!busy &&
|
|
92
|
+
validation?.canPublish === true &&
|
|
93
|
+
(dirty || creating || row?.status !== "active");
|
|
94
|
+
const persist = async (publish) => persistence.save(draft, publish);
|
|
95
|
+
const saveAndReload = async (publish) => {
|
|
96
|
+
if (await persist(publish))
|
|
97
|
+
await onReload();
|
|
98
|
+
};
|
|
99
|
+
const requestSave = () => {
|
|
100
|
+
if (isActiveTenantRow)
|
|
101
|
+
setDialog({ kind: "activeSave" });
|
|
102
|
+
else
|
|
103
|
+
void saveAndReload(false);
|
|
104
|
+
};
|
|
105
|
+
const perform = (target) => {
|
|
106
|
+
if (target.kind === "locale")
|
|
107
|
+
props.onLocaleChange(target.locale);
|
|
108
|
+
else
|
|
109
|
+
props.onBack();
|
|
110
|
+
};
|
|
111
|
+
const leave = (target) => {
|
|
112
|
+
if (target.kind === "locale" && target.locale === locale)
|
|
113
|
+
return;
|
|
114
|
+
if (dirty)
|
|
115
|
+
setDialog({ kind: "leave", target });
|
|
116
|
+
else
|
|
117
|
+
perform(target);
|
|
118
|
+
};
|
|
119
|
+
const resources = parseLinkedResources(row?.linkedResources);
|
|
120
|
+
const pdfTitles = new Map(pdfTemplates.rows.map((template) => [template.slug, template.title]));
|
|
121
|
+
const resourcePreviews = Object.fromEntries(resources.flatMap((resource) => resource.kind === "image"
|
|
122
|
+
? [
|
|
123
|
+
[
|
|
124
|
+
resource.key,
|
|
125
|
+
{
|
|
126
|
+
src: fileUrl(resource.fileRefId),
|
|
127
|
+
caption: t("documentDesigner.mail.resource.caption", { key: resource.key }),
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
]
|
|
131
|
+
: []));
|
|
132
|
+
const uploadTemplateId = row?.scope === "tenant" ? row.id : null;
|
|
133
|
+
const takenKeys = resources.map((resource) => resource.key);
|
|
134
|
+
const removeResource = async (key) => {
|
|
135
|
+
if (uploadTemplateId === null)
|
|
136
|
+
return;
|
|
137
|
+
setResourceError(null);
|
|
138
|
+
const result = await resourceRemove.mutate({ templateId: uploadTemplateId, key });
|
|
139
|
+
if (result.isSuccess)
|
|
140
|
+
await onRefreshRow();
|
|
141
|
+
else
|
|
142
|
+
setResourceError(dispatcherErrorText(result.error, t));
|
|
143
|
+
};
|
|
144
|
+
const sendTestMail = () => {
|
|
145
|
+
void testMail.send({
|
|
146
|
+
slug,
|
|
147
|
+
locale,
|
|
148
|
+
content: draft.body,
|
|
149
|
+
meta: buildMeta(draft, baseline.sender),
|
|
150
|
+
variableSchema,
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
const names = placeholderNames(validation ?? null);
|
|
154
|
+
const vocabulary = {
|
|
155
|
+
names,
|
|
156
|
+
labels: variableLabelMap(validation?.variables ?? {}),
|
|
157
|
+
system: validation?.systemPlaceholders ?? [],
|
|
158
|
+
};
|
|
159
|
+
const rails = { validation: validation ?? null, row, slug, draft, onChange: update };
|
|
160
|
+
return (_jsxs("div", { className: "flex flex-col gap-4 p-6", "data-testid": "document-designer-mail-editor", children: [_jsx(MailHeader, { title: title, row: row, locales: props.locales, locale: locale, mode: mode, testPending: testMail.pending, actions: {
|
|
161
|
+
busy,
|
|
162
|
+
canSave,
|
|
163
|
+
canPublish,
|
|
164
|
+
onSave: requestSave,
|
|
165
|
+
onPublish: () => void saveAndReload(true),
|
|
166
|
+
}, onBack: () => leave({ kind: "back" }), onLocale: (next) => leave({ kind: "locale", locale: next }), onMode: setMode, onTestMail: sendTestMail }), 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 })), resourceError !== null && (_jsx(Banner, { variant: "error", testId: "mail-resource-error", children: resourceError })), _jsx(MailOriginBar, { row: row, title: title, systemVersion: props.systemVersion, slug: slug, locale: locale, fallbackLocale: props.fallbackLocale, isSystemAdmin: isSystemAdmin, creating: creating, onCreateBlank: () => setCreating(true), onReload: () => void onReload() }), mode === "edit" ? (_jsxs("div", { className: "grid items-start gap-4 lg:grid-cols-[260px_minmax(0,1fr)_300px]", children: [_jsxs("div", { className: "flex max-h-[70vh] flex-col overflow-y-auto", children: [_jsx(PlaceholderSection, { validation: rails.validation, onInsert: insert.insertPlaceholder }), _jsx(SystemPlaceholderSection, { validation: rails.validation, onInsert: insert.insertPlaceholder }), _jsx(ResourceSection, { resources: resources, pdfTitles: pdfTitles, canUpload: uploadTemplateId !== null, onUpload: () => setUploadOpen(true), onInsert: insert.insertResource, onRemove: (key) => setDialog({ kind: "remove", key }) })] }), _jsx(MailEditCenter, { draft: draft, vocabulary: vocabulary, resourcePreviews: resourcePreviews, insert: insert, readOnly: locked, onChange: update }, editorEpoch), _jsxs("div", { className: "flex max-h-[70vh] flex-col overflow-y-auto", children: [_jsx(PropertiesSection, { draft: draft, layouts: layouts.rows, onChange: update }), _jsx(AttachmentSection, { attachments: resources.filter((resource) => resource.kind !== "image"), 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: {
|
|
167
|
+
slug,
|
|
168
|
+
kind: "mail",
|
|
169
|
+
locale,
|
|
170
|
+
content: settled.body,
|
|
171
|
+
meta: buildMeta(settled, baseline.sender),
|
|
172
|
+
variableSchema,
|
|
173
|
+
}, validation: validation ?? null, slug: slug, locale: locale, testMail: { pending: testMail.pending, onSend: sendTestMail } })), _jsx(MailFooter, { validation: validation ?? null, previewMode: mode === "preview", saveError: persistence.error, modifiedAt: row?.modifiedAt ?? null, dirty: dirty, busy: busy, canSave: canSave, canPublish: canPublish, onDiscard: () => setDialog({ kind: "discard" }), onSave: requestSave, onPublish: () => void saveAndReload(true) }), uploadTemplateId !== null && (_jsxs(_Fragment, { children: [_jsx(UploadModal, { open: uploadOpen, onOpenChange: setUploadOpen, target: {
|
|
174
|
+
templateId: uploadTemplateId,
|
|
175
|
+
takenKeys,
|
|
176
|
+
onUploaded: (uploaded) => {
|
|
177
|
+
if (uploaded.purpose === "image")
|
|
178
|
+
insert.insertResource(uploaded.key);
|
|
179
|
+
void onRefreshRow();
|
|
180
|
+
},
|
|
181
|
+
} }), _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: () => {
|
|
182
|
+
reset();
|
|
183
|
+
setDialog({ kind: "none" });
|
|
184
|
+
}, onSaveDraft: async () => {
|
|
185
|
+
await saveAndReload(false);
|
|
186
|
+
}, onRemove: removeResource, onLeaveSave: async (target) => {
|
|
187
|
+
if (await persist(false))
|
|
188
|
+
perform(target);
|
|
189
|
+
}, onLeaveDiscard: (target) => {
|
|
190
|
+
setDialog({ kind: "none" });
|
|
191
|
+
perform(target);
|
|
192
|
+
} })] }));
|
|
193
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// @runtime client
|
|
3
|
+
import { TemplateResolverQueries } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
|
|
4
|
+
import { useNav, useQuery, useUserRoles } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import { LoadingState } from "@cosmicdrift/kumiko-renderer-web";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { DEFAULT_TEMPLATE_LOCALES, PLATFORM_TEMPLATES_SCREEN_ID, TEMPLATES_SCREEN_ID, } from "../constants.js";
|
|
8
|
+
import { MailEditorBody } from "./mail-editor-body.js";
|
|
9
|
+
import { useOverviewRows } from "./use-overview-rows.js";
|
|
10
|
+
function defaultLocaleOf(appLocales, rowLocales) {
|
|
11
|
+
const preferred = appLocales[0] ?? DEFAULT_TEMPLATE_LOCALES[0] ?? "de";
|
|
12
|
+
if (rowLocales.length === 0 || rowLocales.includes(preferred))
|
|
13
|
+
return preferred;
|
|
14
|
+
return rowLocales[0] ?? preferred;
|
|
15
|
+
}
|
|
16
|
+
function fallbackLocaleOf(locale, rowLocales) {
|
|
17
|
+
const others = rowLocales.filter((candidate) => candidate !== locale);
|
|
18
|
+
const preferred = DEFAULT_TEMPLATE_LOCALES[0];
|
|
19
|
+
return (preferred !== undefined && others.includes(preferred) ? preferred : others[0]) ?? null;
|
|
20
|
+
}
|
|
21
|
+
function shownSummaryOf(summaries, slug, isSystemAdmin) {
|
|
22
|
+
const ofSlug = summaries.filter((summary) => summary.slug === slug);
|
|
23
|
+
const tenant = ofSlug.find((summary) => summary.scope === "tenant");
|
|
24
|
+
const system = ofSlug.find((summary) => summary.scope === "system");
|
|
25
|
+
return (isSystemAdmin ? system : (tenant ?? system)) ?? null;
|
|
26
|
+
}
|
|
27
|
+
function MailEditorData({ slug, locale, locales, overviewRow, isSystemAdmin, onLocaleChange, refreshOverview, }) {
|
|
28
|
+
const nav = useNav();
|
|
29
|
+
const [generation, setGeneration] = useState(0);
|
|
30
|
+
const list = useQuery(TemplateResolverQueries.list, {
|
|
31
|
+
kind: "mail",
|
|
32
|
+
locale,
|
|
33
|
+
includeSystem: true,
|
|
34
|
+
});
|
|
35
|
+
const shown = shownSummaryOf(list.data ?? [], slug, isSystemAdmin);
|
|
36
|
+
const stored = useQuery(TemplateResolverQueries.findById, { id: shown?.id ?? "" }, { enabled: shown !== null });
|
|
37
|
+
const row = stored.data !== null && stored.data.id === shown?.id ? stored.data : null;
|
|
38
|
+
// A changed row id keeps the previous row in `stored` until the new one arrives.
|
|
39
|
+
if (list.data === null || (shown !== null && row === null))
|
|
40
|
+
return _jsx(LoadingState, {});
|
|
41
|
+
const reload = async () => {
|
|
42
|
+
await Promise.all([list.refetch(), refreshOverview()]);
|
|
43
|
+
await stored.refetch();
|
|
44
|
+
setGeneration((current) => current + 1);
|
|
45
|
+
};
|
|
46
|
+
const rowLocales = overviewRow?.locales ?? [];
|
|
47
|
+
return (_jsx(MailEditorBody, { slug: slug, locale: locale, locales: locales, title: overviewRow?.title ?? slug, isSystemAdmin: isSystemAdmin, row: row, systemVersion: overviewRow?.systemVersion ?? null, fallbackLocale: fallbackLocaleOf(locale, rowLocales), onLocaleChange: onLocaleChange, onReload: reload, onRefreshRow: async () => {
|
|
48
|
+
await stored.refetch();
|
|
49
|
+
}, onBack: () => nav.navigate({
|
|
50
|
+
screenId: isSystemAdmin ? PLATFORM_TEMPLATES_SCREEN_ID : TEMPLATES_SCREEN_ID,
|
|
51
|
+
}) }, `${locale}|${generation}|${row?.id ?? "none"}`));
|
|
52
|
+
}
|
|
53
|
+
function MailEditorEntry({ appLocales }) {
|
|
54
|
+
const nav = useNav();
|
|
55
|
+
const roles = useUserRoles();
|
|
56
|
+
const slug = nav.searchParams["slug"] ?? "";
|
|
57
|
+
const overview = useOverviewRows("mail", slug);
|
|
58
|
+
const [picked, setPicked] = useState(nav.searchParams["locale"] ?? null);
|
|
59
|
+
if (!overview.loaded)
|
|
60
|
+
return _jsx(LoadingState, {});
|
|
61
|
+
const overviewRow = overview.rows.find((candidate) => candidate.slug === slug) ?? null;
|
|
62
|
+
const rowLocales = overviewRow?.locales ?? [];
|
|
63
|
+
const locale = picked ?? defaultLocaleOf(appLocales, rowLocales);
|
|
64
|
+
const locales = [...new Set([...appLocales, ...rowLocales])];
|
|
65
|
+
return (_jsx(MailEditorData, { slug: slug, locale: locale, locales: locales, overviewRow: overviewRow, isSystemAdmin: roles?.includes("SystemAdmin") === true, onLocaleChange: setPicked, refreshOverview: overview.refetch }, locale));
|
|
66
|
+
}
|
|
67
|
+
export function createMailEditor(appLocales) {
|
|
68
|
+
return function MailEditor() {
|
|
69
|
+
return _jsx(MailEditorEntry, { appLocales: appLocales });
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { MailValidation } from "./mail-types.js";
|
|
3
|
+
type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
|
|
4
|
+
export declare function footerMessage(t: Translate, validation: MailValidation, previewMode: boolean): {
|
|
5
|
+
readonly tone: "bad" | "warn" | "ok";
|
|
6
|
+
readonly text: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function MailFooter({ validation, previewMode, saveError, modifiedAt, dirty, busy, canSave, canPublish, onDiscard, onSave, onPublish, }: {
|
|
9
|
+
readonly validation: MailValidation | null;
|
|
10
|
+
readonly previewMode: boolean;
|
|
11
|
+
readonly saveError: string | null;
|
|
12
|
+
readonly modifiedAt: string | null;
|
|
13
|
+
readonly dirty: boolean;
|
|
14
|
+
readonly busy: boolean;
|
|
15
|
+
readonly canSave: boolean;
|
|
16
|
+
readonly canPublish: boolean;
|
|
17
|
+
readonly onDiscard: () => void;
|
|
18
|
+
readonly onSave: () => void;
|
|
19
|
+
readonly onPublish: () => void;
|
|
20
|
+
}): ReactNode;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// @runtime client
|
|
3
|
+
import { formatWhen, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
function issueText(t, issue) {
|
|
5
|
+
const translated = t(issue.i18nKey, issue.params);
|
|
6
|
+
return translated === issue.i18nKey ? issue.code : translated;
|
|
7
|
+
}
|
|
8
|
+
function missingLabels(validation) {
|
|
9
|
+
return validation.missingRequired
|
|
10
|
+
.map((name) => validation.variables[name]?.label ?? name)
|
|
11
|
+
.join(", ");
|
|
12
|
+
}
|
|
13
|
+
// The footer sentence: missing required placeholders first, then other blocking issues,
|
|
14
|
+
// then hints (not-yet-uploaded resources), else "ready".
|
|
15
|
+
export function footerMessage(t, validation, previewMode) {
|
|
16
|
+
if (validation.missingRequired.length > 0) {
|
|
17
|
+
return {
|
|
18
|
+
tone: "bad",
|
|
19
|
+
text: t(previewMode
|
|
20
|
+
? "documentDesigner.mail.footer.missingPreview"
|
|
21
|
+
: "documentDesigner.mail.footer.missing", { names: missingLabels(validation) }),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const blocking = [...validation.issues, ...validation.publishIssues][0];
|
|
25
|
+
if (blocking !== undefined)
|
|
26
|
+
return { tone: "bad", text: issueText(t, blocking) };
|
|
27
|
+
const warning = validation.warnings[0];
|
|
28
|
+
if (warning !== undefined) {
|
|
29
|
+
const translated = t(warning.i18nKey, { detail: warning.detail });
|
|
30
|
+
return { tone: "warn", text: translated === warning.i18nKey ? warning.code : translated };
|
|
31
|
+
}
|
|
32
|
+
return { tone: "ok", text: t("documentDesigner.mail.footer.ok") };
|
|
33
|
+
}
|
|
34
|
+
const TONE_CLASS = {
|
|
35
|
+
bad: "text-status-bad",
|
|
36
|
+
warn: "text-status-warn",
|
|
37
|
+
ok: "text-status-ok",
|
|
38
|
+
};
|
|
39
|
+
export function MailFooter({ validation, previewMode, saveError, modifiedAt, dirty, busy, canSave, canPublish, onDiscard, onSave, onPublish, }) {
|
|
40
|
+
const t = useTranslation();
|
|
41
|
+
const { Banner, Button, Text } = usePrimitives();
|
|
42
|
+
const message = validation === null ? null : footerMessage(t, validation, previewMode);
|
|
43
|
+
return (_jsxs("div", { className: "flex flex-wrap items-center gap-3 border-t pt-3", "data-testid": "mail-editor-footer", children: [saveError !== null ? (_jsx(Banner, { variant: "error", testId: "mail-editor-save-error", children: saveError })) : (_jsx("span", { className: `text-sm ${message === null ? "text-muted-foreground" : TONE_CLASS[message.tone]}`, "data-testid": "mail-editor-footer-message", "data-tone": message?.tone, children: message === null ? t("documentDesigner.editor.footer.checking") : message.text })), _jsx("div", { className: "grow" }), modifiedAt !== null && (_jsx(Text, { variant: "muted", testId: "mail-editor-saved-at", children: t("documentDesigner.mail.footer.savedAt", { when: formatWhen(modifiedAt) }) })), dirty && (_jsx(Button, { variant: "ghost", onClick: onDiscard, testId: "document-designer-discard", children: t("documentDesigner.editor.discard") })), _jsx(Button, { variant: "secondary", disabled: !canSave, loading: busy, onClick: onSave, testId: "document-designer-save", children: t("documentDesigner.editor.save") }), _jsx(Button, { disabled: !canPublish, onClick: onPublish, testId: "document-designer-publish", children: t("documentDesigner.editor.publish") })] }));
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { TemplateRowView } from "./mail-types.js";
|
|
3
|
+
export type EditorMode = "edit" | "preview";
|
|
4
|
+
export type HeaderActions = {
|
|
5
|
+
readonly busy: boolean;
|
|
6
|
+
readonly canSave: boolean;
|
|
7
|
+
readonly canPublish: boolean;
|
|
8
|
+
readonly onSave: () => void;
|
|
9
|
+
readonly onPublish: () => void;
|
|
10
|
+
};
|
|
11
|
+
export declare function MailHeader({ title, row, locales, locale, mode, testPending, actions, onBack, onLocale, onMode, onTestMail, }: {
|
|
12
|
+
readonly title: string;
|
|
13
|
+
readonly row: TemplateRowView | null;
|
|
14
|
+
readonly locales: readonly string[];
|
|
15
|
+
readonly locale: string;
|
|
16
|
+
readonly mode: EditorMode;
|
|
17
|
+
readonly testPending: boolean;
|
|
18
|
+
readonly actions: HeaderActions;
|
|
19
|
+
readonly onBack: () => void;
|
|
20
|
+
readonly onLocale: (locale: string) => void;
|
|
21
|
+
readonly onMode: (mode: EditorMode) => void;
|
|
22
|
+
readonly onTestMail: () => void;
|
|
23
|
+
}): ReactNode;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// @runtime client
|
|
3
|
+
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import { ModeSwitch, StatusBadge } from "@cosmicdrift/kumiko-renderer-web";
|
|
5
|
+
import { asRowStatus, statusLabel, statusTone } from "./status-label.js";
|
|
6
|
+
export function MailHeader({ title, row, locales, locale, mode, testPending, actions, onBack, onLocale, onMode, onTestMail, }) {
|
|
7
|
+
const t = useTranslation();
|
|
8
|
+
const { Button, Heading, Link, Text } = usePrimitives();
|
|
9
|
+
const status = row === null ? null : asRowStatus(row.status);
|
|
10
|
+
return (_jsxs("div", { className: "flex flex-col gap-3", "data-testid": "mail-editor-header", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsx(Link, { href: "#", onPress: onBack, testId: "document-designer-back", children: t("documentDesigner.editor.crumb") }), _jsx(Text, { variant: "muted", children: "\u203A" }), _jsx(Heading, { variant: "page", testId: "document-designer-editor-title", children: title }), row !== null && status !== null && (_jsxs(_Fragment, { children: [_jsx(StatusBadge, { tone: statusTone(status), testId: "document-designer-editor-status", children: statusLabel(t, status, row.version) }), _jsx(StatusBadge, { tone: row.scope === "tenant" ? "accent" : "muted", testId: "document-designer-editor-origin", children: t(row.scope === "tenant"
|
|
11
|
+
? "documentDesigner.origin.tenant"
|
|
12
|
+
: "documentDesigner.origin.system") })] })), _jsx("div", { className: "grow" }), _jsx(ModeSwitch, { value: locale, onChange: onLocale, ariaLabel: t("documentDesigner.mail.header.localeSwitch"), testId: "mail-locale-switch", options: locales.map((code) => ({ value: code, label: code })) }), _jsx(ModeSwitch, { value: mode, onChange: onMode, ariaLabel: t("documentDesigner.mail.header.modeSwitch"), testId: "mail-mode-switch", options: [
|
|
13
|
+
{ value: "edit", label: t("documentDesigner.mail.header.edit") },
|
|
14
|
+
{ value: "preview", label: t("documentDesigner.mail.header.preview") },
|
|
15
|
+
] })] }), _jsxs("div", { className: "flex items-center gap-3", "data-testid": "mail-editor-header-actions", children: [_jsx(Button, { variant: "secondary", disabled: testPending, loading: testPending, onClick: onTestMail, testId: "mail-test-mail-header", children: t("documentDesigner.mail.header.testMail") }), _jsx(Button, { variant: "secondary", disabled: !actions.canSave, loading: actions.busy, onClick: actions.onSave, testId: "mail-header-save", children: t("documentDesigner.editor.save") }), _jsx(Button, { disabled: !actions.canPublish, onClick: actions.onPublish, testId: "mail-header-publish", children: t("documentDesigner.editor.publish") })] })] }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { LinkedResourceView, MailValidation } from "./mail-types.js";
|
|
3
|
+
export declare function PlaceholderSection({ validation, onInsert, }: {
|
|
4
|
+
readonly validation: MailValidation | null;
|
|
5
|
+
readonly onInsert: (name: string) => void;
|
|
6
|
+
}): ReactNode;
|
|
7
|
+
export declare function SystemPlaceholderSection({ validation, onInsert, }: {
|
|
8
|
+
readonly validation: MailValidation | null;
|
|
9
|
+
readonly onInsert: (name: string) => void;
|
|
10
|
+
}): ReactNode;
|
|
11
|
+
export declare function ResourceSection({ resources, pdfTitles, canUpload, onUpload, onInsert, onRemove, }: {
|
|
12
|
+
readonly resources: readonly LinkedResourceView[];
|
|
13
|
+
readonly pdfTitles: ReadonlyMap<string, string>;
|
|
14
|
+
readonly canUpload: boolean;
|
|
15
|
+
readonly onUpload: () => void;
|
|
16
|
+
readonly onInsert: (key: string) => void;
|
|
17
|
+
readonly onRemove: (key: string) => void;
|
|
18
|
+
}): ReactNode;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// @runtime client
|
|
3
|
+
import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
4
|
+
import { PLACEHOLDER_SYMBOL } from "./mail-draft.js";
|
|
5
|
+
import { RailHint, RailRow, RailSection } from "./rail.js";
|
|
6
|
+
function placeholderStatus(t, validation, name) {
|
|
7
|
+
if (validation.missingRequired.includes(name))
|
|
8
|
+
return t("documentDesigner.mail.placeholder.missing");
|
|
9
|
+
return validation.used.includes(name) ? "✓" : "–";
|
|
10
|
+
}
|
|
11
|
+
export function PlaceholderSection({ validation, onInsert, }) {
|
|
12
|
+
const t = useTranslation();
|
|
13
|
+
const entries = Object.entries(validation?.variables ?? {});
|
|
14
|
+
return (_jsxs(RailSection, { title: t("documentDesigner.mail.placeholder.title"), testId: "mail-editor-placeholders", children: [validation !== null &&
|
|
15
|
+
entries.map(([name, variable]) => (_jsx(RailRow, { testId: `mail-placeholder-${name}`, symbol: PLACEHOLDER_SYMBOL[variable.type], label: `${variable.label ?? name}${variable.required === true ? " *" : ""}`, bad: validation.missingRequired.includes(name), trailing: placeholderStatus(t, validation, name), onPress: () => onInsert(name) }, name))), _jsx(RailHint, { children: t("documentDesigner.mail.placeholder.hint") })] }));
|
|
16
|
+
}
|
|
17
|
+
function systemLabel(t, name) {
|
|
18
|
+
const key = `documentDesigner.placeholder.${name}`;
|
|
19
|
+
const translated = t(key);
|
|
20
|
+
return translated === key ? name : translated;
|
|
21
|
+
}
|
|
22
|
+
export function SystemPlaceholderSection({ validation, onInsert, }) {
|
|
23
|
+
const t = useTranslation();
|
|
24
|
+
return (_jsx(RailSection, { title: t("documentDesigner.mail.system.title"), testId: "mail-editor-system-placeholders", children: (validation?.systemPlaceholders ?? []).map((name) => (_jsx(RailRow, { testId: `mail-system-${name}`, label: systemLabel(t, name), trailing: validation?.used.includes(name) === true ? "✓" : "–", onPress: () => onInsert(name) }, name))) }));
|
|
25
|
+
}
|
|
26
|
+
function resourceRow(t, resource, pdfTitles) {
|
|
27
|
+
switch (resource.kind) {
|
|
28
|
+
case "image":
|
|
29
|
+
return { symbol: "▣", label: resource.key, meta: t("documentDesigner.mail.resource.inline") };
|
|
30
|
+
case "file":
|
|
31
|
+
return {
|
|
32
|
+
symbol: "⎘",
|
|
33
|
+
label: resource.fileName,
|
|
34
|
+
meta: t("documentDesigner.mail.resource.attachment"),
|
|
35
|
+
};
|
|
36
|
+
case "pdf":
|
|
37
|
+
return {
|
|
38
|
+
symbol: "⎘",
|
|
39
|
+
label: resource.fileName,
|
|
40
|
+
meta: t("documentDesigner.mail.resource.fromPdf", {
|
|
41
|
+
title: pdfTitles.get(resource.templateSlug) ?? resource.templateSlug,
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function ResourceSection({ resources, pdfTitles, canUpload, onUpload, onInsert, onRemove, }) {
|
|
47
|
+
const t = useTranslation();
|
|
48
|
+
const { Button } = usePrimitives();
|
|
49
|
+
return (_jsxs(RailSection, { title: t("documentDesigner.mail.resource.title"), testId: "mail-editor-resources", action: _jsx(Button, { variant: "link", disabled: !canUpload, onClick: onUpload, testId: "mail-resource-upload-open", children: t("documentDesigner.mail.resource.upload") }), children: [!canUpload && _jsx(RailHint, { children: t("documentDesigner.mail.resource.saveFirst") }), resources.map((resource) => {
|
|
50
|
+
const row = resourceRow(t, resource, pdfTitles);
|
|
51
|
+
return (_jsxs("div", { className: "flex items-center gap-1", "data-testid": "mail-resource-row", children: [_jsx("div", { className: "min-w-0 grow", children: _jsx(RailRow, { symbol: row.symbol, label: row.label, trailing: row.meta, ...(resource.kind === "image" && { onPress: () => onInsert(resource.key) }) }) }), _jsx(Button, { variant: "danger-ghost", size: "sm", onClick: () => onRemove(resource.key), testId: `mail-resource-remove-${resource.key}`, children: t("documentDesigner.mail.resource.remove") })] }, resource.key));
|
|
52
|
+
}), _jsx(RailHint, { children: t("documentDesigner.mail.resource.hint") })] }));
|
|
53
|
+
}
|