feat: consolidate shared UI and harden browser authority for release

This commit is contained in:
2026-09-08 01:35:05 +02:00
parent ac40774785
commit b75ca34295
143 changed files with 8664 additions and 752 deletions
@@ -0,0 +1,25 @@
import AddressBookPage from "../../../govoplan-addresses/webui/src/features/addressbook/AddressBookPage";
import { generatedTranslations } from "../../../govoplan-addresses/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { AuthInfo } from "../src/types";
import "../../../govoplan-addresses/webui/src/styles/addresses.css";
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
export default function AddressExplorerScenario() {
const params = new URLSearchParams(window.location.search);
const auth: AuthInfo = {
user: { id: "address-fixture-user", account_id: "address-fixture-account", email: "address-fixture@example.test" },
tenant: { id: "address-fixture-tenant", name: "Address fixture", slug: "address-fixture" },
scopes: params.has("read-only") ? ["addresses:contact:read"] : [
"addresses:contact:read", "addresses:contact:write", "addresses:contact:delete",
"addresses:address_book:write", "addresses:address_book:delete",
"addresses:address_list:write", "addresses:address_list:delete",
"addresses:sync:read", "addresses:sync:write", "addresses:governance:read"
],
roles: [], groups: [], profile_loaded: true, groups_loaded: true, roles_loaded: true
};
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
<AddressBookPage settings={settings} auth={auth} />
</PlatformLanguageProvider>;
}
@@ -0,0 +1,34 @@
import { useState } from "react";
import AttachmentsDataPage from "../../../govoplan-campaign/webui/src/features/campaigns/AttachmentsDataPage";
import { generatedTranslations as campaignTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import ManagedFileChooser from "../../../govoplan-files/webui/src/features/files/components/ManagedFileChooser";
import { listFileSpaces } from "../../../govoplan-files/webui/src/api/files";
import { generatedTranslations as filesTranslations } from "../../../govoplan-files/webui/src/i18n/generatedTranslations";
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import type { AuthInfo, PlatformWebModule } from "../src/types";
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
import "../../../govoplan-files/webui/src/styles/file-manager.css";
const auth: AuthInfo = { user: { id: "user-1", account_id: "account-1", email: "fixture@example.test" },
tenant: { id: "tenant-1", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:write", "files:file:read"],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
export default function CampaignAttachmentsScenario() {
const [available, setAvailable] = useState(true);
const modules: PlatformWebModule[] = [
{ id: "campaigns", label: "Campaign", version: "fixture" },
{ id: "files", label: "Files", version: "fixture", uiCapabilities: available ? {
"files.fileExplorer": { ManagedFileChooser, listFileSpaces }
} : {} }
];
return <PlatformModulesProvider modules={modules}>
<PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[campaignTranslations, filesTranslations]}>
<button type="button" onClick={() => setAvailable(value => !value)}>Toggle Files capability</button>
<ConcurrencyConflictProvider>
<AttachmentsDataPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="campaign-attachments" />
</ConcurrencyConflictProvider>
</PlatformLanguageProvider>
</PlatformModulesProvider>;
}
@@ -0,0 +1,31 @@
import { useState } from "react";
import { apiPostJson } from "../src/api/client";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import BulkMessageReviewDialog from "../../../govoplan-campaign/webui/src/features/campaigns/review/BulkMessageReviewDialog";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
function job(id: string, overrides: Record<string, unknown> = {}) {
return { id, build_status: "built", validation_status: "needs_review", recipient_email: `${id}@example.test`, subject: `Frozen ${id}`,
review_decision: { eligible: true, category_key: "same-attachment-condition", reason_required: true, issue_codes: ["attachment_match_empty"] }, ...overrides };
}
export default function CampaignBulkReviewScenario() {
const params = new URLSearchParams(window.location.search);
const [open, setOpen] = useState(false);
const [buildToken, setBuildToken] = useState("build-one");
const rows = [...Array.from({ length: params.has("large") ? 205 : 3 }, (_, index) => job(`job-${String(index).padStart(3, "0")}`)),
job("reviewed", { reviewed: true }), job("blocked", { validation_status: "blocked", review_decision: { eligible: false } }),
job("expected-exclusion", { validation_status: "excluded", review_decision: { eligible: false } }),
job("allowed-zero", { validation_status: "warning", review_decision: { eligible: false } }),
job("other-category", { review_decision: { eligible: true, category_key: "other-condition", reason_required: false, issue_codes: ["address_warning"] } })];
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
<main><button onClick={() => setOpen(true)}>Open grouped review</button>
<button data-testid="replace-build" onClick={() => setBuildToken("build-two")}>Replace build</button>
{open && <BulkMessageReviewDialog rows={rows} buildToken={buildToken} disabled={params.has("read-only")}
onClose={() => setOpen(false)} onAccept={async (selection) => {
await apiPostJson({ apiBaseUrl: "", apiKey: "", accessToken: "" }, "/api/v1/conformance/review-state", selection);
}} />}
</main>
</PlatformLanguageProvider>;
}
@@ -0,0 +1,10 @@
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import CampaignDeliveryPolicyPanel from "../../../govoplan-campaign/webui/src/features/admin/CampaignDeliveryPolicyPanel";
export default function CampaignDeliveryPolicyScenario() {
const params = new URLSearchParams(window.location.search);
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"}>
<CampaignDeliveryPolicyPanel settings={{ apiBaseUrl: "", accessToken: "", apiKey: "" }}
scope={params.get("scope") === "tenant" ? "tenant" : "system"} canWrite={!params.has("readonly")} />
</PlatformLanguageProvider>;
}
@@ -0,0 +1,19 @@
import ReviewSendPage from "../../../govoplan-campaign/webui/src/features/campaigns/ReviewSendPage";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import type { AuthInfo, PlatformWebModule } from "../src/types";
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }];
const auth: AuthInfo = { user: { id: "sender", account_id: "sender-account", email: "sender@example.test" },
tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:campaign:read", "campaigns:recipient:read", "campaigns:campaign:review",
"campaigns:campaign:validate", "campaigns:campaign:build", "campaigns:campaign:send", "campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:control"],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
export default function CampaignDeliveryProgressScenario() {
const params = new URLSearchParams(window.location.search);
return <PlatformModulesProvider modules={modules}>
<PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
<ReviewSendPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="delivery-campaign" />
</PlatformLanguageProvider>
</PlatformModulesProvider>;
}
@@ -0,0 +1,25 @@
// Real optional-module settings surface with test-owned HTTP responses only.
import MailSettingsPage from "../../../govoplan-campaign/webui/src/features/campaigns/MailSettingsPage";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import type { PlatformWebModule } from "../src/types";
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" };
const modules: PlatformWebModule[] = [
{ id: "campaigns", label: "Campaign", version: "fixture" },
{ id: "mail", label: "Mail", version: "fixture" }
];
export default function CampaignMailSettingsScenario() {
const language = new URLSearchParams(window.location.search).get("language") ?? "en";
return <PlatformModulesProvider modules={modules}>
<PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[generatedTranslations]}>
<ConcurrencyConflictProvider>
<MailSettingsPage settings={settings} campaignId="campaign-mail" />
</ConcurrencyConflictProvider>
</PlatformLanguageProvider>
</PlatformModulesProvider>;
}
@@ -0,0 +1,66 @@
import { useCallback, useState } from "react";
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { useCampaignDraftEditor } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignDraftEditor";
import { getCampaignVersion, type CampaignVersionDetail } from "../../../govoplan-campaign/webui/src/api/campaigns";
import {
HeaderAddressEditorDialog, RecipientAddressEditorDialog, entryWithAddressValues, getAddressColumn,
type HeaderAddressValues
} from "../../../govoplan-campaign/webui/src/features/campaigns/recipients/RecipientAddressEditor";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" };
const initialAddresses = [
{ name: "Alpha", email: "alpha@example.test" },
{ name: "Beta", email: "beta@example.test" },
{ name: "Zulu", email: "zulu@example.test" }
];
const initialVersion: CampaignVersionDetail = {
id: "order-version", campaign_id: "order-campaign", version_number: 1, edit_revision: 1, strong_etag: '"order-version:1"',
current_flow: "manual", current_step: "recipients", workflow_state: "editing", is_complete: false,
raw_json: {
campaign: { name: "Recipient ordering" }, server: {},
recipients: { allow_individual_to: true, to: initialAddresses },
entries: { defaults: {}, inline: [{ id: "entry-1", name: "Alpha", email: "alpha@example.test", to: initialAddresses }] }
}, editor_state: {}, updated_at: "2026-09-07T10:00:00Z"
};
export default function CampaignRecipientOrderScenario() {
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
<ConcurrencyConflictProvider><RecipientOrderEditor /></ConcurrencyConflictProvider>
</PlatformLanguageProvider>;
}
function RecipientOrderEditor() {
const [version, setVersion] = useState(initialVersion);
const [error, setError] = useState("");
const [dialog, setDialog] = useState<"entry" | "header" | null>(null);
const reload = useCallback(async () => setVersion(await getCampaignVersion(settings, "order-campaign", "order-version")), []);
const editor = useCampaignDraftEditor({ settings, campaignId: "order-campaign", version, locked: false,
reload, setError, currentStep: "recipients", unsavedTitle: "Unsaved recipient order", unsavedMessage: "Save or discard the campaign draft?" });
const entries = editor.displayDraft.entries as { defaults: Record<string, unknown>; inline: Record<string, unknown>[] };
const recipients = editor.displayDraft.recipients as Record<string, unknown>;
const entry = entries?.inline?.[0] ?? {};
return <main>
<button onClick={() => setDialog("entry")}>Edit individual addresses</button>
<button onClick={() => setDialog("header")}>Edit global addresses</button>
<button disabled={!editor.dirty || editor.saving} onClick={() => void editor.saveDraft()}>Save campaign</button>
<output data-testid="recipient-order">{JSON.stringify(entry.to)}</output>
<output data-testid="global-order">{JSON.stringify(recipients?.to)}</output>
<output data-testid="primary-address">{String(entry.email ?? "")}</output>
<output data-testid="recipient-dirty">{String(editor.dirty)}</output>
<output data-testid="recipient-error">{editor.localError || error}</output>
{dialog === "entry" && <RecipientAddressEditorDialog
entry={entry} index={0} locked={false} recipientsSection={recipients} entryDefaults={entries.defaults ?? {}}
onSave={(values, merges) => {
// Same owning helper as RecipientDataPage; no test copy of ordering or
// legacy primary-address/merge-field normalization.
editor.patch(["entries", "inline"], [entryWithAddressValues(entry, values, merges)]);
setDialog(null);
}} onClose={() => setDialog(null)} />}
{dialog === "header" && <HeaderAddressEditorDialog title="Global To addresses"
columns={[getAddressColumn("to")]} values={{ to: recipients.to } as HeaderAddressValues} locked={false}
onSave={(values) => { editor.patch(["recipients", "to"], values.to); setDialog(null); }} onClose={() => setDialog(null)} />}
</main>;
}
@@ -0,0 +1,23 @@
import CampaignReportPage from "../../../govoplan-campaign/webui/src/features/campaigns/CampaignReportPage";
import { useState } from "react";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import type { AuthInfo, PlatformWebModule } from "../src/types";
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }];
export default function CampaignReportScenario() {
const params = new URLSearchParams(window.location.search);
const [account, setAccount] = useState("reporter");
const read = ["campaigns:campaign:read", "campaigns:recipient:read", "campaigns:report:read"];
const auth: AuthInfo = { user: { id: account, account_id: `${account}-account`, email: "reporter@example.test" },
tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: params.has("read-only") ? read :
[...read, "campaigns:campaign:send", "campaigns:campaign:retry", "campaigns:campaign:queue", "campaigns:campaign:reconcile"],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
return <PlatformModulesProvider modules={modules}>
<PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
{params.has("switch-account") && <button type="button" onClick={() => setAccount("another-reporter")}>Switch fixture account</button>}
<CampaignReportPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="report-campaign" />
</PlatformLanguageProvider>
</PlatformModulesProvider>;
}
@@ -0,0 +1,20 @@
import ValidationDetails from "../../../govoplan-campaign/webui/src/features/campaigns/review/ValidationDetails";
import RepeatedFilesDetails from "../../../govoplan-campaign/webui/src/features/campaigns/review/RepeatedFilesDetails";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { useState } from "react";
const issues = Array.from({ length: 12 }, (_, index) => [
{ code: "missing_required_attachment", severity: "warning", path: `/entries/recipient-${index + 1}/attachments/0`, message: `Missing attachment for recipient ${index + 1}.` },
{ code: "missing_attachment_coverage", severity: "info", path: `/entries/recipient-${index + 1}`, message: `Policy excludes recipient ${index + 1} without attachments.` }
]).flat();
const findings = Array.from({ length: 12 }, (_, index) => ({ file_name: `repeated-file-${index + 1}.pdf`, file_fingerprint: `file-${index}`, use_count: 3, disposition: "allowed" }));
export default function CampaignReviewDetailsScenario() {
const [count, setCount] = useState(12);
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
<button onClick={() => setCount(2)}>Reduce fixture details</button>
<section aria-label="Validation details fixture"><ValidationDetails issues={issues.slice(0, count * 2)} /></section>
<section aria-label="Repeated files fixture"><RepeatedFilesDetails findings={findings.slice(0, count)} /></section>
</PlatformLanguageProvider>;
}
@@ -0,0 +1,24 @@
import ReviewSendPage from "../../../govoplan-campaign/webui/src/features/campaigns/ReviewSendPage";
import { useSearchParams } from "react-router";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import type { AuthInfo, PlatformWebModule } from "../src/types";
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
const auth: AuthInfo = { user: { id: "reviewer", account_id: "reviewer-account", email: "reviewer@example.test" },
tenant: { id: "tenant", name: "Fixture", slug: "fixture" }, scopes: ["campaigns:campaign:read", "campaigns:campaign:review", "campaigns:campaign:build"],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true };
const modules: PlatformWebModule[] = [{ id: "campaigns", label: "Campaign", version: "fixture" }];
export default function CampaignReviewScenario() {
const params = new URLSearchParams(window.location.search);
const [, setSearchParams] = useSearchParams();
const fixtureAuth = params.has("read-only") ? { ...auth, scopes: ["campaigns:campaign:read"] } : auth;
return <PlatformModulesProvider modules={modules}>
<PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
{params.has("race") && <button onClick={() => setSearchParams((current) => {
const next = new URLSearchParams(current); next.set("version", "replacement-version"); return next;
})}>Switch fixture version</button>}
<ReviewSendPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={fixtureAuth} campaignId="review-campaign" />
</PlatformLanguageProvider>
</PlatformModulesProvider>;
}
@@ -0,0 +1,44 @@
import { useCallback, useState } from "react";
import { Link } from "react-router";
import { ConcurrencyConflictProvider } from "../src/components/ConcurrencyConflictDialog";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { useCampaignDraftEditor } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignDraftEditor";
import { getCampaignVersion, type CampaignVersionDetail } from "../../../govoplan-campaign/webui/src/api/campaigns";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
const settings = { apiBaseUrl: "", accessToken: "", apiKey: "" };
const initialVersion: CampaignVersionDetail = {
id: "version-a", campaign_id: "campaign-a", version_number: 1, edit_revision: 1, strong_etag: '"version-a:1"',
current_flow: "manual", current_step: "template", workflow_state: "editing", is_complete: false,
raw_json: { campaign: { name: "Original" }, template: { subject: "Original subject", text: "" }, server: {} },
editor_state: {}, updated_at: "2026-09-07T10:00:00Z"
};
export default function CampaignSavingScenario() {
return <PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[generatedTranslations]}>
<ConcurrencyConflictProvider><SavingEditor /></ConcurrencyConflictProvider>
</PlatformLanguageProvider>;
}
function SavingEditor() {
const [version, setVersion] = useState(initialVersion);
const [error, setError] = useState("");
const [result, setResult] = useState("");
const reload = useCallback(async () => setVersion(await getCampaignVersion(settings, "campaign-a", "version-a")), []);
const editor = useCampaignDraftEditor({ settings, campaignId: "campaign-a", version, locked: false,
reload, setError, currentStep: "template", unsavedTitle: "Unsaved fixture changes", unsavedMessage: "Save or discard the draft?" });
const subject = (editor.displayDraft.template as Record<string, unknown>)?.subject ?? "";
return <main>
<label>Subject<input aria-label="Subject" value={String(subject)} onChange={(event) => editor.patch(["template", "subject"], event.target.value)} /></label>
<button disabled={editor.saving} onClick={() => void editor.saveDraft().then((saved) => setResult(String(saved)))}>Save draft</button>
<button disabled={editor.saving} onClick={() => { void editor.saveDraft(); void editor.saveDraft(); }}>Request save twice</button>
<button disabled={editor.saving} onClick={() => void editor.discardDraft()}>Discard draft</button>
<button onClick={() => setVersion({ ...initialVersion, raw_json: { ...initialVersion.raw_json, template: { subject: "Background refresh" } } })}>Background refresh</button>
<Link to="/?campaign-saving&elsewhere">Other route</Link>
<output data-testid="save-busy">{String(editor.saving)}</output>
<output data-testid="save-dirty">{String(editor.dirty)}</output>
<output data-testid="save-status">{editor.saveState}</output>
<output data-testid="save-error">{editor.localError || error}</output>
<output data-testid="save-result">{result}</output>
</main>;
}
@@ -0,0 +1,28 @@
// Exercise the owning Campaign hook, with network responses controlled by tests.
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import { useCampaignWorkspaceData } from "../../../govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignWorkspaceData";
export default function CampaignWorkspaceScenario() {
const [campaignId, setCampaignId] = useState("campaign-a");
const [accessToken, setAccessToken] = useState("token-a");
const [, setSearchParams] = useSearchParams();
const settings = useMemo(() => ({ apiBaseUrl: "", apiKey: "", accessToken }), [accessToken]);
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeVersions: true });
return <main>
<button onClick={() => void reload()}>Reload workspace</button>
<button onClick={() => void reload({ force: true })}>Force reload workspace</button>
<button onClick={() => setCampaignId("campaign-b")}>Switch campaign</button>
<button onClick={() => setAccessToken("token-b")}>Switch identity</button>
<button onClick={() => setSearchParams((current) => {
const next = new URLSearchParams(current);
next.set("version", "selected-version");
return next;
})}>Select version</button>
<output data-testid="workspace-campaign">{data.campaign?.id ?? "none"}</output>
<output data-testid="workspace-version">{data.currentVersion?.id ?? "none"}</output>
<output data-testid="workspace-revision">{data.currentVersion?.edit_revision ?? 0}</output>
<output data-testid="workspace-loading">{String(loading)}</output>
<output data-testid="workspace-error">{error}</output>
</main>;
}
+101
View File
@@ -1,6 +1,34 @@
import { useMemo, useState } from "react";
import { CalendarDays, FileText, Folder, GitBranch, Inbox, ListChecks, Mail, Search, ShieldCheck } from "lucide-react";
import { useLocation } from "react-router";
import DialogLayoutScenario from "./DialogLayoutScenario";
import DataGridLayoutScenario from "./DataGridLayoutScenario";
import NavigationLayoutScenario from "./NavigationLayoutScenario";
import ManagedArchiveScenario from "./ManagedArchiveScenario";
import FilesToolbarScenario from "./FilesToolbarScenario";
import CredentialReferencesScenario from "./CredentialReferencesScenario";
import FormControlLayoutScenario from "./FormControlLayoutScenario";
import CampaignWorkspaceScenario from "./CampaignWorkspaceScenario";
import CampaignReportScenario from "./CampaignReportScenario";
import ModuleLayoutScenario from "./ModuleLayoutScenario";
import HelpCenterScenario from "./HelpCenterScenario";
import NotificationFilterScenario from "./NotificationFilterScenario";
import MultiSelectFilterScenario from "./MultiSelectFilterScenario";
import SearchFiltersScenario from "./SearchFiltersScenario";
import AddressExplorerScenario from "./AddressExplorerScenario";
import MailFolderExplorerScenario from "./MailFolderExplorerScenario";
import MailToolbarScenario from "./MailToolbarScenario";
import CampaignDeliveryProgressScenario from "./CampaignDeliveryProgressScenario";
import CampaignSavingScenario from "./CampaignSavingScenario";
import CampaignMailSettingsScenario from "./CampaignMailSettingsScenario";
import CampaignAttachmentsScenario from "./CampaignAttachmentsScenario";
import CampaignRecipientOrderScenario from "./CampaignRecipientOrderScenario";
import CampaignReviewScenario from "./CampaignReviewScenario";
import CampaignBulkReviewScenario from "./CampaignBulkReviewScenario";
import CampaignReviewDetailsScenario from "./CampaignReviewDetailsScenario";
import CampaignDeliveryPolicyScenario from "./CampaignDeliveryPolicyScenario";
import MailCredentialPolicyScenario from "./MailCredentialPolicyScenario";
import type { NavigationPreferenceScope } from "../src/components/navigationPreferenceLayout";
import FormInstancePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormInstancePage";
import FormsRuntimePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormsRuntimePage";
import PublicFormPage from "../../../govoplan-forms-runtime/webui/src/features/forms/PublicFormPage";
@@ -28,6 +56,7 @@ import StatePanel from "../src/components/StatePanel";
import WorkspaceFrame from "../src/components/WorkspaceFrame";
import WorkspaceLayout from "../src/components/WorkspaceLayout";
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
import WysiwygEditor from "../src/components/WysiwygEditor";
import BreadcrumbBar from "../src/layout/BreadcrumbBar";
import HelpMenu from "../src/layout/HelpMenu";
import IconRail from "../src/layout/IconRail";
@@ -54,6 +83,49 @@ export default function ConformanceApp() {
const [editorDirty, setEditorDirty] = useState(true);
const [metricDrilldown, setMetricDrilldown] = useState("");
if (new URLSearchParams(location.search).has("credential-references")) return <CredentialReferencesScenario />;
if (new URLSearchParams(location.search).has("files-toolbar")) return <FilesToolbarScenario />;
if (new URLSearchParams(location.search).has("form-control-layout")) return <FormControlLayoutScenario />;
if (new URLSearchParams(location.search).has("campaign-workspace")) return <CampaignWorkspaceScenario />;
if (new URLSearchParams(location.search).has("campaign-report")) return <CampaignReportScenario />;
if (new URLSearchParams(location.search).has("module-layouts")) return <ModuleLayoutScenario />;
if (new URLSearchParams(location.search).has("help-center")) return <HelpCenterScenario />;
if (new URLSearchParams(location.search).has("notification-filter")) return <NotificationFilterScenario />;
if (new URLSearchParams(location.search).has("multi-select-filter")) return <MultiSelectFilterScenario />;
if (new URLSearchParams(location.search).has("search-filters")) return <SearchFiltersScenario />;
if (new URLSearchParams(location.search).has("address-explorer")) return <AddressExplorerScenario />;
if (new URLSearchParams(location.search).has("mail-folder-explorer")) return <MailFolderExplorerScenario />;
if (new URLSearchParams(location.search).has("mail-toolbar")) return <MailToolbarScenario />;
if (new URLSearchParams(location.search).has("campaign-delivery-progress")) return <CampaignDeliveryProgressScenario />;
if (new URLSearchParams(location.search).has("campaign-saving")) return <CampaignSavingScenario />;
if (new URLSearchParams(location.search).has("campaign-mail-settings")) return <CampaignMailSettingsScenario />;
if (new URLSearchParams(location.search).has("campaign-attachments")) return <CampaignAttachmentsScenario />;
if (new URLSearchParams(location.search).has("campaign-recipient-order")) return <CampaignRecipientOrderScenario />;
if (new URLSearchParams(location.search).has("campaign-review")) return <CampaignReviewScenario />;
if (new URLSearchParams(location.search).has("campaign-bulk-review")) return <CampaignBulkReviewScenario />;
if (new URLSearchParams(location.search).has("campaign-review-details")) return <CampaignReviewDetailsScenario />;
if (new URLSearchParams(location.search).has("campaign-delivery-policy")) return <CampaignDeliveryPolicyScenario />;
if (new URLSearchParams(location.search).has("mail-credential-policy")) return <MailCredentialPolicyScenario />;
if (new URLSearchParams(location.search).has("data-grid-layout")) return <DataGridLayoutScenario />;
if (new URLSearchParams(location.search).has("managed-archive")) {
const params = new URLSearchParams(location.search);
return <ManagedArchiveScenario language={params.get("language") ?? "en"} downloadAllowed={!params.has("no-download")} />;
}
if (new URLSearchParams(location.search).has("navigation-layout")) {
const params = new URLSearchParams(location.search);
return <NavigationLayoutScenario scope={(params.get("scope") ?? "user") as NavigationPreferenceScope} language={params.get("language") ?? "en"} disabled={params.has("disabled")} />;
}
if (new URLSearchParams(location.search).has("dialog-layout")) {
const params = new URLSearchParams(location.search);
return <DialogLayoutScenario templates={params.get("fixture") === "templates"} language={params.get("language") ?? "de"} />;
}
if (new URLSearchParams(location.search).has("wysiwyg-lifecycle")) {
return <WysiwygLifecycleScenario source={new URLSearchParams(location.search).get("mode") === "source"} />;
}
if (new URLSearchParams(location.search).has("product-navigation")) {
return <ProductNavigationScenario />;
}
@@ -200,6 +272,35 @@ export default function ConformanceApp() {
);
}
function WysiwygLifecycleScenario({ source }: { source: boolean }) {
const initialHtml = source
? '<table style="width: 100%"><tbody><tr><td>Legacy template</td></tr></tbody></table>'
: "<p>Legacy <i>template</i></p>";
const [value, setValue] = useState(initialHtml);
const [changeCount, setChangeCount] = useState(0);
const [disabled, setDisabled] = useState(false);
const [mounted, setMounted] = useState(true);
return <main className="conformance-root">
<h1>Rich-text lifecycle fixture</h1>
<Button onClick={() => setDisabled((current) => !current)}>Toggle read-only</Button>
<Button onClick={() => setMounted((current) => !current)}>Toggle editor mount</Button>
<Button onClick={() => setValue(initialHtml.replace("Legacy", "Reloaded"))}>Load another value</Button>
<output data-testid="wysiwyg-change-count">{changeCount}</output>
<pre data-testid="wysiwyg-controlled-value">{value}</pre>
{mounted && <WysiwygEditor
value={value}
disabled={disabled}
ariaLabel="Rich-text fixture content"
labels={{ visual: "Visual fixture mode", source: "Source fixture mode" }}
onChange={(nextValue) => {
setValue(nextValue);
setChangeCount((current) => current + 1);
}}
/>}
</main>;
}
function ProductNavigationScenario() {
const projection = useMemo(
() => projectProductNavigation(
@@ -0,0 +1,18 @@
// Exercise the real shared credential editor with Mail's optional capability.
// API fixtures supply public metadata only; this never loads or saves secrets.
import CredentialEnvelopeManager from "../src/components/CredentialEnvelopeManager";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import { mailCredentialReferenceSelectors } from "../../../govoplan-mail/webui/src/features/mail/mailReferenceProviders";
import type { ApiSettings, PlatformWebModule } from "../src/types";
const settings: ApiSettings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
const modules: PlatformWebModule[] = [{
id: "mail", label: "Mail", version: "1",
uiCapabilities: { "core.credentialReferenceSelectors": mailCredentialReferenceSelectors }
}];
export default function CredentialReferencesScenario() {
return <PlatformModulesProvider modules={modules}>
<CredentialEnvelopeManager settings={settings} scopeType="tenant" canWrite />
</PlatformModulesProvider>;
}
@@ -0,0 +1,66 @@
import { useMemo, useState } from "react";
import { ArrowDown, ArrowUp, Eye, Plus, Trash2 } from "lucide-react";
import Button from "../src/components/Button";
import DataGrid, { DataGridEmptyAction, type DataGridColumn, type DataGridResizeBehavior } from "../src/components/table/DataGrid";
import TableActionGroup from "../src/components/table/TableActionGroup";
type Row = { id: string; name: string; detail: string };
const rows: Row[] = [{ id: "alpha", name: "Alpha", detail: "Long configured field value ".repeat(20) }];
/** Genuine shared grid: intentionally undersized legacy action preference. */
export default function DataGridLayoutScenario() {
const mode = new URLSearchParams(window.location.search).get("mode") ?? "cover";
const behavior: DataGridResizeBehavior = mode === "free" || mode === "constrained" ? mode : "cover";
const composite = mode === "composite";
const [width, setWidth] = useState(900);
const [mounted, setMounted] = useState(true);
const [empty, setEmpty] = useState(false);
const [extraAction, setExtraAction] = useState(false);
const [clicked, setClicked] = useState("");
const columns = useMemo<DataGridColumn<Row>[]>(() => [
{ id: "name", header: "Name", width: 260, minWidth: 180, resizable: true, value: (row) => row.name },
{ id: "detail", header: "Details", width: 360, minWidth: 220, resizable: true, value: (row) => row.detail },
{
id: "actions", header: "Actions", width: mode === "oversized" ? 500 : 72,
minWidth: mode === "oversized" ? 500 : undefined,
columnType: composite ? "actions" : undefined, sticky: behavior === "free" ? undefined : "end",
render: (row) => {
const group = <TableActionGroup actions={[
{ id: "inspect", label: `Inspect ${row.name}`, icon: <Eye />, onClick: () => setClicked(`Inspect ${row.name}`) },
{ id: "up", label: "Move up", icon: <ArrowUp />, disabledReason: "Already first", onClick: () => undefined },
{ id: "down", label: "Move down", icon: <ArrowDown />, onClick: () => setClicked("Move down") },
{ id: "remove", label: "Remove row", icon: <Trash2 />, onClick: () => setClicked("Remove row") },
extraAction && { id: "add", label: "Add below", icon: <Plus />, onClick: () => setClicked("Add below") }
]} />;
return composite ? <div role="group" aria-label="Composite controls" style={{ display: "flex", gap: 4, width: "100%" }}>
<Button className="table-action-button" aria-label="Extra control" onClick={() => setClicked("Extra control")}><Plus /></Button>
{group}
</div> : group;
}
}
], [behavior, composite, extraAction, mode]);
return (
<main style={{ padding: 16, minWidth: 0 }}>
<h1>Data grid layout conformance</h1>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
<Button onClick={() => setWidth(320)}>Narrow grid</Button>
<Button onClick={() => setWidth(900)}>Wide grid</Button>
<Button onClick={() => setMounted((value) => !value)}>Toggle grid mount</Button>
<Button onClick={() => setEmpty((value) => !value)}>Toggle empty rows</Button>
<Button onClick={() => setExtraAction((value) => !value)}>Toggle extra action</Button>
</div>
<output data-testid="clicked-action">{clicked}</output>
<div data-testid="grid-container" style={{ display: "grid", gridTemplateColumns: "minmax(0, 1fr)", width, maxWidth: "100%", minWidth: 0 }}>
{mounted && <DataGrid
id={`layout-conformance-${behavior}`}
rows={empty ? [] : rows}
columns={columns}
getRowKey={(row) => row.id}
initialFit={behavior === "free" ? "content" : "container"}
resizeBehavior={behavior}
emptyAction={<DataGridEmptyAction onAdd={() => setClicked("Add first row")} />}
/>}
</div>
</main>
);
}
@@ -0,0 +1,48 @@
import { useState } from "react";
import TemplatesPage from "../../../govoplan-templates/webui/src/features/templates/TemplatesPage";
import { generatedTranslations as templateTranslations } from "../../../govoplan-templates/webui/src/i18n/generatedTranslations";
import "../../../govoplan-templates/webui/src/styles/templates.css";
import Button from "../src/components/Button";
import Dialog from "../src/components/Dialog";
import { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
import { FormGrid } from "../src/components/ContentGrid";
import FormField from "../src/components/FormField";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { AuthInfo } from "../src/types";
const fixtureAuth: AuthInfo = {
user: { id: "fixture-user", account_id: "fixture-account", email: "fixture@example.test" },
tenant: { id: "fixture-tenant", name: "Fixture", slug: "fixture" },
scopes: ["templates:template:read", "templates:template:write"],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
};
export default function DialogLayoutScenario({ templates = false, language = "de" }: { templates?: boolean; language?: string }) {
const [open, setOpen] = useState(true);
if (templates) return <PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[templateTranslations]}>
<TemplatesPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={fixtureAuth} />
</PlatformLanguageProvider>;
return <main className="conformance-root">
<Button onClick={() => setOpen(true)}>Open layout fixture</Button>
<Dialog
open={open}
title={`LangeDialogüberschriftOhneTrennzeichen${"Zusatz".repeat(12)}`}
description={`Referenz:${"abcdef0123456789".repeat(12)}`}
onClose={() => setOpen(false)}
footer={<><Button>Abbrechen</Button><Button variant="primary">Änderungen speichern</Button></>}
>
<DialogForm onSubmit={(event) => event.preventDefault()}>
<DialogSection>
<FormGrid columns={2} collapseAt="standard">
<FormField label={`Feldname${"Lang".repeat(20)}`}><input defaultValue={"Langer Wert ".repeat(20)} /></FormField>
<FormField label="Auswahl"><select defaultValue="long"><option value="long">{"Lange Auswahlliste ".repeat(20)}</option></select></FormField>
</FormGrid>
</DialogSection>
<FormField label="Mehrzeiliger Inhalt"><textarea defaultValue={"Nachweistext ".repeat(60)} rows={5} /></FormField>
<div style={{ maxWidth: "100%", minWidth: 0, overflowX: "auto" }} data-testid="dialog-local-scroll">
<table style={{ width: 1400 }}><tbody><tr><td>Absichtlich breite Tabelle</td><td>Letzte Tabellenspalte</td></tr></tbody></table>
</div>
</DialogForm>
</Dialog>
</main>;
}
@@ -0,0 +1,28 @@
// Real owning Files module with fixture-only authentication; tests intercept every API call.
import { useLocation } from "react-router";
import { useState } from "react";
import FilesPage from "../../../govoplan-files/webui/src/features/files/FilesPage";
import { generatedTranslations } from "../../../govoplan-files/webui/src/i18n/generatedTranslations";
import "../../../govoplan-files/webui/src/styles/file-manager.css";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { AuthInfo } from "../src/types";
const fullAuth: AuthInfo = {
user: { id: "fixture-user", account_id: "fixture-account", email: "fixture@example.test" },
tenant: { id: "fixture-tenant", name: "Fixture", slug: "fixture" },
scopes: ["files:file:read", "files:file:download", "files:file:upload", "files:file:organize", "files:file:delete", "files:file:share", "access:role:read"],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
};
const readOnlyAuth = { ...fullAuth, scopes: ["files:file:read"] };
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
export default function FilesToolbarScenario() {
const parameters = new URLSearchParams(useLocation().search);
const [nextSession, setNextSession] = useState(false);
const auth = parameters.has("read-only") ? readOnlyAuth : fullAuth;
return <PlatformLanguageProvider preferredLanguageCode={parameters.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
{parameters.has("session-switch") && <button type="button" onClick={() => setNextSession(true)}>Switch fixture session</button>}
<FilesPage settings={nextSession ? { ...settings, accessToken: "fixture-next-session" } : settings}
auth={nextSession ? { ...auth, user: { ...auth.user, id: "fixture-next-user" } } : auth} />
</PlatformLanguageProvider>;
}
@@ -0,0 +1,52 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import { FormGrid, FormLayout, GridItem } from "../src/components/ContentGrid";
import FormField from "../src/components/FormField";
import PasswordField from "../src/components/PasswordField";
import ToggleSwitch from "../src/components/ToggleSwitch";
import TableActionGroup from "../src/components/table/TableActionGroup";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { AttachmentRulesDataGrid } from "../../../govoplan-campaign/webui/src/features/campaigns/components/AttachmentRulesOverlay";
import type { AttachmentRule } from "../../../govoplan-campaign/webui/src/features/campaigns/utils/attachments";
import { generatedTranslations } from "../../../govoplan-campaign/webui/src/i18n/generatedTranslations";
import "../../../govoplan-campaign/webui/src/styles/campaign-workspace.css";
/** Real shared controls and Campaign attachment editor; no API writes. */
export default function FormControlLayoutScenario() {
const [secret, setSecret] = useState("");
const [checked, setChecked] = useState(false);
const [rules, setRules] = useState<AttachmentRule[]>([]);
return <PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[generatedTranslations]}>
<main style={{ padding: 16, minWidth: 0 }}>
<h1>Shared form-control layout</h1>
<FormGrid columns={2} data-testid="mixed-form-grid">
<FormField label="Password" help="Leave blank to retain the configured secret.">
<PasswordField value={secret} onValueChange={setSecret} generator />
</FormField>
<ToggleSwitch label="Remove configured secret" checked={checked} onChange={setChecked} disabled={Boolean(secret)} />
<FormField label={"Long translated field label with additional context ".repeat(4)}>
<input aria-label="Long label field" />
</FormField>
<FormField label="Short label"><input aria-label="Short label field" /></FormField>
<FormField label="Wrapped field"><input aria-label="Wrapped field" /></FormField>
<GridItem><ToggleSwitch label="Wrapped switch" checked={checked} onChange={setChecked} /></GridItem>
</FormGrid>
<FormLayout columns={2} data-testid="mixed-form-layout" onSubmit={(event) => event.preventDefault()}>
<FormField label="Other setting"><input aria-label="Other setting" /></FormField>
<ToggleSwitch label="Other switch" checked={checked} onChange={setChecked} />
</FormLayout>
<h2>Global attachments</h2>
<div data-testid="global-attachments">
<AttachmentRulesDataGrid id="layout-global-attachments" rules={rules}
settings={{ apiBaseUrl: "", accessToken: "", apiKey: "" }} campaignId="fixture-campaign"
basePaths={[{ id: "fixture-source", name: "Fixture source", path: "fixtures" }]}
onChange={setRules} />
</div>
<h2>Compact actions resist broad consumer styles</h2>
<style>{".fixture-broad-button-style .btn { width: 100%; }"}</style>
<div className="fixture-broad-button-style" data-testid="compact-action">
<TableActionGroup actions={[{ id: "add", label: "Add fixture item", icon: <Plus />, onClick: () => setChecked(true) }]} />
</div>
</main>
</PlatformLanguageProvider>;
}
+9
View File
@@ -0,0 +1,9 @@
import DocsPage from "../../../govoplan-docs/webui/src/features/docs/DocsPage";
import { generatedTranslations } from "../../../govoplan-docs/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
export default function HelpCenterScenario() {
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
<DocsPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} />
</PlatformLanguageProvider>;
}
@@ -0,0 +1,16 @@
import { MailProfilePolicyEditor } from "../../../govoplan-mail/webui/src/features/mail/MailProfilePolicyEditor";
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { MailProfileScope } from "../src/types";
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
export default function MailCredentialPolicyScenario() {
const params = new URLSearchParams(window.location.search);
const scope = (params.get("scope") ?? "tenant") as MailProfileScope;
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
<MailProfilePolicyEditor settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }}
scopeType={scope} scopeId={scope === "system" || scope === "tenant" ? null : "fixture-target"}
profiles={[]} canWrite={!params.has("read-only")} locked={params.has("locked")}
onSaved={params.has("refresh-failure") ? async () => { throw new Error("Synthetic dependent refresh failed"); } : undefined} />
</PlatformLanguageProvider>;
}
@@ -0,0 +1,19 @@
import MailboxPage from "../../../govoplan-mail/webui/src/features/mail/MailboxPage";
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { AuthInfo } from "../src/types";
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
const auth: AuthInfo = {
user: { id: "mail-tree-user", account_id: "mail-tree-account", email: "mail-tree@example.test" },
tenant: { id: "mail-tree-tenant", name: "Mail tree fixture", slug: "mail-tree" },
scopes: ["mail:mailbox:read", "mail:profile:read", "mail:profile:use"],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
};
export default function MailFolderExplorerScenario() {
return <PlatformLanguageProvider preferredLanguageCode="en" moduleTranslations={[generatedTranslations]}>
<MailboxPage settings={settings} auth={auth} />
</PlatformLanguageProvider>;
}
+23
View File
@@ -0,0 +1,23 @@
import { useState } from "react";
import MailboxPage from "../../../govoplan-mail/webui/src/features/mail/MailboxPage";
import { generatedTranslations } from "../../../govoplan-mail/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { AuthInfo } from "../src/types";
import "../../../govoplan-mail/webui/src/styles/mail-profiles.css";
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
export default function MailToolbarScenario() {
const parameters = new URLSearchParams(window.location.search);
const [tenant, setTenant] = useState("first");
const auth: AuthInfo = {
user: { id: "mail-toolbar-user", account_id: "mail-toolbar-account", email: "mail-toolbar@example.test" },
tenant: { id: `mail-toolbar-${tenant}`, name: "Mailbox toolbar fixture", slug: tenant },
scopes: ["mail:mailbox:read", "mail:profile:read", "mail:profile:use", ...(parameters.has("bounce-allowed") ? ["mail:bounce:read"] : [])],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
};
return <PlatformLanguageProvider preferredLanguageCode={parameters.get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
{parameters.has("switch-tenant") && <button type="button" onClick={() => setTenant("second")}>Switch fixture tenant</button>}
<MailboxPage settings={settings} auth={auth} />
</PlatformLanguageProvider>;
}
@@ -0,0 +1,22 @@
// Conformance-only composition: render the owning module, never a test copy.
import FilesPage from "../../../govoplan-files/webui/src/features/files/FilesPage";
import { generatedTranslations } from "../../../govoplan-files/webui/src/i18n/generatedTranslations";
import "../../../govoplan-files/webui/src/styles/file-manager.css";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { AuthInfo } from "../src/types";
const fixtureAuth: AuthInfo = {
user: { id: "fixture-user", account_id: "fixture-account", email: "fixture@example.test" },
tenant: { id: "fixture-tenant", name: "Fixture", slug: "fixture" },
scopes: ["files:file:read", "files:file:download", "files:file:upload"],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
};
export default function ManagedArchiveScenario({ language = "en", downloadAllowed = true }: { language?: string; downloadAllowed?: boolean }) {
const auth = downloadAllowed ? fixtureAuth : {
...fixtureAuth, scopes: fixtureAuth.scopes.filter((scope) => scope !== "files:file:download")
};
return <PlatformLanguageProvider preferredLanguageCode={language} moduleTranslations={[generatedTranslations]}>
<FilesPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} />
</PlatformLanguageProvider>;
}
@@ -0,0 +1,49 @@
import CommitteePage from "../../../govoplan-committee/webui/src/features/committee/CommitteePage";
import VotingPage from "../../../govoplan-voting/webui/src/features/voting/VotingPage";
import SchedulingPage from "../../../govoplan-scheduling/webui/src/features/scheduling/SchedulingPage";
import RiskCompliancePage from "../../../govoplan-risk-compliance/webui/src/features/riskCompliance/RiskCompliancePage";
import OrganizationsPage from "../../../govoplan-organizations/webui/src/features/organizations/OrganizationsPage";
import TypedRelationshipsPanel from "../../../govoplan-idm/webui/src/features/TypedRelationshipsPanel";
import { generatedTranslations as committeeTranslations } from "../../../govoplan-committee/webui/src/i18n/generatedTranslations";
import { generatedTranslations as votingTranslations } from "../../../govoplan-voting/webui/src/i18n/generatedTranslations";
import { generatedTranslations as schedulingTranslations } from "../../../govoplan-scheduling/webui/src/i18n/generatedTranslations";
import { generatedTranslations as riskTranslations } from "../../../govoplan-risk-compliance/webui/src/i18n/generatedTranslations";
import { generatedTranslations as organizationTranslations } from "../../../govoplan-organizations/webui/src/i18n/generatedTranslations";
import { generatedTranslations as idmTranslations } from "../../../govoplan-idm/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { AuthInfo } from "../src/types";
import "../../../govoplan-committee/webui/src/styles/committee.css";
import "../../../govoplan-voting/webui/src/styles/voting.css";
import "../../../govoplan-scheduling/webui/src/styles/scheduling.css";
import "../../../govoplan-risk-compliance/webui/src/styles/risk-compliance.css";
import "../../../govoplan-organizations/webui/src/styles/organizations.css";
import "../../../govoplan-idm/webui/src/styles/idm.css";
const settings = { apiBaseUrl: "", apiKey: "", accessToken: "" };
export default function ModuleLayoutScenario() {
const params = new URLSearchParams(window.location.search);
const auth: AuthInfo = {
user: { id: "layout-user", account_id: "layout-account", email: "layout@example.test" },
tenant: { id: "layout-tenant", name: "Layout fixture", slug: "layout" },
scopes: params.has("read-only") ? ["idm:relationship:read"] : [
"committee:workspace:write", "voting:ballot:manage", "scheduling:schedule:write",
"risk_compliance:sanctions:review", "risk_compliance:sanctions:screen", "risk_compliance:workspace:read",
"organizations:model:read", "organizations:model:write", "organizations:unit:write", "organizations:function:write",
"idm:relationship:read", "idm:relationship:write"
],
roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
};
const context = { settings, auth };
const module = params.get("module-layouts");
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[committeeTranslations, votingTranslations, schedulingTranslations, riskTranslations, organizationTranslations, idmTranslations]}>
<div data-testid="module-layout-fixture" style={{ height: "100vh", minWidth: 0 }}>
{module === "committee" && <CommitteePage {...context} />}
{module === "voting" && <VotingPage {...context} />}
{module === "scheduling" && <SchedulingPage {...context} />}
{module === "risk" && <RiskCompliancePage {...context} />}
{module === "organizations" && <OrganizationsPage {...context} />}
{module === "idm" && <TypedRelationshipsPanel {...context} />}
</div>
</PlatformLanguageProvider>;
}
@@ -0,0 +1,34 @@
import { useState } from "react";
import Button from "../src/components/Button";
import Dialog from "../src/components/Dialog";
import MultiSelectFilter from "../src/components/MultiSelectFilter";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
const options = [
{ value: "first", label: "First tag" },
{ value: "long", label: "X".repeat(80) },
{ value: "last", label: "Last tag" }
];
export default function MultiSelectFilterScenario() {
const [open, setOpen] = useState(false);
const [selection, setSelection] = useState<string[] | null>(null);
const filter = <MultiSelectFilter label="Fixture tags" options={options} value={selection} onChange={setSelection} />;
return <PlatformLanguageProvider preferredLanguageCode="en">
<main className="content-pad">
<Button onClick={() => setOpen(true)}>Open filter dialog</Button>
<Button>Outside action</Button>
{filter}
<output aria-label="Selected tags">{selection === null ? "all" : JSON.stringify(selection)}</output>
<Dialog open={open} title="Filter owner" onClose={() => setOpen(false)} size="small"
panelStyle={{ transform: "translateZ(0)" }}
footer={<Button onClick={() => setOpen(false)}>Done</Button>}
>
<div style={{ overflow: "hidden", maxHeight: 100 }}>
<p>The filter escapes this clipped and transformed dialog.</p>
{filter}
</div>
</Dialog>
</main>
</PlatformLanguageProvider>;
}
@@ -0,0 +1,33 @@
import { useState } from "react";
import { Folder, LayoutDashboard, Mail } from "lucide-react";
import NavigationPreferenceEditor from "../src/components/NavigationPreferenceEditor";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import IconRail from "../src/layout/IconRail";
import type { NavigationPreferences, PlatformNavItem, ProductAreaContribution } from "../src/types";
import type { NavigationPreferenceScope } from "../src/components/navigationPreferenceLayout";
const items: PlatformNavItem[] = [
{ to: "/dashboard", label: "Dashboard", surfaceId: "dashboard", navigationId: "dashboard", icon: LayoutDashboard, navigationLocked: true, navigationLayers: { module: { order: 0, visible: true, locked: false }, system: { order: 0, visible: true, locked: true }, tenant: { order: 0, visible: true, locked: true } } },
{ to: "/files", label: "Files", surfaceId: "files", navigationId: "files", icon: Folder, order: 1 },
{ to: "/mail", label: "Mail", surfaceId: "mail", navigationId: "mail", icon: Mail, order: 2 }
];
const areas: ProductAreaContribution[] = [
{ id: "content", moduleId: "files", label: "Documents", iconName: "files", surfaceIds: ["files"], order: 1 },
{ id: "communication", moduleId: "mail", label: "Communication", iconName: "mail", surfaceIds: ["mail"], order: 2 }
];
export default function NavigationLayoutScenario({ scope = "user", disabled = false, language = "en" }: { scope?: NavigationPreferenceScope; disabled?: boolean; language?: string }) {
const [value, setValue] = useState<NavigationPreferences | null>(() => new URLSearchParams(window.location.search).has("unavailable") ? {
contract_version: "1", order: ["dashboard", "optional.navigation.absent", "files", "mail"], hidden: [], separators: []
} : null);
return <PlatformLanguageProvider preferredLanguageCode={language}>
<div style={{ display: "grid", gridTemplateColumns: "auto minmax(0, 1fr)", minWidth: 0 }}>
<IconRail navItems={items} productAreas={areas} presentation={{ navigation: value }} />
<main style={{ minWidth: 0, padding: 16 }}>
<h1>Navigation editor fixture</h1>
<NavigationPreferenceEditor items={items} productAreas={areas} value={value} onChange={setValue} scope={scope} disabled={disabled} />
<output data-testid="navigation-draft" style={{ display: "none" }}>{JSON.stringify(value)}</output>
</main>
</div>
</PlatformLanguageProvider>;
}
@@ -0,0 +1,28 @@
import NotificationCenterPage from "../../../govoplan-notifications/webui/src/features/notifications/NotificationCenterPage";
import { generatedTranslations } from "../../../govoplan-notifications/webui/src/i18n/generatedTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import type { AuthInfo } from "../src/types";
import { useEffect, useState } from "react";
import "../../../govoplan-notifications/webui/src/styles/notifications.css";
const auth: AuthInfo = {
user: { id: "filter-user", account_id: "filter-account", email: "filter@example.test" },
tenant: { id: "filter-tenant", name: "Filter fixture", slug: "filter" },
scopes: ["notifications:notification:read"], roles: [], groups: [],
profile_loaded: true, roles_loaded: true, groups_loaded: true,
};
export default function NotificationFilterScenario() {
const params = new URLSearchParams(window.location.search);
const [account, setAccount] = useState("filter-user");
useEffect(() => {
const changeAccount = () => setAccount("other-user");
window.addEventListener("conformance-notification-account", changeAccount);
return () => window.removeEventListener("conformance-notification-account", changeAccount);
}, []);
const scopedAuth = { ...auth, user: { ...auth.user, id: account },
scopes: params.has("write") ? [...auth.scopes, "notifications:notification:write", "notifications:delivery:dispatch"] : auth.scopes };
return <PlatformLanguageProvider preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? "en"} moduleTranslations={[generatedTranslations]}>
<NotificationCenterPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: account }} auth={scopedAuth} />
</PlatformLanguageProvider>;
}
+91 -8
View File
@@ -1,11 +1,85 @@
// Narrow facade used only by the conformance build. It lets optional modules
// exercise their real task surfaces without pulling the composed application's
// generated module catalogue into this isolated test bundle.
export { apiFetch, apiPath } from "../src/api/client";
export { ApiError, apiDownload, apiFetch, apiGetList, apiPath, apiPost, apiPostJson, apiQuery, apiUrl, authHeaders, csrfToken } from "../src/api/client";
export { fetchAuthGroups } from "../src/api/auth";
export { default as FormSection } from "../src/components/FormSection";
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "../src/api/mailContracts";
export type * from "../src/api/mailContracts";
export type * from "../src/types";
export { default as FieldLabel } from "../src/components/help/FieldLabel";
export { default as PasswordField } from "../src/components/PasswordField";
export { default as ResourceAccessExplanation } from "../src/components/ResourceAccessExplanation";
export { default as ExplorerTree } from "../src/components/ExplorerTree";
export type { ExplorerTreeNodeContext } from "../src/components/ExplorerTree";
export { default as DataGrid, DataGridEmptyAction, DataGridRowActions, DataGridPaginationBar } from "../src/components/table/DataGrid";
export type { DataGridColumn, DataGridQueryState, DataGridListOption } from "../src/components/table/DataGrid";
export { default as TableActionGroup } from "../src/components/table/TableActionGroup";
export { revisionConflictFromError, threeWayMerge } from "../src/api/concurrency";
export { useConcurrencyConflictResolver } from "../src/components/ConcurrencyConflictDialog";
export { useRegisterUnsavedChanges, UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard";
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "../src/components/UnsavedChangesGuard";
export { useDeltaWatermarks } from "../src/utils/deltaHooks";
export { default as ReferenceSelect } from "../src/components/ReferenceSelect";
export { unavailableReferenceOption } from "../src/components/ReferenceSelect";
export { filterSearchableSelectOptions } from "../src/components/SearchableSelect";
export type { ReferenceOptionProvider, ReferenceOption, CredentialReferenceSelectorContext, CredentialReferenceSelectorsUiCapability } from "../src/components/ReferenceSelect";
export { apiReferenceOptionProvider } from "../src/platform/referenceProviders";
export { fetchResourceAccessExplanation, fetchResourceAccessExplanationSubjects } from "../src/api/resourceAccess";
export type { AccessDecisionProvenanceItem, ResourceAccessExplanationUser, ResourceAccessExplanationResponse, ResourceAccessExplanationSubjectsResponse } from "../src/api/resourceAccess";
export { default as ActionBlockerHint } from "../src/components/ActionBlockerHint";
export type { ActionBlockerReason } from "../src/components/ActionBlockerHint";
export { default as MessageDisplayPanel } from "../src/components/MessageDisplayPanel";
export type { MessageDisplayAttachment } from "../src/components/MessageDisplayPanel";
export { default as GuidedReviewList } from "../src/components/GuidedReviewList";
export { default as InlineHelp } from "../src/components/help/InlineHelp";
export { default as ActionToolbar } from "../src/components/ActionToolbar";
export { ToolbarGroup } from "../src/components/ActionToolbar";
export { default as MultiSelectFilter } from "../src/components/MultiSelectFilter";
export { default as CountBadge } from "../src/components/CountBadge";
export { default as Button } from "../src/components/Button";
export { default as Card } from "../src/components/Card";
export { default as MetricGrid } from "../src/components/MetricGrid";
export { default as MetricCard } from "../src/components/MetricCard";
export { default as PageActionBar } from "../src/components/PageActionBar";
export { default as PageLayout } from "../src/components/PageLayout";
export { default as PageTitle } from "../src/components/PageTitle";
export { default as AdminIconButton } from "../src/components/admin/AdminIconButton";
export { default as ModuleSubnav } from "../src/layout/ModuleSubnav";
export type { ModuleSubnavGroup } from "../src/layout/ModuleSubnav";
export { default as DateTimeField } from "../src/components/DateTimeField";
export { default as PeoplePicker } from "../src/components/people/PeoplePicker";
export type { PeoplePickerItem, PeoplePickerSearch, PeoplePickerSearchGroup } from "../src/components/people/peoplePickerTypes";
export type { FormatDateTimeOptions } from "../src/utils/datetime";
export { default as SearchableSelect } from "../src/components/SearchableSelect";
export type { SearchableSelectOption } from "../src/components/SearchableSelect";
export { FormLayout } from "../src/components/ContentGrid";
export { apiPatchJson, isApiError } from "../src/api/client";
export { hasAnyScope } from "../src/utils/permissions";
export { useEffectiveView, useViewSurfaces } from "../src/platform/ViewContext";
export { isViewSurfaceVisible } from "../src/platform/views";
export { MailServerFolderLookupResultView } from "../src/components/mail/MailServerSettingsPanel";
export type { MailServerFolderLookupResult } from "../src/components/mail/MailServerSettingsPanel";
export { default as AdminSelectionList } from "../src/components/admin/AdminSelectionList";
export { default as AdminPageLayout } from "../src/components/admin/AdminPageLayout";
export { adminErrorMessage } from "../src/components/admin/adminUtils";
export { default as ConnectionTree } from "../src/components/ConnectionTree";
export type { ConnectionTreeColumn } from "../src/components/ConnectionTree";
export { default as StageRail } from "../src/components/StageRail";
export type { StageRailTone } from "../src/components/StageRail";
export { default as PolicyLockedHint } from "../src/components/PolicyLockedHint";
export { default as PolicyPathHelp, normalizePolicySourcePathItems } from "../src/components/PolicyPathHelp";
export type { NormalizedPolicySourcePathItem } from "../src/components/PolicyPathHelp";
export { default as PolicySourcePath } from "../src/components/PolicySourcePath";
export type { PolicySourcePathItem } from "../src/components/PolicySourcePath";
export { PolicyRow, PolicyTable } from "../src/components/PolicyTable";
export { default as MailServerSettingsPanel, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, normalizeMailImapFolderMappings, normalizeMailServerSecurity } from "../src/components/mail/MailServerSettingsPanel";
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerImapSettings, MailServerSmtpSettings } from "../src/components/mail/MailServerSettingsPanel";
export { mergeDeltaRows } from "../src/utils/delta";
export { ReferenceMultiSelect, customReferenceOption, staticReferenceOptionProvider } from "../src/components/ReferenceSelect";
export { platformModuleReferenceProvider } from "../src/platform/referenceProviders";
export { default as ConfirmDialog } from "../src/components/ConfirmDialog";
export { default as ContentSection } from "../src/components/ContentSection";
export { default as DescriptionList, DescriptionItem } from "../src/components/DescriptionList";
export { default as Dialog } from "../src/components/Dialog";
export { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
@@ -13,12 +87,14 @@ export { default as DismissibleAlert } from "../src/components/DismissibleAlert"
export { default as DocumentationHelpLink } from "../src/components/help/DocumentationHelpLink";
export type { DocumentationHelpReference } from "../src/components/help/documentationHelp";
export { default as FileDropZone } from "../src/components/FileDropZone";
export { default as FilterBar } from "../src/components/FilterBar";
export { default as FormField } from "../src/components/FormField";
export { FormGrid } from "../src/components/ContentGrid";
export { FormGrid, default as ContentGrid } from "../src/components/ContentGrid";
export { default as IconButton } from "../src/components/IconButton";
export { default as LoadingFrame } from "../src/components/LoadingFrame";
export { default as LoadingIndicator } from "../src/components/LoadingIndicator";
export { default as PageScrollViewport } from "../src/components/PageScrollViewport";
export { default as SegmentedControl } from "../src/components/SegmentedControl";
export {
default as SelectionList,
SelectionListItem,
@@ -29,29 +105,36 @@ export { default as StatusBadge } from "../src/components/StatusBadge";
export { default as ToggleSwitch } from "../src/components/ToggleSwitch";
export {
useGuardedNavigate,
useUnsavedChanges,
useUnsavedDraftGuard
} from "../src/components/UnsavedChangesGuard";
export {
i18nMessage,
usePlatformLanguage
} from "../src/i18n/LanguageContext";
export { usePlatformModuleInstalled } from "../src/platform/ModuleContext";
export { usePlatformModuleInstalled, usePlatformUiCapability, usePlatformUiCapabilities, usePlatformModules } from "../src/platform/ModuleContext";
export {
dispatchQuickAccessResult,
quickAccessLaunchState
} from "../src/platform/launchContext";
export { hasScope } from "../src/utils/permissions";
export { formatDateTime } from "../src/utils/datetime";
export { insertAfter, moveArrayItem } from "../src/utils/arrayOrder";
export { addressesFromValue, dedupeAddresses, parseMailboxAddressText } from "../src/utils/emailAddresses";
export type { MailboxAddress } from "../src/utils/emailAddresses";
export { default as WorkspaceActionBar } from "../src/components/WorkspaceActionBar";
export { default as WorkspaceFrame } from "../src/components/WorkspaceFrame";
export { default as WorkspaceLayout } from "../src/components/WorkspaceLayout";
export type {
ApiSettings,
GlobalSearchProps,
SearchContextContribution,
SearchContextsUiCapability,
DeltaDeletedItem,
FilesManagedFileLinkTarget,
AuthInfo,
PlatformRouteContext,
PlatformTranslations,
QuickAccessRailProps,
QuickAccessToolsUiCapability
} from "../src/types";
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
void capabilityName;
return [];
}
@@ -0,0 +1,39 @@
import SearchPage from "../../../govoplan-search/webui/src/features/search/SearchPage";
import GlobalSearch from "../../../govoplan-search/webui/src/components/GlobalSearch";
import { searchFilterTranslations } from "../../../govoplan-search/webui/src/i18n/searchFilterTranslations";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import type { AuthInfo, PlatformWebModule } from "../src/types";
import { useEffect, useMemo, useState } from "react";
import "../../../govoplan-search/webui/src/styles/search.css";
const auth: AuthInfo = {
user: { id: "search-user", account_id: "search-account", email: "search@example.test" },
tenant: { id: "search-tenant", name: "Search fixture", slug: "search" },
scopes: ["search:result:read"], roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true,
};
const modules: PlatformWebModule[] = [
{ id: "files", label: "Files", version: "1", uiCapabilities: { "search.contexts": { contexts: [{ id: "files.current", moduleId: "files", label: "Current files", pathPrefixes: ["/files"], resourceTypes: ["file"] }] } } },
{ id: "mail", label: "Mail", version: "1" },
];
export default function SearchFiltersScenario() {
const params = new URLSearchParams(window.location.search);
const [account, setAccount] = useState("search-user");
const accessToken = params.has("same-token") ? "search-user" : account;
const settings = useMemo(() => ({ apiBaseUrl: "", apiKey: "", accessToken }), [accessToken]);
const scopedAuth = useMemo(() => ({ ...auth,
user: { ...auth.user, id: account, account_id: `${account}-account` },
tenant: { ...auth.tenant, id: `${account}-tenant` },
}), [account]);
useEffect(() => {
const changeAccount = () => setAccount("other-user");
window.addEventListener("conformance-search-account", changeAccount);
return () => window.removeEventListener("conformance-search-account", changeAccount);
}, []);
return <PlatformLanguageProvider preferredLanguageCode={params.get("language") ?? "en"} moduleTranslations={[searchFilterTranslations]}>
<PlatformModulesProvider modules={modules}>
{params.has("overlay") ? <GlobalSearch settings={settings} auth={scopedAuth} /> : <SearchPage settings={settings} auth={scopedAuth} />}
</PlatformModulesProvider>
</PlatformLanguageProvider>;
}
+1 -1
View File
@@ -39,7 +39,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<PlatformModulesProvider modules={CONFORMANCE_MODULES}>
<PlatformLanguageProvider
preferredLanguageCode="de"
preferredLanguageCode={new URLSearchParams(window.location.search).get("language") ?? (["navigation-layout", "managed-archive"].some((key) => new URLSearchParams(window.location.search).has(key)) ? "en" : "de")}
moduleTranslations={[formsRuntimeTranslations, productSurfaceTranslations]}>
<UnsavedChangesProvider>
<Routes>
@@ -0,0 +1,100 @@
import { expect, test, type Page } from "@playwright/test";
async function mockAddresses(page: Page) {
const errors: string[] = [];
const writes: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const timestamp = "2026-09-01T12:00:00Z";
await page.route(url => url.pathname.startsWith("/api/"), async route => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (request.method() !== "GET") writes.push(path);
let body: unknown = {};
if (path === "/api/v1/addresses/address-books") body = { address_books: [{
id: "book-fixture", name: "Fixture address book", scope_type: "tenant",
source_kind: "local", read_only: false, contact_count: 0, created_at: timestamp, updated_at: timestamp
}] };
else if (path === "/api/v1/addresses/address-lists") body = { address_lists: [{
id: "list-fixture", address_book_id: "book-fixture", name: "Fixture address list",
source_kind: "local", read_only: false, entry_count: 0, created_at: timestamp, updated_at: timestamp
}] };
else if (path === "/api/v1/addresses/sync-sources") body = { sync_sources: [] };
else if (path === "/api/v1/addresses/contacts") body = { contacts: [], total: 0, limit: 50, offset: 0 };
else if (path.endsWith("/entries")) body = { entries: [] };
return route.fulfill({ json: body });
});
return { errors, writes };
}
test("Addressbook keeps Reload/New together and moves specialist actions out of the narrow tree header", async ({ page }) => {
const fixture = await mockAddresses(page);
await page.setViewportSize({ width: 1360, height: 900 });
await page.goto("/?address-explorer&language=en&theme=light");
const toolbar = page.getByRole("toolbar", { name: "Address book actions", exact: true });
const reload = toolbar.locator('[data-page-action-slot="reload"] button');
const create = toolbar.getByRole("button", { name: "Add address book", exact: true });
await expect(reload).toBeEnabled();
await expect(create).toBeVisible();
const [reloadBox, createBox] = await Promise.all([reload.boundingBox(), create.boundingBox()]);
expect(createBox!.x).toBeGreaterThan(1100);
expect(Math.abs(createBox!.y - reloadBox!.y)).toBeLessThan(3);
expect(createBox!.x - reloadBox!.x - reloadBox!.width).toBeLessThan(20);
await expect(page.locator('.address-tree-header button')).toHaveCount(1);
await toolbar.getByRole("button", { name: "Import / export", exact: true }).click();
const transfer = page.getByRole("dialog", { name: "Import / export", exact: true });
await expect(transfer.getByRole("button", { name: "Import contacts", exact: true })).toBeVisible();
await expect(transfer.getByRole("button", { name: "Export address book", exact: true })).toBeVisible();
await expect(transfer.getByRole("combobox", { name: "vCard export version" })).toBeVisible();
await page.keyboard.press("Escape");
await toolbar.getByRole("button", { name: "Connections", exact: true }).click();
const connections = page.getByRole("dialog", { name: "Connections", exact: true });
await expect(connections.getByRole("button", { name: "Connect CardDAV", exact: true })).toBeVisible();
await expect(connections.getByRole("button", { name: "Connect LDAP or Active Directory", exact: true })).toBeVisible();
await expect(connections.getByRole("button", { name: "Run sync", exact: true })).toBeDisabled();
expect(fixture.errors).toEqual([]);
expect(fixture.writes).toEqual([]);
});
test("Addressbook labels select only; folder buttons toggle, including after Reload", async ({ page }) => {
const fixture = await mockAddresses(page);
await page.goto("/?address-explorer&language=en&theme=light");
const root = page.locator('.address-tree-list > .explorer-tree-children > div').first();
const label = root.locator(':scope > .explorer-tree-node-wrap > .explorer-tree-node');
const folder = root.locator(':scope > .explorer-tree-node-wrap > .explorer-tree-toggle');
await expect(folder).toHaveAttribute("aria-expanded", "true");
await label.click();
await expect(label).toHaveAttribute("aria-current", "true");
await expect(folder).toHaveAttribute("aria-expanded", "true");
await expect(page.getByText("Select an address book in this group.", { exact: false })).toBeVisible();
await folder.click();
await expect(folder).toHaveAttribute("aria-expanded", "false");
await label.click();
await expect(folder).toHaveAttribute("aria-expanded", "false");
await page.locator('[data-page-action-slot="reload"] button').click();
await expect(page.locator('[data-page-action-slot="reload"] button')).toBeEnabled();
await expect(folder).toHaveAttribute("aria-expanded", "false");
await expect(label).toHaveAttribute("aria-current", "true");
await folder.click();
const book = page.locator('.address-tree-list .explorer-tree-node').filter({ hasText: "Fixture address book" });
await book.click();
await expect(book).toHaveAttribute("aria-current", "true");
await page.locator('.address-tree-header').getByRole("button", { name: "Manage", exact: true }).click();
const manage = page.getByRole("dialog", { name: "Manage selected book or list", exact: true });
await expect(manage.getByRole("button", { name: "Add address list", exact: true })).toBeVisible();
await expect(manage.getByRole("button", { name: "Edit address book", exact: true })).toBeVisible();
await expect(manage.locator('.form-section-separated').getByRole("button", { name: "Delete address book", exact: true })).toBeVisible();
expect(fixture.errors).toEqual([]);
expect(fixture.writes).toEqual([]);
});
test("Addressbook specialist controls retain permission blockers in German", async ({ page }) => {
const fixture = await mockAddresses(page);
await page.goto("/?address-explorer&language=de&read-only&theme=light");
await expect(page.getByRole("button", { name: "Adressbuch hinzufügen", exact: true })).toBeDisabled();
await page.getByRole("button", { name: "Import / Export", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Import / Export", exact: true });
await expect(dialog.getByRole("button", { name: "Kontakte importieren", exact: true })).toBeDisabled();
await expect(dialog.getByRole("heading", { name: "In das ausgewählte Adressbuch importieren", exact: true })).toBeVisible();
expect(fixture.errors).toEqual([]);
expect(fixture.writes).toEqual([]);
});
@@ -0,0 +1,84 @@
import { expect, test, type Page } from "@playwright/test";
async function install(page: Page) {
const errors: string[] = [];
page.on("pageerror", error => { errors.push(error.message); console.error("Attachment fixture:", error.message); });
let revision = 1;
const zip = { enabled: true, archives: [{ id: "zip-1", name: "recipient.zip", method: "zip_standard", password_enabled: true }] };
let raw = { campaign: { name: "Fixture" }, template: { subject: "Fixture", text: "" }, server: {},
attachments: { base_paths: [{ id: "source-1", name: "Source", path: ".", source: "managed:user:user-1", allow_individual: true }],
global: [], zip } };
const writes: Record<string, any>[] = [];
const version = () => ({ id: "version-attachments", campaign_id: "campaign-attachments", version_number: 1,
edit_revision: revision, strong_etag: `"version-attachments:${revision}"`, editor_state: {},
current_flow: "manual", current_step: "files", workflow_state: "editing", is_complete: false,
updated_at: "2026-09-07T10:00:00Z", raw_json: raw });
await page.route((url) => url.pathname.startsWith("/api/"), async route => {
const request = route.request(); const url = new URL(request.url());
if (request.method() === "GET") {
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
campaign: { id: "campaign-attachments", name: "Fixture", current_version_id: "version-attachments", status: "draft" },
versions: [version()], current_version: version(), summary: null, deleted: [], full: true, has_more: false, watermark: `w${revision}`
} });
if (url.pathname === "/api/v1/files/spaces") return route.fulfill({ json: { spaces: [{ id: "space-1", label: "My files", space_type: "managed", owner_type: "user", owner_id: "user-1" }] } });
if (url.pathname === "/api/v1/files/folders") return route.fulfill({ json: { folders: [], next_cursor: null } });
if (url.pathname === "/api/v1/files") return route.fulfill({ json: { files: [], next_cursor: null } });
if (url.pathname === "/api/v1/files/delta") return route.fulfill({ json: { files: [], folders: [{ id: "folder-1", owner_type: "user", owner_id: "user-1", path: "letters", name: "letters" }], deleted: [], full: true, has_more: false, watermark: "files-w1" } });
if (url.pathname.endsWith("/versions/version-attachments")) return route.fulfill({ json: version() });
if (url.pathname.endsWith("/archive-encryption-policy")) return route.fulfill({ json: {
available: true, allowed_password_encryption_methods: ["aes"], allowed_password_delivery_channels: ["separate_mail"],
policy_hash: "fixture", source_path: [], diagnostics: [], reason: "Legacy ZipCrypto is blocked by policy.", legacy_label: "Legacy ZipCrypto"
} });
return route.fulfill({ json: {} });
}
if (request.method() === "POST" && url.pathname.endsWith("/autosave")) {
const body = request.postDataJSON(); writes.push(body);
raw = body.campaign_json; revision++;
return route.fulfill({ json: version() });
}
return route.abort();
});
await page.goto("/?campaign-attachments");
await expect(page.locator("#campaign-attachment-sources .chooser-display-input")).toBeEnabled();
return { writes, errors, zip };
}
test("actual Files chooser opens repeatedly from attachment path by click and keyboard", async ({ page }) => {
const fixture = await install(page);
const input = page.locator("#campaign-attachment-sources .chooser-display-input");
for (const action of ["click", "Enter", "Space", "click"]) {
if (action === "click") await input.click();
else { await input.focus(); await page.keyboard.press(action); }
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByRole("dialog").getByRole("button", { name: /Use.*folder|Select.*folder/i })).toBeEnabled();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
}
expect(fixture.writes).toHaveLength(0);
expect(fixture.errors).toEqual([]);
});
test("temporarily unavailable Files capability does not silently turn managed paths into text edits", async ({ page }) => {
const fixture = await install(page);
await page.getByRole("button", { name: "Toggle Files capability" }).click();
await expect(page.locator("#campaign-attachment-sources .chooser-display-input")).toBeDisabled();
await expect(page.getByText(/The Files browser is currently unavailable/).first()).toBeVisible();
await page.getByRole("button", { name: "Toggle Files capability" }).click();
await page.locator("#campaign-attachment-sources .chooser-display-input").click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
expect(fixture.writes).toHaveLength(0);
expect(fixture.errors).toEqual([]);
});
test("attachment source corrections save without changing or reauthorizing legacy ZIP configuration", async ({ page }) => {
const fixture = await install(page);
await page.locator('#campaign-attachment-sources input[placeholder="Campaign files"]').fill("Updated source name");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].campaign_json.attachments.zip).toEqual(fixture.zip);
expect(fixture.writes[0].campaign_json.attachments.base_paths[0].name).toBe("Updated source name");
await page.reload();
await expect(page.locator('#campaign-attachment-sources input[placeholder="Campaign files"]')).toHaveValue("Updated source name");
expect(fixture.errors).toEqual([]);
});
@@ -0,0 +1,103 @@
import { expect, test, type Page } from "@playwright/test";
async function setup(page: Page, options: { firstFailure?: boolean; delay?: Promise<void> } = {}) {
const writes: Record<string, unknown>[] = [];
await page.route("**/api/v1/conformance/review-state", async (route) => {
writes.push(route.request().postDataJSON());
if (options.delay) await options.delay;
if (options.firstFailure && writes.length === 1) {
await route.fulfill({ status: 409, json: { detail: "Synthetic review revision conflict" } }); return;
}
await route.fulfill({ json: { saved: true } });
});
return writes;
}
async function openAttachmentGroup(page: Page, query = "") {
page.on("pageerror", (error) => { throw error; });
await page.goto(`/?campaign-bulk-review${query}`);
await page.getByRole("button", { name: "Open grouped review" }).click();
if (await page.getByRole("combobox").isEnabled()) await page.getByRole("combobox").selectOption("same-attachment-condition");
return page.getByRole("dialog");
}
test("grouped review accepts only exact selected eligible IDs with shared required reason", async ({ page }) => {
const writes = await setup(page);
const dialog = await openAttachmentGroup(page);
await expect(dialog.getByRole("checkbox")).toHaveCount(3);
await expect(dialog.getByRole("button", { name: "Accept 3 selected messages" })).toBeDisabled();
await dialog.getByRole("checkbox", { name: "job-001@example.test" }).focus();
await page.keyboard.press("Space");
await dialog.getByRole("textbox").fill("The optional file is intentionally omitted for these recipients.");
await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click();
await expect(dialog).not.toBeVisible();
expect(writes).toEqual([{ buildToken: "build-one", categoryKey: "same-attachment-condition", jobIds: ["job-000", "job-002"], reason: "The optional file is intentionally omitted for these recipients." }]);
});
test("failed grouped save retains reason and selection, and retries explicitly", async ({ page }) => {
const writes = await setup(page, { firstFailure: true });
const dialog = await openAttachmentGroup(page);
await dialog.getByRole("textbox").fill("Accepted after inspecting frozen evidence.");
await dialog.getByRole("checkbox", { name: "job-001@example.test" }).focus();
await page.keyboard.press("Space");
await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click();
await expect(dialog.getByRole("alert")).toContainText("Synthetic review revision conflict");
await expect(dialog.getByRole("textbox")).toHaveValue("Accepted after inspecting frozen evidence.");
await expect(dialog.getByRole("checkbox", { name: "job-001@example.test" })).not.toBeChecked();
expect(writes).toHaveLength(1);
await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click();
await expect(dialog).not.toBeVisible();
expect(writes).toHaveLength(2);
expect(writes[1]).toEqual(writes[0]);
});
test("grouped save blocks double clicks and dismissal until acknowledged", async ({ page }) => {
let acknowledge!: () => void;
const writes = await setup(page, { delay: new Promise<void>((resolve) => { acknowledge = resolve; }) });
const dialog = await openAttachmentGroup(page);
await dialog.getByRole("textbox").fill("Shared operational exception.");
const accept = dialog.getByRole("button", { name: "Accept 3 selected messages" });
await accept.evaluate((button: HTMLButtonElement) => { button.click(); button.click(); });
await expect.poll(() => writes.length).toBe(1);
await expect(dialog.getByRole("button", { name: /^(Cancel|Abbrechen)$/ })).toBeDisabled();
await page.keyboard.press("Escape");
await expect(dialog).toBeVisible();
acknowledge();
await expect(dialog).not.toBeVisible();
});
test("grouped review is bounded and clearly counts the remaining matching messages", async ({ page }) => {
const writes = await setup(page);
const dialog = await openAttachmentGroup(page, "&large");
await expect(dialog.getByRole("checkbox")).toHaveCount(200);
await expect(dialog).toContainText("Showing 200 of 205 matching unreviewed messages in this category; 200 selected.");
await dialog.getByRole("textbox").fill("Confirmed shared exception.");
await dialog.getByRole("button", { name: "Accept 200 selected messages" }).click();
await expect(dialog).not.toBeVisible();
expect(writes[0].jobIds).toHaveLength(200);
});
test("a replaced build or missing permission cannot be bulk accepted", async ({ page }) => {
const writes = await setup(page);
let dialog = await openAttachmentGroup(page);
await dialog.getByRole("textbox").fill("Do not save stale build evidence.");
await page.getByTestId("replace-build").evaluate((button: HTMLButtonElement) => button.click());
await expect(dialog.getByRole("alert")).toContainText("The build changed");
await expect(dialog.getByRole("button", { name: "Accept 3 selected messages" })).toBeDisabled();
dialog = await openAttachmentGroup(page, "&read-only");
await expect(dialog.getByRole("button", { name: /Accept .* selected messages/ })).toBeDisabled();
expect(writes).toHaveLength(0);
});
test("German bulk review labels and category changes preserve explicit scope", async ({ page }) => {
const writes = await setup(page);
const dialog = await openAttachmentGroup(page, "&language=de");
await expect(dialog).toHaveAccessibleName("Gleichartige Prüfbedingungen bestätigen");
await expect(dialog.getByRole("button", { name: "3 ausgewählte Nachrichten bestätigen" })).toBeDisabled();
await dialog.getByRole("combobox").selectOption("other-condition");
await expect(dialog.getByRole("checkbox")).toHaveCount(1);
await expect(dialog.getByRole("textbox", { name: "Gemeinsamer Prüfvermerk" })).toHaveValue("");
await dialog.getByRole("button", { name: "1 ausgewählte Nachrichten bestätigen" }).click();
await expect(dialog).not.toBeVisible();
expect(writes[0]).toMatchObject({ categoryKey: "other-condition", jobIds: ["other-category"], reason: "" });
});
@@ -0,0 +1,107 @@
import { expect, test, type Page } from "@playwright/test";
async function fixture(page: Page, options: { language?: string; scope?: string; readonly?: boolean; ceiling?: number; reject?: number; hold?: boolean } = {}) {
let value: number | null = null;
let revision = 1;
let reject = options.reject;
let release: (() => void) | undefined;
let rejectRead = false;
const writes: any[] = [];
const errors: string[] = [];
const inherited = options.ceiling ?? 25;
const maximum = options.ceiling ?? 500;
page.on("pageerror", (cause) => errors.push(cause.message));
const state = () => ({ scope: options.scope ?? "system", synchronous_send_max_recipients: value,
revision: String(revision).padStart(64, "0"), max_configurable_recipients: maximum,
effective_max_recipients: value ?? inherited, inherited_max_recipients: inherited,
absolute_max_recipients: 500, deployment_ceiling_explicit: Boolean(options.ceiling), deployment_max_recipients: maximum });
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
if (!route.request().url().includes("/campaigns/settings/delivery-policy/")) return route.fulfill({ status: 404, json: { detail: "Not found" } });
if (route.request().method() === "GET") return route.fulfill(rejectRead ? { status: 503, json: { detail: "Fixture reload unavailable" } } : { json: state() });
expect(route.request().method()).toBe("PUT");
const body = route.request().postDataJSON(); writes.push(body);
if (options.hold) await new Promise<void>((resolve) => { release = resolve; });
if (reject) {
const status = reject; reject = undefined;
return route.fulfill({ status, json: { detail: "Fixture policy save rejected; draft was not saved" } });
}
value = body.synchronous_send_max_recipients; revision += 1;
return route.fulfill({ json: state() });
});
await page.goto(`/?campaign-delivery-policy&language=${options.language ?? "en"}&scope=${options.scope ?? "system"}${options.readonly ? "&readonly" : ""}`);
await expect(page.getByRole("spinbutton")).toHaveValue(String(inherited));
return { writes, errors, release: () => release?.(), failRead: () => { rejectRead = true; } };
}
for (const language of ["en", "de"]) {
test(`administrator deliberately raises implicit 25 to 200 and reloads saved policy in ${language}`, async ({ page }) => {
const f = await fixture(page, { language });
const save = page.getByRole("button", { name: language === "de" ? "Speichern" : "Save", exact: true });
await expect(save).toBeDisabled();
await page.getByRole("checkbox").press("Space");
await page.getByRole("spinbutton").fill("200");
await save.click();
await expect(save).toBeDisabled();
expect(f.writes).toEqual([{ synchronous_send_max_recipients: 200, expected_revision: "1".padStart(64, "0") }]);
await page.getByRole("button", { name: language === "de" ? "Gespeicherte Versandrichtlinie neu laden" : "Reload saved delivery policy", exact: true }).click();
await expect(page.getByRole("spinbutton")).toHaveValue("200");
await expect(page.getByRole("checkbox")).not.toBeChecked();
expect(f.errors).toEqual([]);
});
}
for (const status of [422, 409]) {
test(`policy save ${status} retains draft for an explicit retry`, async ({ page }) => {
const f = await fixture(page, { reject: status });
await page.getByRole("checkbox").press("Space");
await page.getByRole("spinbutton").fill("200");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByRole("alert").filter({ hasText: "Fixture policy save rejected" })).toBeVisible();
await expect(page.getByRole("spinbutton")).toHaveValue("200");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
expect(f.writes).toHaveLength(2);
expect(f.writes[1].synchronous_send_max_recipients).toBe(200);
});
}
test("explicit ceiling prevents invalid save and clearing override submits inheritance", async ({ page }) => {
const f = await fixture(page, { scope: "tenant", ceiling: 40 });
await page.getByRole("checkbox").press("Space");
await page.getByRole("spinbutton").fill("41");
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
expect(f.writes).toHaveLength(0);
await page.getByRole("spinbutton").fill("0");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
await page.getByRole("checkbox").press("Space");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
expect(f.writes.map((body) => body.synchronous_send_max_recipients)).toEqual([0, null]);
});
test("read-only policy cannot be edited or saved", async ({ page }) => {
const f = await fixture(page, { readonly: true });
await expect(page.getByRole("checkbox")).toBeDisabled();
await expect(page.getByRole("spinbutton")).toBeDisabled();
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
expect(f.writes).toHaveLength(0);
});
test("pending policy save disables edits and reload; failed refresh preserves acknowledged value", async ({ page }) => {
const f = await fixture(page, { hold: true });
await page.getByRole("checkbox").press("Space");
await page.getByRole("spinbutton").fill("200");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect.poll(() => f.writes.length).toBe(1);
await expect(page.getByRole("spinbutton")).toBeDisabled();
await expect(page.getByRole("button", { name: "Reload saved delivery policy", exact: true })).toBeDisabled();
f.release();
await expect(page.getByRole("spinbutton")).toBeEnabled();
f.failRead();
await page.getByRole("button", { name: "Reload saved delivery policy", exact: true }).click();
await expect(page.getByRole("alert").filter({ hasText: "Fixture reload unavailable" })).toBeVisible();
await expect(page.getByRole("spinbutton")).toHaveValue("200");
await expect(page.getByRole("button", { name: "Save", exact: true })).toBeDisabled();
expect(f.writes).toHaveLength(1);
});
@@ -0,0 +1,187 @@
import { expect, test, type Page } from "@playwright/test";
async function install(page: Page, options: {
kind?: "smtp" | "imap"; language?: "en" | "de"; holdWrite?: Promise<void>; interrupt?: boolean; failRefresh?: boolean;
diagnostics?: boolean; authoring?: boolean; holdPreview?: Promise<void>;
} = {}) {
const kind = options.kind ?? "smtp";
const errors: string[] = []; page.on("pageerror", error => { errors.push(error.message); console.error("Delivery fixture:", error.message); });
const writes: { path: string; payload: Record<string, any> }[] = [];
const wholeReads: string[] = [];
let progressReads = 0; let previews = 0; let acknowledged = false; let active = false; let validated = false;
const jobs = Array.from({ length: 4 }, (_value, index) => ({ id: `delivery-job-${index + 1}`, entry_index: index + 1, entry_id: `entry-${index + 1}`,
recipient_email: `recipient-${index + 1}@example.test`, subject: `Delivery message ${index + 1}`, build_status: "built", validation_status: "ready", queue_status: "draft",
send_status: kind === "imap" ? "smtp_accepted" : "not_queued", imap_status: options.diagnostics ? ["pending", "appending", "failed", "outcome_unknown"][index] : kind === "imap" ? "pending" : "not_requested",
resolved_recipients: { to: [{ email: `recipient-${index + 1}@example.test` }] }, issues: [], attachments: [], review_decision: { eligible: false }, attempt_count: kind === "imap" ? 1 : 0
}));
const version = () => ({ id: "delivery-version", campaign_id: "delivery-campaign", version_number: 1, edit_revision: 1, strong_etag: '"delivery-version:1"',
review_build_token: "delivery-build", locked_at: options.authoring && !validated ? null : "2026-09-07T10:00:00Z", workflow_state: options.authoring && !validated ? "draft" : "built",
validation_summary: options.authoring && !validated ? null : { ok: true, warning_count: 0, error_count: 0, issues: [] },
build_summary: options.authoring ? null : { built_count: 4, blocked_count: 0 }, execution_snapshot_hash: "frozen-snapshot",
raw_json: { campaign: { name: "Delivery fixture" }, server: { mail_profile_id: "profile-fixture" },
delivery: { rate_limit: { messages_per_minute: 30 }, imap_append_sent: { enabled: kind === "imap", folder: "Sent" } },
entries: { inline: jobs.map(job => ({ id: job.entry_id, email: job.recipient_email, active: true })) } },
editor_state: { review_send: { review_build_token: "delivery-build", inspection_complete: true, reviewed_message_keys: [], issue_decisions: [] } }
});
const baseJobs = (url?: URL) => {
const states = url?.searchParams.getAll("imap_status") ?? [];
const selected = states.length ? jobs.filter(job => states.includes(job.imap_status)) : jobs;
return { jobs: selected, page: 1, page_size: 200, total: selected.length, total_unfiltered: 4, pages: 1, counts: {}, filtered_counts: {},
review: { blocking_count: 0, required_count: 0, bulk_acceptable_count: 0, reviewed_required_count: 0, inspection_complete: true } };
};
await page.route(url => url.pathname.startsWith("/api/"), async route => {
const request = route.request(); const url = new URL(request.url());
if (request.method() === "GET") {
if (/\/(workspace\/delta|summary|jobs|jobs\/delta)$/.test(url.pathname)) wholeReads.push(url.pathname);
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
campaign: { id: "delivery-campaign", name: "Delivery fixture", current_version_id: "delivery-version", status: "draft" },
versions: [version()], current_version: version(), summary: { cards: { jobs_total: 4, sent: kind === "imap" ? 4 : 0, failed: 0 },
delivery: { background_workers_enabled: false }, status_counts: { send: kind === "imap" ? { smtp_accepted: 4 } : { not_queued: 4 },
imap: kind === "imap" ? options.diagnostics ? { pending: 1, appending: 1, failed: 1, outcome_unknown: 1 } : { pending: 4 } : {} }, attachments: {} },
deleted: [], full: true, has_more: false, watermark: "delivery-workspace"
} });
if (url.pathname.endsWith("/delivery-options")) return route.fulfill({ json: { worker_queue_available: false,
synchronous_send: { allowed: true, eligible_recipient_job_count: 4, policy: { max_recipient_jobs: 200 } }, approval_gate: { configured: false, available: false } } });
if (url.pathname.endsWith("/delivery-progress")) {
progressReads++;
if (acknowledged && options.failRefresh) return route.fulfill({ status: 503, json: { detail: "Fixture progress unavailable" } });
return route.fulfill({ json: { campaign_id: "delivery-campaign", version_id: "delivery-version", generated_at: "2026-09-07T10:00:00Z", total_jobs: 4,
smtp: { total: 4, processed: acknowledged ? 4 : 1, accepted: acknowledged || kind === "imap" ? 4 : 1, active: active && kind === "smtp" ? 1 : 0, pending: acknowledged ? 0 : active ? 2 : 3, failed: 0, outcome_unknown: 0, excluded: 0, paused: 0, cancelled: 0 },
imap: { total: 4, processed: acknowledged ? 4 : 1, appended: acknowledged ? 4 : 1, active: active && kind === "imap" ? 1 : 0, pending: acknowledged ? 0 : active ? 2 : 3, failed: 0, outcome_unknown: 0, excluded: 0 },
status_counts: { send: {}, queue: {}, imap: {} }
} });
}
if (url.pathname.endsWith("/jobs/delta")) {
if (acknowledged && options.failRefresh) return route.fulfill({ status: 503, json: { detail: "Fixture diagnostics unavailable" } });
return route.fulfill({ json: { ...baseJobs(url), full: true, has_more: false, deleted: [], watermark: "diagnostic-watermark" } });
}
if (url.pathname.endsWith("/jobs")) {
if (acknowledged && options.failRefresh) return route.fulfill({ status: 503, json: { detail: "Fixture diagnostics unavailable" } });
return route.fulfill({ json: baseJobs(url) });
}
return route.fulfill({ json: {} });
}
if (url.pathname.endsWith("/attachments/preview")) {
previews++;
if (previews === 1 && options.holdPreview) await options.holdPreview;
const unlinked = options.authoring && previews >= 2 && !validated;
const file = { id: "newly-unlinked-file", filename: "letter.pdf", display_path: "letters/letter.pdf", linked_to_campaign: false };
return route.fulfill({ json: { campaign_id: "delivery-campaign", version_id: "delivery-version", shared_file_count: 0,
rules: [], linkable_files: unlinked ? [file] : [], unused_shared_files: [] } });
}
const payload = request.postDataJSON(); writes.push({ path: url.pathname, payload });
if (url.pathname.endsWith("/validate")) { validated = true; return route.fulfill({ json: { ok: true, issues: [] } }); }
if (url.pathname.endsWith("/send-now") || url.pathname.endsWith("/append-sent")) {
active = true;
if (options.holdWrite) await options.holdWrite;
active = false;
if (options.interrupt) return route.fulfill({ status: 503, json: { detail: "Fixture request interrupted; verify stored outcomes." } });
acknowledged = true;
for (const job of jobs) { job.send_status = "smtp_accepted"; if (kind === "imap") job.imap_status = "appended"; }
return route.fulfill({ json: { result: { attempted_count: 4, sent_count: 4, failed_count: 0, outcome_unknown_count: 0, paused_count: 0,
pending_count: 4, appended_count: 4, processed_count: 4, results: [] } } });
}
return route.abort();
});
await page.goto(`/?campaign-delivery-progress&language=${options.language ?? "en"}`);
if (options.authoring) await expect.poll(() => previews).toBe(1);
else await expect(page.getByRole("table", { name: "campaign-delivery-campaign-workflow-built-messages", exact: true }).getByText("Delivery message 1", { exact: true })).toBeVisible();
return { writes, wholeReads, errors, get progressReads() { return progressReads; }, get previews() { return previews; } };
}
async function start(page: Page, kind: "smtp" | "imap") {
if (kind === "smtp") {
await page.getByRole("button", { name: /^(Send now|Jetzt senden)$/ }).click();
await page.getByRole("alertdialog").getByRole("button", { name: /^(Send now|Jetzt senden)$/ }).click();
} else await page.getByRole("button", { name: /^(Append pending IMAP now|Ausstehende IMAP-Kopien jetzt anhängen)$/ }).click();
}
for (const language of ["en", "de"] as const) for (const kind of ["smtp", "imap"] as const) test(`${language}: actual ${kind} request shows live active progress without refreshing the underlying workflow`, async ({ page }) => {
let release!: () => void; const held = new Promise<void>(resolve => { release = resolve; });
const fixture = await install(page, { kind, language, holdWrite: held });
await start(page, kind);
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect.poll(() => fixture.writes.length).toBe(1);
const wholeReads = fixture.wholeReads.length;
await expect.poll(() => fixture.progressReads).toBeGreaterThanOrEqual(2);
await expect(dialog.getByText(language === "de" ? "In Bearbeitung" : "In progress", { exact: true })).toBeVisible();
await expect(dialog.getByRole("status").first()).toContainText(/1.*4/);
expect(fixture.wholeReads.length).toBe(wholeReads);
await page.keyboard.press("Escape"); await expect(dialog).toBeVisible();
expect(fixture.writes).toHaveLength(1);
release();
await expect(dialog.getByRole("button", { name: /Close|Schließen/, exact: true }).last()).toBeEnabled();
expect(fixture.errors).toEqual([]);
});
for (const kind of ["smtp", "imap"] as const) test(`${kind}: failed display refresh preserves acknowledged success`, async ({ page }) => {
const fixture = await install(page, { kind, failRefresh: true });
await start(page, kind);
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("button", { name: /Close|Schließen/, exact: true }).last()).toBeEnabled();
await expect(dialog.getByText(/The request finished|Die Anfrage ist abgeschlossen/i).first()).toBeVisible();
await dialog.getByRole("button", { name: /Close|Schließen/, exact: true }).last().click();
await expect(page.getByText(kind === "smtp" ? /Send finished\. SMTP accepted 4/ : /IMAP append processed 4 job/)).toBeVisible();
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
});
test("an interrupted send remains uncertain and is never replayed by progress polling", async ({ page }) => {
const fixture = await install(page, { interrupt: true });
await start(page, "smtp");
const dialog = page.getByRole("dialog");
await expect(dialog.getByText("Fixture request interrupted; verify stored outcomes.", { exact: false })).toBeVisible();
await expect.poll(() => fixture.progressReads).toBeGreaterThanOrEqual(2);
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
});
test("IMAP diagnostics use shared SMTP and IMAP list filters", async ({ page }) => {
const fixture = await install(page, { kind: "imap", diagnostics: true });
await page.getByRole("button", { name: /^(Load IMAP diagnostics|IMAP-Diagnose laden)$/ }).click();
const table = page.getByRole("table", { name: "campaign-delivery-campaign-workflow-imap-diagnostics", exact: true });
await expect(table.getByText("recipient-1@example.test", { exact: true })).toBeVisible();
const filterButtons = table.getByRole("button", { name: /^Filter (SMTP|IMAP)$/ });
await expect(filterButtons).toHaveCount(2);
await filterButtons.last().click();
await expect(page.getByRole("checkbox", { name: /Pending|Ausstehend/, exact: true })).toBeVisible();
await expect(page.getByRole("checkbox", { name: /Outcome uncertain|Ergebnis ungewiss/, exact: true })).toBeVisible();
await page.getByRole("button", { name: /^(Deselect all|Alle abwählen)$/ }).click();
await page.getByRole("checkbox", { name: "Pending", exact: true }).check();
await page.keyboard.press("Escape");
await expect(table.getByText("recipient-1@example.test", { exact: true })).toBeVisible();
await expect(table.getByText("recipient-2@example.test", { exact: true })).toHaveCount(0);
await table.getByRole("button", { name: "Filter SMTP", exact: true }).click();
await expect(page.getByRole("checkbox", { name: "SMTP accepted", exact: true })).toBeVisible();
expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
});
test("acknowledged IMAP append refreshes pending diagnostics immediately", async ({ page }) => {
const fixture = await install(page, { kind: "imap" });
await page.getByRole("button", { name: "Load IMAP diagnostics", exact: true }).click();
const table = page.getByRole("table", { name: "campaign-delivery-campaign-workflow-imap-diagnostics", exact: true });
await expect(table.getByText("recipient-1@example.test", { exact: true })).toBeVisible();
await start(page, "imap");
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("button", { name: "Close", exact: true }).last()).toBeEnabled();
await dialog.getByRole("button", { name: "Close", exact: true }).last().click();
await expect(table).toHaveCount(0);
await expect(page.getByText(/IMAP append processed 4 job/)).toBeVisible();
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
});
test("lock waits for attachment preview and rechecks fresh unlinked matches before validation", async ({ page }) => {
let release!: () => void; const held = new Promise<void>(resolve => { release = resolve; });
const fixture = await install(page, { authoring: true, holdPreview: held });
const lock = page.getByRole("button", { name: /^(Lock and validate|Sperren und validieren)$/ });
await expect(lock).toBeDisabled(); expect(fixture.writes).toHaveLength(0);
release(); await expect(lock).toBeEnabled();
await lock.click();
await expect.poll(() => fixture.previews).toBeGreaterThanOrEqual(2);
await expect(page.getByRole("alertdialog")).toBeVisible();
expect(fixture.writes).toHaveLength(0);
await page.getByRole("alertdialog").getByRole("button", { name: /^(Link and lock|Verknüpfen und sperren)$/ }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].path).toMatch(/\/validate$/);
expect(fixture.writes[0].payload.link_unshared_matches).toBe(true);
expect(fixture.errors).toEqual([]);
});
@@ -0,0 +1,196 @@
import { expect, test, type Page } from "@playwright/test";
const zip = { enabled: true, archives: [{
id: "legacy-zip", name: "Existing recipient archive", method: "zip_standard",
password_enabled: true, password_delivery_channel: "separate_mail"
}] };
const defaultCredential = {
id: "credential-default", name: "Default account", is_active: true, is_default: true,
public_data: { username: "sender@example.test" }
};
const alternateCredential = {
id: "credential-alternate", name: "Explicit alternate account", is_active: true, is_default: false,
public_data: { username: "alternate@example.test" }
};
const inactiveCredential = {
id: "credential-inactive", name: "Inactive account", is_active: false, is_default: false,
public_data: { username: "inactive@example.test" }
};
const profile = {
id: "profile-1", name: "Campaign delivery", scope_type: "tenant", scope_id: "tenant-1", is_active: true,
servers: [{
id: "smtp-1", name: "Active SMTP server", protocol: "smtp", is_active: true, is_default: true,
config: { host: "smtp.example.test", port: 587, security: "starttls" },
credentials: [defaultCredential, alternateCredential, inactiveCredential]
}, {
id: "smtp-inactive", name: "Inactive SMTP server", protocol: "smtp", is_active: false, is_default: false,
config: { host: "inactive.example.test", port: 587, security: "starttls" },
credentials: [defaultCredential]
}]
};
async function install(page: Page, options: {
language?: "en" | "de";
selectedServer?: Record<string, string>;
excludeProfile?: boolean;
requireCredential?: boolean;
} = {}) {
const errors: string[] = [];
page.on("pageerror", (error) => { errors.push(error.message); });
let revision = 1;
let migrationRequired = true;
let raw: Record<string, unknown> = {
version: "1.0", campaign: { id: "campaign-mail", name: "Mail repair fixture", mode: "send" },
server: options.selectedServer ?? { mail_profile_id: "profile-1", smtp_server_id: "smtp-1" },
template: { subject: "Fixture subject", text: "Fixture body" },
recipients: { from: [{ email: "sender@example.test" }] }, entries: { inline: [] },
attachments: { zip }, delivery: { imap_append_sent: { enabled: false, folder: "auto" } }
};
const writes: Record<string, any>[] = [];
const reads: URL[] = [];
const unexpectedWrites: string[] = [];
const version = () => ({
id: "version-mail", campaign_id: "campaign-mail", version_number: 2,
schema_version: "1.0", edit_revision: revision, strong_etag: `"version-mail:${revision}"`,
current_flow: "manual", current_step: "mail-settings", workflow_state: "editing",
is_complete: false, editor_state: { created_from: "minimal_campaign" },
user_lock_state: null, locked_at: null, published_at: null,
created_at: "2026-09-07T10:00:00Z", updated_at: "2026-09-07T10:00:00Z",
raw_json: raw, mail_profile_migration_required: migrationRequired
});
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request(); const url = new URL(request.url());
if (request.method() === "GET") {
reads.push(url);
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
campaign: { id: "campaign-mail", name: "Mail repair fixture", current_version_id: "version-mail", status: "draft" },
versions: [version()], current_version: version(), summary: null, deleted: [],
watermark: `mail-watermark-${revision}`, has_more: false, full: true
} });
if (url.pathname === "/api/v1/mail/profiles") return route.fulfill({ json: {
profiles: options.excludeProfile ? [] : [profile]
} });
if (url.pathname.endsWith("/versions/version-mail")) return route.fulfill({ json: version() });
return route.fulfill({ json: {} });
}
if (request.method() === "POST" && url.pathname.endsWith("/versions/version-mail/autosave")) {
const body = request.postDataJSON(); writes.push(body);
if (options.requireCredential && !body.campaign_json.server.smtp_credential_id) {
return route.fulfill({ status: 422, json: {
detail: "Campaign delivery cannot use the selected profile because the effective SMTP credential policy requires an explicit credential selection for this campaign."
} });
}
if (body.base_revision !== revision) return route.fulfill({ status: 409, json: { detail: {
code: "revision_conflict", resource: { type: "campaign_version", id: "version-mail" },
current_revision: revision, submitted_base_revision: body.base_revision
} } });
raw = body.campaign_json;
revision += 1;
if (body.migrate_legacy_mail_settings) migrationRequired = false;
return route.fulfill({ json: version() });
}
unexpectedWrites.push(`${request.method()} ${url.pathname}`);
return route.abort();
});
await page.goto(`/?campaign-mail-settings&language=${options.language ?? "en"}`);
await expect(page.getByRole("combobox", { name: "SMTP credential", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: /Reload profiles|Profile neu laden/ })).toBeEnabled();
return { writes, reads, errors, unexpectedWrites, raw: () => raw, revision: () => revision };
}
for (const language of ["en", "de"] as const) {
test(`real Mail settings never display inherited defaults as explicit saved credentials in ${language}`, async ({ page }) => {
const fixture = await install(page, { language, requireCredential: true });
const credential = page.getByRole("combobox", { name: "SMTP credential", exact: true });
await expect(credential).toHaveValue("");
await expect(credential.locator("option:checked")).toContainText(language === "en" ? "Use profile credentials" : "Profil-Zugangsdaten verwenden");
await expect(credential.locator('option[value="credential-default"]')).toHaveCount(1);
expect(fixture.writes).toHaveLength(0);
await credential.selectOption("credential-default");
await page.getByRole("button", { name: language === "en" ? "Migrate to selected Mail profile" : "Auf ausgewähltes Mail-Profil umstellen", exact: true }).click();
await expect.poll(() => fixture.revision()).toBe(2);
await expect(page.getByRole("button", { name: language === "en" ? "Migrate to selected Mail profile" : "Auf ausgewähltes Mail-Profil umstellen", exact: true })).toHaveCount(0);
expect(fixture.writes).toHaveLength(1);
expect(fixture.writes[0].migrate_legacy_mail_settings).toBe(true);
expect(fixture.writes[0].campaign_json.server).toEqual({
mail_profile_id: "profile-1", smtp_server_id: "smtp-1", smtp_credential_id: "credential-default"
});
expect(fixture.writes[0].campaign_json.attachments.zip).toEqual(zip);
expect(fixture.writes[0].base_revision).toBe(1);
expect(fixture.unexpectedWrites).toEqual([]);
expect(fixture.errors).toEqual([]);
await page.reload();
await expect(credential).toHaveValue("credential-default");
expect(fixture.writes).toHaveLength(1);
});
}
test("explicit-credential 422 preserves the real Mail draft and can be corrected then saved", async ({ page }) => {
const fixture = await install(page, { requireCredential: true });
await page.getByRole("button", { name: "Migrate to selected Mail profile", exact: true }).click();
await expect(page.getByText(/effective SMTP credential policy requires an explicit credential selection/)).toBeVisible();
expect(fixture.revision()).toBe(1);
expect(fixture.writes).toHaveLength(1);
await expect(page.getByRole("combobox", { name: "SMTP server", exact: true })).toHaveValue("smtp-1");
await expect(page.getByRole("combobox", { name: "SMTP credential", exact: true })).toHaveValue("");
await expect(page.getByRole("button", { name: "Migrate to selected Mail profile", exact: true })).toBeEnabled();
await page.getByRole("combobox", { name: "SMTP credential", exact: true }).selectOption("credential-alternate");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect.poll(() => fixture.revision()).toBe(2);
await expect(page.getByRole("button", { name: "Migrate to selected Mail profile", exact: true })).toHaveCount(0);
expect(fixture.writes).toHaveLength(2);
expect(fixture.writes[0].campaign_json.server.smtp_credential_id).toBeUndefined();
expect(fixture.writes[1].campaign_json.server.smtp_credential_id).toBe("credential-alternate");
expect(fixture.writes[1].campaign_json.server.smtp_server_id).toBe("smtp-1");
expect(fixture.writes[1].campaign_json.attachments.zip).toEqual(zip);
expect(fixture.errors).toEqual([]);
expect(fixture.unexpectedWrites).toEqual([]);
});
for (const credentialId of ["credential-missing", "credential-inactive"]) {
test(`a stored ${credentialId} stays visibly unavailable until explicitly replaced`, async ({ page }) => {
const fixture = await install(page, { selectedServer: {
mail_profile_id: "profile-1", smtp_server_id: "smtp-1", smtp_credential_id: credentialId
} });
const credential = page.getByRole("combobox", { name: "SMTP credential", exact: true });
await expect(credential).toHaveValue(credentialId);
await expect(credential.locator("option:checked")).toHaveText("Selected credential is unavailable");
await expect(credential.locator('option[value="credential-inactive"]')).toHaveCount(credentialId === "credential-inactive" ? 1 : 0);
expect(fixture.writes).toHaveLength(0);
await credential.selectOption("credential-default");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect.poll(() => fixture.revision()).toBe(2);
expect(fixture.writes[0].campaign_json.server.smtp_credential_id).toBe("credential-default");
expect(fixture.errors).toEqual([]);
});
}
for (const serverId of ["smtp-missing", "smtp-inactive"]) {
test(`a stored ${serverId} cannot silently fall back to the default SMTP endpoint`, async ({ page }) => {
const fixture = await install(page, { selectedServer: {
mail_profile_id: "profile-1", smtp_server_id: serverId, smtp_credential_id: "credential-default"
} });
const server = page.getByRole("combobox", { name: "SMTP server", exact: true });
await expect(server).toHaveValue(serverId);
await expect(server.locator("option:checked")).toContainText("unavailable");
await expect(page.getByRole("combobox", { name: "SMTP credential", exact: true })).toBeDisabled();
expect(fixture.writes).toHaveLength(0);
await server.selectOption("smtp-1");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect.poll(() => fixture.revision()).toBe(2);
expect(fixture.writes[0].campaign_json.server).toEqual({
mail_profile_id: "profile-1", smtp_server_id: "smtp-1", smtp_credential_id: "credential-default"
});
expect(fixture.errors).toEqual([]);
});
}
test("an unavailable profile remains identified and cannot be silently migrated", async ({ page }) => {
const fixture = await install(page, { excludeProfile: true });
await expect(page.getByRole("combobox", { name: "Profile", exact: true })).toHaveValue("profile-1");
await expect(page.getByRole("button", { name: "Migrate to selected Mail profile", exact: true })).toBeDisabled();
await expect(page.getByRole("combobox", { name: "SMTP server", exact: true })).toBeDisabled();
expect(fixture.writes).toHaveLength(0);
expect(fixture.reads.filter((url) => url.pathname === "/api/v1/mail/profiles").every((url) => url.searchParams.get("campaign_id") === "campaign-mail")).toBe(true);
expect(fixture.unexpectedWrites).toEqual([]);
});
@@ -0,0 +1,119 @@
import { expect, test, type Page } from "@playwright/test";
async function install(page: Page, language = "en", rejectSave = false) {
let revision = 1;
let raw: any = null;
let rejected = false;
const writes: any[] = [];
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const version = () => ({ id: "order-version", campaign_id: "order-campaign", version_number: 1,
edit_revision: revision, strong_etag: `"order-version:${revision}"`, editor_state: {},
workflow_state: "editing", current_flow: "manual", current_step: "recipients", is_complete: false,
updated_at: "2026-09-07T10:00:00Z", raw_json: raw });
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
if (route.request().method() === "POST" && route.request().url().endsWith("/autosave")) {
const body = route.request().postDataJSON(); writes.push(body);
if (rejectSave && !rejected) {
rejected = true;
return route.fulfill({ status: 422, json: { detail: "Fixture policy rejected this save" } });
}
raw = body.campaign_json; revision += 1;
return route.fulfill({ json: version() });
}
if (route.request().method() === "GET") return route.fulfill({ json: version() });
return route.abort();
});
await page.goto(`/?campaign-recipient-order&language=${language}`);
await expect(page.getByTestId("recipient-order")).toContainText("alpha@example.test");
return { writes, errors, raw: () => raw };
}
async function moveLastToFirst(page: Page) {
const dialog = page.getByRole("dialog");
await dialog.getByRole("button", { name: "Move address up", exact: true }).nth(2).click();
await dialog.getByRole("button", { name: "Move address up", exact: true }).nth(1).click();
await expect(dialog.locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
}
for (const language of ["en", "de"]) {
test(`individual recipient order survives dialog Save, reopen and actual campaign POST in ${language}`, async ({ page }) => {
const fixture = await install(page, language);
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
await moveLastToFirst(page);
await page.getByRole("dialog").getByRole("button", { name: language === "de" ? "Speichern" : "Save", exact: true }).click();
await expect(page.getByTestId("primary-address")).toHaveText("zulu@example.test");
await expect(page.getByTestId("recipient-dirty")).toHaveText("true");
expect(fixture.writes).toHaveLength(0); // Dialog Save applies the page draft, not a hidden network mutation.
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
await expect(page.getByRole("dialog").locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
await page.getByRole("dialog").getByRole("button", { name: language === "de" ? "Abbrechen" : "Cancel", exact: true }).click();
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
expect(fixture.writes[0].campaign_json.entries.inline[0].to.map((item: any) => item.email)).toEqual([
"zulu@example.test", "alpha@example.test", "beta@example.test"
]);
expect(fixture.raw().entries.inline[0].email).toBe("zulu@example.test");
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
await expect(page.getByRole("dialog").locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
expect(fixture.errors).toEqual([]);
});
}
test("global header addresses preserve their explicitly selected order", async ({ page }) => {
const fixture = await install(page);
await page.getByRole("button", { name: "Edit global addresses", exact: true }).click();
await moveLastToFirst(page);
await page.getByRole("dialog").getByRole("button", { name: "Save", exact: true }).click();
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
expect(fixture.raw().recipients.to.map((item: any) => item.email)).toEqual([
"zulu@example.test", "alpha@example.test", "beta@example.test"
]);
await page.getByRole("button", { name: "Edit global addresses", exact: true }).click();
await expect(page.getByRole("dialog").locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
});
test("paste deduplicates without alphabetically rearranging existing or newly added addresses", async ({ page }) => {
const fixture = await install(page);
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
await moveLastToFirst(page);
await page.getByRole("dialog").locator(".recipient-address-category").evaluate((section) => {
const clipboard = new DataTransfer();
clipboard.setData("text/plain", "Zulu <ZULU@example.test>; Omega <omega@example.test>; Charlie <charlie@example.test>");
section.dispatchEvent(new ClipboardEvent("paste", { clipboardData: clipboard, bubbles: true, cancelable: true }));
});
await expect(page.getByRole("dialog").locator('input[type="email"]')).toHaveCount(5);
await page.getByRole("dialog").getByRole("button", { name: "Save", exact: true }).click();
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
expect(fixture.raw().entries.inline[0].to.map((item: any) => item.email)).toEqual([
"zulu@example.test", "alpha@example.test", "beta@example.test", "omega@example.test", "charlie@example.test"
]);
});
test("failed campaign save retains the reordered dialog result for explicit retry", async ({ page }) => {
const fixture = await install(page, "en", true);
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
await moveLastToFirst(page);
await page.getByRole("dialog").getByRole("button", { name: "Save", exact: true }).click();
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
await expect(page.getByTestId("recipient-error")).toContainText("Fixture policy rejected");
await expect(page.getByTestId("recipient-dirty")).toHaveText("true");
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
await expect(page.getByRole("dialog").locator('input[type="email"]').first()).toHaveValue("zulu@example.test");
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
await page.getByRole("button", { name: "Save campaign", exact: true }).click();
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
expect(fixture.writes).toHaveLength(2);
expect(fixture.writes[1].campaign_json.entries.inline[0].to).toEqual(fixture.writes[0].campaign_json.entries.inline[0].to);
});
test("Cancel deliberately discards only the dialog's unconfirmed reorder", async ({ page }) => {
await install(page);
await page.getByRole("button", { name: "Edit individual addresses", exact: true }).click();
await moveLastToFirst(page);
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
await expect(page.getByTestId("recipient-dirty")).toHaveText("false");
await expect(page.getByTestId("primary-address")).toHaveText("alpha@example.test");
});
@@ -0,0 +1,270 @@
import { expect, test, type Page } from "@playwright/test";
async function install(page: Page, options: { language?: "en" | "de"; readOnly?: boolean; rejectFirst?: boolean; holdWrite?: Promise<void>; switchAccount?: boolean } = {}) {
const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message));
const writes: { path: string; payload: Record<string, any> }[] = [];
let detailReads = 0;
let holdNextRead: Promise<void> | undefined;
let heldReads = 0;
const statuses = ["failed_temporary", "failed_permanent", "outcome_unknown", "sending", "sending", "queued", "smtp_accepted", "skipped"];
const jobs = statuses.map((status, index) => ({
id: `job-${index + 1}`, campaign_version_id: "report-version", entry_id: `entry-${index + 1}`, entry_index: index + 1,
recipient_email: `primary-${index + 1}@example.test`, subject: `Report message ${index + 1}`,
resolved_recipients: { to: [{ email: `primary-${index + 1}@example.test` }, { name: "Additional person", email: `additional-${index + 1}@example.test` }],
cc: [{ email: `copy-${index + 1}@example.test` }], bcc: [{ email: `blind-${index + 1}@example.test` }] },
build_status: "built", validation_status: status === "skipped" ? "excluded" : "ready", queue_status: status === "sending" ? "claimed" : "draft",
send_status: status, imap_status: status === "smtp_accepted" ? "appended" : index === 0 ? "pending" : index === 1 ? "appending" : index === 2 ? "outcome_unknown" : "not_requested", attempt_count: ["not_queued", "queued", "skipped"].includes(status) ? 0 : 1,
postbox_attempt_count: 0, print_attempt_count: 0,
attachments: [], recovery: { smtp: { eligible: index === 3, revision: `claim-revision-${index + 1}`, reason: index === 3 ? "recoverable" : "live_claim" }, imap: { eligible: false, revision: "imap-revision", reason: "not_active" } }
}));
const version = { id: "report-version", campaign_id: "report-campaign", version_number: 1, edit_revision: 1, strong_etag: '"report-version:1"',
workflow_state: "partially_completed", locked_at: "2026-09-07T10:00:00Z", raw_json: {}, editor_state: {} };
const summary = () => ({ cards: { jobs_total: jobs.length, sent: jobs.filter(job => job.send_status === "smtp_accepted").length, failed: jobs.filter(job => job.send_status.startsWith("failed")).length },
delivery: { background_workers_enabled: false, celery_enabled: false }, status_counts: {
send: Object.fromEntries([...new Set(jobs.map(job => job.send_status))].map(status => [status, jobs.filter(job => job.send_status === status).length])),
imap: Object.fromEntries([...new Set(jobs.map(job => job.imap_status))].map(status => [status, jobs.filter(job => job.imap_status === status).length]))
} });
await page.route(url => url.pathname.startsWith("/api/"), async route => {
const request = route.request(); const url = new URL(request.url());
if (request.method() === "GET") {
if (url.pathname.endsWith("/workspace/delta")) return route.fulfill({ json: {
campaign: { id: "report-campaign", name: "Report fixture", current_version_id: "report-version", status: "partially_completed" },
versions: [version], current_version: version, summary: summary(), deleted: [], full: true, has_more: false, watermark: "report-watermark"
} });
if (url.pathname.endsWith("/delivery-progress")) return route.fulfill({ json: {
campaign_id: "report-campaign", version_id: "report-version", generated_at: "2026-09-07T10:00:00Z", total_jobs: jobs.length,
smtp: { total: 7, processed: 4, accepted: summary().cards.sent, active: 2, pending: 1, failed: summary().cards.failed, outcome_unknown: 1, excluded: 1, paused: 0, cancelled: 0 },
imap: { total: 1, processed: 1, appended: 1, active: 0, pending: 0, failed: 0, outcome_unknown: 0, excluded: 7 }, status_counts: {}
} });
if (url.pathname.endsWith("/jobs")) {
const search = (url.searchParams.get("q") ?? url.searchParams.get("filter_recipient") ?? "").toLowerCase();
const matchesStatus = (column: "send" | "imap", status: string) => {
const filter = url.searchParams.get(`filter_${column}`);
return !filter?.startsWith("list:") || JSON.parse(filter.slice(5)).includes(status);
};
const selected = structuredClone(jobs.filter(job => (!search || JSON.stringify([job.resolved_recipients, job.recipient_email, job.entry_id, job.subject]).toLowerCase().includes(search)) &&
matchesStatus("send", job.send_status) && matchesStatus("imap", job.imap_status)));
const hold = holdNextRead;
if (hold) { holdNextRead = undefined; heldReads++; await hold; }
return route.fulfill({ json: { jobs: selected, page: 1, page_size: 50, total: selected.length, total_unfiltered: jobs.length, pages: 1, counts: { send: {}, imap: {} }, filtered_counts: {} } });
}
if (/\/jobs\/job-\d+$/.test(url.pathname)) {
detailReads++;
return route.fulfill({ json: { job: jobs.find(job => url.pathname.endsWith(job.id)), attempts: { smtp: [], imap: [] } } });
}
return route.fulfill({ json: {} });
}
const payload = request.postDataJSON(); writes.push({ path: url.pathname, payload });
if (options.holdWrite) await options.holdWrite;
if (options.rejectFirst && writes.length === 1) return route.fulfill({ status: 409, json: { detail: "Fixture recovery changed. Reload evidence before retrying." } });
if (/\/jobs\/(retry|send-unattempted)$/.test(url.pathname)) {
expect(payload.run_inline).toBe(true); expect(payload.enqueue_celery).toBe(false);
expect(payload.version_id).toBe("report-version");
for (const id of payload.job_ids) {
const job = jobs.find(row => row.id === id)!;
expect(["failed_temporary", "failed_permanent", "not_queued", "queued"]).toContain(job.send_status);
job.send_status = "smtp_accepted";
}
return route.fulfill({ json: { result: { selected_count: payload.job_ids.length, attempted_count: payload.job_ids.length,
sent_count: payload.job_ids.length, failed_count: 0, outcome_unknown_count: 0, remaining_count: 0, run_inline: true } } });
}
const job = jobs.find(row => url.pathname.includes(`/jobs/${row.id}/`));
if (url.pathname.endsWith("/recover-claim") && job) {
expect(payload.expected_revision).toBe(job.recovery.smtp.revision); expect(payload.note.trim()).not.toBe("");
job.send_status = "outcome_unknown"; job.recovery.smtp.eligible = false;
return route.fulfill({ json: { result: { job_id: job.id } } });
}
if (url.pathname.endsWith("/resolve-outcome") && job) {
expect(payload.note.trim()).not.toBe("");
job.send_status = payload.decision === "smtp_accepted" ? "smtp_accepted" : "failed_temporary";
return route.fulfill({ json: { result: { job_id: job.id } } });
}
return route.abort();
});
await page.goto(`/?campaign-report&language=${options.language ?? "en"}${options.readOnly ? "&read-only" : ""}${options.switchAccount ? "&switch-account" : ""}`);
const table = page.getByRole("table", { name: "campaign-report-jobs-v2-report-campaign", exact: true });
await expect(table.getByText("Report message 1", { exact: true })).toBeVisible();
return { table, jobs, writes, errors, holdNextJobsRead(hold: Promise<void>) { holdNextRead = hold; }, get heldReads() { return heldReads; }, get detailReads() { return detailReads; } };
}
for (const language of ["en", "de"] as const) test(`${language}: Report shows every To/Cc/Bcc recipient and searches without per-row details`, async ({ page }) => {
const fixture = await install(page, { language });
await expect(fixture.table.getByText("Additional person <additional-1@example.test>", { exact: false })).toBeVisible();
await expect(fixture.table.getByText("copy-1@example.test", { exact: true })).toBeVisible();
await expect(fixture.table.getByText("blind-1@example.test", { exact: true })).toBeVisible();
await expect(fixture.table.getByText(language === "de" ? "An:" : "To:", { exact: true }).first()).toBeVisible();
expect(fixture.detailReads).toBe(0);
await page.getByRole("textbox").first().fill("blind-2@example.test");
await expect(fixture.table.getByText("Report message 2", { exact: true })).toBeVisible();
await expect(fixture.table.getByText("Report message 1", { exact: true })).toHaveCount(0);
expect(fixture.detailReads).toBe(0); expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
});
test("workerless Report retry executes one canonical request and displays real acceptance", async ({ page }) => {
const fixture = await install(page);
await expect(page.getByRole("button", { name: "Queue temporary failures for workers", exact: true })).toBeDisabled();
await fixture.table.getByRole("button", { name: "Retry now", exact: true }).first().click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].path).toMatch(/\/jobs\/retry$/);
expect(fixture.writes[0].payload.job_ids).toEqual(["job-1"]);
await expect(page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last()).toBeEnabled();
await page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last().click();
await expect(page.getByText(/Delivery request finished: 1 attempted, 1 accepted, 0 failed/)).toBeVisible();
await page.reload();
await expect(fixture.table.getByText("Report message 1", { exact: true })).toBeVisible();
expect(fixture.jobs[0].send_status).toBe("smtp_accepted"); expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
});
test("page retry and unattempted continuation exclude accepted, active and uncertain messages", async ({ page }) => {
const fixture = await install(page);
expect(fixture.jobs[5].send_status).toBe("queued");
await page.getByRole("button", { name: "Retry failed messages on this page now (2)", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].payload.job_ids).toEqual(["job-1", "job-2"]);
expect(fixture.writes[0].payload.include_permanent).toBe(true);
await page.getByRole("dialog").getByRole("button", { name: /Close|Schließen/, exact: true }).last().click();
await page.getByRole("button", { name: "Send unattempted messages on this page now (1)", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(2);
expect(fixture.writes[1].path).toMatch(/\/jobs\/send-unattempted$/);
expect(fixture.writes[1].payload.job_ids).toEqual(["job-6"]);
expect(fixture.errors).toEqual([]);
});
test("reconciliation requires evidence, retains failed input, and retries only explicitly", async ({ page }) => {
const fixture = await install(page, { rejectFirst: true });
await fixture.table.getByRole("button", { name: /^(Not sent|Nicht gesendet)$/ }).and(page.locator(":enabled")).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("button", { name: "Record message as not sent", exact: true })).toBeDisabled();
await dialog.getByRole("textbox", { name: /^Evidence note/ }).fill("SMTP logs show no acceptance for this message ID.");
await dialog.getByRole("button", { name: "Record message as not sent", exact: true }).click();
await expect(dialog.getByText("Fixture recovery changed. Reload evidence before retrying.", { exact: false })).toBeVisible();
await expect(dialog.getByRole("textbox")).toHaveValue("SMTP logs show no acceptance for this message ID.");
expect(fixture.writes).toHaveLength(1);
await dialog.getByRole("button", { name: "Record message as not sent", exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(fixture.writes[1].payload.note).toBe("SMTP logs show no acceptance for this message ID.");
expect(fixture.jobs[2].send_status).toBe("failed_temporary");
expect(fixture.writes).toHaveLength(2); expect(fixture.errors).toEqual([]);
});
test("only a server-proven stale claim can be recovered, without implicit send or reconciliation", async ({ page }) => {
const fixture = await install(page);
await expect(fixture.table.getByText(/Processing is still active or its lease has not expired/)).toBeVisible();
await expect(fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).and(page.locator(":enabled"))).toHaveCount(1);
await fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).and(page.locator(":enabled")).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("textbox").fill("Previous process stopped; checked its expired lease and server logs.");
await dialog.getByRole("button", { name: "Mark outcome for investigation", exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(fixture.writes).toHaveLength(1); expect(fixture.writes[0].path).toContain("/jobs/job-4/recover-claim");
expect(fixture.writes[0].payload.expected_revision).toBe("claim-revision-4");
expect(fixture.jobs[3].send_status).toBe("outcome_unknown"); expect(fixture.jobs[4].send_status).toBe("sending");
expect(fixture.errors).toEqual([]);
});
test("pending recovery cannot duplicate or dismiss, and read-only Report cannot mutate", async ({ page }) => {
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
const fixture = await install(page, { holdWrite: held });
await fixture.table.getByRole("button", { name: /^(Accepted|Angenommen)$/ }).and(page.locator(":enabled")).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("textbox").fill("Verified SMTP acceptance in the server log.");
await dialog.getByRole("button", { name: "Record SMTP acceptance", exact: true }).dblclick();
await expect.poll(() => fixture.writes.length).toBe(1);
await page.keyboard.press("Escape");
await expect(dialog).toBeVisible(); await expect(dialog.getByRole("textbox")).toBeDisabled();
release(); await expect(dialog).toHaveCount(0);
await page.goto("/?campaign-report&language=en&read-only");
await expect(fixture.table.getByRole("button", { name: "Retry now", exact: true }).first()).toBeDisabled();
await expect(fixture.table.getByRole("button", { name: "Recover interrupted SMTP processing", exact: true }).first()).toBeDisabled();
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
});
test("queued remainder can continue by row, but previous SMTP, Postbox or Print attempts cannot", async ({ page }) => {
const fixture = await install(page);
const continuations = fixture.table.getByRole("button", { name: "Send unattempted message now", exact: true }).and(page.locator(":enabled"));
for (const field of ["attempt_count", "postbox_attempt_count", "print_attempt_count"] as const) {
fixture.jobs[5][field] = 1;
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(continuations).toHaveCount(0);
await expect(page.getByRole("button", { name: "Send unattempted messages on this page now (0)", exact: true })).toBeDisabled();
fixture.jobs[5][field] = 0;
}
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(continuations).toHaveCount(1);
await continuations.click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].payload.job_ids).toEqual(["job-6"]);
expect(fixture.jobs[3].send_status).toBe("sending");
expect(fixture.jobs[2].send_status).toBe("outcome_unknown");
expect(fixture.errors).toEqual([]);
});
test("active, queued and incomplete IMAP counts drill down to the exact current states", async ({ page }) => {
const fixture = await install(page);
for (const [label, count, ids] of [
["Sending", 2, [4, 5]], ["Queued", 1, [6]], ["Pending", 1, [1]],
["Copying to Sent", 1, [2]], ["Outcome uncertain", 1, [3]]
] as const) {
const shortcut = page.locator("dt").filter({ hasText: new RegExp(`^${label}$`) }).locator("..").getByRole("button");
await expect(shortcut).toHaveText(String(count));
await shortcut.click();
await expect(shortcut).toHaveAttribute("aria-pressed", "true");
await expect(fixture.table.getByText(/^Report message \d+$/)).toHaveCount(ids.length);
for (const id of ids) await expect(fixture.table.getByText(`Report message ${id}`, { exact: true })).toBeVisible();
}
expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
});
test("old report reads cannot restore rows after switching account context", async ({ page }) => {
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
const fixture = await install(page, { switchAccount: true });
fixture.holdNextJobsRead(held);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect.poll(() => fixture.heldReads).toBe(1);
fixture.jobs[0].subject = "New account message";
await page.getByRole("button", { name: "Switch fixture account" }).click();
await expect(fixture.table.getByText("New account message", { exact: true })).toBeVisible();
const oldResponse = page.waitForResponse(response => new URL(response.url()).pathname.endsWith("/jobs"));
release();
await oldResponse;
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await expect(fixture.table.getByText("Report message 1", { exact: true })).toHaveCount(0);
await expect(fixture.table.getByText("New account message", { exact: true })).toBeVisible();
expect(fixture.writes).toHaveLength(0); expect(fixture.errors).toEqual([]);
});
test("an old recovery acknowledgment cannot reopen progress in another account context", async ({ page }) => {
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
const fixture = await install(page, { switchAccount: true, holdWrite: held });
await fixture.table.getByRole("button", { name: "Retry now", exact: true }).first().click();
await expect.poll(() => fixture.writes.length).toBe(1);
await expect(page.getByRole("dialog")).toBeVisible();
// Simulate an externally changed authentication provider while the request is pending.
await page.getByRole("button", { name: "Switch fixture account" }).evaluate(button => (button as HTMLButtonElement).click());
await expect(page.getByRole("dialog")).toHaveCount(0);
const oldResponse = page.waitForResponse(response => new URL(response.url()).pathname.endsWith("/jobs/retry"));
release();
await oldResponse;
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await expect.poll(() => fixture.jobs[0].send_status).toBe("smtp_accepted");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(page.getByText(/Delivery request finished:/)).toHaveCount(0);
expect(fixture.writes).toHaveLength(1); expect(fixture.errors).toEqual([]);
});
test("long recipient names and addresses wrap without clipping their contents", async ({ page }) => {
const fixture = await install(page);
const email = `${"long-address-segment-".repeat(5)}@example.test`;
fixture.jobs[0].resolved_recipients.bcc = [{ email }];
await page.getByRole("button", { name: "Reload", exact: true }).click();
const cell = fixture.table.locator(".campaign-report-recipient-cell").filter({ hasText: email });
await expect(cell.getByText(email, { exact: true })).toBeVisible();
await expect.poll(() => cell.evaluate(node => ({ overflow: node.scrollWidth - node.clientWidth, height: node.getBoundingClientRect().height })))
.toMatchObject({ overflow: 0 });
expect(await cell.getByText(email, { exact: true }).evaluate(node => getComputedStyle(node).overflowWrap)).toBe("anywhere");
expect(fixture.errors).toEqual([]);
});
@@ -0,0 +1,55 @@
import { expect, test } from "@playwright/test";
for (const language of ["en", "de"]) {
test(`${language}: validation cause/outcome groups and repeated files are fully paginated`, async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message));
await page.goto(`/?campaign-review-details&language=${language}`);
const validation = page.getByRole("region", { name: "Validation details fixture" });
const repeated = page.getByRole("region", { name: "Repeated files fixture" });
const next = language === "en" ? "Next page" : "Nächste Seite";
const previous = language === "en" ? "Previous page" : "Vorherige Seite";
const details = language === "en" ? "Technical details" : "Technische Details";
await expect(validation.locator("[data-validation-issue-group]")).toHaveCount(10);
const first = validation.locator("[data-validation-issue-group]").first();
await expect(first.getByText("Missing attachment for recipient 1.", { exact: true })).toBeVisible();
await expect(first.getByText("Policy excludes recipient 1 without attachments.", { exact: true })).toBeVisible();
await first.getByText(details, { exact: true }).click();
await expect(first.getByText(/\/entries\/recipient-1\/attachments\/0/)).toBeVisible();
await expect(first.getByText(/missing_attachment_coverage/)).toBeVisible();
await validation.getByRole("button", { name: next, exact: true }).click();
await expect(validation.locator("[data-validation-issue-group]")).toHaveCount(2);
await expect(validation.getByText("Missing attachment for recipient 12.", { exact: true })).toBeVisible();
await expect(validation.getByRole("button", { name: next, exact: true })).toBeDisabled();
await validation.getByRole("button", { name: previous, exact: true }).click();
await expect(validation.getByText("Missing attachment for recipient 1.", { exact: true })).toBeVisible();
await validation.getByRole("combobox").selectOption("25");
await expect(validation.locator("[data-validation-issue-group]")).toHaveCount(12);
await expect(repeated.getByText(/repeated-file-12\.pdf/)).not.toBeVisible();
await repeated.getByRole("button", { name: next, exact: true }).click();
await expect(repeated.getByText(/repeated-file-12\.pdf/)).toBeVisible();
await repeated.getByRole("button", { name: previous, exact: true }).click();
await expect(repeated.getByText(/repeated-file-1\.pdf/)).toBeVisible();
await expect(repeated.getByText(/repeated-file-12\.pdf/)).not.toBeVisible();
expect(errors).toEqual([]);
});
}
test("detail lists clamp their last page when refreshed results shrink", async ({ page }) => {
await page.goto("/?campaign-review-details&language=en");
const validation = page.getByRole("region", { name: "Validation details fixture" });
const repeated = page.getByRole("region", { name: "Repeated files fixture" });
await validation.getByRole("button", { name: "Next page", exact: true }).click();
await repeated.getByRole("button", { name: "Next page", exact: true }).click();
await expect(validation.getByText("Missing attachment for recipient 12.", { exact: true })).toBeVisible();
await expect(repeated.getByText(/repeated-file-12\.pdf/)).toBeVisible();
await page.getByRole("button", { name: "Reduce fixture details", exact: true }).click();
await expect(validation.locator("[data-validation-issue-group]")).toHaveCount(2);
await expect(validation.getByText("Missing attachment for recipient 1.", { exact: true })).toBeVisible();
await expect(repeated.getByText(/repeated-file-1\.pdf/)).toBeVisible();
await expect(repeated.getByText(/repeated-file-12\.pdf/)).not.toBeVisible();
for (const region of [validation, repeated]) {
await expect(region.getByRole("button", { name: "Previous page", exact: true })).toBeDisabled();
await expect(region.getByRole("button", { name: "Next page", exact: true })).toBeDisabled();
}
});
@@ -0,0 +1,321 @@
import { expect, test, type Page } from "@playwright/test";
async function install(page: Page, options: { rejectFirst?: boolean; conflictFirst?: boolean; stateMatrix?: boolean; holdSave?: Promise<void>; holdFirstJobs?: Promise<void>; rejectFirstJobs?: boolean; language?: "en" | "de"; readOnly?: boolean } = {}) {
const errors: string[] = [];
page.on("pageerror", error => { errors.push(error.message); console.error("Review fixture:", error.message); });
const writes: Record<string, any>[] = [];
const reads: string[] = [];
let revision = 1;
let complete = false;
let buildGeneration = 1;
let selectedVersionId = "review-version";
let jobRequests = 0;
const builds: Record<string, unknown>[] = [];
const publicBuildToken = () => selectedVersionId === "replacement-version" ? "replacement-build-reference"
: buildGeneration === 1 ? "opaque-build-reference" : `rebuilt-reference-${buildGeneration}`;
const subjectFor = (subject: string) => selectedVersionId === "replacement-version" ? `Replacement ${subject}`
: buildGeneration === 1 ? subject : `Rebuilt ${subject}`;
const decisions = new Map<string, Record<string, unknown>>();
const statuses = options.stateMatrix ? ["needs_review", "ready", "excluded", "warning", "blocked", "needs_review"] : ["needs_review", "needs_review", "excluded"];
if (options.stateMatrix) decisions.set("job-6", { job_id: "job-6", decision: "accept", reason: "Already reviewed this specific exception." });
const jobs = statuses.map((status, offset) => {
const index = offset + 1;
return { id: `job-${index}`, entry_id: `entry-${index}`, review_key: `entry-${index}`,
entry_index: index, recipient_email: `recipient-${index}@example.test`, subject: `Message ${index}`,
build_status: "built", validation_status: status, send_status: "not_queued",
resolved_recipients: { to: [{ email: `recipient-${index}@example.test` }] }, attachments: [],
issues: status === "ready" ? [] : [{ code: "missing_optional_attachment", behavior: status === "excluded" ? "drop" : status === "warning" ? "warn" : status === "blocked" ? "block" : "ask", severity: status === "blocked" ? "error" : "warning", source: "attachments", message: "Expected attachment was not found" }],
review_decision: { eligible: status === "needs_review", category_key: "attachment-group", reason_required: true, issue_codes: ["missing_optional_attachment"] }
}; });
const version = () => ({ id: selectedVersionId, campaign_id: "review-campaign", version_number: 1,
edit_revision: revision, strong_etag: `"${selectedVersionId}:${revision}"`, review_build_token: publicBuildToken(),
locked_at: "2026-09-07T09:00:00Z",
current_flow: "manual", current_step: "review", workflow_state: "built", is_complete: false,
updated_at: "2026-09-07T10:00:00Z", build_summary: { built_count: 2, needs_review_count: 2 }, validation_summary: { ok: true, warning_count: 14, issues: [] },
raw_json: { campaign: { name: "Review fixture" }, template: { subject: "Review", text: "Fixture message", html: "<p>Fixture message</p>" },
server: {}, entries: { inline: jobs.map(row => ({ id: row.entry_id, email: row.recipient_email, active: true })) } },
editor_state: { review_send: { review_build_token: publicBuildToken(), inspection_complete: complete,
reviewed_message_keys: [...decisions.keys()].map(id => `entry-${id.split("-")[1]}`), issue_decisions: [...decisions.values()] } }
});
const jobResponse = () => ({ jobs: jobs.map(row => ({ ...row, subject: subjectFor(row.subject), reviewed: decisions.has(row.id) })), page: 1, page_size: 200,
total: jobs.length, total_unfiltered: jobs.length, pages: 1, cursor: null, next_cursor: null, counts: {}, filtered_counts: {},
review: { blocking_count: options.stateMatrix ? 1 : 0, required_count: 2, bulk_acceptable_count: options.stateMatrix ? 1 : 0, reviewed_required_count: decisions.size, inspection_complete: complete } });
await page.route((url) => url.pathname.startsWith("/api/"), async route => {
const request = route.request(); const url = new URL(request.url());
if (request.method() === "GET") {
reads.push(url.pathname);
if (url.pathname.endsWith("/workspace/delta")) {
selectedVersionId = url.searchParams.get("version_id") ?? "review-version";
return route.fulfill({ json: {
campaign: { id: "review-campaign", name: "Review fixture", current_version_id: selectedVersionId, status: "draft" },
versions: [version()], current_version: version(), summary: { cards: { jobs_total: jobs.length, sent: 0, failed: 0 }, status_counts: {}, attachments: { missing_configs: 14 } },
deleted: [], full: true, has_more: false, watermark: `w-${revision}`
} }); }
if (url.pathname.endsWith("/jobs")) {
jobRequests++;
const snapshot = jobResponse();
if (jobRequests === 1 && options.holdFirstJobs) {
// A stale response advertising another page must not start that
// follow-up request after the selected build has changed.
snapshot.pages = 2;
await options.holdFirstJobs;
}
if (jobRequests === 1 && options.rejectFirstJobs) return route.fulfill({ status: 503, json: { detail: "Fixture initial review load failed" } });
return route.fulfill({ json: snapshot });
}
if (/\/jobs\/job-/.test(url.pathname)) return route.fulfill({ json: { job: jobs.find(row => url.pathname.endsWith(row.id)), attempts: [] } });
if (url.pathname.endsWith(`/versions/${selectedVersionId}`)) return route.fulfill({ json: version() });
return route.fulfill({ json: {} });
}
if (url.pathname.endsWith("/attachments/preview")) return route.fulfill({ json: {
campaign_id: "review-campaign", version_id: "review-version", shared_file_count: 0,
rules: [], linkable_files: [], unused_shared_files: []
} });
if (request.method() === "POST" && url.pathname.endsWith("/review-state")) {
const body = request.postDataJSON(); writes.push(body);
if (options.holdSave) await options.holdSave;
if (options.rejectFirst && writes.length === 1) return route.fulfill({ status: 503, json: { detail: "Fixture decision save unavailable" } });
if (options.conflictFirst && writes.length === 1) {
decisions.set("job-2", { job_id: "job-2", decision: "accept", reason: "Another reviewer checked this document.", actor_user_id: "other-reviewer" });
revision++;
return route.fulfill({ status: 409, json: { detail: "Another reviewer saved a decision. Inspect the refreshed progress and retry explicitly." } });
}
expect(body.merge_progress).toBe(true);
expect(body.build_token).toBe(publicBuildToken());
expect(body.base_revision).toBe(revision);
for (const decision of body.issue_decisions) decisions.set(decision.job_id, decision);
revision++; complete = body.inspection_complete;
return route.fulfill({ json: version() });
}
if (request.method() === "POST" && url.pathname.endsWith("/build")) {
builds.push(request.postDataJSON());
buildGeneration++; revision++; complete = false; decisions.clear();
return route.fulfill({ json: { built_count: 2, build_token: publicBuildToken() } });
}
return route.abort();
});
await page.goto(`/?campaign-review&language=${options.language ?? "en"}${options.readOnly ? "&read-only" : ""}${options.holdFirstJobs ? "&race" : ""}`);
if (options.holdFirstJobs || options.rejectFirstJobs) await expect.poll(() => jobRequests).toBe(1);
else await expect(page.getByText("Message 1", { exact: true }).first()).toBeVisible();
return { writes, reads, errors, decisions, builds, get jobRequests() { return jobRequests; } };
}
async function openMessage(page: Page, index = 1) {
const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true });
await expect(table.getByText(`Message ${index}`, { exact: true })).toBeVisible();
await table.getByRole("button", { name: /^(Review|Prüfung)$/ }).nth(index - 1).click();
await expect(page.getByRole("dialog")).toBeVisible();
return page.getByRole("dialog");
}
test("each acceptance persists immediately and advances, surviving reload before whole-review completion", async ({ page }) => {
const fixture = await install(page);
const dialog = await openMessage(page);
const readsBefore = fixture.reads.length;
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Recipient already received the document.");
expect(fixture.reads.length).toBe(readsBefore);
expect(fixture.writes).toHaveLength(0);
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
expect(fixture.writes).toHaveLength(1);
expect(fixture.writes[0].inspection_complete).toBe(false);
expect(fixture.writes[0].issue_decisions[0].reason).toBe("Recipient already received the document.");
expect(fixture.reads.length).toBe(readsBefore);
await page.reload();
await openMessage(page);
await expect(page.getByRole("dialog").getByText("Review decision saved. You can leave and continue later.")).toBeVisible();
expect(fixture.writes).toHaveLength(1);
expect(fixture.errors).toEqual([]);
});
test("failed review save keeps the reason and current message until explicit retry succeeds", async ({ page }) => {
const fixture = await install(page, { rejectFirst: true });
const dialog = await openMessage(page);
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Retain this reason.");
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
await expect(dialog.getByText(/Fixture decision save unavailable/)).toBeVisible();
await expect(dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ })).toHaveValue("Retain this reason.");
expect(fixture.decisions.size).toBe(0);
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
expect(fixture.writes).toHaveLength(2);
expect(fixture.errors).toEqual([]);
});
test("pending individual acceptance cannot duplicate, dismiss, or navigate before acknowledgement", async ({ page }) => {
let acknowledge!: () => void;
const fixture = await install(page, { holdSave: new Promise<void>((resolve) => { acknowledge = resolve; }) });
const dialog = await openMessage(page);
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Confirmed exception, waiting for durable save.");
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).evaluate((button: HTMLButtonElement) => {
button.click(); button.click();
});
await expect.poll(() => fixture.writes.length).toBe(1);
for (const navigation of await dialog.locator(".template-preview-nav button").all()) await expect(navigation).toBeDisabled();
for (const close of await dialog.getByRole("button", { name: /^(Close|Schließen)$/ }).all()) await expect(close).toBeDisabled();
await page.keyboard.press("Escape");
await expect(dialog).toBeVisible();
await expect(dialog.getByText("recipient-1@example.test", { exact: true }).first()).toBeVisible();
acknowledge();
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
expect(fixture.writes).toHaveLength(1);
expect(fixture.errors).toEqual([]);
});
test("German individual review records its reason and skips intentional exclusions", async ({ page }) => {
const fixture = await install(page, { language: "de" });
let dialog = await openMessage(page);
await dialog.getByRole("textbox", { name: /^Warum ist das in Ordnung\?/ }).fill("Das Dokument liegt dem Empfänger bereits vor.");
await dialog.getByRole("button", { name: "Akzeptieren und weiter", exact: true }).click();
await expect(dialog.getByText("recipient-2@example.test", { exact: true }).first()).toBeVisible();
await dialog.getByRole("textbox", { name: /^Warum ist das in Ordnung\?/ }).fill("Auch dieser Empfänger benötigt keinen weiteren Anhang.");
await dialog.getByRole("button", { name: "Akzeptieren und weiter", exact: true }).click();
await expect(dialog).not.toBeVisible();
expect(fixture.writes).toHaveLength(2);
expect(fixture.decisions.has("job-3")).toBe(false);
await page.reload();
dialog = await openMessage(page);
await expect(dialog.getByText("Prüfentscheidung gespeichert. Sie können die Prüfung später fortsetzen.")).toBeVisible();
expect(fixture.errors).toEqual([]);
});
test("real review grouped acceptance persists only eligible messages with exact category guard", async ({ page }) => {
const fixture = await install(page);
await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(1);
await page.getByRole("button", { name: "Accept similar review conditions", exact: true }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("checkbox")).toHaveCount(2);
await expect(dialog.getByRole("checkbox", { name: "recipient-3@example.test" })).toHaveCount(0);
await dialog.getByRole("textbox").fill("Both recipients have already received these documents.");
await dialog.getByRole("button", { name: "Accept 2 selected messages" }).click();
await expect(dialog).not.toBeVisible();
expect(fixture.writes).toHaveLength(1);
expect(fixture.writes[0]).toMatchObject({ merge_progress: true, build_token: "opaque-build-reference", base_revision: 1,
inspection_complete: false, decision_category_key: "attachment-group", reviewed_message_keys: ["entry-1", "entry-2"] });
expect(fixture.writes[0].issue_decisions).toEqual([
{ job_id: "job-1", decision: "accept", reason: "Both recipients have already received these documents." },
{ job_id: "job-2", decision: "accept", reason: "Both recipients have already received these documents." }
]);
await page.reload();
await expect(page.getByRole("button", { name: "Accept similar review conditions", exact: true })).toBeDisabled();
await openMessage(page, 2);
await expect(page.getByRole("dialog").getByText("Review decision saved. You can leave and continue later.")).toBeVisible();
expect(fixture.decisions.has("job-3")).toBe(false);
await page.getByRole("dialog").getByRole("button", { name: /^(Close|Schließen)$/ }).last().click();
await page.getByRole("button", { name: /^(Complete review|Prüfung abschließen)$/ }).click();
await expect.poll(() => fixture.writes.length).toBe(2);
expect(fixture.writes[1].inspection_complete).toBe(true);
const attachmentsPreflight = page.locator(".deliverability-preflight-item").filter({ has: page.getByText(/^(Attachments|Anhänge)$/) });
await expect(attachmentsPreflight).toHaveAttribute("data-state", "ready");
await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(0);
await page.reload();
await expect(attachmentsPreflight).toHaveAttribute("data-state", "ready");
await expect(page.getByText(/14 validation warning.*need review/)).toHaveCount(0);
expect(fixture.errors).toEqual([]);
});
test("read-only campaign access permits inspection but no review decisions", async ({ page }) => {
const fixture = await install(page, { readOnly: true });
await expect(page.getByRole("button", { name: "Accept similar review conditions", exact: true })).toBeDisabled();
await expect(page.getByRole("button", { name: /^(Complete review|Prüfung abschließen)$/ })).toBeDisabled();
const dialog = await openMessage(page);
const accept = dialog.getByRole("button", { name: "Accept and continue", exact: true });
if (await accept.count()) await expect(accept).toBeDisabled();
expect(fixture.writes).toHaveLength(0);
expect(fixture.errors).toEqual([]);
});
test("concurrent reviewer conflict refreshes evidence without replaying or overwriting their decision", async ({ page }) => {
const fixture = await install(page, { conflictFirst: true });
const dialog = await openMessage(page);
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Keep my independently inspected exception.");
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
await expect(dialog.getByText(/Another reviewer saved a decision/)).toBeVisible();
await expect(dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ })).toHaveValue("Keep my independently inspected exception.");
await expect(dialog.getByText("recipient-1@example.test", { exact: true }).first()).toBeVisible();
expect(fixture.writes).toHaveLength(1);
expect(fixture.decisions.has("job-1")).toBe(false);
expect(fixture.decisions.get("job-2")?.reason).toBe("Another reviewer checked this document.");
expect(fixture.reads.filter(path => path.endsWith("/versions/review-version"))).toHaveLength(1);
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
await expect(dialog).not.toBeVisible();
expect(fixture.writes).toHaveLength(2);
expect(fixture.writes[1].base_revision).toBe(2);
expect(fixture.writes[1].issue_decisions).toEqual([{ job_id: "job-1", decision: "accept", reason: "Keep my independently inspected exception." }]);
expect(fixture.decisions.get("job-2")?.actor_user_id).toBe("other-reviewer");
expect(fixture.errors).toEqual([]);
});
test("one four-state filter distinguishes automatic, accepted, warning, blocked and excluded messages", async ({ page }) => {
const fixture = await install(page, { stateMatrix: true, language: "de" });
const section = page.locator("#campaign-built-message-details");
const table = section.getByRole("table");
const stateCells = table.locator('.data-grid-body-cell[data-column-id="messageState"]');
await expect(table.locator('[data-column-id="validation"]')).toHaveCount(0);
await expect(table.locator('[data-column-id="reviewed"]')).toHaveCount(0);
await expect(stateCells).toHaveText(["Prüfung erforderlich", "Bereit", "Ausgeschlossen", "Prüfung erforderlich", "Blockiert", "Bereit"]);
await expect(table.locator('.data-grid-body-cell[data-column-id="stateExplanation"]').nth(1)).toContainText("keine manuelle Entscheidung erforderlich");
await expect(table.locator('.data-grid-body-cell[data-column-id="stateExplanation"]').nth(5)).toContainText("gespeicherter Prüfentscheidung");
await table.locator('.data-grid-header-cell[data-column-id="messageState"] .data-grid-filter-trigger').click();
const filter = page.locator(".data-grid-filter-popover");
await expect(filter.getByRole("checkbox")).toHaveCount(4);
for (const label of ["Bereit", "Prüfung erforderlich", "Blockiert", "Ausgeschlossen"]) await expect(filter.getByRole("checkbox", { name: label, exact: true })).toBeVisible();
await filter.getByRole("button", { name: "Alle abwählen", exact: true }).click();
await filter.getByRole("checkbox", { name: "Prüfung erforderlich", exact: true }).check();
await expect(stateCells).toHaveText(["Prüfung erforderlich", "Prüfung erforderlich"]);
await filter.getByRole("button", { name: /^(Close filter|Filter schließen)$/ }).click();
// The convenience action uses the very same derived state, adding blockers
// but never reintroducing accepted rows just because their build said ask.
await section.getByRole("button", { name: /^(Show review candidates only|Nur Prüfkandidaten anzeigen)$/ }).click();
await expect(stateCells).toHaveText(["Prüfung erforderlich", "Prüfung erforderlich", "Blockiert"]);
await section.getByRole("button", { name: /^(Show all messages|Alle Nachrichten anzeigen)$/ }).click();
await expect(stateCells).toHaveCount(6);
expect(fixture.writes).toHaveLength(0);
expect(fixture.errors).toEqual([]);
});
test("rebuilding a version loads its new review rows after the build busy state clears", async ({ page }) => {
const fixture = await install(page);
expect(fixture.jobRequests).toBe(1);
await page.getByRole("button", { name: /^(Build again|Erneut erstellen|Erneut bauen)$/ }).click();
const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true });
await expect(table.getByText("Rebuilt Message 1", { exact: true })).toBeVisible();
await expect(table.getByText("Message 1", { exact: true })).toHaveCount(0);
expect(fixture.builds).toHaveLength(1);
expect(fixture.jobRequests).toBe(2);
await table.getByRole("button", { name: /^(Review|Prüfung)$/ }).first().click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("textbox", { name: /^Why is this acceptable\?/ }).fill("Reviewed the rebuilt evidence.");
await dialog.getByRole("button", { name: "Accept and continue", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0]).toMatchObject({ build_token: "rebuilt-reference-2", base_revision: 2 });
expect(fixture.errors).toEqual([]);
});
test("a delayed old-version job response cannot replace the newly selected build", async ({ page }) => {
let releaseOld!: () => void;
const fixture = await install(page, { holdFirstJobs: new Promise<void>((resolve) => { releaseOld = resolve; }) });
await page.getByRole("button", { name: "Switch fixture version", exact: true }).click();
await expect(page).toHaveURL(/version=replacement-version/);
await expect.poll(() => fixture.reads.filter(path => path.endsWith("/workspace/delta")).length).toBeGreaterThanOrEqual(2);
releaseOld();
const table = page.getByRole("table", { name: "campaign-review-campaign-workflow-built-messages", exact: true });
await expect(table.getByText("Replacement Message 1", { exact: true })).toBeVisible();
await expect(table.getByText("Message 1", { exact: true })).toHaveCount(0);
expect(fixture.jobRequests).toBe(2);
expect(fixture.writes).toHaveLength(0);
expect(fixture.errors).toEqual([]);
});
test("an initial jobs failure stops automatic retries but a manual retry can recover", async ({ page }) => {
const fixture = await install(page, { rejectFirstJobs: true });
await expect(page.getByText(/Fixture initial review load failed/)).toBeVisible();
const retry = page.getByRole("button", { name: /^(Load review|Prüfung laden)$/ });
await expect(retry).toBeEnabled();
await page.waitForLoadState("networkidle");
expect(fixture.jobRequests).toBe(1);
await retry.click();
await expect(page.getByText("Message 1", { exact: true }).first()).toBeVisible();
expect(fixture.jobRequests).toBe(2);
expect(fixture.errors).toEqual([]);
});
@@ -0,0 +1,145 @@
import { expect, test, type Page } from "@playwright/test";
async function install(page: Page, options: { rejectSave?: boolean; rejectReload?: boolean; conflict?: boolean; hold?: Promise<void>; holdFirstRead?: Promise<void> } = {}) {
page.on("pageerror", (error) => { console.error("Campaign saving fixture:", error.message); });
let revision = 1;
let subject = "Original subject";
let rejected = false;
const writes: Record<string, unknown>[] = [];
let reads = 0;
const version = () => ({ id: "version-a", campaign_id: "campaign-a", version_number: 1,
edit_revision: revision, strong_etag: `"version-a:${revision}"`, editor_state: {},
current_flow: "manual", current_step: "template", workflow_state: "editing", is_complete: false,
updated_at: "2026-09-07T10:00:00Z", raw_json: { campaign: { name: "Original" }, template: { subject, text: "" }, server: {} } });
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
if (route.request().method() === "POST" && route.request().url().endsWith("/autosave")) {
const body = route.request().postDataJSON();
writes.push(body);
if (options.hold) await options.hold;
if (options.rejectSave && !rejected) {
rejected = true;
return route.fulfill({ status: 422, json: { detail: "Fixture policy denied this save" } });
}
if ((options.conflict && !rejected) || body.base_revision !== revision) {
rejected = true;
if (options.conflict) { revision = 2; subject = "Other editor's subject"; }
return route.fulfill({ status: 409, json: { detail: { code: "revision_conflict", resource: { type: "campaign_version", id: "version-a" }, current_revision: revision, submitted_base_revision: body.base_revision, retryable: true } } });
}
subject = body.campaign_json.template.subject;
revision += 1;
return route.fulfill({ json: version() });
}
if (route.request().method() === "GET" && route.request().url().includes("/versions/version-a")) {
reads++;
const snapshot = version();
if (reads === 1 && options.holdFirstRead) await options.holdFirstRead;
return options.rejectReload ? route.fulfill({ status: 503, json: { detail: "Fixture refresh unavailable" } }) : route.fulfill({ json: snapshot });
}
return route.abort();
});
await page.goto("/?campaign-saving&language=en");
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Original subject");
return { writes, reads: () => reads, subject: () => subject };
}
test("a rejected campaign save retains its draft and retries only on explicit request", async ({ page }) => {
const fixture = await install(page, { rejectSave: true });
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Unsaved work");
await page.getByRole("button", { name: "Save draft", exact: true }).click();
await expect(page.getByTestId("save-error")).toContainText("Fixture policy denied");
await expect(page.getByTestId("save-dirty")).toHaveText("true");
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Unsaved work");
expect(fixture.writes).toHaveLength(1);
await page.getByRole("button", { name: "Save draft", exact: true }).click();
await expect(page.getByTestId("save-result")).toHaveText("true");
await expect(page.getByTestId("save-dirty")).toHaveText("false");
expect(fixture.writes).toHaveLength(2);
expect(fixture.subject()).toBe("Unsaved work");
});
test("duplicate save requests coalesce and newer typing survives the older acknowledgement", async ({ page }) => {
let release!: () => void;
const fixture = await install(page, { hold: new Promise<void>((resolve) => { release = resolve; }) });
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Submitted work");
await page.getByRole("button", { name: "Request save twice", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
await expect(page.getByRole("button", { name: "Save draft", exact: true })).toBeDisabled();
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Newer unsaved work");
release();
await expect(page.getByTestId("save-busy")).toHaveText("false");
await expect(page.getByTestId("save-dirty")).toHaveText("true");
await expect(page.getByTestId("save-status")).toContainText("newer changes are still unsaved");
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Newer unsaved work");
expect(fixture.subject()).toBe("Submitted work");
expect(fixture.writes).toHaveLength(1);
await page.getByRole("button", { name: "Save draft", exact: true }).click();
await expect(page.getByRole("alertdialog", { name: "Concurrent changes" })).toBeVisible();
await page.getByRole("tab", { name: "Use my change", exact: true }).click();
await page.getByRole("button", { name: "Apply resolution", exact: true }).click();
await expect(page.getByTestId("save-dirty")).toHaveText("false");
expect(fixture.subject()).toBe("Newer unsaved work");
});
test("failed follow-up refresh never turns an acknowledged save into save failed", async ({ page }) => {
const fixture = await install(page, { rejectReload: true });
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Committed work");
await page.getByRole("button", { name: "Save draft", exact: true }).click();
await expect(page.getByTestId("save-result")).toHaveText("true");
await expect(page.getByTestId("save-status")).toContainText("Saved");
await expect(page.getByTestId("save-error")).toContainText("was saved");
await expect(page.getByTestId("save-dirty")).toHaveText("false");
expect(fixture.writes).toHaveLength(1);
});
test("cancelling a revision conflict ends saving and keeps the local draft", async ({ page }) => {
const fixture = await install(page, { conflict: true });
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("My conflicting work");
await page.getByRole("button", { name: "Save draft", exact: true }).click();
const dialog = page.getByRole("alertdialog", { name: "Concurrent changes" });
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(page.getByTestId("save-busy")).toHaveText("false");
await expect(page.getByTestId("save-status")).toContainText("Save cancelled");
await expect(page.getByTestId("save-dirty")).toHaveText("true");
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("My conflicting work");
expect(fixture.writes).toHaveLength(1);
});
test("background version refresh and failed discard do not silently clear unsaved work", async ({ page }) => {
await install(page, { rejectReload: true });
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Keep this draft");
await page.getByRole("button", { name: "Background refresh", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Keep this draft");
await page.getByRole("button", { name: "Discard draft", exact: true }).click();
await expect(page.getByTestId("save-error")).toContainText("Fixture refresh unavailable");
await expect(page.getByTestId("save-dirty")).toHaveText("true");
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Keep this draft");
});
test("new typing during a discard refresh is retained instead of being replaced by the late response", async ({ page }) => {
let release!: () => void;
const fixture = await install(page, { holdFirstRead: new Promise<void>(resolve => { release = resolve; }) });
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Earlier draft");
await page.getByRole("button", { name: "Discard draft", exact: true }).click();
await expect.poll(fixture.reads).toBe(1);
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Newer draft");
release();
await expect(page.getByTestId("save-error")).toContainText("Discard cancelled");
await expect(page.getByTestId("save-dirty")).toHaveText("true");
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Newer draft");
expect(fixture.writes).toHaveLength(0);
});
test("a late discard response cannot overwrite an acknowledged save", async ({ page }) => {
let release!: () => void;
const fixture = await install(page, { holdFirstRead: new Promise<void>(resolve => { release = resolve; }) });
await page.getByRole("textbox", { name: "Subject", exact: true }).fill("Saved work");
await page.getByRole("button", { name: "Discard draft", exact: true }).click();
await expect.poll(fixture.reads).toBe(1);
await page.getByRole("button", { name: "Save draft", exact: true }).click();
await expect(page.getByTestId("save-result")).toHaveText("true");
release();
await expect(page.getByRole("textbox", { name: "Subject", exact: true })).toHaveValue("Saved work");
await expect(page.getByTestId("save-dirty")).toHaveText("false");
expect(fixture.subject()).toBe("Saved work");
});
@@ -0,0 +1,127 @@
import { expect, test, type Page, type Route } from "@playwright/test";
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => { resolve = done; });
return { promise, resolve };
}
function response(campaignId = "campaign-a", revision = 1, versionId = `${campaignId}-version`, hasMore = false) {
const version = { id: versionId, campaign_id: campaignId, version_number: 1, edit_revision: revision, raw_json: {} };
return {
campaign: { id: campaignId, current_version_id: versionId },
versions: [version], current_version: version, summary: null,
deleted: [], watermark: `watermark-${campaignId}-${revision}`, has_more: hasMore, full: true
};
}
async function fixture(page: Page) {
const requests: URL[] = [];
const state = {
handler: async (route: Route) => {
const url = new URL(route.request().url());
const campaignId = url.pathname.split("/")[4];
await route.fulfill({ json: response(campaignId, 1, url.searchParams.get("version_id") ?? undefined) });
}
};
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith("/workspace/delta")) {
requests.push(url);
await state.handler(route);
} else await route.fulfill({ json: {} });
});
await page.goto("/?campaign-workspace");
await expect(page.getByTestId("workspace-version")).toHaveText("campaign-a-version");
await expect(page.getByTestId("workspace-loading")).toHaveText("false");
requests.length = 0;
return { state, requests };
}
test("failed refresh retains saved workspace and retries without a stale watermark", async ({ page }) => {
const { state, requests } = await fixture(page);
state.handler = async (route) => { await route.fulfill({ status: 503, json: { detail: "Refresh unavailable" } }); };
await page.getByRole("button", { name: "Force reload workspace", exact: true }).click();
await expect(page.getByTestId("workspace-error")).toContainText("Refresh unavailable");
await expect(page.getByTestId("workspace-loading")).toHaveText("false");
await expect(page.getByTestId("workspace-version")).toHaveText("campaign-a-version");
state.handler = async (route) => { await route.fulfill({ json: response("campaign-a", 2) }); };
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
await expect(page.getByTestId("workspace-revision")).toHaveText("2");
expect(requests[requests.length - 1].searchParams.has("since")).toBe(false);
await expect(page.getByTestId("workspace-error")).toBeEmpty();
});
for (const staleFails of [false, true]) {
test(`newer reload wins over an older ${staleFails ? "failed" : "successful"} response`, async ({ page }) => {
const { state } = await fixture(page);
const pending = deferred(); const finished = deferred();
let requestCount = 0;
state.handler = async (route) => {
requestCount += 1;
if (requestCount === 1) {
await pending.promise;
await route.fulfill(staleFails ? { status: 503, json: { detail: "Obsolete error" } } : { json: response("campaign-a", 2) });
finished.resolve();
} else await route.fulfill({ json: response("campaign-a", 3) });
};
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
await expect.poll(() => requestCount).toBe(1);
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
await expect(page.getByTestId("workspace-revision")).toHaveText("3");
pending.resolve(); await finished.promise;
await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
await expect(page.getByTestId("workspace-revision")).toHaveText("3");
await expect(page.getByTestId("workspace-loading")).toHaveText("false");
await expect(page.getByTestId("workspace-error")).toBeEmpty();
});
}
for (const change of ["campaign", "identity", "version"] as const) {
test(`changing ${change} discards old responses and cancels obsolete pagination`, async ({ page }) => {
const { state, requests } = await fixture(page);
const pending = deferred(); const finished = deferred();
let requestCount = 0;
state.handler = async (route) => {
requestCount += 1;
if (requestCount === 1) {
await pending.promise;
await route.fulfill({ json: response("campaign-a", 99, "obsolete-version", true) });
finished.resolve();
} else await route.fulfill({ json: response(change === "campaign" ? "campaign-b" : "campaign-a", 2, "fresh-version") });
};
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
await expect.poll(() => requestCount).toBe(1);
await page.getByRole("button", { name: { campaign: "Switch campaign", identity: "Switch identity", version: "Select version" }[change] }).click();
await expect(page.getByTestId("workspace-version")).toHaveText("fresh-version");
pending.resolve(); await finished.promise;
await page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done))));
await expect(page.getByTestId("workspace-version")).toHaveText("fresh-version");
expect(requests).toHaveLength(2);
expect(requests[1].searchParams.has("since")).toBe(false);
});
}
test("failed initial load for a different campaign never reveals the old campaign", async ({ page }) => {
const { state } = await fixture(page);
state.handler = async (route) => { await route.fulfill({ status: 403, json: { detail: "Campaign unavailable" } }); };
await page.getByRole("button", { name: "Switch campaign" }).click();
await expect(page.getByTestId("workspace-error")).toContainText("Campaign unavailable");
await expect(page.getByTestId("workspace-campaign")).toHaveText("none");
await expect(page.getByTestId("workspace-version")).toHaveText("none");
});
test("a failed later page never publishes a partially refreshed workspace", async ({ page }) => {
const { state } = await fixture(page);
let requestCount = 0;
state.handler = async (route) => {
requestCount += 1;
await route.fulfill(requestCount === 1
? { json: response("campaign-a", 4, "partial-version", true) }
: { status: 503, json: { detail: "Second page unavailable" } });
};
await page.getByRole("button", { name: "Reload workspace", exact: true }).click();
await expect(page.getByTestId("workspace-error")).toContainText("Second page unavailable");
await expect(page.getByTestId("workspace-version")).toHaveText("campaign-a-version");
await expect(page.getByTestId("workspace-revision")).toHaveText("1");
});
@@ -0,0 +1,177 @@
import { expect, test, type Page } from "@playwright/test";
const credential = {
id: "fixture-credential", name: "Mailbox login", description: "Fixture metadata only",
credential_kind: "username_password", scope_type: "tenant", scope_id: null,
public_data: { username: "fixture@example.test" }, secret_configured: true,
allowed_modules: ["mail"], allowed_server_refs: ["mail:smtp-server", "mail:imap-server", "mail:deleted-server"],
inherit_to_lower_scopes: false, is_active: true,
created_at: "2026-01-01T12:00:00Z", updated_at: "2026-01-01T12:00:00Z"
};
async function installFixtures(page: Page, release: Promise<void>, options: { failFirst?: boolean; paginated?: boolean; denied?: boolean; retrySave?: Promise<void> } = {}) {
let loads = 0;
let savedCredential = credential;
const saves: Record<string, unknown>[] = [];
const forbiddenRequests: string[] = [];
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
if (request.method() === "GET" && url.pathname === "/api/v1/credentials") {
await route.fulfill({ json: { credentials: [savedCredential] } });
return;
}
if (request.method() === "PATCH" && url.pathname === `/api/v1/credentials/${credential.id}` && options.retrySave) {
const body = request.postDataJSON() as Record<string, unknown>;
saves.push(body);
await options.retrySave;
if (saves.length === 1) {
await route.fulfill({ status: 503, json: { detail: "Fixture save failed; keep your draft and retry." } });
} else {
savedCredential = { ...credential, name: String(body.name) };
await route.fulfill({ json: savedCredential });
}
return;
}
if (request.method() === "GET" && url.pathname === "/api/v1/platform/modules") {
await route.fulfill({ json: { modules: [{ id: "mail", label: "Mail", version: "1" }] } });
return;
}
if (request.method() === "GET" && url.pathname === "/api/v1/mail/settings/delta") {
loads += 1;
const number = loads;
await release;
if (options.denied || (options.failFirst && number === 1)) {
await route.fulfill({ status: options.denied ? 403 : 503, json: { detail: "Fixture catalogue unavailable" } });
return;
}
expect(url.searchParams.get("scope_type")).toBe("tenant");
expect(url.searchParams.get("include_inactive")).toBe("true");
if (options.paginated && url.searchParams.get("since")) {
await route.fulfill({ json: { full: false, has_more: false, watermark: "fixture-watermark-2", profiles: [],
deleted: [{ id: "deleted-profile", resource_type: "mail_profile" }] } });
return;
}
await route.fulfill({ json: {
full: true, has_more: Boolean(options.paginated), watermark: "fixture-watermark", deleted: [],
profiles: [{ id: "fixture-profile", name: "Town hall", slug: "town-hall", servers: [
{ id: "smtp-server", name: "Outgoing town hall", protocol: "smtp", is_active: true, scope_type: "tenant" },
{ id: "imap-server", name: "Incoming town hall", protocol: "imap", is_active: false, scope_type: "tenant" }
] }, ...(options.paginated ? [{ id: "deleted-profile", name: "Removed profile", slug: "removed", servers: [
{ id: "deleted-server", name: "Removed server", protocol: "smtp", is_active: true, scope_type: "tenant" }
] }] : [])]
} });
return;
}
forbiddenRequests.push(`${request.method()} ${url.pathname}`);
await route.fulfill({ status: 403, json: { detail: "Unexpected fixture request" } });
});
return { get loads() { return loads; }, forbiddenRequests, saves };
}
test("credential server labels resolve on first opening despite StrictMode cleanup", async ({ page }) => {
page.on("pageerror", (error) => { throw error; });
let release!: () => void;
const fixture = await installFixtures(page, new Promise<void>((resolve) => { release = resolve; }));
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Edit reusable credential", exact: true });
await expect(dialog).toBeVisible();
await expect.poll(() => fixture.loads).toBeGreaterThan(0);
release();
const servers = dialog.getByRole("list", { name: "Credential servers selected" });
await expect(servers).toContainText("Outgoing town hall");
await expect(servers).toContainText("Incoming town hall");
await expect(servers.locator(".is-inactive")).toContainText("Incoming town hall");
await expect(servers.locator(".is-unavailable")).toContainText("mail:deleted-server");
await expect(servers.locator(".is-unavailable")).toHaveCount(1);
await dialog.locator('input[data-help-context-id="access.credentials.field.name"]').fill("Renamed metadata only");
await expect(servers).toContainText("Outgoing town hall");
expect(fixture.loads).toBe(1);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("paginated server catalogues remove deleted profiles while retaining their selected references", async ({ page }) => {
const fixture = await installFixtures(page, Promise.resolve(), { paginated: true });
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
const servers = page.getByRole("list", { name: "Credential servers selected" });
await expect(servers).toContainText("Outgoing town hall");
await expect(servers.locator(".is-unavailable")).toHaveCount(1);
await expect(servers.locator(".is-unavailable")).toContainText("mail:deleted-server");
await expect(servers).not.toContainText("Removed server");
expect(fixture.loads).toBe(2);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("denied server metadata remains unavailable without dropping stored references", async ({ page }) => {
const fixture = await installFixtures(page, Promise.resolve(), { denied: true });
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
const servers = page.getByRole("list", { name: "Credential servers selected" });
await expect(servers.locator(".is-unavailable")).toHaveCount(3);
for (const reference of credential.allowed_server_refs) await expect(servers).toContainText(reference);
await expect(servers).not.toContainText("Outgoing town hall");
expect(fixture.forbiddenRequests).toEqual([]);
});
test("temporary server-catalogue failures can recover by reopening the editor", async ({ page }) => {
const fixture = await installFixtures(page, Promise.resolve(), { failFirst: true });
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
await expect(page.getByRole("list", { name: "Credential servers selected" }).locator(".is-unavailable")).toHaveCount(3);
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
await expect(page.getByRole("list", { name: "Credential servers selected" })).toContainText("Outgoing town hall");
expect(fixture.loads).toBe(2);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("closing while servers load does not poison the next credential editor", async ({ page }) => {
let release!: () => void;
const fixture = await installFixtures(page, new Promise<void>((resolve) => { release = resolve; }));
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
await expect.poll(() => fixture.loads).toBeGreaterThan(0);
await page.getByRole("dialog").getByRole("button", { name: "Cancel", exact: true }).click();
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
release();
const servers = page.getByRole("list", { name: "Credential servers selected" });
await expect(servers).toContainText("Outgoing town hall");
await expect(servers).toContainText("Incoming town hall");
expect(fixture.forbiddenRequests).toEqual([]);
});
test("failed credential saves show their error inside the editor and retain the draft for explicit retry", async ({ page }) => {
let finishSave!: () => void;
const retrySave = new Promise<void>((resolve) => { finishSave = resolve; });
const fixture = await installFixtures(page, Promise.resolve(), { retrySave });
await page.goto("/?credential-references&language=en");
await page.getByRole("button", { name: "Edit Mailbox login", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Edit reusable credential", exact: true });
const name = dialog.locator('input[data-help-context-id="access.credentials.field.name"]');
const secret = dialog.locator('input[type="password"]');
await name.fill("Renamed mailbox credential");
await secret.fill("synthetic-fixture-replacement");
await dialog.getByRole("button", { name: "Save credential", exact: true }).click();
await expect.poll(() => fixture.saves.length).toBe(1);
await expect(name).toBeDisabled();
await expect(secret).toBeDisabled();
await expect(dialog.getByRole("button", { name: "Cancel", exact: true })).toBeDisabled();
await expect(dialog.locator(".dialog-close")).toBeDisabled();
await page.keyboard.press("Escape");
await expect(dialog).toBeVisible();
finishSave();
await expect(dialog.getByRole("alert")).toContainText("Fixture save failed; keep your draft and retry.");
await expect(page.getByRole("alert")).toHaveCount(1);
await expect(name).toHaveValue("Renamed mailbox credential");
await expect(secret).toHaveValue("synthetic-fixture-replacement");
expect(fixture.saves).toHaveLength(1);
await dialog.getByRole("button", { name: "Save credential", exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByRole("button", { name: "Edit Renamed mailbox credential", exact: true })).toBeVisible();
expect(fixture.saves).toHaveLength(2);
expect(fixture.saves[1]).toEqual(fixture.saves[0]);
expect(fixture.saves[1].allowed_server_refs).toEqual(credential.allowed_server_refs);
expect(fixture.forbiddenRequests).toEqual([]);
});
@@ -0,0 +1,141 @@
import { expect, test, type Page } from "@playwright/test";
const actionCell = (page: Page) => page.locator('.data-grid-body-cell[data-column-id="actions"]');
const header = (page: Page, id: string) => page.locator(`.data-grid-header-cell[data-column-id="${id}"]`);
const width = (page: Page, id: string) => header(page, id).evaluate((element) => element.getBoundingClientRect().width);
async function expectActionsUnclipped(page: Page) {
await expect.poll(async () => actionCell(page).evaluate((cell) => {
const bounds = cell.getBoundingClientRect();
const scroller = cell.closest(".data-grid-scroll-region")!;
const viewport = scroller.getBoundingClientRect();
return Array.from(cell.querySelectorAll("button")).every((button) => {
const rect = button.getBoundingClientRect();
return rect.width >= 35 && rect.left >= bounds.left && rect.right <= bounds.right
&& rect.top >= bounds.top && rect.bottom <= bounds.bottom
&& rect.left >= viewport.left && rect.right <= viewport.right;
});
})).toBe(true);
}
test("undersized action tracks fit real controls and stay visible through narrow horizontal scrolling", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/?data-grid-layout");
await expect.poll(() => width(page, "actions")).toBeGreaterThanOrEqual(181);
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Narrow grid", exact: true }).click();
await expect.poll(() => width(page, "actions")).toBeLessThanOrEqual(160);
const scroller = page.locator(".data-grid-scroll-region");
await expectActionsUnclipped(page);
await scroller.evaluate((element) => { element.scrollLeft = element.scrollWidth / 2; });
await expectActionsUnclipped(page);
await scroller.evaluate((element) => { element.scrollLeft = element.scrollWidth; });
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Inspect Alpha", exact: true }).click();
await expect(page.getByTestId("clicked-action")).toHaveText("Inspect Alpha");
await scroller.focus();
await expect(scroller).toBeFocused();
// The application shell's supported viewport floor is 320px; its padded
// content area is narrower and must still contain the complete action set.
await page.setViewportSize({ width: 320, height: 720 });
await expectActionsUnclipped(page);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
expect(errors).toEqual([]);
});
test("pointer and keyboard resizing persists, cancels safely, and adapts to container resize", async ({ page }) => {
await page.goto("/?data-grid-layout");
await expectActionsUnclipped(page);
const handle = header(page, "name").getByRole("separator");
await expect(handle).toHaveAttribute("aria-orientation", "vertical");
const initial = await width(page, "name");
await handle.focus();
await handle.press("Shift+ArrowRight");
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 70, box.y + box.height / 2, { steps: 5 });
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 110, 0);
await page.keyboard.press("Escape");
await page.mouse.up();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
await page.getByRole("button", { name: "Narrow grid", exact: true }).click();
await expect.poll(() => width(page, "name")).toBeLessThan(initial + 40);
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Wide grid", exact: true }).click();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
await handle.press("Enter");
await expect.poll(() => width(page, "name")).toBeCloseTo(initial, 0);
const resetBox = (await handle.boundingBox())!;
await page.mouse.move(resetBox.x + resetBox.width / 2, resetBox.y + resetBox.height / 2);
await page.mouse.down();
await page.mouse.move(resetBox.x + resetBox.width / 2 + 50, resetBox.y + resetBox.height / 2, { steps: 5 });
await page.mouse.up();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 50, 0);
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 50, 0);
});
test("free content tracks retain explicit user resizing on remount", async ({ page }) => {
await page.goto("/?data-grid-layout&mode=free");
const handle = header(page, "name").getByRole("separator");
await expectActionsUnclipped(page);
const initial = await width(page, "name");
await handle.press("ArrowRight");
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 10, 0);
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await page.getByRole("button", { name: "Toggle grid mount", exact: true }).click();
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 10, 0);
});
test("empty and changing action sets reserve the same complete slots", async ({ page }) => {
await page.goto("/?data-grid-layout");
await expectActionsUnclipped(page);
const initial = await width(page, "actions");
await page.getByRole("button", { name: "Toggle empty rows", exact: true }).click();
await expectActionsUnclipped(page);
await expect.poll(() => width(page, "actions")).toBeCloseTo(initial, 0);
await page.getByRole("button", { name: "Toggle empty rows", exact: true }).click();
await page.getByRole("button", { name: "Toggle extra action", exact: true }).click();
await expect.poll(() => width(page, "actions")).toBeGreaterThan(initial + 35);
await expectActionsUnclipped(page);
});
test("constrained resizing redistributes tracks without introducing overflow", async ({ page }) => {
await page.goto("/?data-grid-layout&mode=constrained");
await expectActionsUnclipped(page);
const initial = await width(page, "name");
await header(page, "name").getByRole("separator").press("Shift+ArrowRight");
await expect.poll(() => width(page, "name")).toBeCloseTo(initial + 40, 0);
expect(await page.locator(".data-grid-scroll-region").evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await expectActionsUnclipped(page);
});
test("explicit composite action groups include outer controls in their measured minimum", async ({ page }) => {
await page.goto("/?data-grid-layout&mode=composite");
await expect.poll(() => width(page, "actions")).toBeGreaterThanOrEqual(221);
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Narrow grid", exact: true }).click();
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Extra control", exact: true }).click();
await expect(page.getByTestId("clicked-action")).toHaveText("Extra control");
});
test("oversized explicit sticky minima release stickiness instead of obscuring every data column", async ({ page }) => {
await page.goto("/?data-grid-layout&mode=oversized");
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Narrow grid", exact: true }).click();
await expect(page.locator(".data-grid-shell")).toHaveClass(/data-grid-release-sticky/);
const name = page.locator('.data-grid-body-cell[data-column-id="name"]');
await expect(name).toBeInViewport();
await page.locator(".data-grid-scroll-region").evaluate((element) => { element.scrollLeft = element.scrollWidth; });
await expectActionsUnclipped(page);
await page.getByRole("button", { name: "Wide grid", exact: true }).click();
await expect(page.locator(".data-grid-shell")).not.toHaveClass(/data-grid-release-sticky/);
});
@@ -0,0 +1,52 @@
import { expect, test, type Locator } from "@playwright/test";
async function expectDialogFits(dialog: Locator) {
await expect(dialog).toBeVisible();
const sizes = await dialog.evaluate((panel) => {
const body = panel.querySelector<HTMLElement>(".dialog-body")!;
const rect = panel.getBoundingClientRect();
const overflowControls = Array.from(panel.querySelectorAll("input,select,textarea,.dialog-close,.dialog-footer button")).filter((control) => {
const bounds = control.getBoundingClientRect();
return bounds.left < rect.left - 1 || bounds.right > rect.right + 1;
}).map((control) => control.tagName);
return { horizontalOverflow: body.scrollWidth - body.clientWidth, left: rect.left, right: rect.right, viewport: window.innerWidth, overflowControls };
});
expect(sizes.horizontalOverflow).toBeLessThanOrEqual(1);
expect(sizes.left).toBeGreaterThanOrEqual(0);
expect(sizes.right).toBeLessThanOrEqual(sizes.viewport);
expect(sizes.overflowControls).toEqual([]);
}
for (const width of [320, 390, 768, 1280]) {
test(`shared dialog keeps narrow form content and local table scrolling inside ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto("/?dialog-layout");
const dialog = page.getByRole("dialog");
await expectDialogFits(dialog);
const tableScroll = page.getByTestId("dialog-local-scroll");
expect(await tableScroll.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true);
await tableScroll.evaluate((element) => { element.scrollLeft = element.scrollWidth; });
expect(await tableScroll.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0);
await expectDialogFits(dialog);
});
for (const language of ["en", "de"]) {
test(`actual Templates Add dialog fits ${width}px in ${language}`, async ({ page }) => {
const unexpectedWrites: string[] = [];
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
if (request.method() !== "GET") unexpectedWrites.push(`${request.method()} ${request.url()}`);
await route.fulfill({ json: { items: [] } });
});
await page.setViewportSize({ width, height: 900 });
await page.goto(`/?dialog-layout&fixture=templates&language=${language}`);
await page.getByRole("button", { name: language === "de" ? "Vorlage hinzufügen" : "Add template", exact: true }).click();
const dialog = page.getByRole("dialog");
await expectDialogFits(dialog);
await dialog.locator("input").fill("Eine sehr lange Vorlagenbezeichnung ".repeat(8));
await expectDialogFits(dialog);
await expect(dialog.locator(".dialog-footer button").last()).toBeEnabled();
expect(unexpectedWrites).toEqual([]);
});
}
}
@@ -0,0 +1,62 @@
import { expect, test } from "@playwright/test";
for (const initiallyEmpty of [false, true]) {
test(`Files ignores a late ${initiallyEmpty ? "space discovery" : "folder listing"} Reload after session change`, async ({ page }) => {
let release!: () => void;
const heldReload = new Promise<void>((resolve) => { release = resolve; });
let holdNextRead = false;
let holdingRead = false;
const unexpected: string[] = [];
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
if (request.method() !== "GET") {
unexpected.push(`${request.method()} ${path}`);
await route.fulfill({ status: 403, json: { detail: "Fixture forbids writes" } });
return;
}
const next = request.headers().authorization === "Bearer fixture-next-session";
const actor = next ? "fixture-next-user" : "fixture-user";
const name = next ? "new-session.zip" : "old-session.zip";
const delayed = holdNextRead && !next && path === (initiallyEmpty ? "/api/v1/files/spaces" : "/api/v1/files");
if (delayed) {
holdNextRead = false;
holdingRead = true;
await heldReload;
}
if (path === "/api/v1/files/spaces") {
await route.fulfill({ json: { spaces: initiallyEmpty && !next && !delayed ? [] : [
{ id: `user:${actor}`, label: next ? "New session files" : "Old session files", owner_type: "user", owner_id: actor, space_type: "managed" }
] } });
} else if (path === "/api/v1/files/folders") {
await route.fulfill({ json: { folders: [], total: 0, next_cursor: null } });
} else if (path === "/api/v1/files") {
await route.fulfill({ json: { files: [{ id: `${actor}-archive`, tenant_id: "fixture-tenant", owner_type: "user", owner_id: actor,
display_path: name, filename: name, size_bytes: 42, content_type: "application/zip", checksum_sha256: "a".repeat(64), version_id: "fixture-version",
created_at: "2026-01-01T12:00:00Z", updated_at: "2026-01-01T12:00:00Z", audit_relevant: false, shares: [], metadata: {}, deleted_at: null
}], total: 1, next_cursor: null } });
} else {
unexpected.push(`${request.method()} ${path}`);
await route.fulfill({ status: 404, json: { detail: "Unconfigured fixture read" } });
}
});
await page.goto("/?files-toolbar&session-switch&language=en");
if (initiallyEmpty) await expect(page.getByText("No file spaces available.", { exact: true })).toBeVisible();
else await expect(page.locator(".file-row").filter({ hasText: "old-session.zip" })).toBeVisible();
const reload = page.locator('[data-interface-id="files.workspace.actions"]').getByRole("button", { name: "Reload", exact: true });
await expect(reload).toBeEnabled();
holdNextRead = true;
await reload.click();
await expect.poll(() => holdingRead).toBe(true);
await page.getByRole("button", { name: "Switch fixture session", exact: true }).click();
await expect(page.locator(".file-row").filter({ hasText: "new-session.zip" })).toBeVisible();
const oldResponse = page.waitForResponse((response) => response.url().includes(initiallyEmpty ? "/api/v1/files/spaces" : "/api/v1/files?") && response.request().headers().authorization !== "Bearer fixture-next-session");
release();
await oldResponse;
await expect(page.locator(".file-tree-root")).toHaveText(["New session files"]);
await expect(page.locator(".file-row")).toHaveCount(1);
await expect(page.locator(".file-row")).toContainText("new-session.zip");
await expect(reload).toBeEnabled();
expect(unexpected).toEqual([]);
});
}
@@ -0,0 +1,199 @@
import { expect, test, type Page } from "@playwright/test";
const archive = {
id: "fixture-archive", tenant_id: "fixture-tenant", owner_type: "user", owner_id: "fixture-user",
display_path: "small-archive.zip", filename: "small-archive.zip", size_bytes: 280,
content_type: "application/zip", checksum_sha256: "a".repeat(64), version_id: "fixture-version-7",
created_at: "2026-01-01T12:00:00Z", updated_at: "2026-01-01T12:00:00Z", audit_relevant: false,
shares: [], metadata: {}, deleted_at: null
};
async function fixtures(page: Page, connector = false) {
const reads: string[] = [];
const forbidden: string[] = [];
const state = { fail: false };
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname;
const readOnlyPattern = request.method() === "POST" && path === "/api/v1/files/resolve-patterns";
if (request.method() !== "GET" && !readOnlyPattern) {
forbidden.push(`${request.method()} ${path}`);
await route.fulfill({ status: 403, json: { detail: "Fixture refuses all writes" } });
return;
}
reads.push(`${request.method()} ${path}${url.search}`);
if (state.fail && (path === "/api/v1/files" || path.endsWith("/browse"))) {
await route.fulfill({ status: 503, json: { detail: "Fixture listing temporarily unavailable" } });
return;
}
if (path === "/api/v1/files/spaces") {
await route.fulfill({ json: { spaces: [
{ id: "user:fixture-user", label: "My files", owner_type: "user", owner_id: "fixture-user", space_type: "managed" },
...(connector ? [{ id: "connector:fixture-remote", label: "Remote archive", owner_type: "user", owner_id: "fixture-user", space_type: "connector", connector_space_id: "fixture-remote", connector_profile_id: "fixture-profile", remote_path: "source", provider: "s3", read_only: true, sync_mode: "manual" }] : [])
] } });
} else if (path === "/api/v1/files/folders") {
await route.fulfill({ json: { folders: [], total: 0, next_cursor: null } });
} else if (path === "/api/v1/files") {
await route.fulfill({ json: { files: [archive], total: 1, next_cursor: null } });
} else if (readOnlyPattern) {
await route.fulfill({ json: { patterns: [{ pattern: "*.zip", matches: [archive] }], unmatched: [] } });
} else if (path === "/api/v1/files/connectors/profiles") {
await route.fulfill({ json: { profiles: [] } });
} else if (path.endsWith("/fixture-profile/browse")) {
const folder = url.searchParams.get("path") ?? "source";
await route.fulfill({ json: { profile_id: "fixture-profile", provider: "s3", path: folder, library_id: null,
read_only: true, has_more: false, decision: { allowed: true }, items: folder.endsWith("/nested")
? [{ kind: "file", name: "remote-letter.pdf", path: "source/nested/remote-letter.pdf", metadata: {}, size_bytes: 42 }]
: [{ kind: "folder", name: "nested", path: "source/nested", metadata: {} }]
} });
} else {
forbidden.push(`${request.method()} ${path}`);
await route.fulfill({ status: 404, json: { detail: "Unconfigured fixture read" } });
}
});
return { reads, forbidden, state };
}
const header = (page: Page) => page.locator('[data-interface-id="files.workspace.actions"]');
const archiveRow = (page: Page) => page.locator(".file-row").filter({ hasText: "small-archive.zip" });
test("Files global Reload, Create folder and primary Upload remain above both workspace panes", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&language=en");
await expect(archiveRow(page)).toBeVisible();
const toolbar = header(page);
await expect(toolbar).toHaveAttribute("data-workspace-action-scope", "workspace");
await expect(toolbar.getByRole("link", { name: "Open user documentation", exact: true })).toBeVisible();
await expect(toolbar.locator('[data-page-action-group="trailing"] button')).toHaveText(["Reload", /Create Folder/, /Upload/]);
const [bar, reload, create, upload, tree] = await Promise.all([
toolbar.boundingBox(), toolbar.getByRole("button", { name: "Reload", exact: true }).boundingBox(),
toolbar.getByRole("button", { name: "Create Folder", exact: true }).boundingBox(),
toolbar.getByRole("button", { name: "Upload", exact: true }).boundingBox(), page.locator(".file-tree-panel").boundingBox()
]);
expect(bar && reload && create && upload && tree).toBeTruthy();
expect(reload!.x + reload!.width).toBeLessThanOrEqual(create!.x);
expect(create!.x + create!.width).toBeLessThanOrEqual(upload!.x);
expect(upload!.x + upload!.width).toBeGreaterThan(bar!.x + bar!.width - 24);
expect(bar!.y + bar!.height).toBeLessThanOrEqual(tree!.y + 1);
await expect(toolbar.getByRole("button", { name: "Upload", exact: true })).toHaveClass(/primary/);
await expect(page.locator('.file-list-sticky [data-workspace-action-scope="workspace"]')).toHaveCount(0);
expect(fixture.forbidden).toEqual([]);
});
test("Files selection tools preserve organization, access and confirmed destructive actions", async ({ page }) => {
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&language=en");
await archiveRow(page).click();
await expect(page.getByRole("button", { name: "Unpack archive", exact: true })).toBeEnabled();
await page.getByRole("button", { name: "Manage selection", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Manage selection", exact: true });
await expect(dialog).toContainText("1 file");
for (const name of ["Move", "Copy", "Rename", "Manage shares", "Explain access"]) {
await expect(dialog.getByRole("button", { name, exact: true })).toBeEnabled();
}
const remove = dialog.locator('[data-page-action-separation="destructive"]').getByRole("button", { name: "Delete", exact: true });
await remove.click();
await expect(dialog).toHaveCount(0);
await expect(page.getByRole("alertdialog")).toContainText(/delete|Delete/);
await page.getByRole("alertdialog").getByRole("button", { name: "Cancel", exact: true }).click();
await archiveRow(page).click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Unpack archive", exact: true })).toBeVisible();
expect(fixture.forbidden).toEqual([]);
});
test("Files Connections and imports exposes explicit tools without starting synchronization", async ({ page }) => {
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&language=en");
await expect(archiveRow(page)).toBeVisible();
const initialReads = fixture.reads.length;
await header(page).getByRole("button", { name: "Connections and imports", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Connections and imports", exact: true });
await expect(dialog).toContainText("never synchronizes or imports");
await expect(dialog.getByRole("button", { name: "Sync", exact: true })).toBeEnabled();
await dialog.getByRole("button", { name: "Add Space", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Add connector space", exact: true })).toBeVisible();
expect(fixture.reads.slice(initialReads).every((read) => read.startsWith("GET "))).toBe(true);
expect(fixture.forbidden).toEqual([]);
});
test("Files Reload preserves active pattern and property filters and retains loaded data after failure", async ({ page }) => {
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&language=en");
await expect(archiveRow(page)).toBeVisible();
await page.locator(".file-search-row input:not([type=checkbox])").fill("*.zip");
await page.locator(".file-search-row").getByRole("button", { name: "Search", exact: true }).click();
await page.getByRole("combobox", { name: "Campaign use", exact: true }).selectOption("linked");
await page.getByRole("button", { name: "Apply filters", exact: true }).click();
await expect(page.getByRole("button", { name: "Clear filters", exact: true })).toBeVisible();
const before = fixture.reads.length;
await header(page).getByRole("button", { name: "Reload", exact: true }).click();
await expect(header(page).getByRole("button", { name: "Reload", exact: true })).toBeEnabled();
await expect.poll(() => fixture.reads.slice(before).some((read) => read.startsWith("POST /api/v1/files/resolve-patterns"))).toBe(true);
await expect.poll(() => fixture.reads.slice(before).some((read) => read.includes("campaign_usage=linked"))).toBe(true);
await expect(header(page).getByRole("button", { name: "Reload", exact: true })).toBeEnabled();
await expect(page.locator(".file-search-row input:not([type=checkbox])")).toHaveValue("*.zip");
await expect(page.getByRole("combobox", { name: "Campaign use", exact: true })).toHaveValue("linked");
fixture.state.fail = true;
await header(page).getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByText(/Fixture listing temporarily unavailable/)).toBeVisible();
await expect(archiveRow(page)).toBeVisible();
await expect(page.getByRole("button", { name: "Clear filters", exact: true })).toBeVisible();
expect(fixture.forbidden).toEqual([]);
});
test("Files reader keeps creation visible with permission reasons and cannot use selected write tools", async ({ page }) => {
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&read-only&language=en");
await expect(archiveRow(page)).toBeVisible();
const upload = header(page).getByRole("button", { name: "Upload", exact: true });
await expect(upload).toBeDisabled();
await header(page).locator(".disabled-action-tooltip").filter({ has: page.getByRole("button", { name: "Upload", exact: true }) }).focus();
await expect(page.getByRole("tooltip")).toContainText(/upload permission/i);
await expect(header(page).getByRole("button", { name: "Create Folder", exact: true })).toBeDisabled();
await archiveRow(page).click();
await expect(page.getByRole("button", { name: "Unpack archive", exact: true })).toBeDisabled();
await page.getByRole("button", { name: "Manage selection", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Manage selection", exact: true });
for (const name of ["Move", "Copy", "Rename", "Manage shares", "Explain access", "Delete"]) {
await expect(dialog.getByRole("button", { name, exact: true })).toBeDisabled();
}
expect(fixture.forbidden).toEqual([]);
});
test("Files connector Reload only re-browses the selected remote folder and retains it on failure", async ({ page }) => {
const fixture = await fixtures(page, true);
await page.goto("/?files-toolbar&language=en");
await expect(archiveRow(page)).toBeVisible();
await page.locator(".file-tree-root").filter({ hasText: "Remote archive" }).click();
await page.locator(".file-list-panel").getByText("nested", { exact: true }).dblclick();
await expect(page.getByText("remote-letter.pdf", { exact: true })).toBeVisible();
const before = fixture.reads.length;
await header(page).getByRole("button", { name: "Reload", exact: true }).click();
await expect(header(page).getByRole("button", { name: "Reload", exact: true })).toBeEnabled();
await expect.poll(() => fixture.reads.slice(before)).toEqual(["GET /api/v1/files/connectors/profiles/fixture-profile/browse?path=source%2Fnested"]);
await expect(header(page).getByRole("button", { name: "Reload", exact: true })).toBeEnabled();
fixture.state.fail = true;
await header(page).getByRole("button", { name: "Reload", exact: true }).click();
await expect(page.getByText(/Fixture listing temporarily unavailable/)).toBeVisible();
await expect(page.getByText("remote-letter.pdf", { exact: true })).toBeVisible();
expect(fixture.forbidden).toEqual([]);
});
test("Files grouped tools and creation remain reachable without horizontal overflow on narrow German screens", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const fixture = await fixtures(page);
await page.goto("/?files-toolbar&language=de");
await expect(archiveRow(page)).toBeVisible();
await expect(header(page).getByRole("button", { name: "Hochladen", exact: true })).toBeVisible();
await header(page).getByRole("button", { name: "Verbindungen und Importe", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Verbindungen und Importe", exact: true });
await expect(dialog).toBeVisible();
const overflow = await dialog.evaluate((element) => element.scrollWidth - element.clientWidth);
expect(overflow).toBeLessThanOrEqual(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
expect(fixture.forbidden).toEqual([]);
});
@@ -0,0 +1,49 @@
import { expect, test } from "@playwright/test";
for (const width of [1440, 900, 390]) {
test(`mixed form controls and attachment actions remain aligned at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 1200 });
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.route((url) => url.pathname.startsWith("/api/"), (route) => route.abort());
await page.goto("/?form-control-layout&language=en");
const grid = page.getByTestId("mixed-form-grid");
const password = grid.locator('input[type="password"]');
const remove = grid.locator(".toggle-switch-row").filter({ hasText: "Remove configured secret" });
await expect(remove).toBeVisible();
if (width > 1100) {
for (const [input, toggle] of [
[password, remove.locator(".toggle-switch-track")],
[grid.getByRole("textbox", { name: "Wrapped field", exact: true }), grid.locator(".content-grid-item .toggle-switch-track")],
[page.getByRole("textbox", { name: "Other setting", exact: true }), page.getByTestId("mixed-form-layout").locator(".toggle-switch-track")]
]) {
await expect.poll(async () => {
const a = await input.boundingBox(), b = await toggle.boundingBox();
return a && b ? Math.abs(a.y + a.height / 2 - b.y - b.height / 2) : Infinity;
}).toBeLessThan(2);
}
const long = await grid.getByRole("textbox", { name: "Long label field" }).boundingBox();
const short = await grid.getByRole("textbox", { name: "Short label field" }).boundingBox();
expect(Math.abs(long!.y - short!.y)).toBeLessThan(2);
} else {
const field = await password.boundingBox(), toggle = await remove.boundingBox();
expect(toggle!.y).toBeGreaterThan(field!.y + field!.height);
expect(toggle!.height).toBeLessThanOrEqual(64);
}
await remove.getByRole("checkbox").focus();
await page.keyboard.press("Space");
await expect(remove.getByRole("checkbox")).toBeChecked();
await password.fill("fixture-only");
await expect(remove.getByRole("checkbox")).toBeDisabled();
await expect(page.getByTestId("compact-action").getByRole("button")).toHaveCSS("width", "36px");
const attachments = page.getByTestId("global-attachments");
const add = attachments.getByRole("button", { name: "Add first attachment", exact: true });
await expect(add).toHaveCSS("width", "36px");
await expect.poll(() => attachments.locator(".data-grid-empty-action-cell").evaluate((node) => node.getBoundingClientRect().width)).toBeLessThanOrEqual(200);
await add.click();
await expect(attachments.locator(".data-grid-empty-action-cell")).toHaveCount(0);
await expect(attachments.getByRole("button", { name: "Add attachment below", exact: true })).toHaveCSS("width", "36px");
await expect.poll(() => page.locator("main").evaluate((node) => node.scrollWidth <= node.clientWidth + 1)).toBe(true);
expect(errors).toEqual([]);
});
}
@@ -0,0 +1,72 @@
import { expect, test, type Page } from "@playwright/test";
async function installHelp(page: Page, language = "en") {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.route("**/api/v1/docs/context?**", (route) => {
const topic = (id: string, title: string, metadata = {}) => ({
id, title, source_module_id: "forms", kind: "reference", anchor_id: `topic-${id}`, summary: "Application guidance", body: "Public service application instructions", order: 1,
active: true, layer: "configured", target_layer: "configured", links: [], unlocks: [], related_modules: [], area_module_ids: ["forms", "services"],
metadata: { tags: ["Application", "Antrag"], ...metadata }, blockers: { modules: [], capabilities: [], scopes: [], configuration: [] }
});
return route.fulfill({ json: {
actor: { documentation_type: new URL(route.request().url()).searchParams.get("type") ?? "user", available_documentation_types: ["user", "admin"] },
versions: { supported_versions: [], installed_versions: {}, fallback_policy: "Installed versions" },
topic_groups: { system: [], pattern: [], workflow: [], reference: [topic("service", "Service application"), topic("field", "Application field", { parent_topic_id: "service" })] },
layers: { configured: { modules: [{ id: "forms", name: language === "de" ? "Formulare" : "Forms" }, { id: "services", name: language === "de" ? "Leistungen" : "Services" }], routes: [], permissions: [] },
available: { routes: [] }, evidence: { optional_modules: [], sources: [] } }
} });
});
await page.goto(`/?help-center&language=${language}`);
await expect(page.locator(".docs-tree-page").filter({ hasText: "Service application" }).first()).toBeVisible();
return errors;
}
test("help keeps repeated topic selection and expansion local to the clicked occurrence", async ({ page }) => {
const errors = await installHelp(page);
const area = page.locator(".docs-tree-node").filter({ has: page.locator(":scope > .docs-tree-row > .docs-tree-page", { hasText: /^Services$/ }) }).first();
await area.locator(":scope > .docs-tree-row > .docs-tree-toggle").click();
const repeated = area.locator(".docs-tree-node").filter({ has: page.locator(":scope > .docs-tree-row > .docs-tree-page", { hasText: /^Service application$/ }) }).first();
await repeated.locator(":scope > .docs-tree-row > .docs-tree-page").click();
await expect(page.locator(".docs-tree-page.is-active")).toHaveCount(1);
await expect(repeated.locator(":scope > .docs-tree-row > .docs-tree-page")).toHaveClass(/is-active/);
await repeated.locator(":scope > .docs-tree-row > .docs-tree-toggle").click();
await expect(page.locator(".docs-tree-page").filter({ hasText: /^Application field$/ })).toHaveCount(1);
await page.reload();
await expect(area.locator(".docs-tree-page.is-active")).toHaveText("Service application");
await expect(page.locator(".docs-tree-page.is-active")).toHaveCount(1);
expect(errors).toEqual([]);
});
for (const language of ["en", "de"]) test(`help finds topics from tags and localized area names (${language})`, async ({ page }) => {
const errors = await installHelp(page, language);
const search = page.getByRole("searchbox");
await search.fill(language === "de" ? "leistungen antrag" : "services application");
const results = page.getByRole("region", { name: language === "de" ? "Passende Hilfethemen" : "Matching help topics" });
await expect(results.getByRole("option", { name: "Service application", exact: true })).toHaveCount(1);
await expect(results.getByRole("option", { name: "Application field", exact: true })).toHaveCount(1);
await search.fill("not-found-tag");
await expect(results.getByRole("option")).toHaveCount(0);
await search.fill("antrag");
await results.getByRole("option", { name: "Service application", exact: true }).click();
await expect(search).toHaveValue("");
await expect(page.locator(".docs-page-main h2")).toHaveText("Service application");
await expect(page.locator(".docs-tree-page.is-active")).toHaveCount(1);
expect(errors).toEqual([]);
});
test("help tag dropdown shares list filtering and all/none behavior", async ({ page }) => {
const errors = await installHelp(page);
await page.getByRole("button", { name: "Areas and tags", exact: true }).click();
const filter = page.getByRole("dialog", { name: "Areas and tags", exact: true });
await filter.getByRole("button", { name: /clear all|deselect all|select none/i }).click();
const results = page.getByRole("region", { name: "Matching help topics" });
await expect(results.getByRole("option")).toHaveCount(0);
await filter.getByRole("checkbox", { name: "Services", exact: true }).check();
await expect(results.getByRole("option")).toHaveCount(2);
await filter.getByRole("button", { name: "Select all", exact: true }).click();
await expect(results).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(filter).toHaveCount(0);
expect(errors).toEqual([]);
});
@@ -0,0 +1,215 @@
import { expect, test, type Page } from "@playwright/test";
type Policy = {
smtp_credentials?: { inherit?: boolean | null };
imap_credentials?: { inherit?: boolean | null };
allow_lower_level_limits?: Record<string, boolean>;
[key: string]: unknown;
};
async function installPolicy(page: Page, options: { parent?: Policy | null; local?: Policy; failFirst?: boolean; failReadOnce?: boolean; holdSave?: Promise<void> } = {}) {
let current = options.local ?? {};
const parent = options.parent === null ? null : { smtp_credentials: { inherit: false }, imap_credentials: { inherit: true }, ...options.parent };
const writes: Policy[] = [];
const unexpected: string[] = [];
let reads = 0;
function response() {
const effective: Policy = { allow_user_profiles: true, allow_group_profiles: true, allow_campaign_profiles: true, ...parent, ...current };
for (const protocol of ["smtp", "imap"] as const) {
const key = `${protocol}_credentials` as const;
const locked = parent?.allow_lower_level_limits?.[`${key}.inherit`] === false;
effective[key] = { inherit: (!locked ? current[key]?.inherit : null) ?? parent?.[key]?.inherit ?? true };
}
return { policy: current, parent_policy: parent, effective_policy: effective, effective_policy_sources: [
{ scope_type: "system", label: "System", path: "system", applied_fields: [], policy: parent ?? {} },
{ scope_type: "tenant", label: "Tenant", path: "tenant", applied_fields: [], policy: current }
] };
}
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
if (new URL(request.url()).pathname.startsWith("/api/v1/mail/policies/")) {
if (request.method() === "GET") {
reads += 1;
if (options.failReadOnce && reads === 1) {
await route.fulfill({ status: 503, json: { detail: "Synthetic policy load failed" } }); return;
}
await route.fulfill({ json: response() }); return;
}
if (request.method() === "PUT") {
const submitted = request.postDataJSON().policy as Policy;
writes.push(submitted);
if (options.holdSave) await options.holdSave;
if (options.failFirst && writes.length === 1) {
await route.fulfill({ status: 503, json: { detail: "Synthetic policy write failed" } }); return;
}
current = submitted;
await route.fulfill({ json: response() }); return;
}
}
unexpected.push(`${request.method()} ${new URL(request.url()).pathname}`);
await route.fulfill({ status: 403, json: { detail: "Unexpected fixture request" } });
});
return { writes, unexpected, get reads() { return reads; } };
}
function credentialRows(page: Page) {
const section = page.getByTestId("mail-credential-policy");
return {
section,
smtp: section.locator(".policy-row").filter({ has: page.locator('select[aria-label="SMTP credential selection"]') }),
imap: section.locator(".policy-row").filter({ has: page.locator('select[aria-label="IMAP credential selection"]') })
};
}
test("Mail credential policy exposes inherited/effective choices and persists stable override keys", async ({ page }) => {
page.on("pageerror", (error) => { throw error; });
const fixture = await installPolicy(page);
await page.goto("/?mail-credential-policy&language=en");
const { smtp, imap } = credentialRows(page);
await expect(smtp.locator("select")).toHaveValue("inherit");
await expect(smtp.locator(".policy-effective-value")).toContainText("Require explicit Mail credential");
// An inherited false does not itself lock the child: only the separate
// lower-level limit may prevent choosing an inherited default credential.
await smtp.locator("select").selectOption("profile");
await imap.locator("select").selectOption("explicit");
await smtp.getByRole("checkbox", { name: "Allow override" }).focus();
await page.keyboard.press("Space");
const wildcard = page.locator(".mail-policy-pattern-row").filter({ hasText: "SMTP hostnames" });
await wildcard.getByRole("checkbox", { name: "Whitelist", exact: true }).focus();
await page.keyboard.press("Space");
const campaignRow = page.locator(".mail-policy-row").filter({ hasText: "Campaign-scoped profiles" });
await campaignRow.getByRole("checkbox", { name: "Allow override" }).focus();
await page.keyboard.press("Space");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].smtp_credentials).toEqual({ inherit: true });
expect(fixture.writes[0].imap_credentials).toEqual({ inherit: false });
expect(fixture.writes[0].allow_lower_level_limits).toMatchObject({
"smtp_credentials.inherit": false, "whitelist.smtp_hosts": false, "allow_campaign_profiles": false
});
expect(Object.keys(fixture.writes[0].allow_lower_level_limits ?? {}).some((key) => key.startsWith("i18n:"))).toBe(false);
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
expect(fixture.unexpected).toEqual([]);
});
test("ancestor credential locks cannot be bypassed or re-enabled and stale local overrides are cleared on save", async ({ page }) => {
const fixture = await installPolicy(page, {
parent: { allow_lower_level_limits: { "smtp_credentials.inherit": false } },
local: { smtp_credentials: { inherit: true }, allow_lower_level_limits: { "smtp_credentials.inherit": true } }
});
await page.goto("/?mail-credential-policy&language=en");
const { smtp, imap, section } = credentialRows(page);
await expect(smtp.locator("select")).toHaveValue("inherit");
await expect(smtp.locator("select")).toBeDisabled();
await expect(smtp.getByRole("checkbox", { name: "Allow override" })).toBeDisabled();
await expect(smtp.getByRole("checkbox", { name: "Allow override" })).not.toBeChecked();
await expect(section).toContainText("An ancestor has locked credential selection.");
await imap.locator("select").selectOption("explicit");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].smtp_credentials).toEqual({ inherit: null });
expect(fixture.writes[0].allow_lower_level_limits?.["smtp_credentials.inherit"]).toBeUndefined();
expect(fixture.unexpected).toEqual([]);
});
test("system defaults are concrete and campaign policy has no lower-level override controls", async ({ page }) => {
const fixture = await installPolicy(page, { parent: null });
await page.goto("/?mail-credential-policy&scope=system&language=en");
let { smtp, imap } = credentialRows(page);
await expect(smtp.locator("select")).toHaveValue("profile");
await expect(smtp.locator('option[value="inherit"]')).toHaveCount(0);
await smtp.locator("select").selectOption("explicit");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
expect(fixture.writes[0].smtp_credentials).toEqual({ inherit: false });
expect(fixture.writes[0].imap_credentials).toEqual({ inherit: true });
expect(fixture.writes[0].allow_lower_level_limits).toHaveProperty("allow_campaign_profiles", true);
await page.goto("/?mail-credential-policy&scope=campaign&language=en");
({ smtp, imap } = credentialRows(page));
await expect(smtp.getByRole("checkbox")).toHaveCount(0);
await expect(imap.getByRole("checkbox")).toHaveCount(0);
await smtp.locator("select").selectOption("profile");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(2);
expect(fixture.writes[1].allow_lower_level_limits).toEqual({});
expect(fixture.unexpected).toEqual([]);
});
test("read-only and workflow-locked policy scopes cannot change credential selection", async ({ page }) => {
const fixture = await installPolicy(page);
for (const blocker of ["read-only", "locked"]) {
await page.goto(`/?mail-credential-policy&language=en&${blocker}`);
const { smtp, imap } = credentialRows(page);
await expect(smtp.locator("select")).toBeDisabled();
await expect(imap.locator("select")).toBeDisabled();
await expect(smtp.getByRole("checkbox", { name: "Allow override" })).toBeDisabled();
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
}
expect(fixture.writes).toEqual([]);
expect(fixture.unexpected).toEqual([]);
});
test("successful policy saves remain successful when a dependent refresh fails", async ({ page }) => {
const fixture = await installPolicy(page);
await page.goto("/?mail-credential-policy&language=en&refresh-failure");
await credentialRows(page).smtp.locator("select").selectOption("profile");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect(page.getByRole("alert")).toContainText("Mail policy was saved, but refreshing dependent data failed");
await expect(page.locator(".alert.danger")).toHaveCount(0);
await expect(page.locator(".alert.success")).toContainText("Mail profile policy saved");
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
expect(fixture.writes).toHaveLength(1);
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect.poll(() => fixture.reads).toBeGreaterThan(1);
await expect(credentialRows(page).smtp.locator("select")).toHaveValue("profile");
expect(fixture.writes).toHaveLength(1);
expect(fixture.unexpected).toEqual([]);
});
test("failed policy writes retain the draft and require an explicit retry", async ({ page }) => {
let finish!: () => void;
const fixture = await installPolicy(page, { failFirst: true, holdSave: new Promise<void>((resolve) => { finish = resolve; }) });
await page.goto("/?mail-credential-policy&language=en");
const { smtp } = credentialRows(page);
await smtp.locator("select").selectOption("profile");
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect.poll(() => fixture.writes.length).toBe(1);
await expect(smtp.locator("select")).toBeDisabled();
finish();
await expect(page.locator(".alert.danger")).toContainText("Synthetic policy write failed");
await expect(smtp.locator("select")).toHaveValue("profile");
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeEnabled();
expect(fixture.writes).toHaveLength(1);
await page.getByRole("button", { name: "Save policy", exact: true }).click();
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
expect(fixture.writes).toHaveLength(2);
expect(fixture.writes[1]).toEqual(fixture.writes[0]);
expect(fixture.unexpected).toEqual([]);
});
test("failed policy loads cannot enable editing an unknown ancestor policy", async ({ page }) => {
const fixture = await installPolicy(page, { failReadOnce: true });
await page.goto("/?mail-credential-policy&language=en");
await expect(page.locator(".alert.danger")).toContainText("Synthetic policy load failed");
await expect(credentialRows(page).smtp.locator("select")).toBeDisabled();
await expect(page.getByRole("button", { name: "Save policy", exact: true })).toBeDisabled();
await page.getByRole("button", { name: "Reload", exact: true }).click();
await expect(credentialRows(page).smtp.locator("select")).toBeEnabled();
expect(fixture.writes).toEqual([]);
expect(fixture.unexpected).toEqual([]);
});
test("credential selection choices and explanations are available in German", async ({ page }) => {
const fixture = await installPolicy(page);
await page.goto("/?mail-credential-policy&language=de");
const section = page.getByTestId("mail-credential-policy");
await expect(section.getByRole("heading", { name: "Auswahl der Zugangsdaten", exact: true })).toBeVisible();
const smtp = section.getByRole("combobox", { name: "SMTP-Zugangsdaten auswählen", exact: true });
await expect(smtp).toBeEnabled();
await expect(smtp.locator('option[value="inherit"]')).toHaveText("Richtlinie vom übergeordneten Bereich erben");
await expect(smtp.locator('option[value="profile"]')).toHaveText("Standard-Zugangsdaten des Profils zulassen");
await expect(smtp.locator('option[value="explicit"]')).toHaveText("Ausdrückliche Mail-Zugangsdaten verlangen");
await expect(section).toContainText("Beide Optionen belassen Geheimnisse in Mail");
expect(fixture.writes).toEqual([]);
expect(fixture.unexpected).toEqual([]);
});
@@ -0,0 +1,48 @@
import { expect, test } from "@playwright/test";
test("Mail synthetic parent labels select without opening nonexistent folders or changing expansion", async ({ page }) => {
const errors: string[] = [];
const providerFolders: string[] = [];
const writes: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.route(url => url.pathname.startsWith("/api/"), async route => {
const request = route.request();
const url = new URL(request.url());
if (request.method() !== "GET") writes.push(url.pathname);
let body: unknown = {};
if (url.pathname === "/api/v1/mail/profiles") body = { profiles: [{
id: "tree-profile", name: "Fixture mailbox", is_active: true, scope_type: "tenant",
imap: { enabled: true, host: "imap.example.test", port: 993, security: "ssl", folder_mappings: { inbox: "INBOX" } }
}] };
else if (url.pathname.endsWith("/mailbox/bootstrap")) body = {
folder: "INBOX", folders: { ok: true, folders: [{ name: "INBOX", flags: [] }, { name: "Archive/2026", flags: [] }] },
messages: { ok: true, folder: "INBOX", messages: [], total_count: 0 }
};
else if (url.pathname.endsWith("/mailbox/messages")) {
providerFolders.push(url.searchParams.get("folder") ?? "");
body = { ok: true, folder: url.searchParams.get("folder"), messages: [], total_count: 0 };
}
return route.fulfill({ json: body });
});
await page.goto("/?mail-folder-explorer&language=en&theme=light");
const archive = page.locator('.explorer-tree-node').filter({ hasText: /^Archive$/ });
const archiveRow = page.locator('.explorer-tree-node-wrap').filter({ has: archive });
const toggle = archiveRow.locator(':scope > .explorer-tree-toggle');
await expect(archive).toBeEnabled();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
const initialReads = providerFolders.length;
await archive.click();
await expect(archive).toHaveAttribute("aria-current", "true");
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(page.getByText("This grouping contains mailbox folders.", { exact: false })).toBeVisible();
expect(providerFolders).toHaveLength(initialReads);
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await archive.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await page.locator('.explorer-tree-node').filter({ hasText: /^2026$/ }).click();
await expect.poll(() => providerFolders.includes("Archive/2026")).toBe(true);
expect(providerFolders).not.toContain("Archive");
expect(errors).toEqual([]);
expect(writes).toEqual([]);
});
@@ -0,0 +1,424 @@
import { expect, test, type Page, type Route } from "@playwright/test";
function deferred() {
let release!: () => void;
const promise = new Promise<void>(resolve => { release = resolve; });
return { promise, release };
}
function profile(id: string, protocol = "imap") {
return {
id, name: `${id} mailbox`, is_active: true, scope_type: "tenant",
...(protocol === "imap" ? { imap: { enabled: true, host: "imap.example.test", port: 993, security: "ssl", folder_mappings: { inbox: "INBOX" } } }
: { servers: [{ id: `${id}-jmap`, protocol: "jmap", is_active: true, is_default: true, config: { session_url: "https://jmap.example.test/session" } }] })
};
}
async function mockMailbox(page: Page) {
const state = {
profiles: [profile("Alpha"), profile("Beta")],
reads: [] as URL[], writes: [] as string[], errors: [] as string[],
failProfiles: false, failBootstrap: false, failDetail: false,
intercept: null as null | ((url: URL, route: Route) => Promise<boolean>),
releasedReads: 0
};
page.on("pageerror", error => state.errors.push(error.message));
await page.route(url => url.pathname.startsWith("/api/"), async route => {
const request = route.request();
const url = new URL(request.url());
if (request.method() !== "GET") {
state.writes.push(url.pathname);
return route.fulfill({ status: 405, json: { detail: "Fixture forbids mailbox writes" } });
}
state.reads.push(url);
if (state.intercept && await state.intercept(url, route)) return;
let body: unknown = {};
if (url.pathname === "/api/v1/mail/profiles") {
if (state.failProfiles) return route.fulfill({ status: 503, json: { detail: "Mailbox profile refresh unavailable" } });
body = { profiles: state.profiles };
} else if (url.pathname.endsWith("/mailbox/bootstrap")) {
if (state.failBootstrap) return route.fulfill({ status: 503, json: { detail: "Mailbox refresh unavailable" } });
body = bootstrap(url);
} else if (url.pathname.endsWith("/mailbox/folders")) body = folderCatalogue();
else if (url.pathname.endsWith("/mailbox/messages")) body = messageIndex(url);
else if (/\/mailbox\/messages\/[^/]+$/.test(url.pathname)) {
if (state.failDetail) return route.fulfill({ status: 503, json: { detail: "Message preview refresh unavailable" } });
body = messageDetail(url);
} else if (url.pathname.endsWith("/address-write-targets")) body = { available: false, targets: [] };
return route.fulfill({ json: body });
});
return state;
}
function folderCatalogue() {
return { ok: true, folders: [{ name: "INBOX", flags: [] }, { name: "Archive/2026", flags: [] }] };
}
function messageIndex(url: URL) {
const id = url.pathname.split("/")[5];
const folder = url.searchParams.get("folder") || "INBOX";
const offset = Number(url.searchParams.get("offset") ?? 0);
const limit = Number(url.searchParams.get("limit") ?? 10);
return {
profile_id: id, folder, total_count: 24, offset, limit,
next_cursor: "next-fixture-cursor", from_cache: false,
messages: Array.from({ length: Math.max(0, Math.min(limit, 24 - offset)) }, (_, index) => ({
uid: String(offset + index + 1), folder, subject: `${id} message ${offset + index + 1}`,
from_header: "Fixture sender <fixture@example.test>", to_header: "Reader <reader@example.test>",
flags: [], size_bytes: 1024, date: "2026-09-01T12:00:00Z"
}))
};
}
function bootstrap(url: URL) {
const messages = messageIndex(url);
return { profile_id: messages.profile_id, folder: messages.folder, folders: folderCatalogue(), messages };
}
function messageDetail(url: URL) {
const parts = url.pathname.split("/");
const uid = parts[parts.length - 1];
const summary = messageIndex(new URL(url.href.replace(/offset=[^&]*/, "offset=0"))).messages[0];
return { message: { ...summary, uid, subject: `${url.pathname.split("/")[5]} message ${uid}`, body_text: `Fixture body ${url.pathname.split("/")[5]} ${uid}`, headers: {}, attachments: [] } };
}
const workspaceBar = (page: Page) => page.locator('[data-workspace-action-scope="workspace"]');
const reload = (page: Page) => workspaceBar(page).locator('[data-page-action-slot="reload"] button');
const mailboxRows = (page: Page) => page.locator(".mailbox-message-row");
const mainMailboxReads = (reads: URL[]) => reads.filter(url => /\/mail\/profiles(?:$|\/[^/]+\/mailbox\/)/.test(url.pathname));
test("Mail has one persistent right-aligned Reload, including empty profiles, and recovers when a profile becomes available", async ({ page }) => {
const state = await mockMailbox(page);
state.profiles = [];
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(reload(page)).toBeEnabled();
await expect(workspaceBar(page)).toHaveCount(1);
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toBeDisabled();
await expect(page.getByRole("button", { name: "Mailbox tools", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: /Refresh (available profiles|folders only|messages only)/ })).toHaveCount(0);
const [barBox, reloadBox] = await Promise.all([workspaceBar(page).boundingBox(), reload(page).boundingBox()]);
expect(reloadBox!.x).toBeGreaterThan(barBox!.x + barBox!.width / 2);
expect(barBox!.x + barBox!.width - reloadBox!.x - reloadBox!.width).toBeLessThanOrEqual(20);
expect(barBox!.y).toBeLessThan(20);
state.profiles = [profile("Alpha")];
await reload(page).click();
await expect(mailboxRows(page)).toHaveCount(10);
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toHaveValue("Alpha");
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail Reload coherently refreshes catalogue, current IMAP page and selected preview without resetting expansion", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
expect((await page.locator(".mailbox-message-scroll").boundingBox())!.height).toBeGreaterThan(90);
await page.getByRole("button", { name: "Next page", exact: true }).click();
await expect(mailboxRows(page).first()).toContainText("Alpha message 11");
await mailboxRows(page).first().click();
await expect(page.getByText("Fixture body Alpha 11", { exact: true })).toBeVisible();
const group = page.locator(".explorer-tree-node-wrap").filter({ has: page.locator(".explorer-tree-node").filter({ hasText: /^Archive$/ }) });
const toggle = group.locator(":scope > .explorer-tree-toggle");
await toggle.click();
const before = state.reads.length;
await reload(page).click();
await expect(reload(page)).toBeEnabled();
await expect(mailboxRows(page).first()).toContainText("Alpha message 11");
await expect(mailboxRows(page).first()).toHaveClass(/is-selected/);
await expect(page.getByText("Fixture body Alpha 11", { exact: true })).toBeVisible();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
const reads = mainMailboxReads(state.reads.slice(before));
expect(reads.map(url => url.pathname)).toEqual(["/api/v1/mail/profiles", "/api/v1/mail/profiles/Alpha/mailbox/bootstrap", "/api/v1/mail/profiles/Alpha/mailbox/messages/11"]);
expect(reads[1].searchParams.get("offset")).toBe("10");
expect(reads[1].searchParams.get("refresh")).toBe("true");
expect(reads[1].searchParams.get("folder")).toBe("INBOX");
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail refresh failures preserve loaded index and preview, disclose failure, and leave recovery available", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
await mailboxRows(page).first().click();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
for (const failure of ["failProfiles", "failBootstrap", "failDetail"] as const) {
state[failure] = true;
await reload(page).click();
await expect(reload(page)).toBeEnabled();
await expect(workspaceBar(page)).toHaveAttribute("data-page-refresh-state", "reload-failed");
await expect(mailboxRows(page)).toHaveCount(10);
await expect(mailboxRows(page).first()).toHaveClass(/is-selected/);
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
state[failure] = false;
await reload(page).click();
await expect(workspaceBar(page)).toHaveAttribute("data-page-refresh-state", "current");
await expect(reload(page)).toBeEnabled();
}
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail grouping Reload refreshes only profiles and folders, preserving synthetic selection and collapsed state", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
const group = page.locator(".explorer-tree-node").filter({ hasText: /^Archive$/ });
await expect(group).toBeEnabled();
await group.click();
const before = state.reads.length;
await reload(page).click();
await expect(reload(page)).toBeEnabled();
await expect(group).toHaveAttribute("aria-current", "true");
await expect(page.locator(".explorer-tree-node-wrap").filter({ has: group }).locator(":scope > .explorer-tree-toggle")).toHaveAttribute("aria-expanded", "false");
await expect(mailboxRows(page)).toHaveCount(0);
expect(mainMailboxReads(state.reads.slice(before)).map(url => url.pathname)).toEqual(["/api/v1/mail/profiles", "/api/v1/mail/profiles/Alpha/mailbox/folders"]);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail Escape while Reload is pending does not reopen the dismissed preview", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
await mailboxRows(page).first().click();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
const held = deferred();
let started = false;
state.intercept = async (url, route) => {
if (url.pathname.endsWith("/mailbox/bootstrap") && url.searchParams.get("refresh")) {
started = true;
await held.promise;
await route.fulfill({ json: bootstrap(url) });
return true;
}
return false;
};
const before = state.reads.length;
await reload(page).click();
await expect.poll(() => started).toBe(true);
await page.keyboard.press("Escape");
held.release();
await expect(reload(page)).toBeEnabled();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toHaveCount(0);
await expect(page.locator(".mailbox-message-row.is-selected")).toHaveCount(0);
expect(state.reads.slice(before).filter(url => /\/mailbox\/messages\/[^/]+$/.test(url.pathname))).toHaveLength(0);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail failed pagination keeps retained rows labelled with their committed page and size", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
state.intercept = async (url, route) => {
if (url.pathname.endsWith("/mailbox/messages")) {
await route.fulfill({ status: 503, json: { detail: "Mailbox page unavailable" } });
return true;
}
return false;
};
const before = state.reads.length;
await page.getByRole("button", { name: "Next page", exact: true }).click();
await expect(reload(page)).toBeEnabled();
await expect(workspaceBar(page)).toHaveAttribute("data-page-refresh-state", "reload-failed");
await expect(page.locator(".data-grid-page-controls")).toContainText("Page 1 of 3");
await expect(mailboxRows(page).first()).toContainText("Alpha message 1");
expect(mainMailboxReads(state.reads.slice(before))).toHaveLength(1);
await page.getByRole("combobox", { name: "Rows per page" }).selectOption("25");
await expect(reload(page)).toBeEnabled();
await expect(page.getByRole("combobox", { name: "Rows per page" })).toHaveValue("10");
await expect(page.locator(".data-grid-page-controls")).toContainText("Page 1 of 3");
await expect(mailboxRows(page)).toHaveCount(10);
expect(mainMailboxReads(state.reads.slice(before))).toHaveLength(2);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail Reload reauthorizes profile availability and never keeps a removed account selected", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page).first()).toContainText("Alpha message 1");
state.profiles = [profile("Beta")];
await reload(page).click();
await expect(mailboxRows(page).first()).toContainText("Beta message 1");
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toHaveValue("Beta");
await expect(page.getByRole("option", { name: "Alpha mailbox" })).toHaveCount(0);
state.profiles = [];
await reload(page).click();
await expect(reload(page)).toBeEnabled();
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toBeDisabled();
await expect(mailboxRows(page)).toHaveCount(0);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail JMAP Reload restarts its cursor chain but retains the server-side search", async ({ page }) => {
const state = await mockMailbox(page);
state.profiles = [profile("Alpha", "jmap")];
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
await page.getByPlaceholder("Search messages").fill("message");
await expect.poll(() => state.reads.some(url => url.searchParams.get("q") === "message")).toBe(true);
await expect(reload(page)).toBeEnabled();
await page.getByRole("button", { name: "Next page", exact: true }).click();
await expect(mailboxRows(page).first()).toContainText("Alpha message 11");
const before = state.reads.length;
await reload(page).click();
await expect(reload(page)).toBeEnabled();
await expect(page.getByPlaceholder("Search messages")).toHaveValue("message");
await expect(page.locator(".data-grid-page-controls")).toContainText("Page 1 of 3");
await expect(mailboxRows(page).first()).toContainText("Alpha message 1");
const reads = mainMailboxReads(state.reads.slice(before));
expect(reads.map(url => url.pathname)).toEqual(["/api/v1/mail/profiles", "/api/v1/mail/profiles/Alpha/mailbox/bootstrap", "/api/v1/mail/profiles/Alpha/mailbox/messages"]);
expect(reads[2].searchParams.get("protocol")).toBe("jmap");
expect(reads[2].searchParams.get("q")).toBe("message");
expect(reads[2].searchParams.has("cursor")).toBe(false);
expect(reads[2].searchParams.get("offset")).toBe("0");
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail profile switches discard late bootstrap and preview responses instead of showing a previous account", async ({ page }) => {
const state = await mockMailbox(page);
const heldBootstrap = deferred();
let started = false;
state.intercept = async (url, route) => {
if (url.pathname === "/api/v1/mail/profiles/Alpha/mailbox/bootstrap") {
started = true;
await heldBootstrap.promise;
await route.fulfill({ json: bootstrap(url) });
state.releasedReads += 1;
return true;
}
return false;
};
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect.poll(() => started).toBe(true);
await page.getByRole("combobox", { name: "Mailbox profile" }).selectOption("Beta");
await expect(mailboxRows(page).first()).toContainText("Beta message 1");
heldBootstrap.release();
await expect.poll(() => state.releasedReads).toBeGreaterThan(0);
await expect(mailboxRows(page).first()).toContainText("Beta message 1");
const heldPreview = deferred();
let previewStarted = false;
state.intercept = async (url, route) => {
if (url.pathname === "/api/v1/mail/profiles/Beta/mailbox/messages/1") {
previewStarted = true;
await heldPreview.promise;
await route.fulfill({ json: messageDetail(url) });
state.releasedReads += 1;
return true;
}
return false;
};
await mailboxRows(page).first().click();
await expect.poll(() => previewStarted).toBe(true);
await page.getByRole("combobox", { name: "Mailbox profile" }).selectOption("Alpha");
await expect(mailboxRows(page).first()).toContainText("Alpha message 1");
await mailboxRows(page).first().click();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
const released = state.releasedReads;
heldPreview.release();
await expect.poll(() => state.releasedReads).toBeGreaterThan(released);
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
await expect(page.getByText("Fixture body Beta 1", { exact: true })).toHaveCount(0);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail tools keeps advanced reads scoped, bounce permission explained, and Escape leaves selection intact", async ({ page }) => {
const state = await mockMailbox(page);
await page.goto("/?mail-toolbar&language=en&theme=light");
await expect(mailboxRows(page)).toHaveCount(10);
await mailboxRows(page).first().click();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
const tools = page.getByRole("button", { name: "Mailbox tools", exact: true });
await tools.click();
const dialog = page.getByRole("dialog", { name: "Mailbox tools" });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole("button", { name: "Bounce status", exact: true })).toBeDisabled();
for (let index = 0; index < 10; index += 1) {
await page.keyboard.press("Tab");
expect(await dialog.evaluate(element => element.contains(document.activeElement))).toBe(true);
}
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
await expect(tools).toBeFocused();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
for (const [action, expectedPath] of [
["Refresh available profiles", "/api/v1/mail/profiles"],
["Refresh folders only", "/api/v1/mail/profiles/Alpha/mailbox/folders"],
["Refresh messages only", "/api/v1/mail/profiles/Alpha/mailbox/messages"]
]) {
await tools.click();
const before = state.reads.length;
await dialog.getByRole("button", { name: action, exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(reload(page)).toBeEnabled();
expect(mainMailboxReads(state.reads.slice(before)).map(url => url.pathname)).toEqual([expectedPath]);
await expect(mailboxRows(page).first()).toHaveClass(/is-selected/);
}
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail authority switch invalidates a previous tenant's pending profile catalogue", async ({ page }) => {
const state = await mockMailbox(page);
const held = deferred();
let started = false;
state.intercept = async (url, route) => {
if (url.pathname === "/api/v1/mail/profiles" && !started) {
started = true;
await held.promise;
await route.fulfill({ json: { profiles: [profile("PreviousTenant")] } });
state.releasedReads += 1;
return true;
}
return false;
};
state.profiles = [profile("CurrentTenant")];
await page.goto("/?mail-toolbar&language=en&theme=light&switch-tenant");
await expect.poll(() => started).toBe(true);
await page.getByRole("button", { name: "Switch fixture tenant" }).click();
await expect(mailboxRows(page).first()).toContainText("CurrentTenant message 1");
held.release();
await expect.poll(() => state.releasedReads).toBeGreaterThan(0);
await expect(page.getByRole("combobox", { name: "Mailbox profile" })).toHaveValue("CurrentTenant");
await expect(page.getByRole("option", { name: "PreviousTenant mailbox" })).toHaveCount(0);
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
test("Mail narrow German workspace keeps its sole Reload visible and the grouped tools dialog within the viewport", async ({ page }) => {
const state = await mockMailbox(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/?mail-toolbar&language=de&theme=light&bounce-allowed");
await expect(reload(page)).toBeEnabled();
await expect(reload(page)).toHaveText(/Neu laden/);
await expect(page.locator(".mailbox-message-head")).not.toBeVisible();
expect(await mailboxRows(page).first().evaluate(element => getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(1);
expect((await page.locator(".mailbox-message-scroll").boundingBox())!.height).toBeGreaterThan(90);
await expect(workspaceBar(page).locator('[data-page-action-slot="reload"]')).toHaveCount(1);
const bounds = await reload(page).boundingBox();
expect(bounds!.x).toBeGreaterThanOrEqual(0);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
await mailboxRows(page).first().click();
const preview = page.getByText("Fixture body Alpha 1", { exact: true });
await expect(preview).toBeVisible();
await preview.scrollIntoViewIfNeeded();
await expect(preview).toBeInViewport();
await expect(reload(page)).toBeVisible();
expect((await reload(page).boundingBox())!.y).toBe(bounds!.y);
await page.getByRole("button", { name: "Postfachwerkzeuge", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Postfachwerkzeuge" });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole("button", { name: "Zustellrückläufer", exact: true })).toBeEnabled();
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true);
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
await expect(page.getByText("Fixture body Alpha 1", { exact: true })).toBeVisible();
expect(state.errors).toEqual([]);
expect(state.writes).toEqual([]);
});
@@ -0,0 +1,296 @@
import { expect, test, type Page } from "@playwright/test";
const sourceFile = {
id: "fixture-archive", tenant_id: "fixture-tenant", owner_type: "user", owner_id: "fixture-user",
display_path: "small-archive.zip", filename: "small-archive.zip", size_bytes: 280,
content_type: "application/zip", checksum_sha256: "a".repeat(64), version_id: "fixture-version-7",
created_at: "2026-01-01T12:00:00Z", updated_at: "2026-01-01T12:00:00Z", audit_relevant: false,
shares: [], metadata: {}, deleted_at: null
};
type RecordedCall = { path: string; body: Record<string, unknown>; contentType: string };
async function installFileFixtures(page: Page, options: { encrypted?: boolean; failPreviewOnce?: boolean; failConfirmation?: boolean; holdPreview?: Promise<void>; holdConfirmation?: Promise<void>; progress?: { current: Record<string, unknown> | null }; staged?: boolean } = {}) {
const calls: RecordedCall[] = [];
const forbiddenRequests: string[] = [];
const importedFiles: Record<string, unknown>[] = [];
const releasedStages: string[] = [];
let previewFailed = false;
// A broad **/api/** glob also matches Vite's /@fs/.../src/api/*.ts.
// Restrict interception to actual API URLs so real module code is rendered.
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname;
if (request.method() === "DELETE" && path.startsWith("/api/v1/files/archive-staging/")) {
releasedStages.push(path.split("/").slice(-1)[0]);
await route.fulfill({ status: 204 });
return;
}
if (request.method() === "GET") {
if (path.startsWith("/api/v1/files/archive-progress/")) {
const operationId = path.split("/").slice(-1)[0];
const confirmation = calls.find((call) => call.path.endsWith("/archive-confirm") && call.body.operation_id === operationId);
if (confirmation && options.progress?.current) await route.fulfill({ json: options.progress.current });
else await route.fulfill({ status: 404, json: { detail: "No measured progress available" } });
return;
}
if (path === "/api/v1/files/spaces") {
await route.fulfill({ json: { spaces: [
{ id: "user:fixture-user", label: "My files", owner_type: "user", owner_id: "fixture-user", space_type: "managed" },
{ id: "group:fixture-team", label: "Team files", owner_type: "group", owner_id: "fixture-team", space_type: "managed" }
] } });
return;
}
const team = url.searchParams.get("owner_id") === "fixture-team";
if (path === "/api/v1/files/folders") {
await route.fulfill({ json: { folders: [{ id: team ? "team-output" : "personal-output", path: "extracted",
owner_type: team ? "group" : "user", owner_id: team ? "fixture-team" : "fixture-user",
name: "extracted", created_at: sourceFile.created_at, updated_at: sourceFile.updated_at }], total: 1, next_cursor: null } });
return;
}
if (path === "/api/v1/files") {
const files = team ? importedFiles : [sourceFile];
await route.fulfill({ json: { files, total: files.length, next_cursor: null } });
return;
}
}
if (request.method() === "POST" && (path.endsWith("/fixture-archive/archive-preview") || path.endsWith("/fixture-archive/archive-confirm") || path === "/api/v1/files/archive-preview" || path === "/api/v1/files/archive-confirm")) {
const contentType = request.headers()["content-type"] ?? "";
let body: Record<string, unknown>;
if (contentType.startsWith("application/json")) body = request.postDataJSON() as Record<string, unknown>;
else {
const form = await new Request(request.url(), { method: "POST", headers: { "content-type": contentType }, body: new Uint8Array(request.postDataBuffer()!) }).formData();
body = Object.fromEntries([...form.entries()].map(([key, value]) => [key, typeof value === "string" ? value : value.name]));
if (typeof body.selected_paths_json === "string") body.selected_paths = JSON.parse(body.selected_paths_json);
}
calls.push({ path, body, contentType: request.headers()["content-type"] ?? "" });
if (path.endsWith("/archive-preview")) {
if (options.holdPreview) await options.holdPreview;
if (options.failPreviewOnce && !previewFailed) {
previewFailed = true;
await route.fulfill({ status: 400, json: { detail: "Managed archive version changed; reload and preview again" } });
return;
}
await route.fulfill({ json: {
preview_token: `fixture-preview-${calls.length}`, archive_format: "zip", file_count: 2, directory_count: 1,
compressed_size_bytes: 280, expanded_size_bytes: 6, expires_at: "2099-01-01T12:00:00Z",
requires_password: Boolean(options.encrypted), password_verified: Boolean(body.password),
...(options.staged ? { staged_upload_id: "fixture-staged-archive" } : {}),
entries: [
{ path: "folder", kind: "directory", size_bytes: 0, encrypted: false },
{ path: "folder/one.txt", kind: "file", size_bytes: 3, compressed_size_bytes: 5, encrypted: Boolean(options.encrypted) },
{ path: "two.txt", kind: "file", size_bytes: 3, compressed_size_bytes: 5, encrypted: Boolean(options.encrypted) }
]
} });
return;
}
if (options.holdConfirmation) await options.holdConfirmation;
if (options.failConfirmation) {
await route.fulfill({ status: 400, json: { detail: "Target file already exists: extracted/folder/one.txt" } });
return;
}
for (const [index, selected] of (body.selected_paths as string[]).entries()) {
importedFiles.push({ ...sourceFile, id: `imported-${index}`, owner_type: body.owner_type, owner_id: body.owner_id,
display_path: `${body.path}/${selected}`, filename: selected.split("/").slice(-1)[0], version_id: `imported-version-${index}` });
}
await route.fulfill({ json: { files: importedFiles } });
return;
}
forbiddenRequests.push(`${request.method()} ${path}`);
await route.fulfill({ status: 403, json: { detail: "Unexpected fixture request; no real API access permitted" } });
});
return { calls, forbiddenRequests, importedFiles, releasedStages };
}
async function openExistingArchive(page: Page, language = "en", contextMenu = false) {
const name = language === "de" ? "Archiv entpacken" : "Unpack archive";
await page.goto(`/?managed-archive&language=${language}`);
const row = page.locator(".file-row").filter({ hasText: "small-archive.zip" });
await expect(row).toBeVisible();
await expect(page.getByRole("button", { name, exact: true })).toHaveCount(0);
await row.click({ button: contextMenu ? "right" : "left" });
if (contextMenu) await page.getByRole("menuitem", { name, exact: true }).click();
else await page.getByRole("button", { name, exact: true }).click();
const dialog = page.getByRole("dialog", { name, exact: true });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText("small-archive.zip");
await expect(dialog.locator('input[type="file"]')).toHaveCount(0);
await dialog.locator("select").selectOption("group:fixture-team");
await dialog.getByRole("button", { name: "extracted", exact: true }).click();
return dialog;
}
for (const language of ["en", "de"]) {
test(`real Files page unpacks a selected managed archive without re-upload in ${language}`, async ({ page }) => {
let finish!: () => void;
const holdConfirmation = new Promise<void>((resolve) => { finish = resolve; });
const fixture = await installFileFixtures(page, { holdConfirmation });
const dialog = await openExistingArchive(page, language);
await dialog.getByRole("button", { name: language === "de" ? "Archivvorschau" : "Preview archive", exact: true }).click();
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
await dialog.locator(".archive-entry-row").filter({ hasText: "two.txt" }).locator('input[type="checkbox"]').uncheck();
await dialog.getByRole("button", { name: "Import selected", exact: true }).click();
await expect.poll(() => fixture.calls.filter((call) => call.path.endsWith("/archive-confirm")).length).toBe(1);
await expect(dialog.getByRole("button", { name: "Import selected", exact: true, includeHidden: true })).toBeDisabled();
await expect(dialog.locator(".loading-frame-overlay")).toBeVisible();
await expect(dialog.locator(".loading-frame-overlay")).toHaveCSS("backdrop-filter", /blur/);
await expect(dialog.locator(".loading-envelope")).toHaveCount(0);
await expect(dialog.locator("[inert]")).toHaveCount(1);
await expect(dialog.locator(".dialog-close")).toBeDisabled();
await expect(dialog.getByRole("progressbar")).not.toHaveAttribute("value");
await page.keyboard.press("Escape");
await expect(dialog).toBeVisible();
finish();
await expect(dialog).toHaveCount(0);
await expect(page.locator(".file-row").filter({ hasText: "small-archive.zip" })).toBeVisible();
expect(fixture.calls).toHaveLength(2);
expect(fixture.calls.every((call) => call.contentType.startsWith("application/json"))).toBe(true);
expect(fixture.calls[0].body).toEqual({ source_version_id: "fixture-version-7", owner_type: "group", owner_id: "fixture-team", path: "extracted" });
expect(fixture.calls[1].body).toEqual({ ...fixture.calls[0].body, preview_token: "fixture-preview-1", selected_paths: ["folder/one.txt"], operation_id: expect.any(String) });
expect(fixture.importedFiles.map((file) => file.display_path)).toEqual(["extracted/folder/one.txt"]);
expect(fixture.forbiddenRequests).toEqual([]);
});
}
test("managed context-menu flow verifies a ZIP password in the shared dialog", async ({ page }) => {
const fixture = await installFileFixtures(page, { encrypted: true });
const dialog = await openExistingArchive(page, "en", true);
await dialog.getByRole("button", { name: "Preview archive", exact: true }).click();
await expect(dialog.getByRole("button", { name: "Import selected", exact: true })).toBeDisabled();
await dialog.locator('input[type="password"]').fill("fixture-only-password");
await dialog.getByRole("button", { name: "Verify password", exact: false }).click();
await expect.poll(() => fixture.calls.length).toBe(2);
await expect(dialog.getByRole("button", { name: "Import selected", exact: true })).toBeEnabled();
await dialog.getByRole("button", { name: "Import selected", exact: true }).click();
await expect(dialog).toHaveCount(0);
expect(fixture.calls[1].body.password).toBe("fixture-only-password");
expect(fixture.calls[2].body.password).toBe("fixture-only-password");
expect(fixture.calls[2].body.preview_token).toBe("fixture-preview-2");
expect(fixture.calls.every((call) => call.body.source_version_id === "fixture-version-7")).toBe(true);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("cancelling an uploaded archive releases only its temporary stage without importing", async ({ page }) => {
const fixture = await installFileFixtures(page, { staged: true });
await page.goto("/?managed-archive&language=en");
await expect(page.locator(".file-row").filter({ hasText: "small-archive.zip" })).toBeVisible();
await page.getByRole("button", { name: "Upload", exact: true }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("checkbox", { name: "Preview and unpack archive", exact: true }).focus();
await page.keyboard.press("Space");
await dialog.locator('input[type="file"]').setInputFiles({ name: "cancelled.zip", mimeType: "application/zip", buffer: Buffer.from("fixture archive") });
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect.poll(() => fixture.releasedStages).toEqual(["fixture-staged-archive"]);
expect(fixture.calls.filter((call) => call.path.endsWith("/archive-confirm"))).toHaveLength(0);
expect(fixture.importedFiles).toEqual([]);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("managed preview and confirmation failures stay visible inside the real dialog", async ({ page }) => {
const fixture = await installFileFixtures(page, { failPreviewOnce: true, failConfirmation: true });
const dialog = await openExistingArchive(page);
await dialog.getByRole("button", { name: "Preview archive", exact: true }).click();
await expect(dialog.getByText(/Managed archive version changed/)).toBeVisible();
await expect(dialog).toContainText("small-archive.zip");
await dialog.getByRole("button", { name: "Preview archive", exact: true }).click();
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
await dialog.getByRole("button", { name: "Import selected", exact: true }).click();
await expect(dialog.getByText(/Target file already exists/)).toBeVisible();
await expect(dialog.locator(".loading-frame-overlay")).toHaveCount(0);
await expect(dialog.locator("[inert]")).toHaveCount(0);
await expect(dialog.locator(".dialog-close")).toBeEnabled();
await expect(dialog.getByRole("button", { name: "Import selected", exact: true })).toBeEnabled();
await dialog.getByRole("button", { name: "Change destination", exact: true }).click();
await expect(dialog.getByRole("button", { name: "Preview archive", exact: true })).toBeVisible();
await expect(dialog.locator(".archive-entry-row")).toHaveCount(0);
expect(fixture.importedFiles).toEqual([]);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("managed extraction is unavailable without the download permission", async ({ page }) => {
const fixture = await installFileFixtures(page);
await page.goto("/?managed-archive&language=en&no-download");
await page.locator(".file-row").filter({ hasText: "small-archive.zip" }).click();
await expect(page.getByRole("button", { name: "Unpack archive", exact: true })).toBeDisabled();
expect(fixture.calls).toEqual([]);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("managed preview covers and blurs existing dialog controls without fabricating progress", async ({ page }) => {
let release!: () => void;
const holdPreview = new Promise<void>((resolve) => { release = resolve; });
const fixture = await installFileFixtures(page, { holdPreview });
const dialog = await openExistingArchive(page);
await dialog.getByRole("button", { name: "Preview archive", exact: true }).click();
const overlay = dialog.locator(".loading-frame-overlay");
await expect(overlay).toBeVisible();
await expect(overlay).toContainText("Inspecting the archive");
await expect(dialog.getByRole("progressbar")).not.toHaveAttribute("value");
await expect(dialog.locator(".loading-envelope")).toHaveCount(0);
await expect(dialog.locator(".dialog-close")).toBeDisabled();
await page.keyboard.press("Escape");
await expect(dialog).toBeVisible();
release();
await expect(overlay).toHaveCount(0);
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("actual server counters update the blurred overlay and finalization never claims completion", async ({ page }) => {
let release!: () => void;
const holdConfirmation = new Promise<void>((resolve) => { release = resolve; });
const progress = { current: { phase: "extracting", completed_files: 1, total_files: 2, completed_bytes: 3, total_bytes: 6, status: "running" } as Record<string, unknown> };
const fixture = await installFileFixtures(page, { holdConfirmation, progress });
const dialog = await openExistingArchive(page);
await dialog.getByRole("button", { name: "Preview archive", exact: true }).click();
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
await dialog.getByRole("button", { name: "Import selected", exact: true }).click();
await expect(dialog.getByRole("progressbar")).toHaveAttribute("value", "50");
await expect(dialog.locator(".loading-frame-overlay")).toContainText("1 of 2 files");
await expect(dialog.locator(".loading-frame-overlay")).toContainText("3 B of 6 B");
progress.current = { ...progress.current, phase: "finalizing", completed_files: 2, completed_bytes: 6 };
await expect(dialog.locator(".loading-frame-overlay")).toContainText("Finalizing and committing changes");
await expect(dialog.getByRole("progressbar")).not.toHaveAttribute("value");
await expect(dialog).toBeVisible();
release();
await expect(dialog).toHaveCount(0);
expect(fixture.calls.filter((call) => call.path.endsWith("/archive-confirm"))).toHaveLength(1);
expect(fixture.forbiddenRequests).toEqual([]);
});
test("new archive upload preview and confirmation use the same no-envelope progress overlay", async ({ page }) => {
let previewReady!: () => void;
let importReady!: () => void;
const holdPreview = new Promise<void>((resolve) => { previewReady = resolve; });
const holdConfirmation = new Promise<void>((resolve) => { importReady = resolve; });
const progress = { current: { phase: "storing", completed_files: 1, total_files: 2, completed_bytes: 3, total_bytes: 6, status: "running" } as Record<string, unknown> };
const fixture = await installFileFixtures(page, { holdPreview, holdConfirmation, progress, staged: true });
await page.goto("/?managed-archive&language=en");
await expect(page.locator(".file-row").filter({ hasText: "small-archive.zip" })).toBeVisible();
await page.getByRole("button", { name: "Upload", exact: true }).click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("checkbox", { name: "Preview and unpack archive", exact: true }).focus();
await page.keyboard.press("Space");
await dialog.locator('input[type="file"]').setInputFiles({ name: "new-archive.zip", mimeType: "application/zip", buffer: Buffer.from("fixture ZIP bytes; backend is intercepted") });
await expect(dialog.locator(".loading-frame-overlay")).toBeVisible();
await expect(dialog.locator(".loading-envelope")).toHaveCount(0);
await expect(dialog.locator(".dialog-close")).toBeDisabled();
await expect(dialog.getByRole("progressbar")).not.toHaveAttribute("value", "100");
previewReady();
await expect(dialog.locator(".archive-entry-row")).toHaveCount(3);
await expect(dialog.locator(".loading-frame-overlay")).toHaveCount(0);
await dialog.getByRole("button", { name: "Import selected", exact: true }).click();
await expect(dialog.locator(".loading-frame-overlay")).toContainText("Storing extracted files");
await expect(dialog.getByRole("progressbar")).toHaveAttribute("value", "50");
await expect(dialog.locator(".loading-envelope")).toHaveCount(0);
importReady();
await expect(dialog).toHaveCount(0);
expect(fixture.calls.filter((call) => call.path === "/api/v1/files/archive-confirm")).toHaveLength(1);
const confirmation = fixture.calls.find((call) => call.path === "/api/v1/files/archive-confirm")!;
expect(confirmation.body.staged_upload_id).toBe("fixture-staged-archive");
expect(confirmation.body.file).toBeUndefined();
expect(fixture.forbiddenRequests).toEqual([]);
});
@@ -0,0 +1,116 @@
import { expect, test, type Page } from "@playwright/test";
async function mockModules(page: Page) {
const errors: string[] = [];
const writes: string[] = [];
page.on("pageerror", error => errors.push(error.message));
await page.route(url => url.pathname.startsWith("/api/"), async route => {
const path = new URL(route.request().url()).pathname;
if (route.request().method() !== "GET") writes.push(path);
let body: unknown = {};
if (path.startsWith("/api/v1/committee/workspace/")) body = { records: [], total: 0, offset: 0, limit: 200 };
else if (path === "/api/v1/voting") body = { ballots: [] };
else if (path === "/api/v1/scheduling/requests") body = { requests: [] };
else if (path.endsWith("/source-snapshots")) body = { available: true, snapshots: [] };
else if (path.endsWith("/list-snapshots")) body = { snapshots: [] };
else if (path.endsWith("/review-queue")) body = { candidates: [] };
else if (path.endsWith("/assurance/nodes")) body = { nodes: [] };
else if (path.endsWith("/assurance/summary")) body = { node_count: 0, edge_count: 0, by_kind: {}, by_state: {} };
else if (path === "/api/v1/organizations/model") body = { unit_types: [{ id: "unit-type", name: "Department", slug: "department", is_active: true }], structures: [], relation_types: [], units: [], relations: [], function_types: [], functions: [] };
else if (path === "/api/v1/idm/typed-groups") body = { groups: [{ id: "group", tenant_id: "layout-tenant", key: "reviewers", name: "Reviewers", group_type: "team", status: "active", source_provider: "manual", properties: {}, provenance: {}, revision: 1 }] };
else if (path === "/api/v1/idm/relationships") body = { relationships: [] };
else if (path === "/api/v1/idm/organization-identities") body = { identities: [] };
return route.fulfill({ json: body });
});
return { errors, writes };
}
for (const module of ["committee", "voting", "scheduling"] as const) {
test(`${module} keeps Reload immediately before New at the upper right`, async ({ page }) => {
const fixture = await mockModules(page);
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(`/?module-layouts=${module}&language=en&theme=light`);
const toolbar = page.locator('[data-workspace-action-scope="workspace"]').first();
const create = toolbar.locator('[data-page-action-slot="create"] button');
const reload = toolbar.locator('[data-page-action-slot="reload"] button');
await expect(create).toBeVisible();
await expect(reload).toBeEnabled();
const [barBox, createBox, reloadBox] = await Promise.all([toolbar.boundingBox(), create.boundingBox(), reload.boundingBox()]);
expect(createBox!.x).toBeGreaterThan(barBox!.x + barBox!.width / 2);
expect(Math.abs(barBox!.x + barBox!.width - createBox!.x - createBox!.width)).toBeLessThanOrEqual(20);
expect(Math.abs(createBox!.y - reloadBox!.y)).toBeLessThan(3);
expect(reloadBox!.x + reloadBox!.width).toBeLessThanOrEqual(createBox!.x + 1);
expect(createBox!.x - reloadBox!.x - reloadBox!.width).toBeLessThan(20);
expect(barBox!.y).toBeLessThan(20);
expect(fixture.errors).toEqual([]);
expect(fixture.writes).toEqual([]);
});
}
test("Scheduling retains workspace actions when its editor opens and in read-only mode", async ({ page }) => {
const fixture = await mockModules(page);
await page.goto("/?module-layouts=scheduling&language=en&theme=light");
const toolbar = page.locator('[data-workspace-action-scope="workspace"]');
const create = toolbar.locator('[data-page-action-slot="create"] button');
await expect(create).toBeEnabled();
await create.click();
await expect(page.locator('[data-workspace-action-scope="editor-pane"]')).toBeVisible();
await expect(create).toBeVisible();
await expect(toolbar.locator('[data-page-action-slot="reload"] button')).toBeVisible();
expect(fixture.errors).toEqual([]);
expect(fixture.writes).toEqual([]);
await page.goto("/?module-layouts=scheduling&language=en&read-only&theme=light");
await expect(create).toBeVisible();
await expect(create).toBeDisabled();
});
for (const module of ["organizations", "idm"] as const) {
test(`${module} table cards have no inset or negative overflow`, async ({ page }) => {
const fixture = await mockModules(page);
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto(`/?module-layouts=${module}&language=en&theme=light`);
const cards = page.locator('.card:has([data-card-body-layout="table"])');
await expect(cards.first()).toBeVisible();
await expect(page.getByText(module === "idm" ? "Reviewers" : "Department", { exact: true })).toBeVisible();
const geometry = await cards.evaluateAll(elements => elements.map(card => {
const body = card.querySelector('.card-body')!;
const grid = card.querySelector('.data-grid')!;
const cardRect = card.getBoundingClientRect();
const gridRect = grid.getBoundingClientRect();
return { left: gridRect.left - cardRect.left, right: cardRect.right - gridRect.right, padding: getComputedStyle(body).paddingLeft };
}));
expect(geometry.length).toBeGreaterThanOrEqual(2);
for (const item of geometry) {
expect(Math.abs(item.left)).toBeLessThanOrEqual(2);
expect(Math.abs(item.right)).toBeLessThanOrEqual(2);
expect(item.padding).toBe("0px");
}
if (module === "idm") {
const [first, second] = await Promise.all([cards.nth(0).boundingBox(), cards.nth(1).boundingBox()]);
expect(second!.y - first!.y - first!.height).toBeGreaterThanOrEqual(12);
await expect(page.getByText("Business membership is an institutional fact.", { exact: false })).toHaveCount(0);
}
expect(fixture.errors).toEqual([]);
expect(fixture.writes).toEqual([]);
});
}
for (const width of [1280, 390]) {
test(`Risk Compliance uses shared cards and responsive layout at ${width}px`, async ({ page }) => {
const fixture = await mockModules(page);
await page.setViewportSize({ width, height: 900 });
await page.goto("/?module-layouts=risk&language=en&theme=light");
await expect(page.getByRole("heading", { name: "Review queue" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Candidate evidence" })).toBeVisible();
const toolbar = page.locator('[data-workspace-action-scope="workspace"]');
await expect(toolbar.locator('[data-page-action-slot="reload"] button')).toBeEnabled();
await page.getByRole("tab", { name: "Sources", exact: true }).click();
await expect(page.getByRole("heading", { name: "Connector evidence" })).toBeVisible();
await page.getByRole("tab", { name: "Assurance", exact: true }).click();
await expect(page.getByRole("heading", { name: "Assurance objects" })).toBeVisible();
await expect(page.locator('.risk-panel, .risk-toolbar')).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
expect(fixture.errors).toEqual([]);
expect(fixture.writes).toEqual([]);
});
}
@@ -0,0 +1,70 @@
import { expect, test } from "@playwright/test";
test("nested list filter owns keyboard focus and Escape only closes the filter", async ({ page }) => {
await page.goto("/?multi-select-filter");
await page.getByRole("button", { name: "Open filter dialog", exact: true }).click();
const owner = page.locator(".dialog-panel");
const trigger = owner.getByRole("button", { name: "Fixture tags", exact: true });
await trigger.click();
const popup = page.locator(".multi-select-filter-popover");
await expect(popup).toHaveAttribute("data-dialog-stack-state", "topmost");
await expect(owner).toHaveAttribute("inert", "");
await expect(popup.getByRole("button", { name: "Close filter", exact: true })).toBeFocused();
await page.keyboard.press("Tab");
await expect(popup.getByRole("button", { name: "Select all", exact: true })).toBeFocused();
await page.keyboard.press("Tab");
await expect(popup.getByRole("button", { name: "Deselect all", exact: true })).toBeFocused();
await page.keyboard.press("Space");
await expect(popup.getByRole("checkbox").first()).not.toBeChecked();
await page.keyboard.press("Tab");
await expect(popup.getByRole("checkbox").first()).toBeFocused();
await page.keyboard.press("Space");
await expect(popup.getByRole("checkbox").first()).toBeChecked();
await popup.getByRole("checkbox").last().focus();
await page.keyboard.press("Tab");
await expect(popup.getByRole("button", { name: "Close filter", exact: true })).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(popup.getByRole("checkbox").last()).toBeFocused();
await page.keyboard.press("Escape");
await expect(popup).toHaveCount(0);
await expect(owner).toBeVisible();
await expect(owner).not.toHaveAttribute("inert");
await expect(trigger).toBeFocused();
await page.keyboard.press("Escape");
await expect(owner).toHaveCount(0);
});
test("outside pointer dismisses only the nested filter, not its parent dialog", async ({ page }) => {
await page.goto("/?multi-select-filter");
await page.getByRole("button", { name: "Open filter dialog", exact: true }).click();
const owner = page.locator(".dialog-panel");
await owner.getByRole("button", { name: "Fixture tags", exact: true }).click();
await expect(page.locator(".multi-select-filter-popover")).toBeVisible();
await page.mouse.click(4, 4);
await expect(page.locator(".multi-select-filter-popover")).toHaveCount(0);
await expect(owner).toBeVisible();
await expect(owner.getByRole("button", { name: "Fixture tags", exact: true })).toBeFocused();
});
for (const modal of [false, true]) test(`80-character labels wrap without horizontal scrolling (${modal ? "modal" : "standalone"})`, async ({ page }) => {
await page.setViewportSize({ width: 320, height: 600 });
await page.goto("/?multi-select-filter");
if (modal) await page.getByRole("button", { name: "Open filter dialog", exact: true }).click();
const root = modal ? page.locator(".dialog-panel") : page.locator("main");
await root.getByRole("button", { name: "Fixture tags", exact: true }).click();
const popup = page.locator(".multi-select-filter-popover");
await expect(popup).toBeVisible();
const sizes = await popup.evaluate((element) => {
const options = element.querySelector(".data-grid-list-filter-options")!;
const rect = element.getBoundingClientRect();
return { left: rect.left, right: rect.right, bottom: rect.bottom, height: window.innerHeight,
viewport: window.innerWidth, popupOverflow: element.scrollWidth - element.clientWidth,
listOverflow: options.scrollWidth - options.clientWidth, parent: element.parentElement?.tagName };
});
expect(sizes.parent).toBe("BODY");
expect(sizes.left).toBeGreaterThanOrEqual(0);
expect(sizes.right).toBeLessThanOrEqual(sizes.viewport);
expect(sizes.bottom).toBeLessThanOrEqual(sizes.height);
expect(sizes.popupOverflow).toBeLessThanOrEqual(1);
expect(sizes.listOverflow).toBeLessThanOrEqual(1);
});
@@ -0,0 +1,86 @@
import { expect, test } from "@playwright/test";
test("collapsed rail keeps visible group separators; opening settings is clean", async ({ page }) => {
await page.goto("/?navigation-layout");
const rail = page.locator(".icon-rail");
await expect(rail).not.toHaveClass(/expanded/);
await expect(rail.getByRole("separator")).toHaveCount(2);
for (const separator of await rail.getByRole("separator").all()) {
await expect(separator).toBeVisible();
expect((await separator.boundingBox())!.width).toBeGreaterThan(20);
}
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
const spacing = await page.locator(".navigation-preference-list > li").first().evaluate((row) => {
const style = getComputedStyle(row);
return { padding: Number.parseFloat(style.paddingLeft), gap: Number.parseFloat(style.columnGap) };
});
expect(spacing.padding).toBeGreaterThanOrEqual(8);
expect(spacing.gap).toBeGreaterThanOrEqual(8);
await page.getByRole("button", { name: "Expand navigation", exact: true }).click();
await expect(rail.getByText("Documents", { exact: true })).toBeVisible();
await expect(rail.getByRole("separator")).toHaveCount(0);
await page.getByRole("button", { name: "Collapse navigation", exact: true }).click();
await expect(rail.getByRole("separator")).toHaveCount(2);
await expect(rail.getByText("Documents", { exact: true })).toBeHidden();
});
for (const scope of ["system", "tenant", "user", "view"]) {
test(`same editor supports add/remove, keyboard reorder and inheritance at ${scope} scope`, async ({ page }) => {
await page.goto(`/?navigation-layout&scope=${scope}`);
const list = page.getByRole("list", { name: "Navigation layout" });
const files = list.locator('[data-navigation-id="files"]');
await files.getByRole("button", { name: "Remove Files", exact: true }).click();
await expect(files).toHaveCount(0);
await page.getByRole("button", { name: "Add module", exact: true }).click();
await expect(list.locator("li").last()).toHaveAttribute("data-navigation-id", "files");
const handle = files.getByRole("button", { name: "Reorder Files", exact: true });
const before = await list.locator("li").evaluateAll((rows) => rows.map((row) => row.getAttribute("data-navigation-id")));
await handle.press("Space");
await handle.press("ArrowUp");
await expect(list.locator("li").last()).not.toHaveAttribute("data-navigation-id", "files");
await handle.press("Escape");
expect(await list.locator("li").evaluateAll((rows) => rows.map((row) => row.getAttribute("data-navigation-id")))).toEqual(before);
await handle.press("Space"); await handle.press("ArrowUp"); await handle.press("Enter");
await page.getByRole("button", { name: "Add separator", exact: true }).click();
const separator = list.locator("li").last();
await expect(separator).toHaveAttribute("data-navigation-kind", "separator");
await separator.getByRole("textbox").fill("Custom group");
const saved = JSON.parse(await page.getByTestId("navigation-draft").textContent() ?? "null");
expect(saved.separators.at(-1).label).toBe("Custom group");
await separator.getByRole("button", { name: "Remove Custom group", exact: true }).click();
if (scope !== "system") await expect(list.getByRole("button", { name: "Remove Dashboard", exact: true })).toBeDisabled();
await page.getByRole("button", { name: "Use inherited layout", exact: true }).click();
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
});
}
test("native pointer drag reorders modules and separators", async ({ page }) => {
await page.goto("/?navigation-layout");
const list = page.getByRole("list", { name: "Navigation layout" });
const files = list.locator('[data-navigation-id="files"]');
await files.getByRole("button", { name: "Reorder Files", exact: true }).dragTo(list.locator("li").first(), { targetPosition: { x: 15, y: 3 } });
await expect(list.locator("li").first()).toHaveAttribute("data-navigation-id", "files");
const separator = list.locator('[data-navigation-kind="separator"]').first();
const separatorId = await separator.getAttribute("data-navigation-id");
await separator.getByRole("button", { name: /^Reorder / }).dragTo(list.locator("li").first(), { targetPosition: { x: 15, y: 3 } });
await expect(list.locator("li").first()).toHaveAttribute("data-navigation-id", separatorId!);
});
test("no-op reordering stays clean and optional module positions survive other edits", async ({ page }) => {
await page.goto("/?navigation-layout");
const handle = page.getByRole("button", { name: "Reorder Files", exact: true });
await handle.press("Space"); await handle.press("Enter");
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
await page.goto("/?navigation-layout&unavailable");
await page.getByRole("button", { name: "Move Mail up", exact: true }).click();
const draft = JSON.parse(await page.getByTestId("navigation-draft").textContent() ?? "null");
expect(draft.order).toContain("optional.navigation.absent");
});
test("German narrow editor does not overflow and read-only controls cannot mutate", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 900 });
await page.goto("/?navigation-layout&language=de&disabled");
await expect(page.getByRole("button", { name: "Trennlinie hinzufügen", exact: true })).toBeDisabled();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
});
@@ -0,0 +1,112 @@
import { expect, test } from "@playwright/test";
for (const language of ["en", "de"]) test(`notification status uses a shared multi-select dropdown (${language})`, async ({ page }) => {
const errors: string[] = [];
const requests: string[][] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.route("**/api/v1/notifications?**", (route) => {
const states = new URL(route.request().url()).searchParams.getAll("status");
requests.push(states);
const notifications = ["pending", "failed", "sent"].filter((state) => !states.length || states.includes(state)).map((state) => ({
id: state, tenant_id: "filter-tenant", source_module: "test", source_resource_type: "fixture", event_kind: "test", channel: "inbox",
recipient_id: "filter-user", subject: `Message ${state}`, body_text: "Filter-only fixture", status: state,
created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", attempts: [], metadata: {}, payload: {}, priority: 0, attempt_count: 0,
}));
return route.fulfill({ json: { notifications } });
});
await page.goto(`/?notification-filter&language=${language}`);
const list = page.locator(".notifications-selection-list");
await expect(list.locator("button")).toHaveCount(3);
const trigger = page.locator(".multi-select-filter-trigger");
await trigger.click();
const popup = page.locator(".multi-select-filter-popover");
await expect(popup.getByRole("checkbox")).toHaveCount(9);
await popup.locator(".data-grid-list-filter-actions button").nth(1).click();
await expect(list).toHaveCount(0);
const countAfterClear = requests.length;
await popup.getByRole("checkbox").nth(0).check();
await popup.getByRole("checkbox").nth(6).check();
await expect(list.locator("button")).toHaveCount(2);
expect(requests[requests.length - 1]).toEqual(["pending", "failed"]);
expect(requests.length).toBeGreaterThan(countAfterClear);
await popup.locator(".data-grid-list-filter-actions button").first().click();
await expect(list.locator("button")).toHaveCount(3);
await page.keyboard.press("Escape");
await expect(popup).toHaveCount(0);
await expect(trigger).toBeFocused();
expect(errors).toEqual([]);
});
test("standalone list filter fits a narrow viewport and closes outside", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 700 });
await page.route("**/api/v1/notifications?**", (route) => route.fulfill({ json: { notifications: [] } }));
await page.goto("/?notification-filter&language=en");
await page.locator(".multi-select-filter-trigger").click();
const popup = page.locator(".multi-select-filter-popover");
const box = await popup.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
await page.mouse.click(385, 680);
await expect(popup).toHaveCount(0);
});
test("clearing the filter cancels a slow read and cannot restore old rows", async ({ page }) => {
let releaseRead!: () => void;
const heldRead = new Promise<void>((resolve) => { releaseRead = resolve; });
let readStarted = false;
const failedReads: string[] = [];
page.on("requestfailed", (request) => { if (request.url().includes("/api/v1/notifications?")) failedReads.push(request.url()); });
await page.route("**/api/v1/notifications?**", async (route) => {
readStarted = true;
await heldRead;
await route.fulfill({ json: { notifications: [{ id: "stale", status: "sent", subject: "Stale notice", attempts: [] }] } });
});
await page.goto("/?notification-filter&language=en");
await expect.poll(() => readStarted).toBe(true);
await page.locator(".multi-select-filter-trigger").click();
await page.locator(".multi-select-filter-popover .data-grid-list-filter-actions button").nth(1).click();
await expect(page.locator(".notifications-note")).toHaveText("No notifications in this view.");
releaseRead();
await expect.poll(() => failedReads.length).toBeGreaterThan(0);
await expect(page.getByText("Stale notice", { exact: true })).toHaveCount(0);
});
for (const action of ["read", "dispatch"] as const) test(`late ${action} completion cannot restore another account's inbox`, async ({ page }) => {
let releaseWrite!: () => void;
let writeStarted = false;
let writeFinished = false;
const heldWrite = new Promise<void>((resolve) => { releaseWrite = resolve; });
const reads: string[] = [];
const notice = (owner: string) => ({ id: owner, subject: `Notice for ${owner}`, status: "pending", channel: "inbox",
source_module: "test", source_resource_type: "fixture", recipient_id: owner, body_text: "Fixture", attempts: [], metadata: {}, payload: {},
created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", priority: 0, attempt_count: 0 });
await page.route("**/api/v1/notifications**", async (route) => {
const owner = route.request().headers().authorization?.replace("Bearer ", "") ?? "filter-user";
if (route.request().method() === "GET") {
reads.push(owner);
return route.fulfill({ json: { notifications: [notice(owner)] } });
}
writeStarted = true;
await heldWrite;
await route.fulfill({ json: action === "read" ? { ...notice(owner), read_at: "2026-01-02T00:00:00Z" } : { processed: 1, sent: 1, errors: [] } });
writeFinished = true;
});
await page.goto("/?notification-filter&language=en&write");
await expect(page.locator(".notifications-selection-list button")).toHaveCount(1);
if (action === "read") await page.getByRole("button", { name: "Mark read", exact: true }).click();
else {
await page.getByRole("button", { name: "Dispatch pending", exact: true }).click();
await page.getByRole("alertdialog").getByRole("button", { name: "Dispatch pending", exact: true }).click();
}
await expect.poll(() => writeStarted).toBe(true);
await page.evaluate(() => window.dispatchEvent(new Event("conformance-notification-account")));
await expect(page.locator(".notifications-selection-list")).toContainText("Notice for other-user");
const readCount = reads.length;
const writeResponse = page.waitForResponse((response) => response.request().method() !== "GET" && response.url().includes("/api/v1/notifications"));
releaseWrite();
await writeResponse;
await expect.poll(() => writeFinished).toBe(true);
await expect(page.locator(".notifications-selection-list")).not.toContainText("Notice for filter-user");
await expect(page.getByRole("button", { name: "Mark read", exact: true })).toBeEnabled();
expect(reads.length).toBe(readCount);
});
@@ -0,0 +1,266 @@
import { expect, test, type Page, type Request } from "@playwright/test";
const resources = [
{ provider_id: "files", module_id: "files", resource_type: "file", label: "File", order: 1 },
{ provider_id: "mail", module_id: "mail", resource_type: "message", label: "Message", order: 2 },
{ provider_id: "tickets", module_id: "tickets", resource_type: "ticket", label: "Ticket", order: 3 },
];
function result(module: string, type: string, title: string) {
return { provider_id: module, module_id: module, resource_type: type, resource_id: title, title, url: `/result/${title}`,
highlights: [], breadcrumbs: [], metadata: {}, provenance: {}, score: 1 };
}
async function mockSearch(page: Page) {
const reads: URLSearchParams[] = [];
await page.route("**/api/v1/search/providers", (route) => route.fulfill({ json: { providers: [], resources } }));
await page.route("**/api/v1/search?**", (route) => {
const params = new URL(route.request().url()).searchParams;
reads.push(params);
const modules = params.getAll("module"), types = params.getAll("resource_type");
return route.fulfill({ json: { query: params.get("q"), diagnostics: [], has_more: false,
results: resources.filter((item) => (!modules.length || modules.includes(item.module_id)) && (!types.length || types.includes(item.resource_type)))
.map((item) => result(item.module_id, item.resource_type, `${params.get("q")} ${item.label}`)) } });
});
return reads;
}
const popup = (page: Page) => page.locator(".multi-select-filter-popover");
for (const language of ["en", "de"]) test(`Search page shared filters preserve all, none, OR, and URL history (${language})`, async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const reads = await mockSearch(page);
await page.goto(`/?search-filters&language=${language}&q=permit&context=current&keep=1`);
await expect(page.locator(".search-result")).toHaveCount(3);
const trigger = page.locator(".multi-select-filter-trigger").first();
await trigger.click();
await expect(popup(page).getByRole("checkbox")).toHaveCount(3);
await expect(popup(page).getByRole("checkbox").first()).toBeChecked();
await popup(page).locator(".data-grid-list-filter-actions button").nth(1).click();
await expect(page.locator(".search-result")).toHaveCount(0);
expect(new URL(page.url()).searchParams.get("module_none")).toBe("1");
const noneReadCount = reads.length;
await popup(page).getByRole("checkbox", { name: "Files", exact: true }).click();
await expect(page.locator(".search-result")).toHaveCount(1);
expect(reads[reads.length - 1]?.getAll("module")).toEqual(["files"]);
await popup(page).getByRole("checkbox", { name: "Mail", exact: true }).click();
await expect(page.locator(".search-result")).toHaveCount(2);
expect(reads[reads.length - 1]?.getAll("module")).toEqual(["files", "mail"]);
expect(reads.length).toBeGreaterThan(noneReadCount);
await page.keyboard.press("Escape");
await expect(trigger).toBeFocused();
await page.getByRole("button", { name: language === "de" ? "Filter zurücksetzen" : "Clear filters", exact: true }).click();
await expect.poll(() => reads[reads.length - 1]?.getAll("module")).toEqual([]);
expect(new URL(page.url()).searchParams.get("context")).toBe("current");
expect(new URL(page.url()).searchParams.get("keep")).toBe("1");
await page.goBack();
await expect.poll(() => reads[reads.length - 1]?.getAll("module")).toEqual(["files", "mail"]);
await page.goBack();
await expect(page.locator(".search-result")).toHaveCount(1);
await page.goBack();
await expect(page.locator(".search-result")).toHaveCount(0);
expect(new URL(page.url()).searchParams.get("module_none")).toBe("1");
expect(errors).toEqual([]);
});
test("legacy Search links retain explicit unknown values, and none takes precedence", async ({ page }) => {
const reads = await mockSearch(page);
await page.goto("/?search-filters&q=permit&module=retired&resource_type=file");
await expect.poll(() => reads.length).toBe(1);
expect(reads[0].getAll("module")).toEqual(["retired"]);
await page.locator(".multi-select-filter-trigger").first().click();
await expect(popup(page).getByRole("checkbox", { name: "Retired", exact: true })).toBeChecked();
await page.goto("/?search-filters&q=permit&module=files&module_none=1");
await expect(page.locator(".search-empty")).toBeVisible();
expect(reads).toHaveLength(1);
});
test("overlay context never broadens incompatible result types; nested filter Escape preserves Search", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const reads = await mockSearch(page);
await page.goto("/files?search-filters&overlay&language=en");
await expect(page.locator(".titlebar-search-button")).toBeVisible();
await page.keyboard.press("F3");
const overlay = page.locator(".search-overlay-dialog");
await overlay.getByRole("searchbox").fill("permit");
await expect(overlay.locator(".search-result")).toHaveCount(1);
expect(reads[reads.length - 1]?.getAll("module")).toEqual(["files"]);
expect(reads[reads.length - 1]?.getAll("resource_type")).toEqual(["file"]);
await overlay.getByRole("tab", { name: "Everywhere", exact: true }).click();
await expect(overlay.locator(".search-result")).toHaveCount(3);
const types = overlay.locator(".multi-select-filter-trigger").nth(1);
await types.click();
await popup(page).locator(".data-grid-list-filter-actions button").nth(1).click();
await popup(page).getByRole("checkbox", { name: "Message", exact: true }).check();
await expect(overlay.locator(".search-result")).toHaveCount(1);
await page.keyboard.press("Escape");
await expect(popup(page)).toHaveCount(0);
await expect(types).toBeFocused();
await expect(overlay).toBeVisible();
const count = reads.length;
await overlay.getByRole("tab", { name: "Current files", exact: true }).click();
await expect(overlay.locator(".search-empty")).toHaveText("No results for “permit”.");
expect(reads.length).toBe(count);
await overlay.getByRole("button", { name: "Clear filters", exact: true }).click();
await expect(overlay.locator(".search-result")).toHaveCount(1);
expect(reads[reads.length - 1]?.getAll("resource_type")).toEqual(["file"]);
await page.keyboard.press("Escape");
await expect(overlay).toHaveCount(0);
expect(errors).toEqual([]);
});
for (const overlay of [false, true]) test(`late cursor cannot append after filter change (${overlay ? "overlay" : "page"})`, async ({ page }) => {
let release!: () => void;
let cursorStarted = false;
const held = new Promise<void>((resolve) => { release = resolve; });
await mockSearch(page);
await page.route("**/api/v1/search?**", async (route) => {
const params = new URL(route.request().url()).searchParams;
if (params.has("cursor")) { cursorStarted = true; await held; }
return route.fulfill({ json: { query: "permit", diagnostics: [], results: [result("files", "file", params.has("cursor") ? "Old cursor result" : "First result")], next_cursor: params.has("cursor") ? null : "next", has_more: !params.has("cursor") } });
});
await page.goto(`/?search-filters&q=permit&language=en${overlay ? "&overlay" : ""}`);
if (overlay) { await expect(page.locator(".titlebar-search-button")).toBeVisible(); await page.keyboard.press("Control+k"); await page.getByRole("searchbox").fill("permit"); }
await page.getByRole("button", { name: "Load more", exact: true }).click();
await expect.poll(() => cursorStarted).toBe(true);
const failed = page.waitForEvent("requestfailed", { predicate: (request) => request.url().includes("cursor=") });
await page.locator(".multi-select-filter-trigger").first().click();
await popup(page).locator(".data-grid-list-filter-actions button").nth(1).click();
await expect(page.locator(".search-result")).toHaveCount(0);
release();
await failed;
await expect(page.locator(".search-result")).toHaveCount(0);
await expect(page.locator(".search-empty")).toBeVisible();
});
test("shared Search dropdown fits a narrow viewport and typing invalidates keyboard selection immediately", async ({ page }) => {
await mockSearch(page);
await page.setViewportSize({ width: 390, height: 700 });
await page.goto("/?search-filters&overlay&language=de");
await expect(page.locator(".titlebar-search-button")).toBeVisible();
await page.keyboard.press("Control+k");
const input = page.getByRole("searchbox");
await input.fill("permit");
await expect(page.locator(".search-result")).toHaveCount(3);
await input.press("ArrowDown");
await input.fill("changed");
await input.press("Enter");
expect(new URL(page.url()).pathname).toBe("/");
await expect(page.locator(".search-result").first()).toContainText("changed");
await page.locator(".multi-select-filter-trigger").first().click();
const box = await popup(page).boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
await expect(popup(page).getByRole("button", { name: "Alle auswählen", exact: true })).toBeVisible();
});
for (const overlay of [false, true]) test(`partial providers preserve safe results and successful pagination (${overlay ? "overlay" : "page"})`, async ({ page }) => {
const errors: string[] = [];
const reads: URLSearchParams[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.route("**/api/v1/search/providers", (route) => route.fulfill({ json: { providers: [], resources } }));
await page.route("**/api/v1/search?**", (route) => {
const params = new URL(route.request().url()).searchParams;
reads.push(params);
const nextPage = params.has("cursor");
return route.fulfill({ json: {
query: params.get("q"),
results: [result("files", "file", nextPage ? "Second authorized result" : "First authorized result")],
diagnostics: [
{ provider_id: "mail", message: "Mail provider temporarily unavailable." },
...(nextPage ? [{ provider_id: "tickets", message: "Ticket provider returned partial results." }] : []),
],
next_cursor: nextPage ? null : "authorized-page-two", has_more: !nextPage,
} });
});
await page.goto(`/?search-filters&q=permit&language=en${overlay ? "&overlay" : ""}`);
if (overlay) {
await expect(page.locator(".titlebar-search-button")).toBeVisible();
await page.keyboard.press("F3");
await page.getByRole("searchbox").fill("permit");
}
await expect(page.locator(".search-result")).toHaveCount(1);
await expect(page.getByText("Mail provider temporarily unavailable.", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Load more", exact: true }).click();
await expect(page.locator(".search-result")).toHaveCount(2);
await expect(page.locator(".search-result").first()).toContainText("First authorized result");
await expect(page.locator(".search-result").nth(1)).toContainText("Second authorized result");
await expect(page.getByText("Mail provider temporarily unavailable.", { exact: true })).toHaveCount(1);
await expect(page.getByText("Ticket provider returned partial results.", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Load more", exact: true })).toHaveCount(0);
expect(reads).toHaveLength(2);
expect(reads[1].get("q")).toBe("permit");
expect(reads[1].get("cursor")).toBe("authorized-page-two");
expect(errors).toEqual([]);
});
for (const sameToken of [false, true]) for (const overlay of [false, true]) test(`late query and catalogue cannot restore another account's Search (${overlay ? "overlay" : "page"}, ${sameToken ? "same token" : "new token"})`, async ({ page }) => {
const errors: string[] = [];
const oldStarted = new Set<string>();
const oldFinished = new Set<string>();
const oldFailed = new Set<string>();
const oldRequests = new Set<Request>();
const currentReads: string[] = [];
const currentTokens: string[] = [];
let accountSwitched = false;
let releaseOld!: () => void;
const oldResponses = new Promise<void>((resolve) => { releaseOld = resolve; });
page.on("pageerror", (error) => errors.push(error.message));
page.on("requestfailed", (request) => {
if (oldRequests.has(request)) {
oldFailed.add(new URL(request.url()).pathname.endsWith("/providers") ? "catalogue" : "query");
}
});
await page.route("**/api/v1/search**", async (route) => {
const oldAccount = !accountSwitched;
const catalogue = new URL(route.request().url()).pathname.endsWith("/providers");
const kind = catalogue ? "catalogue" : "query";
if (oldAccount) { oldRequests.add(route.request()); oldStarted.add(kind); await oldResponses; }
else {
currentReads.push(kind);
currentTokens.push(route.request().headers().authorization ?? "");
}
const json = catalogue ? {
providers: [],
resources: oldAccount
? [{ provider_id: "retired", module_id: "retired_source", resource_type: "retired_record", label: "Retired record", order: 1 }]
: [{ provider_id: "mail", module_id: "mail", resource_type: "message", label: "Current record", order: 1 }],
} : {
query: "permit", diagnostics: [], next_cursor: null, has_more: false,
results: [oldAccount
? result("retired_source", "retired_record", "Previous account result")
: result("mail", "message", "Current account result")],
};
await route.fulfill({ json });
if (oldAccount) oldFinished.add(kind);
});
await page.goto(`/?search-filters&q=permit&language=en${overlay ? "&overlay" : ""}${sameToken ? "&same-token" : ""}`);
if (overlay) {
await expect(page.locator(".titlebar-search-button")).toBeVisible();
await page.keyboard.press("F3");
await page.getByRole("searchbox").fill("permit");
}
await expect.poll(() => [...oldStarted].sort()).toEqual(["catalogue", "query"]);
accountSwitched = true;
await page.evaluate(() => window.dispatchEvent(new Event("conformance-search-account")));
await expect(page.locator(".search-result")).toHaveCount(1);
await expect(page.locator(".search-result")).toContainText("Current account result");
await expect.poll(() => [...currentReads].sort()).toEqual(["catalogue", "query"]);
await page.locator(".multi-select-filter-trigger").first().click();
await expect(popup(page).getByRole("checkbox", { name: "Mail", exact: true })).toBeChecked();
const readsAfterSwitch = currentReads.length;
releaseOld();
await expect.poll(() => [...oldFinished].sort()).toEqual(["catalogue", "query"]);
await expect.poll(() => [...oldFailed].sort()).toEqual(["catalogue", "query"]);
await expect(page.locator(".search-result")).toHaveCount(1);
await expect(page.locator(".search-result")).toContainText("Current account result");
await expect(page.getByText("Previous account result", { exact: true })).toHaveCount(0);
await expect(popup(page).getByRole("checkbox")).toHaveCount(1);
await expect(popup(page).getByRole("checkbox", { name: "Retired Source", exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await page.locator(".multi-select-filter-trigger").nth(1).click();
await expect(popup(page).getByRole("checkbox", { name: "Current record", exact: true })).toBeChecked();
await expect(popup(page).getByRole("checkbox", { name: "Retired record", exact: true })).toHaveCount(0);
expect(currentReads.length).toBe(readsAfterSwitch);
expect(currentTokens).toEqual(Array(2).fill(sameToken ? "Bearer search-user" : "Bearer other-user"));
expect(errors).toEqual([]);
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 228 KiB

@@ -152,22 +152,22 @@ test("shared components remain accessible and keyboard operable", async ({ page
await expect(editorActions.locator("[data-page-action-separation='destructive']")).toHaveCSS("border-left-width", "2px");
const actionSlots = await editorActions.locator("[data-page-action-slot]").evaluateAll((elements) => elements.map((element) => element.getAttribute("data-page-action-slot")));
expect(actionSlots).toEqual([
"reload",
"context",
"reload",
"destructive",
"discard",
"save"
]);
await expect(editorActions.getByRole("button")).toHaveText([
"Neu laden",
"Vorschau öffnen",
"Neu laden",
"Löschen",
"Verwerfen",
"Änderungen speichern"
]);
await editorActions.getByRole("button", { name: "Neu laden" }).focus();
await editorActions.getByRole("button", { name: "Vorschau öffnen" }).focus();
await page.keyboard.press("Tab");
await expect(editorActions.getByRole("button", { name: "Vorschau öffnen" })).toBeFocused();
await expect(editorActions.getByRole("button", { name: "Neu laden" })).toBeFocused();
await page.keyboard.press("Tab");
await expect(editorActions.locator(".disabled-action-tooltip")).toBeFocused();
await expect(page.getByRole("tooltip")).toContainText("Nur die federführende Stelle");
@@ -0,0 +1,56 @@
import { expect, test } from "@playwright/test";
for (const mode of ["visual", "source"] as const) {
test(`rich-text ${mode} editor stays clean until a real content edit`, async ({ page }) => {
const pageErrors: string[] = [];
page.on("pageerror", (error) => pageErrors.push(error.message));
await page.goto(`/?wysiwyg-lifecycle&mode=${mode}`);
const changeCount = page.getByTestId("wysiwyg-change-count");
const value = page.getByTestId("wysiwyg-controlled-value");
const editor = mode === "visual"
? page.locator(".wysiwyg-prosemirror")
: page.locator(".wysiwyg-editor-source");
const original = mode === "visual"
? "<p>Legacy <i>template</i></p>"
: '<table style="width: 100%"><tbody><tr><td>Legacy template</td></tr></tbody></table>';
await expect(editor).toBeVisible();
// <i> is supported visually but normalized to <em> inside Tiptap. That
// normalization must not leak into controlled content without a real edit.
if (mode === "visual") await expect(editor.locator("em")).toHaveText("template");
await expect(value).toHaveText(original);
await expect(changeCount).toHaveText("0");
await page.getByRole("button", { name: "Toggle read-only", exact: true }).click();
if (mode === "visual") await expect(editor).toHaveAttribute("contenteditable", "false");
else await expect(editor).toBeDisabled();
await expect(value).toHaveText(original);
await expect(changeCount).toHaveText("0");
await page.getByRole("button", { name: "Toggle read-only", exact: true }).click();
await page.getByRole("button", { name: "Load another value", exact: true }).click();
await expect(value).toHaveText(original.replace("Legacy", "Reloaded"));
await expect(changeCount).toHaveText("0");
await page.getByRole("button", { name: "Toggle editor mount", exact: true }).click();
await expect(editor).toHaveCount(0);
await page.getByRole("button", { name: "Toggle editor mount", exact: true }).click();
await expect(editor).toBeVisible();
await expect(value).toHaveText(original.replace("Legacy", "Reloaded"));
await expect(changeCount).toHaveText("0");
if (mode === "visual") {
await page.getByRole("tab", { name: "Source fixture mode", exact: true }).click();
await expect(page.locator(".wysiwyg-editor-source")).toHaveValue(original.replace("Legacy", "Reloaded"));
await expect(changeCount).toHaveText("0");
await page.getByRole("tab", { name: "Visual fixture mode", exact: true }).click();
await expect(editor).toBeVisible();
await expect(changeCount).toHaveText("0");
}
await editor.fill(mode === "source" ? "<p>Edited content</p>" : "Edited content");
await expect.poll(async () => Number(await changeCount.textContent())).toBeGreaterThan(0);
await expect(value).toContainText("Edited content");
expect(pageErrors).toEqual([]);
});
}
+260 -260
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.44",
"version": "0.1.45",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.44",
"version": "0.1.45",
"dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.24",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22",
+10 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.44",
"version": "0.1.45",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -41,13 +41,16 @@
"test:i18n-catalog": "node --test tests/i18n-catalog-validation.test.mjs",
"test:theme-contract": "node scripts/test-theme-contract.mjs",
"test:core-interface-patterns": "node scripts/test-core-interface-patterns.mjs",
"test:vite-cache-isolation": "node scripts/test-vite-cache-isolation.mjs",
"test:api-client-cache": "node --test tests/api-client-cache.test.mjs",
"test:dependency-security": "node --test tests/dependency-security.test.mjs",
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js && node .component-test-build/tests/data-grid-sizing.test.js",
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
"test:layout-primitives": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && printf 'module.exports = {};\\n' > .component-test-build/src/components/ProductAvailabilityState.css && node .component-test-build/tests/layout-primitives.test.js",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js && node .module-test-build/tests/launch-context.test.js && node .module-test-build/tests/definition-graph.test.js",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/module-loading.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js && node .module-test-build/tests/launch-context.test.js && node .module-test-build/tests/definition-graph.test.js",
"test:module-permutations": "node scripts/test-module-permutations.mjs",
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
"test:metric-card": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/metric-card.test.js",
@@ -108,11 +111,11 @@
"@govoplan/voting-webui": "file:../../govoplan-voting/webui",
"@govoplan/wiki-webui": "file:../../govoplan-wiki/webui",
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui",
"@tiptap/core": "^3.29.2",
"@tiptap/extension-image": "^3.29.2",
"@tiptap/pm": "^3.29.2",
"@tiptap/react": "^3.29.2",
"@tiptap/starter-kit": "^3.29.2"
"@tiptap/core": "^3.30.4",
"@tiptap/extension-image": "^3.30.4",
"@tiptap/pm": "^3.30.4",
"@tiptap/react": "^3.30.4",
"@tiptap/starter-kit": "^3.30.4"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
+63 -21
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.44",
"version": "0.1.45",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -15,9 +15,17 @@
"types": "./src/app.ts",
"import": "./src/app.ts"
},
"./definition-graph": {
"types": "./src/definitionGraph.ts",
"import": "./src/definitionGraph.ts"
},
"./wysiwyg": {
"types": "./src/wysiwyg.ts",
"import": "./src/wysiwyg.ts"
},
"./outcome-product-surface-translations": {
"types": "./src/i18n/outcomeProductSurfaceTranslations.ts",
"import": "./src/i18n/outcomeProductSurfaceTranslations.ts"
}
},
"scripts": {
@@ -26,37 +34,71 @@
"preview": "vite preview --host 127.0.0.1 --port 4173"
},
"dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.24",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22",
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.25",
"@govoplan/addresses-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-addresses.git#v0.1.22",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.23",
"@govoplan/approvals-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-approvals.git#v0.1.20",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.20",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.23",
"@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.20",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.28",
"@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.24",
"@govoplan/committee-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-committee.git#v0.1.21",
"@govoplan/connectors-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-connectors.git#v0.1.26",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.20",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.22",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.25",
"@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.20",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.24",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.26",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.27",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.20",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.21",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.22",
"@govoplan/dataflow-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dataflow.git#v0.1.24",
"@govoplan/datasources-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-datasources.git#v0.1.25",
"@govoplan/dist-lists-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dist-lists.git#v0.1.21",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.23",
"@govoplan/encryption-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-encryption.git#v0.1.19",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.26",
"@govoplan/forms-runtime-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-forms-runtime.git#v0.1.20",
"@govoplan/forms-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-forms.git#v0.1.22",
"@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.21",
"@govoplan/identity-trust-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-identity-trust.git#v0.1.20",
"@govoplan/identity-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-identity.git#v0.1.20",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.25",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.27",
"@govoplan/notifications-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-notifications.git#v0.1.20",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.22",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.21",
"@govoplan/payments-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-payments.git#v0.1.21",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.23",
"@govoplan/portal-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-portal.git#v0.1.21",
"@govoplan/postbox-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-postbox.git#v0.1.22",
"@govoplan/projects-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-projects.git#v0.1.19",
"@govoplan/quick-access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-quick-access.git#v0.1.20",
"@govoplan/records-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-records.git#v0.1.23",
"@govoplan/reporting-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-reporting.git#v0.1.21",
"@govoplan/risk-compliance-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-risk-compliance.git#v0.1.21",
"@govoplan/scheduling-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-scheduling.git#v0.1.22",
"@govoplan/search-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-search.git#v0.1.20",
"@govoplan/tasks-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tasks.git#v0.1.23",
"@govoplan/templates-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-templates.git#v0.1.22",
"@govoplan/tenancy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tenancy.git#v0.1.22",
"@govoplan/tickets-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tickets.git#v0.1.22",
"@govoplan/views-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-views.git#v0.1.22",
"@govoplan/voting-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-voting.git#v0.1.21",
"@govoplan/wiki-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-wiki.git#v0.1.22",
"@tiptap/core": "^3.29.2",
"@tiptap/extension-image": "^3.29.2",
"@tiptap/pm": "^3.29.2",
"@tiptap/react": "^3.29.2",
"@tiptap/starter-kit": "^3.29.2"
"@govoplan/workflow-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-workflow.git#v0.1.23",
"@tiptap/core": "^3.30.4",
"@tiptap/extension-image": "^3.30.4",
"@tiptap/pm": "^3.30.4",
"@tiptap/react": "^3.30.4",
"@tiptap/starter-kit": "^3.30.4"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@types/node": "^26.2.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"@xyflow/react": "^12.11.2",
"axe-core": "^4.13.0",
"lucide-react": "^1.23.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router": "8.3.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"read-excel-file": "^9.2.0",
"typescript": "^5.7.2",
"vite": "^7.3.6"
},
@@ -1,10 +1,12 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const webuiRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
const read = (path) => readFileSync(resolve(webuiRoot, path), "utf8");
const require = createRequire(import.meta.url);
const settings = read("src/features/settings/SettingsPage.tsx");
const retention = read("src/features/privacy/RetentionPolicyManagement.tsx");
@@ -12,6 +14,14 @@ const confirmDialog = read("src/components/ConfirmDialog.tsx");
const credentials = read("src/components/CredentialEnvelopeManager.tsx");
const iconRail = read("src/layout/IconRail.tsx");
const moduleLoadBoundary = read("src/components/ModuleLoadBoundary.tsx");
const moduleLoading = read("src/platform/modules.ts");
const application = read("src/App.tsx");
assert.match(moduleLoading, /importModuleWithRetry\(loader\.load\)/, "local descriptor imports use one bounded retry");
assert.match(moduleLoading, /failedLocalModules\.add\(loader\.packageName\)/, "final local import failures retain an explicit unavailable state");
assert.match(moduleLoading, /onLoadFailure\?\.\(info\.id\)/, "enabled module import failures are reported to the shell");
assert.match(application, /webModuleLoadFailures\.length > 0[\s\S]*optional_module_load_failed/, "an import failure is not silently presented as an uninstalled optional module");
assert.match(application, /function WebModuleLoadFailureNotice[\s\S]*useUnsavedChanges\(\)[\s\S]*requestNavigation\(\(\) => window\.location\.reload\(\)\)/, "module recovery reload uses the shared unsaved-draft navigation guard");
const wysiwygEditor = read("src/components/WysiwygEditor.tsx");
const titlebar = read("src/layout/Titlebar.tsx");
const temporalDataMenu = read("src/layout/TemporalDataMenu.tsx");
const helpMenu = read("src/layout/HelpMenu.tsx");
@@ -27,6 +37,43 @@ const dialogAnatomy = read("src/components/DialogAnatomy.tsx");
const adminPageLayout = read("src/components/admin/AdminPageLayout.tsx");
const layoutStyles = read("src/styles/layout.css");
const authGateStyles = read("src/styles/auth-gate.css");
const viteConfig = read("vite.config.ts");
const packageManifest = JSON.parse(read("package.json"));
// Component presence alone cannot detect a collection toolbar in a left pane.
await import("./test-workspace-collection-actions.mjs");
const listSelectionFilter = read("src/components/ListSelectionFilter.tsx");
const dataGrid = read("src/components/table/DataGrid.tsx");
const multiSelectFilter = read("src/components/MultiSelectFilter.tsx");
assert.match(dataGrid, /<ListSelectionFilter\b/, "DataGrid consumes the shared checkbox filter body");
assert.match(multiSelectFilter, /<ListSelectionFilter\b/, "standalone list filters consume the same checkbox body");
assert.match(listSelectionFilter, /onChange\(null\)/, "Select all explicitly removes the restriction");
assert.match(listSelectionFilter, /onChange\(\[\]\)/, "Deselect all explicitly matches nothing");
assert.match(read("src/components/Card.tsx"), /bodyLayout\?: "content" \| "table"/, "table cards explicitly own their inset contract");
// A package in resolve.dedupe is not necessarily prebundled. Check include
// specifically: lazy Campaign imports previously triggered dependency churn
// after the UI was already open, leaving module-load errors on first visits.
const optimization = viteConfig.match(/optimizeDeps:\s*\{([\s\S]*?)\n\s*\},/)?.[1];
assert.ok(optimization, "the development dependency optimizer is configured");
const optimizedVendors = [...(optimization.match(/include:\s*\[([\s\S]*?)\]/)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
for (const specifier of [
"@xyflow/react",
"read-excel-file/browser",
"read-excel-file/universal",
"@tiptap/core",
"@tiptap/extension-image",
"@tiptap/react",
"@tiptap/starter-kit"
]) {
assert.ok(optimizedVendors.includes(specifier), `${specifier} is prebundled before lazy routes open`);
const packageName = specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
assert.ok(packageManifest.dependencies[packageName] ?? packageManifest.devDependencies[packageName], `${specifier} is a direct dependency of the development shell`);
assert.doesNotThrow(() => require.resolve(specifier), `${specifier} has an installed resolvable entry point`);
}
assert.match(optimization, /exclude:\s*availableWebModuleSpecifiers\(\)/, "optional module packages stay outside dependency prebundling");
assert.ok(!optimizedVendors.some((specifier) => specifier.startsWith("@govoplan/")), "vendor prebundling must not eagerly import optional module packages");
assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation");
assert.match(settings, /archetype=\{editorSection \? "editor" : "workspace"\}/, "settings declare editor intent only for draft-owning sections");
@@ -71,6 +118,8 @@ assert.match(pageActionBar, /refreshable: true;\s*reloadAction: PageReloadAction
assert.match(pageActionBar, /state: PageEditorState;/, "editors require an explicit persistence lifecycle state");
assert.match(pageActionBar, /data-page-dirty-state=/, "editors announce clean, dirty, and saving states");
assert.match(pageActionBar, /<ActionSlot name="reload">/, "page action bars keep reload in a named stable slot");
assert.match(pageActionBar, /data-page-action-group="trailing"[\s\S]*<ActionSlot name="reload">/, "Reload belongs to the trailing group beside primary actions");
assert.doesNotMatch(pageActionBar, /data-page-action-group="leading"(?:(?!<\/ToolbarGroup>)[\s\S])*name="reload"/, "module-independent reload placement must not drift back to the leading group");
assert.match(pageActionBar, /data-page-action-separation="destructive"/, "destructive page actions expose a separate semantic group");
assert.match(pageActionBar, /<ActionSlot name="discard">[\s\S]*<ActionSlot name="save">/, "editor save follows discard in the trailing group");
assert.match(workspaceActionBar, /actionScope=\{scope\}/, "workspace and pane action bars project their semantic scope centrally");
@@ -102,5 +151,8 @@ assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.app-main \{[\s\S
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.titlebar-context-selectors \{[\s\S]*overflow-x: auto;/, "narrow context selectors remain reachable without covering titlebar actions");
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.account-pill span \{[\s\S]*display: none;/, "narrow account controls retain the icon while removing collision-prone text");
assert.match(moduleLoadBoundary, /<DismissibleAlert tone="danger" compact/, "module failures use the compact shared alert");
assert.match(wysiwygEditor, /editor\.setEditable\(!disabled, false\)/, "mounting and permission changes never emit rich-text changes");
assert.match(wysiwygEditor, /if \(!isWysiwygDocumentUpdate\(transaction, appendedTransactions\)\) return;/, "rich-text editors ignore non-document update events");
assert.match(wysiwygEditor, /if \(nextValue === valueRef\.current\) return;/, "rich-text editors do not report identical content as an edit");
console.log("Core interface-pattern contracts passed.");
+34 -4
View File
@@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import vm from "node:vm";
import ts from "typescript";
const webuiRoot = resolve(import.meta.dirname, "..");
const repositoryRoot = resolve(webuiRoot, "..", "..");
@@ -9,6 +11,7 @@ const app = readFileSync(resolve(webuiRoot, "src/App.tsx"), "utf8");
const settings = readFileSync(resolve(webuiRoot, "src/features/settings/SettingsPage.tsx"), "utf8");
const paletteControl = readFileSync(resolve(webuiRoot, "src/components/AppearancePaletteControl.tsx"), "utf8");
const overridesEditor = readFileSync(resolve(webuiRoot, "src/components/AppearanceOverridesEditor.tsx"), "utf8");
const overridesRuntime = readFileSync(resolve(webuiRoot, "src/components/appearanceOverrides.ts"), "utf8");
assert.match(tokens, /:root\[data-theme="dark"\]/, "dark token overrides are required");
assert.match(tokens, /color-scheme:\s*dark/, "native controls must receive the dark color scheme");
@@ -24,11 +27,38 @@ for (const palette of ["default", "civic_blue", "forest", "plum"]) {
assert.match(settings, /AppearancePaletteSelect/, "personal settings must use the shared palette control");
assert.match(settings, /AppearanceOverridesEditor/, "personal settings must use the shared override editor");
assert.match(app, /applyAppearanceOverrides/, "the shell must apply validated overrides centrally");
assert.match(overridesEditor, /schema_version:\s*"1"/, "override exchange must use an explicit versioned schema");
assert.match(overridesEditor, /contrastRatio[\s\S]*?<\s*4\.5/, "custom pairs must enforce WCAG AA contrast");
assert.match(overridesEditor, /rgbDistance[\s\S]*?<\s*12/, "custom status colors must enforce differentiation");
assert.match(app, /import \{ applyAppearanceOverrides \} from "\.\/components\/appearanceOverrides"/, "startup applies themes synchronously without importing settings editor controls");
assert.doesNotMatch(overridesRuntime, /import .*from ["']react["']|\.tsx|ColorPickerField|ContentGrid/, "the startup theme runtime must stay independent of editor UI");
assert.match(overridesEditor, /from "\.\/appearanceOverrides"/, "editor and runtime share the same validation implementation");
assert.match(overridesRuntime, /schema_version:\s*"1"/, "override exchange must use an explicit versioned schema");
assert.match(overridesRuntime, /contrastRatio[\s\S]*?<\s*4\.5/, "custom pairs must enforce WCAG AA contrast");
assert.match(overridesRuntime, /rgbDistance[\s\S]*?<\s*12/, "custom status colors must enforce differentiation");
const runtimeExports = {};
vm.runInNewContext(ts.transpileModule(overridesRuntime, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: runtimeExports });
const validOverrides = runtimeExports.cloneDefaultAppearanceOverrides();
assert.equal(runtimeExports.validateAppearanceOverrides(validOverrides), validOverrides);
const colorProperties = new Map();
const root = { style: {
setProperty: (property, value) => colorProperties.set(property, value),
removeProperty: (property) => colorProperties.delete(property)
} };
runtimeExports.applyAppearanceOverrides(root, validOverrides, "dark");
assert.equal(colorProperties.get("--accent"), validOverrides.dark.accent);
assert.equal(colorProperties.get("--danger-text"), validOverrides.dark.danger_foreground);
runtimeExports.applyAppearanceOverrides(root, validOverrides, "light");
assert.equal(colorProperties.get("--accent"), validOverrides.light.accent);
const invalidOverrides = runtimeExports.cloneDefaultAppearanceOverrides();
invalidOverrides.light.accent_foreground = invalidOverrides.light.accent;
assert.throws(() => runtimeExports.validateAppearanceOverrides(invalidOverrides));
runtimeExports.applyAppearanceOverrides(root, invalidOverrides, "dark");
assert.equal(colorProperties.size, 0, "invalid documents clear previous custom tokens and never partially apply");
runtimeExports.applyAppearanceOverrides(root, validOverrides, "light");
runtimeExports.applyAppearanceOverrides(root, null, "light");
assert.equal(colorProperties.size, 0, "reset restores inherited CSS values synchronously");
for (const token of ["accent", "surface", "success", "info", "warning", "danger"]) {
assert.match(overridesEditor, new RegExp(`"--${token}`), `runtime overrides must map ${token} into shared tokens`);
assert.match(overridesRuntime, new RegExp(`"--${token}`), `runtime overrides must map ${token} into shared tokens`);
}
for (const relativePath of [
"govoplan-admin/webui/src/features/admin/SystemSettingsPanel.tsx",
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import { dirname, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveConfig } from "vite";
const webuiRoot = fileURLToPath(new URL("..", import.meta.url));
function contains(parent, candidate) {
const suffix = relative(parent, candidate);
return suffix === "" || (!suffix.startsWith(`..${sep}`) && suffix !== ".." && !suffix.startsWith(sep));
}
// Resolve the real Vite configurations without creating a server, optimizing
// dependencies, or changing any existing development/browser-test cache.
const app = await resolveConfig({
root: webuiRoot,
configFile: resolve(webuiRoot, "vite.config.ts")
}, "serve");
const conformance = await resolveConfig({
root: webuiRoot,
configFile: resolve(webuiRoot, "vite.conformance.config.ts")
}, "serve");
assert.notEqual(app.cacheDir, conformance.cacheDir,
"Browser conformance must never replace the running application's optimized dependencies.");
assert.ok(!contains(app.cacheDir, conformance.cacheDir) && !contains(conformance.cacheDir, app.cacheDir),
"Neither cache may own the other cache's directory: optimizer cleanup must remain isolated.");
assert.equal(dirname(app.cacheDir), dirname(conformance.cacheDir),
"Product and conformance use explicit sibling cache directories.");
assert.ok(contains(resolve(webuiRoot, "node_modules"), app.cacheDir),
"The application cache remains generated local dependency state.");
assert.ok(contains(resolve(webuiRoot, "node_modules"), conformance.cacheDir),
"The conformance cache remains generated local dependency state.");
assert.ok(app.optimizeDeps.include.includes("@xyflow/react"),
"Lazy Workflow and Dataflow graph imports are prebundled before their first visit.");
assert.ok(app.optimizeDeps.include.includes("read-excel-file/browser"),
"Deferred spreadsheet imports remain prebundled.");
assert.ok(app.optimizeDeps.include.includes("@tiptap/react"),
"Deferred rich-text editors remain prebundled.");
console.log("Vite product/conformance dependency-cache isolation passed.");
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import ts from "typescript";
// Collection-wide commands belong to the full workspace, never its left pane.
// Keep editor/detail commands scoped to their own pane. The real-page browser
// scenarios separately verify the shared action engine's physical placement.
const pages = [
["workflow", "workflow/WorkflowPage.tsx"],
["dataflow", "dataflow/DataflowPage.tsx"],
["templates", "templates/TemplatesPage.tsx"],
["datasources", "datasources/DatasourcesPage.tsx"],
["dist-lists", "distributionLists/DistributionListsPage.tsx"],
["tasks", "tasks/TasksPage.tsx"],
["voting", "voting/VotingPage.tsx"],
["scheduling", "scheduling/SchedulingPage.tsx"],
["committee", "committee/CommitteePage.tsx"]
];
function attribute(node, name) {
const item = node.attributes.properties.find(prop => ts.isJsxAttribute(prop) && prop.name.getText() === name);
return item?.initializer && ts.isStringLiteral(item.initializer) ? item.initializer.text : item;
}
let checked = 0;
for (const [module, page] of pages) {
const path = resolve(import.meta.dirname, `../../../govoplan-${module}/webui/src/features/${page}`);
// Core-only checkouts must not acquire dependencies on optional modules.
if (!existsSync(path)) continue;
checked += 1;
const source = ts.createSourceFile(path, readFileSync(path, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const bars = [];
function visit(node) {
if (ts.isJsxSelfClosingElement(node) && node.tagName.getText() === "WorkspaceActionBar" && attribute(node, "variant") === "collection") bars.push(node);
ts.forEachChild(node, visit);
}
visit(source);
assert.equal(bars.length, 1, `${module}: one collection-wide action bar`);
const bar = bars[0];
assert.equal(attribute(bar, "scope"), "workspace", `${module}: collection commands use workspace scope`);
assert.ok(attribute(bar, "refreshable") && attribute(bar, "reloadAction") && attribute(bar, "createAction"), `${module}: Reload and creation stay in the same semantic bar`);
let parent = bar.parent;
while (parent && !ts.isSourceFile(parent)) {
assert.ok(!(ts.isJsxAttribute(parent) && parent.name.getText() === "primary"), `${module}: collection commands must not be nested in the left pane`);
parent = parent.parent;
}
let enclosingFrame = bar.parent;
while (enclosingFrame && !(ts.isJsxElement(enclosingFrame) && enclosingFrame.openingElement.tagName.getText() === "WorkspaceFrame")) enclosingFrame = enclosingFrame.parent;
assert.ok(enclosingFrame, `${module}: the persistent workspace owns its commands`);
if (module !== "committee" && module !== "scheduling") {
const layout = enclosingFrame.children.find(node => ts.isJsxElement(node) && node.openingElement.tagName.getText() === "WorkspaceLayout");
assert.ok(layout && bar.pos < layout.pos, `${module}: commands precede the split workspace`);
}
}
// Explorer modules use custom virtualized panes, but not a custom page toolbar.
for (const [module, page, variant] of [["files", "files/FilesPage.tsx", "collection"], ["mail", "mail/MailboxPage.tsx", "workspace"]]) {
const path = resolve(import.meta.dirname, `../../../govoplan-${module}/webui/src/features/${page}`);
if (!existsSync(path)) continue;
checked += 1;
const source = ts.createSourceFile(path, readFileSync(path, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const nodes = [];
function visit(node) { nodes.push(node); ts.forEachChild(node, visit); }
visit(source);
const bars = nodes.filter(node => ts.isJsxSelfClosingElement(node) && node.tagName.getText() === "WorkspaceActionBar" && attribute(node, "scope") === "workspace");
assert.equal(bars.length, 1, `${module}: one persistent explorer-wide action bar`);
const bar = bars[0];
assert.equal(attribute(bar, "variant"), variant, `${module}: explicit semantic action variant`);
assert.ok(attribute(bar, "refreshable") && attribute(bar, "reloadAction"), `${module}: current-view Reload is semantic`);
assert.equal(Boolean(attribute(bar, "createAction")), module === "files", `${module}: creation only where an owning workflow exists`);
const owner = bar.parent;
const placements = ts.isVariableDeclaration(owner)
? nodes.filter(node => ts.isJsxExpression(node) && node.expression && ts.isIdentifier(node.expression) && node.expression.text === owner.name.getText())
: [bar];
assert.equal(placements.length, 1, `${module}: workspace bar has one persistent placement`);
const placement = placements[0];
assert.ok(ts.isJsxElement(placement.parent) && placement.parent.openingElement.tagName.getText() === "WorkspaceFrame", `${module}: commands are direct workspace children, outside panes and selection branches`);
const shell = placement.parent.children.find(node => ts.isJsxElement(node) && node.openingElement.tagName.getText() === "div" && /file-manager-shell/.test(node.openingElement.getText()));
assert.ok(shell && placement.pos < shell.pos, `${module}: toolbar precedes the explorer panes`);
}
console.log(`Workspace actions: ${checked} present module pages conform.`);
+30 -7
View File
@@ -2,7 +2,7 @@ import { Navigate, Route, Routes, useLocation } from "react-router";
import { lazy, useEffect, useMemo, useState } from "react";
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import { AUTH_REQUIRED_EVENT, apiSettingsForAuthUpdate, clearApiReadCache, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, EffectiveViewProjection, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPalette, UserUiPreferences, ViewsRuntimeUiCapability } from "./types";
import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage";
@@ -20,13 +20,16 @@ import {
type WorkflowViewChangedEventDetail
} from "./platform/views";
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
import { UnsavedChangesProvider, useUnsavedChanges } from "./components/UnsavedChangesGuard";
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary";
import ModuleLoadBoundary from "./components/ModuleLoadBoundary";
import DismissibleAlert from "./components/DismissibleAlert";
import Button from "./components/Button";
import { i18nMessage } from "./i18n/LanguageContext";
import { DocumentationHelpProvider } from "./components/help/DocumentationHelpLink";
import { hasAnyScope } from "./utils/permissions";
import { applyAppearanceOverrides } from "./components/AppearanceOverridesEditor";
import { applyAppearanceOverrides } from "./components/appearanceOverrides";
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
@@ -54,6 +57,7 @@ export default function App() {
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
const [webModulesLoading, setWebModulesLoading] = useState(true);
const [webModuleLoadFailures, setWebModuleLoadFailures] = useState<string[]>([]);
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
const [backendReachable, setBackendReachable] = useState(true);
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
@@ -172,9 +176,10 @@ export default function App() {
}
function updateAuth(next: AuthUpdate | null, accessToken?: string) {
const nextSettings = accessToken !== undefined ? { ...settings, accessToken } : settings;
clearApiReadCache();
const nextSettings = apiSettingsForAuthUpdate(settings, next, accessToken);
setAuth((current) => next ? normalizeAuthInfo(mergeAuthPayload(current, next)) : null);
if (accessToken !== undefined) {
if (nextSettings !== settings) {
setSettings(nextSettings);
saveApiSettings(nextSettings);
}
@@ -328,6 +333,7 @@ export default function App() {
useEffect(() => {
let cancelled = false;
setWebModuleLoadFailures([]);
if (!auth) {
setLocalWebModules([]);
setRemoteWebModules([]);
@@ -345,12 +351,18 @@ export default function App() {
}
async function loadWebModules() {
const local = await loadInstalledWebModules(platformModules);
const failedIds: string[] = [];
const local = await loadInstalledWebModules(platformModules, (moduleId) => failedIds.push(moduleId));
if (cancelled) return;
setLocalWebModules(local);
setWebModuleLoadFailures(failedIds);
setWebModulesLoading(false);
const remote = await loadRemoteWebModules(platformModules, local);
if (!cancelled) setRemoteWebModules(remote);
if (!cancelled) {
setRemoteWebModules(remote);
const recovered = new Set(remote.map((module) => module.id));
setWebModuleLoadFailures(failedIds.filter((moduleId) => !recovered.has(moduleId)));
}
}
void loadWebModules().catch((error) => {
@@ -451,8 +463,10 @@ export default function App() {
setBackendReachable(true);
const shellRefreshDue = now - lastShellRefreshAt >= 60_000;
if (!sessionMatchesAuth(sessionInfo, currentAuth) || shellRefreshDue) {
clearApiReadCache();
const shellAuth = await fetchShellAuth(settings);
if (cancelled) return;
clearApiReadCache();
lastShellRefreshAt = Date.now();
setAuth((current) => current && sessionMatchesAuth(sessionInfo, current)
? normalizeAuthInfo(mergeAuthPayload(current, shellAuth))
@@ -555,6 +569,7 @@ export default function App() {
<PlatformActiveObjectProvider>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} allToolItems={allToolItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
{webModuleLoadFailures.length > 0 && <WebModuleLoadFailureNotice moduleIds={webModuleLoadFailures} />}
<ModuleLoadBoundary resetKey={`${location.pathname}:${temporalRevision}`} loading={webModulesLoading}>
<Routes key={`${(auth.active_tenant ?? auth.tenant).id}:${temporalRevision}`}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
@@ -607,6 +622,14 @@ export default function App() {
}
function WebModuleLoadFailureNotice({ moduleIds }: { moduleIds: string[] }) {
const { requestNavigation } = useUnsavedChanges();
return <DismissibleAlert tone="warning" dismissible={false} floating>
<p>{i18nMessage("i18n:govoplan-core.optional_module_load_failed", { value0: moduleIds.join(", ") })}</p>
<Button onClick={() => requestNavigation(() => window.location.reload())}>i18n:govoplan-core.reload.cce71553</Button>
</DismissibleAlert>;
}
type AuthPayload = AuthUpdate;
function mergeAuthPayload(current: AuthInfo | null, next: AuthPayload): AuthPayload {
+85 -41
View File
@@ -1,4 +1,4 @@
import type { ApiSettings } from "../types";
import type { ApiSettings, AuthUpdate } from "../types";
import { temporalRequestHeaders } from "../platform/temporal";
const STORAGE_KEY = "govoplan.apiSettings";
@@ -25,12 +25,14 @@ type RecentSafeRequest = {
type ConditionalSafeRequest = {
value: unknown;
etag: string;
cacheControl: string;
};
const inFlightSafeRequests = new Map<string, Promise<unknown>>();
const recentSafeRequests = new Map<string, RecentSafeRequest>();
const conditionalSafeRequests = new Map<string, ConditionalSafeRequest>();
let safeRequestGeneration = 0;
let lastSessionCookie: string | undefined;
export class ApiError extends Error {
readonly status: number;
@@ -166,7 +168,16 @@ export function loadApiSettings(): ApiSettings {
};
}
export function apiSettingsForAuthUpdate(settings: ApiSettings, next: AuthUpdate | null, accessToken?: string): ApiSettings {
if (next === null || accessToken !== undefined || next.principal?.auth_method === "session") {
const token = next === null ? "" : accessToken ?? settings.accessToken;
return settings.apiKey || token !== settings.accessToken ? { ...settings, apiKey: "", accessToken: token } : settings;
}
return settings;
}
export function saveApiSettings(settings: ApiSettings): void {
clearApiReadCache();
localStorage.setItem(`${STORAGE_KEY}.baseUrl`, normalizeApiBaseUrl(settings.apiBaseUrl));
if (settings.apiKey) {
sessionStorage.setItem(`${SESSION_STORAGE_KEY}.apiKey`, settings.apiKey);
@@ -181,6 +192,7 @@ export function saveApiSettings(settings: ApiSettings): void {
}
export function clearAccessToken(): void {
clearApiReadCache();
sessionStorage.removeItem(`${SESSION_STORAGE_KEY}.accessToken`);
localStorage.removeItem(`${STORAGE_KEY}.accessToken`);
}
@@ -220,30 +232,27 @@ function isUnsafeMethod(method?: string): boolean {
return !["GET", "HEAD", "OPTIONS", "TRACE"].includes(normalized);
}
function canReuseSafeRequest(method: string, init?: RequestInit): boolean {
function canReuseSafeRequest(method: string, headers: Headers, init?: RequestInit): boolean {
return (method === "GET" || method === "HEAD") &&
!init?.body &&
!init?.signal &&
init?.cache !== "no-store" &&
init?.cache !== "reload";
!requiresFreshRead(headers, init);
}
function requestHeadersKey(headers: Headers): string {
return [...headers.entries()].
sort(([left], [right]) => left.localeCompare(right)).
map(([key, value]) => `${key}:${value}`).
join("\n");
function requiresFreshRead(headers: Headers, init?: RequestInit): boolean {
return init?.cache === "no-store" || init?.cache === "reload" || init?.cache === "no-cache" ||
/\b(?:no-store|no-cache|max-age\s*=\s*"?0\b)/i.test(headers.get("Cache-Control") ?? "");
}
function safeRequestKey(url: string, method: string, headers: Headers, init?: RequestInit): string {
return [
// Headers already iterates normalized names in lexicographical order.
return JSON.stringify([
method,
url,
init?.credentials ?? "include",
init?.mode ?? "",
init?.redirect ?? "",
requestHeadersKey(headers)].
join("\n\n");
[...headers].filter(([key]) => key !== "cache-control")]);
}
function pruneRecentSafeRequests(now = Date.now()): void {
@@ -285,9 +294,9 @@ function conditionalSafeResponse(key: string): ConditionalSafeRequest | undefine
return cached;
}
function rememberConditionalSafeResponse(key: string, etag: string, value: unknown): void {
function rememberConditionalSafeResponse(key: string, etag: string, value: unknown, cacheControl: string): void {
conditionalSafeRequests.delete(key);
conditionalSafeRequests.set(key, { etag, value });
conditionalSafeRequests.set(key, { etag, value, cacheControl });
while (conditionalSafeRequests.size > MAX_CONDITIONAL_SAFE_REQUESTS) {
const oldestKey = conditionalSafeRequests.keys().next().value;
if (!oldestKey) break;
@@ -295,7 +304,8 @@ function rememberConditionalSafeResponse(key: string, etag: string, value: unkno
}
}
function clearSafeRequestCaches(): void {
/** Invalidate before changing the shell's account, tenant, permissions, or session. */
export function clearApiReadCache(): void {
safeRequestGeneration += 1;
inFlightSafeRequests.clear();
recentSafeRequests.clear();
@@ -318,6 +328,7 @@ function shouldNotifyAuthRequired(path: string): boolean {
}
function notifyAuthRequired(path: string): void {
clearApiReadCache();
if (typeof window === "undefined" || !shouldNotifyAuthRequired(path)) return;
const detail: AuthRequiredEventDetail = {
path,
@@ -347,43 +358,85 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
}
const csrf = csrfToken();
// The HttpOnly session cookie cannot enter a cache key. Its paired CSRF
// cookie rotates with the session, including sign-in from another tab.
if (lastSessionCookie !== csrf) {
clearApiReadCache();
lastSessionCookie = csrf;
}
if (csrf && isUnsafeMethod(method) && !headers.has("X-CSRF-Token")) {
headers.set("X-CSRF-Token", csrf);
}
if (isUnsafeMethod(method)) {
clearSafeRequestCaches();
const unsafe = isUnsafeMethod(method);
if (unsafe) {
clearApiReadCache();
}
const url = apiUrl(settings, path);
const reusableSafeRequest = canReuseSafeRequest(method, init);
const cacheKey = reusableSafeRequest ? safeRequestKey(url, method, headers, init) : null;
const reusableSafeRequest = canReuseSafeRequest(method, headers, init);
const cacheKey = safeRequestKey(url, method, headers, init);
const requestGeneration = safeRequestGeneration;
let request: Promise<T> | undefined;
if ((method === "GET" || method === "HEAD") && requiresFreshRead(headers, init)) {
// Explicit fresh reads supersede both stored and still-running reads for
// this resource, without invalidating unrelated workspace requests.
inFlightSafeRequests.delete(cacheKey);
recentSafeRequests.delete(cacheKey);
conditionalSafeRequests.delete(cacheKey);
}
function rememberResponse(response: Response, value: T, previous?: ConditionalSafeRequest): void {
if (!reusableSafeRequest || requestGeneration !== safeRequestGeneration ||
inFlightSafeRequests.get(cacheKey) !== request) return;
const cacheControl = response.headers.get("Cache-Control") ?? previous?.cacheControl ?? "";
const noStore = /(?:^|,)\s*no-store\b/i.test(cacheControl) || response.headers.get("Vary")?.trim() === "*";
const mustValidate = /(?:^|,)\s*(?:no-cache\b|max-age\s*=\s*"?0\b)/i.test(cacheControl);
const etag = response.headers.get("etag") ?? previous?.etag;
if (!noStore && etag) {
rememberConditionalSafeResponse(cacheKey, etag, value, cacheControl);
} else {
conditionalSafeRequests.delete(cacheKey);
}
if (!noStore && !mustValidate) {
rememberSafeResponse(cacheKey, value);
} else {
recentSafeRequests.delete(cacheKey);
}
}
async function runFetch(): Promise<T> {
const fetchHeaders = new Headers(headers);
const conditional = cacheKey ? conditionalSafeResponse(cacheKey) : undefined;
const conditional = reusableSafeRequest ? conditionalSafeResponse(cacheKey) : undefined;
if (conditional && !fetchHeaders.has("If-None-Match")) {
fetchHeaders.set("If-None-Match", conditional.etag);
}
const fetchInit = { ...init, headers: fetchHeaders, credentials: init?.credentials ?? "include" };
const response = await fetch(url, fetchInit);
if (response.status === 304 && cacheKey && conditional) {
rememberSafeResponse(cacheKey, conditional.value);
if (response.status === 304 && conditional && fetchHeaders.get("If-None-Match") === conditional.etag) {
rememberResponse(response, conditional.value as T, conditional);
return conditional.value as T;
}
if (!response.ok) {
const text = await response.text();
if (response.status === 401 && shouldNotifyAuthRequired(path)) {
notifyAuthRequired(path);
// An obsolete request must not expire a newly established session.
if (requestGeneration === safeRequestGeneration && csrfToken() === csrf) notifyAuthRequired(path);
throw authExpiredError(response.statusText);
}
throw new ApiError(response.status, response.statusText, text);
}
if (response.status === 204) {
if (cacheKey) conditionalSafeRequests.delete(cacheKey);
if (reusableSafeRequest && requestGeneration === safeRequestGeneration &&
inFlightSafeRequests.get(cacheKey) === request) {
recentSafeRequests.delete(cacheKey);
conditionalSafeRequests.delete(cacheKey);
}
return undefined as T;
}
@@ -395,20 +448,15 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
value = (await response.json()) as T;
}
const etag = response.headers.get("etag");
if (cacheKey && etag) {
rememberConditionalSafeResponse(cacheKey, etag, value);
} else if (cacheKey) {
conditionalSafeRequests.delete(cacheKey);
}
rememberResponse(response, value);
return value;
}
if (!reusableSafeRequest || !cacheKey) {
return runFetch();
if (!reusableSafeRequest) {
// Reads made while a write was pending may still describe its old state.
return unsafe ? runFetch().finally(clearApiReadCache) : runFetch();
}
const requestGeneration = safeRequestGeneration;
const recent = recentSafeResponse(cacheKey);
if (recent !== undefined) {
return recent as T;
@@ -419,13 +467,7 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
return existing as Promise<T>;
}
const request = runFetch().
then((value) => {
if (requestGeneration === safeRequestGeneration) {
rememberSafeResponse(cacheKey, value);
}
return value;
}).
request = runFetch().
finally(() => {
if (inFlightSafeRequests.get(cacheKey) === request) {
inFlightSafeRequests.delete(cacheKey);
@@ -437,6 +479,8 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
export async function apiDownload(settings: ApiSettings, path: string, filename: string): Promise<void> {
const requestGeneration = safeRequestGeneration;
const sessionCookie = csrfToken();
const headers = authHeaders(settings);
for (const [key, value] of Object.entries(temporalRequestHeaders())) {
headers.set(key, value);
@@ -445,7 +489,7 @@ export async function apiDownload(settings: ApiSettings, path: string, filename:
if (!response.ok) {
const text = await response.text();
if (response.status === 401 && shouldNotifyAuthRequired(path)) {
notifyAuthRequired(path);
if (requestGeneration === safeRequestGeneration && csrfToken() === sessionCookie) notifyAuthRequired(path);
throw authExpiredError(response.statusText);
}
throw new ApiError(response.status, response.statusText, text);
+1
View File
@@ -79,6 +79,7 @@ export const mailProfilePolicyLimitKeys = [
"allowed_profile_ids",
"allow_user_profiles",
"allow_group_profiles",
"allow_campaign_profiles",
"smtp_credentials.inherit",
"imap_credentials.inherit",
"whitelist.smtp_hosts",
@@ -12,11 +12,8 @@ import DismissibleAlert from "./DismissibleAlert";
import FormField from "./FormField";
import SegmentedControl from "./SegmentedControl";
export const APPEARANCE_OVERRIDE_TOKENS: readonly AppearanceOverrideToken[] = [
"accent", "accent_foreground", "surface", "surface_foreground",
"success", "success_foreground", "info", "info_foreground",
"warning", "warning_foreground", "danger", "danger_foreground"
];
import { APPEARANCE_OVERRIDE_TOKENS, STATUS_TOKENS, cloneDefaultAppearanceOverrides, validateAppearanceOverrides } from "./appearanceOverrides";
export { APPEARANCE_OVERRIDE_TOKENS, DEFAULT_APPEARANCE_OVERRIDES, applyAppearanceOverrides, cloneDefaultAppearanceOverrides, validateAppearanceOverrides } from "./appearanceOverrides";
const TOKEN_LABELS: Record<AppearanceOverrideToken, string> = {
accent: "i18n:govoplan-core.override_accent",
@@ -33,112 +30,6 @@ const TOKEN_LABELS: Record<AppearanceOverrideToken, string> = {
danger_foreground: "i18n:govoplan-core.override_danger_foreground"
};
const STATUS_TOKENS: readonly AppearanceOverrideToken[] = ["success", "info", "warning", "danger"];
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const RUNTIME_PROPERTIES = new Set<string>();
const RUNTIME_TOKEN_PROPERTIES: Record<AppearanceOverrideToken, readonly string[]> = {
accent: ["--accent", "--action-primary-bg"],
accent_foreground: ["--on-accent", "--badge-accent-text", "--action-primary-text"],
surface: ["--surface", "--panel-soft"],
surface_foreground: ["--text", "--text-strong"],
success: ["--success-bg", "--success-soft"],
success_foreground: ["--success-text", "--success-text-strong"],
info: ["--info-bg", "--info-soft"],
info_foreground: ["--info-text", "--info-text-strong", "--info-text-deep"],
warning: ["--warning-bg", "--warning-soft"],
warning_foreground: ["--warning-text", "--warning-text-strong"],
danger: ["--danger-bg", "--danger-soft"],
danger_foreground: ["--danger-text", "--danger-text-strong", "--danger-text-deep"]
};
for (const properties of Object.values(RUNTIME_TOKEN_PROPERTIES)) {
for (const property of properties) RUNTIME_PROPERTIES.add(property);
}
export const DEFAULT_APPEARANCE_OVERRIDES: AppearanceOverridesDocument = {
schema_version: "1",
light: {
accent: "#245f91", accent_foreground: "#ffffff",
surface: "#ffffff", surface_foreground: "#303135",
success: "#d8eee8", success_foreground: "#315f55",
info: "#dce9f3", info_foreground: "#294a61",
warning: "#ffe1a3", warning_foreground: "#593700",
danger: "#f8d1cc", danger_foreground: "#873c35"
},
dark: {
accent: "#7ea6c5", accent_foreground: "#242424",
surface: "#262724", surface_foreground: "#f1f1f1",
success: "#24473f", success_foreground: "#d8eee8",
info: "#243d4e", info_foreground: "#dce9f3",
warning: "#5a431f", warning_foreground: "#ffe1a3",
danger: "#4f2d2a", danger_foreground: "#f8d1cc"
}
};
export function cloneDefaultAppearanceOverrides(): AppearanceOverridesDocument {
return JSON.parse(JSON.stringify(DEFAULT_APPEARANCE_OVERRIDES)) as AppearanceOverridesDocument;
}
export function validateAppearanceOverrides(value: unknown): AppearanceOverridesDocument {
if (!isRecord(value) || value.schema_version !== "1" || !isRecord(value.light) || !isRecord(value.dark)) {
throw new Error("i18n:govoplan-core.appearance_override_invalid_schema");
}
if (!hasExactKeys(value, ["schema_version", "light", "dark"])) {
throw new Error("i18n:govoplan-core.appearance_override_invalid_schema");
}
const document = value as unknown as AppearanceOverridesDocument;
for (const modeName of ["light", "dark"] as const) {
const mode = document[modeName];
if (!hasExactKeys(mode, APPEARANCE_OVERRIDE_TOKENS)) {
throw new Error("i18n:govoplan-core.appearance_override_all_tokens_required");
}
for (const token of APPEARANCE_OVERRIDE_TOKENS) {
if (typeof mode[token] !== "string" || !HEX_COLOR.test(mode[token])) {
throw new Error("i18n:govoplan-core.appearance_override_hex_required");
}
}
for (const [background, foreground] of [
["accent", "accent_foreground"], ["surface", "surface_foreground"],
["success", "success_foreground"], ["info", "info_foreground"],
["warning", "warning_foreground"], ["danger", "danger_foreground"]
] as const) {
if (contrastRatio(mode[background], mode[foreground]) < 4.5) {
throw new Error("i18n:govoplan-core.appearance_override_contrast_error");
}
}
for (let first = 0; first < STATUS_TOKENS.length; first += 1) {
for (let second = first + 1; second < STATUS_TOKENS.length; second += 1) {
if (rgbDistance(mode[STATUS_TOKENS[first]], mode[STATUS_TOKENS[second]]) < 12) {
throw new Error("i18n:govoplan-core.appearance_override_status_error");
}
}
}
}
return document;
}
export function applyAppearanceOverrides(
root: HTMLElement,
document: AppearanceOverridesDocument | null,
theme: "light" | "dark"
) {
for (const property of RUNTIME_PROPERTIES) root.style.removeProperty(property);
if (!document) return;
let validated: AppearanceOverridesDocument;
try {
validated = validateAppearanceOverrides(document);
} catch {
return;
}
const colors = validated[theme];
for (const token of APPEARANCE_OVERRIDE_TOKENS) {
for (const property of RUNTIME_TOKEN_PROPERTIES[token]) {
root.style.setProperty(property, colors[token]);
}
}
}
export default function AppearanceOverridesEditor({
value,
onChange,
@@ -257,31 +148,3 @@ function appearanceOverridesValidationMessage(value: AppearanceOverridesDocument
return error instanceof Error ? error.message : "i18n:govoplan-core.appearance_override_invalid_schema";
}
}
function hasExactKeys(value: object, keys: readonly string[]): boolean {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function relativeLuminance(color: string): number {
const channels = [1, 3, 5].map((index) => Number.parseInt(color.slice(index, index + 2), 16) / 255);
const linear = channels.map((channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
}
function contrastRatio(first: string, second: string): number {
const luminances = [relativeLuminance(first), relativeLuminance(second)].sort((left, right) => right - left);
return (luminances[0] + 0.05) / (luminances[1] + 0.05);
}
function rgbDistance(first: string, second: string): number {
return Math.sqrt([1, 3, 5].reduce((total, index) => {
const delta = Number.parseInt(first.slice(index, index + 2), 16) - Number.parseInt(second.slice(index, index + 2), 16);
return total + delta * delta;
}, 0));
}
+4 -1
View File
@@ -14,6 +14,8 @@ export type CardProps = PlatformInterfaceIdentityProps & Omit<HTMLAttributes<HTM
as?: "section" | "article";
headerClassName?: string;
bodyClassName?: string;
/** Table-only bodies are edge-to-edge, including with loading wrappers. */
bodyLayout?: "content" | "table";
actionsClassName?: string;
};
@@ -56,6 +58,7 @@ export default function Card({
className = "",
headerClassName = "",
bodyClassName = "",
bodyLayout = "content",
actionsClassName = "",
interfaceId,
helpContextId,
@@ -69,7 +72,7 @@ export default function Card({
const [collapseState, setCollapseState] = useState(() => ({ storageKey, collapsed: readCollapseState(storageKey) }));
const collapsed = collapseState.storageKey === storageKey ? collapseState.collapsed : readCollapseState(storageKey);
const hasHeader = Boolean(title || actions || collapsible);
const body = <div className={["card-body", bodyClassName].filter(Boolean).join(" ")}>{children}</div>;
const body = <div className={["card-body", `card-body-${bodyLayout}`, bodyClassName].filter(Boolean).join(" ")} data-card-body-layout={bodyLayout}>{children}</div>;
const shouldRenderBody = !collapsible || !collapsed;
const collapseLabel = translateText(collapsed ? "i18n:govoplan-core.show_content.0528d8d2" : "i18n:govoplan-core.show_header_only.24afefca");
@@ -1,5 +1,5 @@
import { FormGrid } from "./ContentGrid";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import type {
ApiSettings,
@@ -104,12 +104,15 @@ const CREDENTIAL_DOCUMENTATION = {
documentationType: "admin" as const
};
const EMPTY_TARGET_OPTIONS: CredentialEnvelopeTargetOption[] = [];
const EMPTY_SERVER_OPTIONS: CredentialEnvelopeServerOption[] = [];
export default function CredentialEnvelopeManager({
settings,
scopeType,
scopeId,
targetOptions = [],
serverOptions = [],
targetOptions = EMPTY_TARGET_OPTIONS,
serverOptions = EMPTY_SERVER_OPTIONS,
targetLabel = "Target",
title = "Credential envelopes",
canWrite
@@ -120,6 +123,7 @@ export default function CredentialEnvelopeManager({
const [credentials, setCredentials] = useState<CredentialEnvelopeSummary[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const saveInFlightRef = useRef<Promise<boolean> | null>(null);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [editing, setEditing] = useState<CredentialEnvelopeSummary | "new" | null>(null);
@@ -175,6 +179,9 @@ export default function CredentialEnvelopeManager({
]),
[
activeScopeId,
// Reopening refreshes the catalogue, but typing in a draft must not
// recreate it and repeat every module's server-metadata request.
editing,
referenceCapabilities,
scopeType,
serverReferenceOptions,
@@ -260,19 +267,21 @@ export default function CredentialEnvelopeManager({
setSavedDraftKey("");
}
async function saveDraft(): Promise<boolean> {
if (!editing || !scopeReady || !draft.name.trim() || !canWrite) return false;
function saveDraft(): Promise<boolean> {
if (saveInFlightRef.current) return saveInFlightRef.current;
if (!editing || !scopeReady || !draft.name.trim() || !canWrite) return Promise.resolve(false);
if (editing === "new" && !draft.secret.trim()) {
setError("Enter a secret before creating the credential.");
return false;
return Promise.resolve(false);
}
const kindChanged = editing !== "new" && draft.credentialKind !== editing.credential_kind;
if (kindChanged && !draft.secret.trim()) {
setError("Enter a replacement secret when changing the credential type.");
return false;
return Promise.resolve(false);
}
setSaving(true);
setError("");
const operation = (async () => {
try {
const publicData = {
...draft.retainedPublicData,
@@ -313,6 +322,10 @@ export default function CredentialEnvelopeManager({
} finally {
setSaving(false);
}
})();
const pending = operation.finally(() => { saveInFlightRef.current = null; });
saveInFlightRef.current = pending;
return pending;
}
async function confirmDelete() {
@@ -427,7 +440,7 @@ export default function CredentialEnvelopeManager({
</select>
</FormField>
)}
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{error && !editing && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{notice && !error && <DismissibleAlert tone="success" resetKey={notice}>{notice}</DismissibleAlert>}
{managerBlocker && (
<ActionBlockerHint
@@ -522,6 +535,7 @@ export default function CredentialEnvelopeManager({
</>
}
>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<div className="adaptive-config-form">
<section className="adaptive-config-section">
<header>
+4
View File
@@ -16,6 +16,8 @@ type ExplorerTreeCommonProps<T> = {
getNodeLabel: (node: T) => string;
getNodeChildren: (node: T) => T[];
activeId?: string;
/** Select/open the labelled item without changing expansion. Group labels
* select the group as well; never forward this callback to onToggle. */
onOpen: (node: T, context: ExplorerTreeNodeContext) => void;
disabled?: boolean;
depth?: number;
@@ -43,6 +45,8 @@ type ExplorerTreeCommonProps<T> = {
type CollapsibleExplorerTreeProps<T> = {
collapsible?: true;
expandedIds: ReadonlySet<string>;
/** The folder/disclosure button exclusively controls expansion. Keep the
* current selection intact when expanding or collapsing its neighbours. */
onToggle: (node: T, context: ExplorerTreeNodeContext) => void;
};
+3 -2
View File
@@ -10,14 +10,15 @@ type FormFieldProps = PlatformInterfaceIdentityProps & {
help?: ReactNode;
documentation?: DocumentationHelpReference;
children: ReactNode;
className?: string;
};
export default function FormField({ label, help, documentation, children, interfaceId, helpContextId, helpModuleId, helpTopicId }: FormFieldProps) {
export default function FormField({ label, help, documentation, children, className = "", interfaceId, helpContextId, helpModuleId, helpTopicId }: FormFieldProps) {
const { translateText } = usePlatformLanguage();
const renderedLabel = typeof label === "string" ? translateText(label) : label;
return (
<label
className="form-field"
className={["form-field", className].filter(Boolean).join(" ")}
data-help-scope="field"
data-interface-id={interfaceId}
data-help-context-id={helpContextId ?? documentation?.contextId}
@@ -0,0 +1,47 @@
import type { ReactNode } from "react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
export type ListFilterOption = { value: string; label: string; disabled?: boolean };
export type ListFilterSelection = string[] | null;
/** null means unrestricted; an empty array deliberately matches nothing. */
export default function ListSelectionFilter({
options, value, onChange, label, renderOption, renderOptionActions,
}: {
options: ListFilterOption[];
value: ListFilterSelection;
onChange: (value: ListFilterSelection) => void;
label?: string;
renderOption?: (option: ListFilterOption) => ReactNode;
renderOptionActions?: (option: ListFilterOption) => ReactNode;
}) {
const { translateText } = usePlatformLanguage();
const selected = new Set(value ?? options.map((option) => option.value));
function toggle(optionValue: string) {
const next = new Set(selected);
if (next.has(optionValue)) next.delete(optionValue);
else next.add(optionValue);
onChange(options.length > 0 && options.every((option) => next.has(option.value)) ? null : [...next]);
}
return (
<div className="data-grid-list-filter">
<div className="data-grid-list-filter-actions">
<button type="button" onClick={() => onChange(null)}>{translateText("i18n:govoplan-core.select_all.913afff1")}</button>
<button type="button" onClick={() => onChange([])}>{translateText("i18n:govoplan-core.deselect_all.85cce1e1")}</button>
</div>
<div className="data-grid-list-filter-options" role="group" aria-label={translateText(label ?? "i18n:govoplan-core.allowed_values.495fcf3a")}>
{options.length === 0 ? <p className="muted small-note">{translateText("i18n:govoplan-core.no_options_available.a88ab045")}</p> : options.map((option) => (
<div className="data-grid-list-filter-row" key={option.value}>
<label>
<input type="checkbox" checked={selected.has(option.value)} disabled={option.disabled} onChange={() => toggle(option.value)} />
{renderOption ? renderOption(option) : <span className="data-grid-list-option-label">{translateText(option.label)}</span>}
</label>
{renderOptionActions?.(option)}
</div>
))}
</div>
</div>
);
}
+12 -4
View File
@@ -6,24 +6,32 @@ type LoadingFrameProps = {
loading?: boolean;
label?: string;
className?: string;
indicator?: "default" | "none";
/** Undefined omits the bar; null reports unknown progress without an invented percentage. */
progress?: number | null;
progressLabel?: string;
};
export default function LoadingFrame({ children, loading = false, label = "i18n:govoplan-core.loading_data.089f19c5", className = "" }: LoadingFrameProps) {
export default function LoadingFrame({ children, loading = false, label = "i18n:govoplan-core.loading_data.089f19c5", className = "", indicator = "default", progress, progressLabel }: LoadingFrameProps) {
const { translateText } = usePlatformLanguage();
const translatedLabel = translateText(label);
const classNames = ["loading-frame", loading ? "is-loading" : "", className].filter(Boolean).join(" ");
const progressValue = typeof progress === "number" && Number.isFinite(progress) ? Math.max(0, Math.min(100, progress)) : undefined;
const translatedProgressLabel = progressLabel ? translateText(progressLabel) : undefined;
return (
<div className={classNames} aria-busy={loading || undefined}>
{children}
{loading &&
<div className="loading-frame-overlay" role="status" aria-live="polite">
<div className="loading-frame-panel">
<LoadingIndicator label={translatedLabel} size="md" />
<div className={`loading-frame-panel${progress !== undefined ? " has-progress" : ""}`}>
{indicator !== "none" && <LoadingIndicator label={translatedLabel} size="md" />}
<span>{translatedLabel}</span>
{progress !== undefined && <progress max={100} value={progressValue} aria-label={translatedLabel} aria-valuetext={translatedProgressLabel} />}
{translatedProgressLabel && <span className="loading-frame-progress-label">{translatedProgressLabel}</span>}
</div>
</div>
}
</div>);
}
}
+129
View File
@@ -0,0 +1,129 @@
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronDown, Filter, X } from "lucide-react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import ListSelectionFilter, { type ListFilterOption, type ListFilterSelection } from "./ListSelectionFilter";
import { nextDialogActivationOrder, registerDialog } from "./dialogStack";
export type MultiSelectFilterProps = {
options: ListFilterOption[];
value: ListFilterSelection;
onChange: (value: ListFilterSelection) => void;
label: string;
disabled?: boolean;
className?: string;
};
/** The DataGrid checkbox filter as a standalone, non-clipping dropdown. */
export default function MultiSelectFilter({ options, value, onChange, label, disabled = false, className = "" }: MultiSelectFilterProps) {
const { translateText } = usePlatformLanguage();
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0, width: 280, maxHeight: 400 });
const triggerRef = useRef<HTMLButtonElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
const dialogStackId = useRef(Symbol("govoplan-list-filter"));
const nestedDialog = useRef(false);
const popupId = useId();
const visible = open && !disabled;
const selectedCount = value === null ? options.length : options.filter((option) => value.includes(option.value)).length;
const summary = value === null ? translateText("i18n:govoplan-core.all") : value.length === 0
? translateText("i18n:govoplan-core.none.6eef6648") : `${selectedCount}/${options.length}`;
function close(restoreFocus = false) {
setOpen(false);
if (restoreFocus) triggerRef.current?.focus();
}
useEffect(() => { if (disabled) setOpen(false); }, [disabled]);
useLayoutEffect(() => {
if (!visible) return;
function place() {
const anchor = triggerRef.current?.getBoundingClientRect();
if (!anchor) return;
const margin = 8;
const width = Math.min(Math.max(280, anchor.width), window.innerWidth - margin * 2);
const spaceBelow = window.innerHeight - anchor.bottom - margin * 2;
const spaceAbove = anchor.top - margin * 2;
const above = spaceBelow < 300 && spaceAbove > spaceBelow;
const maxHeight = Math.max(80, above ? spaceAbove : spaceBelow);
const height = Math.min(popupRef.current?.scrollHeight ?? 360, maxHeight);
setPosition({
top: above ? Math.max(margin, anchor.top - height - margin) : anchor.bottom + margin,
left: Math.max(margin, Math.min(anchor.left, window.innerWidth - width - margin)),
width, maxHeight,
});
}
place();
window.addEventListener("resize", place);
window.addEventListener("scroll", place, true);
return () => {
window.removeEventListener("resize", place);
window.removeEventListener("scroll", place, true);
};
}, [visible, options.length]);
useEffect(() => {
if (!visible) return;
const panel = popupRef.current;
if (!panel || !triggerRef.current?.closest("[data-dialog-stack-state]")) return;
// The popup must stay in document.body to escape clipping/transforming
// containers. Register it with the existing modal stack so the parent's
// focus trap does not treat its keyboard controls as unrelated content.
nestedDialog.current = true;
const unregister = registerDialog({
id: dialogStackId.current,
activationOrder: nextDialogActivationOrder(),
panel,
restoreFocus: triggerRef.current,
canClose: () => true,
onClose: () => setOpen(false)
}, document.activeElement);
return () => {
nestedDialog.current = false;
unregister();
};
}, [visible]);
useEffect(() => {
if (!visible) return;
popupRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
function outside(event: PointerEvent | FocusEvent) {
const target = event.target as Node | null;
if (target && !popupRef.current?.contains(target) && !triggerRef.current?.contains(target)) {
// A pointer dismissal belongs to this nested popup, not also to its
// newly reactivated parent's backdrop mousedown handler.
if (nestedDialog.current && event.type === "pointerdown") event.preventDefault();
setOpen(false);
}
}
document.addEventListener("pointerdown", outside);
document.addEventListener("focusin", outside);
return () => {
document.removeEventListener("pointerdown", outside);
document.removeEventListener("focusin", outside);
};
}, [visible]);
return (
<div className={`multi-select-filter ${className}`}>
<button ref={triggerRef} type="button" className="btn btn-secondary multi-select-filter-trigger"
aria-label={translateText(label)} aria-haspopup="dialog" aria-expanded={visible}
aria-controls={visible ? popupId : undefined} disabled={disabled}
onClick={() => setOpen((current) => !current)}>
<Filter size={16} aria-hidden="true" /><span>{translateText(label)}: {summary}</span><ChevronDown size={16} aria-hidden="true" />
</button>
{visible && createPortal(
<div id={popupId} ref={popupRef} role="dialog" tabIndex={-1} aria-label={translateText(label)}
className="data-grid-filter-popover multi-select-filter-popover" style={position}
onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); close(true); } }}>
<div className="data-grid-filter-popover-header">
<strong>{translateText(label)}</strong>
<button type="button" aria-label={translateText("i18n:govoplan-core.close_filter.3a281c3f")} onClick={() => close(true)}><X size={15} aria-hidden="true" /></button>
</div>
<ListSelectionFilter options={options} value={value} onChange={onChange} label={label} />
</div>, document.body
)}
</div>
);
}
@@ -1,139 +1,149 @@
import { ArrowDown, ArrowUp, LockKeyhole } from "lucide-react";
import type { NavigationPreferences, PlatformNavItem } from "../types";
import { useId, useRef, useState, type DragEvent, type KeyboardEvent } from "react";
import { ArrowDown, ArrowUp, GripVertical, LockKeyhole, Plus, Trash2 } from "lucide-react";
import type { NavigationPreferences, PlatformNavItem, ProductAreaContribution } from "../types";
import ActionToolbar from "./ActionToolbar";
import Button from "./Button";
import IconButton from "./IconButton";
import ToggleSwitch from "./ToggleSwitch";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import { navigationEditorTranslations } from "../i18n/navigationEditorTranslations";
import { inheritedNavigationLayout, materializeNavigationLayout, moveNavigationEntry, navigationEditorOrder, navigationId, type NavigationPreferenceScope } from "./navigationPreferenceLayout";
type Scope = "system" | "tenant" | "user";
export default function NavigationPreferenceEditor({
items,
value,
onChange,
scope,
disabled = false
}: {
export default function NavigationPreferenceEditor({ items, productAreas = [], value, onChange, scope, disabled = false }: {
items: PlatformNavItem[];
productAreas?: ProductAreaContribution[];
value: NavigationPreferences | null;
onChange: (value: NavigationPreferences | null) => void;
scope: Scope;
scope: NavigationPreferenceScope;
disabled?: boolean;
}) {
const { translateText } = usePlatformLanguage();
const inheritedScope = scope === "system" ? "module" : scope === "tenant" ? "system" : "tenant";
const inherited = preferenceFromLayer(items, inheritedScope);
const editable = value ?? inherited;
const { translateText, language, t } = usePlatformLanguage();
function navigationText(name: string, values: Record<string, string | number> = {}) {
const fallback = navigationEditorTranslations[language]?.[name] ?? navigationEditorTranslations.en[name] ?? name;
const template = t(`i18n:govoplan-core.navigation_editor_${name}`, fallback);
return template.replace(/\{(\w+)\}/g, (match, field: string) => String(values[field] ?? match));
}
const instructionsId = useId();
const inherited = inheritedNavigationLayout(items, scope, productAreas);
const editable = materializeNavigationLayout(value, inherited);
const byId = new Map(items.map((item) => [navigationId(item), item]));
const inheritedIds = [...items]
.sort((left, right) => layerOrder(left, inheritedScope) - layerOrder(right, inheritedScope))
.map(navigationId);
const orderedIds = [
...editable.order.filter((id) => byId.has(id)),
...inheritedIds.filter((id) => !editable.order.includes(id))
];
const hidden = new Set(editable.hidden);
const localLocks = new Set(editable.locked ?? []);
const inheritedScope = scope === "system" ? "module" : scope === "tenant" ? "system" : "tenant";
const ancestorLocked = (id: string) => Boolean(byId.get(id)?.navigationLayers?.[inheritedScope]?.locked);
const locked = (id: string) => ancestorLocked(id) || Boolean(editable.locked?.includes(id));
const separators = new Map((editable.separators ?? []).map((item) => [item.id, item]));
const effective = { ...editable, hidden: editable.hidden.filter((id) => !locked(id)) };
const orderedIds = navigationEditorOrder(items, effective);
const available = items.filter((item) => effective.hidden.includes(navigationId(item)));
const [selectedModule, setSelectedModule] = useState("");
const [dragged, setDragged] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [announcement, setAnnouncement] = useState("");
const pickup = useRef<{ id: string; original: NavigationPreferences | null } | null>(null);
const addId = available.some((item) => navigationId(item) === selectedModule) ? selectedModule : navigationId(available[0] ?? { to: "", label: "" });
function labelFor(id: string) {
return translateText(byId.get(id)?.label ?? separators.get(id)?.label ?? "") || translateText(navigationText("separator"));
}
function update(patch: Partial<NavigationPreferences>) {
onChange({ ...editable, ...patch, contract_version: "1" });
if (disabled) return;
const next = { ...effective, order: orderedIds, ...patch, contract_version: "1" as const };
// Optional modules can be temporarily absent. Preserve their stored place
// instead of destroying it when an unrelated visible entry is edited.
const unavailable = new Set(editable.order.filter((id) => !byId.has(id) && !separators.has(id)));
const order = [...next.order];
for (const id of editable.order) {
if (!unavailable.has(id) || order.includes(id)) continue;
const following = editable.order.slice(editable.order.indexOf(id) + 1).find((entry) => order.includes(entry));
order.splice(following ? order.indexOf(following) : order.length, 0, id);
}
onChange({ ...next, order });
}
function move(id: string, offset: -1 | 1) {
const index = orderedIds.indexOf(id);
const target = index + offset;
if (index < 0 || target < 0 || target >= orderedIds.length) return;
const next = [...orderedIds];
[next[index], next[target]] = [next[target], next[index]];
const target = orderedIds[orderedIds.indexOf(id) + offset];
if (!target || disabled) return;
const next = moveNavigationEntry(orderedIds, id, target, offset === 1);
update({ order: next });
setAnnouncement(translateText(navigationText("moved", { label: labelFor(id), position: next.indexOf(id) + 1, total: next.length })));
}
function setVisible(id: string, visible: boolean) {
const next = new Set(hidden);
if (visible) next.delete(id);
else next.add(id);
update({ order: orderedIds, hidden: [...next] });
function remove(id: string) {
if (locked(id)) return;
update({ order: orderedIds.filter((item) => item !== id),
hidden: byId.has(id) ? [...new Set([...effective.hidden, id])] : effective.hidden,
separators: (effective.separators ?? []).filter((item) => item.id !== id) });
}
function setLocked(id: string, locked: boolean) {
const next = new Set(localLocks);
if (locked) next.add(id);
else next.delete(id);
const nextHidden = new Set(hidden);
if (locked) nextHidden.delete(id);
update({ order: orderedIds, hidden: [...nextHidden], locked: [...next] });
function drop(event: DragEvent, target: string) {
event.preventDefault();
if (disabled || !dragged) return;
const bounds = event.currentTarget.getBoundingClientRect();
const next = moveNavigationEntry(orderedIds, dragged, target, event.clientY > bounds.top + bounds.height / 2);
if (next.some((id, index) => id !== orderedIds[index])) update({ order: next });
setAnnouncement(translateText(navigationText("moved", { label: labelFor(dragged), position: next.indexOf(dragged) + 1, total: next.length })));
setDragged(null); setDropTarget(null);
}
function keyboardDrag(event: KeyboardEvent, id: string) {
if (disabled) return;
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
if (pickup.current) { pickup.current = null; setDragged(null); setAnnouncement(translateText(navigationText("dropped"))); }
else { pickup.current = { id, original: value }; setDragged(id); setAnnouncement(translateText(navigationText("picked_up"))); }
} else if (pickup.current?.id === id && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
event.preventDefault(); move(id, event.key === "ArrowUp" ? -1 : 1);
} else if (pickup.current && event.key === "Escape") {
event.preventDefault(); onChange(pickup.current.original); pickup.current = null; setDragged(null); setAnnouncement(translateText(navigationText("cancelled")));
}
}
return (
<div className="navigation-preference-editor" data-navigation-preference-scope={scope}>
<ActionToolbar className="navigation-preference-toolbar" justify="between">
<p className="muted small-note">
Higher personal settings take precedence over tenant and system order. Locked entries remain visible.
</p>
<Button onClick={() => onChange(null)} disabled={disabled || value === null}>
Use inherited order
</Button>
<p className="muted small-note" id={instructionsId}>{navigationText("help")}</p>
<Button onClick={() => onChange(null)} disabled={disabled || value === null}>{navigationText("inherit")}</Button>
</ActionToolbar>
<ol className="navigation-preference-list">
<ActionToolbar className="navigation-preference-add">
<select aria-label={translateText(navigationText("available"))} value={addId} disabled={disabled || available.length === 0} onChange={(event) => setSelectedModule(event.target.value)}>
{available.length === 0 && <option value="">{translateText(navigationText("all_added"))}</option>}
{available.map((item) => <option key={navigationId(item)} value={navigationId(item)}>{translateText(item.label)}</option>)}
</select>
<Button disabled={disabled || !addId} onClick={() => update({ order: [...orderedIds, addId], hidden: effective.hidden.filter((id) => id !== addId) })}><Plus size={16} aria-hidden="true" />{navigationText("add_module")}</Button>
<Button disabled={disabled || (effective.separators?.length ?? 0) >= 128} onClick={() => {
const separator = { id: `separator:${crypto.randomUUID()}`, label: "" };
update({ order: [...orderedIds, separator.id], separators: [...(effective.separators ?? []), separator] });
}}><Plus size={16} aria-hidden="true" />{navigationText("add_separator")}</Button>
</ActionToolbar>
<ol className="navigation-preference-list" aria-label={translateText(navigationText("layout"))}>
{orderedIds.map((id, index) => {
const item = byId.get(id);
if (!item) return null;
const inheritedState = item.navigationLayers?.[inheritedScope];
const ancestorLocked = Boolean(inheritedState?.locked);
const locked = ancestorLocked || localLocks.has(id);
const label = translateText(item.label);
const separator = separators.get(id);
const label = labelFor(id);
return (
<li key={id} data-navigation-id={id} data-navigation-locked={locked ? "true" : "false"}>
<li key={id} data-navigation-id={id} data-navigation-kind={separator ? "separator" : "module"} data-navigation-locked={locked(id)} data-dragging={dragged === id} data-drop-target={dropTarget === id}
onDragOver={(event) => { if (!disabled && dragged) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; setDropTarget(id); } }} onDrop={(event) => drop(event, id)}>
<div className="navigation-preference-order-actions">
<IconButton label={`Move ${label} up`} icon={<ArrowUp size={16} />} onClick={() => move(id, -1)} disabled={disabled || index === 0} />
<IconButton label={`Move ${label} down`} icon={<ArrowDown size={16} />} onClick={() => move(id, 1)} disabled={disabled || index === orderedIds.length - 1} />
<IconButton label={navigationText("reorder", { label })} icon={<GripVertical size={16} />} className="navigation-preference-drag" disabled={disabled} draggable={!disabled}
aria-describedby={instructionsId} aria-pressed={dragged === id} onKeyDown={(event) => keyboardDrag(event, id)}
onDragStart={(event) => { setDragged(id); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", id); }} onDragEnd={() => { setDragged(null); setDropTarget(null); }} />
<IconButton label={navigationText("up", { label })} icon={<ArrowUp size={16} />} onClick={() => move(id, -1)} disabled={disabled || index === 0} />
<IconButton label={navigationText("down", { label })} icon={<ArrowDown size={16} />} onClick={() => move(id, 1)} disabled={disabled || index === orderedIds.length - 1} />
</div>
<div className="navigation-preference-label">
<strong>{label}</strong>
<span>{id}</span>
{separator ? <label><span>{navigationText("separator_label")}</span><input value={translateText(separator.label)} maxLength={120} disabled={disabled} placeholder={translateText(navigationText("separator"))} onChange={(event) => update({ separators: (effective.separators ?? []).map((entry) => entry.id === id ? { ...entry, label: event.target.value } : entry) })} /></label> : <><strong>{label}</strong><span>{id}</span></>}
</div>
<div className="navigation-preference-item-actions">
{item && (scope === "system" || scope === "tenant") && <ToggleSwitch label={<><LockKeyhole size={14} aria-hidden="true" />{navigationText("locked")}</>} checked={locked(id)} disabled={disabled || ancestorLocked(id)} onChange={(next) => update({ locked: next ? [...new Set([...(effective.locked ?? []), id])] : (effective.locked ?? []).filter((entry) => entry !== id), hidden: effective.hidden.filter((entry) => entry !== id) })} />}
{item && locked(id) && <span className="muted small-note" title={translateText(navigationText("locked_help"))}><LockKeyhole size={14} aria-label={translateText(navigationText("locked"))} /></span>}
<IconButton label={navigationText("remove", { label })} icon={<Trash2 size={16} />} disabled={disabled || locked(id)} disabledReason={locked(id) ? navigationText("locked_help") : undefined} onClick={() => remove(id)} />
</div>
<ToggleSwitch
label="Visible"
checked={locked || !hidden.has(id)}
disabled={disabled || locked}
help={locked ? `Locked by ${inheritedState?.lock_source ?? scope}` : undefined}
onChange={(visible) => setVisible(id, visible)}
/>
{scope !== "user" && (
<ToggleSwitch
label={<><LockKeyhole size={14} aria-hidden="true" /> Locked</>}
checked={locked}
disabled={disabled || ancestorLocked}
help={ancestorLocked ? `Locked by ${inheritedState?.lock_source}` : "Lower scopes cannot hide this entry."}
onChange={(next) => setLocked(id, next)}
/>
)}
</li>
);
})}
</ol>
{orderedIds.length === 0 && <p className="muted">{navigationText("empty")}</p>}
<p className="visually-hidden" role="status" aria-live="polite">{announcement}</p>
</div>
);
}
function navigationId(item: PlatformNavItem): string {
return item.navigationId ?? item.surfaceId ?? item.to;
}
function preferenceFromLayer(
items: PlatformNavItem[],
layer: "module" | "system" | "tenant"
): NavigationPreferences {
const ordered = [...items].sort((left, right) => layerOrder(left, layer) - layerOrder(right, layer));
return {
contract_version: "1",
order: ordered.map(navigationId),
hidden: ordered.filter((item) => item.navigationLayers?.[layer]?.visible === false).map(navigationId),
locked: []
};
}
function layerOrder(item: PlatformNavItem, layer: "module" | "system" | "tenant"): number {
return item.navigationLayers?.[layer]?.order ?? item.order ?? 100;
}
+4 -4
View File
@@ -291,10 +291,9 @@ export default function PageActionBar(props: PageActionBarProps & SemanticAction
: undefined}
data-page-dirty={variant === "editor" ? (state === "clean" ? "false" : "true") : undefined}
>
<ToolbarGroup className="page-action-bar-leading" data-page-action-group="leading">
{refreshable ? <ActionSlot name="reload"><ReloadAction action={reloadAction!} /></ActionSlot> : null}
{contextActions ? <ActionSlot name="context">{contextActions}</ActionSlot> : null}
</ToolbarGroup>
{contextActions ? <ToolbarGroup className="page-action-bar-leading" data-page-action-group="leading">
<ActionSlot name="context">{contextActions}</ActionSlot>
</ToolbarGroup> : null}
<ToolbarGroup className="page-action-bar-trailing" align="end" data-page-action-group="trailing">
{variant === "editor" ? (
<span
@@ -317,6 +316,7 @@ export default function PageActionBar(props: PageActionBarProps & SemanticAction
</span>
) : null}
{helpAction ? <ActionSlot name="help">{helpAction}</ActionSlot> : null}
{refreshable ? <ActionSlot name="reload"><ReloadAction action={reloadAction!} /></ActionSlot> : null}
{trailingActions}
</ToolbarGroup>
</ActionToolbar>
+9 -5
View File
@@ -44,6 +44,7 @@ import FormField from "./FormField";
import IconButton from "./IconButton";
import SegmentedControl from "./SegmentedControl";
import { normalizeWysiwygImageUrl, normalizeWysiwygLinkUrl } from "./wysiwygEditorUrls";
import { isWysiwygDocumentUpdate } from "./wysiwygEditorUpdates";
export type WysiwygEditorMode = "visual" | "source";
@@ -282,9 +283,11 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
}
},
onFocus: () => onFocusRef.current?.(),
onUpdate: ({ editor: currentEditor }) => {
onUpdate: ({ editor: currentEditor, transaction, appendedTransactions }) => {
if (!isWysiwygDocumentUpdate(transaction, appendedTransactions)) return;
const nextValue = editorHtmlValue(currentEditor);
appliedValueRef.current = nextValue;
if (nextValue === valueRef.current) return;
onChangeRef.current(nextValue);
}
});
@@ -313,7 +316,9 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
} as CSSProperties;
useEffect(() => {
editor.setEditable(!disabled);
// Tiptap otherwise emits an update even when the document did not change.
// Mounting or locking an editor must not normalize and dirty stored HTML.
editor.setEditable(!disabled, false);
}, [disabled, editor]);
useEffect(() => {
@@ -381,9 +386,8 @@ const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(functi
window.requestAnimationFrame(() => editor.commands.focus("end"));
return;
}
const nextValue = editorHtmlValue(editor);
appliedValueRef.current = nextValue;
if (nextValue !== valueRef.current) onChangeRef.current(nextValue);
// Real visual edits already publish through onUpdate. Merely inspecting
// source must preserve the supplied HTML, including legacy formatting.
setMode("source");
window.requestAnimationFrame(() => sourceRef.current?.focus());
}
+142
View File
@@ -0,0 +1,142 @@
/** Synchronous theme application and validation; no settings UI dependencies. */
import type { AppearanceOverrideToken, AppearanceOverridesDocument } from "../types";
export const APPEARANCE_OVERRIDE_TOKENS: readonly AppearanceOverrideToken[] = [
"accent", "accent_foreground", "surface", "surface_foreground",
"success", "success_foreground", "info", "info_foreground",
"warning", "warning_foreground", "danger", "danger_foreground"
];
export const STATUS_TOKENS: readonly AppearanceOverrideToken[] = ["success", "info", "warning", "danger"];
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const RUNTIME_PROPERTIES = new Set<string>();
const RUNTIME_TOKEN_PROPERTIES: Record<AppearanceOverrideToken, readonly string[]> = {
accent: ["--accent", "--action-primary-bg"],
accent_foreground: ["--on-accent", "--badge-accent-text", "--action-primary-text"],
surface: ["--surface", "--panel-soft"],
surface_foreground: ["--text", "--text-strong"],
success: ["--success-bg", "--success-soft"],
success_foreground: ["--success-text", "--success-text-strong"],
info: ["--info-bg", "--info-soft"],
info_foreground: ["--info-text", "--info-text-strong", "--info-text-deep"],
warning: ["--warning-bg", "--warning-soft"],
warning_foreground: ["--warning-text", "--warning-text-strong"],
danger: ["--danger-bg", "--danger-soft"],
danger_foreground: ["--danger-text", "--danger-text-strong", "--danger-text-deep"]
};
for (const properties of Object.values(RUNTIME_TOKEN_PROPERTIES)) {
for (const property of properties) RUNTIME_PROPERTIES.add(property);
}
export const DEFAULT_APPEARANCE_OVERRIDES: AppearanceOverridesDocument = {
schema_version: "1",
light: {
accent: "#245f91", accent_foreground: "#ffffff",
surface: "#ffffff", surface_foreground: "#303135",
success: "#d8eee8", success_foreground: "#315f55",
info: "#dce9f3", info_foreground: "#294a61",
warning: "#ffe1a3", warning_foreground: "#593700",
danger: "#f8d1cc", danger_foreground: "#873c35"
},
dark: {
accent: "#7ea6c5", accent_foreground: "#242424",
surface: "#262724", surface_foreground: "#f1f1f1",
success: "#24473f", success_foreground: "#d8eee8",
info: "#243d4e", info_foreground: "#dce9f3",
warning: "#5a431f", warning_foreground: "#ffe1a3",
danger: "#4f2d2a", danger_foreground: "#f8d1cc"
}
};
export function cloneDefaultAppearanceOverrides(): AppearanceOverridesDocument {
return JSON.parse(JSON.stringify(DEFAULT_APPEARANCE_OVERRIDES)) as AppearanceOverridesDocument;
}
export function validateAppearanceOverrides(value: unknown): AppearanceOverridesDocument {
if (!isRecord(value) || value.schema_version !== "1" || !isRecord(value.light) || !isRecord(value.dark)) {
throw new Error("i18n:govoplan-core.appearance_override_invalid_schema");
}
if (!hasExactKeys(value, ["schema_version", "light", "dark"])) {
throw new Error("i18n:govoplan-core.appearance_override_invalid_schema");
}
const document = value as unknown as AppearanceOverridesDocument;
for (const modeName of ["light", "dark"] as const) {
const mode = document[modeName];
if (!hasExactKeys(mode, APPEARANCE_OVERRIDE_TOKENS)) {
throw new Error("i18n:govoplan-core.appearance_override_all_tokens_required");
}
for (const token of APPEARANCE_OVERRIDE_TOKENS) {
if (typeof mode[token] !== "string" || !HEX_COLOR.test(mode[token])) {
throw new Error("i18n:govoplan-core.appearance_override_hex_required");
}
}
for (const [background, foreground] of [
["accent", "accent_foreground"], ["surface", "surface_foreground"],
["success", "success_foreground"], ["info", "info_foreground"],
["warning", "warning_foreground"], ["danger", "danger_foreground"]
] as const) {
if (contrastRatio(mode[background], mode[foreground]) < 4.5) {
throw new Error("i18n:govoplan-core.appearance_override_contrast_error");
}
}
for (let first = 0; first < STATUS_TOKENS.length; first += 1) {
for (let second = first + 1; second < STATUS_TOKENS.length; second += 1) {
if (rgbDistance(mode[STATUS_TOKENS[first]], mode[STATUS_TOKENS[second]]) < 12) {
throw new Error("i18n:govoplan-core.appearance_override_status_error");
}
}
}
}
return document;
}
export function applyAppearanceOverrides(
root: HTMLElement,
document: AppearanceOverridesDocument | null,
theme: "light" | "dark"
) {
for (const property of RUNTIME_PROPERTIES) root.style.removeProperty(property);
if (!document) return;
let validated: AppearanceOverridesDocument;
try {
validated = validateAppearanceOverrides(document);
} catch {
return;
}
const colors = validated[theme];
for (const token of APPEARANCE_OVERRIDE_TOKENS) {
for (const property of RUNTIME_TOKEN_PROPERTIES[token]) {
root.style.setProperty(property, colors[token]);
}
}
}
function hasExactKeys(value: object, keys: readonly string[]): boolean {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function relativeLuminance(color: string): number {
const channels = [1, 3, 5].map((index) => Number.parseInt(color.slice(index, index + 2), 16) / 255);
const linear = channels.map((channel) => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
}
function contrastRatio(first: string, second: string): number {
const luminances = [relativeLuminance(first), relativeLuminance(second)].sort((left, right) => right - left);
return (luminances[0] + 0.05) / (luminances[1] + 0.05);
}
function rgbDistance(first: string, second: string): number {
return Math.sqrt([1, 3, 5].reduce((total, index) => {
const delta = Number.parseInt(first.slice(index, index + 2), 16) - Number.parseInt(second.slice(index, index + 2), 16);
return total + delta * delta;
}, 0));
}
@@ -0,0 +1,67 @@
import type { NavigationPreferences, PlatformNavItem, ProductAreaContribution } from "../types";
import { groupNavigationItems } from "../platform/productAreas";
export type NavigationPreferenceScope = "system" | "tenant" | "user" | "view";
export function navigationId(item: PlatformNavItem): string {
return item.navigationId ?? item.surfaceId ?? item.to;
}
export function inheritedNavigationLayout(items: PlatformNavItem[], scope: NavigationPreferenceScope, productAreas: ProductAreaContribution[]): NavigationPreferences {
const layer = scope === "system" ? "module" : scope === "tenant" ? "system" : "tenant";
const ordered = items.map((item) => ({
...item,
order: item.navigationLayers?.[layer]?.order ?? item.order ?? 100,
navigationCustomLayout: item.navigationLayers?.[layer]?.custom_layout ?? false,
navigationSection: item.navigationLayers?.[layer]?.section,
navigationLayoutSource: item.navigationLayers?.[layer]?.layout_source ?? "module",
navigationOrderSource: layer
})).sort((left, right) => left.order - right.order);
const groups = groupNavigationItems(ordered, productAreas);
const order: string[] = [];
const separators: NonNullable<NavigationPreferences["separators"]> = [];
groups.forEach((group, index) => {
if (group.label !== undefined || index > 0) {
const id = group.id.startsWith("separator:") ? group.id : `separator:${group.id}`;
separators.push({ id, label: group.label ?? "" });
order.push(id);
}
order.push(...group.items.map(navigationId));
});
return { contract_version: "1", order, separators,
hidden: ordered.filter((item) => item.navigationLayers?.[layer]?.visible === false).map(navigationId), locked: [] };
}
export function navigationEditorOrder(items: PlatformNavItem[], value: NavigationPreferences): string[] {
const available = new Set([...items.map(navigationId), ...(value.separators ?? []).map((item) => item.id)]);
const hidden = new Set(value.hidden);
return [...new Set([...value.order, ...items.map(navigationId)])].filter((id) => available.has(id) && !hidden.has(id));
}
/** Upgrade legacy order-only drafts without losing their order or group markers. */
export function materializeNavigationLayout(value: NavigationPreferences | null, inherited: NavigationPreferences): NavigationPreferences {
if (!value) return inherited;
if (value.separators != null) return value;
const separators = new Map((inherited.separators ?? []).map((item) => [item.id, item]));
const sectionByItem = new Map<string, string>();
let section: string | null = null;
for (const id of inherited.order) {
if (separators.has(id)) section = id;
else if (section) sectionByItem.set(id, section);
}
const order: string[] = [];
const placed = new Set<string>();
for (const id of new Set([...value.order, ...inherited.order.filter((id) => !separators.has(id))])) {
const group = sectionByItem.get(id);
if (group && !placed.has(group)) { order.push(group); placed.add(group); }
order.push(id);
}
return { ...value, order, separators: inherited.separators };
}
export function moveNavigationEntry(order: string[], id: string, target: string, after = false): string[] {
if (id === target || !order.includes(id) || !order.includes(target)) return order;
const next = order.filter((item) => item !== id);
next.splice(next.indexOf(target) + (after ? 1 : 0), 0, id);
return next;
}
+205 -80
View File
@@ -2,10 +2,12 @@ import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type
import { createPortal } from "react-dom";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronsUpDown, Filter, GripVertical, Plus, Trash2, X } from "lucide-react";
import StatusBadge from "../StatusBadge";
import ListSelectionFilter, { type ListFilterOption } from "../ListSelectionFilter";
import TableActionGroup from "./TableActionGroup";
import { usePlatformLanguage, i18nMessage } from "../../i18n/LanguageContext";
import {
DATA_GRID_MAX_TRACK_WIDTH,
dataGridActionTrackMinimum,
dataGridColumnPixelWidth as columnPixelWidth,
dataGridColumnTrackWithMinimum as columnTrackWithMinimum,
dataGridLayoutSignature,
@@ -23,11 +25,7 @@ export type DataGridFilterType = "text" | "number" | "integer" | "boolean" | "da
export type DataGridInitialFit = "content" | "container";
export type DataGridResizeBehavior = "free" | "cover" | "constrained";
export type DataGridListOption = {
value: string;
label: string;
disabled?: boolean;
};
export type DataGridListOption = ListFilterOption;
export type DataGridListConfig<T> = {
options: DataGridListOption[];
@@ -97,7 +95,8 @@ export type DataGridColumn<T> = {
sortable?: boolean;
filterable?: boolean;
filterType?: DataGridFilterType;
columnType?: "default" | "from-list";
/** Canonical TableActionGroup content is recognized automatically. Mark custom action controls explicitly. */
columnType?: "default" | "from-list" | "actions";
list?: DataGridListConfig<T>;
sticky?: "start" | "end";
align?: "left" | "center" | "right";
@@ -200,6 +199,8 @@ type ColumnResizeState = {
uncompensatedShrinkRoom: number;
behavior: DataGridResizeBehavior;
containerWidth: number;
pointerId: number;
previousState: DataGridState;
};
const STORAGE_PREFIX = "govoplan.datagrid.";
@@ -261,6 +262,11 @@ export default function DataGrid<T>({
const serverPaginationRef = useRef<DataGridServerPagination | null>(serverQueryMode ? pagination : null);
const lastQueryRef = useRef<DataGridQueryState | null>(null);
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
const [actionWidths, setActionWidths] = useState<Record<string, number>>({});
const [containerWidth, setContainerWidth] = useState(0);
const sizingColumns = useMemo(() => columns.map((column) => actionWidths[column.id] !== undefined
? { ...column, minWidth: dataGridActionTrackMinimum(column, actionWidths[column.id], containerWidth) }
: column), [columns, actionWidths, containerWidth]);
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
@@ -322,7 +328,7 @@ export default function DataGrid<T>({
for (const column of columns) {
const element = headerCellRefs.current[column.id];
if (!element) continue;
const width = Math.round(element.getBoundingClientRect().width);
const width = Math.round(element.getBoundingClientRect().width * 100) / 100;
if (width > 0) next[column.id] = width;
}
setMeasuredWidths((current) => shallowEqualNumberRecords(current, next) ? current : next);
@@ -340,7 +346,7 @@ export default function DataGrid<T>({
if (element) observer.observe(element);
}
return () => observer.disconnect();
}, [columns, state.widths]);
}, [columns]);
useLayoutEffect(() => {
const element = scrollRegionRef.current;
@@ -353,16 +359,17 @@ export default function DataGrid<T>({
animationFrame = window.requestAnimationFrame(() => {
const nextContainerWidth = Math.round(scrollElement.clientWidth);
if (nextContainerWidth <= 0) return;
setContainerWidth(nextContainerWidth);
setState((current) => {
const signatureMatches = current.layoutSignature === layoutSignature;
const userWidths = signatureMatches ? current.userWidths ?? {} : {};
const mustFit = effectiveResizeBehavior !== "free" || resolvedInitialFit === "container";
if (!mustFit) {
if (signatureMatches && current.widths === undefined) return current;
if (signatureMatches && shallowEqualNumberRecords(current.widths ?? {}, userWidths)) return current;
return {
...current,
widths: undefined,
widths: userWidths,
userWidths,
layoutSignature,
fillColumnId: undefined
@@ -370,7 +377,7 @@ export default function DataGrid<T>({
}
const layout = fitDataGridColumns(
columns,
sizingColumns,
nextContainerWidth,
measuredWidths,
userWidths,
@@ -407,16 +414,17 @@ export default function DataGrid<T>({
window.cancelAnimationFrame(animationFrame);
observer.disconnect();
};
}, [columns, layoutSignature, effectiveResizeBehavior, resolvedInitialFit, measuredWidths, resizeState]);
}, [sizingColumns, layoutSignature, effectiveResizeBehavior, resolvedInitialFit, measuredWidths, resizeState]);
useEffect(() => {
if (!resizeState) return;
const activeResize = resizeState;
function onMove(event: MouseEvent) {
function onMove(event: PointerEvent) {
if (event.pointerId !== activeResize.pointerId) return;
const rawDelta = event.clientX - activeResize.startX;
const resized = resizeDataGridColumn(
columns,
sizingColumns,
activeResize.baseWidths,
activeResize.columnId,
rawDelta,
@@ -438,9 +446,10 @@ export default function DataGrid<T>({
}));
}
function onUp() {
function onUp(event?: PointerEvent) {
if (event && event.pointerId !== activeResize.pointerId) return;
setState((current) => sanitizePersistedColumnState(
columns,
sizingColumns,
current,
effectiveResizeBehavior,
layoutSignature
@@ -448,13 +457,29 @@ export default function DataGrid<T>({
setResizeState(null);
}
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
function cancel(event: PointerEvent | KeyboardEvent) {
if ("pointerId" in event && event.pointerId !== activeResize.pointerId) return;
if ("key" in event && event.key !== "Escape") return;
event.preventDefault();
if ("key" in event) event.stopPropagation();
setState(activeResize.previousState);
setResizeState(null);
}
function onBlur() { onUp(); }
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", cancel);
window.addEventListener("keydown", cancel, true);
window.addEventListener("blur", onBlur);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", cancel);
window.removeEventListener("keydown", cancel, true);
window.removeEventListener("blur", onBlur);
};
}, [resizeState, columns, effectiveResizeBehavior, layoutSignature]);
}, [resizeState, sizingColumns, effectiveResizeBehavior, layoutSignature]);
useEffect(() => {
if (!openFilterColumnId) return undefined;
@@ -549,18 +574,71 @@ export default function DataGrid<T>({
const paginationPageSize = Math.max(1, pagination?.pageSize ?? Math.max(1, visibleRows.length));
const paginationPageCount = Math.max(1, Math.ceil(paginationTotal / paginationPageSize));
const paginationPage = pagination ? Math.min(paginationPageCount, Math.max(1, pagination.page)) : 1;
const renderedRows = pagination && paginationMode === "client" ?
visibleRows.slice((paginationPage - 1) * paginationPageSize, paginationPage * paginationPageSize) :
visibleRows;
const renderedRows = useMemo(() => pagination && paginationMode === "client" ?
visibleRows.slice((paginationPage - 1) * paginationPageSize, paginationPage * paginationPageSize) :
visibleRows, [Boolean(pagination), paginationMode, paginationPage, paginationPageSize, visibleRows]);
useEffect(() => {
if (pagination && pagination.page !== paginationPage) pagination.onPageChange(paginationPage);
}, [pagination, paginationPage]);
const actualTracks = columns.map((column) => widthForColumn(column, state.widths?.[column.id]));
useLayoutEffect(() => {
const region = scrollRegionRef.current;
if (!region) return;
let frame = 0;
function measure() {
const next: Record<string, number> = {};
for (const cell of Array.from(region!.querySelectorAll<HTMLElement>(".data-grid-body-cell[data-column-id]"))) {
const columnId = cell.dataset.columnId!;
const explicitActions = columns.some((column) => column.id === columnId && column.columnType === "actions");
const groups = cell.querySelectorAll<HTMLElement>(".table-action-group");
if (!groups.length && !explicitActions) continue;
const cellStyle = window.getComputedStyle(cell);
const cellInsets = cssPixels(cellStyle.paddingLeft) + cssPixels(cellStyle.paddingRight)
+ cssPixels(cellStyle.borderLeftWidth) + cssPixels(cellStyle.borderRightWidth);
// Measure action slots, never arbitrary row text (long titles must not
// inflate an entire grid). Include disabled wrappers and reserved slots.
const contentWidth = explicitActions
? measureActionGroup(cell)
: Math.max(...Array.from(groups, measureActionGroup)) + cellInsets;
next[columnId] = Math.max(next[columnId] ?? 0, Math.ceil(contentWidth));
}
setActionWidths((current) => shallowEqualNumberRecords(current, next) ? current : next);
}
function schedule() {
window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(measure);
}
measure();
const groups = region.querySelectorAll<HTMLElement>(".table-action-group, .data-grid-action-cell > *");
const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(schedule) : null;
const mutationObserver = typeof MutationObserver !== "undefined" ? new MutationObserver(schedule) : null;
for (const group of Array.from(groups)) {
resizeObserver?.observe(group);
mutationObserver?.observe(group, { childList: true, subtree: true, attributes: true, characterData: true });
}
window.addEventListener("resize", schedule);
return () => {
window.cancelAnimationFrame(frame);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
window.removeEventListener("resize", schedule);
};
}, [columns, renderedRows, emptyAction]);
const actualTracks = sizingColumns.map((column) => widthForColumn(column, state.widths?.[column.id]));
const templateColumns = actualTracks.join(" ");
const pixelLayoutWidth = sizingColumns.every((column) => state.widths?.[column.id] !== undefined)
? sizingColumns.reduce((total, column) => total + Math.max(effectiveColumnMinWidth(column), state.widths![column.id]), 0)
: undefined;
const hasFlexibleColumns = columns.some((column) => !state.widths?.[column.id] && isFlexibleColumn(column));
const stickyOffsets = useMemo(() => computeStickyOffsets(columns, state.widths, measuredWidths), [columns, state.widths, measuredWidths]);
const stickyOffsets = useMemo(() => computeStickyOffsets(sizingColumns, state.widths, measuredWidths), [sizingColumns, state.widths, measuredWidths]);
const stickyWidth = sizingColumns.reduce((total, column) => column.sticky
? total + Math.max(effectiveColumnMinWidth(column), state.widths?.[column.id] ?? measuredWidths[column.id] ?? 0)
: total, 0);
// Very wide explicit/persisted sticky tracks must not obscure all data. They
// remain reachable through the same keyboard-accessible horizontal scroller.
const releaseStickyColumns = containerWidth > 0 && stickyWidth > containerWidth - Math.min(120, containerWidth / 2);
const gridClassName = [
"data-grid",
`data-grid-fit-${resolvedInitialFit}`,
@@ -613,14 +691,49 @@ export default function DataGrid<T>({
});
}
function resizeBase(columnId: string) {
const baseWidths = measuredColumnWidths(sizingColumns, headerCellRefs.current, state.widths, measuredWidths);
const index = columns.findIndex((column) => column.id === columnId);
const lastResizable = !columns.slice(index + 1).some(isResizeCompensationColumn);
const region = scrollRegionRef.current;
const overflow = region ? Math.max(0, region.scrollWidth - region.clientWidth) : 0;
return {
baseWidths,
uncompensatedShrinkRoom: region && !lastResizable ? Math.max(0, overflow - region.scrollLeft) : overflow,
containerWidth: Math.max(1, region?.clientWidth ?? 0)
};
}
function resizeByKeyboard(columnId: string, delta: number) {
const base = resizeBase(columnId);
const resized = resizeDataGridColumn(sizingColumns, base.baseWidths, columnId, delta, effectiveResizeBehavior, base.uncompensatedShrinkRoom);
setState((current) => ({
...current,
widths: resized.widths,
userWidths: effectiveResizeBehavior === "free" ? { ...current.userWidths, [columnId]: resized.widths[columnId] } : resized.widths,
userLayoutContainerWidth: base.containerWidth,
layoutSignature
}));
}
function resetColumnWidth(columnId: string) {
setResizeState(null);
setState((current) => {
const userWidths = { ...current.userWidths };
delete userWidths[columnId];
const fitted = fitDataGridColumns(sizingColumns, containerWidth, measuredWidths, userWidths, effectiveResizeBehavior, current.userLayoutContainerWidth);
return { ...current, userWidths, widths: fitted.widths };
});
}
return (
<div
className={`data-grid-shell data-grid-${resolvedInitialFit} data-grid-shell-resize-${effectiveResizeBehavior} ${className}`.trim()}
className={`data-grid-shell data-grid-${resolvedInitialFit} data-grid-shell-resize-${effectiveResizeBehavior} ${releaseStickyColumns ? "data-grid-release-sticky" : ""} ${className}`.trim()}
data-resize-behavior={effectiveResizeBehavior}
data-requested-resize-behavior={resizeBehavior}>
<div className="data-grid-scroll-region" ref={scrollRegionRef}>
<div className={gridClassName} role="table" aria-label={id} style={{ gridTemplateColumns: templateColumns }}>
<div className="data-grid-scroll-region" ref={scrollRegionRef} tabIndex={0} role="region" aria-label={id}>
<div className={gridClassName} role="table" aria-label={id} style={{ gridTemplateColumns: templateColumns, width: pixelLayoutWidth }}>
{columns.map((column, columnIndex) => {
const sorted = state.sort?.columnId === column.id ? state.sort.direction : undefined;
const hasFilter = Boolean((state.filters?.[column.id] ?? "").trim());
@@ -628,6 +741,7 @@ export default function DataGrid<T>({
<div
key={`header-${column.id}`}
role="columnheader"
data-column-id={column.id}
ref={(element) => {headerCellRefs.current[column.id] = element;}}
className={`data-grid-cell data-grid-header-cell ${column.headerClassName ?? ""} ${column.sortable ? "is-sortable" : ""} ${sorted ? "is-sorted" : ""} ${stickyClass(column)}`.trim()}
style={stickyStyle(column, stickyOffsets[columnIndex])}>
@@ -659,35 +773,44 @@ export default function DataGrid<T>({
<button
type="button"
className="data-grid-resize-handle"
role="separator"
aria-orientation="vertical"
aria-valuemin={effectiveColumnMinWidth(sizingColumns[columnIndex])}
aria-valuemax={Math.max(state.widths?.[column.id] ?? 0, effectiveColumnMaxWidth(sizingColumns[columnIndex]))}
aria-valuenow={Math.round(state.widths?.[column.id] ?? measuredWidths[column.id] ?? effectiveColumnMinWidth(sizingColumns[columnIndex]))}
title={translateText("i18n:govoplan-core.data_grid_resize_help")}
aria-description={translateText("i18n:govoplan-core.data_grid_resize_help")}
aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", { value0: translateText("i18n:govoplan-core.resize.f52dc753"), value1: translateHeaderLabel(column.header, translateText) })}
onMouseDown={(event) => {
onDoubleClick={() => resetColumnWidth(column.id)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
resetColumnWidth(column.id);
} else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault();
resizeByKeyboard(column.id, (event.key === "ArrowLeft" ? -1 : 1) * (event.shiftKey ? 40 : 10));
}
}}
onPointerDown={(event) => {
if (event.button !== 0 || !event.isPrimary) return;
event.preventDefault();
event.stopPropagation();
const baseWidths = measuredColumnWidths(columns, headerCellRefs.current, state.widths, measuredWidths);
const activeColumnIndex = columns.findIndex((candidate) => candidate.id === column.id);
const isLastResizableColumn = !columns.
slice(activeColumnIndex + 1).
some(isResizeCompensationColumn);
const scrollElement = scrollRegionRef.current;
const totalHorizontalOverflow = scrollElement ?
Math.max(0, scrollElement.scrollWidth - scrollElement.clientWidth) :
0;
const shrinkRoomWithoutScroll = scrollElement && !isLastResizableColumn ?
Math.max(0, totalHorizontalOverflow - scrollElement.scrollLeft) :
totalHorizontalOverflow;
event.currentTarget.focus();
event.currentTarget.setPointerCapture(event.pointerId);
const base = resizeBase(column.id);
setState((current) => ({
...current,
widths: roundWidthRecord(baseWidths),
widths: roundWidthRecord(base.baseWidths),
fillColumnId: undefined
}));
setResizeState({
columnId: column.id,
startX: event.clientX,
baseWidths,
uncompensatedShrinkRoom: shrinkRoomWithoutScroll,
...base,
behavior: effectiveResizeBehavior,
containerWidth: Math.max(1, scrollElement?.clientWidth ?? 0)
pointerId: event.pointerId,
previousState: state
});
}}>
@@ -712,7 +835,8 @@ export default function DataGrid<T>({
{translatedEmptyText}
</div>
<div
className={`data-grid-cell data-grid-body-cell data-grid-empty-action-cell data-grid-row-even is-last-row ${stickyClass(actionColumn)}`.trim()}
className={`data-grid-cell data-grid-body-cell data-grid-action-cell data-grid-empty-action-cell data-grid-row-even is-last-row ${stickyClass(actionColumn)}`.trim()}
data-column-id={actionColumn.id}
role="cell"
style={{ ...stickyStyle(actionColumn, stickyOffsets[actionColumnIndex]), gridColumn: `${actionColumnIndex + 1} / ${actionColumnIndex + 2}` }}>
@@ -738,7 +862,8 @@ export default function DataGrid<T>({
<div
key={`${rowKey}-${column.id}`}
role="cell"
className={`data-grid-cell data-grid-body-cell ${parityClass} ${lastRowClass} ${column.align ? `align-${column.align}` : ""} ${column.className ?? ""} ${rowClass ?? ""} ${stickyClass(column)}`.trim()}
data-column-id={column.id}
className={`data-grid-cell data-grid-body-cell ${actionWidths[column.id] !== undefined || column.columnType === "actions" ? "data-grid-action-cell" : ""} ${parityClass} ${lastRowClass} ${column.align ? `align-${column.align}` : ""} ${column.className ?? ""} ${rowClass ?? ""} ${stickyClass(column)}`.trim()}
style={stickyStyle(column, stickyOffsets[columnIndex])}>
{renderCell(column, row, originalIndex, translateText)}
@@ -782,6 +907,28 @@ export default function DataGrid<T>({
}
function cssPixels(value: string): number {
return Number.parseFloat(value) || 0;
}
function measureActionGroup(element: HTMLElement | null): number {
if (!element || !element.getClientRects().length) return 0;
const style = window.getComputedStyle(element);
if (element.matches("button, a, input, select, .table-action-placeholder")) {
return Math.max(element.getBoundingClientRect().width, cssPixels(style.minWidth), element.scrollWidth);
}
const children = Array.from(element.children).filter((child): child is HTMLElement => {
if (!(child instanceof HTMLElement) || !child.getClientRects().length || child.getAttribute("role") === "tooltip") return false;
const position = window.getComputedStyle(child).position;
return position !== "absolute" && position !== "fixed";
});
const widths = children.map(measureActionGroup);
const width = style.flexDirection === "column" ? Math.max(0, ...widths)
: widths.reduce((total, childWidth) => total + childWidth, 0) + Math.max(0, widths.length - 1) * cssPixels(style.columnGap);
return width + cssPixels(style.paddingLeft) + cssPixels(style.paddingRight)
+ cssPixels(style.borderLeftWidth) + cssPixels(style.borderRightWidth);
}
export type DataGridPaginationBarProps = {
page: number;
pageSize: number;
@@ -1031,13 +1178,6 @@ function ListFilterEditor({
const selected = parseListFilter(value, options.map((option) => option.value));
const selectedSet = new Set(selected);
function toggleOption(optionValue: string) {
const next = new Set(selectedSet);
if (next.has(optionValue)) next.delete(optionValue);else
next.add(optionValue);
onChange(formatListSelection([...next], options));
}
function addOption() {
const normalized = newValue.trim();
if (!normalized || !onOptionsChange || options.some((option) => option.value === normalized)) return;
@@ -1056,32 +1196,17 @@ function ListFilterEditor({
return (
<div className="data-grid-list-filter">
<div className="data-grid-list-filter-actions">
<button type="button" onClick={() => onChange("")}>{translateText("i18n:govoplan-core.select_all.913afff1")}</button>
<button type="button" onClick={() => onChange(formatListFilter([]))}>{translateText("i18n:govoplan-core.deselect_all.85cce1e1")}</button>
</div>
<div className="data-grid-list-filter-options" role="group" aria-label={translateText("i18n:govoplan-core.allowed_values.495fcf3a")}>
{options.length === 0 ?
<p className="muted small-note">{translateText("i18n:govoplan-core.no_values_are_configured_for_this_column.16e935e8")}</p> :
options.map((option) =>
<div className="data-grid-list-filter-row" key={option.value}>
<label>
<input
type="checkbox"
checked={selectedSet.has(option.value)}
disabled={option.disabled}
onChange={() => toggleOption(option.value)} />
{display === "pill" ? <StatusBadge status={option.value} label={option.label} /> : <span>{translateText(option.label)}</span>}
</label>
{editable &&
<ListSelectionFilter
options={options}
value={value ? selected : null}
onChange={(next) => onChange(next === null ? "" : formatListSelection(next, options))}
renderOption={display === "pill" ? (option) => <StatusBadge status={option.value} label={option.label} /> : undefined}
renderOptionActions={editable ? (option) => (
<button type="button" className="data-grid-list-option-remove" aria-label={i18nMessage("i18n:govoplan-core.value_value.dca59cc0", { value0: translateText("i18n:govoplan-core.remove.e963907d"), value1: translateText(option.label) })} onClick={() => removeOption(option.value)}>
<Trash2 size={14} aria-hidden="true" />
</button>
}
</div>
)}
</div>
<Trash2 size={14} aria-hidden="true" />
</button>
) : undefined}
/>
{editable &&
<div className="data-grid-list-option-add">
<input
+20 -1
View File
@@ -8,6 +8,7 @@ export type DataGridSizingColumn = {
fill?: boolean;
sortable?: boolean;
filterable?: boolean;
columnType?: "default" | "from-list" | "actions";
sticky?: "start" | "end";
};
@@ -50,9 +51,27 @@ export function dataGridLayoutSignature(
column.maxWidth ?? "",
column.resizable ? "r" : "f",
column.fill ? "fill" : "",
column.sortable ? "sort" : "",
column.filterable ? "filter" : "",
column.columnType ?? "default",
column.sticky ?? ""
].join(":")).join("|");
return `v2::${columnSignature}::${initialFit}::${resizeBehavior}`;
return `v3::${columnSignature}::${initialFit}::${resizeBehavior}`;
}
/** Reserve real action slots, but leave room to read data in narrow viewports.
* Oversized groups wrap within this minimum; declared hard minima still apply.
* This measured minimum is deliberately not part of the persisted signature.
*/
export function dataGridActionTrackMinimum(
column: DataGridSizingColumn,
intrinsicWidth: number,
containerWidth: number
): number {
const minimum = effectiveDataGridColumnMinWidth(column);
const contentWidth = Number.isFinite(intrinsicWidth) ? Math.max(0, intrinsicWidth) : 0;
const availableWidth = containerWidth > 0 ? Math.max(minimum, Math.floor(containerWidth / 2)) : contentWidth;
return Math.max(minimum, Math.min(contentWidth, availableWidth));
}
export function dataGridWidthsForLayout(
@@ -0,0 +1,9 @@
type DocumentTransaction = { docChanged: boolean };
/** Editable-state and focus updates are not user document edits. */
export function isWysiwygDocumentUpdate(
transaction: DocumentTransaction,
appendedTransactions: readonly DocumentTransaction[] = []
): boolean {
return transaction.docChanged || appendedTransactions.some((item) => item.docChanged);
}
+4 -3
View File
@@ -1,5 +1,5 @@
import DescriptionList from "../../components/DescriptionList";
import ContentGrid, { FormGrid } from "../../components/ContentGrid";
import ContentGrid, { FormGrid, GridItem } from "../../components/ContentGrid";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import type { AppearanceOverridesDocument, ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, NavigationPreferences, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPalette, UserUiPreferences, UserUiTheme } from "../../types";
@@ -553,15 +553,16 @@ export default function SettingsPage({
<span>i18n:govoplan-core.template_placeholder_chips_and_preview_overlays.11634d55</span>
</div>
</Card>
<Card title="Navigation order">
<GridItem span="full"><Card title="Navigation order">
<NavigationPreferenceEditor
items={navigationItems}
productAreas={platformModules.flatMap((module) => module.productAreas ?? [])}
value={navigation}
onChange={setNavigation}
scope="user"
disabled={uiBusy}
/>
</Card>
</Card></GridItem>
</ContentGrid>
}
+14 -10
View File
@@ -2,6 +2,8 @@ import type { PlatformTranslations } from "../types";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-core.optional_module_load_failed": "An enabled module could not load after retrying: {value0}. Its screens and integrations may be unavailable; the module has not been uninstalled. Save any other drafts before reloading this page.",
"i18n:govoplan-core.data_grid_resize_help": "Drag to resize. Left/Right: 10 px; Shift: 40 px. Enter or double-click: reset this column. Escape: cancel dragging.",
"i18n:govoplan-core.inherit_governed_palette": "Inherit governed default",
"i18n:govoplan-core.effective_source": "Effective source",
"i18n:govoplan-core.appearance_source_user": "Personal preference",
@@ -638,7 +640,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.use_hh_mm.e995be3f": "Use HH:MM.",
"i18n:govoplan-core.use_value.bac38fc3": "Use {value0}",
"i18n:govoplan-core.use_yyyy_mm_dd.406d2e4d": "Use YYYY-MM-DD.",
"i18n:govoplan-core.used_only_when_there_is_no_browser_session_token.9d399e70": "Used only when there is no browser session token. Browser login remains the preferred interactive mode.",
"i18n:govoplan-core.used_only_when_there_is_no_browser_session_token.9d399e70": "Applying an API key switches identity. Sign-in and sign-out clear it.",
"i18n:govoplan-core.user_docs.1e38e8d3": "User docs",
"i18n:govoplan-core.user.9f8a2389": "User",
"i18n:govoplan-core.users": "Users",
@@ -739,6 +741,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.temporal_selection_invalid": "The selected data state is invalid."
},
"de": {
"i18n:govoplan-core.optional_module_load_failed": "Ein aktiviertes Modul konnte auch nach einem Wiederholungsversuch nicht geladen werden: {value0}. Seine Ansichten und Integrationen sind möglicherweise nicht verfügbar; das Modul wurde nicht deinstalliert. Andere Entwürfe vor dem Neuladen dieser Seite speichern.",
"i18n:govoplan-core.data_grid_resize_help": "Zum Ändern der Breite ziehen. Links/Rechts: 10 px; Umschalt: 40 px. Eingabe oder Doppelklick: Spalte zurücksetzen. Escape: Ziehen abbrechen.",
"i18n:govoplan-core.inherit_governed_palette": "Verwalteten Standard übernehmen",
"i18n:govoplan-core.effective_source": "Wirksame Quelle",
"i18n:govoplan-core.appearance_source_user": "Persönliche Einstellung",
@@ -977,7 +981,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.default.808d7dca": "Default",
"i18n:govoplan-core.density.f9160c22": "Density",
"i18n:govoplan-core.description.55f8ebc8": "Description",
"i18n:govoplan-core.deselect_all.85cce1e1": "Deselect all",
"i18n:govoplan-core.deselect_all.85cce1e1": "Alle abwählen",
"i18n:govoplan-core.detected_saved_sent_folder.18642a29": "Detected/saved sent folder",
"i18n:govoplan-core.detected_sent_folder.cbf8ec8d": "Detected Sent folder:",
"i18n:govoplan-core.disable_storage.07c44d8b": "Disable storage",
@@ -1022,7 +1026,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.files.6ce6c512": "Dateien",
"i18n:govoplan-core.filter_yields_an_empty_result.8bfe7c90": "Filter yields an empty result.",
"i18n:govoplan-core.filter.d7decf1a": "Filter",
"i18n:govoplan-core.first_page.49d74b49": "First page",
"i18n:govoplan-core.first_page.49d74b49": "Erste Seite",
"i18n:govoplan-core.folder_below_the_campaign_attachment_base_path_w.04f81c33": "Folder below the campaign attachment base path where this rule starts looking for files.",
"i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9": "Folder for sent-message copies. Leave as auto unless this campaign needs a different target.",
"i18n:govoplan-core.folder_used_when_this_imap_account_is_used_for_s.08503f5e": "Folder used when this IMAP account is used for sent-message copies. Leave as auto to use the server default.",
@@ -1087,7 +1091,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.language_native_german": "Deutsch",
"i18n:govoplan-core.language.89b86ab0": "Sprache",
"i18n:govoplan-core.light_theme.7878f1fa": "Hell",
"i18n:govoplan-core.last_page.b01f16ae": "Last page",
"i18n:govoplan-core.last_page.b01f16ae": "Letzte Seite",
"i18n:govoplan-core.leave_empty_to_use_the_same_origin_in_vite_dev_a.9a1c25d7": "Leave empty to use the same origin. In Vite dev, /api is proxied to the FastAPI backend.",
"i18n:govoplan-core.less_or_equal.2860e695": "Less or equal",
"i18n:govoplan-core.less_than.1d3d412a": "Less than",
@@ -1152,7 +1156,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.never.80c3052d": "Never",
"i18n:govoplan-core.next_actions.7b09055a": "Next actions",
"i18n:govoplan-core.next_month.8abf7cf1": "Next month",
"i18n:govoplan-core.next_page.4bfc194b": "Next page",
"i18n:govoplan-core.next_page.4bfc194b": "Nächste Seite",
"i18n:govoplan-core.next_passes_will_add_functionality_here.c13caade": "Die nächsten Durchläufe ergänzen hier die Funktionalität.",
"i18n:govoplan-core.no_access_evidence_was_returned.84a21e4e": "Es wurden keine Zugriffsnachweise zurückgegeben.",
"i18n:govoplan-core.no_accessible_campaigns_found.0b74419a": "No accessible campaigns found.",
@@ -1220,7 +1224,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.prepared_preference_for_users_who_prefer_fewer_a.b288e8ab": "Prepared preference for users who prefer fewer animations.",
"i18n:govoplan-core.prepared_ui_preference_for_denser_tables_the_cur.45698d83": "Prepared UI preference for denser tables. The current table layout remains unchanged until this is wired globally.",
"i18n:govoplan-core.previous_month.46a29921": "Previous month",
"i18n:govoplan-core.previous_page.81f54719": "Previous page",
"i18n:govoplan-core.previous_page.81f54719": "Vorherige Seite",
"i18n:govoplan-core.profile_saved_the_account_menu_has_been_updated.aee56076": "Profile saved. The account menu has been updated.",
"i18n:govoplan-core.quiet_ui_mode.1b0bd558": "Quiet UI mode",
"i18n:govoplan-core.rate_limit_for_outgoing_messages_lower_values_ar.9e929f6c": "Rate limit for outgoing messages. Lower values are safer for mail providers and throttled accounts.",
@@ -1254,7 +1258,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.reusable_template_record_this_campaign_should_re.5896529b": "Reusable template record this campaign should refer to once the template backend is available.",
"i18n:govoplan-core.review_send.1627617d": "Prüfen & Senden",
"i18n:govoplan-core.role.b5b4a5a2": "Rolle",
"i18n:govoplan-core.rows_per_page.af2f9c1b": "Rows per page",
"i18n:govoplan-core.rows_per_page.af2f9c1b": "Zeilen pro Seite",
"i18n:govoplan-core.sa.50cf95ce": "Sa",
"i18n:govoplan-core.same_origin_proxied.c39e6e2b": "Same-origin / proxied",
"i18n:govoplan-core.save_and_leave.0507824a": "Save and leave",
@@ -1266,7 +1270,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.scenario.569aae5b": "Scenario",
"i18n:govoplan-core.search_nested_folders_below_the_configured_base_.122d1916": "Search nested folders below the configured base directory.",
"i18n:govoplan-core.security.f25ce1b8": "Sicherheit",
"i18n:govoplan-core.select_all.913afff1": "Select all",
"i18n:govoplan-core.select_all.913afff1": "Alle auswählen",
"i18n:govoplan-core.select_an_item_to_inspect_its_content.1f67f131": "Select an item to inspect its content.",
"i18n:govoplan-core.select_value.a9ef046e": "Select {value0}",
"i18n:govoplan-core.send_without_attachments.ead6d030": "Send without attachments",
@@ -1320,7 +1324,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.system_retention_defaults_and_the_fields_lower_l.90a9e923": "System retention defaults and the fields lower levels may override.",
"i18n:govoplan-core.system_roles.a9461aa6": "System roles",
"i18n:govoplan-core.system.bc0792d8": "System",
"i18n:govoplan-core.table_pagination.3665bd76": "Table pagination",
"i18n:govoplan-core.table_pagination.3665bd76": "Tabellenseiten",
"i18n:govoplan-core.target.61ad50a9": "Target",
"i18n:govoplan-core.template_body_content_shown_for_review_placehold.57454b52": "Template body content shown for review. Placeholders are checked against campaign fields.",
"i18n:govoplan-core.template_placeholder_chips_and_preview_overlays.11634d55": "Template placeholder chips and preview overlays",
@@ -1375,7 +1379,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.use_hh_mm.e995be3f": "Use HH:MM.",
"i18n:govoplan-core.use_value.bac38fc3": "Use {value0}",
"i18n:govoplan-core.use_yyyy_mm_dd.406d2e4d": "Use YYYY-MM-DD.",
"i18n:govoplan-core.used_only_when_there_is_no_browser_session_token.9d399e70": "Used only when there is no browser session token. Browser login remains the preferred interactive mode.",
"i18n:govoplan-core.used_only_when_there_is_no_browser_session_token.9d399e70": "API-Schlüssel wechseln die Identität. An- und Abmelden entfernt den Schlüssel.",
"i18n:govoplan-core.user_docs.1e38e8d3": "User docs",
"i18n:govoplan-core.user.9f8a2389": "User",
"i18n:govoplan-core.users": "Benutzer",
@@ -0,0 +1,47 @@
/** Loaded with the navigation editor, not with the startup shell. */
export const navigationEditorTranslations: Record<string, Record<string, string>> = {
"en": {
"help": "Drag the handles to reorder modules and separators. Keyboard: Space to pick up, arrows to move, Enter to drop, Escape to cancel. Removing a module only hides it here; locked entries stay visible.",
"inherit": "Use inherited layout",
"available": "Available modules",
"all_added": "All available modules are included",
"add_module": "Add module",
"add_separator": "Add separator",
"layout": "Navigation layout",
"separator": "Separator",
"separator_label": "Group label (optional)",
"locked": "Locked",
"locked_help": "An administrator has locked this entry; lower scopes cannot remove it.",
"empty": "Add modules to create this navigation layout.",
"reorder": "Reorder {label}",
"up": "Move {label} up",
"down": "Move {label} down",
"remove": "Remove {label}",
"moved": "{label} moved to position {position} of {total}.",
"picked_up": "Picked up. Use arrow keys to move, Enter to drop, Escape to cancel.",
"dropped": "Item placed.",
"cancelled": "Reordering cancelled."
},
"de": {
"help": "Ziehen Sie die Griffe, um Module und Trennlinien anzuordnen. Tastatur: Leertaste zum Aufnehmen, Pfeile zum Verschieben, Eingabe zum Ablegen, Escape zum Abbrechen. Entfernen blendet Module nur hier aus; gesperrte Einträge bleiben sichtbar.",
"inherit": "Geerbte Anordnung verwenden",
"available": "Verfügbare Module",
"all_added": "Alle verfügbaren Module sind enthalten",
"add_module": "Modul hinzufügen",
"add_separator": "Trennlinie hinzufügen",
"layout": "Navigationsanordnung",
"separator": "Trennlinie",
"separator_label": "Gruppenbezeichnung (optional)",
"locked": "Gesperrt",
"locked_help": "Dieser Eintrag ist administrativ gesperrt und kann auf untergeordneten Ebenen nicht entfernt werden.",
"empty": "Fügen Sie Module zu dieser Navigationsanordnung hinzu.",
"reorder": "{label} anordnen",
"up": "{label} nach oben",
"down": "{label} nach unten",
"remove": "{label} entfernen",
"moved": "{label} wurde auf Position {position} von {total} verschoben.",
"picked_up": "Aufgenommen. Mit Pfeiltasten verschieben, Eingabe zum Ablegen, Escape zum Abbrechen.",
"dropped": "Eintrag abgelegt.",
"cancelled": "Anordnung abgebrochen."
}
};
+5 -2
View File
@@ -66,14 +66,14 @@ export { default as AdminSelectionList } from "./components/admin/AdminSelection
export { adminErrorMessage, formatAdminDateTime, joinLabels } from "./components/admin/adminUtils";
export { default as Button } from "./components/Button";
export { AppearancePalettePreview, AppearancePaletteSelect, APPEARANCE_PALETTE_OPTIONS, appearancePaletteLabel } from "./components/AppearancePaletteControl";
export { default as AppearanceOverridesEditor } from "./components/AppearanceOverridesEditor";
export {
default as AppearanceOverridesEditor,
APPEARANCE_OVERRIDE_TOKENS,
DEFAULT_APPEARANCE_OVERRIDES,
applyAppearanceOverrides,
cloneDefaultAppearanceOverrides,
validateAppearanceOverrides
} from "./components/AppearanceOverridesEditor";
} from "./components/appearanceOverrides";
export type { ButtonProps } from "./components/Button";
export { default as Card } from "./components/Card";
export type { CardProps } from "./components/Card";
@@ -212,6 +212,9 @@ export {
platformModuleReferenceProvider
} from "./platform/referenceProviders";
export { DashboardWidgetList, useDashboardWidgetData } from "./components/DashboardWidgetContent";
export { default as MultiSelectFilter } from "./components/MultiSelectFilter";
export type { MultiSelectFilterProps } from "./components/MultiSelectFilter";
export type { ListFilterOption, ListFilterSelection } from "./components/ListSelectionFilter";
export type { DashboardWidgetDataState, DashboardWidgetListItem } from "./components/DashboardWidgetContent";
export { default as SegmentedControl } from "./components/SegmentedControl";
export type { SegmentedControlOption, SegmentedControlProps, SegmentedControlSize, SegmentedControlWidth } from "./components/SegmentedControl";
+2 -1
View File
@@ -78,8 +78,9 @@ export default function IconRail({
<>
<div className="icon-rail-scroll">
<nav className="icon-nav">
{navigationGroups.map((group) => (
{navigationGroups.map((group, index) => (
<div className="icon-nav-group" key={group.id}>
{!railExpanded && index > 0 && <div className="icon-nav-group-separator" role="separator" aria-label={group.label ? translateText(group.label) : undefined} />}
{group.label && (
<div className="icon-nav-group-label">
{translateText(group.label)}
+12
View File
@@ -0,0 +1,12 @@
/** Retry only the import boundary, once. Validation and capabilities stay separate. */
export async function importModuleWithRetry<T>(
load: () => Promise<T>,
pause: () => Promise<void> = () => new Promise((resolve) => setTimeout(resolve, 250))
): Promise<T> {
try {
return await load();
} catch {
await pause();
return await load();
}
}
+14 -2
View File
@@ -11,6 +11,7 @@ import {
uiCapability as uiCapabilityForModules } from
"./moduleLogic";
import { hasAnyScope, hasScope } from "../utils/permissions";
import { importModuleWithRetry } from "./moduleLoading";
import {
isViewSurfaceVisible,
navigationViewSurfaceId,
@@ -34,6 +35,7 @@ export function shellNavItemsForModules(modules: PlatformWebModule[]): PlatformN
}
const localModuleCache = new Map<string, Promise<PlatformWebModule | null>>();
const failedLocalModules = new Set<string>();
const loadedLocalModules = new Map<string, PlatformWebModule>();
const remoteModuleCache = new Map<string, Promise<PlatformWebModule | null>>();
@@ -117,6 +119,9 @@ function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavIt
navigationOrderSource: item.navigation_order_source,
navigationVisibilitySource: item.navigation_visibility_source,
navigationLockSource: item.navigation_lock_source,
navigationSection: item.navigation_section,
navigationCustomLayout: item.navigation_custom_layout,
navigationLayoutSource: item.navigation_layout_source,
navigationLayers: item.navigation_layers
};
}
@@ -294,13 +299,17 @@ export async function loadRemotePublicWebModules(
}
export async function loadInstalledWebModules(
platformModules: PlatformModuleInfo[] | null | undefined
platformModules: PlatformModuleInfo[] | null | undefined,
onLoadFailure?: (moduleId: string) => void
): Promise<PlatformWebModule[]> {
if (!platformModules?.length) return [];
const enabledModules = platformModules.filter((module) => module.enabled);
const resolved = await Promise.all(enabledModules.map(async (info) => {
const local = await loadInstalledWebModule(info);
if (!local && info.frontend?.package_name && failedLocalModules.has(info.frontend.package_name)) {
onLoadFailure?.(info.id);
}
return local ? applyServerMetadata(local, info) : null;
}));
return resolved.filter((module): module is PlatformWebModule => module !== null);
@@ -353,17 +362,19 @@ async function loadInstalledWebModulePackage(
): Promise<PlatformWebModule | null> {
let promise = localModuleCache.get(loader.packageName);
if (!promise) {
promise = loader.load().
promise = importModuleWithRetry(loader.load).
then((imported) => {
const module = imported.default;
if (!isPlatformWebModule(module)) {
throw new Error(`${loader.packageName} does not export a PlatformWebModule`);
}
loadedLocalModules.set(loader.packageName, module);
failedLocalModules.delete(loader.packageName);
return module;
}).
catch((error) => {
localModuleCache.delete(loader.packageName);
failedLocalModules.add(loader.packageName);
console.warn("GovOPlaN installed WebUI module was not loaded:", loader.packageName, error);
return null;
});
@@ -371,6 +382,7 @@ async function loadInstalledWebModulePackage(
}
const module = await promise;
if (module && expectedModuleId && module.id !== expectedModuleId) {
failedLocalModules.add(loader.packageName);
console.warn(
"GovOPlaN installed WebUI package id mismatch:",
loader.packageName,
+44
View File
@@ -17,6 +17,30 @@ export function groupNavigationItems(
contributions: ProductAreaContribution[],
presentation?: ViewPresentation
): NavigationGroup[] {
const viewLayout = presentation?.navigation;
const personalLayout = items.some((item) => item.navigationLayoutSource === "user"
|| item.navigationOrderSource === "user" || item.navigationVisibilitySource === "user");
if (viewLayout && !personalLayout) {
const byId = new Map(items.flatMap((item) => navigationItemAliases(item).map((id) => [id, item] as const)));
const separators = new Map((viewLayout.separators ?? []).map((item) => [item.id, item]));
const orderedIds = [...new Set([...viewLayout.order, ...byId.keys()])];
const projected: PlatformNavItem[] = [];
const seen = new Set<string>();
let section: { id: string; label: string } | null = null;
for (const id of orderedIds) {
if (separators.has(id)) { section = separators.get(id)!; continue; }
const item = byId.get(id);
if (!item || seen.has(item.to) || (navigationItemAliases(item).some((alias) => viewLayout.hidden.includes(alias)) && !item.navigationLocked)) continue;
seen.add(item.to);
projected.push(viewLayout.separators == null ? item : { ...item, navigationSection: section });
}
return viewLayout.separators == null
? groupNavigationItems(projected, contributions, { ...presentation, navigation: null })
: explicitNavigationGroups(projected);
}
if (items.some((item) => item.navigationCustomLayout)) {
return explicitNavigationGroups(items);
}
if (presentation?.navigationMode === "flat" || contributions.length === 0) {
return [{ id: "all-tools", items }];
}
@@ -89,3 +113,23 @@ export function groupNavigationItems(
}
return groups;
}
function explicitNavigationGroups(items: PlatformNavItem[]): NavigationGroup[] {
const groups: NavigationGroup[] = [];
for (const item of items) {
const section = item.navigationSection;
const id = section?.id ?? "navigation-ungrouped";
let group = groups[groups.length - 1];
if (!group || group.id !== id) {
group = { id, label: section?.label, areaLabel: section?.label, items: [] };
groups.push(group);
}
group.items.push(item);
}
return groups;
}
function navigationItemAliases(item: PlatformNavItem): string[] {
return [...new Set([item.navigationId, item.surfaceId, item.to, ...(item.navigationAliases ?? [])]
.filter((id): id is string => Boolean(id)))];
}
+18 -2
View File
@@ -130,6 +130,7 @@ export function projectProductNavigation(
const authorizedItems = items.filter((item) => navigationItemAuthorized(item, auth));
const allToolItems = catalogueItems.filter((item) => navigationItemAuthorized(item, auth));
const ownerItemByPath = new Map(authorizedItems.map((item) => [item.to, item]));
const positionByPath = new Map(authorizedItems.map((item, index) => [item.to, index]));
const consumedOwnerPaths = new Set<string>();
const replacementByPath = new Map<string, PlatformNavItem>();
@@ -152,14 +153,29 @@ export function projectProductNavigation(
const target = navigable[0];
if (!target) continue;
// Contributor priority still determines the operational target of the
// product surface. Its rail placement instead follows the earliest owner
// in the effective personal/tenant/system navigation layout.
const placement = navigable.reduce((earliest, candidate) =>
(positionByPath.get(candidate.item.to) ?? Infinity) < (positionByPath.get(earliest.item.to) ?? Infinity)
? candidate : earliest, target);
const lockedOwners = navigable.map(({ item }) => item).filter((item) => item.navigationLocked);
const lockedOwner = lockedOwners.find((item) => item.navigationLockSource === "system") ?? lockedOwners[0];
navigable.forEach(({ contribution }) => {
consumedOwnerPaths.add(contribution.routePath);
});
replacementByPath.set(target.contribution.routePath, {
...target.item,
replacementByPath.set(placement.contribution.routePath, {
...placement.item,
to: surface.entryPath,
label: surface.label,
navigationId: surface.id,
navigationAliases: [...new Set([
...surface.aliases,
...navigable.flatMap(({ item }) => [item.navigationId, item.surfaceId, item.to, ...(item.navigationAliases ?? [])])
].filter((alias): alias is string => Boolean(alias)))],
navigationLocked: lockedOwners.length > 0,
navigationLockSource: lockedOwner?.navigationLockSource ?? placement.item.navigationLockSource,
activePaths: [
...surface.aliases,
...navigable.map(({ contribution }) => contribution.routePath)
+43 -2
View File
@@ -1018,6 +1018,18 @@
margin-bottom: var(--space-3);
}
.navigation-preference-editor { min-width: 0; container-type: inline-size; }
.navigation-preference-add { margin-bottom: var(--space-3); flex-wrap: wrap; }
.navigation-preference-add select { flex: 1 1 12rem; min-width: 0; width: auto; max-width: 100%; }
.navigation-preference-item-actions { display: flex; align-items: center; gap: var(--space-2); justify-content: flex-end; }
.navigation-preference-drag { cursor: grab; }
.navigation-preference-drag:active { cursor: grabbing; }
.navigation-preference-list > li[data-dragging="true"] { outline: 2px solid var(--accent); outline-offset: -2px; }
.navigation-preference-list > li[data-drop-target="true"] { border-color: var(--accent); background: var(--surface-subtle); }
.navigation-preference-list > li[data-navigation-kind="separator"] { border-style: dashed; }
.navigation-preference-label label, .navigation-preference-label input { width: 100%; min-width: 0; }
.navigation-preference-label strong { overflow-wrap: anywhere; }
.navigation-preference-toolbar p {
margin: 0;
}
@@ -1032,7 +1044,8 @@
.navigation-preference-list > li {
display: grid;
grid-template-columns: auto minmax(12rem, 1fr) auto auto;
grid-template-columns: auto minmax(0, 1fr) auto;
min-width: 0;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
@@ -1041,6 +1054,11 @@
background: var(--surface-raised);
}
@container (max-width: 520px) {
.navigation-preference-list > li { grid-template-columns: minmax(0, 1fr) auto; }
.navigation-preference-label { grid-row: 1; grid-column: 1 / -1; }
}
.navigation-preference-order-actions {
display: flex;
gap: var(--space-1);
@@ -1054,7 +1072,7 @@
.navigation-preference-label span {
overflow: hidden;
color: var(--text-muted);
color: var(--muted);
font-family: var(--font-mono, monospace);
font-size: 0.75rem;
text-overflow: ellipsis;
@@ -2336,6 +2354,29 @@
font-weight: 600;
}
.loading-frame-panel.has-progress {
display: grid;
width: min(32rem, 100%);
min-width: 0;
box-sizing: border-box;
border-radius: var(--radius-lg);
text-align: center;
overflow-wrap: anywhere;
}
.loading-frame-panel progress {
width: 100%;
min-width: 0;
height: 0.7rem;
accent-color: var(--accent);
}
.loading-frame-progress-label {
font-size: 0.8rem;
font-weight: 400;
color: var(--muted);
}
.module-load-progress {
display: flex;
min-height: 120px;
+22 -2
View File
@@ -75,6 +75,9 @@
.dialog-panel {
width: min(560px, 100%);
min-width: 0;
max-width: 100%;
box-sizing: border-box;
max-height: min(760px, calc(100vh - 3rem));
overflow: hidden;
display: flex;
@@ -97,6 +100,7 @@
.dialog-header {
flex: 0 0 auto;
min-width: 0;
min-height: 58px;
display: flex;
align-items: center;
@@ -108,12 +112,15 @@
}
.dialog-title {
min-width: 0;
overflow-wrap: anywhere;
margin: 0;
color: var(--text-strong);
font-size: 1.05rem;
}
.dialog-close {
flex: 0 0 2rem;
width: 2rem;
height: 2rem;
border: 0;
@@ -142,11 +149,23 @@
.dialog-body {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: auto;
color: var(--text);
}
/* Form controls and their labels must shrink inside a dialog's padded body.
Wide tables/editors retain their own local scroll surfaces; do not conceal
overflowing controls by clipping horizontal overflow on the whole dialog. */
.dialog-body :where(.form-field, .form-grid-layout, .dialog-form-layout, .dialog-section-layout) {
min-width: 0;
max-width: 100%;
}
.dialog-body .form-field { grid-template-columns: minmax(0, 1fr); }
.dialog-body :where(input, select, textarea) { min-width: 0; max-width: 100%; }
.dialog-body :where(.form-label, .dialog-description, .dialog-notices) { overflow-wrap: anywhere; }
.dialog-body-padding-none { padding: 0; }
.dialog-body-padding-compact { padding: 12px; }
.dialog-body-padding-default { padding: 20px; }
@@ -157,6 +176,7 @@
.dialog-footer {
flex: 0 0 auto;
min-width: 0;
display: flex;
justify-content: flex-end;
gap: 0.6rem;
@@ -169,11 +189,11 @@
.dialog-actions-start { justify-content: flex-start; }
.dialog-actions-between { justify-content: space-between; }
.dialog-actions-end { justify-content: flex-end; }
.dialog-form-layout { min-width: 0; display: grid; }
.dialog-form-layout { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr); }
.dialog-form-spacing-compact { gap: 10px; }
.dialog-form-spacing-default { gap: 16px; }
.dialog-form-spacing-loose { gap: 24px; }
.dialog-section-layout { min-width: 0; display: grid; gap: 12px; }
.dialog-section-layout { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
.dialog-section-separated { padding-top: 16px; border-top: var(--border-line); }
.dialog-section-inset { padding: 14px; border: var(--border-line); border-radius: var(--radius); background: var(--panel-soft); }
+6
View File
@@ -14,6 +14,12 @@
.content-grid-item-two { grid-column: span 2; }
.content-grid-item-full { grid-column: 1 / -1; }
.form-grid-layout > .wide { grid-column: 1 / -1; }
/* Form rows align their controls, not a switch with its neighbour's label.
This is intrinsic: wrapped labels and one-column layouts need no spacer. */
.form-grid-layout > :where(.form-field, .toggle-switch-row),
.form-grid-layout > .content-grid-item:has(> :is(.form-field, .toggle-switch-row):only-child) {
align-self: end;
}
.form-section-layout { min-width: 0; display: grid; gap: 14px; }
.form-section-separated { padding-top: 18px; border-top: var(--border-line); }
.form-section-panel { padding: 18px; border: var(--border-line); border-radius: var(--radius); background: var(--panel); }

Some files were not shown because too many files have changed in this diff Show More