From ab2343aa88f90645d350906db2c286d537f6e11f Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 26 Jun 2026 01:39:19 +0200 Subject: [PATCH] Release v0.1.3 --- pyproject.toml | 4 +- src/govoplan_campaign/backend/manifest.py | 3 +- webui/package.json | 7 +- webui/src/api/files.ts | 15 +- .../campaigns/AttachmentsDataPage.tsx | 11 +- .../features/campaigns/MailSettingsPage.tsx | 36 +- .../features/campaigns/RecipientDataPage.tsx | 656 +++++++++++++++++- .../campaigns/RecipientDetailsPage.tsx | 78 ++- .../src/features/campaigns/ReviewSendPage.tsx | 41 +- .../features/campaigns/TemplateDataPage.tsx | 21 +- .../components/AttachmentRulesOverlay.tsx | 55 +- .../components/ManagedFileChooser.tsx | 398 +++++++---- .../components/MessagePreviewOverlay.tsx | 35 +- .../TemplateExpressionEditorDialog.tsx | 9 + .../TemplatePlaceholderControls.tsx | 44 ++ .../campaigns/hooks/useCampaignDraftEditor.ts | 9 +- .../features/campaigns/utils/bulkImport.ts | 460 ++++++++++++ .../features/campaigns/utils/fileLinking.ts | 143 ++++ .../campaigns/utils/templatePlaceholders.ts | 6 + webui/src/styles/campaign-workspace.css | 241 +++++++ webui/tests/import-utils.test.ts | 78 +++ webui/tsconfig.import-tests.json | 23 + 22 files changed, 2176 insertions(+), 197 deletions(-) create mode 100644 webui/src/features/campaigns/utils/bulkImport.ts create mode 100644 webui/src/features/campaigns/utils/fileLinking.ts create mode 100644 webui/tests/import-utils.test.ts create mode 100644 webui/tsconfig.import-tests.json diff --git a/pyproject.toml b/pyproject.toml index 63e5880..17574ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 4bba8d6..2599a21 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -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 - diff --git a/webui/package.json b/webui/package.json index a88048a..3891433 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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" diff --git a/webui/src/api/files.ts b/webui/src/api/files.ts index 29481ed..f3927c5 100644 --- a/webui/src/api/files.ts +++ b/webui/src/api/files.ts @@ -76,13 +76,22 @@ export function listFiles(settings: ApiSettings, params: { owner_type?: string; return apiFetch(settings, `/api/v1/files${suffix}`); } -export function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise { - return apiFetch(settings, `/api/v1/files/${fileId}/shares`, { +export type FileBulkShareResponse = { shares: FileShare[]; shared_count: number }; + +export function shareFilesWithCampaign(settings: ApiSettings, fileIds: string[], campaignId: string): Promise { + return apiFetch(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 { + 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 } diff --git a/webui/src/features/campaigns/AttachmentsDataPage.tsx b/webui/src/features/campaigns/AttachmentsDataPage.tsx index 7a5dc11..c6a6163 100644 --- a/webui/src/features/campaigns/AttachmentsDataPage.tsx +++ b/webui/src/features/campaigns/AttachmentsDataPage.tsx @@ -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)} /> diff --git a/webui/src/features/campaigns/MailSettingsPage.tsx b/webui/src/features/campaigns/MailSettingsPage.tsx index 1a93343..195fa20 100644 --- a/webui/src/features/campaigns/MailSettingsPage.tsx +++ b/webui/src/features/campaigns/MailSettingsPage.tsx @@ -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): Record { + 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, + credentialsValue: Record, + 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) { diff --git a/webui/src/features/campaigns/RecipientDataPage.tsx b/webui/src/features/campaigns/RecipientDataPage.tsx index ff5db6f..9d874ff 100644 --- a/webui/src/features/campaigns/RecipientDataPage.tsx +++ b/webui/src/features/campaigns/RecipientDataPage.tsx @@ -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 (
@@ -244,7 +306,7 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
- Import}> + setImportOpen(true)}>Import}> {inlineEntries.length === 0 && Boolean(source.type) && ( This campaign references an external recipient source. A parsed preview table will be added when file/source preview support is implemented. )} @@ -252,22 +314,608 @@ export default function RecipientDataPage({ settings, campaignId }: { settings:
String(entry.id || index)} emptyText="No recipient profiles are stored in the current version yet." emptyAction={ 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); + } + }} />
)}
+ + {importOpen && ( + setImportOpen(false)} + onImport={applyRecipientImport} + /> + )} ); } +type RecipientImportDialogProps = { + settings: ApiSettings; + campaignId: string; + existingEntries: Record[]; + existingFields: ReturnType; + 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("upload"); + const [csvText, setCsvText] = useState(""); + const [filename, setFilename] = useState(""); + const [mode, setMode] = useState("append"); + const [delimiter, setDelimiter] = useState("auto"); + const [headerRows, setHeaderRows] = useState(1); + const [quoted, setQuoted] = useState(true); + const [valueSeparators, setValueSeparators] = useState(",;|"); + const [mappings, setMappings] = useState([]); + const [fileError, setFileError] = useState(""); + const [fileLinkResolution, setFileLinkResolution] = useState(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" ? ( + <> +
+ + readFile(files[0])} + /> + + + + +
+ {fileError && {fileError}} + +