refactor(webui): use central campaign components

This commit is contained in:
2026-07-21 12:04:30 +02:00
parent ce92499333
commit b7653b58f4
9 changed files with 192 additions and 432 deletions

View File

@@ -8,7 +8,7 @@ import { Button } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { DismissibleAlert, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import { DismissibleAlert, TableActionGroup, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
import type { CampaignListItem } from "../../types";
@@ -126,15 +126,13 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
width: 70,
sticky: "end",
align: "right",
render: (campaign) =>
<Link
to={`/campaigns/${campaign.id}`}
className="btn btn-primary admin-icon-button"
aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}
title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id })}>
<ExternalLink aria-hidden="true" />
</Link>
render: (campaign) => <TableActionGroup actions={[{
id: "open",
label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: campaign.name || campaign.external_id || campaign.id }),
icon: <ExternalLink aria-hidden="true" />,
variant: "primary",
onClick: () => navigate(`/campaigns/${campaign.id}`)
}]} />
}];

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { ExternalLink, LockKeyhole } from "lucide-react";
import { ExternalLink, LockKeyhole, LockOpen } from "lucide-react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import { Button } from "@govoplan/core-webui";
@@ -10,7 +10,7 @@ import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { DismissibleAlert, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { DismissibleAlert, TableActionGroup, i18nMessage, useGuardedNavigate, useUnsavedDraftGuard } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import {
lockCampaignVersionPermanently,
@@ -40,6 +40,7 @@ type LockAction = "temporary" | "unlock" | "permanent";
type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null;
export default function CampaignOverviewPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const navigate = useGuardedNavigate();
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const campaign = data.campaign;
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
@@ -214,7 +215,7 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
<DataGrid
id={`campaign-${campaignId}-versions`}
rows={versions}
columns={versionColumns(setPendingLockAction, campaign?.current_version_id)}
columns={versionColumns(setPendingLockAction, navigate, campaign?.current_version_id)}
getRowKey={(version) => version.id}
initialSort={{ columnId: "version", direction: "desc" }}
emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e"
@@ -296,7 +297,7 @@ function textValue(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, navigate: (to: string) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
return [
{ id: "version", header: "i18n:govoplan-campaign.version.2da600bf", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
{ id: "state", header: "i18n:govoplan-campaign.state.a7250206", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
@@ -307,36 +308,18 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 260,
width: 150,
sticky: "end",
render: (version) => {
const isCurrent = version.id === currentVersionId;
return (
<div className="button-row compact-actions">
<Link
to={`send?version=${version.id}`}
className={`btn ${isCurrent ? "btn-primary" : "btn-secondary"} admin-icon-button`}
aria-label={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}
title={i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number })}>
<ExternalLink aria-hidden="true" />
</Link>
{isCurrent && (isTemporaryUserLockedVersion(version) ?
<>
<Button onClick={() => setPendingLockAction({ version, action: "unlock" })}>i18n:govoplan-campaign.unlock.1526a17e</Button>
<Button variant="danger" onClick={() => setPendingLockAction({ version, action: "permanent" })}>i18n:govoplan-campaign.lock_permanently.cc0ce9e7</Button>
</> :
!isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at ?
<Button
className="admin-icon-button"
onClick={() => setPendingLockAction({ version, action: "temporary" })}
aria-label={i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number })}
title="i18n:govoplan-campaign.temporarily_lock_version.82b31149">
<LockKeyhole aria-hidden="true" />
</Button> :
null)}
</div>);
const temporarilyLocked = isCurrent && isTemporaryUserLockedVersion(version);
const canTemporarilyLock = isCurrent && !temporarilyLocked && !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at;
return <TableActionGroup actions={[
{ id: "open", label: i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number }), icon: <ExternalLink aria-hidden="true" />, variant: isCurrent ? "primary" : "secondary", onClick: () => navigate(`send?version=${version.id}`) },
{ id: "unlock", label: "i18n:govoplan-campaign.unlock.1526a17e", icon: <LockOpen aria-hidden="true" />, applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "unlock" }) },
{ id: "permanent-lock", label: "i18n:govoplan-campaign.lock_permanently.cc0ce9e7", icon: <LockKeyhole aria-hidden="true" />, variant: "danger", applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "permanent" }) },
{ id: "temporary-lock", label: i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number }), icon: <LockKeyhole aria-hidden="true" />, applicable: canTemporarilyLock, onClick: () => setPendingLockAction({ version, action: "temporary" }) }
]} />;
}
}];

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, RotateCcw, Search, X } from "lucide-react";
import type { ApiSettings } from "../../types";
import {
downloadCampaignJobsCsv,
@@ -21,7 +22,7 @@ import { DismissibleAlert } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import VersionLine from "./components/VersionLine";
import { LoadingFrame, i18nMessage, useDeltaWatermarks } from "@govoplan/core-webui";
import { LoadingFrame, TableActionGroup, ToggleSwitch, i18nMessage, useDeltaWatermarks } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, humanize } from "./utils/campaignView";
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
@@ -358,18 +359,17 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 250,
width: 190,
sticky: "end",
render: (row) => {
const id = String(row.id ?? "");
const status = String(row.send_status ?? "");
return (
<div className="button-row compact-actions">
<Button onClick={() => void openJob(id)} disabled={!id || busyAction === "detail"}>i18n:govoplan-campaign.details.dc3decbb</Button>
{retryableFailedStatus(status) && <Button onClick={() => void retryFailedSynchronously([row])} disabled={!id || Boolean(busyAction)}>{busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now"}</Button>}
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "smtp_accepted" })}>i18n:govoplan-campaign.accepted.61a0572c</Button>}
{status === "outcome_unknown" && <Button onClick={() => setReconcile({ jobId: id, decision: "not_sent" })}>i18n:govoplan-campaign.not_sent.587c501e</Button>}
</div>);
return <TableActionGroup actions={[
{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, disabled: !id || busyAction === "detail", onClick: () => void openJob(id) },
{ id: "retry", label: busyAction === `retry-sync:${id}` ? "Sending..." : "Retry now", icon: <RotateCcw aria-hidden="true" />, applicable: retryableFailedStatus(status), disabled: !id || Boolean(busyAction), onClick: () => void retryFailedSynchronously([row]) },
{ id: "accepted", label: "i18n:govoplan-campaign.accepted.61a0572c", icon: <Check aria-hidden="true" />, applicable: status === "outcome_unknown", onClick: () => setReconcile({ jobId: id, decision: "smtp_accepted" }) },
{ id: "not-sent", label: "i18n:govoplan-campaign.not_sent.587c501e", icon: <X aria-hidden="true" />, variant: "danger", applicable: status === "outcome_unknown", onClick: () => setReconcile({ jobId: id, decision: "not_sent" }) }
]} />;
}
}],
@@ -473,8 +473,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<span>i18n:govoplan-campaign.recipients.78cbf8eb</span>
<textarea value={emailRecipients} onChange={(event) => setEmailRecipients(event.target.value)} placeholder="audit@example.org; owner@example.org" rows={3} />
</label>
<label><input type="checkbox" checked={attachCsv} onChange={(event) => setAttachCsv(event.target.checked)} /> i18n:govoplan-campaign.attach_job_csv.adb76197</label>
<label><input type="checkbox" checked={attachJson} onChange={(event) => setAttachJson(event.target.checked)} /> i18n:govoplan-campaign.attach_json_report.d70883b5</label>
<ToggleSwitch label="i18n:govoplan-campaign.attach_job_csv.adb76197" checked={attachCsv} onChange={setAttachCsv} />
<ToggleSwitch label="i18n:govoplan-campaign.attach_json_report.d70883b5" checked={attachJson} onChange={setAttachJson} />
</Dialog>
<Dialog
@@ -526,39 +526,21 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record
}
const columns: DataGridColumn<Record<string, unknown>>[] = [
{ id: "attempt", header: "#", width: 72, sortable: true, value: (row, index) => Number(row.attempt_number ?? index + 1), render: (row, index) => String(row.attempt_number ?? index + 1) },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 150, sortable: true, filterable: true, value: (row) => String(row.status ?? "unknown"), render: (row) => <StatusBadge status={String(row.status ?? "unknown")} /> },
kind === "imap" ?
{ id: "folder", header: "i18n:govoplan-campaign.folder.30baa249", width: 180, sortable: true, filterable: true, value: (row) => String(row.folder ?? "—"), render: (row) => String(row.folder ?? "—") } :
{ id: "code", header: "i18n:govoplan-campaign.code.adac6937", width: 110, sortable: true, value: (row) => String(row.smtp_status_code ?? "—"), render: (row) => String(row.smtp_status_code ?? "—") },
{ id: "started", header: "i18n:govoplan-campaign.started.faa9e7e7", width: 180, sortable: true, value: (row) => String(row.started_at ?? row.created_at ?? ""), render: (row) => formatDateTime(String(row.started_at ?? row.created_at ?? "")) },
{ id: "finished", header: "i18n:govoplan-campaign.finished.355bcc57", width: 180, sortable: true, value: (row) => String(row.finished_at ?? row.updated_at ?? ""), render: (row) => formatDateTime(String(row.finished_at ?? row.updated_at ?? "")) },
{ id: "result", header: "i18n:govoplan-campaign.result.5faa59d4", width: "minmax(240px, 1fr)", minWidth: 200, resizable: true, filterable: true, value: (row) => String(row.smtp_response ?? row.error_message ?? "—"), render: (row) => <span title={String(row.smtp_response ?? row.error_message ?? "")}>{String(row.smtp_response ?? row.error_message ?? "—")}</span> }
];
return (
<section className="attempt-history-section">
<h3>{title}</h3>
<div className="attempt-history-wrap">
<table className="attempt-history-table">
<thead>
<tr>
<th>#</th>
<th>i18n:govoplan-campaign.status.bae7d5be</th>
{kind === "imap" && <th>i18n:govoplan-campaign.folder.30baa249</th>}
{kind === "smtp" && <th>i18n:govoplan-campaign.code.adac6937</th>}
<th>i18n:govoplan-campaign.started.faa9e7e7</th>
<th>i18n:govoplan-campaign.finished.355bcc57</th>
<th>i18n:govoplan-campaign.result.5faa59d4</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) =>
<tr key={String(row.id ?? `${kind}-${index}`)}>
<td>{String(row.attempt_number ?? index + 1)}</td>
<td><StatusBadge status={String(row.status ?? "unknown")} /></td>
{kind === "imap" && <td>{String(row.folder ?? "—")}</td>}
{kind === "smtp" && <td>{String(row.smtp_status_code ?? "—")}</td>}
<td>{formatDateTime(String(row.started_at ?? row.created_at ?? ""))}</td>
<td>{formatDateTime(String(row.finished_at ?? row.updated_at ?? ""))}</td>
<td title={String(row.smtp_response ?? row.error_message ?? "")}>
{String(row.smtp_response ?? row.error_message ?? "—")}
</td>
</tr>
)}
</tbody>
</table>
</div>
<DataGrid id={`campaign-${kind}-attempt-history`} rows={rows} columns={columns} getRowKey={(row, index) => String(row.id ?? `${kind}-${index}`)} />
</section>);
}

