Compare commits

..
3 Commits
Author SHA1 Message Date
zemion 11598b7b5b fix(ui): align campaign tables, recipient sizing and contextual help
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:03:36 +02:00
zemion 19437ce378 perf(campaign): share linear collision-safe attachment naming
Module Package Release / publish-packages (push) Successful in 13s
Release v0.1.29. Coordinated integrity review: GovOPlaN/govoplan-core#298.
2026-09-08 12:20:33 +02:00
zemion 8bca4fc728 perf(campaign): share linear collision-safe attachment naming
Release v0.1.29. Coordinated integrity review: GovOPlaN/govoplan-core#298.
2026-09-08 12:19:37 +02:00
17 changed files with 241 additions and 66 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.28",
"version": "0.1.29",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-campaign"
version = "0.1.28"
version = "0.1.29"
description = "GovOPlaN campaigns module with backend and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.45",
"govoplan-core>=0.1.46",
"jsonschema>=4,<5",
"pydantic>=2,<3",
"SQLAlchemy>=2,<3",
@@ -1,9 +1,8 @@
from __future__ import annotations
from dataclasses import replace
from typing import Iterable
from govoplan_core.core.modules import DocumentationTopic
from govoplan_core.core.modules import DocumentationTopic, localize_documentation_topics as _localize_topics
_TRANSLATIONS = {
@@ -258,15 +257,4 @@ _TRANSLATIONS = {
def localize_documentation_topics(
topics: Iterable[DocumentationTopic],
) -> tuple[DocumentationTopic, ...]:
localized: list[DocumentationTopic] = []
for topic in topics:
german = _TRANSLATIONS.get(topic.id)
if german is None:
localized.append(topic)
continue
translations = {
locale: dict(value) for locale, value in topic.translations.items()
}
translations["de"] = {**translations.get("de", {}), **german}
localized.append(replace(topic, translations=translations))
return tuple(localized)
return _localize_topics(topics, locale="de", translations=_TRANSLATIONS)
+80 -2
View File
@@ -460,11 +460,13 @@ def _campaigns_router(context: ModuleContext):
return aggregate
MODULE_VERSION = "0.1.29"
manifest = ModuleManifest(
id="campaigns",
name="Campaigns",
version="0.1.28",
workflow_definitions=campaign_workflow_definitions(module_version="0.1.28"),
version=MODULE_VERSION,
workflow_definitions=campaign_workflow_definitions(module_version=MODULE_VERSION),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -834,6 +836,82 @@ manifest = ModuleManifest(
),
),
documentation=localize_documentation_topics((
DocumentationTopic(
id="campaigns.module-navigation-and-table-layout",
title="Distinguish module reports from one campaign's report",
summary="Use accurate module breadcrumbs and shared edge-to-edge report and attachment tables.",
body=(
"The Campaign audit documentation book sits immediately to the right of the Recent audit "
"events heading. "
"The Campaigns module has separate Reports (/campaigns/reports) and Operator queue (/campaigns/queue) views. "
"Their breadcrumbs name the module section, not a campaign record. A selected campaign's own report remains under "
"/campaigns/{campaign_id}/report (including its reports alias) with the singular Campaign and Report context. "
"The legacy /operator link still redirects to the queue; report selection query parameters, editor deep links and "
"Quick Access return-to-origin history behavior remain unchanged. These labels grant no extra report or delivery permissions. "
"The Campaign reports available to you and Global Attachments cards use the shared table-body layout: tables reach "
"the card boundary, including while loading, without module-local negative margins. Global Attachments remains collapsible; "
"chooser warnings, row actions, empty-state actions and existing filters remain available. Layout changes do not change "
"recipients, attachment rules or bytes, report privacy suppression, saved data, exports or delivery state. "
"In Recipient data, automatic fitting prefers at most 640/480 pixels for Recipient(s)/Delivery and 360 pixels "
"for configurable fields before distributing spare space. These are starting-layout preferences, not manual "
"resize limits: all three kinds of column can be widened further and reduced again. "
"The table scrolls horizontally while manual resizing keeps the non-resizable Active and Attachments columns "
"at their current widths. These are personal "
"browser layout changes, not campaign autosaves. Recipient and global-attachment grids wait for the initial draft "
"before restoring their saved column layout, so a reload does not replace personal widths with temporary loading columns."
),
layer="available",
documentation_types=("user", "admin"),
audience=("campaign_manager", "campaign_operator", "campaign_admin"),
order=19,
conditions=(DocumentationCondition(required_modules=("campaigns",), any_scopes=CAMPAIGN_MODULE_REQUIRED_ANY),),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
DocumentationLink(label="Reports", href="/campaigns/reports", kind="runtime"),
DocumentationLink(label="Operator queue", href="/campaigns/queue", kind="runtime"),
),
metadata={"kind": "reference", "related_topic_ids": ["campaigns.workflow.prepare-validate-and-build"]},
translations={"de": {
"title": "Modulberichte vom Bericht einer einzelnen Kampagne unterscheiden",
"summary": "Eindeutige Modul-Breadcrumbs sowie gemeinsame, bündige Berichts- und Anhangstabellen verwenden.",
"body": (
"Das Dokumentationsbuch im Kampagnen-Audit steht unmittelbar rechts neben der Überschrift "
"Letzte Audit-Ereignisse. "
"Das Modul Kampagnen besitzt getrennte Ansichten für Berichte (/campaigns/reports) und die Operator-Warteschlange "
"(/campaigns/queue). Ihre Breadcrumbs benennen den Modulbereich und keinen Kampagnendatensatz. Der Bericht einer "
"ausgewählten Kampagne bleibt unter /campaigns/{campaign_id}/report (einschließlich des reports-Alias) im Kontext "
"Kampagne und Bericht. Der bisherige Link /operator leitet weiterhin zur Warteschlange um; Auswahlparameter für "
"Berichte, Editor-Deep-Links und die Rückkehr zum Ursprung über Quick Access bleiben unverändert. Die Beschriftung "
"vergibt keine zusätzlichen Berichts- oder Versandberechtigungen. Die Karten für verfügbare Kampagnenberichte und "
"globale Anhänge verwenden das gemeinsame Tabellenlayout: Tabellen reichen auch beim Laden bis an den Kartenrand, "
"ohne negative modulspezifische Abstände. Globale Anhänge bleiben einklappbar; Auswahlwarnungen, Zeilenaktionen, "
"Aktionen für leere Tabellen und vorhandene Filter bleiben verfügbar. Das Layout ändert weder Empfänger, "
"Anhangsregeln oder Bytes noch Datenschutzunterdrückung, gespeicherte Daten, Exporte oder Versandzustand. "
"In den Empfängerdaten bevorzugt die automatische Anpassung zunächst höchstens 640/480 Pixel für Empfänger/Zustellung "
"und 360 Pixel für konfigurierbare Felder, bevor sie freien Platz verteilt. Diese Werte bestimmen nur das "
"Ausgangslayout und begrenzen nicht die manuelle Größenänderung: Alle drei Spaltenarten lassen sich weiter "
"verbreitern und wieder verkleinern. Die Tabelle wird horizontal scrollbar; bei der manuellen Größenänderung "
"behalten die nicht verstellbaren Spalten Aktiv und Anhänge ihre aktuellen Breiten. Dies sind persönliche "
"Browser-Einstellungen und keine automatische Speicherung der Kampagne. "
"Empfänger- und globale Anhangstabellen warten beim ersten Laden auf den Entwurf, bevor sie gespeicherte "
"Spaltenbreiten wiederherstellen. Vorläufige Ladespalten überschreiben dadurch beim Neuladen keine persönlichen Breiten."
),
}},
),
DocumentationTopic(
id="campaigns.attachment-filename-fidelity",
title="Deterministic attachment and ZIP names",
summary="Resolve repeated names efficiently without dropping or reordering attachments.",
body="Message attachments and ZIP members share a first-free suffix allocator. Repeated names retain the established case-insensitive collision rule and exact numbered suffixes, including names already containing suffixes, Unicode case folding and multiple extensions. Each message/archive has independent allocation state. Large groups of identical requested names no longer restart every suffix search from two. This changes naming work only: intended attachment bytes, recipients, order, existing duplicate-file review policy and ZIP encryption remain unchanged.",
layer="available", documentation_types=("user", "admin"), audience=("campaign_manager", "campaign_admin"), order=18,
conditions=(DocumentationCondition(required_modules=("campaigns",), any_scopes=("campaigns:campaign:read", "campaigns:campaign:write")),),
links=(DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
translations={"de": {
"title": "Deterministische Namen für Anhänge und ZIP-Einträge",
"summary": "Wiederholte Namen effizient auflösen, ohne Anhänge auszulassen oder umzuordnen.",
"body": "Nachrichtenanhänge und ZIP-Einträge verwenden dieselbe Vergabe des ersten freien nummerierten Suffixes. Die bisherige groß-/kleinschreibungsunabhängige Kollisionsregel und exakte Nummerierung bleiben erhalten, auch bei vorhandenen Nummernsuffixen, Unicode-Groß-/Kleinschreibung und mehrfachen Erweiterungen. Jede Nachricht und jedes Archiv besitzt einen getrennten Vergabezustand. Große Gruppen gleicher gewünschter Namen beginnen die Suffixsuche nicht mehr jeweils bei zwei. Nur der Suchaufwand ändert sich: vorgesehene Bytes, Empfänger, Reihenfolge, bestehende Prüfung mehrfach verwendeter Dateien und ZIP-Verschlüsselung bleiben unverändert.",
}},
),
*CAMPAIGN_USER_DOCUMENTATION,
DocumentationTopic(
id="campaigns.workflow.link-exact-campaign-to-case",
@@ -1,5 +1,7 @@
from __future__ import annotations
from govoplan_campaign.backend.services.filenames import FilenameAllocator
import mimetypes
import re
import tempfile
@@ -303,15 +305,8 @@ def _archive_filename(archive: ZipArchiveConfig, values: dict[str, Any], entry_i
return filename if filename.lower().endswith(".zip") else f"{filename}.zip"
def _unique_attachment_filename(filename: str, used: set[str]) -> str:
candidate = filename
path = Path(filename)
counter = 2
while candidate.casefold() in used:
candidate = f"{path.stem} ({counter}){path.suffix}"
counter += 1
used.add(candidate.casefold())
return candidate
def _unique_attachment_filename(filename: str, used: FilenameAllocator) -> str:
return used.allocate(filename)
def _deduplicated_archive_members(members: list[tuple[Path, str]]) -> list[tuple[Path, str]]:
@@ -381,8 +376,8 @@ def _attach_files(
evidence: list[dict[str, object]] = []
archive_members: dict[str, list[tuple[Path, str]]] = {}
archive_attachments: dict[str, list[ResolvedAttachment]] = {}
used_message_filenames: set[str] = set()
used_zip_member_filenames: dict[str, set[str]] = {}
used_message_filenames = FilenameAllocator()
used_zip_member_filenames: dict[str, FilenameAllocator] = {}
for attachment in resolution.attachments:
attachment.message_filenames = []
@@ -394,7 +389,7 @@ def _attach_files(
continue
match_paths = [Path(match) for match in attachment.matches]
if attachment.zip_enabled and attachment.zip_archive_id:
used_archive_names = used_zip_member_filenames.setdefault(attachment.zip_archive_id, set())
used_archive_names = used_zip_member_filenames.setdefault(attachment.zip_archive_id, FilenameAllocator())
for position, path in enumerate(match_paths, start=1):
requested = _render_attachment_filename(
template=attachment.zip_entry_name_template,
+23
View File
@@ -0,0 +1,23 @@
"""Deterministic collision naming shared by message and ZIP construction."""
from pathlib import Path
class FilenameAllocator:
"""Append-only names with the original first-free, casefolded suffix rule."""
def __init__(self) -> None:
self.used: set[str] = set()
self._next: dict[str, int] = {}
def allocate(self, filename: str) -> str:
key = filename.casefold()
candidate = filename
path = Path(filename)
counter = self._next.get(key, 2)
while candidate.casefold() in self.used:
candidate = f"{path.stem} ({counter}){path.suffix}"
counter += 1
self.used.add(candidate.casefold())
self._next[key] = counter
return candidate
@@ -12,6 +12,8 @@ from pathlib import Path
from typing import Iterable
import zlib
from govoplan_campaign.backend.services.filenames import FilenameAllocator
try:
import pyzipper
except ImportError: # pragma: no cover
@@ -24,18 +26,11 @@ ZIP_METHOD_STANDARD = "zip_standard"
def _normalized_members(files: Iterable[Path | ArchiveMember]) -> list[ArchiveMember]:
members: list[ArchiveMember] = []
used_names: set[str] = set()
names = FilenameAllocator()
for item in files:
path, requested_name = item if isinstance(item, tuple) else (item, item.name)
requested = Path(requested_name).name or path.name
stem = Path(requested).stem
suffix = Path(requested).suffix
candidate = requested
counter = 2
while candidate.casefold() in used_names:
candidate = f"{stem} ({counter}){suffix}"
counter += 1
used_names.add(candidate.casefold())
candidate = names.allocate(requested)
members.append((path, candidate))
return members
+12
View File
@@ -5,3 +5,15 @@ def test_static_documentation_has_complete_german_reference_copy() -> None:
for topic in get_manifest().documentation:
german = topic.translations.get("de", {})
assert all(german.get(field, "").strip() for field in ("title", "summary", "body")), topic.id
def test_module_breadcrumb_and_table_layout_contract_is_bilingual_and_non_mutating() -> None:
topic = next(item for item in get_manifest().documentation if item.id == "campaigns.module-navigation-and-table-layout")
assert set(topic.documentation_types) == {"user", "admin"}
assert topic.layer == "available"
for body in (topic.body, topic.translations["de"]["body"]):
for route in ("/campaigns/reports", "/campaigns/queue", "/campaigns/{campaign_id}/report", "/operator"):
assert route in body
assert "Quick Access" in body
assert "report privacy suppression" in topic.body
assert "without module-local negative margins" in topic.body
+75
View File
@@ -0,0 +1,75 @@
from pathlib import Path
import random
import unittest
from govoplan_campaign.backend.services.filenames import FilenameAllocator
from govoplan_campaign.backend.services.zip_service import _normalized_members
def legacy_names(names):
used = set()
result = []
for name in names:
path = Path(name)
candidate = name
counter = 2
while candidate.casefold() in used:
candidate = f"{path.stem} ({counter}){path.suffix}"
counter += 1
used.add(candidate.casefold())
result.append(candidate)
return result
class FilenameAllocatorTests(unittest.TestCase):
def test_exact_equivalence_with_colliding_suffixes_case_unicode_and_multiple_dots(
self,
):
choices = [
"report.pdf",
"REPORT.PDF",
"report (2).pdf",
"report (3).pdf",
"report (2) (2).pdf",
".hidden",
"a.tar.gz",
"A.TAR.GZ",
"Straße.txt",
"STRASSE.txt",
"readme",
]
rng = random.Random(42)
for _ in range(30):
names = [rng.choice(choices) for _ in range(200)]
allocator = FilenameAllocator()
self.assertEqual(
legacy_names(names), [allocator.allocate(name) for name in names]
)
def test_zip_and_message_names_preserve_every_input_and_sequence(self):
names = ["a.pdf", "A.pdf", "a (2).pdf", "a.pdf"]
paths = [Path(f"/synthetic/{index}/source") for index in range(len(names))]
members = _normalized_members(list(zip(paths, names)))
self.assertEqual(paths, [path for path, _ in members])
self.assertEqual(legacy_names(names), [name for _, name in members])
self.assertEqual("a.pdf", FilenameAllocator().allocate("a.pdf"))
def test_many_collisions_have_linear_membership_work(self):
class CountingSet(set):
probes = 0
def __contains__(self, item):
self.probes += 1
return super().__contains__(item)
allocator = FilenameAllocator()
allocator.used = CountingSet()
for _ in range(10000):
last = allocator.allocate("report.pdf")
self.assertEqual("report (10000).pdf", last)
self.assertEqual(10000, len(allocator.used))
self.assertEqual(19999, allocator.used.probes)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.28",
"version": "0.1.29",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -444,8 +444,10 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
}
</Card>
<Card id="campaign-global-attachments" tabIndex={-1} title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
<AttachmentRulesDataGrid
<Card id="campaign-global-attachments" tabIndex={-1} title="i18n:govoplan-campaign.global_attachments.492bd841" bodyLayout="table" collapsible>
{/* The loading draft has no ZIP columns yet. Do not let that
temporary signature overwrite the user's saved table layout. */}
{version && draft && <AttachmentRulesDataGrid
id={`campaign-${campaignId}-global-attachments`}
rules={globalRules}
disabled={locked}
@@ -456,7 +458,7 @@ export default function AttachmentsDataPage({ settings, auth, campaignId }: {set
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
previewContext={attachmentPreviewContext}
onChange={(rules) => patch(["attachments", "global"], rules)} />
onChange={(rules) => patch(["attachments", "global"], rules)} />}
</Card>
@@ -24,7 +24,13 @@ export default function CampaignAuditPage({ settings, campaignId }: {settings: A
reloadAction={{ onReload: () => void reload({ force: true }), loading }}
/>}
>
<Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d">
<Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d" titleHelp={<DocumentationHelpLink
reference={{
topicId: "campaigns.reference.composition-assurance",
documentationType: "user"
}}
label="Open Campaign assurance documentation"
/>}>
<ActionBlockerHint
tone="info"
reason={{
@@ -39,13 +45,6 @@ export default function CampaignAuditPage({ settings, campaignId }: {settings: A
documentationType: "admin"
}}
/>
<DocumentationHelpLink
reference={{
topicId: "campaigns.reference.composition-assurance",
documentationType: "user"
}}
label="Open Campaign assurance documentation"
/>
</Card>
</PageLayout>);
@@ -5,7 +5,6 @@ import {
getCampaignPostboxCatalog,
listCampaignRecipientAddressSources,
listCampaignRecipientDistributionLists,
snapshotCampaignRecipientAddressSource,
type CampaignDistributionListExpansion,
type CampaignDistributionListSource,
type CampaignPostboxCatalog,
@@ -41,7 +40,7 @@ import {
createAddressSourceImportProvenance
} from "./utils/addressSourceImport";
import { addressesFromValue, type MailboxAddress } from "@govoplan/core-webui";
import { i18nMessage, insertAfter, moveArrayItem, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
import { i18nMessage, insertAfter, moveArrayItem, usePlatformLanguage } from "@govoplan/core-webui";
import AddressSourceImportDialog from "./recipients/AddressSourceImportDialog";
import DistributionListImportDialog from "./recipients/DistributionListImportDialog";
import {
@@ -56,11 +55,7 @@ import {
entryWithAddressValues,
formatAddressCollectionForClipboard,
getAddressColumn,
getEntryAddresses,
headerAddressValues,
hiddenRecipientAddressMatch,
recipientAddressFilterValue,
recipientAddressSummary,
recipientHeaderRows,
type AddressFieldKey,
type EntryAddressColumn,
@@ -563,7 +558,9 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
</div>
</DismissibleAlert>
}
{!source.type &&
{/* Mount with the real draft's delivery/attachment/field columns;
an empty loading signature would erase personal column widths. */}
{version && draft && !source.type &&
<div className="admin-table-surface recipient-profiles-table-surface">
<DataGrid
id={`campaign-${campaignId}-recipient-profiles`}
@@ -23,7 +23,6 @@ import {
import { getDraftFields } from "../utils/fieldDefinitions";
import { asRecord } from "../utils/campaignView";
import {
getEntryAddresses,
hiddenRecipientAddressMatch,
recipientAddressFilterValue,
recipientAddressSummary
@@ -74,7 +73,7 @@ export function recipientProfileColumns({ settings, campaignId, draft, locked, f
id: "recipients",
header: "Recipient(s)",
width: "minmax(320px, 1.4fr)",
maxWidth: 640,
preferredMaxWidth: 640,
resizable: true,
filterable: true,
render: (entry, index) => {
@@ -114,7 +113,7 @@ export function recipientProfileColumns({ settings, campaignId, draft, locked, f
id: "delivery",
header: "Delivery",
width: "minmax(260px, 0.9fr)",
maxWidth: 480,
preferredMaxWidth: 480,
resizable: true,
filterable: true,
render: (entry, index) => {
@@ -151,7 +150,7 @@ export function recipientProfileColumns({ settings, campaignId, draft, locked, f
Postboxes ({targets.length})
</Button>
)}
{printTarget.target && (
{Boolean(printTarget.target) && (
<span className="muted small-note" title={String(printTarget.target)}>
{printTarget.channel === "internal_mail" ? "Internal mail" : "Postal"}: {String(printTarget.target)}
</span>
@@ -185,13 +184,13 @@ export function recipientProfileColumns({ settings, campaignId, draft, locked, f
},
value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ")
}] : []),
} satisfies DataGridColumn<Record<string, unknown>>] : []),
...fieldDefinitions.filter((field) => field.can_override !== false).map((field): DataGridColumn<Record<string, unknown>> => ({
id: `field-${field.name}`,
header: field.label || field.name,
width: 190,
minWidth: 160,
maxWidth: 360,
preferredMaxWidth: 360,
resizable: true,
sortable: true,
filterable: true,
@@ -183,7 +183,7 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
/>}
>
<Card title="i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403">
<Card title="i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403" bodyLayout="table">
<LoadingFrame loading={listLoading} label="i18n:govoplan-campaign.loading_campaign_reports_.61ec1ee8">
<DataGrid
id="campaign-aggregate-report-list"
@@ -28,6 +28,8 @@ assert(!page.includes("downloadCampaignJobsCsv"), "the aggregate page has no exp
assert(!page.includes("localStorage"), "the aggregate page does not persist report data in local storage");
assert(!page.includes("sessionStorage"), "the aggregate page does not persist report data in session storage");
assert(page.includes("TableActionGroup"), "campaign selection uses the central icon-only table action group");
assert.match(page, /<Card\s+title="i18n:govoplan-campaign\.campaign_reports_available_to_you\.f14fa403"\s+bodyLayout="table">\s*<LoadingFrame[\s\S]*?<DataGrid/, "the report selector uses the shared edge-to-edge table body through its loading frame");
assert.match(attachmentsPage, /<Card\s+id="campaign-global-attachments"[^>]*bodyLayout="table"[^>]*collapsible>[\s\S]*?\{version && draft && <AttachmentRulesDataGrid/, "global attachments retain their collapsible table body and wait for real draft columns before restoring personal widths");
assert(page.includes("disabled: campaign.id === selectedFromUrl"), "the selected row action stays visible and disabled");
assert(page.includes('columnType: "from-list"'), "campaign status uses the stable shared list-filter model");
const expectedCampaignStatuses = [
@@ -29,4 +29,14 @@ assertIncludes(
"a row must explain when its match came from a hidden address"
);
assertIncludes("version && draft && !source.type", "recipient grids wait for the initial draft before restoring column widths");
const profileColumns = readFileSync(new URL("../src/features/campaigns/recipients/recipientProfileColumns.tsx", import.meta.url), "utf8");
for (const id of ["recipients", "delivery"]) {
const start = profileColumns.indexOf(`id: "${id}"`);
const declarations = profileColumns.slice(start, profileColumns.indexOf("render:", start));
if (!declarations.includes("resizable: true") || declarations.includes("maxWidth:")) {
throw new Error(`${id} remains user-expandable without an arbitrary hard display cap`);
}
}
console.log("Campaign recipient search covers and explains hidden address matches.");