|
|
|
|
@@ -1,24 +1,28 @@
|
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
|
import type { ApiSettings } from "../../types";
|
|
|
|
|
import {
|
|
|
|
|
createRecipientImportMappingProfile,
|
|
|
|
|
listCampaignRecipientAddressSources,
|
|
|
|
|
listRecipientImportMappingProfiles,
|
|
|
|
|
lookupCampaignAddresses,
|
|
|
|
|
snapshotCampaignRecipientAddressSource,
|
|
|
|
|
updateRecipientImportMappingProfile,
|
|
|
|
|
type CampaignAddressLookupCandidate,
|
|
|
|
|
type CampaignRecipientAddressSource,
|
|
|
|
|
type CampaignRecipientAddressSourceSnapshot,
|
|
|
|
|
type RecipientImportMappingProfilePayload } from
|
|
|
|
|
"../../api/campaigns";
|
|
|
|
|
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";
|
|
|
|
|
import { usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui";
|
|
|
|
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
|
|
|
|
import VersionLine from "./components/VersionLine";
|
|
|
|
|
import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold";
|
|
|
|
|
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 { SegmentedControl } from "@govoplan/core-webui";
|
|
|
|
|
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
|
|
|
|
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
|
|
|
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
|
|
|
|
@@ -34,17 +38,18 @@ import {
|
|
|
|
|
createRecipientImportProvenance,
|
|
|
|
|
createRecipientMappingProfile as buildRecipientMappingProfile,
|
|
|
|
|
defaultColumnMappings,
|
|
|
|
|
importedRowsNeedAttachmentSource,
|
|
|
|
|
matchRecipientMappingProfiles,
|
|
|
|
|
materializeRecipientImport,
|
|
|
|
|
materializeRecipientImportWithAttachmentDefaults,
|
|
|
|
|
recipientImportHeaderFingerprints,
|
|
|
|
|
xlsxSheetsFromArrayBuffer,
|
|
|
|
|
type CsvDelimiter,
|
|
|
|
|
type ImportedAddress,
|
|
|
|
|
type ImportedRecipientRow,
|
|
|
|
|
type RecipientColumnKind,
|
|
|
|
|
type RecipientColumnMapping,
|
|
|
|
|
type RecipientImportMode,
|
|
|
|
|
type RecipientImportPreview,
|
|
|
|
|
type RecipientImportTable,
|
|
|
|
|
type RecipientImportProvenance,
|
|
|
|
|
type RecipientImportSourceType,
|
|
|
|
|
type RecipientImportSpreadsheetSheet,
|
|
|
|
|
@@ -61,9 +66,10 @@ import {
|
|
|
|
|
import {
|
|
|
|
|
addressesFromValue,
|
|
|
|
|
collectCampaignAddressSuggestions,
|
|
|
|
|
dedupeAddresses,
|
|
|
|
|
type MailboxAddress } from
|
|
|
|
|
"@govoplan/core-webui";
|
|
|
|
|
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
|
|
|
|
import { insertAfter, moveArrayItem, i18nMessage, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
|
|
|
|
|
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" },
|
|
|
|
|
@@ -71,6 +77,7 @@ const recipientHeaderRows = [
|
|
|
|
|
const;
|
|
|
|
|
|
|
|
|
|
type RecipientAddressKey = "to" | "cc" | "bcc";
|
|
|
|
|
type AddressSourceFilter = "all" | "books" | "lists";
|
|
|
|
|
|
|
|
|
|
type EntryAddressColumn = {
|
|
|
|
|
key: RecipientAddressKey | "from" | "reply_to";
|
|
|
|
|
@@ -82,8 +89,17 @@ type EntryAddressColumn = {
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
|
|
|
|
const { translateText } = usePlatformLanguage();
|
|
|
|
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
|
|
|
|
const [importOpen, setImportOpen] = useState(false);
|
|
|
|
|
const [addressSourceImportOpen, setAddressSourceImportOpen] = useState(false);
|
|
|
|
|
const [addressSourceImportInitialId, setAddressSourceImportInitialId] = useState("");
|
|
|
|
|
const [addressSourcesAvailable, setAddressSourcesAvailable] = useState(false);
|
|
|
|
|
const [addressSourcesLoading, setAddressSourcesLoading] = useState(false);
|
|
|
|
|
const [addressSources, setAddressSources] = useState<CampaignRecipientAddressSource[]>([]);
|
|
|
|
|
const [addressLookupQuery, setAddressLookupQuery] = useState("");
|
|
|
|
|
const [addressLookupSuggestions, setAddressLookupSuggestions] = useState<MailboxAddress[]>([]);
|
|
|
|
|
const [addressLookupCache, setAddressLookupCache] = useState<Record<string, MailboxAddress[]>>({});
|
|
|
|
|
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
|
|
|
|
|
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
|
|
|
|
|
|
|
|
|
|
@@ -110,6 +126,35 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
|
|
|
|
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 cachedAddressSuggestions = useMemo(
|
|
|
|
|
() => dedupeAddresses(Object.values(addressLookupCache).flat()),
|
|
|
|
|
[addressLookupCache]
|
|
|
|
|
);
|
|
|
|
|
const combinedAddressSuggestions = useMemo(
|
|
|
|
|
() => dedupeAddresses([...addressSuggestions, ...cachedAddressSuggestions, ...addressLookupSuggestions]),
|
|
|
|
|
[addressLookupSuggestions, addressSuggestions, cachedAddressSuggestions]
|
|
|
|
|
);
|
|
|
|
|
const addressSourceRevisionById = useMemo(
|
|
|
|
|
() => new Map(addressSources.map((addressSource) => [addressSource.source_id, addressSource.source_revision])),
|
|
|
|
|
[addressSources]
|
|
|
|
|
);
|
|
|
|
|
const staleAddressImports = useMemo(() => {
|
|
|
|
|
return asArray(entries.imports).
|
|
|
|
|
map(asRecord).
|
|
|
|
|
filter((record) => record.source_type === "addresses").
|
|
|
|
|
map((record) => {
|
|
|
|
|
const sourceId = typeof record.source_id === "string" ? record.source_id : "";
|
|
|
|
|
const importedRevision = typeof record.source_revision === "string" ? record.source_revision : "";
|
|
|
|
|
const currentRevision = sourceId ? addressSourceRevisionById.get(sourceId) ?? "" : "";
|
|
|
|
|
return {
|
|
|
|
|
sourceId,
|
|
|
|
|
sourceLabel: typeof record.source_label === "string" ? record.source_label : sourceId,
|
|
|
|
|
importedRevision,
|
|
|
|
|
currentRevision
|
|
|
|
|
};
|
|
|
|
|
}).
|
|
|
|
|
filter((record) => record.sourceId && record.currentRevision && record.importedRevision && record.currentRevision !== record.importedRevision);
|
|
|
|
|
}, [addressSourceRevisionById, entries.imports]);
|
|
|
|
|
const defaultFrom = addressesFromValue(recipientsSection.from).slice(0, 1);
|
|
|
|
|
const globalReplyTo = addressesFromValue(recipientsSection.reply_to);
|
|
|
|
|
const globalRecipientValues: Record<string, MailboxAddress[]> = {
|
|
|
|
|
@@ -133,6 +178,75 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
|
|
|
|
return columns;
|
|
|
|
|
}, [recipientsSection]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
setAddressSourcesLoading(true);
|
|
|
|
|
void listCampaignRecipientAddressSources(settings, campaignId).
|
|
|
|
|
then((response) => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
setAddressSourcesAvailable(response.available);
|
|
|
|
|
setAddressSources(response.available ? response.sources ?? [] : []);
|
|
|
|
|
}).
|
|
|
|
|
catch(() => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
setAddressSourcesAvailable(false);
|
|
|
|
|
setAddressSources([]);
|
|
|
|
|
}).
|
|
|
|
|
finally(() => {
|
|
|
|
|
if (!cancelled) setAddressSourcesLoading(false);
|
|
|
|
|
});
|
|
|
|
|
return () => {cancelled = true;};
|
|
|
|
|
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const query = addressLookupQuery.trim();
|
|
|
|
|
if (query.length < 2) {
|
|
|
|
|
setAddressLookupSuggestions(addressLookupCache[""] ?? []);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const cached = addressLookupCache[query.toLowerCase()];
|
|
|
|
|
if (cached) {
|
|
|
|
|
setAddressLookupSuggestions(cached);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
const timer = window.setTimeout(() => {
|
|
|
|
|
void lookupCampaignAddresses(settings, campaignId, query).
|
|
|
|
|
then((response) => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
if (!response.available) {
|
|
|
|
|
setAddressLookupSuggestions([]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const nextSuggestions = mailboxAddressesFromLookupCandidates(response.candidates);
|
|
|
|
|
setAddressLookupCache((current) => ({ ...current, [query.toLowerCase()]: nextSuggestions }));
|
|
|
|
|
setAddressLookupSuggestions(nextSuggestions);
|
|
|
|
|
}).
|
|
|
|
|
catch(() => {
|
|
|
|
|
if (!cancelled) setAddressLookupSuggestions([]);
|
|
|
|
|
});
|
|
|
|
|
}, 100);
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
window.clearTimeout(timer);
|
|
|
|
|
};
|
|
|
|
|
}, [addressLookupCache, addressLookupQuery, campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
void lookupCampaignAddresses(settings, campaignId, "", 100).
|
|
|
|
|
then((response) => {
|
|
|
|
|
if (cancelled || !response.available) return;
|
|
|
|
|
setAddressLookupCache((current) => ({ ...current, "": mailboxAddressesFromLookupCandidates(response.candidates) }));
|
|
|
|
|
}).
|
|
|
|
|
catch(() => undefined);
|
|
|
|
|
return () => {cancelled = true;};
|
|
|
|
|
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
|
|
|
|
|
|
|
|
|
const handleAddressSuggestionQueryChange = useCallback((query: string) => {
|
|
|
|
|
setAddressLookupQuery(query);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
|
|
|
|
|
patch(["entries", "inline"], nextEntries);
|
|
|
|
|
@@ -200,63 +314,47 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
|
|
|
|
|
|
|
|
|
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) {
|
|
|
|
|
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, provenance });
|
|
|
|
|
const nextDraft = needsAttachmentSource ?
|
|
|
|
|
{
|
|
|
|
|
...importedDraft,
|
|
|
|
|
attachments: {
|
|
|
|
|
...asRecord(importedDraft.attachments),
|
|
|
|
|
base_paths: nextBasePaths,
|
|
|
|
|
base_path: nextBasePaths[0]?.path || "."
|
|
|
|
|
}
|
|
|
|
|
} :
|
|
|
|
|
importedDraft;
|
|
|
|
|
setDraft(nextDraft);
|
|
|
|
|
setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode, provenance }));
|
|
|
|
|
markDirty();
|
|
|
|
|
setImportOpen(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyAddressSourceImport(snapshot: CampaignRecipientAddressSourceSnapshot, mode: RecipientImportMode) {
|
|
|
|
|
if (locked || !draft) return;
|
|
|
|
|
const preview = buildAddressSourceImportPreview(snapshot, inlineEntries);
|
|
|
|
|
const provenance = createAddressSourceImportProvenance(snapshot, preview, mode);
|
|
|
|
|
applyRecipientImport(preview, mode, provenance);
|
|
|
|
|
setAddressSourceImportOpen(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="content-pad workspace-data-page">
|
|
|
|
|
<div className="page-heading split workspace-heading">
|
|
|
|
|
<div>
|
|
|
|
|
<PageTitle loading={loading}>i18n:govoplan-campaign.sender_recipients.922c6d24</PageTitle>
|
|
|
|
|
<VersionLine version={version} versions={data.versions} status={saveState} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="button-row compact-actions">
|
|
|
|
|
<Button onClick={reload} disabled={loading}>i18n:govoplan-campaign.reload.cce71553</Button>
|
|
|
|
|
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<>
|
|
|
|
|
<CampaignDraftPageScaffold
|
|
|
|
|
settings={settings}
|
|
|
|
|
campaignId={campaignId}
|
|
|
|
|
title="i18n:govoplan-campaign.sender_recipients.922c6d24"
|
|
|
|
|
loading={loading}
|
|
|
|
|
version={version}
|
|
|
|
|
versions={data.versions}
|
|
|
|
|
saveState={saveState}
|
|
|
|
|
onReload={reload}
|
|
|
|
|
onSave={() => saveDraft("manual")}
|
|
|
|
|
dirty={dirty}
|
|
|
|
|
locked={locked}
|
|
|
|
|
draft={draft}
|
|
|
|
|
error={error}
|
|
|
|
|
localError={localError}
|
|
|
|
|
currentVersionId={data.campaign?.current_version_id}>
|
|
|
|
|
|
|
|
|
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
|
|
|
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
|
|
|
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
|
|
|
|
|
|
|
|
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
|
|
|
|
<>
|
|
|
|
|
<Card title="i18n:govoplan-campaign.campaign_sender.b12e8ae2" collapsible>
|
|
|
|
|
<div className="campaign-header-stack">
|
|
|
|
|
<div className="campaign-header-grid">
|
|
|
|
|
<FormField label="i18n:govoplan-campaign.default_from_address.b9ee6d77">
|
|
|
|
|
<EmailAddressInput
|
|
|
|
|
value={defaultFrom}
|
|
|
|
|
suggestions={addressSuggestions}
|
|
|
|
|
suggestions={combinedAddressSuggestions}
|
|
|
|
|
onSuggestionQueryChange={handleAddressSuggestionQueryChange}
|
|
|
|
|
allowMultiple={false}
|
|
|
|
|
disabled={locked}
|
|
|
|
|
addLabel="i18n:govoplan-campaign.set_from.11fe7396"
|
|
|
|
|
@@ -278,7 +376,8 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
|
|
|
|
<FormField label="i18n:govoplan-campaign.global_reply_to_address.bec1a89b">
|
|
|
|
|
<EmailAddressInput
|
|
|
|
|
value={globalReplyTo}
|
|
|
|
|
suggestions={addressSuggestions}
|
|
|
|
|
suggestions={combinedAddressSuggestions}
|
|
|
|
|
onSuggestionQueryChange={handleAddressSuggestionQueryChange}
|
|
|
|
|
allowMultiple
|
|
|
|
|
disabled={locked}
|
|
|
|
|
addLabel="i18n:govoplan-campaign.add_reply_to.84b195f4"
|
|
|
|
|
@@ -305,7 +404,8 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
|
|
|
|
<FormField label={row.label}>
|
|
|
|
|
<EmailAddressInput
|
|
|
|
|
value={globalRecipientValues[row.key] ?? []}
|
|
|
|
|
suggestions={addressSuggestions}
|
|
|
|
|
suggestions={combinedAddressSuggestions}
|
|
|
|
|
onSuggestionQueryChange={handleAddressSuggestionQueryChange}
|
|
|
|
|
allowMultiple
|
|
|
|
|
disabled={locked}
|
|
|
|
|
addLabel={row.addLabel}
|
|
|
|
|
@@ -326,16 +426,44 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<Card title="i18n:govoplan-campaign.recipient_profiles.3dc9671c" actions={<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>}>
|
|
|
|
|
<Card
|
|
|
|
|
title="i18n:govoplan-campaign.recipient_profiles.3dc9671c"
|
|
|
|
|
actions={
|
|
|
|
|
<div className="button-row compact-actions">
|
|
|
|
|
{(addressSourcesAvailable || addressSourcesLoading) &&
|
|
|
|
|
<Button disabled={locked || addressSourcesLoading} onClick={() => {setAddressSourceImportInitialId("");setAddressSourceImportOpen(true);}}>
|
|
|
|
|
Import address book/list
|
|
|
|
|
</Button>
|
|
|
|
|
}
|
|
|
|
|
<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>
|
|
|
|
|
</div>
|
|
|
|
|
}>
|
|
|
|
|
{inlineEntries.length === 0 && Boolean(source.type) &&
|
|
|
|
|
<DismissibleAlert tone="info">i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.bdae9fb4</DismissibleAlert>
|
|
|
|
|
}
|
|
|
|
|
{staleAddressImports.length > 0 &&
|
|
|
|
|
<DismissibleAlert tone="warning" dismissible={false}>
|
|
|
|
|
<div className="stale-address-import-warning">
|
|
|
|
|
<span>Address imports are older than their source: {staleAddressImports.map((item) => item.sourceLabel || item.sourceId).join(", ")}.</span>
|
|
|
|
|
<div className="button-row compact-actions">
|
|
|
|
|
{staleAddressImports.map((item) =>
|
|
|
|
|
<Button
|
|
|
|
|
key={item.sourceId}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {setAddressSourceImportInitialId(item.sourceId);setAddressSourceImportOpen(true);}}>
|
|
|
|
|
Re-import {item.sourceLabel || "source"}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</DismissibleAlert>
|
|
|
|
|
}
|
|
|
|
|
{!source.type &&
|
|
|
|
|
<div className="admin-table-surface recipient-profiles-table-surface">
|
|
|
|
|
<DataGrid
|
|
|
|
|
id={`campaign-${campaignId}-recipient-profiles`}
|
|
|
|
|
rows={inlineEntries}
|
|
|
|
|
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry })}
|
|
|
|
|
columns={recipientProfileColumns({ locked, entries: inlineEntries, entryDefaults, entryAddressColumns, addressSuggestions: combinedAddressSuggestions, onAddressSuggestionQueryChange: handleAddressSuggestionQueryChange, translateText, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry })}
|
|
|
|
|
getRowKey={(entry, index) => String(entry.id || index)}
|
|
|
|
|
emptyText="i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.478b8026"
|
|
|
|
|
emptyAction={<DataGridEmptyAction onAdd={() => addRecipient(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_recipient.e540d52a" />}
|
|
|
|
|
@@ -354,9 +482,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
</Card>
|
|
|
|
|
</>
|
|
|
|
|
</LoadingFrame>
|
|
|
|
|
|
|
|
|
|
</CampaignDraftPageScaffold>
|
|
|
|
|
{importOpen &&
|
|
|
|
|
<RecipientImportDialog
|
|
|
|
|
settings={settings}
|
|
|
|
|
@@ -368,8 +494,263 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
|
|
|
|
onImport={applyRecipientImport} />
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
</div>);
|
|
|
|
|
{addressSourceImportOpen &&
|
|
|
|
|
<AddressSourceImportDialog
|
|
|
|
|
settings={settings}
|
|
|
|
|
campaignId={campaignId}
|
|
|
|
|
sources={addressSources}
|
|
|
|
|
initialSourceId={addressSourceImportInitialId}
|
|
|
|
|
onCancel={() => setAddressSourceImportOpen(false)}
|
|
|
|
|
onImport={applyAddressSourceImport} />
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
</>);
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
<div className="admin-table-surface recipient-import-preview-surface">
|
|
|
|
|
<table className="recipient-import-preview-table">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>#</th>
|
|
|
|
|
<th>Name</th>
|
|
|
|
|
<th>Email</th>
|
|
|
|
|
<th>Fields</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{snapshot.recipients.slice(0, 20).map((recipient, index) =>
|
|
|
|
|
<tr key={`${recipient.contact_id}-${recipient.email}`}>
|
|
|
|
|
<td>{index + 1}</td>
|
|
|
|
|
<td>{recipient.display_name}</td>
|
|
|
|
|
<td>{recipient.email}</td>
|
|
|
|
|
<td>{Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
{snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>}
|
|
|
|
|
</>
|
|
|
|
|
}
|
|
|
|
|
</Dialog>);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type RecipientImportDialogProps = {
|
|
|
|
|
@@ -1188,18 +1569,162 @@ 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";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mailboxAddressesFromLookupCandidates(candidates: CampaignAddressLookupCandidate[]): MailboxAddress[] {
|
|
|
|
|
return dedupeAddresses(
|
|
|
|
|
candidates.
|
|
|
|
|
map((candidate) => candidate.email ? { name: candidate.display_name || undefined, email: candidate.email } : null).
|
|
|
|
|
filter((address): address is MailboxAddress => Boolean(address?.email))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type RecipientProfileColumnContext = {
|
|
|
|
|
locked: boolean;
|
|
|
|
|
entries: Record<string, unknown>[];
|
|
|
|
|
entryDefaults: Record<string, unknown>;
|
|
|
|
|
entryAddressColumns: EntryAddressColumn[];
|
|
|
|
|
addressSuggestions: MailboxAddress[];
|
|
|
|
|
onAddressSuggestionQueryChange: (query: string) => void;
|
|
|
|
|
translateText: (value: string) => string;
|
|
|
|
|
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void;
|
|
|
|
|
updateEntryMerge: (index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) => void;
|
|
|
|
|
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
|
|
|
|
|
@@ -1208,7 +1733,7 @@ type RecipientProfileColumnContext = {
|
|
|
|
|
removeEntry: (index: number) => void;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressColumns, addressSuggestions, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
|
|
|
|
function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressColumns, addressSuggestions, onAddressSuggestionQueryChange, translateText, updateEntryAddressList, updateEntryMerge, updateEntry, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
|
|
|
|
return [
|
|
|
|
|
{ id: "number", header: "#", width: 70, sortable: true, filterType: "integer", sticky: "start", render: (_entry, index) => <span className="mono-small">{index + 1}</span>, value: (_entry, index) => index + 1 },
|
|
|
|
|
...entryAddressColumns.map((column): DataGridColumn<Record<string, unknown>> => ({
|
|
|
|
|
@@ -1222,6 +1747,7 @@ function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressC
|
|
|
|
|
<EmailAddressInput
|
|
|
|
|
value={getEntryAddresses(entry, column.key)}
|
|
|
|
|
suggestions={addressSuggestions}
|
|
|
|
|
onSuggestionQueryChange={onAddressSuggestionQueryChange}
|
|
|
|
|
allowMultiple={column.allowMultiple}
|
|
|
|
|
compact
|
|
|
|
|
disabled={locked}
|
|
|
|
|
@@ -1237,7 +1763,7 @@ function recipientProfileColumns({ locked, entries, entryDefaults, entryAddressC
|
|
|
|
|
disabled={locked}
|
|
|
|
|
onChange={(checked) => updateEntryMerge(index, column.mergeKey!, checked)} />
|
|
|
|
|
|
|
|
|
|
<span className="muted">i18n:govoplan-campaign.merge_with_global.ba230098 {column.label}</span>
|
|
|
|
|
<span className="muted">{translateText("i18n:govoplan-campaign.merge_with_global.ba230098")} {translateText(column.label)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
</div>,
|
|
|
|
|
|