View File

@@ -21,6 +21,7 @@ import { ToggleSwitch } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { SegmentedControl } from "@govoplan/core-webui";
import { TableActionGroup } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
@@ -582,24 +583,10 @@ function AddressHeaderControl({ columns, values, emptyText, disabled, onEdit, on
<span>{translateText(emptyText)}</span>
}
</div>
<Button
type="button"
className="admin-icon-button recipient-address-inline-button"
disabled={disabled}
aria-label="Edit addresses"
title="Edit addresses"
onClick={onEdit}>
<Pencil aria-hidden="true" />
</Button>
<Button
type="button"
className="admin-icon-button recipient-address-inline-button"
disabled={!copiedText}
aria-label="Copy addresses"
title="Copy addresses"
onClick={onCopy}>
<Copy aria-hidden="true" />
</Button>
<TableActionGroup actions={[
{ id: "edit", label: "Edit addresses", icon: <Pencil aria-hidden="true" />, disabled, onClick: onEdit },
{ id: "copy", label: "Copy addresses", icon: <Copy aria-hidden="true" />, applicable: Boolean(copiedText), onClick: onCopy }
]} />
</div>
</div>);
}
@@ -783,18 +770,17 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
{column.allowMultiple && draftAddresses.length === 0 &&
<div className="recipient-address-empty-row">
<p className="muted recipient-address-empty">{column.emptyText}</p>
<div className="data-grid-row-actions data-grid-empty-row-actions">
<Button
type="button"
variant="primary"
className="data-grid-row-action is-add"
aria-label={translatedAddLabel}
title={translatedAddLabel}
disabled={!canAdd}
onClick={() => addAddress()}>
<Plus size={16} aria-hidden="true" />
</Button>
</div>
<TableActionGroup
className="data-grid-empty-row-actions"
actions={[{
id: "add",
label: translatedAddLabel,
icon: <Plus size={16} aria-hidden="true" />,
variant: "primary",
disabled: !canAdd,
onClick: () => addAddress()
}]}
/>
</div>
}
{draftAddresses.map((address, addressIndex) =>
@@ -813,48 +799,15 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
aria-label={`${column.label} email ${addressIndex + 1}`}
onChange={(event) => patchAddress(addressIndex, { email: event.target.value })} />
{column.allowMultiple &&
<div className="data-grid-row-actions recipient-address-line-actions">
<Button
type="button"
variant="primary"
className="data-grid-row-action is-add"
aria-label={translatedAddLabel}
title={translatedAddLabel}
disabled={!canAdd}
onClick={() => addAddress(addressIndex)}>
<Plus size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="secondary"
className="data-grid-row-action is-reorder"
aria-label={translatedMoveUpLabel}
title={translatedMoveUpLabel}
disabled={locked || addressIndex === 0}
onClick={() => moveAddress(addressIndex, addressIndex - 1)}>
<ArrowUp size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="secondary"
className="data-grid-row-action is-reorder"
aria-label={translatedMoveDownLabel}
title={translatedMoveDownLabel}
disabled={locked || addressIndex >= draftAddresses.length - 1}
onClick={() => moveAddress(addressIndex, addressIndex + 1)}>
<ArrowDown size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="danger"
className="data-grid-row-action is-remove"
aria-label={translatedRemoveLabel}
title={translatedRemoveLabel}
disabled={locked}
onClick={() => removeAddress(addressIndex)}>
<Trash2 size={16} aria-hidden="true" />
</Button>
</div>
<TableActionGroup
className="recipient-address-line-actions"
actions={[
{ id: "add", label: translatedAddLabel, icon: <Plus size={16} aria-hidden="true" />, variant: "primary", disabled: !canAdd, onClick: () => addAddress(addressIndex) },
{ id: "move-up", label: translatedMoveUpLabel, icon: <ArrowUp size={16} aria-hidden="true" />, disabled: locked || addressIndex === 0, onClick: () => moveAddress(addressIndex, addressIndex - 1) },
{ id: "move-down", label: translatedMoveDownLabel, icon: <ArrowDown size={16} aria-hidden="true" />, disabled: locked || addressIndex >= draftAddresses.length - 1, onClick: () => moveAddress(addressIndex, addressIndex + 1) },
{ id: "remove", label: translatedRemoveLabel, icon: <Trash2 size={16} aria-hidden="true" />, variant: "danger", disabled: locked, onClick: () => removeAddress(addressIndex) }
]}
/>
}
</div>
)}
@@ -1080,34 +1033,25 @@ function AddressSourceImportDialog({ settings, campaignId, sources, initialSourc
<div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div>
<div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div>
</dl>
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Email</th>
<th>Fields</th>
</tr>
</thead>
<tbody>
{snapshot.recipients.slice(0, 20).map((recipient, index) =>
<tr key={`${recipient.contact_id}-${recipient.email}`}>
<td>{index + 1}</td>
<td>{recipient.display_name}</td>
<td>{recipient.email}</td>
<td>{Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length}</td>
</tr>
)}
</tbody>
</table>
</div>
<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;
@@ -1451,6 +1395,30 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
replaceColumnMapping(nextMapping);
}
const mappingRows = (table?.headers ?? []).map((header, columnIndex) => ({
id: `${columnIndex}-${header}`,
header,
columnIndex,
mapping: mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const }
}));
type MappingRow = (typeof mappingRows)[number];
const mappingColumns: DataGridColumn<MappingRow>[] = [
{ id: "column", header: "i18n:govoplan-campaign.column.65ba00e9", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (row) => row.header, render: (row) => <strong>{row.header}</strong> },
{ id: "sample", header: "i18n:govoplan-campaign.sample.58fabfa7", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (row) => table ? sampleColumnValue(table.dataRows, row.columnIndex) : "", render: (row) => <span className="mono-small">{table ? sampleColumnValue(table.dataRows, row.columnIndex) : ""}</span> },
{ id: "content", header: "i18n:govoplan-campaign.content.4f9be057", width: 190, value: (row) => row.mapping.kind, render: (row) => <select value={row.mapping.kind} onChange={(event) => changeColumnKind(row.columnIndex, event.target.value as RecipientColumnKind)}>{recipientColumnKindOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select> },
{ id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(200px, .9fr)", minWidth: 180, resizable: true, value: (row) => row.mapping.fieldName ?? row.mapping.newFieldName ?? "", render: (row) => <>{row.mapping.kind === "field" && <select value={row.mapping.fieldName ?? ""} onChange={(event) => replaceColumnMapping({ ...row.mapping, fieldName: event.target.value, newFieldName: undefined })}><option value="">i18n:govoplan-campaign.select_field.bb7e63d5</option>{existingFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}</select>}{row.mapping.kind === "new_field" && <input value={row.mapping.newFieldName ?? ""} onChange={(event) => replaceColumnMapping({ ...row.mapping, newFieldName: event.target.value, fieldName: undefined })} />}</> }
];
type PreviewRow = RecipientImportPreview["rows"][number];
const previewColumns: DataGridColumn<PreviewRow>[] = [
{ id: "row", header: "#", width: 68, sortable: true, value: (row) => row.rowNumber },
{ id: "to", header: "i18n:govoplan-campaign.to.ae79ea1e", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, filterable: true, value: (row) => formatAddressList(row.addresses.to) },
{ id: "name", header: "i18n:govoplan-campaign.name.709a2322", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (row) => row.name },
{ id: "fields", header: "i18n:govoplan-campaign.fields.e8b68527", width: 100, sortable: true, value: (row) => Object.keys(row.fields).length },
{ id: "patterns", header: "i18n:govoplan-campaign.patterns.4d34f7a2", width: 110, sortable: true, value: (row) => row.patterns.length },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, filterable: true, value: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552", render: (row) => row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552" }
];
const stepContent = activeStep === "upload" ?
<>
<div className="campaign-header-grid recipient-import-upload-grid">
@@ -1569,51 +1537,7 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
</select>
</FormField>
</div>
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table recipient-import-mapping-table">
<thead>
<tr>
<th>i18n:govoplan-campaign.column.65ba00e9</th>
<th>i18n:govoplan-campaign.sample.58fabfa7</th>
<th>i18n:govoplan-campaign.content.4f9be057</th>
<th>i18n:govoplan-campaign.field.c326a466</th>
</tr>
</thead>
<tbody>
{table?.headers.map((header, columnIndex) => {
const mapping = mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const };
return (
<tr key={`${columnIndex}-${header}`}>
<td><strong>{header}</strong></td>
<td className="mono-small">{sampleColumnValue(table.dataRows, columnIndex)}</td>
<td>
<select value={mapping.kind} onChange={(event) => changeColumnKind(columnIndex, event.target.value as RecipientColumnKind)}>
{recipientColumnKindOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</td>
<td>
{mapping.kind === "field" &&
<select
value={mapping.fieldName ?? ""}
onChange={(event) => replaceColumnMapping({ ...mapping, fieldName: event.target.value, newFieldName: undefined })}>
<option value="">i18n:govoplan-campaign.select_field.bb7e63d5</option>
{existingFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
</select>
}
{mapping.kind === "new_field" &&
<input
value={mapping.newFieldName ?? ""}
onChange={(event) => replaceColumnMapping({ ...mapping, newFieldName: event.target.value, fieldName: undefined })} />
}
</td>
</tr>);
})}
</tbody>
</table>
</div>
<DataGrid id="campaign-recipient-import-mapping" rows={mappingRows} columns={mappingColumns} getRowKey={(row) => row.id} />
</> :
activeStep === "preview" ?
<>
@@ -1628,32 +1552,14 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
{preview.fieldNamesToCreate.length > 0 &&
<p className="muted small-note">i18n:govoplan-campaign.new_fields.c61e20c0 {preview.fieldNamesToCreate.join(", ")}</p>
}
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table">
<thead>
<tr>
<th>#</th>
<th>i18n:govoplan-campaign.to.ae79ea1e</th>
<th>i18n:govoplan-campaign.name.709a2322</th>
<th>i18n:govoplan-campaign.fields.e8b68527</th>
<th>i18n:govoplan-campaign.patterns.4d34f7a2</th>
<th>i18n:govoplan-campaign.status.bae7d5be</th>
</tr>
</thead>
<tbody>
{preview.rows.slice(0, 20).map((row) =>
<tr key={row.rowNumber} className={row.issues.length ? "is-invalid" : undefined}>
<td>{row.rowNumber}</td>
<td>{formatAddressList(row.addresses.to)}</td>
<td>{row.name}</td>
<td>{Object.keys(row.fields).length}</td>
<td>{row.patterns.length}</td>
<td>{row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552"}</td>
</tr>
)}
</tbody>
</table>
</div>
<DataGrid
id="campaign-recipient-import-preview"
className="recipient-import-preview-grid"
rows={preview.rows.slice(0, 20)}
columns={previewColumns}
getRowKey={(row) => String(row.rowNumber)}
rowClassName={(row) => row.issues.length ? "is-invalid" : undefined}
/>
</>
}
</> :
@@ -1743,6 +1649,14 @@ function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving,
return <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-campaign.no_attachment_patterns_were_imported_there_are_n.c01c8266</DismissibleAlert>;
}
type ResolvedFile = ImportFileLinkResolution["files"][number];
const fileColumns: DataGridColumn<ResolvedFile>[] = [
{ id: "file", header: "i18n:govoplan-campaign.file.2c3cafa4", width: "minmax(200px, .8fr)", minWidth: 180, resizable: true, sortable: true, filterable: true, value: (file) => file.filename, render: (file) => <strong>{file.filename}</strong> },
{ id: "path", header: "i18n:govoplan-campaign.path.519e3913", width: "minmax(260px, 1.3fr)", minWidth: 220, resizable: true, sortable: true, filterable: true, value: (file) => file.display_path },
{ id: "size", header: "i18n:govoplan-campaign.size.b7152342", width: 120, sortable: true, value: (file) => file.size_bytes, render: (file) => formatImportBytes(file.size_bytes) },
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 160, sortable: true, filterable: true, value: (file) => linkableIds.has(file.id) ? "needs-linking" : "linked", render: (file) => linkableIds.has(file.id) ? "i18n:govoplan-campaign.needs_linking.a0fc8341" : "i18n:govoplan-campaign.linked.a089f600" }
];
return (
<div className="recipient-import-file-step">
<div className="recipient-import-file-actions">
@@ -1781,30 +1695,13 @@ function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving,
</div>
}
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table recipient-import-file-link-table">
<thead>
<tr>
<th>i18n:govoplan-campaign.file.2c3cafa4</th>
<th>i18n:govoplan-campaign.path.519e3913</th>
<th>i18n:govoplan-campaign.size.b7152342</th>
<th>i18n:govoplan-campaign.status.bae7d5be</th>
</tr>
</thead>
<tbody>
{resolution.files.length === 0 ?
<tr><td colSpan={4}>i18n:govoplan-campaign.no_files_currently_match_the_imported_patterns.6b599e8b</td></tr> :
resolution.files.map((file) =>
<tr key={file.id}>
<td><strong>{file.filename}</strong></td>
<td>{file.display_path}</td>
<td>{formatImportBytes(file.size_bytes)}</td>
<td>{linkableIds.has(file.id) ? "i18n:govoplan-campaign.needs_linking.a0fc8341" : "i18n:govoplan-campaign.linked.a089f600"}</td>
</tr>
)}
</tbody>
</table>
</div>
<DataGrid
id="campaign-recipient-import-file-links"
rows={resolution.files}
columns={fileColumns}
getRowKey={(file) => file.id}
emptyText="i18n:govoplan-campaign.no_files_currently_match_the_imported_patterns.6b599e8b"
/>
</>
}
</div>);
@@ -1820,27 +1717,31 @@ type RecipientImportRawTableProps = {
function RecipientImportRawTable({ tableRows, headerRowCount, columnCount }: RecipientImportRawTableProps) {
const visibleRows = tableRows.slice(0, 15);
const safeColumnCount = Math.max(1, columnCount, ...visibleRows.map((row) => row.length));
const rows = visibleRows.map((cells, rowIndex) => ({ rowIndex, cells }));
type RawRow = (typeof rows)[number];
const columns: DataGridColumn<RawRow>[] = [
{ id: "row", header: "#", width: 64, sortable: true, value: (row) => row.rowIndex + 1 },
...Array.from({ length: safeColumnCount }, (_value, columnIndex): DataGridColumn<RawRow> => ({
id: `column-${columnIndex + 1}`,
header: String(columnIndex + 1),
width: "minmax(140px, 1fr)",
minWidth: 120,
resizable: true,
filterable: true,
value: (row) => row.cells[columnIndex] ?? ""
}))
];
return (
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table recipient-import-raw-table">
<thead>
<tr>
<th>#</th>
{Array.from({ length: safeColumnCount }, (_value, index) => <th key={index}>{index + 1}</th>)}
</tr>
</thead>
<tbody>
{visibleRows.length === 0 ?
<tr><td colSpan={safeColumnCount + 1}>i18n:govoplan-campaign.no_rows_parsed.a7ccc3de</td></tr> :
visibleRows.map((row, rowIndex) =>
<tr key={rowIndex} className={rowIndex < headerRowCount ? "is-header-row" : undefined}>
<td>{rowIndex + 1}</td>
{Array.from({ length: safeColumnCount }, (_value, columnIndex) => <td key={columnIndex}>{row[columnIndex] ?? ""}</td>)}
</tr>
)}
</tbody>
</table>
</div>);
<DataGrid
id="campaign-recipient-import-raw"
className="recipient-import-raw-grid"
rows={rows}
columns={columns}
getRowKey={(row) => String(row.rowIndex)}
rowClassName={(row) => row.rowIndex < headerRowCount ? "is-header-row" : undefined}
emptyText="i18n:govoplan-campaign.no_rows_parsed.a7ccc3de"
initialFit="content"
/>);
}

