initial commit after split
This commit is contained in:
381
webui/src/features/campaigns/TemplateDataPage.tsx
Normal file
381
webui/src/features/campaigns/TemplateDataPage.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import { previewCampaignAttachments, type CampaignAttachmentPreviewRule } from "../../api/campaigns";
|
||||
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 DismissibleAlert from "../../components/DismissibleAlert";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||
import { TemplateFieldChipList, UndefinedPlaceholderDecisionDialog, UndefinedPlaceholderList } from "./components/TemplatePlaceholderControls";
|
||||
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 { humanizeFieldName } from "./utils/fieldDefinitions";
|
||||
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, 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 [attachmentPreviewRules, setAttachmentPreviewRules] = useState<CampaignAttachmentPreviewRule[]>([]);
|
||||
const [attachmentPreviewLoading, setAttachmentPreviewLoading] = useState(false);
|
||||
const [attachmentPreviewError, setAttachmentPreviewError] = useState("");
|
||||
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, data.campaign?.current_version_id);
|
||||
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 builtInAddressNames = useMemo(() => recipientAddressTemplateFieldOptions().map((field) => field.name), []);
|
||||
const localAvailableNames = useMemo(() => new Set([...localFieldNames, ...builtInAddressNames]), [builtInAddressNames, localFieldNames]);
|
||||
const globalAvailableNames = useMemo(() => new Set(globalFieldNames), [globalFieldNames]);
|
||||
const allAvailableNames = useMemo(() => new Set([...localAvailableNames, ...globalAvailableNames]), [globalAvailableNames, localAvailableNames]);
|
||||
const localFieldOptions = useMemo(() => {
|
||||
const options = [...recipientAddressTemplateFieldOptions(), ...localFieldNames.map((name) => ({ name, label: name }))];
|
||||
return options.filter((option, index) => options.findIndex((candidate) => candidate.name === option.name) === index);
|
||||
}, [localFieldNames]);
|
||||
const globalFieldOptions = useMemo(() => globalFieldNames.map((name) => ({ name, label: name })), [globalFieldNames]);
|
||||
const entries = asRecord(displayDraft.entries);
|
||||
const inlineEntries = useMemo(() => asArray(entries.inline).map(asRecord), [entries.inline]);
|
||||
const activePreviewEntries = useMemo(
|
||||
() => inlineEntries.map((entry, sourceIndex) => ({ entry, sourceIndex: sourceIndex + 1 })).filter(({ entry }) => entry.active !== false),
|
||||
[inlineEntries]
|
||||
);
|
||||
const previewEntries = activePreviewEntries.length > 0 ? activePreviewEntries : [{ entry: {}, sourceIndex: 0 }];
|
||||
const previewSelection = previewEntries[Math.min(previewIndex, previewEntries.length - 1)] ?? previewEntries[0];
|
||||
const previewEntry = previewSelection.entry;
|
||||
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, { local: localAvailableNames, global: globalAvailableNames }),
|
||||
[usedPlaceholders, allAvailableNames, globalAvailableNames, localAvailableNames]
|
||||
);
|
||||
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 selectedPreviewEntryIndex = previewSelection.sourceIndex;
|
||||
const previewAttachmentRules = useMemo(
|
||||
() => attachmentPreviewRules.filter((rule) => rule.entry_index === selectedPreviewEntryIndex),
|
||||
[attachmentPreviewRules, selectedPreviewEntryIndex]
|
||||
);
|
||||
const previewAttachments = useMemo(
|
||||
() => mapResolvedAttachmentsToPreviewBoxes(previewAttachmentRules, attachmentPreviewLoading, attachmentPreviewError),
|
||||
[attachmentPreviewError, attachmentPreviewLoading, previewAttachmentRules]
|
||||
);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1));
|
||||
}, [previewIndex, previewEntries.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewOpen || !version?.id || !draft) return;
|
||||
let cancelled = false;
|
||||
setAttachmentPreviewLoading(true);
|
||||
setAttachmentPreviewError("");
|
||||
const handle = window.setTimeout(() => {
|
||||
void previewCampaignAttachments(settings, campaignId, version.id, {
|
||||
include_unmatched: false,
|
||||
campaign_json: displayDraft
|
||||
})
|
||||
.then((response) => {
|
||||
if (!cancelled) setAttachmentPreviewRules(response.rules);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!cancelled) {
|
||||
setAttachmentPreviewRules([]);
|
||||
setAttachmentPreviewError(reason instanceof Error ? reason.message : String(reason));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setAttachmentPreviewLoading(false);
|
||||
});
|
||||
}, 120);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(handle);
|
||||
};
|
||||
}, [campaignId, displayDraft, draft, previewOpen, settings.apiBaseUrl, settings.apiKey, settings.accessToken, version?.id]);
|
||||
|
||||
|
||||
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 && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="This page is read-only for the selected version." />}
|
||||
|
||||
<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 && (
|
||||
<DismissibleAlert tone="warning" resetKey={invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(",")}>Undefined placeholder namespace detected: {invalidNamespacePlaceholders.map((field) => field.namespace || field.raw).join(", ")}.</DismissibleAlert>
|
||||
)}
|
||||
{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"
|
||||
fields={globalFieldOptions}
|
||||
usedPlaceholders={usedPlaceholders}
|
||||
empty="No campaign fields or global values defined."
|
||||
onInsert={insertPlaceholder}
|
||||
/>
|
||||
|
||||
<h3 className="section-mini-heading">Local fields</h3>
|
||||
<p className="muted small-note">Address fields use the effective merged or overridden headers. Use <code>to[2]</code> or <code>to[2].email</code> for a specific additional address.</p>
|
||||
<TemplateFieldChipList
|
||||
namespace="local"
|
||||
fields={localFieldOptions}
|
||||
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 && (
|
||||
<MessagePreviewOverlay
|
||||
title="Template preview"
|
||||
bodyMode={bodyMode}
|
||||
subject={previewSubject}
|
||||
text={previewText}
|
||||
html={previewHtml}
|
||||
recipientLabel={activePreviewEntries.length > 0 ? recipientLabel(previewEntry, previewSelection.sourceIndex - 1) : "Global preview"}
|
||||
recipientNote={activePreviewEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : inlineEntries.length > 0 ? "No recipient preview is available." : "No inline recipients are available yet."}
|
||||
attachments={previewAttachments}
|
||||
navigation={{
|
||||
index: Math.min(previewIndex, previewEntries.length - 1),
|
||||
total: previewEntries.length,
|
||||
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)
|
||||
}}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UndefinedPlaceholderDecisionDialog
|
||||
field={undefinedDialog}
|
||||
contextLabel="template"
|
||||
removeLabel="Remove from template"
|
||||
onCancel={() => setUndefinedDialog(null)}
|
||||
onRemove={removePlaceholder}
|
||||
onAddField={addUndefinedField}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mapResolvedAttachmentsToPreviewBoxes(
|
||||
rules: CampaignAttachmentPreviewRule[],
|
||||
loading: boolean,
|
||||
error: string
|
||||
): MessagePreviewAttachment[] {
|
||||
if (loading) {
|
||||
return [{ filename: "Resolving attachment patterns…", detail: "Managed files are being checked for this recipient." }];
|
||||
}
|
||||
if (error) {
|
||||
return [{ filename: "Attachment preview unavailable", detail: error }];
|
||||
}
|
||||
return rules.flatMap((rule) => {
|
||||
const detailParts = [
|
||||
rule.source === "global" ? "Global" : "Recipient",
|
||||
rule.label,
|
||||
rule.required ? "required" : "optional",
|
||||
rule.pattern
|
||||
].filter(Boolean);
|
||||
const detail = detailParts.join(" · ");
|
||||
if (rule.matches.length > 0) {
|
||||
return rule.matches.map((match) => ({
|
||||
filename: match.filename || match.display_path,
|
||||
label: rule.label,
|
||||
detail: `${detail}${match.display_path ? ` · ${match.display_path}` : ""}`,
|
||||
contentType: match.content_type,
|
||||
sizeBytes: match.size_bytes,
|
||||
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null,
|
||||
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null
|
||||
}));
|
||||
}
|
||||
return [{
|
||||
filename: `No file matched ${rule.pattern || "attachment pattern"}`,
|
||||
label: rule.label,
|
||||
detail: `${detail}${rule.status !== "ok" ? ` · ${rule.status}` : ""}`,
|
||||
archiveGroup: rule.zip_included ? (rule.zip_filename || "recipient-attachments.zip") : null,
|
||||
archiveLabel: rule.zip_included ? (rule.zip_filename || "Recipient attachments ZIP") : null
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user