Release v0.1.3
This commit is contained in:
@@ -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>[];
|
||||
|
||||
Reference in New Issue
Block a user