Release v0.1.3
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>[];
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? "")}
|
||||
/>
|
||||
</>,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
460
webui/src/features/campaigns/utils/bulkImport.ts
Normal file
460
webui/src/features/campaigns/utils/bulkImport.ts
Normal 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());
|
||||
}
|
||||
143
webui/src/features/campaigns/utils/fileLinking.ts
Normal file
143
webui/src/features/campaigns/utils/fileLinking.ts
Normal 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, "");
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
78
webui/tests/import-utils.test.ts
Normal file
78
webui/tests/import-utils.test.ts
Normal 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");
|
||||
23
webui/tsconfig.import-tests.json
Normal file
23
webui/tsconfig.import-tests.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user