first wokring prototype
This commit is contained in:
558
src/features/campaigns/TemplateDataPage.tsx
Normal file
558
src/features/campaigns/TemplateDataPage.tsx
Normal file
@@ -0,0 +1,558 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
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 { autosaveCampaignVersion } from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asArray, asRecord, formatDateTime, getTemplateSection, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
||||
import { cloneJson, ensureCampaignDraft, getBool, getText, updateNested } from "./utils/draftEditor";
|
||||
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
|
||||
|
||||
type BodyMode = "text" | "html";
|
||||
type EditorTarget = "subject" | "text" | "html";
|
||||
type TemplateNamespace = "global" | "local";
|
||||
|
||||
type TemplatePlaceholder = {
|
||||
raw: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
validNamespace: boolean;
|
||||
display: string;
|
||||
};
|
||||
|
||||
type UndefinedPlaceholder = TemplatePlaceholder & {
|
||||
reason: "missing-field" | "invalid-namespace";
|
||||
};
|
||||
|
||||
export default function TemplateDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saveState, setSaveState] = useState("Loaded");
|
||||
const [localError, setLocalError] = useState("");
|
||||
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 loadedVersionId = useRef<string | 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 template = draft ? asRecord(draft.template) : getTemplateSection(version);
|
||||
const fields = useMemo(() => asArray(draft?.fields).map(asRecord), [draft]);
|
||||
const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]);
|
||||
const globalFieldNames = useMemo(() => uniqueSorted([...localFieldNames, ...Object.keys(asRecord(draft?.global_values))]), [draft?.global_values, localFieldNames]);
|
||||
const allAvailableNames = useMemo(() => new Set([...localFieldNames, ...globalFieldNames]), [localFieldNames, globalFieldNames]);
|
||||
const entries = asRecord(draft?.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(draft?.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(() => uniquePlaceholders(usedPlaceholders
|
||||
.filter((field) => !field.validNamespace || !allAvailableNames.has(field.name))
|
||||
.map((field): UndefinedPlaceholder => ({
|
||||
...field,
|
||||
reason: field.validNamespace ? "missing-field" : "invalid-namespace"
|
||||
}))), [usedPlaceholders, allAvailableNames]);
|
||||
const previewContext = useMemo(() => buildPreviewContext(draft, previewEntry), [draft, previewEntry]);
|
||||
const previewSubject = renderPreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields);
|
||||
const previewText = renderPreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
|
||||
const previewHtml = renderPreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
|
||||
|
||||
useEffect(() => {
|
||||
if (!version) return;
|
||||
if (loadedVersionId.current === version.id) return;
|
||||
loadedVersionId.current = version.id;
|
||||
setDraft(ensureCampaignDraft(version));
|
||||
setDirty(false);
|
||||
setPreviewIndex(0);
|
||||
setSaveState(version.autosaved_at ? `Loaded autosave ${formatDateTime(version.autosaved_at)}` : "Loaded");
|
||||
}, [version]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1));
|
||||
}, [previewIndex, previewEntries.length]);
|
||||
|
||||
function patch(path: string[], value: unknown) {
|
||||
if (locked) return;
|
||||
setDraft((current) => updateNested(current ?? {}, path, value));
|
||||
setDirty(true);
|
||||
setLocalError("");
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
setDirty(true);
|
||||
setLocalError("");
|
||||
setUndefinedDialog(null);
|
||||
}
|
||||
|
||||
async function saveTemplate(mode: "auto" | "manual" = "manual"): Promise<boolean> {
|
||||
if (!draft || !version || locked) return false;
|
||||
setSaveState("Saving…");
|
||||
setError("");
|
||||
setLocalError("");
|
||||
try {
|
||||
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
|
||||
campaign_json: draft,
|
||||
current_flow: "manual",
|
||||
current_step: "template",
|
||||
workflow_state: "editing",
|
||||
is_complete: false
|
||||
});
|
||||
setDraft(ensureCampaignDraft(saved));
|
||||
setDirty(false);
|
||||
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
|
||||
await reload();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : String(err));
|
||||
setSaveState("Save failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
|
||||
title: "Unsaved template changes",
|
||||
message: "The template has unsaved changes. Save them before leaving, or discard them and continue.",
|
||||
onSave: () => saveTemplate("manual"),
|
||||
onDiscard: () => setDirty(false)
|
||||
} : null);
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>Template</PageTitle>
|
||||
<p className="mono-small">{saveState}</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button disabled>Select template</Button>
|
||||
<Button onClick={reload} disabled={loading}>Reload</Button>
|
||||
<Button variant="primary" onClick={() => saveTemplate("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert danger">{error}</div>}
|
||||
{localError && <div className="alert danger">{localError}</div>}
|
||||
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)}</div>}
|
||||
|
||||
{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>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={() => saveTemplate("manual")} disabled={!dirty || locked || !draft}>Save</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
onPrevious={() => setPreviewIndex((value) => Math.max(0, value - 1))}
|
||||
onNext={() => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 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,
|
||||
onClose,
|
||||
onPrevious,
|
||||
onNext
|
||||
}: {
|
||||
bodyMode: BodyMode;
|
||||
entry: Record<string, unknown>;
|
||||
index: number;
|
||||
total: number;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
hasRealRecipients: boolean;
|
||||
onClose: () => void;
|
||||
onPrevious: () => void;
|
||||
onNext: () => 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">
|
||||
<Button onClick={onPrevious} disabled={index <= 0}>Previous</Button>
|
||||
<Button onClick={onNext} disabled={index >= total - 1}>Next</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>
|
||||
</div>
|
||||
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function extractTemplatePlaceholders(text: string): TemplatePlaceholder[] {
|
||||
const placeholders = new Map<string, TemplatePlaceholder>();
|
||||
const patterns = [/\$\{\s*([^}]+?)\s*\}/g, /\{\{\s*([^}]+?)\s*\}\}/g];
|
||||
for (const pattern of patterns) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(text))) {
|
||||
const raw = match[1].trim();
|
||||
if (!raw || placeholders.has(raw)) continue;
|
||||
const parsed = parseTemplatePlaceholder(raw);
|
||||
placeholders.set(raw, parsed);
|
||||
}
|
||||
}
|
||||
return [...placeholders.values()].sort((a, b) => a.display.localeCompare(b.display));
|
||||
}
|
||||
|
||||
function parseTemplatePlaceholder(raw: string): TemplatePlaceholder {
|
||||
const cleaned = raw.trim().replace(/^fields\./, "local:").replace(/^local\./, "local:").replace(/^global\./, "global:").replace(/^local::/, "local:").replace(/^global::/, "global:");
|
||||
const separator = cleaned.indexOf(":");
|
||||
const namespace = separator > -1 ? cleaned.slice(0, separator).trim() : "";
|
||||
const name = separator > -1 ? cleaned.slice(separator + 1).trim() : cleaned.trim();
|
||||
const validNamespace = namespace === "global" || namespace === "local";
|
||||
return {
|
||||
raw,
|
||||
namespace,
|
||||
name,
|
||||
validNamespace,
|
||||
display: validNamespace ? `${namespace}:${name}` : raw
|
||||
};
|
||||
}
|
||||
|
||||
function uniquePlaceholders<T extends TemplatePlaceholder>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
const result: T[] = [];
|
||||
for (const item of items) {
|
||||
const key = item.raw;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildPreviewContext(draft: Record<string, unknown> | null, entry: Record<string, unknown>): Record<string, string> {
|
||||
const context: Record<string, string> = {};
|
||||
const globalValues = asRecord(draft?.global_values);
|
||||
const entryFields = asRecord(entry.fields);
|
||||
const overridePolicy = fieldOverridePolicy(draft);
|
||||
|
||||
for (const [key, value] of Object.entries(globalValues)) {
|
||||
addContextValue(context, key, "global", value);
|
||||
addContextValue(context, key, "local", value);
|
||||
}
|
||||
for (const [key, value] of Object.entries(entryFields)) {
|
||||
if (canOverrideField(overridePolicy, key)) {
|
||||
addContextValue(context, key, "local", value);
|
||||
}
|
||||
}
|
||||
if (entry.name) addContextValue(context, "name", "local", entry.name);
|
||||
if (entry.email) addContextValue(context, "email", "local", entry.email);
|
||||
return context;
|
||||
}
|
||||
|
||||
function fieldOverridePolicy(draft: Record<string, unknown> | null): Map<string, boolean> {
|
||||
const policy = new Map<string, boolean>();
|
||||
for (const field of asArray(draft?.fields).map(asRecord)) {
|
||||
const name = String(field.name || field.id || "").trim();
|
||||
if (!name) continue;
|
||||
policy.set(name, getBool(field, "can_override", true));
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
function canOverrideField(policy: Map<string, boolean>, name: string): boolean {
|
||||
if (!policy.has(name)) return true;
|
||||
return policy.get(name) !== false;
|
||||
}
|
||||
|
||||
function addContextValue(context: Record<string, string>, key: string, namespace: TemplateNamespace, value: unknown) {
|
||||
const text = valueToPreview(value);
|
||||
context[key] = text;
|
||||
context[`${namespace}:${key}`] = text;
|
||||
context[`${namespace}::${key}`] = text;
|
||||
}
|
||||
|
||||
function renderPreviewText(text: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
|
||||
if (!text) return "";
|
||||
return text
|
||||
.replace(/\$\{\s*([^}]+?)\s*\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields))
|
||||
.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields));
|
||||
}
|
||||
|
||||
function previewValueFor(raw: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
|
||||
const key = normalizePreviewKey(raw);
|
||||
const value = context[key];
|
||||
if (value !== undefined) return value;
|
||||
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
|
||||
}
|
||||
|
||||
function normalizePreviewKey(raw: string): string {
|
||||
return raw.trim().replace(/^fields\./, "local:").replace(/^local\./, "local:").replace(/^global\./, "global:").replace(/^local::/, "local:").replace(/^global::/, "global:");
|
||||
}
|
||||
|
||||
function valueToPreview(value: unknown): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
function humanizeFieldName(value: string): string {
|
||||
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function removePlaceholderFromText(text: string, raw: string): string {
|
||||
if (!text) return text;
|
||||
const escaped = escapeRegExp(raw.trim());
|
||||
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), "");
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
Reference in New Issue
Block a user