Release v0.1.3

This commit is contained in:
2026-06-26 01:39:19 +02:00
parent 23318c709a
commit ab2343aa88
22 changed files with 2176 additions and 197 deletions

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-campaign"
version = "0.1.2"
version = "0.1.3"
description = "GovOPlaN campaigns module with backend and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.2",
"govoplan-core>=0.1.3",
"jsonschema>=4,<5",
"pydantic>=2,<3",
"SQLAlchemy>=2,<3",

View File

@@ -109,7 +109,7 @@ def _campaigns_router(context: ModuleContext):
manifest = ModuleManifest(
id="campaigns",
name="Campaigns",
version="1.0.0",
version="0.1.2",
dependencies=("access",),
optional_dependencies=("files", "mail"),
permissions=PERMISSIONS,
@@ -165,4 +165,3 @@ manifest = ModuleManifest(
def get_manifest() -> ModuleManifest:
return manifest

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/campaign-workspace.css": "./src/styles/campaign-workspace.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.1",
"@govoplan/core-webui": "^0.1.2",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -22,7 +22,8 @@
},
"scripts": {
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js",
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js"
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js",
"test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js"
},
"devDependencies": {
"typescript": "^5.7.2"

View File

@@ -76,13 +76,22 @@ export function listFiles(settings: ApiSettings, params: { owner_type?: string;
return apiFetch<FileListResponse>(settings, `/api/v1/files${suffix}`);
}
export function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise<FileShare> {
return apiFetch<FileShare>(settings, `/api/v1/files/${fileId}/shares`, {
export type FileBulkShareResponse = { shares: FileShare[]; shared_count: number };
export function shareFilesWithCampaign(settings: ApiSettings, fileIds: string[], campaignId: string): Promise<FileBulkShareResponse> {
return apiFetch<FileBulkShareResponse>(settings, "/api/v1/files/bulk-shares", {
method: "POST",
body: JSON.stringify({ target_type: "campaign", target_id: campaignId, permission: "read" })
body: JSON.stringify({ file_ids: fileIds, target_type: "campaign", target_id: campaignId, permission: "read" })
});
}
export async function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise<FileShare> {
const response = await shareFilesWithCampaign(settings, [fileId], campaignId);
const share = response.shares[0];
if (!share) throw new Error("File share was not created.");
return share;
}
export function resolveFilePatterns(
settings: ApiSettings,
payload: { patterns: string[]; owner_type?: "user" | "group"; owner_id?: string; campaign_id?: string; path_prefix?: string; include_unmatched?: boolean; case_sensitive?: boolean }

View File

@@ -23,7 +23,7 @@ import TemplateExpressionEditorDialog from "./components/TemplateExpressionEdito
import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments";
import { insertAfter, moveArrayItem } from "@govoplan/core-webui";
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
import { recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
type PathChooserState = { index: number };
type IndividualDisableState = { index: number; usageCount: number };
@@ -61,6 +61,14 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message;
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
const attachmentPreviewEntry = useMemo(
() => asArray(asRecord(displayDraft.entries).inline).map(asRecord).find((entry) => entry.active !== false) ?? {},
[displayDraft.entries]
);
const attachmentPreviewContext = useMemo(
() => buildTemplatePreviewContext(displayDraft, attachmentPreviewEntry),
[attachmentPreviewEntry, displayDraft]
);
useEffect(() => {
if (!filesModuleInstalled) {
@@ -303,6 +311,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
campaignId={campaignId}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
previewContext={attachmentPreviewContext}
onChange={(rules) => patch(["attachments", "global"], rules)}
/>
</Card>

View File

@@ -26,7 +26,7 @@ import {
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { getBool, getNumber, getText } from "./utils/draftEditor";
import { cloneJson, getBool, getNumber, getText } from "./utils/draftEditor";
import { campaignMailSettingsPolicyState } from "./policyUi";
const securityOptions = mailServerSecurityOptions as readonly MailSecurity[];
@@ -70,7 +70,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
unsavedTitle: isPolicyView ? "Unsaved mail policy changes" : "Unsaved mail settings",
unsavedMessage: isPolicyView
? "Mail policy changes have unsaved draft changes. Save them before leaving, or discard them and continue."
: "Mail settings have unsaved changes. Save them before leaving, or discard them and continue."
: "Mail settings have unsaved changes. Save them before leaving, or discard them and continue.",
transformDraftBeforeSave: normalizeMailSettingsBeforeSave
});
const server = asRecord(displayDraft.server);
const smtp = asRecord(server.smtp);
@@ -165,6 +166,37 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
}
}
function normalizeMailSettingsBeforeSave(value: Record<string, unknown>): Record<string, unknown> {
if (mailModuleInstalled === false) return value;
const next = cloneJson(value);
const nextServer = { ...asRecord(next.server) };
const profileId = getText(nextServer, "mail_profile_id");
if (profileId.length === 0) return next;
const nextCredentials = { ...asRecord(nextServer.credentials) };
normalizeProfileCredentialProtocol(nextServer, nextCredentials, "smtp", effectiveMailPolicy?.smtp_credentials?.inherit === false ? false : true);
normalizeProfileCredentialProtocol(nextServer, nextCredentials, "imap", effectiveMailPolicy?.imap_credentials?.inherit === false ? false : true);
nextServer.credentials = nextCredentials;
next.server = nextServer;
return next;
}
function normalizeProfileCredentialProtocol(
serverValue: Record<string, unknown>,
credentialsValue: Record<string, unknown>,
protocol: "smtp" | "imap",
inherit: boolean
) {
serverValue["inherit_" + protocol + "_credentials"] = inherit;
if (inherit === false) return;
const transport = { ...asRecord(serverValue[protocol]) };
delete transport.username;
delete transport.password;
serverValue[protocol] = transport;
credentialsValue[protocol] = {};
}
function selectMailProfile(profileId: string) {
if (!mailModuleInstalled || locked) return;
if (!profileId && !campaignProfilesAllowed) {

View File

@@ -1,7 +1,8 @@
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings } from "../../types";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FileDropZone } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
@@ -10,11 +11,33 @@ import VersionLine from "./components/VersionLine";
import { ToggleSwitch } from "@govoplan/core-webui";
import { EmailAddressInput } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { getBool } from "./utils/draftEditor";
import { getDraftFields } from "./utils/fieldDefinitions";
import { normalizeAttachmentBasePaths, type AttachmentBasePath } from "./utils/attachments";
import {
buildImportTable,
buildRecipientImportPreview,
defaultColumnMappings,
importedRowsNeedAttachmentSource,
materializeRecipientImport,
type CsvDelimiter,
type ImportedAddress,
type RecipientColumnKind,
type RecipientColumnMapping,
type RecipientImportMode,
type RecipientImportPreview
} from "./utils/bulkImport";
import {
bulkLinkFilesToCampaign,
renderedImportPatterns,
resolveImportedAttachmentLinks,
type ImportFileLinkResolution
} from "./utils/fileLinking";
import {
addressesFromValue,
collectCampaignAddressSuggestions,
@@ -40,10 +63,13 @@ type EntryAddressColumn = {
export default function RecipientDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [importOpen, setImportOpen] = useState(false);
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
@@ -56,9 +82,13 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
});
const recipientsSection = asRecord(displayDraft.recipients);
const entries = asRecord(displayDraft.entries);
const attachments = asRecord(displayDraft.attachments);
const entryDefaults = asRecord(entries.defaults);
const inlineEntries = asArray(entries.inline).map(asRecord);
const source = asRecord(entries.source);
const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]);
const importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments, true), [attachments]);
const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]);
const addressSuggestions = useMemo(() => collectCampaignAddressSuggestions(displayDraft), [displayDraft]);
const defaultFrom = addressesFromValue(recipientsSection.from).slice(0, 1);
const globalReplyTo = addressesFromValue(recipientsSection.reply_to);
@@ -148,6 +178,38 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
replaceInlineEntries(moveArrayItem(inlineEntries, index, targetIndex));
}
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode) {
if (locked || !draft) return;
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
const currentAttachments = asRecord(draft.attachments);
let nextBasePaths = normalizeAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
let attachmentBasePath: AttachmentBasePath | null = null;
if (needsAttachmentSource) {
if (nextBasePaths.length === 0) {
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
}
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
}
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath });
const nextDraft = needsAttachmentSource
? {
...importedDraft,
attachments: {
...asRecord(importedDraft.attachments),
base_paths: nextBasePaths,
base_path: nextBasePaths[0]?.path || "."
}
}
: importedDraft;
setDraft(nextDraft);
markDirty();
setImportOpen(false);
}
return (
<div className="content-pad workspace-data-page">
@@ -244,7 +306,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
</div>
</Card>
<Card title="Recipient profiles" actions={<Button disabled>Import</Button>}>
<Card title="Recipient profiles" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>Import</Button>}>
{inlineEntries.length === 0 && Boolean(source.type) && (
<DismissibleAlert tone="info">This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented.</DismissibleAlert>
)}
@@ -252,22 +314,608 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
<div className="admin-table-surface recipient-profiles-table-surface">
<DataGrid
id={`campaign-${campaignId}-recipient-profiles`}
rows={inlineEntries.slice(0, 100)}
rows={inlineEntries}
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry })}
getRowKey={(entry, index) => String(entry.id || index)}
emptyText="No recipient profiles are stored in the current version yet."
emptyAction={<DataGridEmptyAction onAdd={() => addRecipient(-1)} disabled={locked} label="Add first recipient" />}
className="recipient-table-wrap recipient-address-table"
pagination={{
page: recipientProfilesPage,
pageSize: recipientProfilesPageSize,
pageSizeOptions: [10, 25, 50, 100, 250],
onPageChange: setRecipientProfilesPage,
onPageSizeChange: (pageSize) => {
setRecipientProfilesPageSize(pageSize);
setRecipientProfilesPage(1);
}
}}
/>
</div>
)}
</Card>
</>
</LoadingFrame>
{importOpen && (
<RecipientImportDialog
settings={settings}
campaignId={campaignId}
existingEntries={inlineEntries}
existingFields={fieldDefinitions}
defaultAttachmentBasePath={defaultImportBasePath}
onCancel={() => setImportOpen(false)}
onImport={applyRecipientImport}
/>
)}
</div>
);
}
type RecipientImportDialogProps = {
settings: ApiSettings;
campaignId: string;
existingEntries: Record<string, unknown>[];
existingFields: ReturnType<typeof getDraftFields>;
defaultAttachmentBasePath: AttachmentBasePath | null;
onCancel: () => void;
onImport: (preview: RecipientImportPreview, mode: RecipientImportMode) => void;
};
type RecipientImportStepId = "upload" | "parse" | "map" | "preview" | "files";
const recipientImportSteps: Array<{ id: RecipientImportStepId; label: string }> = [
{ id: "upload", label: "Upload" },
{ id: "parse", label: "Parse" },
{ id: "map", label: "Map" },
{ id: "preview", label: "Preview" },
{ id: "files", label: "Files" }
];
const recipientColumnKindOptions: Array<{ value: RecipientColumnKind; label: string }> = [
{ value: "ignore", label: "Ignore" },
{ value: "id", label: "ID" },
{ value: "active", label: "Active" },
{ value: "name", label: "Display name" },
{ value: "from", label: "From" },
{ value: "to", label: "To" },
{ value: "cc", label: "CC" },
{ value: "bcc", label: "BCC" },
{ value: "reply_to", label: "Reply-To" },
{ value: "field", label: "Existing field" },
{ value: "new_field", label: "New field" },
{ value: "attachment_pattern", label: "Attachment pattern" }
];
export function RecipientImportDialog({ settings, campaignId, existingEntries, existingFields, defaultAttachmentBasePath, onCancel, onImport }: RecipientImportDialogProps) {
const [activeStep, setActiveStep] = useState<RecipientImportStepId>("upload");
const [csvText, setCsvText] = useState("");
const [filename, setFilename] = useState("");
const [mode, setMode] = useState<RecipientImportMode>("append");
const [delimiter, setDelimiter] = useState<CsvDelimiter>("auto");
const [headerRows, setHeaderRows] = useState(1);
const [quoted, setQuoted] = useState(true);
const [valueSeparators, setValueSeparators] = useState(",;|");
const [mappings, setMappings] = useState<RecipientColumnMapping[]>([]);
const [fileError, setFileError] = useState("");
const [fileLinkResolution, setFileLinkResolution] = useState<ImportFileLinkResolution | null>(null);
const [fileLinkResolving, setFileLinkResolving] = useState(false);
const [fileLinking, setFileLinking] = useState(false);
const [fileLinkError, setFileLinkError] = useState("");
const [fileLinkNotice, setFileLinkNotice] = useState("");
const hasContent = csvText.trim().length > 0;
const table = useMemo(
() => hasContent ? buildImportTable(csvText, { delimiter, headerRows, quoted }) : null,
[csvText, delimiter, hasContent, headerRows, quoted]
);
const tableHeaderKey = table ? `${table.delimiter}:${table.firstDataRowNumber}:${table.headers.join("\u001f")}` : "";
const parseReady = Boolean(table && table.dataRows.some((row) => row.some((cell) => cell.trim())));
const preview = useMemo(
() => table ? buildRecipientImportPreview(table, mappings, { existingFields, existingEntries, valueSeparators }) : null,
[existingEntries, existingFields, mappings, table, valueSeparators]
);
const activeStepIndex = recipientImportSteps.findIndex((step) => step.id === activeStep);
const nextStep = recipientImportSteps[activeStepIndex + 1]?.id ?? null;
const previousStep = recipientImportSteps[activeStepIndex - 1]?.id ?? null;
const mappedColumnCount = mappings.filter((mapping) => mapping.kind !== "ignore").length;
const patternRows = preview?.rows.filter((row) => row.patterns.length > 0).length ?? 0;
const renderedPatterns = useMemo(() => preview ? renderedImportPatterns(preview) : [], [preview]);
useEffect(() => {
if (!table) {
setMappings([]);
return;
}
setMappings(defaultColumnMappings(table.headers, existingFields));
}, [existingFields, tableHeaderKey]);
useEffect(() => {
if (!hasContent) setActiveStep("upload");
}, [hasContent]);
useEffect(() => {
if (activeStep === "files") void refreshFileLinks();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeStep, defaultAttachmentBasePath?.id, defaultAttachmentBasePath?.path, defaultAttachmentBasePath?.source, renderedPatterns.length]);
async function readFile(file: File | undefined) {
if (!file) return;
setFilename(file.name);
setFileError("");
try {
setCsvText(await file.text());
setActiveStep("parse");
} catch (err) {
setFileError(err instanceof Error ? err.message : String(err));
}
}
function canOpenStep(stepId: RecipientImportStepId): boolean {
if (stepId === "upload") return true;
if (stepId === "parse") return hasContent;
if (stepId === "map") return parseReady;
return parseReady && mappings.length > 0;
}
function stepStatus(stepId: RecipientImportStepId): string {
if (stepId === "upload") return filename || (hasContent ? "Pasted data" : "Waiting");
if (stepId === "parse") return table ? `${table.dataRows.length} rows` : "Set parsing";
if (stepId === "map") return `${mappedColumnCount} mapped`;
if (stepId === "preview") return preview ? `${preview.validCount} valid` : "Review";
if (!preview || preview.patternCount === 0) return "No patterns";
if (fileLinkResolving) return "Checking";
if (fileLinkResolution) return `${fileLinkResolution.linkableFiles.length} to link`;
if (fileLinkError) return "Needs setup";
return `${renderedPatterns.length} patterns`;
}
function goNext() {
if (nextStep && canOpenStep(nextStep)) setActiveStep(nextStep);
}
async function refreshFileLinks() {
setFileLinkNotice("");
if (!preview || preview.patternCount === 0) {
setFileLinkResolution(null);
setFileLinkError("");
return;
}
if (!defaultAttachmentBasePath?.source) {
setFileLinkResolution(null);
setFileLinkError("The selected attachment source is not connected to a managed file space, so imported patterns cannot be bulk-linked here.");
return;
}
setFileLinkResolving(true);
setFileLinkError("");
try {
setFileLinkResolution(await resolveImportedAttachmentLinks(settings, campaignId, defaultAttachmentBasePath, preview));
} catch (err) {
setFileLinkResolution(null);
setFileLinkError(err instanceof Error ? err.message : String(err));
} finally {
setFileLinkResolving(false);
}
}
async function linkImportedFiles() {
if (!fileLinkResolution || fileLinkResolution.linkableFiles.length === 0) return;
setFileLinking(true);
setFileLinkError("");
setFileLinkNotice("");
try {
const linkedCount = await bulkLinkFilesToCampaign(settings, campaignId, fileLinkResolution.linkableFiles);
await refreshFileLinks();
setFileLinkNotice(`Linked ${linkedCount} file${linkedCount === 1 ? "" : "s"} to this campaign.`);
} catch (err) {
setFileLinkError(err instanceof Error ? err.message : String(err));
} finally {
setFileLinking(false);
}
}
function replaceColumnMapping(nextMapping: RecipientColumnMapping) {
const columnCount = table?.headers.length ?? 0;
setMappings((current) => {
const byColumn = new Map(current.map((mapping) => [mapping.columnIndex, mapping]));
byColumn.set(nextMapping.columnIndex, nextMapping);
return Array.from({ length: columnCount }, (_value, columnIndex) => byColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" });
});
}
function changeColumnKind(columnIndex: number, kind: RecipientColumnKind) {
const header = table?.headers[columnIndex] ?? `Column ${columnIndex + 1}`;
const current = mappings.find((mapping) => mapping.columnIndex === columnIndex);
const nextMapping: RecipientColumnMapping = { columnIndex, kind };
if (kind === "field") nextMapping.fieldName = current?.fieldName || existingFields[0]?.name || "";
if (kind === "new_field") nextMapping.newFieldName = current?.newFieldName || suggestImportFieldName(header);
replaceColumnMapping(nextMapping);
}
const stepContent = activeStep === "upload" ? (
<>
<div className="campaign-header-grid recipient-import-upload-grid">
<FormField label="Recipient file">
<FileDropZone
accept=".csv,.tsv,text/csv,text/tab-separated-values,text/plain"
multiple={false}
label="Drop recipient file here"
actionLabel="or click to select CSV"
note={filename || "CSV, TSV or text"}
onFiles={(files) => readFile(files[0])}
/>
</FormField>
<FormField label="Import mode">
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
<option value="append">Append to current profiles</option>
<option value="replace">Replace current profiles</option>
</select>
</FormField>
</div>
{fileError && <DismissibleAlert tone="danger" compact resetKey={fileError}>{fileError}</DismissibleAlert>}
<FormField label={filename ? `Raw content: ${filename}` : "Raw content"}>
<textarea
className="json-editor recipient-import-textarea"
value={csvText}
onChange={(event) => {
setCsvText(event.target.value);
if (!event.target.value.trim()) setFilename("");
}}
spellCheck={false}
/>
</FormField>
</>
) : activeStep === "parse" ? (
<>
<div className="campaign-header-grid">
<FormField label="Header rows">
<input
type="number"
min={0}
max={10}
value={headerRows}
onChange={(event) => setHeaderRows(Math.max(0, Number.parseInt(event.target.value, 10) || 0))}
/>
</FormField>
<FormField label="Separator">
<select value={delimiter} onChange={(event) => setDelimiter(event.target.value as CsvDelimiter)}>
<option value="auto">Auto</option>
<option value=",">Comma</option>
<option value=";">Semicolon</option>
<option value={"\t"}>Tab</option>
</select>
</FormField>
<div className="campaign-header-toggle recipient-import-toggles">
<ToggleSwitch label="Quoted contents" checked={quoted} onChange={setQuoted} />
</div>
</div>
{table && (
<>
<dl className="detail-list recipient-import-summary">
<div><dt>Separator</dt><dd>{formatDelimiter(table.delimiter)}</dd></div>
<div><dt>Header rows</dt><dd>{table.headerRows.length}</dd></div>
<div><dt>Data rows</dt><dd>{table.dataRows.length}</dd></div>
<div><dt>Columns</dt><dd>{table.headers.length}</dd></div>
</dl>
<RecipientImportRawTable tableRows={table.rows} headerRowCount={table.headerRows.length} columnCount={table.headers.length} />
</>
)}
</>
) : activeStep === "map" ? (
<>
<div className="campaign-header-grid recipient-import-map-controls">
<FormField label="Multi-value separators">
<input value={valueSeparators} onChange={(event) => setValueSeparators(event.target.value)} />
</FormField>
<FormField label="Mode">
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
<option value="append">Append</option>
<option value="replace">Replace</option>
</select>
</FormField>
</div>
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table recipient-import-mapping-table">
<thead>
<tr>
<th>Column</th>
<th>Sample</th>
<th>Content</th>
<th>Field</th>
</tr>
</thead>
<tbody>
{table?.headers.map((header, columnIndex) => {
const mapping = mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const };
return (
<tr key={`${columnIndex}-${header}`}>
<td><strong>{header}</strong></td>
<td className="mono-small">{sampleColumnValue(table.dataRows, columnIndex)}</td>
<td>
<select value={mapping.kind} onChange={(event) => changeColumnKind(columnIndex, event.target.value as RecipientColumnKind)}>
{recipientColumnKindOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</td>
<td>
{mapping.kind === "field" && (
<select
value={mapping.fieldName ?? ""}
onChange={(event) => replaceColumnMapping({ ...mapping, fieldName: event.target.value, newFieldName: undefined })}
>
<option value="">Select field</option>
{existingFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
</select>
)}
{mapping.kind === "new_field" && (
<input
value={mapping.newFieldName ?? ""}
onChange={(event) => replaceColumnMapping({ ...mapping, newFieldName: event.target.value, fieldName: undefined })}
/>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
) : activeStep === "preview" ? (
<>
{preview && (
<>
<dl className="detail-list recipient-import-summary">
<div><dt>Rows</dt><dd>{preview.validCount} valid / {preview.invalidCount} invalid</dd></div>
<div><dt>Fields</dt><dd>{preview.fieldNamesToCreate.length} new</dd></div>
<div><dt>Patterns</dt><dd>{preview.patternCount} from {patternRows} row(s)</dd></div>
<div><dt>Source</dt><dd>{preview.patternCount ? defaultAttachmentBasePath?.name ?? "Campaign files" : "No patterns"}</dd></div>
</dl>
{preview.fieldNamesToCreate.length > 0 && (
<p className="muted small-note">New fields: {preview.fieldNamesToCreate.join(", ")}</p>
)}
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table">
<thead>
<tr>
<th>#</th>
<th>To</th>
<th>Name</th>
<th>Fields</th>
<th>Patterns</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{preview.rows.slice(0, 20).map((row) => (
<tr key={row.rowNumber} className={row.issues.length ? "is-invalid" : undefined}>
<td>{row.rowNumber}</td>
<td>{formatAddressList(row.addresses.to)}</td>
<td>{row.name}</td>
<td>{Object.keys(row.fields).length}</td>
<td>{row.patterns.length}</td>
<td>{row.issues.length ? row.issues.join(", ") : "Ready"}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</>
) : (
<RecipientImportFileLinkStep
preview={preview}
basePath={defaultAttachmentBasePath}
resolution={fileLinkResolution}
resolving={fileLinkResolving}
linking={fileLinking}
error={fileLinkError}
notice={fileLinkNotice}
onRefresh={() => void refreshFileLinks()}
onLink={() => void linkImportedFiles()}
/>
);
return (
<Dialog
open
title="Import recipients"
className="recipient-import-modal"
bodyClassName="recipient-import-body"
closeDisabled={fileLinking || fileLinkResolving}
closeOnBackdrop={!fileLinking && !fileLinkResolving}
onClose={onCancel}
footer={(
<>
<Button onClick={onCancel} disabled={fileLinking || fileLinkResolving}>Cancel</Button>
{previousStep && <Button onClick={() => setActiveStep(previousStep)} disabled={fileLinking || fileLinkResolving}>Back</Button>}
{activeStep !== "files" ? (
<Button variant="primary" disabled={!nextStep || !canOpenStep(nextStep)} onClick={goNext}>Next</Button>
) : (
<Button variant="primary" disabled={!preview || preview.validCount === 0 || fileLinking || fileLinkResolving} onClick={() => preview && onImport(preview, mode)}>Import valid rows</Button>
)}
</>
)}
>
<nav className="recipient-import-steps" aria-label="Recipient import steps">
<div className="recipient-import-step-track">
{recipientImportSteps.map((step, index) => {
const stepIndex = recipientImportSteps.findIndex((item) => item.id === step.id);
const stepState = stepIndex < activeStepIndex ? "is-complete" : step.id === activeStep ? "is-current" : "";
return (
<div className="recipient-import-step-group" key={step.id}>
<button
type="button"
className={`recipient-import-step-item ${stepState}`.trim()}
disabled={!canOpenStep(step.id)}
aria-current={step.id === activeStep ? "step" : undefined}
onClick={() => setActiveStep(step.id)}
>
<span className="recipient-import-step-icon">{index + 1}</span>
<span className="recipient-import-step-copy">
<strong>{step.label}</strong>
<small>{stepStatus(step.id)}</small>
</span>
</button>
{index < recipientImportSteps.length - 1 && <span className="recipient-import-step-line" aria-hidden="true" />}
</div>
);
})}
</div>
</nav>
<section className="recipient-import-step-panel">
{stepContent}
</section>
</Dialog>
);
}
type RecipientImportFileLinkStepProps = {
preview: RecipientImportPreview | null;
basePath: AttachmentBasePath | null;
resolution: ImportFileLinkResolution | null;
resolving: boolean;
linking: boolean;
error: string;
notice: string;
onRefresh: () => void;
onLink: () => void;
};
function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving, linking, error, notice, onRefresh, onLink }: RecipientImportFileLinkStepProps) {
const linkableIds = new Set(resolution?.linkableFiles.map((file) => file.id) ?? []);
if (!preview || preview.patternCount === 0) {
return <DismissibleAlert tone="info" dismissible={false}>No attachment patterns were imported. There are no files to link for this import.</DismissibleAlert>;
}
return (
<div className="recipient-import-file-step">
<div className="recipient-import-file-actions">
<div>
<strong>{basePath?.name ?? "Campaign files"}</strong>
<p className="muted small-note">Files matched by imported recipient attachment patterns should be linked to the campaign before send preview and sending can consider them.</p>
</div>
<div className="button-row compact-actions">
<Button onClick={onRefresh} disabled={resolving || linking}>{resolving ? "Checking…" : "Refresh matches"}</Button>
<Button variant="primary" onClick={onLink} disabled={resolving || linking || !resolution || resolution.linkableFiles.length === 0}>{linking ? "Linking…" : `Link ${resolution?.linkableFiles.length ?? 0} file(s)`}</Button>
</div>
</div>
{error && <DismissibleAlert tone="warning" compact resetKey={error}>{error}</DismissibleAlert>}
{notice && <DismissibleAlert tone="success" compact resetKey={notice}>{notice}</DismissibleAlert>}
{resolving && <DismissibleAlert tone="info" compact dismissible={false}>Resolving imported file patterns</DismissibleAlert>}
{resolution && (
<>
<dl className="detail-list recipient-import-summary">
<div><dt>Patterns</dt><dd>{resolution.patterns.length}</dd></div>
<div><dt>Matched files</dt><dd>{resolution.files.length}</dd></div>
<div><dt>Already linked</dt><dd>{resolution.linkedFiles.length}</dd></div>
<div><dt>Need linking</dt><dd>{resolution.linkableFiles.length}</dd></div>
</dl>
{resolution.unmatchedPatterns.length > 0 && (
<div className="recipient-import-unmatched-patterns">
<strong>Patterns without matches</strong>
<ul>
{resolution.unmatchedPatterns.slice(0, 8).map((pattern) => (
<li key={pattern.key}>Row {pattern.rowNumber}: <code>{pattern.renderedPattern}</code></li>
))}
</ul>
{resolution.unmatchedPatterns.length > 8 && <p className="muted small-note">{resolution.unmatchedPatterns.length - 8} more unmatched pattern(s).</p>}
</div>
)}
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table recipient-import-file-link-table">
<thead>
<tr>
<th>File</th>
<th>Path</th>
<th>Size</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{resolution.files.length === 0 ? (
<tr><td colSpan={4}>No files currently match the imported patterns.</td></tr>
) : resolution.files.map((file) => (
<tr key={file.id}>
<td><strong>{file.filename}</strong></td>
<td>{file.display_path}</td>
<td>{formatImportBytes(file.size_bytes)}</td>
<td>{linkableIds.has(file.id) ? "Needs linking" : "Linked"}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
);
}
type RecipientImportRawTableProps = {
tableRows: string[][];
headerRowCount: number;
columnCount: number;
};
function RecipientImportRawTable({ tableRows, headerRowCount, columnCount }: RecipientImportRawTableProps) {
const visibleRows = tableRows.slice(0, 15);
const safeColumnCount = Math.max(1, columnCount, ...visibleRows.map((row) => row.length));
return (
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table recipient-import-raw-table">
<thead>
<tr>
<th>#</th>
{Array.from({ length: safeColumnCount }, (_value, index) => <th key={index}>{index + 1}</th>)}
</tr>
</thead>
<tbody>
{visibleRows.length === 0 ? (
<tr><td colSpan={safeColumnCount + 1}>No rows parsed.</td></tr>
) : visibleRows.map((row, rowIndex) => (
<tr key={rowIndex} className={rowIndex < headerRowCount ? "is-header-row" : undefined}>
<td>{rowIndex + 1}</td>
{Array.from({ length: safeColumnCount }, (_value, columnIndex) => <td key={columnIndex}>{row[columnIndex] ?? ""}</td>)}
</tr>
))}
</tbody>
</table>
</div>
);
}
function formatDelimiter(delimiter: "," | ";" | "\t"): string {
if (delimiter === "\t") return "Tab";
if (delimiter === ";") return "Semicolon";
return "Comma";
}
function sampleColumnValue(rows: string[][], columnIndex: number): string {
return rows.map((row) => String(row[columnIndex] ?? "").trim()).find(Boolean) ?? "";
}
function formatAddressList(addresses: ImportedAddress[]): string {
return addresses.map((address) => address.name ? `${address.name} <${address.email}>` : address.email).join(", ");
}
function formatImportBytes(value?: number | null): string {
if (!value) return "";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}
function suggestImportFieldName(value: string): string {
const cleaned = value.trim().replace(/^fields[._-]+/i, "").replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
return cleaned || "field";
}
type RecipientProfileColumnContext = {
locked: boolean;
entries: Record<string, unknown>[];

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types";
@@ -14,7 +14,10 @@ import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
import { getDraftFields } from "./utils/fieldDefinitions";
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
import { importedRowsNeedAttachmentSource, materializeRecipientImport, type RecipientImportMode, type RecipientImportPreview } from "./utils/bulkImport";
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
import { RecipientImportDialog } from "./RecipientDataPage";
import { addressesFromValue } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
@@ -23,10 +26,13 @@ import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
export default function RecipientDetailsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const filesModuleInstalled = usePlatformModuleInstalled("files");
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [importOpen, setImportOpen] = useState(false);
const [recipientDataPage, setRecipientDataPage] = useState(1);
const [recipientDataPageSize, setRecipientDataPageSize] = useState(10);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, displayDraft, dirty, saveState, localError, patch, saveDraft } = useCampaignDraftEditor({
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
@@ -44,6 +50,8 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
const attachmentSection = asRecord(displayDraft.attachments);
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection), [attachmentSection]);
const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]);
const importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachmentSection.base_paths, attachmentSection, true), [attachmentSection]);
const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]);
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachmentSection.zip), [attachmentSection.zip]);
@@ -70,6 +78,38 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
updateEntry(index, (entry) => ({ ...entry, attachments }));
}
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode) {
if (locked || !draft) return;
const needsAttachmentSource = importedRowsNeedAttachmentSource(preview);
const currentAttachments = asRecord(draft.attachments);
let nextBasePaths = normalizeAttachmentBasePaths(currentAttachments.base_paths, currentAttachments, true);
let attachmentBasePath: AttachmentBasePath | null = null;
if (needsAttachmentSource) {
if (nextBasePaths.length === 0) {
nextBasePaths = [{ id: "base-path-campaign", name: "Campaign files", path: ".", allow_individual: true, unsent_warning: false }];
}
const selectedIndex = Math.max(0, nextBasePaths.findIndex((basePath) => basePath.allow_individual));
nextBasePaths = nextBasePaths.map((basePath, index) => index === selectedIndex ? { ...basePath, allow_individual: true } : basePath);
attachmentBasePath = nextBasePaths[selectedIndex] ?? null;
}
const importedDraft = materializeRecipientImport(draft, preview, { mode, attachmentBasePath });
const nextDraft = needsAttachmentSource
? {
...importedDraft,
attachments: {
...asRecord(importedDraft.attachments),
base_paths: nextBasePaths,
base_path: nextBasePaths[0]?.path || "."
}
}
: importedDraft;
setDraft(nextDraft);
markDirty();
setImportOpen(false);
}
return (
<div className="content-pad workspace-data-page">
@@ -90,7 +130,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
<LoadingFrame loading={loading || !draft} label="Loading campaign draft…">
<>
<Card title="Recipient field values and attachments">
<Card title="Recipient field values and attachments" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>Import</Button>}>
{inlineEntries.length === 0 && !source.type && <p className="muted">No recipient profiles are stored in the current version yet. Add recipients first, then maintain their data here.</p>}
{inlineEntries.length === 0 && Boolean(source.type) && (
<DismissibleAlert tone="info">This campaign references an external recipient source. A parsed data preview will be added when file/source preview support is implemented.</DismissibleAlert>
@@ -99,17 +139,39 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
<div className="admin-table-surface recipient-data-table-surface">
<DataGrid
id={`campaign-${campaignId}-recipient-data`}
rows={inlineEntries.slice(0, 100)}
columns={recipientDataColumns({ settings, campaignId, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })}
rows={inlineEntries}
columns={recipientDataColumns({ settings, campaignId, draft: displayDraft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField })}
getRowKey={(entry, index) => String(entry.id || index)}
emptyText="No recipient data found."
className="recipient-table-wrap recipient-data-table-wrap recipient-data-table"
pagination={{
page: recipientDataPage,
pageSize: recipientDataPageSize,
pageSizeOptions: [10, 25, 50, 100, 250],
onPageChange: setRecipientDataPage,
onPageSizeChange: (pageSize) => {
setRecipientDataPageSize(pageSize);
setRecipientDataPage(1);
}
}}
/>
</div>
)}
</Card>
</>
</LoadingFrame>
{importOpen && (
<RecipientImportDialog
settings={settings}
campaignId={campaignId}
existingEntries={inlineEntries}
existingFields={fieldDefinitions}
defaultAttachmentBasePath={defaultImportBasePath}
onCancel={() => setImportOpen(false)}
onImport={applyRecipientImport}
/>
)}
</div>
);
}
@@ -117,6 +179,7 @@ export default function RecipientDetailsPage({ settings, campaignId }: { setting
type RecipientDataColumnContext = {
settings: ApiSettings;
campaignId: string;
draft: Record<string, unknown>;
locked: boolean;
filesModuleInstalled: boolean;
fieldDefinitions: ReturnType<typeof getDraftFields>;
@@ -126,7 +189,7 @@ type RecipientDataColumnContext = {
updateEntryField: (index: number, field: string, value: unknown) => void;
};
function recipientDataColumns({ settings, campaignId, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
function recipientDataColumns({ settings, campaignId, draft, locked, filesModuleInstalled, fieldDefinitions, individualAttachmentBasePaths, zipConfig, updateEntryAttachments, updateEntryField }: RecipientDataColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small recipient-index-cell">{index + 1}</span>, value: (_entry, index) => index + 1 },
{
@@ -163,6 +226,7 @@ function recipientDataColumns({ settings, campaignId, locked, filesModuleInstall
basePaths={individualAttachmentBasePaths}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
previewContext={buildTemplatePreviewContext(draft, entry)}
onChange={(rules) => updateEntryAttachments(index, rules)}
/>
);

View File

@@ -30,6 +30,7 @@ import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import { Button, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid";
import { DismissibleAlert } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
@@ -1351,28 +1352,26 @@ function DeliveryJobDetailOverlay({ detail, onClose }: { detail: Record<string,
const smtpAttempts = asArray(attempts.smtp).map(asRecord);
const imapAttempts = asArray(attempts.imap).map(asRecord);
const issues = asArray(job.issues).map(asRecord);
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="delivery-job-detail-title">
<div className="modal-panel template-preview-modal message-preview-modal">
<header className="modal-header">
<h2 id="delivery-job-detail-title">Delivery job details</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
<dl className="detail-list">
<div><dt>Recipient</dt><dd>{formatAddressList(asRecord(job.resolved_recipients).to) || String(job.recipient_email ?? "-")}</dd></div>
<div><dt>Subject</dt><dd>{String(job.subject ?? "-")}</dd></div>
<div><dt>SMTP status</dt><dd>{humanize(String(job.send_status ?? "-"))}</dd></div>
<div><dt>IMAP status</dt><dd>{humanize(String(job.imap_status ?? "-"))}</dd></div>
{job.last_error ? <div><dt>Last error</dt><dd>{String(job.last_error)}</dd></div> : null}
</dl>
{issues.length > 0 && <AttemptList title="Message issues" rows={issues} />}
<AttemptList title="SMTP attempts" rows={smtpAttempts} emptyText="No SMTP attempts were recorded." />
<AttemptList title="IMAP attempts" rows={imapAttempts} emptyText="No IMAP append attempts were recorded. If status is pending, append has not run yet." />
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
</div>
</div>
<Dialog
open
title="Delivery job details"
className="template-preview-modal"
onClose={onClose}
footer={<Button variant="primary" onClick={onClose}>Close</Button>}
>
<dl className="detail-list">
<div><dt>Recipient</dt><dd>{formatAddressList(asRecord(job.resolved_recipients).to) || String(job.recipient_email ?? "-")}</dd></div>
<div><dt>Subject</dt><dd>{String(job.subject ?? "-")}</dd></div>
<div><dt>SMTP status</dt><dd>{humanize(String(job.send_status ?? "-"))}</dd></div>
<div><dt>IMAP status</dt><dd>{humanize(String(job.imap_status ?? "-"))}</dd></div>
{job.last_error ? <div><dt>Last error</dt><dd>{String(job.last_error)}</dd></div> : null}
</dl>
{issues.length > 0 && <AttemptList title="Message issues" rows={issues} />}
<AttemptList title="SMTP attempts" rows={smtpAttempts} emptyText="No SMTP attempts were recorded." />
<AttemptList title="IMAP attempts" rows={imapAttempts} emptyText="No IMAP append attempts were recorded. If status is pending, append has not run yet." />
</Dialog>
);
}

View File

@@ -17,7 +17,7 @@ import { asArray, asRecord, formatDateTime, isAuditLockedVersion } from "./utils
import { cloneJson, getBool, getText } from "./utils/draftEditor";
import { humanizeFieldName } from "./utils/fieldDefinitions";
import { campaignJsonForAttachmentPreview } from "./utils/templatePreviewDraft";
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, replacePlaceholderInText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
type TemplateBodyMode = "text" | "html" | "both";
type BodyEditorMode = "text" | "html";
@@ -207,6 +207,22 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
setUndefinedDialog(null);
}
function replacePlaceholder(field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) {
if (locked) return;
const replacement = `{{${namespace}:${name}}}`;
setDraft((current) => {
const next = cloneJson(current ?? {});
const nextTemplate = { ...asRecord(next.template) };
nextTemplate.subject = replacePlaceholderInText(getText(nextTemplate, "subject"), field.raw, replacement);
nextTemplate.text = replacePlaceholderInText(getText(nextTemplate, "text"), field.raw, replacement);
nextTemplate.html = replacePlaceholderInText(getText(nextTemplate, "html"), field.raw, replacement);
next.template = nextTemplate;
return next;
});
markDirty();
setUndefinedDialog(null);
}
return (
<div className="content-pad workspace-data-page">
@@ -349,7 +365,10 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
removeLabel="Remove from template"
onCancel={() => setUndefinedDialog(null)}
onRemove={removePlaceholder}
onReplace={replacePlaceholder}
onAddField={addUndefinedField}
localFields={localFieldOptions}
globalFields={globalFieldOptions}
/>
</div>
);

View File

@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import type { ApiSettings } from "../../../types";
import { Button } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../../components/table/DataGrid";
import { ToggleSwitch } from "@govoplan/core-webui";
import { getBool, getText } from "../utils/draftEditor";
@@ -23,6 +24,7 @@ type AttachmentRulesOverlayProps = {
basePaths?: AttachmentBasePath[];
zipConfig?: AttachmentZipCollection;
filesModuleInstalled?: boolean;
previewContext?: Record<string, string>;
onChange: (rules: AttachmentRule[]) => void;
};
@@ -37,6 +39,7 @@ type AttachmentRulesTableProps = {
activeChooserRuleIndex?: number | null;
zipConfig?: AttachmentZipCollection;
filesModuleInstalled?: boolean;
previewContext?: Record<string, string>;
onOpenFileChooser?: (ruleIndex: number) => void;
onChange: (rules: AttachmentRule[]) => void;
};
@@ -57,6 +60,7 @@ export default function AttachmentRulesOverlay({
basePaths = [],
zipConfig = { enabled: false, archives: [] },
filesModuleInstalled = false,
previewContext,
onChange
}: AttachmentRulesOverlayProps) {
const [open, setOpen] = useState(false);
@@ -80,32 +84,33 @@ export default function AttachmentRulesOverlay({
}
const dialog = open ? createPortal(
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="attachment-rules-title">
<div className="modal-panel attachment-rules-modal">
<header className="modal-header">
<h2 id="attachment-rules-title">{title}</h2>
<button className="modal-close" aria-label="Cancel attachment changes" onClick={cancelOverlay}>×</button>
</header>
<div className="modal-body attachment-rules-body">
<AttachmentRulesTable
rules={draftRules}
settings={settings}
campaignId={campaignId}
disabled={disabled}
emptyText={emptyText}
basePaths={basePaths}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
activeChooserRuleIndex={null}
onChange={setDraftRules}
/>
</div>
<footer className="modal-footer">
<Dialog
open
title={title}
className="attachment-rules-modal"
bodyClassName="attachment-rules-body"
onClose={cancelOverlay}
footer={(
<>
<Button onClick={cancelOverlay}>Cancel</Button>
<Button variant="primary" onClick={saveOverlay} disabled={disabled}>Save</Button>
</footer>
</div>
</div>,
</>
)}
>
<AttachmentRulesTable
rules={draftRules}
settings={settings}
campaignId={campaignId}
disabled={disabled}
emptyText={emptyText}
basePaths={basePaths}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
previewContext={previewContext}
activeChooserRuleIndex={null}
onChange={setDraftRules}
/>
</Dialog>,
document.body
) : null;
@@ -150,6 +155,7 @@ export function AttachmentRulesDataGrid({
activeChooserRuleIndex = null,
zipConfig = { enabled: false, archives: [] },
filesModuleInstalled = false,
previewContext,
onOpenFileChooser,
onChange
}: AttachmentRulesTableProps) {
@@ -230,6 +236,7 @@ export function AttachmentRulesDataGrid({
basePath={fileChooser.basePath?.path ?? "."}
initialPattern={getText(rules[fileChooser.ruleIndex], "file_filter")}
rememberKey={`${id}:${String(rules[fileChooser.ruleIndex]?.id ?? fileChooser.ruleIndex)}`}
previewContext={previewContext}
onClose={() => setFileChooser(null)}
onSelectAttachment={selectAttachment}
/>

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ArrowDown, ArrowUp, ArrowUpDown, File, Folder, FolderOpen, Home, Link2, Search } from "lucide-react";
import { usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui";
@@ -8,7 +8,7 @@ import {
listFiles,
listFolders,
resolveFilePatterns,
shareFileWithCampaign,
shareFilesWithCampaign,
type FileFolder,
type FileSpace,
type ManagedFile
@@ -35,6 +35,7 @@ import {
parseManagedAttachmentSource,
type ManagedAttachmentSource
} from "../utils/attachments";
import { renderTemplatePreviewText } from "../utils/templatePlaceholders";
type ManagedFileChooserMode = "folder" | "attachment";
type AttachmentChoiceMode = "file" | "pattern";
@@ -45,6 +46,8 @@ type RememberedChooserState = {
pattern?: string;
};
type ChooserExplorerEntry = ReturnType<typeof buildExplorerEntries>[number];
export type ManagedFolderSelection = {
space: FileSpace;
folderPath: string;
@@ -67,6 +70,7 @@ type ManagedFileChooserProps = {
basePath?: string;
initialPattern?: string;
rememberKey?: string;
previewContext?: Record<string, string>;
onClose: () => void;
onSelectFolder?: (selection: ManagedFolderSelection) => void;
onSelectAttachment?: (selection: ManagedAttachmentSelection) => void;
@@ -81,6 +85,7 @@ export default function ManagedFileChooser({
basePath = ".",
initialPattern = "",
rememberKey = mode,
previewContext,
onClose,
onSelectFolder,
onSelectAttachment
@@ -100,6 +105,10 @@ export default function ManagedFileChooser({
const [folders, setFolders] = useState<FileFolder[]>([]);
const [currentFolder, setCurrentFolder] = useState("");
const [pattern, setPattern] = useState("");
const patternRef = useRef("");
const rememberDraftTimerRef = useRef<number | null>(null);
const [patternHasText, setPatternHasText] = useState(false);
const [exactSelectionPattern, setExactSelectionPattern] = useState("");
const [patternMatches, setPatternMatches] = useState<ManagedFile[] | null>(null);
const [pendingExactFile, setPendingExactFile] = useState<ManagedFile | null>(null);
const [loading, setLoading] = useState(false);
@@ -126,18 +135,70 @@ export default function ManagedFileChooser({
const breadcrumbs = chooserBreadcrumbs(currentFolder, effectiveRoot);
const allTreeNodes = useMemo(() => buildFolderTree(files, folders), [files, folders]);
const treeNodes = useMemo(() => nodesWithinRoot(allTreeNodes, effectiveRoot), [allTreeNodes, effectiveRoot]);
const openFolder = useCallback((path: string) => {
const normalized = normalizeFolder(path);
if (mode === "attachment" && effectiveRoot && !isWithinRoot(normalized, effectiveRoot)) return;
setCurrentFolder(normalized);
setPatternMatches(null);
}, [effectiveRoot, mode]);
const { expandedTreeNodes, toggleTreeFolder } = useFileTreeState({
activeSpaceId: selectedSpaceId,
currentFolder,
onOpenFolder: (_spaceId, path) => openFolder(path)
});
const toggleSort = useCallback((column: SortColumn) => {
if (sortColumn === column) {
setSortDirection((current) => current === "asc" ? "desc" : "asc");
return;
}
setSortColumn(column);
setSortDirection(column === "name" ? "asc" : "desc");
}, [sortColumn]);
const applyExactFile = useCallback((file: ManagedFile) => {
const exactPattern = relativePath(file.display_path, effectiveRoot);
patternRef.current = exactPattern;
setPattern(exactPattern);
setPatternHasText(true);
setExactSelectionPattern(exactPattern);
setPatternMatches([file]);
setPendingExactFile(null);
}, [effectiveRoot]);
const scheduleRememberedState = useCallback((patternValue = patternRef.current) => {
if (!open || !selectedSpaceId) return;
if (rememberDraftTimerRef.current !== null) window.clearTimeout(rememberDraftTimerRef.current);
rememberDraftTimerRef.current = window.setTimeout(() => {
writeRememberedState(storageKey, { spaceId: selectedSpaceId, folder: currentFolder, pattern: patternValue });
rememberDraftTimerRef.current = null;
}, 350);
}, [currentFolder, open, selectedSpaceId, storageKey]);
useEffect(() => () => {
if (rememberDraftTimerRef.current !== null) window.clearTimeout(rememberDraftTimerRef.current);
}, []);
const requestExactFile = useCallback((file: ManagedFile) => {
const exactPattern = relativePath(file.display_path, effectiveRoot);
const currentPattern = patternRef.current.trim();
if (currentPattern && currentPattern !== exactPattern) {
setPendingExactFile(file);
return;
}
applyExactFile(file);
}, [applyExactFile, effectiveRoot]);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError("");
setPattern(remembered.pattern ?? initialPattern.trim());
const initialPatternValue = remembered.pattern ?? initialPattern.trim();
patternRef.current = initialPatternValue;
setPattern(initialPatternValue);
setPatternHasText(Boolean(initialPatternValue.trim()));
setExactSelectionPattern("");
setPatternMatches(null);
setPendingExactFile(null);
void listFileSpaces(settings)
@@ -186,38 +247,42 @@ export default function ManagedFileChooser({
}, [effectiveRoot, open, selectedSpace?.id, settings.apiBaseUrl, settings.apiKey, settings.accessToken, storageKey]);
useEffect(() => {
if (!open || !selectedSpaceId) return;
writeRememberedState(storageKey, { spaceId: selectedSpaceId, folder: currentFolder, pattern });
}, [currentFolder, open, pattern, selectedSpaceId, storageKey]);
scheduleRememberedState();
}, [currentFolder, pattern, scheduleRememberedState]);
function toggleSort(column: SortColumn) {
if (sortColumn === column) {
setSortDirection((current) => current === "asc" ? "desc" : "asc");
return;
}
setSortColumn(column);
setSortDirection(column === "name" ? "asc" : "desc");
}
function openFolder(path: string) {
const normalized = normalizeFolder(path);
if (mode === "attachment" && effectiveRoot && !isWithinRoot(normalized, effectiveRoot)) return;
setCurrentFolder(normalized);
setPatternMatches(null);
function updatePatternInput(value: string) {
patternRef.current = value;
setPatternHasText((current) => {
const next = Boolean(value.trim());
return current === next ? current : next;
});
setExactSelectionPattern("");
if (patternMatches !== null) setPatternMatches(null);
scheduleRememberedState(value);
}
async function previewPattern(): Promise<ManagedFile[]> {
if (!selectedSpace) return [];
const trimmed = pattern.trim();
const trimmed = patternRef.current.trim();
if (!trimmed) {
setPatternMatches([]);
setPattern("");
setPatternHasText(false);
return [];
}
const previewPatternValue = renderChooserPattern(trimmed, previewContext);
if (!previewPatternValue) {
setPatternMatches([]);
setPattern(trimmed);
return [];
}
setPattern(trimmed);
setPatternHasText(true);
setResolving(true);
setError("");
try {
const response = await resolveFilePatterns(settings, {
patterns: [trimmed],
patterns: [previewPatternValue],
owner_type: selectedSpace.owner_type,
owner_id: selectedSpace.owner_id,
path_prefix: effectiveRoot || undefined,
@@ -234,27 +299,14 @@ export default function ManagedFileChooser({
}
}
function requestExactFile(file: ManagedFile) {
const exactPattern = relativePath(file.display_path, effectiveRoot);
const currentPattern = pattern.trim();
if (currentPattern && currentPattern !== exactPattern) {
setPendingExactFile(file);
return;
}
applyExactFile(file);
}
function applyExactFile(file: ManagedFile) {
setPattern(relativePath(file.display_path, effectiveRoot));
setPatternMatches([file]);
setPendingExactFile(null);
}
async function shareMatches(matches: ManagedFile[]) {
const toShare = matches.filter((file) => !file.shares?.some((share) => (
share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at
)));
await Promise.all(toShare.map((file) => shareFileWithCampaign(settings, file.id, campaignId)));
const fileIds = matches
.filter((file) => !file.shares?.some((share) => (
share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at
)))
.map((file) => file.id);
const uniqueIds = [...new Set(fileIds)];
if (uniqueIds.length > 0) await shareFilesWithCampaign(settings, uniqueIds, campaignId);
}
async function confirmSelection() {
@@ -273,7 +325,7 @@ export default function ManagedFileChooser({
const matches = patternMatches ?? await previewPattern();
await shareMatches(matches);
const trimmedPattern = pattern.trim();
const trimmedPattern = patternRef.current.trim();
onSelectAttachment?.({
space: selectedSpace,
folderPath: effectiveRoot,
@@ -309,7 +361,7 @@ export default function ManagedFileChooser({
<Button
variant="primary"
onClick={() => void confirmSelection()}
disabled={loading || submitting || !selectedSpace || !sourceMatchesSpace || (mode === "attachment" && (!parsedSource || !pattern.trim()))}
disabled={loading || submitting || !selectedSpace || !sourceMatchesSpace || (mode === "attachment" && (!parsedSource || !patternHasText))}
>
{submitting ? "Linking…" : mode === "folder" ? "Use this folder" : "Use pattern"}
</Button>
@@ -384,90 +436,35 @@ export default function ManagedFileChooser({
</div>
{mode === "attachment" && (
<div className="managed-pattern-editor">
<label>
<span>File or pattern relative to <code>{effectiveRoot || "."}</code></span>
<div className="field-with-action split-field-action">
<input
value={pattern}
onChange={(event) => { setPattern(event.target.value); setPatternMatches(null); }}
onKeyDown={(event) => { if (event.key === "Enter") void previewPattern(); }}
placeholder="folder/file.pdf or **/*.pdf"
/>
<Button onClick={() => void previewPattern()} disabled={resolving || !pattern.trim()}>
<Search size={15} aria-hidden="true" /> {resolving ? "Checking…" : "Preview"}
</Button>
</div>
</label>
{patternMatches !== null && (
<div className="managed-pattern-summary">
<span>{patternMatches.length} current file{patternMatches.length === 1 ? "" : "s"} match.</span>
<Button variant="ghost" onClick={() => setPatternMatches(null)}>Back to folder</Button>
</div>
)}
</div>
<ManagedPatternEditor
value={pattern}
effectiveRoot={effectiveRoot}
resolving={resolving}
matchCount={patternMatches?.length ?? null}
previewContext={previewContext}
onDraftChange={updatePatternInput}
onPreview={() => void previewPattern()}
onBackToFolder={() => setPatternMatches(null)}
/>
)}
{patternMatches !== null && mode === "attachment" ? (
<PatternResultList files={patternMatches} campaignId={campaignId} onChoose={requestExactFile} />
) : (
<div className="managed-file-entry-table">
<div className="managed-file-entry-head" role="row">
<span aria-hidden="true" />
<ChooserSortButton column="name" label="Name" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
<ChooserSortButton column="size" label="Size" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
<ChooserSortButton column="modified" label="Modified" activeColumn={sortColumn} direction={sortDirection} onSort={toggleSort} />
</div>
<div className="managed-file-entry-list" role="listbox" aria-label={mode === "folder" ? "Folders and files" : "Managed files"}>
<button
type="button"
className="managed-file-entry is-folder is-parent"
onClick={() => openFolder(parentWithinRoot(currentFolder, effectiveRoot))}
disabled={loading || currentFolder === effectiveRoot}
>
<FolderOpen size={18} aria-hidden="true" />
<span className="managed-file-entry-name"><strong>..</strong><small>Parent folder</small></span>
<span className="managed-file-entry-size"></span>
<span className="managed-file-entry-modified"></span>
</button>
{sortedEntries.map((entry) => {
if (entry.kind === "folder") {
return (
<button type="button" key={entry.id} className="managed-file-entry is-folder" onClick={() => openFolder(entry.path)}>
<Folder size={18} aria-hidden="true" />
<span className="managed-file-entry-name"><strong>{entry.name}</strong><small>{entry.fileCount} file(s), {entry.folderCount} folder(s)</small></span>
<span className="managed-file-entry-size">{formatBytes(entry.totalSize)}</span>
<span className="managed-file-entry-modified">{formatDate(entry.updatedAt)}</span>
</button>
);
}
const exactPattern = relativePath(entry.file.display_path, effectiveRoot);
const selected = mode === "attachment" && pattern.trim() === exactPattern;
const campaignShared = isSharedWithCampaign(entry.file, campaignId);
return (
<button
type="button"
key={entry.file.id}
className={`managed-file-entry is-file ${selected ? "is-selected" : ""} ${mode === "folder" ? "is-context-only" : ""}`}
onClick={() => mode === "attachment" ? requestExactFile(entry.file) : undefined}
disabled={mode === "folder"}
aria-label={mode === "folder" ? `${entry.file.filename}, file shown for context` : entry.file.filename}
>
<File size={18} aria-hidden="true" />
<span className="managed-file-entry-name">
<strong>{entry.file.filename}</strong>
<small>{campaignShared ? <><Link2 size={12} aria-hidden="true" /> linked to campaign</> : "File"}</small>
</span>
<span className="managed-file-entry-size">{formatBytes(entry.file.size_bytes)}</span>
<span className="managed-file-entry-modified">{formatDate(entry.file.updated_at)}</span>
</button>
);
})}
{!loading && sortedEntries.length === 0 && (
<p className="managed-file-empty muted">This folder is empty. Upload files in the top-level Files module.</p>
)}
</div>
</div>
<ChooserFileBrowser
mode={mode}
loading={loading}
currentFolder={currentFolder}
effectiveRoot={effectiveRoot}
sortedEntries={sortedEntries}
sortColumn={sortColumn}
sortDirection={sortDirection}
campaignId={campaignId}
selectedExactPattern={exactSelectionPattern}
onOpenFolder={openFolder}
onToggleSort={toggleSort}
onRequestExactFile={requestExactFile}
/>
)}
{mode === "folder" && (
@@ -483,7 +480,7 @@ export default function ManagedFileChooser({
<ConfirmDialog
open={Boolean(pendingExactFile)}
title="Replace the current pattern?"
message={pendingExactFile ? `Replace ${pattern.trim()} with the exact file ${relativePath(pendingExactFile.display_path, effectiveRoot)}?` : "Replace the current pattern?"}
message={pendingExactFile ? `Replace "${patternRef.current.trim()}" with the exact file "${relativePath(pendingExactFile.display_path, effectiveRoot)}"?` : "Replace the current pattern?"}
confirmLabel="Replace pattern"
onConfirm={() => pendingExactFile && applyExactFile(pendingExactFile)}
onCancel={() => setPendingExactFile(null)}
@@ -521,6 +518,153 @@ function ChooserSortButton({
);
}
function ManagedPatternEditor({
value,
effectiveRoot,
resolving,
matchCount,
previewContext,
onDraftChange,
onPreview,
onBackToFolder
}: {
value: string;
effectiveRoot: string;
resolving: boolean;
matchCount: number | null;
previewContext?: Record<string, string>;
onDraftChange: (value: string) => void;
onPreview: () => void;
onBackToFolder: () => void;
}) {
const [draft, setDraft] = useState(value);
const renderedPattern = useMemo(() => renderChooserPattern(draft, previewContext), [draft, previewContext]);
const patternIsRendered = Boolean(draft.trim() && renderedPattern && renderedPattern !== draft.trim());
useEffect(() => {
setDraft(value);
}, [value]);
function updateDraft(next: string) {
setDraft(next);
onDraftChange(next);
}
return (
<div className="managed-pattern-editor">
<label>
<span>File or pattern relative to <code>{effectiveRoot || "."}</code></span>
<div className="field-with-action split-field-action">
<input
value={draft}
onChange={(event) => updateDraft(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") onPreview(); }}
placeholder="folder/file.pdf or **/*.pdf"
/>
<Button onClick={onPreview} disabled={resolving || !draft.trim()}>
<Search size={15} aria-hidden="true" /> {resolving ? "Checking..." : "Preview"}
</Button>
</div>
</label>
{matchCount !== null && (
<div className="managed-pattern-summary">
<span>{matchCount} current file{matchCount === 1 ? "" : "s"} match.</span>
<Button variant="ghost" onClick={onBackToFolder}>Back to folder</Button>
</div>
)}
{patternIsRendered && <small className="managed-pattern-rendered">Preview resolves to <code>{renderedPattern}</code>.</small>}
</div>
);
}
const ChooserFileBrowser = memo(function ChooserFileBrowser({
mode,
loading,
currentFolder,
effectiveRoot,
sortedEntries,
sortColumn,
sortDirection,
campaignId,
selectedExactPattern,
onOpenFolder,
onToggleSort,
onRequestExactFile
}: {
mode: ManagedFileChooserMode;
loading: boolean;
currentFolder: string;
effectiveRoot: string;
sortedEntries: ChooserExplorerEntry[];
sortColumn: SortColumn;
sortDirection: SortDirection;
campaignId: string;
selectedExactPattern: string;
onOpenFolder: (path: string) => void;
onToggleSort: (column: SortColumn) => void;
onRequestExactFile: (file: ManagedFile) => void;
}) {
return (
<div className="managed-file-entry-table">
<div className="managed-file-entry-head" role="row">
<span aria-hidden="true" />
<ChooserSortButton column="name" label="Name" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
<ChooserSortButton column="size" label="Size" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
<ChooserSortButton column="modified" label="Modified" activeColumn={sortColumn} direction={sortDirection} onSort={onToggleSort} />
</div>
<div className="managed-file-entry-list" role="listbox" aria-label={mode === "folder" ? "Folders and files" : "Managed files"}>
<button
type="button"
className="managed-file-entry is-folder is-parent"
onClick={() => onOpenFolder(parentWithinRoot(currentFolder, effectiveRoot))}
disabled={loading || currentFolder === effectiveRoot}
>
<FolderOpen size={18} aria-hidden="true" />
<span className="managed-file-entry-name"><strong>..</strong><small>Parent folder</small></span>
<span className="managed-file-entry-size">-</span>
<span className="managed-file-entry-modified">-</span>
</button>
{sortedEntries.map((entry) => {
if (entry.kind === "folder") {
return (
<button type="button" key={entry.id} className="managed-file-entry is-folder" onClick={() => onOpenFolder(entry.path)}>
<Folder size={18} aria-hidden="true" />
<span className="managed-file-entry-name"><strong>{entry.name}</strong><small>{entry.fileCount} file(s), {entry.folderCount} folder(s)</small></span>
<span className="managed-file-entry-size">{formatBytes(entry.totalSize)}</span>
<span className="managed-file-entry-modified">{formatDate(entry.updatedAt)}</span>
</button>
);
}
const exactPattern = relativePath(entry.file.display_path, effectiveRoot);
const selected = mode === "attachment" && selectedExactPattern === exactPattern;
const campaignShared = isSharedWithCampaign(entry.file, campaignId);
return (
<button
type="button"
key={entry.file.id}
className={`managed-file-entry is-file ${selected ? "is-selected" : ""} ${mode === "folder" ? "is-context-only" : ""}`}
onClick={() => mode === "attachment" ? onRequestExactFile(entry.file) : undefined}
disabled={mode === "folder"}
aria-label={mode === "folder" ? `${entry.file.filename}, file shown for context` : entry.file.filename}
>
<File size={18} aria-hidden="true" />
<span className="managed-file-entry-name">
<strong>{entry.file.filename}</strong>
<small>{campaignShared ? <><Link2 size={12} aria-hidden="true" /> linked to campaign</> : "File"}</small>
</span>
<span className="managed-file-entry-size">{formatBytes(entry.file.size_bytes)}</span>
<span className="managed-file-entry-modified">{formatDate(entry.file.updated_at)}</span>
</button>
);
})}
{!loading && sortedEntries.length === 0 && (
<p className="managed-file-empty muted">This folder is empty. Upload files in the top-level Files module.</p>
)}
</div>
</div>
);
});
function PatternResultList({ files, campaignId, onChoose }: { files: ManagedFile[]; campaignId: string; onChoose: (file: ManagedFile) => void }) {
return (
<div className="managed-pattern-results" role="table" aria-label="Pattern preview results">
@@ -610,6 +754,12 @@ function isExactPattern(value: string): boolean {
return Boolean(value) && !/[?*\[\]{}]/.test(value);
}
function renderChooserPattern(pattern: string, previewContext?: Record<string, string>): string {
const trimmed = pattern.trim();
if (!trimmed || !previewContext) return trimmed;
return renderTemplatePreviewText(trimmed, previewContext, false).trim();
}
function isSharedWithCampaign(file: ManagedFile, campaignId: string): boolean {
return Boolean(file.shares?.some((share) => share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at));
}

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { useEffect, type ReactNode } from "react";
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import { Button, MessageDisplayPanel, type MessageDisplayAttachment } from "@govoplan/core-webui";
@@ -59,6 +59,34 @@ export default function CampaignMessagePreviewOverlay({
const shownSubject = subject?.trim() || "No subject";
const fields = metaItems.map((item) => ({ label: item.label, value: item.value }));
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (isEditableTarget(event.target)) return;
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
if (!navigation) return;
if (event.key === "ArrowLeft") {
event.preventDefault();
if (navigation.index > 0) navigation.onPrevious();
} else if (event.key === "ArrowRight") {
event.preventDefault();
if (navigation.index < navigation.total - 1) navigation.onNext();
} else if (event.key === "Home") {
event.preventDefault();
if (navigation.index > 0) navigation.onFirst();
} else if (event.key === "End") {
event.preventDefault();
if (navigation.index < navigation.total - 1) navigation.onLast();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [navigation, onClose]);
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="message-preview-title">
<div className="modal-panel template-preview-modal message-preview-modal">
@@ -109,6 +137,11 @@ export default function CampaignMessagePreviewOverlay({
);
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return target.isContentEditable || ["INPUT", "SELECT", "TEXTAREA"].includes(target.tagName);
}
export type MessagePreviewAttachment = CampaignMessagePreviewAttachment;
export type MessagePreviewMetaItem = CampaignMessagePreviewMetaItem;

View File

@@ -7,6 +7,7 @@ import {
buildUndefinedPlaceholders,
extractTemplatePlaceholders,
removePlaceholderFromText,
replacePlaceholderInText,
type TemplateNamespace,
type UndefinedPlaceholder
} from "../utils/templatePlaceholders";
@@ -100,6 +101,11 @@ export default function TemplateExpressionEditorDialog({
setUndefinedDialog(null);
}
function replaceUndefined(field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) {
setDraftValue((current) => replacePlaceholderInText(current, field.raw, `{{${namespace}:${name}}}`));
setUndefinedDialog(null);
}
if (!open || typeof document === "undefined") return null;
return createPortal(
@@ -168,7 +174,10 @@ export default function TemplateExpressionEditorDialog({
removeLabel="Remove from filename"
onCancel={() => setUndefinedDialog(null)}
onRemove={removeUndefined}
onReplace={replaceUndefined}
onAddField={addUndefined}
localFields={localFields}
globalFields={globalFields}
addDisabled={Boolean(undefinedDialog?.name) && !canAddField(undefinedDialog?.name ?? "")}
/>
</>,

View File

@@ -1,3 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { Button } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
@@ -74,19 +75,49 @@ export function UndefinedPlaceholderDecisionDialog({
field,
contextLabel = "template",
removeLabel = "Remove placeholder",
localFields = [],
globalFields = [],
onCancel,
onRemove,
onReplace,
onAddField,
addDisabled = false
}: {
field: UndefinedPlaceholder | null;
contextLabel?: string;
removeLabel?: string;
localFields?: TemplateFieldOption[];
globalFields?: TemplateFieldOption[];
onCancel: () => void;
onRemove: (field: UndefinedPlaceholder) => void;
onReplace?: (field: UndefinedPlaceholder, namespace: TemplateNamespace, name: string) => void;
onAddField: (field: UndefinedPlaceholder) => void;
addDisabled?: boolean;
}) {
const replacementOptions = useMemo(
() => [
...localFields.map((item) => ({ namespace: "local" as const, ...item })),
...globalFields.map((item) => ({ namespace: "global" as const, ...item }))
],
[globalFields, localFields]
);
const [replacement, setReplacement] = useState("");
const firstReplacement = replacementOptions[0];
const effectiveReplacement = replacement || (firstReplacement ? `${firstReplacement.namespace}:${firstReplacement.name}` : "");
useEffect(() => {
setReplacement(firstReplacement ? `${firstReplacement.namespace}:${firstReplacement.name}` : "");
}, [field?.raw, firstReplacement?.namespace, firstReplacement?.name]);
function replaceWithSelected() {
if (!field || !onReplace || !effectiveReplacement) return;
const separator = effectiveReplacement.indexOf(":");
if (separator < 0) return;
const namespace = effectiveReplacement.slice(0, separator) as TemplateNamespace;
const name = effectiveReplacement.slice(separator + 1);
onReplace(field, namespace, name);
}
return (
<Dialog
open={Boolean(field)}
@@ -98,6 +129,7 @@ export function UndefinedPlaceholderDecisionDialog({
<>
<Button onClick={onCancel}>Continue editing</Button>
<Button onClick={() => onRemove(field)}>{removeLabel}</Button>
{onReplace && replacementOptions.length > 0 && <Button onClick={replaceWithSelected}>Replace</Button>}
<Button variant="primary" onClick={() => onAddField(field)} disabled={!field.name || field.reason === "invalid-namespace" || addDisabled}>Add field</Button>
</>
) : undefined}
@@ -111,6 +143,18 @@ export function UndefinedPlaceholderDecisionDialog({
{field.reason === "missing-field" && (
<p className="muted">You can add <strong>{field.name}</strong> as a campaign field, remove this placeholder, or continue editing.</p>
)}
{onReplace && replacementOptions.length > 0 && (
<label className="template-replacement-field">
<span>Replace by existing field</span>
<select value={effectiveReplacement} onChange={(event) => setReplacement(event.target.value)}>
{replacementOptions.map((option) => (
<option key={`${option.namespace}:${option.name}`} value={`${option.namespace}:${option.name}`}>
{option.namespace}:{option.label || option.name}
</option>
))}
</select>
</label>
)}
</>
)}
</Dialog>

View File

@@ -22,6 +22,7 @@ type UseCampaignDraftEditorOptions = {
unsavedMessage: string;
loadedLabel?: (version: CampaignVersionDetail) => string;
transformLoadedDraft?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => Record<string, unknown>;
transformDraftBeforeSave?: (draft: Record<string, unknown>) => Record<string, unknown>;
extraPayload?: () => Partial<CampaignVersionUpdatePayload>;
onLoaded?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => void;
onSaved?: (version: CampaignVersionDetail) => void;
@@ -50,19 +51,22 @@ export function useCampaignDraftEditor({
unsavedMessage,
loadedLabel = defaultLoadedLabel,
transformLoadedDraft,
transformDraftBeforeSave,
extraPayload,
onLoaded,
onSaved
}: UseCampaignDraftEditorOptions) {
const loadedLabelRef = useRef(loadedLabel);
const transformLoadedDraftRef = useRef(transformLoadedDraft);
const transformDraftBeforeSaveRef = useRef(transformDraftBeforeSave);
const onLoadedRef = useRef(onLoaded);
useEffect(() => {
loadedLabelRef.current = loadedLabel;
transformLoadedDraftRef.current = transformLoadedDraft;
transformDraftBeforeSaveRef.current = transformDraftBeforeSave;
onLoadedRef.current = onLoaded;
}, [loadedLabel, transformLoadedDraft, onLoaded]);
}, [loadedLabel, transformLoadedDraft, transformDraftBeforeSave, onLoaded]);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
@@ -97,8 +101,9 @@ export function useCampaignDraftEditor({
setError("");
setLocalError("");
try {
const draftToSave = transformDraftBeforeSaveRef.current?.(draft) ?? draft;
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
campaign_json: draftToSave,
current_flow: currentFlow,
current_step: resolveStep(currentStep),
workflow_state: workflowState,

View File

@@ -0,0 +1,460 @@
type ImportRecord = Record<string, unknown>;
export type ImportFieldDefinition = {
name: string;
label?: string;
type?: string;
required?: boolean;
can_override?: boolean;
};
export type ImportAttachmentBasePath = {
id?: string;
path?: string;
};
export type RecipientImportMode = "append" | "replace";
export type CsvDelimiter = "auto" | "," | ";" | "\t";
export type CsvParseOptions = {
delimiter: CsvDelimiter;
headerRows: number;
quoted: boolean;
};
export type RecipientColumnKind = "ignore" | "id" | "active" | "name" | "from" | "to" | "cc" | "bcc" | "reply_to" | "field" | "new_field" | "attachment_pattern";
export type RecipientColumnMapping = {
columnIndex: number;
kind: RecipientColumnKind;
fieldName?: string;
newFieldName?: string;
};
export type RecipientImportTable = {
delimiter: "," | ";" | "\t";
headers: string[];
headerRows: string[][];
rows: string[][];
dataRows: string[][];
firstDataRowNumber: number;
};
export type ImportedAddress = {
email: string;
name?: string;
};
export type ImportedRecipientRow = {
rowNumber: number;
id: string;
name: string;
email: string;
active: boolean;
addresses: {
from: ImportedAddress[];
to: ImportedAddress[];
cc: ImportedAddress[];
bcc: ImportedAddress[];
reply_to: ImportedAddress[];
};
fields: Record<string, string>;
patterns: string[];
issues: string[];
};
export type RecipientImportPreview = {
table: RecipientImportTable;
rows: ImportedRecipientRow[];
fieldNamesToCreate: string[];
validCount: number;
invalidCount: number;
patternCount: number;
};
export type RecipientImportPreviewOptions = {
existingFields: ImportFieldDefinition[];
existingEntries?: Record<string, unknown>[];
valueSeparators?: string;
};
export type MaterializeImportOptions = {
mode: RecipientImportMode;
attachmentBasePath?: ImportAttachmentBasePath | null;
};
const EMAIL_COLUMNS = new Set(["email", "e_mail", "mail", "to", "to_email", "recipient", "recipient_email"]);
const NAME_COLUMNS = new Set(["name", "full_name", "recipient_name", "to_name"]);
const ID_COLUMNS = new Set(["id", "entry_id", "recipient_id"]);
const ACTIVE_COLUMNS = new Set(["active", "enabled", "send", "include"]);
export function buildImportTable(text: string, options: CsvParseOptions): RecipientImportTable {
const delimiter = options.delimiter === "auto" ? detectDelimiter(text, options.quoted) : options.delimiter;
const rows = parseDelimitedText(text, delimiter, options.quoted);
const normalizedHeaderRows = Math.max(0, Math.min(Math.floor(options.headerRows || 0), rows.length));
const headerRows = rows.slice(0, normalizedHeaderRows);
const dataRows = rows.slice(normalizedHeaderRows);
const columnCount = Math.max(0, ...rows.map((row) => row.length));
const headers = Array.from({ length: columnCount }, (_value, columnIndex) => headerForColumn(headerRows, columnIndex));
return {
delimiter,
headers,
headerRows,
rows,
dataRows,
firstDataRowNumber: normalizedHeaderRows + 1
};
}
export function defaultColumnMappings(headers: string[], existingFields: ImportFieldDefinition[]): RecipientColumnMapping[] {
const existingFieldNames = new Set(existingFields.map((field) => field.name));
return headers.map((header, columnIndex) => {
const key = normalizeColumnKey(header);
if (ID_COLUMNS.has(key)) return { columnIndex, kind: "id" };
if (ACTIVE_COLUMNS.has(key)) return { columnIndex, kind: "active" };
if (NAME_COLUMNS.has(key)) return { columnIndex, kind: "name" };
if (key === "from" || key === "from_email") return { columnIndex, kind: "from" };
if (key === "cc" || key === "cc_email") return { columnIndex, kind: "cc" };
if (key === "bcc" || key === "bcc_email") return { columnIndex, kind: "bcc" };
if (key === "reply_to" || key === "reply_to_email") return { columnIndex, kind: "reply_to" };
if (EMAIL_COLUMNS.has(key)) return { columnIndex, kind: "to" };
if (isPatternColumn(key)) return { columnIndex, kind: "attachment_pattern" };
const explicitFieldName = explicitFieldNameFromHeader(header);
if (explicitFieldName) return existingFieldNames.has(explicitFieldName) ? { columnIndex, kind: "field", fieldName: explicitFieldName } : { columnIndex, kind: "new_field", newFieldName: explicitFieldName };
const sanitized = sanitizeFieldName(header);
if (existingFieldNames.has(sanitized)) return { columnIndex, kind: "field", fieldName: sanitized };
return sanitized ? { columnIndex, kind: "new_field", newFieldName: sanitized } : { columnIndex, kind: "ignore" };
});
}
export function buildRecipientImportPreview(
table: RecipientImportTable,
mappings: RecipientColumnMapping[],
options: RecipientImportPreviewOptions
): RecipientImportPreview {
const existingFields = options.existingFields;
const existingFieldNames = new Set(existingFields.map((field) => field.name));
const existingIds = new Set((options.existingEntries ?? []).map((entry) => String(entry.id || "")).filter(Boolean));
const usedIds = new Set(existingIds);
const fieldNamesToCreate = new Set<string>();
const mappingsByColumn = new Map(mappings.map((mapping) => [mapping.columnIndex, mapping]));
const valueSeparators = options.valueSeparators || ",;|";
const rows = table.dataRows
.map((cells, index): ImportedRecipientRow | null => {
if (cells.every((cell) => !cell.trim())) return null;
const rowNumber = table.firstDataRowNumber + index;
const addresses: ImportedRecipientRow["addresses"] = { from: [], to: [], cc: [], bcc: [], reply_to: [] };
const fields: Record<string, string> = {};
const patterns: string[] = [];
const issues: string[] = [];
let preferredId = "";
let name = "";
let active = true;
cells.forEach((rawValue, columnIndex) => {
const value = String(rawValue ?? "").trim();
if (!value) return;
const mapping = mappingsByColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" as const };
switch (mapping.kind) {
case "id":
preferredId = value;
break;
case "active":
active = parseOptionalBoolean(value, true);
break;
case "name":
name = value;
break;
case "from":
case "to":
case "cc":
case "bcc":
case "reply_to":
addresses[mapping.kind].push(...parseAddressCell(value, valueSeparators));
break;
case "field": {
const fieldName = mapping.fieldName?.trim();
if (fieldName) fields[fieldName] = value;
break;
}
case "new_field": {
const fieldName = sanitizeFieldName(mapping.newFieldName || table.headers[columnIndex] || `column_${columnIndex + 1}`);
if (fieldName) {
fields[fieldName] = value;
if (!existingFieldNames.has(fieldName)) fieldNamesToCreate.add(fieldName);
}
break;
}
case "attachment_pattern":
patterns.push(...splitCell(value, valueSeparators));
break;
case "ignore":
default:
break;
}
});
if (name && addresses.to.length === 1 && !addresses.to[0].name) addresses.to[0].name = name;
const primaryAddress = addresses.to[0] ?? addresses.cc[0] ?? addresses.bcc[0] ?? addresses.reply_to[0] ?? addresses.from[0] ?? null;
const email = primaryAddress?.email ?? "";
const id = uniqueId(preferredId || idFromEmail(email) || `recipient-${rowNumber}`, usedIds);
usedIds.add(id);
for (const [key, list] of Object.entries(addresses)) {
for (const address of list) {
if (!address.email.includes("@")) issues.push(`${address.email || key} must contain @`);
}
}
if (addresses.to.length === 0) issues.push("Missing To address");
return {
rowNumber,
id,
name: name || primaryAddress?.name || "",
email,
active,
addresses,
fields,
patterns,
issues: [...new Set(issues)]
};
})
.filter((row): row is ImportedRecipientRow => row !== null);
return {
table,
rows,
fieldNamesToCreate: [...fieldNamesToCreate].sort(),
validCount: rows.filter((row) => row.issues.length === 0).length,
invalidCount: rows.filter((row) => row.issues.length > 0).length,
patternCount: rows.reduce((count, row) => count + row.patterns.length, 0)
};
}
export function materializeRecipientImport(
draft: Record<string, unknown>,
preview: RecipientImportPreview,
options: MaterializeImportOptions
): Record<string, unknown> {
const currentEntries = asRecord(draft.entries);
const currentInlineEntries = asArray(currentEntries.inline).map(asRecord);
const importedEntries = preview.rows
.filter((row) => row.issues.length === 0)
.map((row) => importedRowToEntry(row, options.attachmentBasePath));
const nextInlineEntries = options.mode === "append" ? [...currentInlineEntries, ...importedEntries] : importedEntries;
const nextEntries: ImportRecord = { ...currentEntries, inline: nextInlineEntries };
delete nextEntries.source;
delete nextEntries.mapping;
return {
...draft,
fields: mergeFieldDefinitions(draft.fields, preview.fieldNamesToCreate),
entries: nextEntries
};
}
export function importedRowsNeedAttachmentSource(preview: RecipientImportPreview): boolean {
return preview.rows.some((row) => row.issues.length === 0 && row.patterns.length > 0);
}
function importedRowToEntry(row: ImportedRecipientRow, attachmentBasePath?: ImportAttachmentBasePath | null): Record<string, unknown> {
return {
id: row.id,
active: row.active,
name: row.name,
email: row.email,
from: row.addresses.from.slice(0, 1),
to: row.addresses.to,
cc: row.addresses.cc,
bcc: row.addresses.bcc,
reply_to: row.addresses.reply_to,
merge_to: false,
merge_cc: true,
merge_bcc: true,
merge_reply_to: true,
fields: row.fields,
attachments: row.patterns.map((pattern, index) => ({
id: `attachment-import-${row.rowNumber}-${index + 1}`,
label: row.patterns.length === 1 ? "Imported attachment pattern" : `Imported attachment pattern ${index + 1}`,
type: "pattern",
base_path_id: attachmentBasePath?.id || undefined,
base_dir: attachmentBasePath?.path || ".",
file_filter: pattern,
include_subdirs: false,
required: true,
zip: { archive_id: "inherit" }
})),
combine_attachments: true
};
}
function mergeFieldDefinitions(fieldsValue: unknown, fieldNamesToCreate: string[]): Record<string, unknown>[] {
const existing = asArray(fieldsValue).map(asRecord);
const existingNames = new Set(existing.map((field) => getText(field, "name") || getText(field, "id")).filter(Boolean));
const additions = fieldNamesToCreate
.filter((name) => !existingNames.has(name))
.map((name) => ({
name,
label: humanizeFieldName(name),
type: "string",
required: false,
can_override: true
}));
return [...existing, ...additions];
}
export function parseDelimitedText(text: string, delimiter: "," | ";" | "\t", quoted = true): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let cell = "";
let inQuotes = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
const next = text[index + 1];
if (quoted && inQuotes) {
if (char === '"' && next === '"') {
cell += '"';
index += 1;
} else if (char === '"') {
inQuotes = false;
} else {
cell += char;
}
continue;
}
if (quoted && char === '"') {
inQuotes = true;
} else if (char === delimiter) {
row.push(cell);
cell = "";
} else if (char === "\n") {
row.push(cell);
rows.push(row);
row = [];
cell = "";
} else if (char !== "\r") {
cell += char;
}
}
row.push(cell);
if (row.length > 1 || row[0] !== "" || text.endsWith(delimiter)) rows.push(row);
return rows;
}
function detectDelimiter(text: string, quoted: boolean): "," | ";" | "\t" {
const firstLine = text.split(/\r?\n/, 1)[0] ?? "";
const candidates: Array<"," | ";" | "\t"> = [",", ";", "\t"];
return candidates
.map((delimiter) => ({ delimiter, cells: parseDelimitedText(firstLine, delimiter, quoted)[0]?.length ?? 1 }))
.sort((left, right) => right.cells - left.cells)[0]?.delimiter ?? ",";
}
function headerForColumn(headerRows: string[][], columnIndex: number): string {
const parts = headerRows.map((row) => String(row[columnIndex] ?? "").trim()).filter(Boolean);
return parts.length ? parts.join(" / ") : `Column ${columnIndex + 1}`;
}
function explicitFieldNameFromHeader(header: string): string {
const explicit = /^(?:field|fields)[.:](.+)$/i.exec(header.trim());
return explicit ? sanitizeFieldName(explicit[1]) : "";
}
function normalizeColumnKey(value: string): string {
return value.trim().toLowerCase().replace(/[\s.-]+/g, "_").replace(/^_+|_+$/g, "");
}
function isPatternColumn(key: string): boolean {
return key === "pattern"
|| key === "patterns"
|| key === "file"
|| key === "files"
|| key === "file_pattern"
|| key === "file_patterns"
|| key === "attachment"
|| key === "attachments"
|| key === "attachment_pattern"
|| key === "attachment_patterns"
|| /^pattern_\d+$/.test(key)
|| /^file_\d+$/.test(key)
|| /^attachment_\d+$/.test(key);
}
function parseAddressCell(value: string, separators: string): ImportedAddress[] {
return splitCell(value, separators)
.map((item) => parseAddress(item))
.filter((address) => Boolean(address.email));
}
function parseAddress(value: string): ImportedAddress {
const trimmed = value.trim();
const match = /^(.*?)<([^<>]+)>$/.exec(trimmed);
if (match) {
const name = match[1].trim().replace(/^"|"$/g, "");
return { email: match[2].trim(), ...(name ? { name } : {}) };
}
return { email: trimmed };
}
function splitCell(value: string, separators: string): string[] {
const escaped = separators.split("").map((char) => char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("");
if (!escaped) return [value.trim()].filter(Boolean);
return value.split(new RegExp(`[${escaped}]+`)).map((item) => item.trim()).filter(Boolean);
}
function parseOptionalBoolean(value: string, fallback: boolean): boolean {
const normalized = value.trim().toLowerCase();
if (!normalized) return fallback;
if (["1", "true", "yes", "y", "ja", "j", "x", "active", "aktiv"].includes(normalized)) return true;
if (["0", "false", "no", "n", "nein", "inactive", "inaktiv"].includes(normalized)) return false;
return fallback;
}
function idFromEmail(email: string): string {
const localPart = email.split("@", 1)[0] || "";
return sanitizeFieldName(localPart).toLowerCase() || "";
}
function uniqueId(preferred: string, usedIds: Set<string>): string {
const base = sanitizeFieldName(preferred).toLowerCase() || "recipient";
if (!usedIds.has(base)) return base;
let index = 2;
let candidate = `${base}-${index}`;
while (usedIds.has(candidate)) {
index += 1;
candidate = `${base}-${index}`;
}
return candidate;
}
function sanitizeFieldName(value: string): string {
return value.trim().replace(/^fields[._-]+/i, "").replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
}
function isRecord(value: unknown): value is ImportRecord {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function asRecord(value: unknown): ImportRecord {
return isRecord(value) ? value : {};
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function getText(record: ImportRecord, key: string, fallback = ""): string {
const value = record[key];
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return fallback;
}
function humanizeFieldName(value: string): string {
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}

View File

@@ -0,0 +1,143 @@
import type { ApiSettings } from "../../../types";
import { listFiles, resolveFilePatterns, shareFilesWithCampaign, type ManagedFile } from "../../../api/files";
import { parseManagedAttachmentSource, type AttachmentBasePath } from "./attachments";
import type { ImportedRecipientRow, RecipientImportPreview } from "./bulkImport";
export type RenderedImportPattern = {
key: string;
rowNumber: number;
sourcePattern: string;
renderedPattern: string;
};
export type ImportFileLinkResolution = {
basePath: AttachmentBasePath;
patterns: RenderedImportPattern[];
matchedPatterns: { pattern: RenderedImportPattern; matches: ManagedFile[] }[];
unmatchedPatterns: RenderedImportPattern[];
files: ManagedFile[];
linkedFiles: ManagedFile[];
linkableFiles: ManagedFile[];
};
export async function resolveImportedAttachmentLinks(
settings: ApiSettings,
campaignId: string,
basePath: AttachmentBasePath,
preview: RecipientImportPreview
): Promise<ImportFileLinkResolution> {
const parsedSource = parseManagedAttachmentSource(basePath.source);
if (!parsedSource) {
throw new Error("The selected attachment source is not connected to a managed file space.");
}
const patterns = renderedImportPatterns(preview);
if (patterns.length === 0) {
return { basePath, patterns, matchedPatterns: [], unmatchedPatterns: [], files: [], linkedFiles: [], linkableFiles: [] };
}
const root = normalizedPathPrefix(basePath.path);
const [resolved, visibleFiles] = await Promise.all([
resolveFilePatterns(settings, {
patterns: patterns.map((item) => item.renderedPattern),
owner_type: parsedSource.ownerType,
owner_id: parsedSource.ownerId,
path_prefix: root || undefined,
include_unmatched: false
}),
listFiles(settings, {
owner_type: parsedSource.ownerType,
owner_id: parsedSource.ownerId,
path_prefix: root || undefined
})
]);
const visibleById = new Map(visibleFiles.files.map((file) => [file.id, file]));
const fileById = new Map<string, ManagedFile>();
const matchedPatterns = patterns.map((pattern, index) => {
const matches = (resolved.patterns[index]?.matches ?? [])
.map((file) => visibleById.get(file.id) ?? file);
matches.forEach((file) => fileById.set(file.id, file));
return { pattern, matches };
});
const files = [...fileById.values()].sort((left, right) => left.display_path.localeCompare(right.display_path));
const linkedFiles = files.filter((file) => isSharedWithCampaign(file, campaignId));
const linkableFiles = files.filter((file) => !isSharedWithCampaign(file, campaignId));
return {
basePath,
patterns,
matchedPatterns,
unmatchedPatterns: matchedPatterns.filter((item) => item.matches.length === 0).map((item) => item.pattern),
files,
linkedFiles,
linkableFiles
};
}
export async function bulkLinkFilesToCampaign(settings: ApiSettings, campaignId: string, files: ManagedFile[]): Promise<number> {
const uniqueIds = [...new Set(files.map((file) => file.id).filter(Boolean))];
if (uniqueIds.length === 0) return 0;
const response = await shareFilesWithCampaign(settings, uniqueIds, campaignId);
return response.shared_count;
}
export function renderedImportPatterns(preview: RecipientImportPreview): RenderedImportPattern[] {
const byKey = new Map<string, RenderedImportPattern>();
preview.rows
.filter((row) => row.issues.length === 0)
.forEach((row) => {
row.patterns.forEach((sourcePattern) => {
const renderedPattern = renderPatternForImportedRow(sourcePattern, row).trim();
if (!renderedPattern) return;
const key = `${row.rowNumber}:${sourcePattern}:${renderedPattern}`;
if (!byKey.has(key)) byKey.set(key, { key, rowNumber: row.rowNumber, sourcePattern, renderedPattern });
});
});
return [...byKey.values()];
}
export function isSharedWithCampaign(file: ManagedFile, campaignId: string): boolean {
return Boolean(file.shares?.some((share) => share.target_type === "campaign" && share.target_id === campaignId && !share.revoked_at));
}
function renderPatternForImportedRow(pattern: string, row: ImportedRecipientRow): string {
const values = valuesForImportedRow(row);
const replace = (rawKey: string, original: string) => {
const key = normalizeTemplateKey(rawKey);
const value = values.get(key);
return value === undefined ? original : value;
};
const dollarRendered = pattern.replace(/\\?\$\{([^}]+)\}/g, (match, key) => match.startsWith("\\") ? match.slice(1) : replace(key, match));
return dollarRendered.replace(/\\?\{\{\s*([^}]+?)\s*\}\}/g, (match, key) => match.startsWith("\\") ? match.slice(1) : replace(key, match));
}
function valuesForImportedRow(row: ImportedRecipientRow): Map<string, string> {
const values = new Map<string, string>();
const setValue = (key: string, value: string) => {
const trimmedKey = key.trim();
if (!trimmedKey) return;
values.set(trimmedKey, value);
values.set(`local:${trimmedKey}`, value);
values.set(`local::${trimmedKey}`, value);
};
setValue("id", row.id);
setValue("name", row.name);
setValue("email", row.email);
Object.entries(row.fields).forEach(([key, value]) => setValue(key, String(value ?? "")));
return values;
}
function normalizeTemplateKey(value: string): string {
const key = value.trim();
if (key.startsWith("local::") || key.startsWith("global::")) return key;
if (key.startsWith("local:")) return `local::${key.slice("local:".length)}`;
if (key.startsWith("global:")) return `global::${key.slice("global:".length)}`;
return key;
}
function normalizedPathPrefix(path: string): string {
const trimmed = path.trim();
if (!trimmed || trimmed === "." || trimmed === "/") return "";
return trimmed.replace(/^[\/]+|[\/]+$/g, "");
}

View File

@@ -215,6 +215,12 @@ export function removePlaceholderFromText(text: string, raw: string): string {
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), "");
}
export function replacePlaceholderInText(text: string, raw: string, replacement: string): string {
if (!text) return text;
const escaped = escapeRegExp(raw.trim());
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), replacement);
}
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)) {

View File

@@ -1615,6 +1615,11 @@
font-size: 11px;
}
.managed-pattern-rendered {
display: block;
color: var(--muted);
}
/* Review & Send workflow page. */
.review-send-page {
--review-flow-purple: #9b86c7;
@@ -2139,6 +2144,14 @@
background: var(--panel);
}
.template-replacement-field {
display: grid;
gap: 6px;
margin-top: 12px;
color: var(--text-strong);
font-weight: 700;
}
.template-expression-description {
margin-top: 0;
}
@@ -2537,6 +2550,234 @@
min-width: 0;
box-shadow: var(--shadow-card, 0 2px 10px rgba(0,0,0,.06));
}
.recipient-import-modal {
width: min(1100px, calc(100vw - 32px));
}
.recipient-import-body {
display: grid;
gap: 14px;
}
.recipient-import-steps {
padding: 13px 10px 9px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: rgba(247, 246, 244, .96);
box-shadow: 0 8px 24px rgba(48, 49, 53, .10);
overflow-x: auto;
}
.recipient-import-step-track {
display: flex;
align-items: flex-start;
width: max-content;
min-width: max-content;
margin: 0 auto;
}
.recipient-import-step-group {
display: flex;
align-items: flex-start;
}
.recipient-import-step-item {
display: grid;
justify-items: center;
gap: 6px;
min-width: 104px;
padding: 3px 6px;
border: 0;
background: transparent;
color: var(--text);
font: inherit;
text-align: center;
cursor: pointer;
}
.recipient-import-step-item:disabled {
cursor: default;
opacity: .56;
}
.recipient-import-step-item:not(:disabled):hover .recipient-import-step-icon,
.recipient-import-step-item:focus-visible .recipient-import-step-icon {
background: color-mix(in srgb, var(--accent, #5d776c) 14%, #fff);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #5d776c) 12%, transparent);
}
.recipient-import-step-item:focus-visible {
outline: none;
}
.recipient-import-step-icon {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 1px solid color-mix(in srgb, var(--accent, #5d776c) 70%, var(--line));
border-radius: 50%;
color: color-mix(in srgb, var(--accent, #5d776c) 82%, var(--text));
background: color-mix(in srgb, var(--accent, #5d776c) 8%, #fff);
font-weight: 700;
transition: background .16s ease, box-shadow .16s ease, color .16s ease;
}
.recipient-import-step-item.is-current .recipient-import-step-icon,
.recipient-import-step-item.is-complete .recipient-import-step-icon {
color: #fff;
background: color-mix(in srgb, var(--accent, #5d776c) 86%, #333);
}
.recipient-import-step-copy {
display: grid;
justify-items: center;
min-width: 0;
}
.recipient-import-step-copy strong {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-strong);
font-size: 12px;
white-space: nowrap;
}
.recipient-import-step-copy small {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 14px;
max-width: 120px;
color: var(--muted);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipient-import-step-line {
width: 42px;
height: 2px;
margin: 21px 2px 0;
flex: 0 0 auto;
background: color-mix(in srgb, var(--accent, #5d776c) 55%, var(--line));
opacity: .82;
}
.recipient-import-step-panel {
display: grid;
gap: 14px;
min-height: 360px;
}
.recipient-import-upload-grid,
.recipient-import-map-controls {
align-items: start;
}
.recipient-import-textarea {
min-height: 190px;
max-height: 300px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.45;
}
.recipient-import-toggles {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 18px;
min-height: 38px;
}
.recipient-import-summary {
grid-template-columns: repeat(4, minmax(120px, 1fr));
}
.recipient-import-preview-surface {
overflow: auto;
}
.recipient-import-preview-table {
width: 100%;
min-width: 760px;
border-collapse: collapse;
}
.recipient-import-preview-table th,
.recipient-import-preview-table td {
border-bottom: 1px solid var(--line);
padding: 9px 10px;
text-align: left;
vertical-align: top;
}
.recipient-import-preview-table th {
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.recipient-import-preview-table td {
color: var(--text);
font-size: 13px;
word-break: break-word;
}
.recipient-import-preview-table tr.is-invalid td {
color: var(--danger-text);
}
.recipient-import-raw-table td {
white-space: pre;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
}
.recipient-import-raw-table tr.is-header-row td {
background: color-mix(in srgb, var(--accent, #5d776c) 8%, transparent);
font-weight: 700;
}
.recipient-import-mapping-table select,
.recipient-import-mapping-table input {
width: 100%;
min-width: 160px;
}
.recipient-import-mapping-table td:nth-child(2) {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipient-import-file-step {
display: grid;
gap: 14px;
}
.recipient-import-file-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
}
.recipient-import-file-actions > div:first-child {
display: grid;
gap: 4px;
min-width: 0;
}
.recipient-import-unmatched-patterns {
display: grid;
gap: 8px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--warning-bg, #fff3cd) 62%, transparent);
}
.recipient-import-unmatched-patterns ul {
display: grid;
gap: 5px;
margin: 0;
padding-left: 18px;
}
.recipient-import-unmatched-patterns code {
overflow-wrap: anywhere;
}
.recipient-import-file-link-table td:nth-child(2) {
overflow-wrap: anywhere;
}
@media (max-width: 760px) {
.recipient-import-file-actions {
display: grid;
}
}
@media (max-width: 760px) {
.recipient-import-summary {
grid-template-columns: repeat(2, minmax(120px, 1fr));
}
.recipient-import-step-panel {
min-height: 300px;
}
}
.card-body > .admin-table-surface:only-child {
margin: -22px -24px;
width: calc(100% + 48px);

View File

@@ -0,0 +1,78 @@
import {
buildImportTable,
buildRecipientImportPreview,
defaultColumnMappings,
materializeRecipientImport,
parseDelimitedText,
type RecipientColumnMapping
} from "../src/features/campaigns/utils/bulkImport";
function assert(condition: unknown, message = "assertion failed"): void {
if (!condition) throw new Error(message);
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function mappingFor(mappings: RecipientColumnMapping[], columnIndex: number): RecipientColumnMapping {
return mappings.find((mapping) => mapping.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" };
}
const parsed = parseDelimitedText('email;name;field.note\n"alice@example.org";"Alice; A";"hello"', ";");
assert(parsed[1][1] === "Alice; A", "quoted delimiters are preserved");
const csv = [
"email;name;customer_id;field.contract_no;attachment_pattern;active",
"alice@example.org;Alice;C-1;CN-1;${customer_id}.pdf|archive/*.pdf;yes",
"bad-address;Broken;C-2;CN-2;broken.pdf;yes",
";Missing;C-3;CN-3;;yes"
].join("\n");
const existingFields = [{ name: "customer_id", label: "Customer ID", type: "string", required: false, can_override: true }];
const table = buildImportTable(csv, { delimiter: "auto", headerRows: 1, quoted: true });
const mappings = defaultColumnMappings(table.headers, existingFields);
const preview = buildRecipientImportPreview(table, mappings, {
existingFields,
existingEntries: [{ id: "alice" }],
valueSeparators: ",;|"
});
assert(table.delimiter === ";", "semicolon delimiter is detected");
assert(table.headerRows.length === 1 && table.dataRows.length === 3, "header and data rows are separated");
assert(mappingFor(mappings, 0).kind === "to", "email columns map to To");
assert(mappingFor(mappings, 2).kind === "field" && mappingFor(mappings, 2).fieldName === "customer_id", "existing fields are detected");
assert(mappingFor(mappings, 3).kind === "new_field" && mappingFor(mappings, 3).newFieldName === "contract_no", "explicit new field columns are detected");
assert(preview.validCount === 1, "one valid row is detected");
assert(preview.invalidCount === 2, "invalid rows are detected");
assert(preview.patternCount === 3, "all pattern cells are counted before invalid rows are skipped");
assert(preview.fieldNamesToCreate.length === 1 && preview.fieldNamesToCreate[0] === "contract_no", "mapped new fields are created");
assert(preview.rows[0].id === "alice-2", "import ids avoid existing entries");
assert(preview.rows[0].patterns.length === 2, "pattern cells split on configured separators");
const multiTable = buildImportTable('to;name\n"Alice <alice@example.org>|bob@example.org";Team', { delimiter: "auto", headerRows: 1, quoted: true });
const multiPreview = buildRecipientImportPreview(multiTable, defaultColumnMappings(multiTable.headers, []), {
existingFields: [],
valueSeparators: "|"
});
assert(multiPreview.validCount === 1, "multi-address cells can produce a valid recipient");
assert(multiPreview.rows[0].addresses.to.length === 2, "To columns can contain multiple addresses");
assert(multiPreview.rows[0].addresses.to[0].name === "Alice", "display names are parsed from address cells");
const draft = {
fields: [{ name: "customer_id", label: "Customer ID", type: "string", required: false, can_override: true }],
entries: { inline: [{ id: "alice", to: [{ email: "old@example.org" }] }], source: { type: "csv" }, mapping: { id: "id" } }
};
const imported = materializeRecipientImport(draft, preview, { mode: "append", attachmentBasePath: { id: "bp-1", path: "invoices" } });
const entries = asRecord(imported.entries);
const inline = entries.inline as Record<string, unknown>[];
const fields = imported.fields as Record<string, unknown>[];
const importedEntry = inline[1];
const attachments = importedEntry.attachments as Record<string, unknown>[];
assert(inline.length === 2, "append keeps existing entries");
assert(entries.source === undefined && entries.mapping === undefined, "external source metadata is removed for inline imports");
assert(fields.some((field) => field.name === "contract_no"), "new field definition is added");
assert(importedEntry.email === "alice@example.org", "entry email is materialized");
assert(attachments.length === 2, "valid row patterns become attachment rules");
assert(attachments[0].base_path_id === "bp-1" && attachments[0].base_dir === "invoices", "attachment rules use the selected source");
assert(attachments[0].file_filter === "${customer_id}.pdf", "field placeholders remain in imported patterns");

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": [
"ES2020",
"DOM"
],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"module": "CommonJS",
"moduleResolution": "Node",
"noEmit": false,
"outDir": ".import-test-build",
"rootDir": "."
},
"include": [
"tests/import-utils.test.ts",
"src/features/campaigns/utils/bulkImport.ts"
]
}