Attachment preview
This commit is contained in:
@@ -131,6 +131,51 @@ export type CampaignSendNowPayload = {
|
||||
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 = {
|
||||
version_id?: string | null;
|
||||
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> {
|
||||
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/summary`);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
|
||||
|
||||
function addGlobalAttachmentRule() {
|
||||
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 ?? "")]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -13,7 +14,6 @@ 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";
|
||||
|
||||
@@ -27,6 +27,9 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
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);
|
||||
@@ -64,13 +67,50 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
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]);
|
||||
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(() => {
|
||||
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);
|
||||
@@ -242,7 +282,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
|
||||
html={previewHtml}
|
||||
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."}
|
||||
attachments={mapEffectiveAttachmentsToPreviewBoxes(previewAttachments)}
|
||||
attachments={previewAttachments}
|
||||
navigation={{
|
||||
index: Math.min(previewIndex, previewEntries.length - 1),
|
||||
total: previewEntries.length,
|
||||
@@ -320,31 +360,44 @@ function UndefinedPlaceholderList({ items, onSelect }: { items: UndefinedPlaceho
|
||||
);
|
||||
}
|
||||
|
||||
function mapEffectiveAttachmentsToPreviewBoxes(attachments: EffectiveAttachmentPreview[]): MessagePreviewAttachment[] {
|
||||
return attachments.flatMap((attachment) => {
|
||||
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 = [
|
||||
attachment.scope === "global" ? "Global" : "Recipient",
|
||||
attachment.label,
|
||||
attachment.required ? "required" : "optional",
|
||||
attachment.includeSubdirs ? "subdirs" : ""
|
||||
rule.source === "global" ? "Global" : "Recipient",
|
||||
rule.label,
|
||||
rule.required ? "required" : "optional",
|
||||
rule.pattern
|
||||
].filter(Boolean);
|
||||
const detail = detailParts.join(" · ");
|
||||
if (attachment.matches.length > 0) {
|
||||
return attachment.matches.map((match) => ({
|
||||
filename: match,
|
||||
label: attachment.label,
|
||||
detail
|
||||
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
|
||||
}));
|
||||
}
|
||||
return [{
|
||||
filename: attachment.fileFilter || "No matched file",
|
||||
label: attachment.label,
|
||||
detail: `${detail}${detail ? " · " : ""}${attachment.basePathName || attachment.basePath || "attachment source"}`
|
||||
filename: `No file matched ${rule.pattern || "attachment pattern"}`,
|
||||
label: rule.label,
|
||||
detail: `${detail}${rule.status !== "ok" ? ` · ${rule.status}` : ""}`
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
function recipientLabel(entry: Record<string, unknown>, index: number): string {
|
||||
const name = valueToPreview(entry.name).trim();
|
||||
const email = valueToPreview(entry.email).trim();
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function AttachmentRulesOverlay({
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -110,7 +110,7 @@ export function AttachmentRulesTable({
|
||||
function addRule() {
|
||||
onChange([
|
||||
...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 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 });
|
||||
}
|
||||
|
||||
@@ -166,6 +167,7 @@ export function AttachmentRulesDataGrid({
|
||||
if (!fileChooser) return;
|
||||
const currentRule = rules[fileChooser.ruleIndex] ?? {};
|
||||
patchRule(fileChooser.ruleIndex, {
|
||||
base_path_id: fileChooser.basePath?.id ?? "",
|
||||
base_dir: fileChooser.basePath?.path ?? (selection.folderPath || "."),
|
||||
file_filter: selection.fileFilter,
|
||||
type: selection.selectionType === "file" ? "direct" : "pattern",
|
||||
@@ -223,13 +225,22 @@ function attachmentRuleColumns({ disabled, basePaths, activeChooserRuleIndex: _a
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
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 ? (
|
||||
<select value={currentBasePath} disabled={disabled} onChange={(event) => patchRule(index, { base_dir: event.target.value })}>
|
||||
{basePaths.map((basePath) => <option key={basePath.id} value={basePath.path}>{basePath.name || basePath.path}</option>)}
|
||||
<select
|
||||
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>
|
||||
) : (
|
||||
<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 ?? "")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { FileSpace } from "../../../api/files";
|
||||
import { asArray, asRecord, isRecord } from "./campaignView";
|
||||
import { getBool, getText } from "./draftEditor";
|
||||
import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./templatePlaceholders";
|
||||
|
||||
export type AttachmentRule = Record<string, unknown>;
|
||||
|
||||
@@ -38,17 +37,6 @@ export type AttachmentSummary = {
|
||||
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> & {
|
||||
label: string;
|
||||
};
|
||||
@@ -61,13 +49,7 @@ export const mockAttachmentPathOptions: MockAttachmentPathOption[] = [
|
||||
{ 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 {
|
||||
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 {
|
||||
id: `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
label,
|
||||
base_path_id: basePathId || undefined,
|
||||
base_dir: baseDir,
|
||||
file_filter: "",
|
||||
required: true,
|
||||
@@ -136,6 +119,7 @@ export function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
|
||||
return value.filter(isRecord).map((rule) => ({
|
||||
id: getText(rule, "id", `attachment-${Math.random().toString(36).slice(2)}`),
|
||||
label: getText(rule, "label"),
|
||||
base_path_id: getText(rule, "base_path_id"),
|
||||
base_dir: getText(rule, "base_dir", ""),
|
||||
file_filter: getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path"),
|
||||
include_subdirs: getBool(rule, "include_subdirs"),
|
||||
@@ -173,67 +157,3 @@ export function isDirectAttachmentRule(rule: AttachmentRule): boolean {
|
||||
if (!fileFilter) return false;
|
||||
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}$`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user