View File

@@ -7,6 +7,7 @@ import {
Link2,
LockKeyhole,
PackageCheck,
Search,
Send,
ShieldCheck,
X,
@@ -37,7 +38,7 @@ import {
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import { Button, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "../../components/table/DataGrid";
import { DismissibleAlert } 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";
@@ -1940,7 +1941,7 @@ reviewedKeys: Set<string>)
},
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) },
{ id: "reviewed", header: "i18n:govoplan-campaign.reviewed.31ef8593", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.reviewed.31ef8593" }, { value: "no", label: "i18n:govoplan-campaign.not_reviewed.0a0e3cff" }] }, render: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? <Check size={17} aria-label="i18n:govoplan-campaign.reviewed.31ef8593" /> : <span className="muted"></span>, value: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no" },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => <Button onClick={() => openMessage(row)}>i18n:govoplan-campaign.review.e29a79fe</Button> }];
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => openMessage(row) }]} /> }];
}
@@ -2132,7 +2133,7 @@ function imapDiagnosticColumns(openDetail: (jobId: string) => Promise<void>): Da
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} />, value: (row) => String(row.send_status ?? "-") },
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} />, value: (row) => String(row.imap_status ?? "-") },
{ id: "error", header: "i18n:govoplan-campaign.last_error.5e4df866", width: "minmax(260px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openDetail(String(row.id ?? ""))}>i18n:govoplan-campaign.details.dc3decbb</Button> }];
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "details", label: "i18n:govoplan-campaign.details.dc3decbb", icon: <Search aria-hidden="true" />, onClick: () => void openDetail(String(row.id ?? "")) }]} /> }];
}
@@ -2146,7 +2147,7 @@ function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGri
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
{ id: "envelope", header: "i18n:govoplan-campaign.envelope_folder.9f30740d", width: 300, resizable: true, filterable: true, value: (row) => `${String(row.envelope_from ?? row.folder ?? "—")}${asArray(row.envelope_recipients).join(", ") || String(row.folder ?? "—")}` },
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? 0) },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openMessage(String(row.id ?? ""))}>i18n:govoplan-campaign.review.e29a79fe</Button> }];
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => void openMessage(String(row.id ?? "")) }]} /> }];
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings, AuthInfo, CampaignListItem } from "../../../types";
import { KeyRound } from "lucide-react";
import { KeyRound, Trash2 } from "lucide-react";
import {
fetchResourceAccessExplanation,
getCampaignShares,
@@ -18,7 +18,7 @@ import { ConfirmDialog } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn } from "../../../components/table/DataGrid";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { StatusBadge, hasScope, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
import { StatusBadge, TableActionGroup, hasScope, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
type TargetType = "user" | "group";
export default function CampaignAccessCard({
@@ -196,7 +196,7 @@ export default function CampaignAccessCard({
}
},
{ id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "i18n:govoplan-campaign.can_edit.cb0ab3da" : "i18n:govoplan-campaign.can_view.1b3e4006"} /> },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 110, resizable: false, sticky: "end", align: "right", render: (row) => <Button variant="danger" onClick={() => setRevokeTarget(row)}>i18n:govoplan-campaign.remove.e963907d</Button> }],
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, resizable: false, sticky: "end", align: "right", render: (row) => <TableActionGroup actions={[{ id: "remove", label: "i18n:govoplan-campaign.remove.e963907d", icon: <Trash2 aria-hidden="true" />, variant: "danger", onClick: () => setRevokeTarget(row) }]} /> }],
[targetMap]);
if (available === false) return null;

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ExternalLink } from "lucide-react";
import { ExternalLink, RotateCcw, Send } from "lucide-react";
import type { ApiSettings, CampaignListItem } from "../../types";
import { getCampaignWorkspaceDelta, listCampaignsDelta, retryCampaignJobs, sendUnattemptedCampaignJobs, type CampaignSummary } from "../../api/campaigns";
import { Button } from "@govoplan/core-webui";
@@ -9,7 +9,7 @@ import { DismissibleAlert } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { MetricCard } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useGuardedNavigate } from "@govoplan/core-webui";
import { StatusBadge, TableActionGroup, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useGuardedNavigate } from "@govoplan/core-webui";
import { asRecord, formatDateTime, humanize } from "../campaigns/utils/campaignView";
type OperatorRow = {
@@ -165,16 +165,13 @@ export default function OperatorQueuePage({ settings }: {settings: ApiSettings;}
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 270,
width: 150,
sticky: "end",
render: (row) =>
<div className="button-row compact-actions">
<Button className="admin-icon-button" onClick={() => navigate(`/campaigns/${row.campaign.id}/report`)} aria-label={i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name })} title={i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name })}>
<ExternalLink />
</Button>
<Button onClick={() => void runAction(row, "retry")} disabled={row.failed <= 0 || Boolean(busy)}>i18n:govoplan-campaign.retry.9f5cd8a2</Button>
<Button onClick={() => void runAction(row, "unattempted")} disabled={row.notAttempted <= 0 || Boolean(busy)}>i18n:govoplan-campaign.queue_unsent.b0e98610</Button>
</div>
render: (row) => <TableActionGroup actions={[
{ id: "open", label: i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: row.campaign.name }), icon: <ExternalLink aria-hidden="true" />, onClick: () => navigate(`/campaigns/${row.campaign.id}/report`) },
{ id: "retry", label: "i18n:govoplan-campaign.retry.9f5cd8a2", icon: <RotateCcw aria-hidden="true" />, applicable: row.failed > 0, disabled: Boolean(busy), onClick: () => void runAction(row, "retry") },
{ id: "queue-unsent", label: "i18n:govoplan-campaign.queue_unsent.b0e98610", icon: <Send aria-hidden="true" />, applicable: row.notAttempted > 0, disabled: Boolean(busy), onClick: () => void runAction(row, "unattempted") }
]} />
}],
[busy, navigate]);

