Files
multi-seal-mail-webui/src/features/campaigns/TemplateDataPage.tsx

438 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { cloneJson, getBool, getText } from "./utils/draftEditor";
import { buildEffectiveAttachmentPreviews, type EffectiveAttachmentPreview } from "./utils/attachments";
import { humanizeFieldName } from "./utils/fieldDefinitions";
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type TemplatePlaceholder, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
type BodyMode = "text" | "html";
type EditorTarget = "subject" | "text" | "html";
export default function TemplateDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [bodyMode, setBodyMode] = useState<BodyMode>("text");
const [activeEditor, setActiveEditor] = useState<EditorTarget>("text");
const [previewOpen, setPreviewOpen] = useState(false);
const [previewIndex, setPreviewIndex] = useState(0);
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null);
const subjectRef = useRef<HTMLInputElement | null>(null);
const textRef = useRef<HTMLTextAreaElement | null>(null);
const htmlRef = useRef<HTMLTextAreaElement | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "template",
unsavedTitle: "Unsaved template changes",
unsavedMessage: "The template has unsaved changes. Save them before leaving, or discard them and continue.",
loadedLabel: (loadedVersion) => loadedVersion.autosaved_at ? `Loaded autosave ${new Date(loadedVersion.autosaved_at).toLocaleString()}` : "Loaded",
onLoaded: () => setPreviewIndex(0)
});
const template = asRecord(displayDraft.template);
const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]);
const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]);
const globalFieldNames = useMemo(() => uniqueSorted([...localFieldNames, ...Object.keys(asRecord(displayDraft.global_values))]), [displayDraft.global_values, localFieldNames]);
const allAvailableNames = useMemo(() => new Set([...localFieldNames, ...globalFieldNames]), [localFieldNames, globalFieldNames]);
const entries = asRecord(displayDraft.entries);
const inlineEntries = useMemo(() => asArray(entries.inline).map(asRecord), [entries.inline]);
const previewEntries = inlineEntries.length > 0 ? inlineEntries : [{}];
const previewEntry = previewEntries[Math.min(previewIndex, previewEntries.length - 1)] ?? {};
const ignoreEmptyFields = getBool(asRecord(displayDraft.validation_policy), "ignore_empty_fields", false);
const templateText = `${getText(template, "subject")}\n${getText(template, "text")}\n${getText(template, "html")}`;
const usedPlaceholders = useMemo(() => extractTemplatePlaceholders(templateText), [templateText]);
const invalidNamespacePlaceholders = useMemo(() => uniquePlaceholders(usedPlaceholders.filter((field) => !field.validNamespace)), [usedPlaceholders]);
const undefinedPlaceholders = useMemo(() => buildUndefinedPlaceholders(usedPlaceholders, allAvailableNames), [usedPlaceholders, allAvailableNames]);
const previewContext = useMemo(() => buildTemplatePreviewContext(displayDraft, previewEntry), [displayDraft, previewEntry]);
const previewSubject = renderTemplatePreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields);
const previewText = renderTemplatePreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
const previewHtml = renderTemplatePreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
const previewAttachments = useMemo(() => buildEffectiveAttachmentPreviews(displayDraft, previewEntry), [displayDraft, previewEntry]);
useEffect(() => {
if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1));
}, [previewIndex, previewEntries.length]);
function patchTemplateText(target: EditorTarget, value: string) {
patch(["template", target], value);
}
function insertPlaceholder(namespace: TemplateNamespace, name: string) {
if (locked) return;
const target = bodyMode === "html" && activeEditor !== "subject" ? "html" : activeEditor;
const element = target === "subject" ? subjectRef.current : target === "html" ? htmlRef.current : textRef.current;
const token = `{{${namespace}:${name}}}`;
const currentText = getText(template, target);
const start = element?.selectionStart ?? currentText.length;
const end = element?.selectionEnd ?? currentText.length;
const nextText = `${currentText.slice(0, start)}${token}${currentText.slice(end)}`;
patchTemplateText(target, nextText);
window.requestAnimationFrame(() => {
element?.focus();
const cursor = start + token.length;
element?.setSelectionRange(cursor, cursor);
});
}
function addUndefinedField(field: UndefinedPlaceholder) {
if (!draft || locked || !field.name) return;
const existingFields = asArray(draft.fields).map(asRecord);
const alreadyDefined = existingFields.some((item) => String(item.name || item.id || "") === field.name);
if (!alreadyDefined) {
patch(["fields"], [
...existingFields,
{
name: field.name,
label: humanizeFieldName(field.name),
type: "string",
required: false,
can_override: true
}
]);
}
setUndefinedDialog(null);
}
function removePlaceholder(field: UndefinedPlaceholder) {
if (locked) return;
setDraft((current) => {
const next = cloneJson(current ?? {});
const nextTemplate = { ...asRecord(next.template) };
nextTemplate.subject = removePlaceholderFromText(getText(nextTemplate, "subject"), field.raw);
nextTemplate.text = removePlaceholderFromText(getText(nextTemplate, "text"), field.raw);
nextTemplate.html = removePlaceholderFromText(getText(nextTemplate, "html"), field.raw);
next.template = nextTemplate;
return next;
});
markDirty();
setUndefinedDialog(null);
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Template</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} />
</div>
<div className="button-row compact-actions">
<Button disabled>Manage templates</Button>
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Create an editable copy before changing the template." />}
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<div className="dashboard-grid template-editor-grid">
<Card title="Editable template" actions={<Button onClick={() => setPreviewOpen(true)}>Preview</Button>}>
<div className="form-grid">
<FormField label="Subject">
<input
ref={subjectRef}
value={getText(template, "subject")}
disabled={locked}
onFocus={() => setActiveEditor("subject")}
onChange={(event) => patchTemplateText("subject", event.target.value)}
/>
</FormField>
<div className="template-body-mode" role="tablist" aria-label="Template body mode">
<button type="button" className={bodyMode === "text" ? "active" : ""} onClick={() => { setBodyMode("text"); setActiveEditor("text"); }}>Plain text</button>
<button type="button" className={bodyMode === "html" ? "active" : ""} onClick={() => { setBodyMode("html"); setActiveEditor("html"); }}>HTML</button>
</div>
{bodyMode === "text" && (
<FormField label="Plain text body">
<textarea
ref={textRef}
rows={16}
value={getText(template, "text")}
disabled={locked}
onFocus={() => setActiveEditor("text")}
onChange={(event) => patchTemplateText("text", event.target.value)}
/>
</FormField>
)}
{bodyMode === "html" && (
<FormField label="HTML body">
<textarea
ref={htmlRef}
rows={16}
value={getText(template, "html")}
disabled={locked}
onFocus={() => setActiveEditor("html")}
onChange={(event) => patchTemplateText("html", event.target.value)}
/>
</FormField>
)}
<div className="button-row template-editor-actions">
<Button disabled>Load from library</Button>
<Button disabled>Save to library</Button>
</div>
</div>
</Card>
<div className="template-side-stack">
<Card title="Fields">
{invalidNamespacePlaceholders.length > 0 && (
<div className="alert warning">Undefined placeholder namespace detected: {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</div>
)}
{usedPlaceholders.length === 0 && <p className="muted">No template placeholders detected yet.</p>}
<p className="muted small-note">Click a field to insert it at the current cursor position as a namespaced placeholder.</p>
<h3 className="section-mini-heading">Global fields</h3>
<TemplateFieldChipList
namespace="global"
names={globalFieldNames}
usedPlaceholders={usedPlaceholders}
empty="No campaign fields or global values defined."
onInsert={insertPlaceholder}
/>
<h3 className="section-mini-heading">Local fields</h3>
<TemplateFieldChipList
namespace="local"
names={localFieldNames}
usedPlaceholders={usedPlaceholders}
empty="No campaign fields defined."
onInsert={insertPlaceholder}
/>
<h3 className="section-mini-heading">Used in template, but undefined</h3>
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
</Card>
</div>
</div>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>Save</Button>
</div>
</>
</LoadingFrame>
{previewOpen && (
<TemplatePreviewOverlay
bodyMode={bodyMode}
entry={previewEntry}
index={Math.min(previewIndex, previewEntries.length - 1)}
total={previewEntries.length}
subject={previewSubject}
text={previewText}
html={previewHtml}
hasRealRecipients={inlineEntries.length > 0}
attachments={previewAttachments}
onClose={() => setPreviewOpen(false)}
onFirst={() => setPreviewIndex(0)}
onPrevious={() => setPreviewIndex((value) => Math.max(0, value - 1))}
onNext={() => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1))}
onLast={() => setPreviewIndex(previewEntries.length - 1)}
/>
)}
{undefinedDialog && (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="undefined-template-field-title">
<div className="modal-panel template-action-dialog">
<header className="modal-header">
<h2 id="undefined-template-field-title">Undefined template field</h2>
<button className="modal-close" onClick={() => setUndefinedDialog(null)}>×</button>
</header>
<div className="modal-body">
<p>The template uses <code>{`{{${undefinedDialog.raw}}}`}</code>, but it cannot be matched to a known field.</p>
{undefinedDialog.reason === "invalid-namespace" && <div className="alert warning">Use the namespace <code>global:</code> or <code>local:</code>.</div>}
{undefinedDialog.reason === "missing-field" && <p className="muted">You can add the name <strong>{undefinedDialog.name}</strong> as a campaign field, or remove this placeholder from subject, plain text and HTML.</p>}
</div>
<footer className="modal-footer">
<Button onClick={() => setUndefinedDialog(null)}>Cancel</Button>
<Button onClick={() => removePlaceholder(undefinedDialog)}>Remove from template</Button>
<Button variant="primary" onClick={() => addUndefinedField(undefinedDialog)} disabled={!undefinedDialog.name}>Add field</Button>
</footer>
</div>
</div>
)}
</div>
);
}
function TemplateFieldChipList({
namespace,
names,
usedPlaceholders,
empty,
onInsert
}: {
namespace: TemplateNamespace;
names: string[];
usedPlaceholders: TemplatePlaceholder[];
empty: string;
onInsert: (namespace: TemplateNamespace, name: string) => void;
}) {
if (names.length === 0) return <p className="muted">{empty}</p>;
return (
<div className="field-chip-list">
{names.map((name) => {
const used = usedPlaceholders.some((field) => field.validNamespace && field.namespace === namespace && field.name === name);
return (
<button type="button" className={`field-chip field-chip-button ${used ? "used" : ""}`} key={`${namespace}:${name}`} onClick={() => onInsert(namespace, name)}>
<span className="field-chip-namespace">{namespace}</span>{name}
</button>
);
})}
</div>
);
}
function UndefinedPlaceholderList({ items, onSelect }: { items: UndefinedPlaceholder[]; onSelect: (item: UndefinedPlaceholder) => void }) {
if (items.length === 0) return <p className="muted">No undefined placeholders detected.</p>;
return (
<div className="field-chip-list">
{items.map((item) => (
<button type="button" className="field-chip field-chip-button undefined" key={`${item.raw}:${item.reason}`} onClick={() => onSelect(item)}>
{item.display}
</button>
))}
</div>
);
}
function TemplatePreviewOverlay({
bodyMode,
entry,
index,
total,
subject,
text,
html,
hasRealRecipients,
attachments,
onClose,
onFirst,
onPrevious,
onNext,
onLast
}: {
bodyMode: BodyMode;
entry: Record<string, unknown>;
index: number;
total: number;
subject: string;
text: string;
html: string;
hasRealRecipients: boolean;
attachments: EffectiveAttachmentPreview[];
onClose: () => void;
onFirst: () => void;
onPrevious: () => void;
onNext: () => void;
onLast: () => void;
}) {
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="template-preview-title">
<div className="modal-panel template-preview-modal">
<header className="modal-header">
<h2 id="template-preview-title">Template preview</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
<div className="template-preview-toolbar">
<div>
<strong>{hasRealRecipients ? recipientLabel(entry, index) : "Global preview"}</strong>
<p className="muted small-note">{hasRealRecipients ? `${index + 1} of ${total}` : "No inline recipients are available yet."}</p>
</div>
<div className="button-row compact-actions template-preview-nav" aria-label="Preview message navigation">
<button type="button" className="version-arrow" onClick={onFirst} disabled={index <= 0} title="First message" aria-label="First message"><ArrowBigLeftDash aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={onPrevious} disabled={index <= 0} title="Previous message" aria-label="Previous message"><ArrowBigLeft aria-hidden="true" /></button>
<span className="template-preview-count">{index + 1} / {total}</span>
<button type="button" className="version-arrow" onClick={onNext} disabled={index >= total - 1} title="Next message" aria-label="Next message"><ArrowBigRight aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={onLast} disabled={index >= total - 1} title="Last message" aria-label="Last message"><ArrowBigRightDash aria-hidden="true" /></button>
</div>
</div>
<div className="template-preview-box">
<h3>{subject || "No subject"}</h3>
{bodyMode === "html" ? (
<iframe className="template-preview-frame" title="Rendered HTML body preview" sandbox="" srcDoc={html || "<p>No HTML body to preview.</p>"} />
) : (
<pre>{text || "No plain-text body to preview."}</pre>
)}
</div>
<EffectiveAttachmentPreviewTable attachments={attachments} />
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
</div>
</div>
);
}
function EffectiveAttachmentPreviewTable({ attachments }: { attachments: EffectiveAttachmentPreview[] }) {
return (
<div className="template-preview-attachments">
<h3>Effective attachments</h3>
{attachments.length === 0 ? (
<p className="muted small-note">No global or recipient attachments are effective for this message.</p>
) : (
<div className="app-table-wrap">
<table className="app-table compact-table">
<thead>
<tr>
<th>Scope</th>
<th>Label</th>
<th>Base path</th>
<th>File / pattern</th>
<th>Options</th>
<th>Preview match</th>
</tr>
</thead>
<tbody>
{attachments.map((attachment, index) => (
<tr key={`${attachment.scope}:${attachment.basePath}:${attachment.fileFilter}:${index}`}>
<td>{attachment.scope === "global" ? "Global" : "Recipient"}</td>
<td>{attachment.label}</td>
<td>{attachment.basePathName}<br /><span className="muted"><code>{attachment.basePath}</code></span></td>
<td><code>{attachment.fileFilter || "—"}</code></td>
<td>{attachment.required ? "required" : "optional"}{attachment.includeSubdirs ? ", subdirs" : ""}</td>
<td>{attachment.matches.length > 0 ? attachment.matches.join(", ") : "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function recipientLabel(entry: Record<string, unknown>, index: number): string {
const name = valueToPreview(entry.name).trim();
const email = valueToPreview(entry.email).trim();
if (name && email) return `${name} <${email}>`;
if (name) return name;
if (email) return email;
return `Recipient ${index + 1}`;
}
function uniqueSorted(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
}