feat: consolidate shared UI and harden browser authority for release
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
}
|
||||
Generated
+260
-260
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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.");
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)))];
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user