View File

@@ -4,7 +4,7 @@ import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { FieldLabel } from "@govoplan/core-webui";
import { StatusBadge, i18nMessage } from "@govoplan/core-webui";
import { StatusBadge, TableActionGroup, i18nMessage } from "@govoplan/core-webui";
import { usePlatformLanguage } from "@govoplan/core-webui";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
@@ -97,10 +97,12 @@ function templateLibraryColumns(openTemplate: (templateId: string) => void): Dat
width: 70,
sticky: "end",
align: "right",
render: (template) =>
<Button className="admin-icon-button" onClick={() => openTemplate(template.id)} aria-label={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name })} title={i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name })}>
<ExternalLink />
</Button>
render: (template) => <TableActionGroup actions={[{
id: "open",
label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: template.name }),
icon: <ExternalLink aria-hidden="true" />,
onClick: () => openTemplate(template.id)
}]} />
}];
@@ -115,8 +117,7 @@ function templateFieldColumns(): DataGridColumn<{id: string;field: string;source
return [
{ id: "field", header: "i18n:govoplan-campaign.field.c326a466", width: "minmax(180px, 1fr)", sortable: true, filterable: true, sticky: "start", render: (row) => <code>{row.field}</code>, value: (row) => row.field },
{ id: "source", header: "i18n:govoplan-campaign.source.6da13add", width: 190, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "detected_placeholder", label: "i18n:govoplan-campaign.detected_placeholder.094b5214" }] }, value: (row) => row.source },
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 120, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.yes.5397e058" }, { value: "no", label: "i18n:govoplan-campaign.no.816c52fd" }] }, value: (row) => row.required },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 130, sticky: "end", render: () => <Button disabled>i18n:govoplan-campaign.configure.792c81a4</Button> }];
{ id: "required", header: "i18n:govoplan-campaign.required.eed6bfb4", width: 120, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.yes.5397e058" }, { value: "no", label: "i18n:govoplan-campaign.no.816c52fd" }] }, value: (row) => row.required }];
}