Attachment preview

This commit is contained in:
2026-06-13 04:15:29 +02:00
parent 76ff0f9d5f
commit 884ba51432
5 changed files with 152 additions and 109 deletions

View File

@@ -131,6 +131,51 @@ export type CampaignSendNowPayload = {
enqueue_imap_task?: boolean; enqueue_imap_task?: boolean;
}; };
export type CampaignAttachmentPreviewFile = {
id: string;
version_id?: string;
blob_id?: string;
display_path: string;
filename: string;
owner_type: string;
owner_id: string;
checksum_sha256?: string;
size_bytes?: number;
content_type?: string | null;
};
export type CampaignAttachmentPreviewRule = {
source: "global" | "entry";
entry_index: number;
entry_id?: string | null;
index: number;
attachment_id?: string | null;
label?: string | null;
required: boolean;
pattern: string;
base_path_name?: string | null;
base_path?: string | null;
status: "ok" | "missing" | "ambiguous";
behavior?: string | null;
matches: CampaignAttachmentPreviewFile[];
match_count: number;
issues: Record<string, unknown>[];
};
export type CampaignAttachmentPreviewResponse = {
campaign_id: string;
version_id: string;
shared_file_count: number;
rules: CampaignAttachmentPreviewRule[];
unused_shared_files: CampaignAttachmentPreviewFile[];
};
export type CampaignAttachmentPreviewPayload = {
include_unmatched?: boolean;
campaign_json?: Record<string, unknown>;
};
export type CampaignMockSendPayload = { export type CampaignMockSendPayload = {
version_id?: string | null; version_id?: string | null;
send?: boolean; send?: boolean;
@@ -309,6 +354,20 @@ export async function buildVersion(
}); });
} }
export function previewCampaignAttachments(
settings: ApiSettings,
campaignId: string,
versionId: string,
payload: CampaignAttachmentPreviewPayload = {}
): Promise<CampaignAttachmentPreviewResponse> {
return apiFetch<CampaignAttachmentPreviewResponse>(
settings,
`/api/v1/campaigns/${campaignId}/versions/${versionId}/attachments/preview`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export async function getCampaignSummary(settings: ApiSettings, campaignId: string): Promise<CampaignSummary> { export async function getCampaignSummary(settings: ApiSettings, campaignId: string): Promise<CampaignSummary> {
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary`); return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary`);
} }

View File

@@ -75,7 +75,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
function addGlobalAttachmentRule() { function addGlobalAttachmentRule() {
if (locked) return; if (locked) return;
patch(["attachments", "global"], [...globalRules, createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(globalRules))]); patch(["attachments", "global"], [...globalRules, createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(globalRules), basePaths[0]?.id ?? "")]);
} }

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { previewCampaignAttachments, type CampaignAttachmentPreviewRule } from "../../api/campaigns";
import Button from "../../components/Button"; import Button from "../../components/Button";
import Card from "../../components/Card"; import Card from "../../components/Card";
import FormField from "../../components/FormField"; import FormField from "../../components/FormField";
@@ -13,7 +14,6 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor"; import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView"; import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { cloneJson, getBool, getText } from "./utils/draftEditor"; import { cloneJson, getBool, getText } from "./utils/draftEditor";
import { buildEffectiveAttachmentPreviews, type EffectiveAttachmentPreview } from "./utils/attachments";
import { humanizeFieldName } from "./utils/fieldDefinitions"; import { humanizeFieldName } from "./utils/fieldDefinitions";
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type TemplatePlaceholder, type UndefinedPlaceholder } from "./utils/templatePlaceholders"; import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type TemplatePlaceholder, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
@@ -27,6 +27,9 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
const [previewOpen, setPreviewOpen] = useState(false); const [previewOpen, setPreviewOpen] = useState(false);
const [previewIndex, setPreviewIndex] = useState(0); const [previewIndex, setPreviewIndex] = useState(0);
const [undefinedDialog, setUndefinedDialog] = useState<UndefinedPlaceholder | null>(null); 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 subjectRef = useRef<HTMLInputElement | null>(null);
const textRef = useRef<HTMLTextAreaElement | null>(null); const textRef = useRef<HTMLTextAreaElement | null>(null);
const htmlRef = useRef<HTMLTextAreaElement | null>(null); const htmlRef = useRef<HTMLTextAreaElement | null>(null);
@@ -64,13 +67,50 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
const previewSubject = renderTemplatePreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields); const previewSubject = renderTemplatePreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields);
const previewText = renderTemplatePreviewText(getText(template, "text"), previewContext, ignoreEmptyFields); const previewText = renderTemplatePreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
const previewHtml = renderTemplatePreviewText(getText(template, "html"), previewContext, ignoreEmptyFields); const previewHtml = renderTemplatePreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
const previewAttachments = useMemo(() => buildEffectiveAttachmentPreviews(displayDraft, previewEntry), [displayDraft, previewEntry]); const selectedPreviewEntryIndex = Math.min(previewIndex, previewEntries.length - 1) + 1;
const previewAttachmentRules = useMemo(
() => attachmentPreviewRules.filter((rule) => rule.entry_index === selectedPreviewEntryIndex),
[attachmentPreviewRules, selectedPreviewEntryIndex]
);
const previewAttachments = useMemo(
() => mapResolvedAttachmentsToPreviewBoxes(previewAttachmentRules, attachmentPreviewLoading, attachmentPreviewError),
[attachmentPreviewError, attachmentPreviewLoading, previewAttachmentRules]
);
useEffect(() => { useEffect(() => {
if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1)); if (previewIndex >= previewEntries.length) setPreviewIndex(Math.max(0, previewEntries.length - 1));
}, [previewIndex, previewEntries.length]); }, [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) { function patchTemplateText(target: EditorTarget, value: string) {
patch(["template", target], value); patch(["template", target], value);
@@ -242,7 +282,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
html={previewHtml} html={previewHtml}
recipientLabel={inlineEntries.length > 0 ? recipientLabel(previewEntry, Math.min(previewIndex, previewEntries.length - 1)) : "Global preview"} recipientLabel={inlineEntries.length > 0 ? recipientLabel(previewEntry, Math.min(previewIndex, previewEntries.length - 1)) : "Global preview"}
recipientNote={inlineEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : "No inline recipients are available yet."} recipientNote={inlineEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : "No inline recipients are available yet."}
attachments={mapEffectiveAttachmentsToPreviewBoxes(previewAttachments)} attachments={previewAttachments}
navigation={{ navigation={{
index: Math.min(previewIndex, previewEntries.length - 1), index: Math.min(previewIndex, previewEntries.length - 1),
total: previewEntries.length, total: previewEntries.length,
@@ -320,31 +360,44 @@ function UndefinedPlaceholderList({ items, onSelect }: { items: UndefinedPlaceho
); );
} }
function mapEffectiveAttachmentsToPreviewBoxes(attachments: EffectiveAttachmentPreview[]): MessagePreviewAttachment[] { function mapResolvedAttachmentsToPreviewBoxes(
return attachments.flatMap((attachment) => { 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 = [ const detailParts = [
attachment.scope === "global" ? "Global" : "Recipient", rule.source === "global" ? "Global" : "Recipient",
attachment.label, rule.label,
attachment.required ? "required" : "optional", rule.required ? "required" : "optional",
attachment.includeSubdirs ? "subdirs" : "" rule.pattern
].filter(Boolean); ].filter(Boolean);
const detail = detailParts.join(" · "); const detail = detailParts.join(" · ");
if (attachment.matches.length > 0) { if (rule.matches.length > 0) {
return attachment.matches.map((match) => ({ return rule.matches.map((match) => ({
filename: match, filename: match.filename || match.display_path,
label: attachment.label, label: rule.label,
detail detail: `${detail}${match.display_path ? ` · ${match.display_path}` : ""}`,
contentType: match.content_type,
sizeBytes: match.size_bytes
})); }));
} }
return [{ return [{
filename: attachment.fileFilter || "No matched file", filename: `No file matched ${rule.pattern || "attachment pattern"}`,
label: attachment.label, label: rule.label,
detail: `${detail}${detail ? " · " : ""}${attachment.basePathName || attachment.basePath || "attachment source"}` detail: `${detail}${rule.status !== "ok" ? ` · ${rule.status}` : ""}`
}]; }];
}); });
} }
function recipientLabel(entry: Record<string, unknown>, index: number): string { function recipientLabel(entry: Record<string, unknown>, index: number): string {
const name = valueToPreview(entry.name).trim(); const name = valueToPreview(entry.name).trim();
const email = valueToPreview(entry.email).trim(); const email = valueToPreview(entry.email).trim();

View File

@@ -61,7 +61,7 @@ export default function AttachmentRulesOverlay({
} }
function addOverlayRule() { function addOverlayRule() {
onChange([...rules, createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(rules))]); onChange([...rules, createAttachmentRule(basePaths[0]?.path ?? "", nextAttachmentLabel(rules), basePaths[0]?.id ?? "")]);
} }
const dialog = open ? createPortal( const dialog = open ? createPortal(
@@ -110,7 +110,7 @@ export function AttachmentRulesTable({
function addRule() { function addRule() {
onChange([ onChange([
...tableProps.rules, ...tableProps.rules,
createAttachmentRule(tableProps.basePaths?.[0]?.path ?? "", nextAttachmentLabel(tableProps.rules)) createAttachmentRule(tableProps.basePaths?.[0]?.path ?? "", nextAttachmentLabel(tableProps.rules), tableProps.basePaths?.[0]?.id ?? "")
]); ]);
} }
@@ -158,7 +158,8 @@ export function AttachmentRulesDataGrid({
} }
const rule = rules[ruleIndex] ?? {}; const rule = rules[ruleIndex] ?? {};
const currentPath = getText(rule, "base_dir", basePaths[0]?.path ?? ""); const currentPath = getText(rule, "base_dir", basePaths[0]?.path ?? "");
const basePath = basePaths.find((item) => item.path === currentPath) ?? basePaths[0] ?? null; const currentBasePathId = getText(rule, "base_path_id");
const basePath = basePaths.find((item) => item.id === currentBasePathId) ?? basePaths.find((item) => item.path === currentPath) ?? basePaths[0] ?? null;
setFileChooser({ ruleIndex, basePath }); setFileChooser({ ruleIndex, basePath });
} }
@@ -166,6 +167,7 @@ export function AttachmentRulesDataGrid({
if (!fileChooser) return; if (!fileChooser) return;
const currentRule = rules[fileChooser.ruleIndex] ?? {}; const currentRule = rules[fileChooser.ruleIndex] ?? {};
patchRule(fileChooser.ruleIndex, { patchRule(fileChooser.ruleIndex, {
base_path_id: fileChooser.basePath?.id ?? "",
base_dir: fileChooser.basePath?.path ?? (selection.folderPath || "."), base_dir: fileChooser.basePath?.path ?? (selection.folderPath || "."),
file_filter: selection.fileFilter, file_filter: selection.fileFilter,
type: selection.selectionType === "file" ? "direct" : "pattern", type: selection.selectionType === "file" ? "direct" : "pattern",
@@ -223,13 +225,22 @@ function attachmentRuleColumns({ disabled, basePaths, activeChooserRuleIndex: _a
sortable: true, sortable: true,
filterable: true, filterable: true,
render: (rule, index) => { render: (rule, index) => {
const currentBasePath = getText(rule, "base_dir", basePaths[0]?.path ?? ""); const currentBasePathValue = getText(rule, "base_dir", basePaths[0]?.path ?? "");
const currentBasePathId = getText(rule, "base_path_id");
const selectedBasePath = basePaths.find((basePath) => basePath.id === currentBasePathId) ?? basePaths.find((basePath) => basePath.path === currentBasePathValue) ?? basePaths[0];
return basePaths.length > 0 ? ( return basePaths.length > 0 ? (
<select value={currentBasePath} disabled={disabled} onChange={(event) => patchRule(index, { base_dir: event.target.value })}> <select
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.path}>{basePath.name || basePath.path}</option>)} value={selectedBasePath?.id ?? ""}
disabled={disabled}
onChange={(event) => {
const nextBasePath = basePaths.find((basePath) => basePath.id === event.target.value);
if (nextBasePath) patchRule(index, { base_path_id: nextBasePath.id, base_dir: nextBasePath.path });
}}
>
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.id}>{basePath.name || basePath.path}</option>)}
</select> </select>
) : ( ) : (
<input value={currentBasePath} disabled={disabled} readOnly placeholder="optional/folder" /> <input value={currentBasePathValue} disabled={disabled} readOnly placeholder="optional/folder" />
); );
}, },
value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "") value: (rule) => getText(rule, "base_dir", basePaths[0]?.path ?? "")

View File

@@ -1,7 +1,6 @@
import type { FileSpace } from "../../../api/files"; import type { FileSpace } from "../../../api/files";
import { asArray, asRecord, isRecord } from "./campaignView"; import { asArray, asRecord, isRecord } from "./campaignView";
import { getBool, getText } from "./draftEditor"; import { getBool, getText } from "./draftEditor";
import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./templatePlaceholders";
export type AttachmentRule = Record<string, unknown>; export type AttachmentRule = Record<string, unknown>;
@@ -38,17 +37,6 @@ export type AttachmentSummary = {
rules: number; rules: number;
}; };
export type EffectiveAttachmentPreview = {
scope: "global" | "recipient";
label: string;
basePathName: string;
basePath: string;
fileFilter: string;
required: boolean;
includeSubdirs: boolean;
matches: string[];
};
export type MockAttachmentPathOption = Partial<AttachmentBasePath> & { export type MockAttachmentPathOption = Partial<AttachmentBasePath> & {
label: string; label: string;
}; };
@@ -61,13 +49,7 @@ export const mockAttachmentPathOptions: MockAttachmentPathOption[] = [
{ label: "Personal upload area", path: "user/uploads" } { label: "Personal upload area", path: "user/uploads" }
]; ];
export const mockAttachmentFiles = [
"welcome.pdf",
"terms-and-conditions.pdf",
"invoice_{{local:invoice_number}}.pdf",
"{{local:recipient_id}}/certificate.pdf",
"attachments/{{local:email}}/*.pdf"
];
export function createAttachmentBasePath(name = "New attachment source", path = "."): AttachmentBasePath { export function createAttachmentBasePath(name = "New attachment source", path = "."): AttachmentBasePath {
return { return {
@@ -79,10 +61,11 @@ export function createAttachmentBasePath(name = "New attachment source", path =
}; };
} }
export function createAttachmentRule(baseDir = "", label = ""): AttachmentRule { export function createAttachmentRule(baseDir = "", label = "", basePathId = ""): AttachmentRule {
return { return {
id: `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`, id: `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`,
label, label,
base_path_id: basePathId || undefined,
base_dir: baseDir, base_dir: baseDir,
file_filter: "", file_filter: "",
required: true, required: true,
@@ -136,6 +119,7 @@ export function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
return value.filter(isRecord).map((rule) => ({ return value.filter(isRecord).map((rule) => ({
id: getText(rule, "id", `attachment-${Math.random().toString(36).slice(2)}`), id: getText(rule, "id", `attachment-${Math.random().toString(36).slice(2)}`),
label: getText(rule, "label"), label: getText(rule, "label"),
base_path_id: getText(rule, "base_path_id"),
base_dir: getText(rule, "base_dir", ""), base_dir: getText(rule, "base_dir", ""),
file_filter: getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path"), file_filter: getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path"),
include_subdirs: getBool(rule, "include_subdirs"), include_subdirs: getBool(rule, "include_subdirs"),
@@ -173,67 +157,3 @@ export function isDirectAttachmentRule(rule: AttachmentRule): boolean {
if (!fileFilter) return false; if (!fileFilter) return false;
return !/[{}*?\[\]]/.test(fileFilter); return !/[{}*?\[\]]/.test(fileFilter);
} }
export function buildEffectiveAttachmentPreviews(draft: Record<string, unknown> | null, entry: Record<string, unknown>): EffectiveAttachmentPreview[] {
const attachments = asRecord(draft?.attachments);
const basePaths = normalizeAttachmentBasePaths(attachments.base_paths, attachments);
const context = buildTemplatePreviewContext(draft, entry);
const includeGlobals = getBool(entry, "combine_attachments", true);
const globalRules = includeGlobals ? normalizeAttachmentRules(attachments.global) : [];
const entryRules = normalizeAttachmentRules(entry.attachments);
const individualPaths = basePaths.filter((basePath) => basePath.allow_individual);
const legacyIndividualAllowed = getBool(attachments, "allow_individual", individualPaths.length === 0);
const items: EffectiveAttachmentPreview[] = [];
for (const rule of globalRules) {
items.push(ruleToAttachmentPreview("global", rule, basePaths, context));
}
for (const rule of entryRules) {
const basePathValue = getText(rule, "base_dir", basePaths[0]?.path ?? ".");
const allowed = individualPaths.length > 0
? individualPaths.some((basePath) => basePath.path === basePathValue)
: legacyIndividualAllowed;
if (allowed) items.push(ruleToAttachmentPreview("recipient", rule, basePaths, context));
}
return items;
}
function ruleToAttachmentPreview(
scope: EffectiveAttachmentPreview["scope"],
rule: AttachmentRule,
basePaths: AttachmentBasePath[],
context: Record<string, string>
): EffectiveAttachmentPreview {
const basePathValue = getText(rule, "base_dir", basePaths[0]?.path ?? ".");
const basePath = basePaths.find((item) => item.path === basePathValue);
const fileFilterTemplate = getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path");
const fileFilter = renderTemplatePreviewText(fileFilterTemplate, context, true);
return {
scope,
label: getText(rule, "label") || (scope === "global" ? "Global attachment" : "Recipient attachment"),
basePathName: basePath?.name || basePathValue || "Campaign files",
basePath: basePath?.path || basePathValue || ".",
fileFilter,
required: getBool(rule, "required", true),
includeSubdirs: getBool(rule, "include_subdirs"),
matches: previewFileMatches(fileFilter)
};
}
function previewFileMatches(fileFilter: string): string[] {
const value = fileFilter.trim();
if (!value) return [];
if (!/[{}*?\[\]]/.test(value)) {
return mockAttachmentFiles.filter((file) => file === value || file.endsWith(`/${value}`));
}
const pattern = globLikePatternToRegExp(value);
return mockAttachmentFiles.filter((file) => pattern.test(file));
}
function globLikePatternToRegExp(value: string): RegExp {
const escaped = value.replace(/[.+^${}()|\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp(`^${escaped}$`);
}