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";
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Check,
|
||||
ChevronDown,
|
||||
FlaskConical,
|
||||
Link2,
|
||||
LockKeyhole,
|
||||
PackageCheck,
|
||||
Search,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
X,
|
||||
type LucideIcon } from
|
||||
ShieldCheck } from
|
||||
"lucide-react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
@@ -34,25 +29,22 @@ import {
|
||||
sendCampaignNow,
|
||||
updateCampaignReviewState,
|
||||
validateVersion,
|
||||
type CampaignAttachmentPreviewFile,
|
||||
type CampaignAttachmentPreviewResponse,
|
||||
type CampaignDeliveryOptions,
|
||||
type CampaignJobsQuery,
|
||||
type CampaignJobsResponse,
|
||||
type CampaignSummary,
|
||||
type CampaignVersionDetail } from
|
||||
type CampaignSummary } from
|
||||
"../../api/campaigns";
|
||||
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
||||
import { Button, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert, TableActionGroup } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { InlineHelp, i18nMessage } from "@govoplan/core-webui";
|
||||
import { i18nMessage } from "@govoplan/core-webui";
|
||||
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
@@ -71,53 +63,41 @@ import {
|
||||
stringifyPreview } from
|
||||
"./utils/campaignView";
|
||||
import { deliveryModeLabel } from "./utils/deliveryMode";
|
||||
import { getBool, getText } from "./utils/draftEditor";
|
||||
import { attachmentPreviewLinkableFiles, attachmentPreviewMatchedFiles } from "./utils/attachmentPreview";
|
||||
import { getText } from "./utils/draftEditor";
|
||||
import { attachmentPreviewLinkableFiles } from "./utils/attachmentPreview";
|
||||
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
|
||||
import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./utils/templatePlaceholders";
|
||||
|
||||
type FlowState =
|
||||
"complete" |
|
||||
"warning" |
|
||||
"danger" |
|
||||
"active" |
|
||||
"locked" |
|
||||
"running" |
|
||||
"partial" |
|
||||
"stale" |
|
||||
"pending";
|
||||
|
||||
type FlowStageDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
shortTitle: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
state: FlowState;
|
||||
connectorState?: FlowState;
|
||||
stateLabel: string;
|
||||
lockReason?: string;
|
||||
};
|
||||
|
||||
type DeliverabilityPreflightItem = {
|
||||
label: string;
|
||||
detail: string;
|
||||
state: "ready" | "warning" | "blocked" | "info";
|
||||
};
|
||||
import AttachmentLinkingPreview from "./review/AttachmentLinkingPreview";
|
||||
import DeliverabilityPreflight, {
|
||||
type DeliverabilityPreflightItem
|
||||
} from "./review/DeliverabilityPreflight";
|
||||
import DeliveryJobDetailOverlay from "./review/DeliveryJobDetailOverlay";
|
||||
import BuiltMessagePreview from "./review/BuiltMessagePreview";
|
||||
import {
|
||||
WorkflowFact,
|
||||
WorkflowNavigation,
|
||||
WorkflowStage,
|
||||
stageConnectorState,
|
||||
stateLabel,
|
||||
type FlowStageDefinition,
|
||||
type FlowState
|
||||
} from "./review/WorkflowNavigation";
|
||||
import {
|
||||
builtMessageKey,
|
||||
filterAndSortBuiltMessageRows,
|
||||
findBuiltMessageIndex,
|
||||
messageNeedsExplicitReview,
|
||||
reviewQueryEquals,
|
||||
sameBuiltMessage,
|
||||
storedMessageReviewState
|
||||
} from "./review/builtMessageQuery";
|
||||
import {
|
||||
countResolvedAttachments,
|
||||
formatAddressList,
|
||||
numberFrom
|
||||
} from "./review/reviewFormatters";
|
||||
|
||||
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "queue" | "control" | "retry" | "imap" | "";
|
||||
|
||||
const stateColors: Record<FlowState, string> = {
|
||||
complete: "var(--green)",
|
||||
warning: "var(--amber)",
|
||||
danger: "var(--red)",
|
||||
active: "var(--blue)",
|
||||
locked: "var(--line-dark)",
|
||||
running: "var(--blue)",
|
||||
partial: "var(--review-flow-partial)",
|
||||
stale: "var(--review-flow-partial)",
|
||||
pending: "var(--muted)"
|
||||
};
|
||||
|
||||
const MESSAGE_VALIDATION_OPTIONS: DataGridListOption[] = [
|
||||
{ value: "ready", label: "i18n:govoplan-campaign.ready.20c7c552" },
|
||||
@@ -1778,409 +1758,6 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
|
||||
|
||||
}
|
||||
|
||||
function WorkflowNavigation({ stages, onSelect }: {stages: FlowStageDefinition[];onSelect: (id: string) => void;}) {
|
||||
return (
|
||||
<nav className="review-flow-navigation" aria-label="i18n:govoplan-campaign.review_and_send_workflow_steps.77fc8af2">
|
||||
<div className="review-flow-navigation-track">
|
||||
{stages.map((stage, index) => {
|
||||
const Icon = stage.icon;
|
||||
const nextStage = stages[index + 1];
|
||||
const connectorState = stageConnectorState(stage);
|
||||
const nextConnectorState = nextStage ? stageConnectorState(nextStage) : connectorState;
|
||||
const title = i18nMessage(stage.title);
|
||||
const shortTitle = i18nMessage(stage.shortTitle);
|
||||
const stateText = i18nMessage(stage.stateLabel);
|
||||
const style = {
|
||||
"--review-nav-color": stateColors[stage.state],
|
||||
"--review-nav-line-color": stateColors[connectorState],
|
||||
"--review-nav-line-next-color": stateColors[nextConnectorState]
|
||||
} as CSSProperties;
|
||||
const showSecondaryState = !["active", "locked"].includes(stage.state);
|
||||
return (
|
||||
<div className="review-flow-navigation-group" key={stage.id} style={style}>
|
||||
<button
|
||||
type="button"
|
||||
className="review-flow-navigation-item"
|
||||
data-state={stage.state}
|
||||
onClick={() => onSelect(stage.id)}
|
||||
title={`${title}: ${stateText}`}>
|
||||
|
||||
<span className="review-flow-navigation-icon"><Icon size={17} strokeWidth={1.8} aria-hidden="true" /></span>
|
||||
<span className="review-flow-navigation-copy">
|
||||
<strong>
|
||||
<span>{shortTitle}</span>
|
||||
{stage.state === "locked" && <LockKeyhole size={12} aria-label="i18n:govoplan-campaign.locked.a798882f" />}
|
||||
</strong>
|
||||
{showSecondaryState && <small>{stateText}</small>}
|
||||
</span>
|
||||
</button>
|
||||
{nextStage && <span className="review-flow-navigation-line" aria-hidden="true" />}
|
||||
</div>);
|
||||
|
||||
})}
|
||||
</div>
|
||||
</nav>);
|
||||
|
||||
}
|
||||
|
||||
function stageConnectorState(stage: FlowStageDefinition): FlowState {
|
||||
return stage.connectorState ?? stage.state;
|
||||
}
|
||||
|
||||
function WorkflowStage({
|
||||
stage,
|
||||
nextState,
|
||||
nextConnectorState,
|
||||
children
|
||||
|
||||
|
||||
|
||||
|
||||
}: {stage: FlowStageDefinition;nextState?: FlowState;nextConnectorState?: FlowState;children: ReactNode;}) {
|
||||
const Icon = stage.icon;
|
||||
const locked = stage.state === "locked";
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const title = i18nMessage(stage.title);
|
||||
const description = i18nMessage(stage.description);
|
||||
const stateText = i18nMessage(stage.stateLabel);
|
||||
const lockReason = stage.lockReason ? i18nMessage(stage.lockReason) : "";
|
||||
const connectorState = stageConnectorState(stage);
|
||||
const style = {
|
||||
"--review-stage-color": stateColors[stage.state],
|
||||
"--review-stage-line-color": stateColors[connectorState],
|
||||
"--review-next-stage-line-color": stateColors[nextConnectorState ?? connectorState]
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<section id={stage.id} className="review-flow-stage" data-state={stage.state} style={style}>
|
||||
<div className="review-flow-stage-marker" aria-hidden="true">
|
||||
<div className="review-flow-stage-node"><Icon size={20} strokeWidth={1.8} /></div>
|
||||
{nextState && <div className="review-flow-stage-line" />}
|
||||
</div>
|
||||
<article className={`card card-collapsible review-flow-stage-card${locked ? " is-locked" : ""}${collapsed ? " is-collapsed" : ""}`} aria-disabled={locked || undefined}>
|
||||
<header className="card-header review-flow-stage-header">
|
||||
<h2>
|
||||
<span>{title}</span>
|
||||
{locked && <LockKeyhole className="review-flow-title-lock" size={15} aria-label="i18n:govoplan-campaign.locked.a798882f" />}
|
||||
{!locked &&
|
||||
<span
|
||||
className="review-flow-state-badge"
|
||||
data-state={stage.state}
|
||||
aria-label={stateText}
|
||||
title={stateText}>
|
||||
|
||||
{stateText}
|
||||
</span>
|
||||
}
|
||||
<InlineHelp>{description}</InlineHelp>
|
||||
</h2>
|
||||
<div className="review-flow-stage-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="card-collapse-toggle"
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={collapsed ? i18nMessage("i18n:govoplan-campaign.expand_value.be085ae4", { value0: title }) : i18nMessage("i18n:govoplan-campaign.collapse_value.29095640", { value0: title })}
|
||||
title={collapsed ? "i18n:govoplan-campaign.show_content.0528d8d2" : "i18n:govoplan-campaign.show_header_only.24afefca"}
|
||||
onClick={() => setCollapsed((value) => !value)}>
|
||||
|
||||
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{!collapsed &&
|
||||
<>
|
||||
<div className="card-body review-flow-stage-content">{children}</div>
|
||||
{locked &&
|
||||
<div className="review-flow-lock-message">
|
||||
<span className="review-flow-lock-icon"><LockKeyhole size={20} aria-hidden="true" /></span>
|
||||
<span>{lockReason}</span>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
}
|
||||
</article>
|
||||
</section>);
|
||||
|
||||
}
|
||||
|
||||
function AttachmentLinkingPreview({
|
||||
preview,
|
||||
loading,
|
||||
error,
|
||||
linking,
|
||||
disabled,
|
||||
onRefresh,
|
||||
onLink
|
||||
}: {
|
||||
preview: CampaignAttachmentPreviewResponse | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
linking: boolean;
|
||||
disabled: boolean;
|
||||
onRefresh: () => void;
|
||||
onLink: () => void;
|
||||
}) {
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const matchedFiles = attachmentPreviewMatchedFiles(preview);
|
||||
const linkableFiles = attachmentPreviewLinkableFiles(preview);
|
||||
const linkedCount = preview?.linked_file_count ?? matchedFiles.filter((file) => file.linked_to_campaign !== false).length;
|
||||
const matchedCount = preview?.matched_file_count ?? matchedFiles.length;
|
||||
const unlinkedCount = preview?.unlinked_file_count ?? linkableFiles.length;
|
||||
return (
|
||||
<>
|
||||
<div className="review-flow-data-section attachment-linking-preview">
|
||||
<div className="attachment-linking-header">
|
||||
<div>
|
||||
<h3>i18n:govoplan-campaign.attachment_file_links.0be74fd1</h3>
|
||||
<p className="muted small-note">i18n:govoplan-campaign.preview_includes_already_linked_files_and_access.dfef92af</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={onRefresh} disabled={loading || linking}>{loading ? "i18n:govoplan-campaign.refreshing.505dddc9" : "i18n:govoplan-campaign.refresh.56e3badc"}</Button>
|
||||
<Button variant="primary" onClick={onLink} disabled={disabled || loading || linking || unlinkedCount === 0}>
|
||||
{linking ?
|
||||
"i18n:govoplan-campaign.linking.a5f54e0f" :
|
||||
i18nMessage(
|
||||
unlinkedCount === 1 ? "i18n:govoplan-campaign.link_value_file.4d4ce740" : "i18n:govoplan-campaign.link_value_files.88b7e6a7",
|
||||
{ value0: unlinkedCount }
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="review-flow-fact-grid">
|
||||
<WorkflowFact
|
||||
label="i18n:govoplan-campaign.matched.1bf3ec5b"
|
||||
value={!loading && matchedFiles.length > 0 ?
|
||||
<button
|
||||
type="button"
|
||||
className="review-flow-fact-detail-button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={detailsOpen}
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951", { value0: matchedFiles.length })}
|
||||
onClick={() => setDetailsOpen(true)}>
|
||||
{matchedCount}
|
||||
</button> :
|
||||
loading ? "..." : matchedCount || "—"} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.linked.a089f600" value={loading ? "..." : linkedCount || "—"} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.need_link.fa4ab530" value={loading ? "..." : unlinkedCount || "—"} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.shared_source.7d4a1bf2" value={loading ? "..." : preview?.shared_file_count ?? "—"} />
|
||||
</div>
|
||||
{error && <p className="review-flow-inline-note is-danger">{error}</p>}
|
||||
{!error && unlinkedCount > 0 &&
|
||||
<p className="review-flow-inline-note is-stale">i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998</p>
|
||||
}
|
||||
{!error && !loading && matchedFiles.length === 0 &&
|
||||
<p className="muted small-note">i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c</p>
|
||||
}
|
||||
{matchedFiles.length > 0 &&
|
||||
<AttachmentLinkingFileList files={matchedFiles} compact />
|
||||
}
|
||||
</div>
|
||||
<Dialog
|
||||
open={detailsOpen}
|
||||
title={i18nMessage("i18n:govoplan-campaign.attachment_file_links_value.ce230e30", { value0: matchedFiles.length })}
|
||||
className="attachment-linking-detail-modal"
|
||||
bodyClassName="attachment-linking-detail-body"
|
||||
onClose={() => setDetailsOpen(false)}
|
||||
footer={<Button variant="primary" onClick={() => setDetailsOpen(false)}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
|
||||
<p className="muted small-note">i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5</p>
|
||||
<AttachmentLinkingFileList files={matchedFiles} />
|
||||
</Dialog>
|
||||
</>);
|
||||
}
|
||||
|
||||
function AttachmentLinkingFileList({ files, compact = false }: {files: CampaignAttachmentPreviewFile[];compact?: boolean;}) {
|
||||
return (
|
||||
<ul
|
||||
className={`attachment-linking-file-list${compact ? " is-compact" : " is-detail"}`}
|
||||
tabIndex={0}
|
||||
aria-label={i18nMessage(
|
||||
files.length === 1 ? "i18n:govoplan-campaign.value_matched_attachment_file_link.30a84824" : "i18n:govoplan-campaign.value_matched_attachment_file_links.ce509a3a",
|
||||
{ value0: files.length }
|
||||
)}>
|
||||
{files.map((file, index) => {
|
||||
const linked = file.linked_to_campaign !== false;
|
||||
const Icon = linked ? Link2 : X;
|
||||
return (
|
||||
<li key={`${file.id || file.display_path || file.filename}:${index}`} data-linked={linked ? "true" : "false"}>
|
||||
<Icon size={15} strokeWidth={2.2} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{file.filename || file.display_path}</strong>
|
||||
<small>{linked ? "i18n:govoplan-campaign.linked_already_part_of_the_campaign_file_snapsho.d037a6bc" : "i18n:govoplan-campaign.unlinked_candidate_match_potentially_missing_unt.2bae9433"}</small>
|
||||
{file.display_path && file.display_path !== file.filename && <small className="muted">{file.display_path}</small>}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>);
|
||||
}
|
||||
|
||||
function DeliverabilityPreflight({ items }: {items: DeliverabilityPreflightItem[];}) {
|
||||
return (
|
||||
<section className="deliverability-preflight" aria-label="Deliverability preflight">
|
||||
<div className="deliverability-preflight-header">
|
||||
<h3>Deliverability preflight</h3>
|
||||
<span className="muted small-note">Operator checks before the first live send.</span>
|
||||
</div>
|
||||
<div className="deliverability-preflight-grid">
|
||||
{items.map((item) =>
|
||||
<div key={item.label} className="deliverability-preflight-item" data-state={item.state}>
|
||||
<div>
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.detail}</strong>
|
||||
</div>
|
||||
<StatusBadge status={item.state === "blocked" ? "failed" : item.state === "warning" ? "warning" : item.state === "ready" ? "ready" : "info"} label={humanize(item.state)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>);
|
||||
}
|
||||
|
||||
function BuiltMessagePreview({
|
||||
campaignJson,
|
||||
entries,
|
||||
rows,
|
||||
index,
|
||||
canStartSingleMessageSend,
|
||||
singleMessageSendBusy,
|
||||
onSelect,
|
||||
onSendSingle,
|
||||
onClose
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {campaignJson: Record<string, unknown>;entries: Record<string, unknown>[];rows: Record<string, unknown>[];index: number;canStartSingleMessageSend: boolean;singleMessageSendBusy: boolean;onSelect: (index: number) => void;onSendSingle: (index: number) => void;onClose: () => void;}) {
|
||||
const row = rows[index] ?? {};
|
||||
const entryIndex = Math.max(0, numberFrom(row, ["entry_index"]) - 1);
|
||||
const entry = entries[entryIndex] ?? {};
|
||||
const template = asRecord(campaignJson.template);
|
||||
const context = buildTemplatePreviewContext(campaignJson, entry);
|
||||
const ignoreEmptyFields = getBool(asRecord(campaignJson.validation_policy), "ignore_empty_fields", false);
|
||||
const html = renderTemplatePreviewText(getText(template, "html"), context, ignoreEmptyFields);
|
||||
const text = renderTemplatePreviewText(getText(template, "text"), context, ignoreEmptyFields);
|
||||
const subject = String(row.subject || renderTemplatePreviewText(getText(template, "subject"), context, ignoreEmptyFields) || "i18n:govoplan-campaign.no_subject.7b4e8035");
|
||||
const issues = asArray(row.issues).map(asRecord);
|
||||
const resolvedRecipients = asRecord(row.resolved_recipients);
|
||||
const singleSendDisabledReason = singleMessageSendDisabledReason(row, canStartSingleMessageSend);
|
||||
|
||||
return (
|
||||
<CampaignMessagePreviewOverlay
|
||||
title="i18n:govoplan-campaign.built_message_review.c7a594f9"
|
||||
subject={subject}
|
||||
bodyMode={html.trim() ? "html" : "text"}
|
||||
text={text}
|
||||
html={html}
|
||||
recipientLabel={formatAddressList(resolvedRecipients.to) || String(row.recipient_email ?? `Message ${index + 1}`)}
|
||||
recipientNote={issues.length > 0 ? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues.map((issue) => String(issue.message ?? issue.code ?? "i18n:govoplan-campaign.issue.73781a12")).join(" · ")}` : "i18n:govoplan-campaign.built_without_reported_issues.99c1f1a6"}
|
||||
metaItems={builtMessageMetaItems(row)}
|
||||
attachments={builtMessageAttachments(row)}
|
||||
navigation={{
|
||||
index,
|
||||
total: rows.length,
|
||||
onFirst: () => onSelect(0),
|
||||
onPrevious: () => onSelect(Math.max(0, index - 1)),
|
||||
onNext: () => onSelect(Math.min(rows.length - 1, index + 1)),
|
||||
onLast: () => onSelect(rows.length - 1)
|
||||
}}
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={singleMessageSendBusy || Boolean(singleSendDisabledReason)}
|
||||
title={singleSendDisabledReason || "Send only this built message"}
|
||||
onClick={() => onSendSingle(index)}>
|
||||
{singleMessageSendBusy ? "Sending..." : "Send this message..."}
|
||||
</Button>
|
||||
}
|
||||
onClose={onClose} />);
|
||||
|
||||
|
||||
}
|
||||
|
||||
function singleMessageSendDisabledReason(row: Record<string, unknown>, canStartSingleMessageSend: boolean): string {
|
||||
if (!canStartSingleMessageSend) {
|
||||
return "Validate, build, and complete the required review/mock gate before sending individual messages.";
|
||||
}
|
||||
const jobId = String(row.id ?? "");
|
||||
if (!jobId) return "This preview row has no delivery job id.";
|
||||
const buildStatus = String(row.build_status ?? "");
|
||||
if (buildStatus !== "built") return "This message has not been built.";
|
||||
const validationStatus = String(row.validation_status ?? "");
|
||||
if (["blocked", "excluded", "inactive"].includes(validationStatus)) {
|
||||
return `This message is ${humanize(validationStatus)}.`;
|
||||
}
|
||||
const sendStatus = String(row.send_status ?? "not_queued");
|
||||
const queueStatus = String(row.queue_status ?? "draft");
|
||||
if (["smtp_accepted", "sent"].includes(sendStatus)) return "This message was already accepted by SMTP.";
|
||||
if (["claimed", "sending", "outcome_unknown"].includes(sendStatus)) return `This message is in delivery state ${humanize(sendStatus)}.`;
|
||||
if (["failed_temporary", "failed_permanent"].includes(sendStatus)) return "Use the explicit retry action for failed messages.";
|
||||
if (sendStatus === "queued" && queueStatus === "queued") return "";
|
||||
if (["not_queued", "cancelled"].includes(sendStatus) && ["draft", "cancelled"].includes(queueStatus)) return "";
|
||||
return `This message cannot be sent from queue ${humanize(queueStatus)} / send ${humanize(sendStatus)}.`;
|
||||
}
|
||||
|
||||
function DeliveryJobDetailOverlay({ detail, onClose }: {detail: Record<string, unknown>;onClose: () => void;}) {
|
||||
const job = asRecord(detail.job);
|
||||
const attempts = asRecord(detail.attempts);
|
||||
const smtpAttempts = asArray(attempts.smtp).map(asRecord);
|
||||
const imapAttempts = asArray(attempts.imap).map(asRecord);
|
||||
const issues = asArray(job.issues).map(asRecord);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="i18n:govoplan-campaign.delivery_job_details.0d9c5b1f"
|
||||
className="template-preview-modal"
|
||||
onClose={onClose}
|
||||
footer={<Button variant="primary" onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
|
||||
|
||||
<dl className="detail-list">
|
||||
<div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{formatAddressList(asRecord(job.resolved_recipients).to) || String(job.recipient_email ?? "-")}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(job.subject ?? "-")}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.smtp_status.6211cb2b</dt><dd>{humanize(String(job.send_status ?? "-"))}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.imap_status.dbc2e430</dt><dd>{humanize(String(job.imap_status ?? "-"))}</dd></div>
|
||||
{job.last_error ? <div><dt>i18n:govoplan-campaign.last_error.5e4df866</dt><dd>{String(job.last_error)}</dd></div> : null}
|
||||
</dl>
|
||||
{issues.length > 0 && <AttemptList title="i18n:govoplan-campaign.message_issues.9092a7db" rows={issues} />}
|
||||
<AttemptList title="i18n:govoplan-campaign.smtp_attempts.eb0a9ca6" rows={smtpAttempts} emptyText="i18n:govoplan-campaign.no_smtp_attempts_were_recorded.ff5b4c5d" />
|
||||
<AttemptList title="i18n:govoplan-campaign.imap_attempts.e815f0c2" rows={imapAttempts} emptyText="i18n:govoplan-campaign.no_imap_append_attempts_were_recorded_if_status_.12279f7e" />
|
||||
</Dialog>);
|
||||
|
||||
}
|
||||
|
||||
function AttemptList({ title, rows, emptyText = "i18n:govoplan-campaign.no_rows.fdbeab75" }: {title: string;rows: Record<string, unknown>[];emptyText?: string;}) {
|
||||
return (
|
||||
<section className="review-flow-data-section">
|
||||
<h3>{title}</h3>
|
||||
{rows.length === 0 ? <p className="muted small-note">{emptyText}</p> :
|
||||
<dl className="detail-list">
|
||||
{rows.map((row, index) =>
|
||||
<div key={`${String(row.id ?? row.code ?? index)}:${index}`}>
|
||||
<dt>{String(row.status ?? row.severity ?? row.code ?? i18nMessage("i18n:govoplan-campaign.value.44b8c76f", { value0: index + 1 }))}</dt>
|
||||
<dd>
|
||||
<strong>{String(row.message ?? row.error_message ?? row.smtp_response ?? row.folder ?? row.path ?? "-")}</strong>
|
||||
{row.started_at || row.created_at ? <span className="muted"> · {formatDateTime(String(row.started_at ?? row.created_at))}</span> : null}
|
||||
{row.finished_at || row.updated_at ? <span className="muted"> → {formatDateTime(String(row.finished_at ?? row.updated_at))}</span> : null}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
}
|
||||
</section>);
|
||||
|
||||
}
|
||||
|
||||
function WorkflowFact({ label, value }: {label: string;value: ReactNode;}) {
|
||||
return (
|
||||
<div className="review-flow-fact">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function builtMessageColumns(
|
||||
openMessage: (row: Record<string, unknown>) => void,
|
||||
reviewedKeys: Set<string>)
|
||||
@@ -2205,144 +1782,6 @@ reviewedKeys: Set<string>)
|
||||
|
||||
}
|
||||
|
||||
type ReviewFilterType = "text" | "integer" | "list";
|
||||
type ReviewFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte";
|
||||
|
||||
function filterAndSortBuiltMessageRows(
|
||||
rows: Record<string, unknown>[],
|
||||
query: DataGridQueryState,
|
||||
reviewedKeys: Set<string>)
|
||||
: Record<string, unknown>[] {
|
||||
const filters = query.filters ?? {};
|
||||
const filtered = rows.filter((row, rowIndex) =>
|
||||
Object.entries(filters).every(([columnId, filterValue]) => {
|
||||
if (!isBuiltMessageQueryColumn(columnId)) return true;
|
||||
if (!filterValue.trim()) return true;
|
||||
return matchesReviewFilter(
|
||||
builtMessageColumnValue(columnId, row, rowIndex, reviewedKeys),
|
||||
filterValue,
|
||||
builtMessageFilterType(columnId)
|
||||
);
|
||||
})
|
||||
);
|
||||
if (!query.sort) return filtered;
|
||||
const { columnId, direction } = query.sort;
|
||||
if (!isBuiltMessageQueryColumn(columnId)) return filtered;
|
||||
return [...filtered].sort((left, right) => {
|
||||
const leftIndex = rows.indexOf(left);
|
||||
const rightIndex = rows.indexOf(right);
|
||||
const result = compareReviewValues(
|
||||
builtMessageColumnValue(columnId, left, leftIndex, reviewedKeys),
|
||||
builtMessageColumnValue(columnId, right, rightIndex, reviewedKeys)
|
||||
);
|
||||
return direction === "desc" ? -result : result;
|
||||
});
|
||||
}
|
||||
|
||||
function isBuiltMessageQueryColumn(columnId: string): boolean {
|
||||
return ["number", "recipient", "subject", "validation", "attachments", "reviewed"].includes(columnId);
|
||||
}
|
||||
|
||||
function builtMessageColumnValue(
|
||||
columnId: string,
|
||||
row: Record<string, unknown>,
|
||||
index: number,
|
||||
reviewedKeys: Set<string>)
|
||||
: unknown {
|
||||
switch (columnId) {
|
||||
case "number":return Number(row.entry_index ?? index + 1);
|
||||
case "recipient":return formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "—");
|
||||
case "subject":return String(row.subject ?? "—");
|
||||
case "validation":return String(row.validation_status ?? "unknown");
|
||||
case "attachments":return Number(row.attachment_count ?? countResolvedAttachments(row.attachments));
|
||||
case "reviewed":return row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no";
|
||||
default:return "";
|
||||
}
|
||||
}
|
||||
|
||||
function builtMessageFilterType(columnId: string): ReviewFilterType {
|
||||
if (columnId === "number" || columnId === "attachments") return "integer";
|
||||
if (columnId === "validation" || columnId === "reviewed") return "list";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function matchesReviewFilter(value: unknown, filterValue: string, filterType: ReviewFilterType): boolean {
|
||||
if (!filterValue.trim()) return true;
|
||||
if (filterType === "list") {
|
||||
const selected = parseReviewListFilter(filterValue);
|
||||
return selected.includes(stringifyReviewCell(value));
|
||||
}
|
||||
if (filterType === "integer") {
|
||||
const parsed = parseReviewTypedFilter(filterValue);
|
||||
if (!parsed.value.trim()) return true;
|
||||
const actual = parseReviewNumber(value);
|
||||
const expected = Number(parsed.value);
|
||||
if (!Number.isFinite(actual) || !Number.isFinite(expected)) return false;
|
||||
return compareReviewByOperator(actual, expected, parsed.operator);
|
||||
}
|
||||
return stringifyReviewCell(value).toLowerCase().includes(filterValue.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function parseReviewListFilter(value: string): string[] {
|
||||
if (value.startsWith("list:")) {
|
||||
try {
|
||||
const parsed = JSON.parse(value.slice(5));
|
||||
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function parseReviewTypedFilter(value: string): {operator: ReviewFilterOperator;value: string;} {
|
||||
if (!value.includes(":")) return { operator: "eq", value };
|
||||
const [operator, ...parts] = value.split(":");
|
||||
if (operator === "contains" || operator === "eq" || operator === "gt" || operator === "gte" || operator === "lt" || operator === "lte") {
|
||||
return { operator, value: parts.join(":") };
|
||||
}
|
||||
return { operator: "eq", value };
|
||||
}
|
||||
|
||||
function parseReviewNumber(value: unknown): number {
|
||||
if (typeof value === "number") return value;
|
||||
const text = stringifyReviewCell(value).replace(/[^0-9.,+-]/g, "").replace(",", ".");
|
||||
if (!text.trim()) return NaN;
|
||||
return Number(text);
|
||||
}
|
||||
|
||||
function compareReviewByOperator(actual: number, expected: number, operator: ReviewFilterOperator): boolean {
|
||||
if (operator === "gt") return actual > expected;
|
||||
if (operator === "gte") return actual >= expected;
|
||||
if (operator === "lt") return actual < expected;
|
||||
if (operator === "lte") return actual <= expected;
|
||||
return actual === expected;
|
||||
}
|
||||
|
||||
function compareReviewValues(left: unknown, right: unknown): number {
|
||||
if (typeof left === "number" && typeof right === "number") return left - right;
|
||||
return stringifyReviewCell(left).localeCompare(stringifyReviewCell(right), undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
|
||||
function stringifyReviewCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (Array.isArray(value)) return value.map(stringifyReviewCell).join(", ");
|
||||
return "";
|
||||
}
|
||||
|
||||
function reviewQueryEquals(left: DataGridQueryState, right: DataGridQueryState): boolean {
|
||||
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
|
||||
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
|
||||
const leftFilters = left.filters ?? {};
|
||||
const rightFilters = right.filters ?? {};
|
||||
const keys = new Set([...Object.keys(leftFilters), ...Object.keys(rightFilters)]);
|
||||
for (const key of keys) {
|
||||
if ((leftFilters[key] ?? "") !== (rightFilters[key] ?? "")) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function mockSendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
|
||||
const options: DataGridListOption[] = [
|
||||
{ value: "sent", label: "i18n:govoplan-campaign.sent.35f49dcf" },
|
||||
@@ -2411,84 +1850,6 @@ function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGri
|
||||
|
||||
}
|
||||
|
||||
function builtMessageMetaItems(row: Record<string, unknown>) {
|
||||
const recipients = asRecord(row.resolved_recipients);
|
||||
return [
|
||||
{ label: "i18n:govoplan-campaign.from.3f66052a", value: formatSingleAddress(recipients.from) || "—" },
|
||||
{ label: "i18n:govoplan-campaign.to.ae79ea1e", value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—") },
|
||||
{ label: "i18n:govoplan-campaign.cc.c5a976de", value: formatAddressList(recipients.cc) || null },
|
||||
{ label: "i18n:govoplan-campaign.bcc.4c0145a3", value: formatAddressList(recipients.bcc) || null },
|
||||
{ label: "i18n:govoplan-campaign.validation.dd74d182", value: String(row.validation_status ?? "—") },
|
||||
{ label: "i18n:govoplan-campaign.mime_size.c8b9d519", value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—" }];
|
||||
|
||||
}
|
||||
|
||||
function builtMessageAttachments(row: Record<string, unknown>): CampaignMessagePreviewAttachment[] {
|
||||
return asArray(row.attachments).flatMap((value, index) => {
|
||||
const attachment = asRecord(value);
|
||||
const zipProtection = zipProtectionFromBuiltAttachment(attachment);
|
||||
const managedMatches = asArray(attachment.managed_matches).map(asRecord);
|
||||
if (managedMatches.length > 0) {
|
||||
return managedMatches.map((match, matchIndex) => ({
|
||||
filename: String(match.filename ?? match.display_path ?? `Attachment ${index + 1}.${matchIndex + 1}`),
|
||||
detail: String(match.display_path ?? match.relative_path ?? attachment.label ?? ""),
|
||||
contentType: stringOrUndefined(match.content_type),
|
||||
sizeBytes: numberOrUndefined(match.size_bytes),
|
||||
archiveGroup: stringOrUndefined(attachment.zip_filename),
|
||||
archiveLabel: stringOrUndefined(attachment.zip_filename),
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
}));
|
||||
}
|
||||
const matches = asArray(attachment.matches).filter((match): match is string => typeof match === "string" && Boolean(match.trim()));
|
||||
if (matches.length > 0) {
|
||||
return matches.map((match) => ({
|
||||
filename: match.split(/[\\/]/).pop() || String(attachment.label ?? `Attachment ${index + 1}`),
|
||||
detail: match,
|
||||
contentType: stringOrUndefined(attachment.content_type),
|
||||
sizeBytes: numberOrUndefined(attachment.size_bytes),
|
||||
archiveGroup: stringOrUndefined(attachment.zip_filename),
|
||||
archiveLabel: stringOrUndefined(attachment.zip_filename),
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
}));
|
||||
}
|
||||
return [{
|
||||
filename: String(attachment.filename ?? attachment.filename_used ?? attachment.display_path ?? attachment.label ?? `Attachment ${index + 1}`),
|
||||
detail: String(attachment.display_path ?? attachment.source_path ?? attachment.file_filter ?? ""),
|
||||
contentType: stringOrUndefined(attachment.content_type),
|
||||
sizeBytes: numberOrUndefined(attachment.size_bytes),
|
||||
archiveGroup: null,
|
||||
archiveLabel: null,
|
||||
protected: false,
|
||||
protectionNote: null
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function zipProtectionFromBuiltAttachment(attachment: Record<string, unknown>): {protected: boolean;note: string | null;} {
|
||||
const zipFilename = stringOrUndefined(attachment.zip_filename);
|
||||
if (!zipFilename) return { protected: false, note: null };
|
||||
const legacyMode = String(attachment.password_mode ?? attachment.zip_password_mode ?? "").trim();
|
||||
const protectedArchive = getBool(attachment, "password_enabled", getBool(attachment, "zip_password_protected", getBool(attachment, "zip_protected", ["direct", "field", "template"].includes(legacyMode))));
|
||||
if (!protectedArchive) return { protected: false, note: null };
|
||||
const field = stringOrUndefined(attachment.password_field) ?? stringOrUndefined(attachment.zip_password_field);
|
||||
const rawScope = String(attachment.password_scope ?? attachment.zip_password_scope ?? "local");
|
||||
const scope = rawScope === "global" ? "global" : "local";
|
||||
const method = String(attachment.method ?? attachment.zip_method ?? "aes") === "zip_standard" ? "i18n:govoplan-campaign.zipcrypto.03bf7fb4" : "i18n:govoplan-campaign.aes.41f215a6";
|
||||
const source = field ? i18nMessage("i18n:govoplan-campaign.scope_field_value", { value0: humanizeScope(scope), value1: field }) : "";
|
||||
return {
|
||||
protected: true,
|
||||
note: source ?
|
||||
i18nMessage("i18n:govoplan-campaign.value_encryption_value", { value0: source, value1: method }) :
|
||||
i18nMessage("i18n:govoplan-campaign.encryption_value", { value0: method })
|
||||
};
|
||||
}
|
||||
|
||||
function humanizeScope(scope: string): string {
|
||||
return scope === "global" ? "i18n:govoplan-campaign.global.5f1184f7" : "i18n:govoplan-campaign.local.dc99d54d";
|
||||
}
|
||||
|
||||
function mockMessageMetaItems(message: MockMailboxMessage) {
|
||||
return [
|
||||
{ label: "i18n:govoplan-campaign.from.3f66052a", value: message.from_header || message.envelope_from || "—" },
|
||||
@@ -2510,102 +1871,6 @@ function mockMessageAttachments(message: MockMailboxMessage): CampaignMessagePre
|
||||
}));
|
||||
}
|
||||
|
||||
function countResolvedAttachments(value: unknown): number {
|
||||
const archives = new Set<string>();
|
||||
let directCount = 0;
|
||||
for (const item of asArray(value)) {
|
||||
const attachment = asRecord(item);
|
||||
const zipFilename = String(attachment.zip_filename ?? "").trim();
|
||||
const managedCount = asArray(attachment.managed_matches).length;
|
||||
const matchCount = asArray(attachment.matches).length;
|
||||
if (zipFilename && (managedCount > 0 || matchCount > 0)) {
|
||||
archives.add(zipFilename);
|
||||
continue;
|
||||
}
|
||||
if (managedCount > 0) {
|
||||
directCount += managedCount;
|
||||
continue;
|
||||
}
|
||||
directCount += matchCount;
|
||||
}
|
||||
return directCount + archives.size;
|
||||
}
|
||||
|
||||
function messageNeedsExplicitReview(row: Record<string, unknown>): boolean {
|
||||
return String(row.validation_status ?? "").toLowerCase() === "needs_review";
|
||||
}
|
||||
|
||||
function storedMessageReviewState(version: CampaignVersionDetail | null): {
|
||||
buildToken: string;
|
||||
inspectionComplete: boolean;
|
||||
reviewedMessageKeys: string[];
|
||||
} {
|
||||
const build = asRecord(version?.build_summary);
|
||||
const buildToken = String(build.build_token ?? build.built_at ?? "");
|
||||
const review = asRecord(asRecord(version?.editor_state).review_send);
|
||||
if (!buildToken || String(review.build_token ?? "") !== buildToken) {
|
||||
return { buildToken, inspectionComplete: false, reviewedMessageKeys: [] };
|
||||
}
|
||||
return {
|
||||
buildToken,
|
||||
inspectionComplete: review.inspection_complete === true,
|
||||
reviewedMessageKeys: asArray(review.reviewed_message_keys).filter((value): value is string => typeof value === "string")
|
||||
};
|
||||
}
|
||||
|
||||
function builtMessageKey(row: Record<string, unknown>, index: number): string {
|
||||
return String(row.entry_id ?? row.entry_index ?? index);
|
||||
}
|
||||
|
||||
function findBuiltMessageIndex(rows: Record<string, unknown>[], row: Record<string, unknown>): number {
|
||||
const id = String(row.id ?? "").trim();
|
||||
if (id) {
|
||||
const index = rows.findIndex((candidate) => String(candidate.id ?? "").trim() === id);
|
||||
if (index >= 0) return index;
|
||||
}
|
||||
const reviewKey = String(row.review_key ?? "").trim();
|
||||
if (reviewKey) {
|
||||
const index = rows.findIndex((candidate) => String(candidate.review_key ?? "").trim() === reviewKey);
|
||||
if (index >= 0) return index;
|
||||
}
|
||||
const entryKey = String(row.entry_id ?? row.entry_index ?? "").trim();
|
||||
if (entryKey) {
|
||||
const index = rows.findIndex((candidate) => String(candidate.entry_id ?? candidate.entry_index ?? "").trim() === entryKey);
|
||||
if (index >= 0) return index;
|
||||
}
|
||||
return rows.indexOf(row);
|
||||
}
|
||||
|
||||
function sameBuiltMessage(
|
||||
left: Record<string, unknown>,
|
||||
leftIndex: number,
|
||||
right: Record<string, unknown>,
|
||||
rightIndex: number)
|
||||
: boolean {
|
||||
const leftId = String(left.id ?? "").trim();
|
||||
const rightId = String(right.id ?? "").trim();
|
||||
if (leftId && rightId) return leftId === rightId;
|
||||
const leftReviewKey = String(left.review_key ?? "").trim();
|
||||
const rightReviewKey = String(right.review_key ?? "").trim();
|
||||
if (leftReviewKey && rightReviewKey) return leftReviewKey === rightReviewKey;
|
||||
const leftEntryKey = String(left.entry_id ?? left.entry_index ?? "").trim();
|
||||
const rightEntryKey = String(right.entry_id ?? right.entry_index ?? "").trim();
|
||||
if (leftEntryKey && rightEntryKey) return leftEntryKey === rightEntryKey;
|
||||
return left === right || leftIndex === rightIndex;
|
||||
}
|
||||
|
||||
function formatAddressList(value: unknown): string {
|
||||
return asArray(value).map(asRecord).map(formatSingleAddress).filter(Boolean).join(", ");
|
||||
}
|
||||
|
||||
function formatSingleAddress(value: unknown): string {
|
||||
const address = asRecord(value);
|
||||
const email = String(address.email ?? "").trim();
|
||||
const name = String(address.name ?? "").trim();
|
||||
if (name && email) return `${name} <${email}>`;
|
||||
return email || name;
|
||||
}
|
||||
|
||||
function synchronousSendReason(option: Record<string, unknown>): string {
|
||||
const configuredMessage = String(option.message ?? "").trim();
|
||||
if (configuredMessage) return configuredMessage;
|
||||
@@ -2631,37 +1896,3 @@ function deliveryPolicySourceLabel(source: string): string {
|
||||
if (source === "deployment_ceiling") return "i18n:govoplan-campaign.deployment_ceiling.abbec75b";
|
||||
return "i18n:govoplan-campaign.not_available.d1a17af1";
|
||||
}
|
||||
|
||||
function numberFrom(record: Record<string, unknown>, keys: string[]): number {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function numberOrUndefined(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringOrUndefined(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
return value.trim() || undefined;
|
||||
}
|
||||
|
||||
function stateLabel(state: FlowState): string {
|
||||
switch (state) {
|
||||
case "complete":return "i18n:govoplan-campaign.passed.271d60f4";
|
||||
case "warning":return "i18n:govoplan-campaign.warnings.1430f976";
|
||||
case "danger":return "i18n:govoplan-campaign.blocked.99613c74";
|
||||
case "active":return "i18n:govoplan-campaign.available.7c62a142";
|
||||
case "locked":return "i18n:govoplan-campaign.locked.a798882f";
|
||||
case "running":return "i18n:govoplan-campaign.running.73989d9c";
|
||||
case "partial":return "i18n:govoplan-campaign.partial.65de2e2a";
|
||||
case "stale":return "i18n:govoplan-campaign.stale.189cc40c";
|
||||
default:return humanize(state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
SegmentedControl,
|
||||
useGuardedNavigate,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import {
|
||||
snapshotCampaignRecipientAddressSource,
|
||||
type CampaignRecipientAddressSource,
|
||||
type CampaignRecipientAddressSourceSnapshot
|
||||
} from "../../../api/campaigns";
|
||||
import type { RecipientImportMode } from "../utils/bulkImport";
|
||||
import { asRecord } from "../utils/campaignView";
|
||||
import { fieldValueToString } from "../utils/addressSourceImport";
|
||||
|
||||
type AddressSourceFilter = "all" | "books" | "lists";
|
||||
|
||||
export default function AddressSourceImportDialog({
|
||||
settings,
|
||||
campaignId,
|
||||
sources,
|
||||
initialSourceId = "",
|
||||
onCancel,
|
||||
onImport
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
sources: CampaignRecipientAddressSource[];
|
||||
initialSourceId?: string;
|
||||
onCancel: () => void;
|
||||
onImport: (
|
||||
snapshot: CampaignRecipientAddressSourceSnapshot,
|
||||
mode: RecipientImportMode
|
||||
) => void;
|
||||
}) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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)}...`;
|
||||
}
|
||||
|
||||
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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
191
webui/src/features/campaigns/review/AttachmentLinkingPreview.tsx
Normal file
191
webui/src/features/campaigns/review/AttachmentLinkingPreview.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useState } from "react";
|
||||
import { Link2, X } from "lucide-react";
|
||||
import { Button, Dialog, i18nMessage } from "@govoplan/core-webui";
|
||||
|
||||
import type {
|
||||
CampaignAttachmentPreviewFile,
|
||||
CampaignAttachmentPreviewResponse
|
||||
} from "../../../api/campaigns";
|
||||
import {
|
||||
attachmentPreviewLinkableFiles,
|
||||
attachmentPreviewMatchedFiles
|
||||
} from "../utils/attachmentPreview";
|
||||
import { WorkflowFact } from "./WorkflowNavigation";
|
||||
|
||||
export default function AttachmentLinkingPreview({
|
||||
preview,
|
||||
loading,
|
||||
error,
|
||||
linking,
|
||||
disabled,
|
||||
onRefresh,
|
||||
onLink
|
||||
}: {
|
||||
preview: CampaignAttachmentPreviewResponse | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
linking: boolean;
|
||||
disabled: boolean;
|
||||
onRefresh: () => void;
|
||||
onLink: () => void;
|
||||
}) {
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const matchedFiles = attachmentPreviewMatchedFiles(preview);
|
||||
const linkableFiles = attachmentPreviewLinkableFiles(preview);
|
||||
const linkedCount =
|
||||
preview?.linked_file_count ??
|
||||
matchedFiles.filter((file) => file.linked_to_campaign !== false).length;
|
||||
const matchedCount = preview?.matched_file_count ?? matchedFiles.length;
|
||||
const unlinkedCount = preview?.unlinked_file_count ?? linkableFiles.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="review-flow-data-section attachment-linking-preview">
|
||||
<div className="attachment-linking-header">
|
||||
<div>
|
||||
<h3>i18n:govoplan-campaign.attachment_file_links.0be74fd1</h3>
|
||||
<p className="muted small-note">
|
||||
i18n:govoplan-campaign.preview_includes_already_linked_files_and_access.dfef92af
|
||||
</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={onRefresh} disabled={loading || linking}>
|
||||
{loading
|
||||
? "i18n:govoplan-campaign.refreshing.505dddc9"
|
||||
: "i18n:govoplan-campaign.refresh.56e3badc"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onLink}
|
||||
disabled={disabled || loading || linking || unlinkedCount === 0}
|
||||
>
|
||||
{linking
|
||||
? "i18n:govoplan-campaign.linking.a5f54e0f"
|
||||
: i18nMessage(
|
||||
unlinkedCount === 1
|
||||
? "i18n:govoplan-campaign.link_value_file.4d4ce740"
|
||||
: "i18n:govoplan-campaign.link_value_files.88b7e6a7",
|
||||
{ value0: unlinkedCount }
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="review-flow-fact-grid">
|
||||
<WorkflowFact
|
||||
label="i18n:govoplan-campaign.matched.1bf3ec5b"
|
||||
value={
|
||||
!loading && matchedFiles.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="review-flow-fact-detail-button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={detailsOpen}
|
||||
aria-label={i18nMessage(
|
||||
"i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951",
|
||||
{ value0: matchedFiles.length }
|
||||
)}
|
||||
onClick={() => setDetailsOpen(true)}
|
||||
>
|
||||
{matchedCount}
|
||||
</button>
|
||||
) : loading ? (
|
||||
"..."
|
||||
) : (
|
||||
matchedCount || "—"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<WorkflowFact
|
||||
label="i18n:govoplan-campaign.linked.a089f600"
|
||||
value={loading ? "..." : linkedCount || "—"}
|
||||
/>
|
||||
<WorkflowFact
|
||||
label="i18n:govoplan-campaign.need_link.fa4ab530"
|
||||
value={loading ? "..." : unlinkedCount || "—"}
|
||||
/>
|
||||
<WorkflowFact
|
||||
label="i18n:govoplan-campaign.shared_source.7d4a1bf2"
|
||||
value={loading ? "..." : preview?.shared_file_count ?? "—"}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="review-flow-inline-note is-danger">{error}</p>}
|
||||
{!error && unlinkedCount > 0 && (
|
||||
<p className="review-flow-inline-note is-stale">
|
||||
i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998
|
||||
</p>
|
||||
)}
|
||||
{!error && !loading && matchedFiles.length === 0 && (
|
||||
<p className="muted small-note">
|
||||
i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c
|
||||
</p>
|
||||
)}
|
||||
{matchedFiles.length > 0 && <AttachmentLinkingFileList files={matchedFiles} compact />}
|
||||
</div>
|
||||
<Dialog
|
||||
open={detailsOpen}
|
||||
title={i18nMessage(
|
||||
"i18n:govoplan-campaign.attachment_file_links_value.ce230e30",
|
||||
{ value0: matchedFiles.length }
|
||||
)}
|
||||
className="attachment-linking-detail-modal"
|
||||
bodyClassName="attachment-linking-detail-body"
|
||||
onClose={() => setDetailsOpen(false)}
|
||||
footer={
|
||||
<Button variant="primary" onClick={() => setDetailsOpen(false)}>
|
||||
i18n:govoplan-campaign.close.bbfa773e
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className="muted small-note">
|
||||
i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5
|
||||
</p>
|
||||
<AttachmentLinkingFileList files={matchedFiles} />
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentLinkingFileList({
|
||||
files,
|
||||
compact = false
|
||||
}: {
|
||||
files: CampaignAttachmentPreviewFile[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ul
|
||||
className={`attachment-linking-file-list${compact ? " is-compact" : " is-detail"}`}
|
||||
tabIndex={0}
|
||||
aria-label={i18nMessage(
|
||||
files.length === 1
|
||||
? "i18n:govoplan-campaign.value_matched_attachment_file_link.30a84824"
|
||||
: "i18n:govoplan-campaign.value_matched_attachment_file_links.ce509a3a",
|
||||
{ value0: files.length }
|
||||
)}
|
||||
>
|
||||
{files.map((file, index) => {
|
||||
const linked = file.linked_to_campaign !== false;
|
||||
const Icon = linked ? Link2 : X;
|
||||
return (
|
||||
<li
|
||||
key={`${file.id || file.display_path || file.filename}:${index}`}
|
||||
data-linked={linked ? "true" : "false"}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2.2} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{file.filename || file.display_path}</strong>
|
||||
<small>
|
||||
{linked
|
||||
? "i18n:govoplan-campaign.linked_already_part_of_the_campaign_file_snapsho.d037a6bc"
|
||||
: "i18n:govoplan-campaign.unlinked_candidate_match_potentially_missing_unt.2bae9433"}
|
||||
</small>
|
||||
{file.display_path && file.display_path !== file.filename && (
|
||||
<small className="muted">{file.display_path}</small>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
312
webui/src/features/campaigns/review/BuiltMessagePreview.tsx
Normal file
312
webui/src/features/campaigns/review/BuiltMessagePreview.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { Button, i18nMessage } from "@govoplan/core-webui";
|
||||
|
||||
import CampaignMessagePreviewOverlay, {
|
||||
type CampaignMessagePreviewAttachment
|
||||
} from "../components/MessagePreviewOverlay";
|
||||
import { asArray, asRecord, humanize } from "../utils/campaignView";
|
||||
import { getBool, getText } from "../utils/draftEditor";
|
||||
import {
|
||||
buildTemplatePreviewContext,
|
||||
renderTemplatePreviewText
|
||||
} from "../utils/templatePlaceholders";
|
||||
import {
|
||||
formatAddressList,
|
||||
formatSingleAddress,
|
||||
numberFrom,
|
||||
numberOrUndefined,
|
||||
stringOrUndefined
|
||||
} from "./reviewFormatters";
|
||||
|
||||
export default function BuiltMessagePreview({
|
||||
campaignJson,
|
||||
entries,
|
||||
rows,
|
||||
index,
|
||||
canStartSingleMessageSend,
|
||||
singleMessageSendBusy,
|
||||
onSelect,
|
||||
onSendSingle,
|
||||
onClose
|
||||
}: {
|
||||
campaignJson: Record<string, unknown>;
|
||||
entries: Record<string, unknown>[];
|
||||
rows: Record<string, unknown>[];
|
||||
index: number;
|
||||
canStartSingleMessageSend: boolean;
|
||||
singleMessageSendBusy: boolean;
|
||||
onSelect: (index: number) => void;
|
||||
onSendSingle: (index: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const row = rows[index] ?? {};
|
||||
const entryIndex = Math.max(0, numberFrom(row, ["entry_index"]) - 1);
|
||||
const entry = entries[entryIndex] ?? {};
|
||||
const template = asRecord(campaignJson.template);
|
||||
const context = buildTemplatePreviewContext(campaignJson, entry);
|
||||
const ignoreEmptyFields = getBool(
|
||||
asRecord(campaignJson.validation_policy),
|
||||
"ignore_empty_fields",
|
||||
false
|
||||
);
|
||||
const html = renderTemplatePreviewText(
|
||||
getText(template, "html"),
|
||||
context,
|
||||
ignoreEmptyFields
|
||||
);
|
||||
const text = renderTemplatePreviewText(
|
||||
getText(template, "text"),
|
||||
context,
|
||||
ignoreEmptyFields
|
||||
);
|
||||
const subject = String(
|
||||
row.subject ||
|
||||
renderTemplatePreviewText(
|
||||
getText(template, "subject"),
|
||||
context,
|
||||
ignoreEmptyFields
|
||||
) ||
|
||||
"i18n:govoplan-campaign.no_subject.7b4e8035"
|
||||
);
|
||||
const issues = asArray(row.issues).map(asRecord);
|
||||
const resolvedRecipients = asRecord(row.resolved_recipients);
|
||||
const singleSendDisabledReason = singleMessageSendDisabledReason(
|
||||
row,
|
||||
canStartSingleMessageSend
|
||||
);
|
||||
|
||||
return (
|
||||
<CampaignMessagePreviewOverlay
|
||||
title="i18n:govoplan-campaign.built_message_review.c7a594f9"
|
||||
subject={subject}
|
||||
bodyMode={html.trim() ? "html" : "text"}
|
||||
text={text}
|
||||
html={html}
|
||||
recipientLabel={
|
||||
formatAddressList(resolvedRecipients.to) ||
|
||||
String(row.recipient_email ?? `Message ${index + 1}`)
|
||||
}
|
||||
recipientNote={
|
||||
issues.length > 0
|
||||
? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues
|
||||
.map((issue) =>
|
||||
String(
|
||||
issue.message ??
|
||||
issue.code ??
|
||||
"i18n:govoplan-campaign.issue.73781a12"
|
||||
)
|
||||
)
|
||||
.join(" · ")}`
|
||||
: "i18n:govoplan-campaign.built_without_reported_issues.99c1f1a6"
|
||||
}
|
||||
metaItems={builtMessageMetaItems(row)}
|
||||
attachments={builtMessageAttachments(row)}
|
||||
navigation={{
|
||||
index,
|
||||
total: rows.length,
|
||||
onFirst: () => onSelect(0),
|
||||
onPrevious: () => onSelect(Math.max(0, index - 1)),
|
||||
onNext: () => onSelect(Math.min(rows.length - 1, index + 1)),
|
||||
onLast: () => onSelect(rows.length - 1)
|
||||
}}
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={singleMessageSendBusy || Boolean(singleSendDisabledReason)}
|
||||
title={singleSendDisabledReason || "Send only this built message"}
|
||||
onClick={() => onSendSingle(index)}
|
||||
>
|
||||
{singleMessageSendBusy ? "Sending..." : "Send this message..."}
|
||||
</Button>
|
||||
}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function singleMessageSendDisabledReason(
|
||||
row: Record<string, unknown>,
|
||||
canStartSingleMessageSend: boolean
|
||||
): string {
|
||||
if (!canStartSingleMessageSend) {
|
||||
return "Validate, build, and complete the required review/mock gate before sending individual messages.";
|
||||
}
|
||||
const jobId = String(row.id ?? "");
|
||||
if (!jobId) return "This preview row has no delivery job id.";
|
||||
const buildStatus = String(row.build_status ?? "");
|
||||
if (buildStatus !== "built") return "This message has not been built.";
|
||||
const validationStatus = String(row.validation_status ?? "");
|
||||
if (["blocked", "excluded", "inactive"].includes(validationStatus)) {
|
||||
return `This message is ${humanize(validationStatus)}.`;
|
||||
}
|
||||
const sendStatus = String(row.send_status ?? "not_queued");
|
||||
const queueStatus = String(row.queue_status ?? "draft");
|
||||
if (["smtp_accepted", "sent"].includes(sendStatus)) {
|
||||
return "This message was already accepted by SMTP.";
|
||||
}
|
||||
if (["claimed", "sending", "outcome_unknown"].includes(sendStatus)) {
|
||||
return `This message is in delivery state ${humanize(sendStatus)}.`;
|
||||
}
|
||||
if (["failed_temporary", "failed_permanent"].includes(sendStatus)) {
|
||||
return "Use the explicit retry action for failed messages.";
|
||||
}
|
||||
if (sendStatus === "queued" && queueStatus === "queued") return "";
|
||||
if (
|
||||
["not_queued", "cancelled"].includes(sendStatus) &&
|
||||
["draft", "cancelled"].includes(queueStatus)
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
return `This message cannot be sent from queue ${humanize(queueStatus)} / send ${humanize(sendStatus)}.`;
|
||||
}
|
||||
|
||||
function builtMessageMetaItems(row: Record<string, unknown>) {
|
||||
const recipients = asRecord(row.resolved_recipients);
|
||||
return [
|
||||
{
|
||||
label: "i18n:govoplan-campaign.from.3f66052a",
|
||||
value: formatSingleAddress(recipients.from) || "—"
|
||||
},
|
||||
{
|
||||
label: "i18n:govoplan-campaign.to.ae79ea1e",
|
||||
value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—")
|
||||
},
|
||||
{
|
||||
label: "i18n:govoplan-campaign.cc.c5a976de",
|
||||
value: formatAddressList(recipients.cc) || null
|
||||
},
|
||||
{
|
||||
label: "i18n:govoplan-campaign.bcc.4c0145a3",
|
||||
value: formatAddressList(recipients.bcc) || null
|
||||
},
|
||||
{
|
||||
label: "i18n:govoplan-campaign.validation.dd74d182",
|
||||
value: String(row.validation_status ?? "—")
|
||||
},
|
||||
{
|
||||
label: "i18n:govoplan-campaign.mime_size.c8b9d519",
|
||||
value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—"
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function builtMessageAttachments(
|
||||
row: Record<string, unknown>
|
||||
): CampaignMessagePreviewAttachment[] {
|
||||
return asArray(row.attachments).flatMap((value, index) => {
|
||||
const attachment = asRecord(value);
|
||||
const zipProtection = zipProtectionFromBuiltAttachment(attachment);
|
||||
const managedMatches = asArray(attachment.managed_matches).map(asRecord);
|
||||
if (managedMatches.length > 0) {
|
||||
return managedMatches.map((match, matchIndex) => ({
|
||||
filename: String(
|
||||
match.filename ??
|
||||
match.display_path ??
|
||||
`Attachment ${index + 1}.${matchIndex + 1}`
|
||||
),
|
||||
detail: String(
|
||||
match.display_path ?? match.relative_path ?? attachment.label ?? ""
|
||||
),
|
||||
contentType: stringOrUndefined(match.content_type),
|
||||
sizeBytes: numberOrUndefined(match.size_bytes),
|
||||
archiveGroup: stringOrUndefined(attachment.zip_filename),
|
||||
archiveLabel: stringOrUndefined(attachment.zip_filename),
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
}));
|
||||
}
|
||||
const matches = asArray(attachment.matches).filter(
|
||||
(match): match is string => typeof match === "string" && Boolean(match.trim())
|
||||
);
|
||||
if (matches.length > 0) {
|
||||
return matches.map((match) => ({
|
||||
filename:
|
||||
match.split(/[\\/]/).pop() ||
|
||||
String(attachment.label ?? `Attachment ${index + 1}`),
|
||||
detail: match,
|
||||
contentType: stringOrUndefined(attachment.content_type),
|
||||
sizeBytes: numberOrUndefined(attachment.size_bytes),
|
||||
archiveGroup: stringOrUndefined(attachment.zip_filename),
|
||||
archiveLabel: stringOrUndefined(attachment.zip_filename),
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
}));
|
||||
}
|
||||
return [{
|
||||
filename: String(
|
||||
attachment.filename ??
|
||||
attachment.filename_used ??
|
||||
attachment.display_path ??
|
||||
attachment.label ??
|
||||
`Attachment ${index + 1}`
|
||||
),
|
||||
detail: String(
|
||||
attachment.display_path ??
|
||||
attachment.source_path ??
|
||||
attachment.file_filter ??
|
||||
""
|
||||
),
|
||||
contentType: stringOrUndefined(attachment.content_type),
|
||||
sizeBytes: numberOrUndefined(attachment.size_bytes),
|
||||
archiveGroup: null,
|
||||
archiveLabel: null,
|
||||
protected: false,
|
||||
protectionNote: null
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function zipProtectionFromBuiltAttachment(
|
||||
attachment: Record<string, unknown>
|
||||
): { protected: boolean; note: string | null } {
|
||||
const zipFilename = stringOrUndefined(attachment.zip_filename);
|
||||
if (!zipFilename) return { protected: false, note: null };
|
||||
const legacyMode = String(
|
||||
attachment.password_mode ?? attachment.zip_password_mode ?? ""
|
||||
).trim();
|
||||
const protectedArchive = getBool(
|
||||
attachment,
|
||||
"password_enabled",
|
||||
getBool(
|
||||
attachment,
|
||||
"zip_password_protected",
|
||||
getBool(
|
||||
attachment,
|
||||
"zip_protected",
|
||||
["direct", "field", "template"].includes(legacyMode)
|
||||
)
|
||||
)
|
||||
);
|
||||
if (!protectedArchive) return { protected: false, note: null };
|
||||
const field =
|
||||
stringOrUndefined(attachment.password_field) ??
|
||||
stringOrUndefined(attachment.zip_password_field);
|
||||
const rawScope = String(
|
||||
attachment.password_scope ?? attachment.zip_password_scope ?? "local"
|
||||
);
|
||||
const scope = rawScope === "global" ? "global" : "local";
|
||||
const method =
|
||||
String(attachment.method ?? attachment.zip_method ?? "aes") === "zip_standard"
|
||||
? "i18n:govoplan-campaign.zipcrypto.03bf7fb4"
|
||||
: "i18n:govoplan-campaign.aes.41f215a6";
|
||||
const source = field
|
||||
? i18nMessage("i18n:govoplan-campaign.scope_field_value", {
|
||||
value0: humanizeScope(scope),
|
||||
value1: field
|
||||
})
|
||||
: "";
|
||||
return {
|
||||
protected: true,
|
||||
note: source
|
||||
? i18nMessage("i18n:govoplan-campaign.value_encryption_value", {
|
||||
value0: source,
|
||||
value1: method
|
||||
})
|
||||
: i18nMessage("i18n:govoplan-campaign.encryption_value", { value0: method })
|
||||
};
|
||||
}
|
||||
|
||||
function humanizeScope(scope: string): string {
|
||||
return scope === "global"
|
||||
? "i18n:govoplan-campaign.global.5f1184f7"
|
||||
: "i18n:govoplan-campaign.local.dc99d54d";
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
|
||||
import { humanize } from "../utils/campaignView";
|
||||
|
||||
export type DeliverabilityPreflightItem = {
|
||||
label: string;
|
||||
detail: string;
|
||||
state: "ready" | "warning" | "blocked" | "info";
|
||||
};
|
||||
|
||||
export default function DeliverabilityPreflight({
|
||||
items
|
||||
}: {
|
||||
items: DeliverabilityPreflightItem[];
|
||||
}) {
|
||||
return (
|
||||
<section className="deliverability-preflight" aria-label="Deliverability preflight">
|
||||
<div className="deliverability-preflight-header">
|
||||
<h3>Deliverability preflight</h3>
|
||||
<span className="muted small-note">Operator checks before the first live send.</span>
|
||||
</div>
|
||||
<div className="deliverability-preflight-grid">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="deliverability-preflight-item"
|
||||
data-state={item.state}
|
||||
>
|
||||
<div>
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.detail}</strong>
|
||||
</div>
|
||||
<StatusBadge
|
||||
status={
|
||||
item.state === "blocked"
|
||||
? "failed"
|
||||
: item.state === "warning"
|
||||
? "warning"
|
||||
: item.state === "ready"
|
||||
? "ready"
|
||||
: "info"
|
||||
}
|
||||
label={humanize(item.state)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
136
webui/src/features/campaigns/review/DeliveryJobDetailOverlay.tsx
Normal file
136
webui/src/features/campaigns/review/DeliveryJobDetailOverlay.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Button, Dialog, i18nMessage } from "@govoplan/core-webui";
|
||||
|
||||
import { asArray, asRecord, formatDateTime, humanize } from "../utils/campaignView";
|
||||
import { formatAddressList } from "./reviewFormatters";
|
||||
|
||||
export default function DeliveryJobDetailOverlay({
|
||||
detail,
|
||||
onClose
|
||||
}: {
|
||||
detail: Record<string, unknown>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const job = asRecord(detail.job);
|
||||
const attempts = asRecord(detail.attempts);
|
||||
const smtpAttempts = asArray(attempts.smtp).map(asRecord);
|
||||
const imapAttempts = asArray(attempts.imap).map(asRecord);
|
||||
const issues = asArray(job.issues).map(asRecord);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="i18n:govoplan-campaign.delivery_job_details.0d9c5b1f"
|
||||
className="template-preview-modal"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
i18n:govoplan-campaign.close.bbfa773e
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<dl className="detail-list">
|
||||
<div>
|
||||
<dt>i18n:govoplan-campaign.recipient.90343260</dt>
|
||||
<dd>
|
||||
{formatAddressList(asRecord(job.resolved_recipients).to) ||
|
||||
String(job.recipient_email ?? "-")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>i18n:govoplan-campaign.subject.8d183dbd</dt>
|
||||
<dd>{String(job.subject ?? "-")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>i18n:govoplan-campaign.smtp_status.6211cb2b</dt>
|
||||
<dd>{humanize(String(job.send_status ?? "-"))}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>i18n:govoplan-campaign.imap_status.dbc2e430</dt>
|
||||
<dd>{humanize(String(job.imap_status ?? "-"))}</dd>
|
||||
</div>
|
||||
{job.last_error ? (
|
||||
<div>
|
||||
<dt>i18n:govoplan-campaign.last_error.5e4df866</dt>
|
||||
<dd>{String(job.last_error)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
{issues.length > 0 && (
|
||||
<AttemptList
|
||||
title="i18n:govoplan-campaign.message_issues.9092a7db"
|
||||
rows={issues}
|
||||
/>
|
||||
)}
|
||||
<AttemptList
|
||||
title="i18n:govoplan-campaign.smtp_attempts.eb0a9ca6"
|
||||
rows={smtpAttempts}
|
||||
emptyText="i18n:govoplan-campaign.no_smtp_attempts_were_recorded.ff5b4c5d"
|
||||
/>
|
||||
<AttemptList
|
||||
title="i18n:govoplan-campaign.imap_attempts.e815f0c2"
|
||||
rows={imapAttempts}
|
||||
emptyText="i18n:govoplan-campaign.no_imap_append_attempts_were_recorded_if_status_.12279f7e"
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AttemptList({
|
||||
title,
|
||||
rows,
|
||||
emptyText = "i18n:govoplan-campaign.no_rows.fdbeab75"
|
||||
}: {
|
||||
title: string;
|
||||
rows: Record<string, unknown>[];
|
||||
emptyText?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="review-flow-data-section">
|
||||
<h3>{title}</h3>
|
||||
{rows.length === 0 ? (
|
||||
<p className="muted small-note">{emptyText}</p>
|
||||
) : (
|
||||
<dl className="detail-list">
|
||||
{rows.map((row, index) => (
|
||||
<div key={`${String(row.id ?? row.code ?? index)}:${index}`}>
|
||||
<dt>
|
||||
{String(
|
||||
row.status ??
|
||||
row.severity ??
|
||||
row.code ??
|
||||
i18nMessage("i18n:govoplan-campaign.value.44b8c76f", {
|
||||
value0: index + 1
|
||||
})
|
||||
)}
|
||||
</dt>
|
||||
<dd>
|
||||
<strong>
|
||||
{String(
|
||||
row.message ??
|
||||
row.error_message ??
|
||||
row.smtp_response ??
|
||||
row.folder ??
|
||||
row.path ??
|
||||
"-"
|
||||
)}
|
||||
</strong>
|
||||
{row.started_at || row.created_at ? (
|
||||
<span className="muted">
|
||||
{" · "}
|
||||
{formatDateTime(String(row.started_at ?? row.created_at))}
|
||||
</span>
|
||||
) : null}
|
||||
{row.finished_at || row.updated_at ? (
|
||||
<span className="muted">
|
||||
{" → "}
|
||||
{formatDateTime(String(row.finished_at ?? row.updated_at))}
|
||||
</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
229
webui/src/features/campaigns/review/WorkflowNavigation.tsx
Normal file
229
webui/src/features/campaigns/review/WorkflowNavigation.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { ChevronDown, LockKeyhole, type LucideIcon } from "lucide-react";
|
||||
import { InlineHelp, i18nMessage } from "@govoplan/core-webui";
|
||||
|
||||
import { humanize } from "../utils/campaignView";
|
||||
|
||||
export type FlowState =
|
||||
| "complete"
|
||||
| "warning"
|
||||
| "danger"
|
||||
| "active"
|
||||
| "locked"
|
||||
| "running"
|
||||
| "partial"
|
||||
| "stale"
|
||||
| "pending";
|
||||
|
||||
export type FlowStageDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
shortTitle: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
state: FlowState;
|
||||
connectorState?: FlowState;
|
||||
stateLabel: string;
|
||||
lockReason?: string;
|
||||
};
|
||||
|
||||
const stateColors: Record<FlowState, string> = {
|
||||
complete: "var(--green)",
|
||||
warning: "var(--amber)",
|
||||
danger: "var(--red)",
|
||||
active: "var(--blue)",
|
||||
locked: "var(--line-dark)",
|
||||
running: "var(--blue)",
|
||||
partial: "var(--review-flow-partial)",
|
||||
stale: "var(--review-flow-partial)",
|
||||
pending: "var(--muted)"
|
||||
};
|
||||
|
||||
export function WorkflowNavigation({
|
||||
stages,
|
||||
onSelect
|
||||
}: {
|
||||
stages: FlowStageDefinition[];
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="review-flow-navigation" aria-label="i18n:govoplan-campaign.review_and_send_workflow_steps.77fc8af2">
|
||||
<div className="review-flow-navigation-track">
|
||||
{stages.map((stage, index) => {
|
||||
const Icon = stage.icon;
|
||||
const nextStage = stages[index + 1];
|
||||
const connectorState = stageConnectorState(stage);
|
||||
const nextConnectorState = nextStage ? stageConnectorState(nextStage) : connectorState;
|
||||
const title = i18nMessage(stage.title);
|
||||
const shortTitle = i18nMessage(stage.shortTitle);
|
||||
const stateText = i18nMessage(stage.stateLabel);
|
||||
const style = {
|
||||
"--review-nav-color": stateColors[stage.state],
|
||||
"--review-nav-line-color": stateColors[connectorState],
|
||||
"--review-nav-line-next-color": stateColors[nextConnectorState]
|
||||
} as CSSProperties;
|
||||
const showSecondaryState = !["active", "locked"].includes(stage.state);
|
||||
return (
|
||||
<div className="review-flow-navigation-group" key={stage.id} style={style}>
|
||||
<button
|
||||
type="button"
|
||||
className="review-flow-navigation-item"
|
||||
data-state={stage.state}
|
||||
onClick={() => onSelect(stage.id)}
|
||||
title={`${title}: ${stateText}`}
|
||||
>
|
||||
<span className="review-flow-navigation-icon">
|
||||
<Icon size={17} strokeWidth={1.8} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="review-flow-navigation-copy">
|
||||
<strong>
|
||||
<span>{shortTitle}</span>
|
||||
{stage.state === "locked" && (
|
||||
<LockKeyhole size={12} aria-label="i18n:govoplan-campaign.locked.a798882f" />
|
||||
)}
|
||||
</strong>
|
||||
{showSecondaryState && <small>{stateText}</small>}
|
||||
</span>
|
||||
</button>
|
||||
{nextStage && <span className="review-flow-navigation-line" aria-hidden="true" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function stageConnectorState(stage: FlowStageDefinition): FlowState {
|
||||
return stage.connectorState ?? stage.state;
|
||||
}
|
||||
|
||||
export function WorkflowStage({
|
||||
stage,
|
||||
nextState,
|
||||
nextConnectorState,
|
||||
children
|
||||
}: {
|
||||
stage: FlowStageDefinition;
|
||||
nextState?: FlowState;
|
||||
nextConnectorState?: FlowState;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const Icon = stage.icon;
|
||||
const locked = stage.state === "locked";
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const title = i18nMessage(stage.title);
|
||||
const description = i18nMessage(stage.description);
|
||||
const stateText = i18nMessage(stage.stateLabel);
|
||||
const lockReason = stage.lockReason ? i18nMessage(stage.lockReason) : "";
|
||||
const connectorState = stageConnectorState(stage);
|
||||
const style = {
|
||||
"--review-stage-color": stateColors[stage.state],
|
||||
"--review-stage-line-color": stateColors[connectorState],
|
||||
"--review-next-stage-line-color": stateColors[nextConnectorState ?? connectorState]
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<section id={stage.id} className="review-flow-stage" data-state={stage.state} style={style}>
|
||||
<div className="review-flow-stage-marker" aria-hidden="true">
|
||||
<div className="review-flow-stage-node">
|
||||
<Icon size={20} strokeWidth={1.8} />
|
||||
</div>
|
||||
{nextState && <div className="review-flow-stage-line" />}
|
||||
</div>
|
||||
<article
|
||||
className={`card card-collapsible review-flow-stage-card${locked ? " is-locked" : ""}${collapsed ? " is-collapsed" : ""}`}
|
||||
aria-disabled={locked || undefined}
|
||||
>
|
||||
<header className="card-header review-flow-stage-header">
|
||||
<h2>
|
||||
<span>{title}</span>
|
||||
{locked && (
|
||||
<LockKeyhole
|
||||
className="review-flow-title-lock"
|
||||
size={15}
|
||||
aria-label="i18n:govoplan-campaign.locked.a798882f"
|
||||
/>
|
||||
)}
|
||||
{!locked && (
|
||||
<span
|
||||
className="review-flow-state-badge"
|
||||
data-state={stage.state}
|
||||
aria-label={stateText}
|
||||
title={stateText}
|
||||
>
|
||||
{stateText}
|
||||
</span>
|
||||
)}
|
||||
<InlineHelp>{description}</InlineHelp>
|
||||
</h2>
|
||||
<div className="review-flow-stage-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="card-collapse-toggle"
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={
|
||||
collapsed
|
||||
? i18nMessage("i18n:govoplan-campaign.expand_value.be085ae4", { value0: title })
|
||||
: i18nMessage("i18n:govoplan-campaign.collapse_value.29095640", { value0: title })
|
||||
}
|
||||
title={
|
||||
collapsed
|
||||
? "i18n:govoplan-campaign.show_content.0528d8d2"
|
||||
: "i18n:govoplan-campaign.show_header_only.24afefca"
|
||||
}
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
>
|
||||
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="card-body review-flow-stage-content">{children}</div>
|
||||
{locked && (
|
||||
<div className="review-flow-lock-message">
|
||||
<span className="review-flow-lock-icon">
|
||||
<LockKeyhole size={20} aria-hidden="true" />
|
||||
</span>
|
||||
<span>{lockReason}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowFact({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="review-flow-fact">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function stateLabel(state: FlowState): string {
|
||||
switch (state) {
|
||||
case "complete":
|
||||
return "i18n:govoplan-campaign.passed.271d60f4";
|
||||
case "warning":
|
||||
return "i18n:govoplan-campaign.warnings.1430f976";
|
||||
case "danger":
|
||||
return "i18n:govoplan-campaign.blocked.99613c74";
|
||||
case "active":
|
||||
return "i18n:govoplan-campaign.available.7c62a142";
|
||||
case "locked":
|
||||
return "i18n:govoplan-campaign.locked.a798882f";
|
||||
case "running":
|
||||
return "i18n:govoplan-campaign.running.73989d9c";
|
||||
case "partial":
|
||||
return "i18n:govoplan-campaign.partial.65de2e2a";
|
||||
case "stale":
|
||||
return "i18n:govoplan-campaign.stale.189cc40c";
|
||||
default:
|
||||
return humanize(state);
|
||||
}
|
||||
}
|
||||
258
webui/src/features/campaigns/review/builtMessageQuery.ts
Normal file
258
webui/src/features/campaigns/review/builtMessageQuery.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { DataGridQueryState } from "@govoplan/core-webui";
|
||||
|
||||
import type { CampaignVersionDetail } from "../../../api/campaigns";
|
||||
import { asArray, asRecord } from "../utils/campaignView";
|
||||
import { countResolvedAttachments, formatAddressList } from "./reviewFormatters";
|
||||
|
||||
type ReviewFilterType = "text" | "integer" | "list";
|
||||
type ReviewFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte";
|
||||
|
||||
export function filterAndSortBuiltMessageRows(
|
||||
rows: Record<string, unknown>[],
|
||||
query: DataGridQueryState,
|
||||
reviewedKeys: Set<string>
|
||||
): Record<string, unknown>[] {
|
||||
const filters = query.filters ?? {};
|
||||
const filtered = rows.filter((row, rowIndex) =>
|
||||
Object.entries(filters).every(([columnId, filterValue]) => {
|
||||
if (!isBuiltMessageQueryColumn(columnId)) return true;
|
||||
if (!filterValue.trim()) return true;
|
||||
return matchesReviewFilter(
|
||||
builtMessageColumnValue(columnId, row, rowIndex, reviewedKeys),
|
||||
filterValue,
|
||||
builtMessageFilterType(columnId)
|
||||
);
|
||||
})
|
||||
);
|
||||
if (!query.sort) return filtered;
|
||||
const { columnId, direction } = query.sort;
|
||||
if (!isBuiltMessageQueryColumn(columnId)) return filtered;
|
||||
return [...filtered].sort((left, right) => {
|
||||
const leftIndex = rows.indexOf(left);
|
||||
const rightIndex = rows.indexOf(right);
|
||||
const result = compareReviewValues(
|
||||
builtMessageColumnValue(columnId, left, leftIndex, reviewedKeys),
|
||||
builtMessageColumnValue(columnId, right, rightIndex, reviewedKeys)
|
||||
);
|
||||
return direction === "desc" ? -result : result;
|
||||
});
|
||||
}
|
||||
|
||||
export function reviewQueryEquals(
|
||||
left: DataGridQueryState,
|
||||
right: DataGridQueryState
|
||||
): boolean {
|
||||
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
|
||||
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
|
||||
const leftFilters = left.filters ?? {};
|
||||
const rightFilters = right.filters ?? {};
|
||||
const keys = new Set([...Object.keys(leftFilters), ...Object.keys(rightFilters)]);
|
||||
for (const key of keys) {
|
||||
if ((leftFilters[key] ?? "") !== (rightFilters[key] ?? "")) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function messageNeedsExplicitReview(row: Record<string, unknown>): boolean {
|
||||
return String(row.validation_status ?? "").toLowerCase() === "needs_review";
|
||||
}
|
||||
|
||||
export function storedMessageReviewState(version: CampaignVersionDetail | null): {
|
||||
buildToken: string;
|
||||
inspectionComplete: boolean;
|
||||
reviewedMessageKeys: string[];
|
||||
} {
|
||||
const build = asRecord(version?.build_summary);
|
||||
const buildToken = String(build.build_token ?? build.built_at ?? "");
|
||||
const review = asRecord(asRecord(version?.editor_state).review_send);
|
||||
if (!buildToken || String(review.build_token ?? "") !== buildToken) {
|
||||
return { buildToken, inspectionComplete: false, reviewedMessageKeys: [] };
|
||||
}
|
||||
return {
|
||||
buildToken,
|
||||
inspectionComplete: review.inspection_complete === true,
|
||||
reviewedMessageKeys: asArray(review.reviewed_message_keys).filter(
|
||||
(value): value is string => typeof value === "string"
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function builtMessageKey(row: Record<string, unknown>, index: number): string {
|
||||
return String(row.entry_id ?? row.entry_index ?? index);
|
||||
}
|
||||
|
||||
export function findBuiltMessageIndex(
|
||||
rows: Record<string, unknown>[],
|
||||
row: Record<string, unknown>
|
||||
): number {
|
||||
const id = String(row.id ?? "").trim();
|
||||
if (id) {
|
||||
const index = rows.findIndex((candidate) => String(candidate.id ?? "").trim() === id);
|
||||
if (index >= 0) return index;
|
||||
}
|
||||
const reviewKey = String(row.review_key ?? "").trim();
|
||||
if (reviewKey) {
|
||||
const index = rows.findIndex(
|
||||
(candidate) => String(candidate.review_key ?? "").trim() === reviewKey
|
||||
);
|
||||
if (index >= 0) return index;
|
||||
}
|
||||
const entryKey = String(row.entry_id ?? row.entry_index ?? "").trim();
|
||||
if (entryKey) {
|
||||
const index = rows.findIndex(
|
||||
(candidate) =>
|
||||
String(candidate.entry_id ?? candidate.entry_index ?? "").trim() === entryKey
|
||||
);
|
||||
if (index >= 0) return index;
|
||||
}
|
||||
return rows.indexOf(row);
|
||||
}
|
||||
|
||||
export function sameBuiltMessage(
|
||||
left: Record<string, unknown>,
|
||||
leftIndex: number,
|
||||
right: Record<string, unknown>,
|
||||
rightIndex: number
|
||||
): boolean {
|
||||
const leftId = String(left.id ?? "").trim();
|
||||
const rightId = String(right.id ?? "").trim();
|
||||
if (leftId && rightId) return leftId === rightId;
|
||||
const leftReviewKey = String(left.review_key ?? "").trim();
|
||||
const rightReviewKey = String(right.review_key ?? "").trim();
|
||||
if (leftReviewKey && rightReviewKey) return leftReviewKey === rightReviewKey;
|
||||
const leftEntryKey = String(left.entry_id ?? left.entry_index ?? "").trim();
|
||||
const rightEntryKey = String(right.entry_id ?? right.entry_index ?? "").trim();
|
||||
if (leftEntryKey && rightEntryKey) return leftEntryKey === rightEntryKey;
|
||||
return left === right || leftIndex === rightIndex;
|
||||
}
|
||||
|
||||
function isBuiltMessageQueryColumn(columnId: string): boolean {
|
||||
return ["number", "recipient", "subject", "validation", "attachments", "reviewed"].includes(columnId);
|
||||
}
|
||||
|
||||
function builtMessageColumnValue(
|
||||
columnId: string,
|
||||
row: Record<string, unknown>,
|
||||
index: number,
|
||||
reviewedKeys: Set<string>
|
||||
): unknown {
|
||||
switch (columnId) {
|
||||
case "number":
|
||||
return Number(row.entry_index ?? index + 1);
|
||||
case "recipient":
|
||||
return (
|
||||
formatAddressList(asRecord(row.resolved_recipients).to) ||
|
||||
String(row.recipient_email ?? "—")
|
||||
);
|
||||
case "subject":
|
||||
return String(row.subject ?? "—");
|
||||
case "validation":
|
||||
return String(row.validation_status ?? "unknown");
|
||||
case "attachments":
|
||||
return Number(row.attachment_count ?? countResolvedAttachments(row.attachments));
|
||||
case "reviewed":
|
||||
return row.reviewed === true ||
|
||||
reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index)))
|
||||
? "yes"
|
||||
: "no";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function builtMessageFilterType(columnId: string): ReviewFilterType {
|
||||
if (["number", "attachments"].includes(columnId)) return "integer";
|
||||
if (["validation", "reviewed"].includes(columnId)) return "list";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function matchesReviewFilter(
|
||||
value: unknown,
|
||||
filterValue: string,
|
||||
filterType: ReviewFilterType
|
||||
): boolean {
|
||||
if (!filterValue.trim()) return true;
|
||||
if (filterType === "list") {
|
||||
const selected = parseReviewListFilter(filterValue);
|
||||
return selected.includes(stringifyReviewCell(value));
|
||||
}
|
||||
if (filterType === "integer") {
|
||||
const parsed = parseReviewTypedFilter(filterValue);
|
||||
if (!parsed.value.trim()) return true;
|
||||
const actual = parseReviewNumber(value);
|
||||
const expected = Number(parsed.value);
|
||||
if (!Number.isFinite(expected) || !Number.isFinite(actual)) return false;
|
||||
return compareReviewByOperator(actual, expected, parsed.operator);
|
||||
}
|
||||
return stringifyReviewCell(value)
|
||||
.toLowerCase()
|
||||
.includes(filterValue.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function parseReviewListFilter(value: string): string[] {
|
||||
if (value.startsWith("list:")) {
|
||||
try {
|
||||
const parsed = JSON.parse(value.slice(5));
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function parseReviewTypedFilter(
|
||||
value: string
|
||||
): { operator: ReviewFilterOperator; value: string } {
|
||||
if (!value.includes(":")) return { operator: "eq", value };
|
||||
const [operator, ...parts] = value.split(":");
|
||||
if (
|
||||
operator === "contains" ||
|
||||
operator === "eq" ||
|
||||
operator === "gt" ||
|
||||
operator === "gte" ||
|
||||
operator === "lt" ||
|
||||
operator === "lte"
|
||||
) {
|
||||
return { operator, value: parts.join(":") };
|
||||
}
|
||||
return { operator: "eq", value };
|
||||
}
|
||||
|
||||
function parseReviewNumber(value: unknown): number {
|
||||
if (typeof value === "number") return value;
|
||||
const text = stringifyReviewCell(value).replace(/[^0-9.,+-]/g, "").replace(",", ".");
|
||||
if (!text.trim()) return Number.NaN;
|
||||
return Number(text);
|
||||
}
|
||||
|
||||
function compareReviewByOperator(
|
||||
actual: number,
|
||||
expected: number,
|
||||
operator: ReviewFilterOperator
|
||||
): boolean {
|
||||
if (operator === "gt") return actual > expected;
|
||||
if (operator === "gte") return actual >= expected;
|
||||
if (operator === "lt") return actual < expected;
|
||||
if (operator === "lte") return actual <= expected;
|
||||
return actual === expected;
|
||||
}
|
||||
|
||||
function compareReviewValues(left: unknown, right: unknown): number {
|
||||
if (typeof left === "number" && typeof right === "number") return left - right;
|
||||
return stringifyReviewCell(left).localeCompare(stringifyReviewCell(right), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base"
|
||||
});
|
||||
}
|
||||
|
||||
function stringifyReviewCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(stringifyReviewCell).join(", ");
|
||||
return "";
|
||||
}
|
||||
66
webui/src/features/campaigns/review/reviewFormatters.ts
Normal file
66
webui/src/features/campaigns/review/reviewFormatters.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { asArray, asRecord } from "../utils/campaignView";
|
||||
|
||||
export function formatAddressList(value: unknown): string {
|
||||
return asArray(value).map(asRecord).map(formatSingleAddress).filter(Boolean).join(", ");
|
||||
}
|
||||
|
||||
export function formatSingleAddress(value: unknown): string {
|
||||
const address = asRecord(value);
|
||||
const email = String(address.email ?? "").trim();
|
||||
const name = String(address.name ?? "").trim();
|
||||
if (name && email) return `${name} <${email}>`;
|
||||
return email || name;
|
||||
}
|
||||
|
||||
export function countResolvedAttachments(value: unknown): number {
|
||||
const archives = new Set<string>();
|
||||
let directCount = 0;
|
||||
for (const item of asArray(value)) {
|
||||
const attachment = asRecord(item);
|
||||
const zipFilename = String(attachment.zip_filename ?? "").trim();
|
||||
const managedCount = asArray(attachment.managed_matches).length;
|
||||
const matchCount = asArray(attachment.matches).length;
|
||||
if (zipFilename && (managedCount > 0 || matchCount > 0)) {
|
||||
archives.add(zipFilename);
|
||||
continue;
|
||||
}
|
||||
if (managedCount > 0) {
|
||||
directCount += managedCount;
|
||||
continue;
|
||||
}
|
||||
directCount += matchCount;
|
||||
}
|
||||
return directCount + archives.size;
|
||||
}
|
||||
|
||||
export function numberFrom(record: Record<string, unknown>, keys: string[]): number {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
value.trim() &&
|
||||
Number.isFinite(Number(value))
|
||||
) {
|
||||
return Number(value);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function numberOrUndefined(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
value.trim() &&
|
||||
Number.isFinite(Number(value))
|
||||
) {
|
||||
return Number(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function stringOrUndefined(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
return value.trim() || undefined;
|
||||
}
|
||||
168
webui/src/features/campaigns/utils/addressSourceImport.ts
Normal file
168
webui/src/features/campaigns/utils/addressSourceImport.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { CampaignRecipientAddressSourceSnapshot } from "../../../api/campaigns";
|
||||
import type {
|
||||
ImportedRecipientRow,
|
||||
RecipientImportMode,
|
||||
RecipientImportPreview,
|
||||
RecipientImportProvenance,
|
||||
RecipientImportTable
|
||||
} from "./bulkImport";
|
||||
|
||||
export 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
|
||||
};
|
||||
}
|
||||
|
||||
export 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: []
|
||||
};
|
||||
}
|
||||
|
||||
export 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 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 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;
|
||||
}
|
||||
@@ -57,13 +57,16 @@ const explicitLinkablePreview = {
|
||||
};
|
||||
assert(attachmentPreviewLinkableFiles(explicitLinkablePreview).length === 2, "explicit linkable candidates are deduplicated without a display cap");
|
||||
|
||||
const reviewSource = readFileSync("src/features/campaigns/ReviewSendPage.tsx", "utf8");
|
||||
const reviewSource = [
|
||||
readFileSync("src/features/campaigns/ReviewSendPage.tsx", "utf8"),
|
||||
readFileSync("src/features/campaigns/review/AttachmentLinkingPreview.tsx", "utf8")
|
||||
].join("\n");
|
||||
assert(!reviewSource.includes("attachmentPreviewMatchedFiles(preview).slice("), "review attachment links must not use an arbitrary display slice");
|
||||
assert(reviewSource.includes("<AttachmentLinkingFileList files={matchedFiles} compact />"), "compact review exposes the complete bounded attachment list");
|
||||
assert(reviewSource.includes('aria-haspopup="dialog"'), "the matched count advertises its attachment detail dialog");
|
||||
assert(reviewSource.includes("tabIndex={0}"), "bounded attachment lists are keyboard-focusable scroll regions");
|
||||
assert(reviewSource.includes('i18nMessage("i18n:govoplan-campaign.attachment_file_links_value.ce230e30"'), "dynamic attachment dialog title uses the i18n message contract");
|
||||
assert(reviewSource.includes('i18nMessage("i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951"'), "dynamic attachment count aria label uses the i18n message contract");
|
||||
assert(/i18nMessage\(\s*"i18n:govoplan-campaign\.attachment_file_links_value\.ce230e30"/.test(reviewSource), "dynamic attachment dialog title uses the i18n message contract");
|
||||
assert(/i18nMessage\(\s*"i18n:govoplan-campaign\.view_all_value_matched_attachment_file_links\.81e53951"/.test(reviewSource), "dynamic attachment count aria label uses the i18n message contract");
|
||||
assert(!reviewSource.includes(">Attachment file links<"), "attachment review heading is not raw English");
|
||||
assert(!reviewSource.includes('label="Matched"'), "attachment review facts are not raw English");
|
||||
assert(!reviewSource.includes("`Link ${unlinkedCount}"), "attachment link actions are not assembled from raw English");
|
||||
|
||||
Reference in New Issue
Block a user