refactor(campaign): split review and recipient boundaries
This commit is contained in:
@@ -63,6 +63,10 @@ import {
|
||||
type RecipientMappingProfile,
|
||||
type RecipientMappingProfileMatch } from
|
||||
"./utils/bulkImport";
|
||||
import {
|
||||
buildAddressSourceImportPreview,
|
||||
createAddressSourceImportProvenance
|
||||
} from "./utils/addressSourceImport";
|
||||
import {
|
||||
bulkLinkFilesToCampaign,
|
||||
type ImportFileLinkCapability,
|
||||
@@ -77,6 +81,7 @@ import {
|
||||
type MailboxAddress } from
|
||||
"@govoplan/core-webui";
|
||||
import { insertAfter, moveArrayItem, i18nMessage, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
|
||||
import AddressSourceImportDialog from "./recipients/AddressSourceImportDialog";
|
||||
const recipientHeaderRows = [
|
||||
{ key: "to", label: "i18n:govoplan-campaign.to.ae79ea1e", toggleKey: "allow_individual_to", toggleLabel: "i18n:govoplan-campaign.allow_individual_to.966cb33e", addLabel: "i18n:govoplan-campaign.add_recipient.a989d1f1", emptyText: "i18n:govoplan-campaign.no_global_recipients_configured.d4e20e92" },
|
||||
{ key: "cc", label: "i18n:govoplan-campaign.cc.c5a976de", toggleKey: "allow_individual_cc", toggleLabel: "i18n:govoplan-campaign.allow_individual_cc.0457c0e2", addLabel: "i18n:govoplan-campaign.add_cc.bcb39ea3", emptyText: "i18n:govoplan-campaign.no_global_cc_recipients_configured.8afb23c1" },
|
||||
@@ -85,7 +90,6 @@ const;
|
||||
|
||||
type RecipientAddressKey = "to" | "cc" | "bcc";
|
||||
type AddressFieldKey = RecipientAddressKey | "from" | "reply_to";
|
||||
type AddressSourceFilter = "all" | "books" | "lists";
|
||||
type HeaderAddressEditorState = {
|
||||
title: string;
|
||||
columns: EntryAddressColumn[];
|
||||
@@ -941,242 +945,6 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
|
||||
|
||||
}
|
||||
|
||||
type AddressSourceImportDialogProps = {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
sources: CampaignRecipientAddressSource[];
|
||||
initialSourceId?: string;
|
||||
onCancel: () => void;
|
||||
onImport: (snapshot: CampaignRecipientAddressSourceSnapshot, mode: RecipientImportMode) => void;
|
||||
};
|
||||
|
||||
function addressSourceType(source: CampaignRecipientAddressSource): "book" | "list" {
|
||||
return source.source_id.startsWith("addresses:address_list:") || source.source_kind === "address_list" ? "list" : "book";
|
||||
}
|
||||
|
||||
function addressSourceTypeLabel(source: CampaignRecipientAddressSource): string {
|
||||
if (addressSourceType(source) === "list") return "Address list";
|
||||
if (source.source_kind && source.source_kind !== "local") return `${source.source_kind} address book`;
|
||||
return "Address book";
|
||||
}
|
||||
|
||||
function addressSourceScopeLabel(source: CampaignRecipientAddressSource): string {
|
||||
const provenance = asRecord(source.provenance);
|
||||
const scopeType = String(provenance.scope_type ?? "").trim();
|
||||
const scopeId = String(provenance.scope_id ?? "").trim();
|
||||
if (!scopeType) return "Unknown scope";
|
||||
const label = scopeType.charAt(0).toUpperCase() + scopeType.slice(1);
|
||||
return scopeId && scopeId !== scopeType ? `${label} - ${scopeId}` : label;
|
||||
}
|
||||
|
||||
function addressSourceSearchText(source: CampaignRecipientAddressSource): string {
|
||||
const provenance = asRecord(source.provenance);
|
||||
return [
|
||||
source.source_label,
|
||||
source.source_kind,
|
||||
source.source_id,
|
||||
addressSourceTypeLabel(source),
|
||||
addressSourceScopeLabel(source),
|
||||
provenance.address_book_id,
|
||||
provenance.address_list_id,
|
||||
provenance.scope_type,
|
||||
provenance.scope_id,
|
||||
provenance.tenant_id
|
||||
].map((value) => String(value ?? "").toLowerCase()).join(" ");
|
||||
}
|
||||
|
||||
function shortSourceRevision(value: string): string {
|
||||
if (!value) return "no revision";
|
||||
if (value.length <= 24) return value;
|
||||
return `${value.slice(0, 19)}...`;
|
||||
}
|
||||
|
||||
function AddressSourceImportDialog({ settings, campaignId, sources, initialSourceId = "", onCancel, onImport }: AddressSourceImportDialogProps) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const [selectedSourceId, setSelectedSourceId] = useState(initialSourceId || sources[0]?.source_id || "");
|
||||
const [sourceFilter, setSourceFilter] = useState<AddressSourceFilter>("all");
|
||||
const [sourceQuery, setSourceQuery] = useState("");
|
||||
const [mode, setMode] = useState<RecipientImportMode>("append");
|
||||
const [snapshot, setSnapshot] = useState<CampaignRecipientAddressSourceSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const sourceCounts = useMemo(() => {
|
||||
return sources.reduce(
|
||||
(counts, source) => {
|
||||
counts[addressSourceType(source)] += 1;
|
||||
return counts;
|
||||
},
|
||||
{ book: 0, list: 0 }
|
||||
);
|
||||
}, [sources]);
|
||||
const filteredSources = useMemo(() => {
|
||||
const query = sourceQuery.trim().toLowerCase();
|
||||
return sources.filter((source) => {
|
||||
const type = addressSourceType(source);
|
||||
if (sourceFilter === "books" && type !== "book") return false;
|
||||
if (sourceFilter === "lists" && type !== "list") return false;
|
||||
if (!query) return true;
|
||||
return addressSourceSearchText(source).includes(query);
|
||||
});
|
||||
}, [sourceFilter, sourceQuery, sources]);
|
||||
const selectedSource = useMemo(
|
||||
() => sources.find((source) => source.source_id === selectedSourceId) ?? null,
|
||||
[selectedSourceId, sources]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredSources.some((source) => source.source_id === selectedSourceId)) return;
|
||||
setSelectedSourceId(filteredSources[0]?.source_id ?? "");
|
||||
}, [filteredSources, selectedSourceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialSourceId && sources.some((source) => source.source_id === initialSourceId)) {
|
||||
setSourceFilter("all");
|
||||
setSourceQuery("");
|
||||
setSelectedSourceId(initialSourceId);
|
||||
}
|
||||
}, [initialSourceId, sources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSourceId) {
|
||||
setSnapshot(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
void snapshotCampaignRecipientAddressSource(settings, campaignId, selectedSourceId).
|
||||
then((nextSnapshot) => {
|
||||
if (!cancelled) setSnapshot(nextSnapshot);
|
||||
}).
|
||||
catch((err) => {
|
||||
if (cancelled) return;
|
||||
setSnapshot(null);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}).
|
||||
finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {cancelled = true;};
|
||||
}, [campaignId, selectedSourceId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
function openAddressBookModule() {
|
||||
onCancel();
|
||||
navigate("/address-book");
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="Import recipients from addresses"
|
||||
className="recipient-import-modal"
|
||||
bodyClassName="recipient-import-body"
|
||||
closeDisabled={loading}
|
||||
closeOnBackdrop={!loading}
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={loading}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||
<Button variant="primary" disabled={loading || !snapshot || snapshot.recipients.length === 0} onClick={() => snapshot && onImport(snapshot, mode)}>
|
||||
Import recipients
|
||||
</Button>
|
||||
</>
|
||||
}>
|
||||
|
||||
<div className="address-source-import-controls">
|
||||
<div>
|
||||
<SegmentedControl
|
||||
ariaLabel="Address source type"
|
||||
value={sourceFilter}
|
||||
onChange={setSourceFilter}
|
||||
size="content"
|
||||
width="inline"
|
||||
disabled={loading || sources.length === 0}
|
||||
options={[
|
||||
{ id: "all", label: `All (${sources.length})` },
|
||||
{ id: "books", label: `Books (${sourceCounts.book})` },
|
||||
{ id: "lists", label: `Lists (${sourceCounts.list})` }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
value={sourceQuery}
|
||||
disabled={loading || sources.length === 0}
|
||||
placeholder="Search address sources"
|
||||
aria-label="Search address sources"
|
||||
onChange={(event) => setSourceQuery(event.target.value)}
|
||||
/>
|
||||
<Button type="button" onClick={openAddressBookModule}>
|
||||
Manage address books
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="campaign-header-grid recipient-import-upload-grid">
|
||||
<div className="address-source-picker" role="radiogroup" aria-label="Address source">
|
||||
{filteredSources.map((source) =>
|
||||
<button
|
||||
type="button"
|
||||
key={source.source_id}
|
||||
className={`address-source-option ${source.source_id === selectedSourceId ? "is-selected" : ""}`}
|
||||
disabled={loading}
|
||||
role="radio"
|
||||
aria-checked={source.source_id === selectedSourceId}
|
||||
onClick={() => setSelectedSourceId(source.source_id)}>
|
||||
<span className="address-source-option-main">
|
||||
<strong>{source.source_label}</strong>
|
||||
<span>{addressSourceTypeLabel(source)} - {addressSourceScopeLabel(source)}</span>
|
||||
</span>
|
||||
<span className="address-source-option-meta">
|
||||
<span>{source.recipient_count} recipients</span>
|
||||
<code>{shortSourceRevision(source.source_revision)}</code>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{sources.length > 0 && filteredSources.length === 0 &&
|
||||
<div className="empty-state compact-empty">No address sources match the current filter.</div>
|
||||
}
|
||||
</div>
|
||||
<FormField label="Import mode">
|
||||
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
|
||||
<option value="append">i18n:govoplan-campaign.append_to_current_profiles.0b434d6f</option>
|
||||
<option value="replace">i18n:govoplan-campaign.replace_current_profiles.3b3be5e2</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert>}
|
||||
{loading && <DismissibleAlert tone="info" compact dismissible={false}>Loading address recipients...</DismissibleAlert>}
|
||||
{!loading && sources.length === 0 && <DismissibleAlert tone="info" dismissible={false}>No address sources are available to this campaign. Create an address book or address list in the addresses module first.</DismissibleAlert>}
|
||||
|
||||
{snapshot &&
|
||||
<>
|
||||
<dl className="detail-list recipient-import-summary">
|
||||
<div><dt>Source</dt><dd>{snapshot.source_label}</dd></div>
|
||||
<div><dt>Type</dt><dd>{selectedSource ? addressSourceTypeLabel(selectedSource) : "Address source"}</dd></div>
|
||||
<div><dt>Scope</dt><dd>{selectedSource ? addressSourceScopeLabel(selectedSource) : "Unknown"}</dd></div>
|
||||
<div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div>
|
||||
<div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div>
|
||||
</dl>
|
||||
<AddressSourceRecipientPreviewGrid recipients={snapshot.recipients.slice(0, 20)} />
|
||||
{snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>}
|
||||
</>
|
||||
}
|
||||
</Dialog>);
|
||||
}
|
||||
|
||||
type AddressSourceSnapshotRecipient = CampaignRecipientAddressSourceSnapshot["recipients"][number];
|
||||
|
||||
function AddressSourceRecipientPreviewGrid({ recipients }: {recipients: AddressSourceSnapshotRecipient[];}) {
|
||||
const columns: DataGridColumn<AddressSourceSnapshotRecipient>[] = [
|
||||
{ id: "row", header: "#", width: 64, value: (_recipient, index) => index + 1, render: (_recipient, index) => index + 1 },
|
||||
{ id: "name", header: "Name", width: "minmax(180px, 1fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.display_name },
|
||||
{ id: "email", header: "Email", width: "minmax(220px, 1.2fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.email },
|
||||
{ id: "fields", header: "Fields", width: 100, sortable: true, value: (recipient) => Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length }
|
||||
];
|
||||
return <DataGrid id="campaign-address-source-import-preview" rows={recipients} columns={columns} getRowKey={(recipient) => `${recipient.contact_id}-${recipient.email}`} />;
|
||||
}
|
||||
|
||||
type RecipientImportDialogProps = {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
@@ -1950,141 +1718,6 @@ function formatImportBytes(value?: number | null): string {
|
||||
return i18nMessage("i18n:govoplan-campaign.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
|
||||
}
|
||||
|
||||
function buildAddressSourceImportPreview(snapshot: CampaignRecipientAddressSourceSnapshot, existingEntries: Record<string, unknown>[]): RecipientImportPreview {
|
||||
const headers = ["display_name", "email", "given_name", "family_name", "organization", "role_title", "phone", "tags"];
|
||||
const tableRows = [
|
||||
headers,
|
||||
...snapshot.recipients.map((recipient) => headers.map((header) => {
|
||||
if (header === "display_name") return recipient.display_name;
|
||||
if (header === "email") return recipient.email;
|
||||
return fieldValueToString((recipient.fields ?? {})[header]);
|
||||
}))];
|
||||
|
||||
const table: RecipientImportTable = {
|
||||
delimiter: ",",
|
||||
headers,
|
||||
headerRows: [headers],
|
||||
rows: tableRows,
|
||||
dataRows: tableRows.slice(1),
|
||||
firstDataRowNumber: 2
|
||||
};
|
||||
const usedIds = new Set(existingEntries.map((entry) => String(entry.id || "")).filter(Boolean));
|
||||
const fieldNamesToCreate = new Set<string>();
|
||||
const rows: ImportedRecipientRow[] = snapshot.recipients.map((recipient, index) => {
|
||||
const email = recipient.email.trim();
|
||||
const name = recipient.display_name.trim();
|
||||
const fields = stringFieldsFromAddressSource(recipient.fields);
|
||||
Object.keys(fields).forEach((fieldName) => fieldNamesToCreate.add(fieldName));
|
||||
const id = uniqueImportId(
|
||||
`address-${recipient.contact_id || idFragmentFromEmail(email) || index + 1}`,
|
||||
usedIds
|
||||
);
|
||||
const issues: string[] = [];
|
||||
if (!email.includes("@")) issues.push(`${email || "email"} must contain @`);
|
||||
return {
|
||||
rowNumber: table.firstDataRowNumber + index,
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
active: true,
|
||||
addresses: {
|
||||
from: [],
|
||||
to: email ? [{ name, email }] : [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
reply_to: []
|
||||
},
|
||||
fields,
|
||||
patterns: [],
|
||||
issues
|
||||
};
|
||||
});
|
||||
|
||||
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: 0
|
||||
};
|
||||
}
|
||||
|
||||
function createAddressSourceImportProvenance(
|
||||
snapshot: CampaignRecipientAddressSourceSnapshot,
|
||||
preview: RecipientImportPreview,
|
||||
mode: RecipientImportMode)
|
||||
: RecipientImportProvenance {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `recipient-import-addresses-${safeImportIdFragment(snapshot.source_id)}-${Date.now().toString(36)}`,
|
||||
imported_at: now,
|
||||
mode,
|
||||
source_type: "addresses",
|
||||
source_id: snapshot.source_id,
|
||||
source_label: snapshot.source_label,
|
||||
source_revision: snapshot.source_revision,
|
||||
source_provenance: snapshot.provenance,
|
||||
filename: null,
|
||||
sheet_name: null,
|
||||
encoding: null,
|
||||
delimiter: null,
|
||||
header_rows: 0,
|
||||
quoted: null,
|
||||
value_separators: null,
|
||||
rows_total: preview.rows.length,
|
||||
valid_rows: preview.validCount,
|
||||
invalid_rows: preview.invalidCount,
|
||||
imported_rows: preview.validCount,
|
||||
field_names_created: preview.fieldNamesToCreate.slice(),
|
||||
attachment_patterns: 0,
|
||||
mapping: []
|
||||
};
|
||||
}
|
||||
|
||||
function stringFieldsFromAddressSource(value: Record<string, unknown> | undefined): Record<string, string> {
|
||||
const fields: Record<string, string> = {};
|
||||
for (const [key, rawValue] of Object.entries(value ?? {})) {
|
||||
const fieldValue = fieldValueToString(rawValue);
|
||||
if (fieldValue) fields[key] = fieldValue;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function fieldValueToString(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (Array.isArray(value)) return value.map(fieldValueToString).filter(Boolean).join(", ");
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function idFragmentFromEmail(value: string): string {
|
||||
return safeImportIdFragment(value.split("@")[0] ?? "");
|
||||
}
|
||||
|
||||
function safeImportIdFragment(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "source";
|
||||
}
|
||||
|
||||
function uniqueImportId(preferred: string, usedIds: Set<string>): string {
|
||||
const base = safeImportIdFragment(preferred) || "recipient";
|
||||
let candidate = base;
|
||||
let counter = 2;
|
||||
while (usedIds.has(candidate)) {
|
||||
candidate = `${base}-${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
usedIds.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
|
||||
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";
|
||||
|
||||
Reference in New Issue
Block a user