fix(ui): unify heading help, table sizing and navigation contracts

Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
This commit is contained in:
2026-09-09 02:03:16 +02:00
parent 6591aaa3fd
commit 6d37aa527f
54 changed files with 1728 additions and 117 deletions
@@ -1,5 +1,6 @@
import { useState } from "react";
import AttachmentsDataPage from "../../../govoplan-campaign/webui/src/features/campaigns/AttachmentsDataPage";
import RecipientDataPage from "../../../govoplan-campaign/webui/src/features/campaigns/RecipientDataPage";
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";
@@ -27,7 +28,9 @@ export default function CampaignAttachmentsScenario() {
<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" />
{new URLSearchParams(window.location.search).has("recipient-data")
? <RecipientDataPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} campaignId="campaign-attachments" />
: <AttachmentsDataPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} campaignId="campaign-attachments" />}
</ConcurrencyConflictProvider>
</PlatformLanguageProvider>
</PlatformModulesProvider>;
+46
View File
@@ -0,0 +1,46 @@
import { useState } from "react";
import { Eye } from "lucide-react";
import Button from "../src/components/Button";
import Card from "../src/components/Card";
import ConnectionTree from "../src/components/ConnectionTree";
import ContentSection from "../src/components/ContentSection";
import LoadingFrame from "../src/components/LoadingFrame";
import DataGrid, { type DataGridColumn } from "../src/components/table/DataGrid";
import TableActionGroup from "../src/components/table/TableActionGroup";
type Row = { id: string; name: string; detail: string };
const rows: Row[] = ["Alpha", "Beta", "Gamma"].map((name) => ({ id: name, name, detail: "A long configured value ".repeat(12) }));
/** The real shared surfaces, without module-local inset or width overrides. */
export default function CardTableLayoutScenario() {
const [loading, setLoading] = useState(false);
const [inspected, setInspected] = useState("");
const columns: DataGridColumn<Row>[] = [
{ id: "name", header: "Name", minWidth: 160, value: (row) => row.name },
{ id: "detail", header: "Details", minWidth: 220, value: (row) => row.detail },
{ id: "actions", header: "Actions", columnType: "actions", sticky: "end", render: (row) => <TableActionGroup actions={[
{ id: "inspect", label: `Inspect ${row.name}`, icon: <Eye />, onClick: () => setInspected(row.name) }
]} /> }
];
const grid = (id: string) => <DataGrid id={`card-table-${id}`} rows={rows} columns={columns} getRowKey={(row) => row.id} />;
const tree = () => <ConnectionTree rows={rows} columns={[{ id: "name", header: "Name", render: (row: Row) => row.name }]} getRowKey={(row) => row.id} />;
return <main style={{ padding: 16, minWidth: 0, display: "grid", gap: 16, gridTemplateColumns: "minmax(0, 1fr)" }}>
<Button onClick={() => setLoading((value) => !value)}>Toggle loading</Button>
<output data-testid="card-table-inspected">{inspected}</output>
<Card title="Direct table" data-testid="direct-table">{grid("direct")}</Card>
<Card title="Collapsible table" data-testid="collapsible-table" collapsible persistCollapse={false}>{grid("collapsible")}</Card>
<Card title="Loading table" data-testid="loading-table"><LoadingFrame loading={loading} indicator="none" label="Loading rows">{grid("loading")}</LoadingFrame></Card>
<Card title="Admin table" data-testid="admin-table"><div className="admin-table-surface">{grid("admin")}</div></Card>
<Card title="Loading admin table" data-testid="loading-admin-table"><LoadingFrame loading={loading} indicator="none" label="Loading rows"><div className="admin-table-surface">{grid("loading-admin")}</div></LoadingFrame></Card>
<Card title="Connection table" data-testid="connection-table">{tree()}</Card>
<Card title="Loading connection table" data-testid="loading-connection-table"><LoadingFrame loading={loading} indicator="none" label="Loading rows">{tree()}</LoadingFrame></Card>
<Card title="Explicit table" data-testid="explicit-table" bodyLayout="table"><LoadingFrame loading={loading} indicator="none" label="Loading rows">{grid("explicit")}</LoadingFrame></Card>
<Card title="Table with context" data-testid="table-with-context" bodyLayout="table">
<ContentSection density="default" spacing="none">Meaningful table context</ContentSection>
{grid("context")}
</Card>
<Card title="Mixed content" data-testid="mixed-content"><p>Ordinary content keeps its inset.</p>{grid("mixed")}</Card>
<Card title="Loading mixed content" data-testid="loading-mixed-content"><LoadingFrame loading={loading} indicator="none" label="Loading rows"><p>Ordinary content keeps its inset.</p>{grid("loading-mixed")}</LoadingFrame></Card>
<div data-testid="standalone-table">{grid("standalone")}</div>
</main>;
}
+5
View File
@@ -2,6 +2,7 @@ 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 HeadingHelpScenario from "./HeadingHelpScenario";
import DataGridLayoutScenario from "./DataGridLayoutScenario";
import NavigationLayoutScenario from "./NavigationLayoutScenario";
import ManagedArchiveScenario from "./ManagedArchiveScenario";
@@ -11,6 +12,7 @@ import FormControlLayoutScenario from "./FormControlLayoutScenario";
import CampaignWorkspaceScenario from "./CampaignWorkspaceScenario";
import CampaignReportScenario from "./CampaignReportScenario";
import ModuleLayoutScenario from "./ModuleLayoutScenario";
import DashboardConfigurationScenario from "./DashboardConfigurationScenario";
import HelpCenterScenario from "./HelpCenterScenario";
import NotificationFilterScenario from "./NotificationFilterScenario";
import MultiSelectFilterScenario from "./MultiSelectFilterScenario";
@@ -84,6 +86,8 @@ export default function ConformanceApp() {
const [editorDirty, setEditorDirty] = useState(true);
const [metricDrilldown, setMetricDrilldown] = useState("");
if (new URLSearchParams(location.search).has("heading-help")) return <HeadingHelpScenario />;
if (new URLSearchParams(location.search).has("password-lifecycle")) return <PasswordLifecycleScenario />;
if (new URLSearchParams(location.search).has("credential-references")) return <CredentialReferencesScenario />;
@@ -92,6 +96,7 @@ export default function ConformanceApp() {
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("dashboard-configuration")) return <DashboardConfigurationScenario />;
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 />;
+26
View File
@@ -0,0 +1,26 @@
import DashboardPage from "../../../govoplan-dashboard/webui/src/features/dashboard/DashboardPage";
import { dashboardModule } from "../../../govoplan-dashboard/webui/src/module";
import { generatedTranslations } from "../../../govoplan-dashboard/webui/src/i18n/generatedTranslations";
import { schedulingModule } from "../../../govoplan-scheduling/webui/src/module";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext";
import type { AuthInfo } from "../src/types";
const auth: AuthInfo = {
user: { id: "dashboard-user", account_id: "dashboard-account", email: "dashboard@example.test" },
tenant: { id: "dashboard-tenant", name: "Dashboard fixture", slug: "dashboard" },
scopes: ["dashboard:dashboard:read"], roles: [], groups: [],
profile_loaded: true, roles_loaded: true, groups_loaded: true
};
export default function DashboardConfigurationScenario() {
const widgetHelp = new URLSearchParams(location.search).has("widget-help");
const modules = widgetHelp ? [dashboardModule, schedulingModule] : [dashboardModule];
return <PlatformModulesProvider modules={modules}>
<PlatformLanguageProvider
preferredLanguageCode={new URLSearchParams(location.search).get("language") ?? "en"}
moduleTranslations={[generatedTranslations, ...(widgetHelp ? [schedulingModule.translations] : [])]}>
<DashboardPage settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={widgetHelp ? { ...auth, scopes: [...auth.scopes, "scheduling:schedule:read"] } : auth} />
</PlatformLanguageProvider>
</PlatformModulesProvider>;
}
+13 -4
View File
@@ -3,13 +3,21 @@ 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";
import CardTableLayoutScenario from "./CardTableLayoutScenario";
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() {
if (new URLSearchParams(window.location.search).has("table-cards")) return <CardTableLayoutScenario />;
return <ResizableDataGridScenario />;
}
function ResizableDataGridScenario() {
const mode = new URLSearchParams(window.location.search).get("mode") ?? "cover";
const mixedColumns = new URLSearchParams(window.location.search).has("mixed-columns");
const preferredLimits = new URLSearchParams(window.location.search).has("preferred-limits");
const behavior: DataGridResizeBehavior = mode === "free" || mode === "constrained" ? mode : "cover";
const composite = mode === "composite";
const [width, setWidth] = useState(900);
@@ -18,8 +26,9 @@ export default function DataGridLayoutScenario() {
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: "name", header: "Name", width: 260, minWidth: 180, preferredMaxWidth: preferredLimits ? 300 : undefined, resizable: true, value: (row) => row.name },
...(mixedColumns ? [{ id: "fixed", header: "Fixed", width: 144, minWidth: 144, maxWidth: 144, value: () => "Fixed value" }] : []),
{ id: "detail", header: "Details", width: 360, minWidth: 220, preferredMaxWidth: preferredLimits ? 360 : undefined, resizable: true, value: (row) => row.detail },
{
id: "actions", header: "Actions", width: mode === "oversized" ? 500 : 72,
minWidth: mode === "oversized" ? 500 : undefined,
@@ -38,7 +47,7 @@ export default function DataGridLayoutScenario() {
</div> : group;
}
}
], [behavior, composite, extraAction, mode]);
], [behavior, composite, extraAction, mixedColumns, mode, preferredLimits]);
return (
<main style={{ padding: 16, minWidth: 0 }}>
<h1>Data grid layout conformance</h1>
@@ -52,7 +61,7 @@ export default function DataGridLayoutScenario() {
<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}`}
id={`layout-conformance-${behavior}${mixedColumns ? "-mixed" : ""}`}
rows={empty ? [] : rows}
columns={columns}
getRowKey={(row) => row.id}
+38
View File
@@ -0,0 +1,38 @@
import { useState } from "react";
import Button from "../src/components/Button";
import Card from "../src/components/Card";
import Dialog from "../src/components/Dialog";
import PageLayout from "../src/components/PageLayout";
import PageActionBar from "../src/components/PageActionBar";
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
import DocumentationHelpLink, { DocumentationHelpProvider } from "../src/components/help/DocumentationHelpLink";
import TextWithHelp from "../src/components/help/TextWithHelp";
import WidgetHeadingHelpScenario from "./WidgetHeadingHelpScenario";
export default function HeadingHelpScenario() {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(true);
if (new URLSearchParams(location.search).has("widgets")) return <WidgetHeadingHelpScenario />;
const help = <DocumentationHelpLink reference={{ contextId: "dashboard" }} />;
const longTitle = `Dashboard ${"DokumentationsüberschriftOhneLeerzeichen".repeat(5)}`;
return <DocumentationHelpProvider localDocsAvailable={false}>
<main className="conformance-root">
<PageLayout archetype="overview" mode="embedded" title="Dashboard" titleHelp={help} headerLoading={loading}
actions={<PageActionBar variant="overview" primaryActions={<Button onClick={() => setLoading(value => !value)}>Toggle loading</Button>} />}>
<WorkspaceActionBar variant="workspace" title="Workspace" titleHelp={help} primaryActions={<Button>Refresh</Button>} />
<Card title={longTitle} titleHelp={help} collapsible persistCollapse={false} data-testid="help-card">
<p data-testid="help-card-content">Saved data</p>
</Card>
<TextWithHelp as="div" help={help}><h3>Section</h3></TextWithHelp>
<div style={{ width: 120 }} data-testid="raw-text-help">
<TextWithHelp help={help}>{"UnbrokenLabel".repeat(8)}</TextWithHelp>
</div>
<Button data-testid="open-help-dialog" onClick={() => setOpen(true)}>Edit settings</Button>
<Dialog open={open} title={longTitle} titleHelp={help} onClose={() => setOpen(false)}
footer={<Button data-testid="dialog-last-action">Save</Button>}>
<label>Value<input defaultValue="Saved value" /></label>
</Dialog>
</PageLayout>
</main>
</DocumentationHelpProvider>;
}
@@ -12,6 +12,7 @@ export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "../src/api/m
export type * from "../src/api/mailContracts";
export type * from "../src/types";
export { default as FieldLabel } from "../src/components/help/FieldLabel";
export { default as TextWithHelp } from "../src/components/help/TextWithHelp";
export { default as PasswordField } from "../src/components/PasswordField";
export { default as ResourceAccessExplanation } from "../src/components/ResourceAccessExplanation";
export { default as ExplorerTree } from "../src/components/ExplorerTree";
@@ -45,6 +46,7 @@ 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 { DashboardWidgetList, useDashboardWidgetData } from "../src/components/DashboardWidgetContent";
export { default as PageActionBar } from "../src/components/PageActionBar";
export { default as PageLayout } from "../src/components/PageLayout";
export { default as PageTitle } from "../src/components/PageTitle";
@@ -117,6 +119,7 @@ export {
usePlatformLanguage
} from "../src/i18n/LanguageContext";
export { usePlatformModuleInstalled, usePlatformUiCapability, usePlatformUiCapabilities, usePlatformModules } from "../src/platform/ModuleContext";
export { dashboardWidgetsForModules } from "../src/platform/modules";
export {
dispatchQuickAccessResult,
quickAccessLaunchState
+40
View File
@@ -0,0 +1,40 @@
import { useState } from "react";
import DashboardGrid from "../../../govoplan-dashboard/webui/src/features/dashboard/DashboardGrid";
import "../../../govoplan-dashboard/webui/src/styles/dashboard.css";
import Button from "../src/components/Button";
import { DocumentationHelpProvider } from "../src/components/help/DocumentationHelpLink";
import type { AuthInfo, DashboardWidgetContribution } from "../src/types";
const auth: AuthInfo = {
user: { id: "widget-user", account_id: "widget-account", email: "widget@example.test" },
tenant: { id: "widget-tenant", name: "Widget fixture", slug: "widget" },
scopes: [], roles: [], groups: [], profile_loaded: true, roles_loaded: true, groups_loaded: true
};
const widget: DashboardWidgetContribution = {
id: "fixture.scheduling",
title: "Scheduling requests",
documentation: { topicId: "scheduling.find-and-decide-meeting-time", documentationType: "user" },
render: () => <p>Existing requests</p>
};
const ignore = () => undefined;
export default function WidgetHeadingHelpScenario() {
const [configuring, setConfiguring] = useState(false);
const [preview, setPreview] = useState(false);
return <DocumentationHelpProvider localDocsAvailable>
<main className="conformance-root">
<Button onClick={() => { setConfiguring(value => !value); setPreview(false); }}>Toggle configuration</Button>
<Button onClick={() => { setConfiguring(true); setPreview(value => !value); }}>Toggle drag preview</Button>
<DashboardGrid
placements={[{ instanceId: "fixture-instance", widgetId: widget.id, size: "medium", columnStart: 1, configuration: {} }]}
widgetById={new Map([[widget.id, widget]])}
settings={{ apiBaseUrl: "", apiKey: "", accessToken: "" }} auth={auth} modules={[]}
effectiveView={null} refreshKey={0} configuring={configuring}
dragItem={preview ? { kind: "placement", instanceId: "fixture-instance" } : null}
dropTarget={preview ? { kind: "end", columnStart: 1 } : null}
onDragStart={ignore} onDragEnd={ignore} onDragOver={ignore} onDragOverEnd={ignore}
onDrop={ignore} onDropPreview={ignore} onDropAtEnd={ignore} onRemove={ignore} onConfigure={ignore}
/>
</main>
</DocumentationHelpProvider>;
}
@@ -1,20 +1,28 @@
import { expect, test, type Page } from "@playwright/test";
async function install(page: Page) {
async function install(page: Page, recipientData = false, fieldCount = 0) {
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: {},
...(recipientData ? { recipients: { allow_individual_to: true },
fields: Array.from({ length: fieldCount }, (_, index) => ({ name: `field_${index + 1}`, label: `Field ${index + 1}`, type: "string", can_override: true })),
entries: { defaults: {}, inline: [
{ id: "recipient-1", name: "Fixture recipient", email: "recipient@example.test", channel_policy: "mail",
print_target: { target: "Dispatch fixture", channel: "internal_mail" } }
] } } : {}),
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 mutations: string[] = [];
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") mutations.push(`${request.method()} ${url.pathname}`);
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" },
@@ -38,9 +46,10 @@ async function install(page: Page) {
}
return route.abort();
});
await page.goto("/?campaign-attachments");
await expect(page.locator("#campaign-attachment-sources .chooser-display-input")).toBeEnabled();
return { writes, errors, zip };
await page.goto(`/?campaign-attachments${recipientData ? "&recipient-data" : ""}`);
if (recipientData) await expect(page.locator('.recipient-profiles-table-surface .data-grid-body-cell[data-column-id="recipients"]')).toContainText("recipient@example.test");
else await expect(page.locator("#campaign-attachment-sources .chooser-display-input")).toBeEnabled();
return { writes, mutations, errors, zip };
}
test("actual Files chooser opens repeatedly from attachment path by click and keyboard", async ({ page }) => {
@@ -82,3 +91,134 @@ test("attachment source corrections save without changing or reauthorizing legac
await expect(page.locator('#campaign-attachment-sources input[placeholder="Campaign files"]')).toHaveValue("Updated source name");
expect(fixture.errors).toEqual([]);
});
test("global attachment labels grow across the fixed source column without changing campaign data", async ({ page }) => {
const fixture = await install(page);
const card = page.locator("#campaign-global-attachments");
const label = card.locator('.data-grid-header-cell[data-column-id="label"]');
const basePath = card.locator('.data-grid-header-cell[data-column-id="base_path"]');
const pattern = card.locator('.data-grid-header-cell[data-column-id="file_filter"]');
const measured = (column: typeof label) => column.evaluate((element) => element.getBoundingClientRect().width);
await expect(label).toBeVisible();
const handle = label.getByRole("separator");
await handle.scrollIntoViewIfNeeded();
const initialLabel = await measured(label);
const initialBasePath = await measured(basePath);
const initialPattern = await measured(pattern);
const bounds = (await handle.boundingBox())!;
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width / 2 + 80, bounds.y + bounds.height / 2, { steps: 6 });
await expect.poll(() => measured(label)).toBeCloseTo(initialLabel + 80, 0);
await page.mouse.up();
await expect.poll(() => measured(label)).toBeCloseTo(initialLabel + 80, 0);
await expect.poll(() => measured(basePath)).toBeCloseTo(initialBasePath, 0);
await expect.poll(() => measured(pattern)).toBeCloseTo(initialPattern, 0);
await handle.press("Shift+ArrowRight");
await expect.poll(() => measured(label)).toBeCloseTo(initialLabel + 120, 0);
await page.reload();
await expect.poll(() => measured(label)).toBeCloseTo(initialLabel + 120, 0);
expect(fixture.writes).toHaveLength(0);
expect(fixture.mutations).toHaveLength(0);
expect(fixture.errors).toEqual([]);
});
test("recipient and delivery columns grow past the old caps across fixed neighbors and keep personal widths", async ({ page }) => {
await page.setViewportSize({ width: 2048, height: 1100 });
const fixture = await install(page, true);
const grid = page.locator(".recipient-profiles-table-surface");
await expect(grid.locator('.data-grid-body-cell[data-column-id="delivery"]')).toContainText("Internal mail: Dispatch fixture");
const column = (id: string) => grid.locator(`.data-grid-header-cell[data-column-id="${id}"]`);
const measured = (id: string) => column(id).evaluate((element) => element.getBoundingClientRect().width);
const fixedWidths = { active: await measured("active"), attachments: await measured("attachments") };
const expectedWidths: Record<string, number> = {};
for (const [id, oldMaximum] of [["recipients", 640], ["delivery", 480]] as const) {
const handle = column(id).getByRole("separator");
await grid.locator(".data-grid-scroll-region").evaluate((element, columnId) => {
const header = element.querySelector<HTMLElement>(`.data-grid-header-cell[data-column-id="${columnId}"]`)!;
element.scrollLeft = Math.max(0, header.offsetLeft - element.clientWidth / 4);
}, id);
await handle.scrollIntoViewIfNeeded();
const initial = await measured(id);
const target = Math.max(oldMaximum + 80, initial + 80);
const bounds = (await handle.boundingBox())!;
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width / 2 + target - initial, bounds.y + bounds.height / 2, { steps: 6 });
await expect.poll(() => measured(id)).toBeCloseTo(target, 0);
await page.mouse.up();
await expect.poll(() => measured(id)).toBeCloseTo(target, 0);
await handle.press("Shift+ArrowRight");
expectedWidths[id] = target + 40;
await expect.poll(() => measured(id)).toBeCloseTo(expectedWidths[id], 0);
for (const fixed of ["active", "attachments"] as const) await expect.poll(() => measured(fixed)).toBeCloseTo(fixedWidths[fixed], 0);
}
expect(await grid.locator(".data-grid-scroll-region").evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true);
await page.reload();
for (const id of ["recipients", "delivery"]) await expect.poll(() => measured(id)).toBeCloseTo(expectedWidths[id], 0);
await expect(grid.locator('.data-grid-body-cell[data-column-id="delivery"]')).toContainText("Internal mail: Dispatch fixture");
for (const fixed of ["active", "attachments"] as const) await expect.poll(() => measured(fixed)).toBeCloseTo(fixedWidths[fixed], 0);
expect(fixture.writes).toHaveLength(0);
expect(fixture.mutations).toHaveLength(0);
expect(fixture.errors).toEqual([]);
});
test("ultrawide recipient grids keep balanced initial widths and resize in both directions across fixed columns", async ({ page }) => {
await page.setViewportSize({ width: 3085, height: 1200 });
const fixture = await install(page, true, 3);
const grid = page.locator(".recipient-profiles-table-surface");
const region = grid.locator(".data-grid-scroll-region");
const column = (id: string) => grid.locator(`.data-grid-header-cell[data-column-id="${id}"]`);
const width = (id: string) => column(id).evaluate(element => element.getBoundingClientRect().width);
await expect.poll(() => width("recipients")).toBeLessThanOrEqual(640.01);
await expect.poll(() => width("delivery")).toBeLessThanOrEqual(480.01);
const fixed = { active: await width("active"), attachments: await width("attachments") };
const ready = async (id: string) => {
const handle = column(id).getByRole("separator");
await handle.scrollIntoViewIfNeeded();
return handle;
};
const keyResize = async (id: string, grow: boolean, count = 1) => {
const handle = await ready(id);
for (let step = 0; step < count; step += 1) await handle.press(grow ? "Shift+ArrowRight" : "Shift+ArrowLeft");
};
// All right-hand field columns must be adjustable beyond their old 360px
// presentation caps, and shrinking must not silently snap back afterwards.
for (const id of ["field-field_1", "field-field_2", "field-field_3"]) {
const initial = await width(id);
await keyResize(id, true, 6);
await expect.poll(() => width(id)).toBeCloseTo(initial + 240, 0);
await keyResize(id, false, 2);
await expect.poll(() => width(id)).toBeCloseTo(initial + 160, 0);
}
const recipientStart = await width("recipients");
await keyResize("recipients", true, 10);
await expect.poll(() => width("recipients")).toBeCloseTo(recipientStart + 400, 0);
const recipientHandle = await ready("recipients");
const bounds = (await recipientHandle.boundingBox())!;
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
await page.mouse.down();
await page.mouse.move(bounds.x + bounds.width / 2 - 160, bounds.y + bounds.height / 2, { steps: 8 });
await expect.poll(() => width("recipients")).toBeCloseTo(recipientStart + 240, 0);
await page.mouse.up();
await expect.poll(() => width("recipients")).toBeCloseTo(recipientStart + 240, 0);
const deliveryStart = await width("delivery");
await keyResize("delivery", true, 3);
await keyResize("delivery", false, 2);
await expect.poll(() => width("delivery")).toBeCloseTo(deliveryStart + 40, 0);
const savedWidths = await Promise.all(["recipients", "delivery", "field-field_1", "field-field_2", "field-field_3"].map(width));
await page.reload();
for (const [index, id] of ["recipients", "delivery", "field-field_1", "field-field_2", "field-field_3"].entries()) {
await expect.poll(() => width(id)).toBeCloseTo(savedWidths[index], 0);
}
await (await ready("delivery")).focus();
const deliveryBounds = (await column("delivery").boundingBox())!;
const viewport = (await region.boundingBox())!;
expect(deliveryBounds.x + deliveryBounds.width).toBeGreaterThan(viewport.x);
expect(deliveryBounds.x).toBeLessThan(viewport.x + viewport.width);
for (const id of ["active", "attachments"] as const) await expect.poll(() => width(id)).toBeCloseTo(fixed[id], 0);
expect(fixture.mutations).toHaveLength(0);
expect(fixture.errors).toEqual([]);
});
+79
View File
@@ -0,0 +1,79 @@
import { expect, test, type Locator } from "@playwright/test";
const tableOnlyCards = ["direct-table", "collapsible-table", "loading-table", "admin-table", "loading-admin-table", "connection-table", "loading-connection-table", "explicit-table"];
async function expectFlush(card: Locator) {
const geometry = await card.evaluate((element) => {
const body = element.querySelector(".card-body")!;
const surface = element.querySelector(".data-grid-shell, .connection-tree")!;
const cardRect = element.getBoundingClientRect();
const bodyRect = body.getBoundingClientRect();
const rect = surface.getBoundingClientRect();
const grid = surface.querySelector(".data-grid");
const scrollRegion = surface.querySelector(".data-grid-scroll-region");
return {
left: rect.left - cardRect.left, right: cardRect.right - rect.right,
top: rect.top - bodyRect.top, bottom: cardRect.bottom - rect.bottom,
padding: getComputedStyle(body).padding, border: getComputedStyle(surface).borderWidth,
unfilledWidth: grid && scrollRegion ? scrollRegion.clientWidth - grid.getBoundingClientRect().width : 0
};
});
expect(geometry.padding).toBe("0px");
expect(geometry.border).toBe("0px");
expect(geometry.unfilledWidth).toBeLessThanOrEqual(1);
for (const edge of [geometry.left, geometry.right, geometry.top, geometry.bottom]) expect(Math.abs(edge)).toBeLessThanOrEqual(1);
}
for (const width of [1280, 390, 320]) {
test(`table-only cards use their full interior without negative margins at ${width}px`, async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.setViewportSize({ width, height: 900 });
await page.goto("/?data-grid-layout&table-cards&language=en&theme=light");
for (const id of tableOnlyCards) await expectFlush(page.getByTestId(id));
for (const id of ["mixed-content", "loading-mixed-content"]) {
const body = page.getByTestId(id).locator(".card-body");
await expect(body).toHaveCSS("padding", "22px 24px");
await expect(body.locator(".data-grid-shell")).toHaveCSS("border-width", "1px");
}
const context = page.getByTestId("table-with-context");
await expect(context.locator(".card-body")).toHaveCSS("padding", "0px");
await expect(context.locator(".content-section")).toHaveCSS("padding", "18px");
const contextBounds = await context.boundingBox();
const contextGrid = await context.locator(".data-grid-shell").boundingBox();
expect(contextGrid!.x - contextBounds!.x).toBeCloseTo(1, 0);
expect(contextBounds!.width - contextGrid!.width).toBeCloseTo(2, 0);
await expect(page.getByTestId("standalone-table").locator(".data-grid-shell")).toHaveCSS("border-width", "1px");
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
expect(errors).toEqual([]);
});
}
test("loading overlays and collapse do not change table insets or lose row actions", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 900 });
await page.goto("/?data-grid-layout&table-cards&language=en&theme=light");
const before = await page.getByTestId("loading-table").boundingBox();
await page.getByRole("button", { name: "Toggle loading", exact: true }).click();
for (const id of ["loading-table", "loading-admin-table", "loading-connection-table", "explicit-table"]) {
const card = page.getByTestId(id);
await expectFlush(card);
const frame = await card.locator(".loading-frame").boundingBox();
const overlay = await card.locator(".loading-frame-overlay").boundingBox();
expect(overlay).toEqual(frame);
await expect(card.locator(".loading-frame")).toHaveAttribute("aria-busy", "true");
}
expect(await page.getByTestId("loading-table").boundingBox()).toEqual(before);
await page.getByRole("button", { name: "Toggle loading", exact: true }).click();
const collapsible = page.getByTestId("collapsible-table");
await collapsible.locator(".card-collapse-toggle").click();
await expect(collapsible.locator(".data-grid-shell")).toHaveCount(0);
await collapsible.locator(".card-collapse-toggle").click();
await expectFlush(collapsible);
const direct = page.getByTestId("direct-table");
const scroller = direct.locator(".data-grid-scroll-region");
expect(await scroller.evaluate((element) => element.scrollWidth > element.clientWidth)).toBe(true);
await scroller.evaluate((element) => { element.scrollLeft = element.scrollWidth; });
const action = direct.getByRole("button", { name: "Inspect Alpha", exact: true });
await action.click();
await expect(page.getByTestId("card-table-inspected")).toHaveText("Alpha");
});
+153
View File
@@ -0,0 +1,153 @@
import { expect, test, type Page } from "@playwright/test";
async function expectDashboardTitleHelp(page: Page) {
const title = page.getByRole("heading", { level: 1, name: new URL(page.url()).searchParams.get("language") === "de" ? "Übersicht" : "Dashboard", exact: true });
await expect(title).toBeVisible();
const anchor = title.locator("..");
const help = anchor.getByRole("link");
await expect(help).toBeVisible();
await expect(title.locator("a")).toHaveCount(0);
const headingBounds = await title.boundingBox();
const helpBounds = await help.boundingBox();
expect(helpBounds!.x - headingBounds!.x - headingBounds!.width).toBeGreaterThanOrEqual(4);
expect(helpBounds!.x - headingBounds!.x - headingBounds!.width).toBeLessThanOrEqual(8);
await expect(page.locator('[data-page-action-slot="help"], .page-action-bar-trailing .documentation-help-link')).toHaveCount(0);
}
async function dashboardFixture(page: Page) {
const writes: string[] = [];
const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message));
await page.route(url => url.pathname.startsWith("/api/"), route => {
if (route.request().method() !== "GET") writes.push(new URL(route.request().url()).pathname);
return route.fulfill({ json: {
exists: false, view_id: null, layout_version: 1, revision: 0,
placements: [], known_widget_ids: [], updated_at: null
} });
});
return { writes, errors };
}
for (const language of ["en", "de"]) {
test(`Dashboard Cancel exits a clean configuration without saving (${language})`, async ({ page }) => {
const fixture = await dashboardFixture(page);
await page.goto(`/?dashboard-configuration&language=${language}`);
await expectDashboardTitleHelp(page);
const configure = page.locator('[data-page-action-archetype="overview"] [data-page-action-slot="primary"] button');
for (let attempt = 0; attempt < 2; attempt += 1) {
await expect(configure).toBeEnabled();
await configure.click();
await expectDashboardTitleHelp(page);
const editor = page.locator('[data-page-action-archetype="editor"]');
await expect(editor.locator('[data-page-action-slot="save"] button')).toBeDisabled();
const cancel = editor.locator('[data-page-action-slot="discard"] button');
await expect(cancel).toBeEnabled();
await expect(cancel).toHaveText(language === "de" ? "Abbrechen" : "Cancel");
await cancel.click();
await expect(configure).toBeVisible();
await expectDashboardTitleHelp(page);
await expect(page.getByRole("alertdialog")).toHaveCount(0);
}
expect(fixture.writes).toEqual([]);
expect(fixture.errors).toEqual([]);
});
}
test("Dashboard Cancel confirms dirty drafts and never saves when discarding", async ({ page }) => {
const fixture = await dashboardFixture(page);
await page.goto("/?dashboard-configuration&language=en");
const configure = page.locator('[data-page-action-archetype="overview"] [data-page-action-slot="primary"] button');
await configure.click();
await page.getByRole("button", { name: "Remove Active interface modules", exact: true }).click();
await expectDashboardTitleHelp(page);
const editor = page.locator('[data-page-action-archetype="editor"]');
await expect(editor.locator('[data-page-action-slot="save"] button')).toBeEnabled();
const cancel = editor.locator('[data-page-action-slot="discard"] button');
await cancel.click();
const confirmation = page.getByRole("alertdialog");
await expect(confirmation).toBeVisible();
await confirmation.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(editor).toBeVisible();
await expect(editor.locator('[data-page-action-slot="save"] button')).toBeEnabled();
await cancel.click();
await confirmation.getByRole("button", { name: "Discard", exact: true }).click();
await expect(configure).toBeVisible();
await expect(page.getByRole("heading", { name: "Active interface modules", exact: true })).toBeVisible();
await configure.click();
await expect(editor.locator('[data-page-action-slot="save"] button')).toBeDisabled();
await expect(cancel).toBeEnabled();
expect(fixture.writes).toEqual([]);
expect(fixture.errors).toEqual([]);
});
test("Dashboard Cancel can save and leave without restoring the previous draft", async ({ page }) => {
const fixture = await dashboardFixture(page);
let releaseSave!: () => void;
const savePending = new Promise<void>(resolve => { releaseSave = resolve; });
let saved: Record<string, unknown> | null = null;
const updates: Record<string, unknown>[] = [];
await page.route("**/api/v1/dashboard/layout*", async route => {
if (route.request().method() === "PUT") {
const payload = route.request().postDataJSON();
updates.push(payload);
await savePending;
saved = { ...payload, exists: true, view_id: null, revision: 1, updated_at: "2026-09-08T12:00:00Z" };
await route.fulfill({ json: saved });
} else if (saved) {
await route.fulfill({ json: saved });
} else {
await route.fallback();
}
});
await page.goto("/?dashboard-configuration&language=en");
const configure = page.locator('[data-page-action-archetype="overview"] [data-page-action-slot="primary"] button');
const editor = page.locator('[data-page-action-archetype="editor"]');
await configure.click();
await page.getByRole("button", { name: "Remove Active interface modules", exact: true }).click();
await editor.locator('[data-page-action-slot="discard"] button').click();
const confirmation = page.getByRole("alertdialog");
await confirmation.getByRole("button", { name: "Save and leave", exact: true }).click();
await expect.poll(() => updates.length).toBe(1);
await expect(editor.locator('[data-page-action-slot="discard"] button')).toBeDisabled();
await expect(editor.locator('[data-page-action-slot="save"] button')).toBeDisabled();
await expect(confirmation.getByRole("button", { name: "Discard", exact: true })).toBeDisabled();
releaseSave();
await expect(configure).toBeVisible();
await expect(confirmation).toHaveCount(0);
expect(updates[0].placements).toEqual([]);
await page.reload();
await configure.click();
await expect(page.getByRole("button", { name: "Remove Active interface modules", exact: true })).toHaveCount(0);
await expect(editor.locator('[data-page-action-slot="save"] button')).toBeDisabled();
await expect(editor.locator('[data-page-action-slot="discard"] button')).toBeEnabled();
expect(updates).toHaveLength(1);
expect(fixture.writes).toEqual([]);
expect(fixture.errors).toEqual([]);
});
test("actual Scheduling widget contributes help at its Dashboard title without duplicating it", async ({ page }) => {
const fixture = await dashboardFixture(page);
await page.route("**/api/v1/dashboard/layout*", route => route.fulfill({ json: {
exists: true, view_id: null, layout_version: 1, revision: 1,
placements: [{ instance_id: "scheduling-widget", widget_id: "scheduling.open-requests", size: "medium", column_start: 1, configuration: {} }],
known_widget_ids: ["dashboard.installed-modules", "scheduling.open-requests"], updated_at: "2026-09-08T12:00:00Z"
} }));
await page.route("**/api/v1/scheduling/requests*", route => route.fulfill({ json: { requests: [] } }));
await page.goto("/?dashboard-configuration&widget-help&language=en");
await expectDashboardTitleHelp(page);
const widget = page.locator(".dashboard-widget").filter({ has: page.getByRole("heading", { level: 2, name: "Scheduling requests", exact: true }) });
await expect(widget).toHaveCount(1);
await expect(widget.getByRole("heading", { name: "Scheduling requests", exact: true })).toHaveCount(1);
const titleHelp = widget.locator(".card-title-with-help .documentation-help-link");
await expect(titleHelp).toHaveAttribute("href", /topic=scheduling\.find-and-decide-meeting-time/);
await expect(titleHelp).toBeVisible();
await expect(widget.locator(".card-body .documentation-help-link")).toHaveCount(0);
await widget.getByRole("button", { name: "Show header only", exact: true }).click();
await expect(titleHelp).toBeVisible();
await page.locator('[data-page-action-archetype="overview"] [data-page-action-slot="primary"] button').click();
await expectDashboardTitleHelp(page);
await expect(titleHelp).toBeVisible();
await expect(widget.locator(".card-actions .documentation-help-link")).toHaveCount(0);
expect(fixture.writes).toEqual([]);
expect(fixture.errors).toEqual([]);
});
@@ -117,6 +117,74 @@ test("constrained resizing redistributes tracks without introducing overflow", a
await expectActionsUnclipped(page);
});
for (const mode of ["cover", "free", "constrained"] as const) {
test(`${mode} resizing crosses a fixed intermediate column without changing that column`, async ({ page }) => {
await page.goto(`/?data-grid-layout&mixed-columns&mode=${mode}`);
const handle = header(page, "name").getByRole("separator");
await expect.poll(() => width(page, "actions")).toBeGreaterThanOrEqual(mode === "free" ? 180 : 181);
if (mode !== "free") await expectActionsUnclipped(page);
const initialName = await width(page, "name");
const initialDetail = await width(page, "detail");
const initialFixed = await width(page, "fixed");
const initialGrid = await page.locator(".data-grid").evaluate((element) => element.getBoundingClientRect().width);
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 + 80, box.y + box.height / 2, { steps: 6 });
await expect.poll(() => width(page, "name")).toBeCloseTo(initialName + 80, 0);
await expect.poll(() => width(page, "fixed")).toBeCloseTo(initialFixed, 0);
await expect.poll(() => width(page, "detail")).toBeCloseTo(initialDetail - (mode === "constrained" ? 80 : 0), 0);
await page.mouse.up();
await expect.poll(() => width(page, "name")).toBeCloseTo(initialName + 80, 0);
await expect.poll(() => page.locator(".data-grid").evaluate((element) => element.getBoundingClientRect().width))
.toBeCloseTo(initialGrid + (mode === "constrained" ? 0 : 80), 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(initialName + 80, 0);
await handle.press("Shift+ArrowLeft");
await expect.poll(() => width(page, "name")).toBeCloseTo(initialName + 40, 0);
await expect.poll(() => width(page, "fixed")).toBeCloseTo(initialFixed, 0);
await handle.press("Shift+ArrowRight");
await expect.poll(() => width(page, "name")).toBeCloseTo(initialName + 80, 0);
await expect.poll(() => width(page, "fixed")).toBeCloseTo(initialFixed, 0);
});
}
test("preferred caps permit two-way resizing across fixed peers at the right scroll boundary", async ({ page }) => {
await page.goto("/?data-grid-layout&mixed-columns&preferred-limits");
await expectActionsUnclipped(page);
const initialName = await width(page, "name");
const initialFixed = await width(page, "fixed");
const nameHandle = header(page, "name").getByRole("separator");
for (let step = 0; step < 10; step += 1) await nameHandle.press("Shift+ArrowRight");
await expect.poll(() => width(page, "name")).toBeCloseTo(initialName + 400, 0);
const scroller = page.locator(".data-grid-scroll-region");
await scroller.evaluate((element) => { element.scrollLeft = element.scrollWidth; });
const detailBeforeShrink = await width(page, "detail");
const scrollBeforeShrink = await scroller.evaluate((element) => element.scrollLeft);
const handleBeforeShrink = (await nameHandle.boundingBox())!;
await page.mouse.move(handleBeforeShrink.x + handleBeforeShrink.width / 2, handleBeforeShrink.y + handleBeforeShrink.height / 2);
await page.mouse.down();
await page.mouse.move(handleBeforeShrink.x + handleBeforeShrink.width / 2 - 80, handleBeforeShrink.y + handleBeforeShrink.height / 2, { steps: 6 });
await expect.poll(() => width(page, "name")).toBeCloseTo(initialName + 320, 0);
await expect.poll(() => width(page, "detail")).toBeCloseTo(detailBeforeShrink + 80, 0);
await expect.poll(() => scroller.evaluate((element) => element.scrollLeft)).toBeCloseTo(scrollBeforeShrink, 0);
expect((await nameHandle.boundingBox())!.x).toBeCloseTo(handleBeforeShrink.x - 80, 0);
await page.mouse.up();
await expect.poll(() => width(page, "name")).toBeCloseTo(initialName + 320, 0);
const detailHandle = header(page, "detail").getByRole("separator");
await detailHandle.press("Shift+ArrowRight");
await expect.poll(() => width(page, "detail")).toBeCloseTo(detailBeforeShrink + 120, 0);
await detailHandle.press("Shift+ArrowLeft");
await expect.poll(() => width(page, "detail")).toBeCloseTo(detailBeforeShrink + 80, 0);
await expect.poll(() => width(page, "fixed")).toBeCloseTo(initialFixed, 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(initialName + 320, 0);
await expect.poll(() => width(page, "detail")).toBeCloseTo(detailBeforeShrink + 80, 0);
await expect.poll(() => width(page, "fixed")).toBeCloseTo(initialFixed, 0);
});
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);
+102
View File
@@ -0,0 +1,102 @@
import { expect, test, type Locator } from "@playwright/test";
async function expectHelpBesideHeading(anchor: Locator) {
const heading = anchor.locator(":scope > :is(h1,h2,h3)");
const link = anchor.getByRole("link");
await expect(heading).toBeVisible();
await expect(link).toBeVisible();
await expect(heading.locator("a,button,[role=status]")).toHaveCount(0);
const geometry = await anchor.evaluate(element => {
const heading = element.querySelector(":scope > :is(h1,h2,h3)")!.getBoundingClientRect();
const link = element.querySelector("a")!.getBoundingClientRect();
return { gap: link.left - heading.right, right: link.right, viewport: window.innerWidth };
});
expect(geometry.gap).toBeGreaterThanOrEqual(4);
expect(geometry.gap).toBeLessThanOrEqual(8);
expect(geometry.right).toBeLessThanOrEqual(geometry.viewport);
}
for (const width of [390, 3085]) {
for (const language of ["en", "de"]) {
test(`documentation remains beside headings during loading and long-title wrapping (${width}px, ${language})`, async ({ page }) => {
await page.setViewportSize({ width, height: 1100 });
await page.goto(`/?heading-help&language=${language}`);
const pageTitle = page.locator(".page-title-with-loader");
await expect(page.getByRole("heading", { level: 1, name: "Dashboard", exact: true })).toBeVisible();
await expect(pageTitle.getByRole("status")).toBeVisible();
await expect(pageTitle.getByRole("link")).toHaveAccessibleName(language === "de" ? "Benutzerdokumentation öffnen" : "Open user documentation");
for (const anchor of await page.locator(".text-with-help:has(> :is(h1,h2,h3))").all()) await expectHelpBesideHeading(anchor);
const rawLabel = page.getByTestId("raw-text-help");
const rawGeometry = await rawLabel.evaluate(element => ({ width: element.clientWidth, scrollWidth: element.scrollWidth, right: element.getBoundingClientRect().right, helpRight: element.querySelector("a")!.getBoundingClientRect().right }));
expect(rawGeometry.scrollWidth).toBeLessThanOrEqual(rawGeometry.width);
expect(rawGeometry.helpRight).toBeLessThanOrEqual(rawGeometry.right);
await page.getByRole("button", { name: "Toggle loading", exact: true }).click();
await expect(pageTitle.getByRole("status")).toHaveCount(0);
await expectHelpBesideHeading(pageTitle.locator(".text-with-help"));
await expect(page.locator('[data-page-action-slot="help"]')).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
});
}
}
test("card help stays available when collapsed and opening it does not toggle content", async ({ page, context }) => {
await context.route("https://govoplan.add-ideas.de/**", route => route.fulfill({ body: "Documentation fixture" }));
await page.goto("/?heading-help&language=en");
const card = page.getByTestId("help-card");
const popupPromise = page.waitForEvent("popup");
await card.getByRole("link").click();
const popup = await popupPromise;
await popup.close();
await expect(page.getByTestId("help-card-content")).toBeVisible();
await card.getByRole("button", { name: "Show header only", exact: true }).click();
await expect(page.getByTestId("help-card-content")).toHaveCount(0);
await expectHelpBesideHeading(card.locator(".text-with-help"));
await card.getByRole("button", { name: "Show content", exact: true }).click();
await expect(page.getByTestId("help-card-content")).toBeVisible();
});
test("dialog help has its own name, remains in keyboard order, and does not dismiss the dialog", async ({ page, context }) => {
await context.route("https://govoplan.add-ideas.de/**", route => route.fulfill({ body: "Documentation fixture" }));
await page.setViewportSize({ width: 390, height: 1000 });
await page.goto("/?heading-help&language=en");
await page.getByTestId("open-help-dialog").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toHaveAccessibleName(/^Dashboard DokumentationsüberschriftOhneLeerzeichen/);
await expectHelpBesideHeading(dialog.locator(".text-with-help"));
const help = dialog.getByRole("link");
await expect(help).toBeFocused();
await help.press("Shift+Tab");
await expect(page.getByTestId("dialog-last-action")).toBeFocused();
await page.getByTestId("dialog-last-action").press("Tab");
await expect(help).toBeFocused();
const popupPromise = page.waitForEvent("popup");
await help.click();
const popup = await popupPromise;
await popup.close();
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "Close", exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByTestId("open-help-dialog")).toBeFocused();
});
test("widget documentation stays beside its one existing title in display, configuration, and drag markup", async ({ page }) => {
await page.goto("/?heading-help&widgets&language=en");
const title = page.getByRole("heading", { name: "Scheduling requests", exact: true });
const anchor = page.locator(".card-title-with-help");
await expect(title).toHaveCount(1);
await expectHelpBesideHeading(anchor);
await expect(anchor.getByRole("link")).toHaveAttribute("href", "/docs?type=user&topic=scheduling.find-and-decide-meeting-time");
await page.getByRole("button", { name: "Toggle configuration", exact: true }).click();
await expect(title).toHaveCount(1);
await expectHelpBesideHeading(anchor);
await expect(page.locator(".card-actions .documentation-help-link")).toHaveCount(0);
await page.getByRole("button", { name: "Toggle drag preview", exact: true }).click();
// A drag placeholder retains hidden content only to preserve the widget height.
const preview = page.locator(".dashboard-widget-placeholder-content");
await expect(preview.locator("h2")).toHaveCount(1);
await expect(preview.locator(".card-title-with-help .documentation-help-link")).toHaveAttribute("href", "/docs?type=user&topic=scheduling.find-and-decide-meeting-time");
await expect(preview).toBeHidden();
await page.getByRole("button", { name: "Toggle drag preview", exact: true }).click();
await expect(title).toHaveCount(1);
await expectHelpBesideHeading(anchor);
});
@@ -10,6 +10,7 @@ test("collapsed rail keeps visible group separators; opening settings is clean",
expect((await separator.boundingBox())!.width).toBeGreaterThan(20);
}
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
await expect(page.locator('[data-navigation-layout-status="inherited"]')).toContainText("standard layout groups available modules by product area");
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) };
@@ -72,9 +73,14 @@ test("no-op reordering stays clean and optional module positions survive other e
await handle.press("Space"); await handle.press("Enter");
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
await page.goto("/?navigation-layout&unavailable");
await expect(page.locator('[data-navigation-layout-status="flat"]')).toContainText("without group headings or dividers");
await expect(page.locator(".icon-rail").getByRole("separator")).toHaveCount(0);
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");
await page.getByRole("button", { name: "Use inherited layout", exact: true }).click();
await expect(page.locator(".icon-rail").getByRole("separator")).toHaveCount(2);
await expect(page.getByTestId("navigation-draft")).toHaveText("null");
});
test("German narrow editor does not overflow and read-only controls cannot mutate", async ({ page }) => {
+18 -16
View File
@@ -46,24 +46,26 @@
"test:auth-action-state": "node --test tests/auth-action-state.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:data-grid-actions": "node scripts/run-component-tests.mjs data-grid-actions",
"test:dialog-focus": "node scripts/run-component-tests.mjs dialog-focus",
"test:explorer-tree": "node scripts/run-component-tests.mjs explorer-tree",
"test:icon-button": "node scripts/run-component-tests.mjs icon-button",
"test:layout-primitives": "node scripts/run-component-tests.mjs layout-primitives",
"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",
"test:page-layout": "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/page-layout.test.js",
"test:workspace-layout": "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/workspace-layout.test.js",
"test:people-picker": "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/people-picker.test.js",
"test:password-field": "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/password-generator.test.js",
"test:resource-access": "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/resource-access-explanation.test.js",
"test:action-blocker": "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/action-blocker-hint.test.js",
"test:documentation-help": "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/documentation-help-link.test.js",
"test:selection-list": "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/selection-list.test.js",
"test:wysiwyg-editor": "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/wysiwyg-editor-utils.test.js"
"test:mail-components": "node scripts/run-component-tests.mjs mail-components",
"test:metric-card": "node scripts/run-component-tests.mjs metric-card",
"test:page-layout": "node scripts/run-component-tests.mjs page-layout",
"test:workspace-layout": "node scripts/run-component-tests.mjs workspace-layout",
"test:people-picker": "node scripts/run-component-tests.mjs people-picker",
"test:password-field": "node scripts/run-component-tests.mjs password-field",
"test:resource-access": "node scripts/run-component-tests.mjs resource-access",
"test:action-blocker": "node scripts/run-component-tests.mjs action-blocker",
"test:documentation-help": "node scripts/run-component-tests.mjs documentation-help",
"test:selection-list": "node scripts/run-component-tests.mjs selection-list",
"test:wysiwyg-editor": "node scripts/run-component-tests.mjs wysiwyg-editor",
"test:components": "node scripts/run-component-tests.mjs",
"test:component-runner": "node --test tests/component-test-runner.test.mjs"
},
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env node
// Compile the shared component contract once per invocation. Each invocation
// owns its output, so standalone aliases and concurrent agents cannot erase it.
import { spawn } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export const componentSuites = Object.freeze({
"data-grid-actions": ["data-grid-actions", "data-grid-sizing"],
"dialog-focus": ["dialog-focus"],
"explorer-tree": ["explorer-tree"],
"icon-button": ["icon-button"],
"layout-primitives": ["layout-primitives"],
"mail-components": ["mail-components"],
"metric-card": ["metric-card"],
"page-layout": ["page-layout"],
"workspace-layout": ["workspace-layout"],
"people-picker": ["people-picker"],
"password-field": ["password-generator"],
"resource-access": ["resource-access-explanation"],
"action-blocker": ["action-blocker-hint"],
"documentation-help": ["documentation-help-link"],
"selection-list": ["selection-list"],
"wysiwyg-editor": ["wysiwyg-editor-utils"],
});
export function selectSuites(names) {
const selected = [...new Set(names.filter((name) => name !== "--"))];
if (!selected.length || (selected.length === 1 && selected[0] === "all")) return Object.keys(componentSuites);
for (const name of selected) {
if (!Object.hasOwn(componentSuites, name)) throw new Error(`Unknown component suite: ${name}`);
}
return selected;
}
function execute(argv, { cwd, signal }) {
if (signal?.aborted) return Promise.reject(new Error("Component tests interrupted"));
return new Promise((resolveCommand, reject) => {
const child = spawn(argv[0], argv.slice(1), { cwd, stdio: "inherit", shell: false });
let killTimer;
const abort = () => {
child.kill("SIGTERM");
killTimer = setTimeout(() => child.kill("SIGKILL"), 5000);
killTimer.unref();
};
signal?.addEventListener("abort", abort, { once: true });
child.once("error", reject);
child.once("close", (code, childSignal) => {
signal?.removeEventListener("abort", abort);
clearTimeout(killTimer);
if (code === 0 && !signal?.aborted) resolveCommand();
else reject(new Error(`Component command failed (${childSignal ?? code}): ${argv.slice(1).join(" ")}`));
});
});
}
export async function runComponentTests({
names = [],
webuiRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."),
run = execute,
compiler,
signal,
} = {}) {
const selected = selectSuites(names);
const require = createRequire(join(webuiRoot, "package.json"));
const typescript = compiler ?? require.resolve("typescript/bin/tsc");
const output = mkdtempSync(join(webuiRoot, ".component-test-build-"));
try {
writeFileSync(join(output, "package.json"), '{"type":"commonjs"}\n');
await run([process.execPath, typescript, "-p", "tsconfig.component-tests.json", "--outDir", output], { cwd: webuiRoot, signal });
// The SSR tests intentionally do not load browser CSS.
mkdirSync(join(output, "src", "components"), { recursive: true });
writeFileSync(join(output, "src", "components", "ProductAvailabilityState.css"), "module.exports = {};\n");
for (const name of selected) {
for (const test of componentSuites[name]) {
await run([process.execPath, join(output, "tests", `${test}.test.js`)], { cwd: webuiRoot, signal });
}
if (name === "dialog-focus") {
await run([process.execPath, join(webuiRoot, "scripts", "test-dialog-focus-structure.mjs")], { cwd: webuiRoot, signal });
}
}
return { suites: selected, compiled: 1 };
} finally {
// Only remove this invocation's freshly-created directory, never the
// legacy shared build or another invocation's artifacts.
rmSync(output, { recursive: true, force: true });
}
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const controller = new AbortController();
let interrupted;
const stop = (signal) => { interrupted = signal; controller.abort(); };
const onInterrupt = () => stop("SIGINT");
const onTerminate = () => stop("SIGTERM");
process.on("SIGINT", onInterrupt);
process.on("SIGTERM", onTerminate);
try {
const result = await runComponentTests({ names: process.argv.slice(2), signal: controller.signal });
process.stdout.write(`Component suites passed: ${result.suites.length}; compilations: ${result.compiled}.\n`);
} catch (error) {
process.stderr.write(`${error.message}\n`);
process.exitCode = interrupted === "SIGINT" ? 130 : interrupted === "SIGTERM" ? 143 : 1;
} finally {
process.removeListener("SIGINT", onInterrupt);
process.removeListener("SIGTERM", onTerminate);
}
}
+4 -2
View File
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import AdvancedOptionsPanel from "./AdvancedOptionsPanel";
import DocumentationHelpLink from "./help/DocumentationHelpLink";
import type { DocumentationHelpReference } from "./help/documentationHelp";
import TextWithHelp from "./help/TextWithHelp";
export type ActionBlockerReason = {
summary: ReactNode;
@@ -46,7 +47,9 @@ export default function ActionBlockerHint({
<section className={joinClasses("action-blocker-hint", `tone-${tone}`, className)}>
<Icon className="action-blocker-icon" size={18} aria-hidden="true" />
<div className="action-blocker-copy">
<strong>{reason.summary}</strong>
<TextWithHelp help={documentation && <DocumentationHelpLink reference={documentation} />}>
<strong>{reason.summary}</strong>
</TextWithHelp>
{reason.details && <p>{reason.details}</p>}
{hasActionRows && (
<dl>
@@ -75,7 +78,6 @@ export default function ActionBlockerHint({
<div>{reason.technicalDetails}</div>
</AdvancedOptionsPanel>
)}
{documentation && <DocumentationHelpLink reference={documentation} />}
</div>
</section>
);
+6 -1
View File
@@ -2,9 +2,11 @@ import { useEffect, useState, type HTMLAttributes, type ReactNode } from "react"
import { ChevronDown } from "lucide-react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../types";
import TextWithHelp from "./help/TextWithHelp";
export type CardProps = PlatformInterfaceIdentityProps & Omit<HTMLAttributes<HTMLElement>, "children" | "title"> & {
title?: ReactNode;
titleHelp?: ReactNode;
children: ReactNode;
actions?: ReactNode;
afterBody?: ReactNode;
@@ -48,6 +50,7 @@ function writeCollapseState(storageKey: string | null, collapsed: boolean): void
export default function Card({
title,
titleHelp,
children,
actions,
afterBody,
@@ -99,7 +102,9 @@ export default function Card({
>
{hasHeader &&
<header className={["card-header", headerClassName].filter(Boolean).join(" ")}>
{title && (typeof title === "string" ? <h2>{translateText(title)}</h2> : <div className="card-title-node">{title}</div>)}
{title && <TextWithHelp as="div" className="card-title-with-help" data-help-anchor="title" help={titleHelp}>
{typeof title === "string" ? <h2>{translateText(title)}</h2> : <div className="card-title-node">{title}</div>}
</TextWithHelp>}
{(actions || collapsible) &&
<div className={["card-actions", actionsClassName].filter(Boolean).join(" ")}>
{actions}
@@ -450,11 +450,11 @@ export default function CredentialEnvelopeManager({
)}
<Card
title={title}
titleHelp={<DocumentationHelpLink reference={CREDENTIAL_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />}
helpContextId="access.credentials"
helpModuleId="access"
actions={
<div className="button-row compact-actions">
<DocumentationHelpLink reference={CREDENTIAL_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />
<Button
type="button"
title="Reload credentials"
+6 -1
View File
@@ -10,6 +10,7 @@ import {
} from "./dialogStack";
import type { PlatformInterfaceIdentityProps } from "../types";
import { DialogActions, type DialogActionAlignment } from "./DialogAnatomy";
import TextWithHelp from "./help/TextWithHelp";
export type DialogSize = "small" | "default" | "large" | "wide" | "full";
export type DialogVariant = "default" | "administration";
@@ -18,6 +19,7 @@ export type DialogBodyPadding = "none" | "compact" | "default";
export type DialogProps = PlatformInterfaceIdentityProps & {
open: boolean;
title: ReactNode;
titleHelp?: ReactNode;
children: ReactNode;
footer?: ReactNode;
description?: ReactNode;
@@ -51,6 +53,7 @@ function joinClasses(...classes: Array<string | undefined | false>) {
export default function Dialog({
open,
title,
titleHelp,
children,
footer,
description,
@@ -168,7 +171,9 @@ export default function Dialog({
aria-describedby={ariaDescribedBy}
>
<div className={joinClasses("dialog-header", headerClassName)}>
<h2 id={titleId} className={joinClasses("dialog-title", titleClassName)}>{renderedTitle}</h2>
<TextWithHelp as="div" className="dialog-title-with-help" data-help-anchor="title" help={titleHelp}>
<h2 id={titleId} className={joinClasses("dialog-title", titleClassName)}>{renderedTitle}</h2>
</TextWithHelp>
{showCloseButton && onClose && (
<button
type="button"
@@ -26,6 +26,7 @@ export default function NavigationPreferenceEditor({ items, productAreas = [], v
const instructionsId = useId();
const inherited = inheritedNavigationLayout(items, scope, productAreas);
const editable = materializeNavigationLayout(value, inherited);
const layoutStatus = value === null ? "inherited" : (editable.separators?.length ?? 0) > 0 ? "grouped" : "flat";
const byId = new Map(items.map((item) => [navigationId(item), item]));
const inheritedScope = scope === "system" ? "module" : scope === "tenant" ? "system" : "tenant";
const ancestorLocked = (id: string) => Boolean(byId.get(id)?.navigationLayers?.[inheritedScope]?.locked);
@@ -104,6 +105,9 @@ export default function NavigationPreferenceEditor({ items, productAreas = [], v
<p className="muted small-note" id={instructionsId}>{navigationText("help")}</p>
<Button onClick={() => onChange(null)} disabled={disabled || value === null}>{navigationText("inherit")}</Button>
</ActionToolbar>
<p className="muted small-note" data-navigation-layout-status={layoutStatus}>
{navigationText(`${layoutStatus}_status`)}
</p>
<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>}
+26 -7
View File
@@ -4,6 +4,8 @@ import type { PlatformInterfaceIdentityProps } from "../types";
import ActionToolbar, { ToolbarGroup, type ActionToolbarDensity, type ActionToolbarSurface } from "./ActionToolbar";
import Button from "./Button";
import { useUnsavedChanges } from "./UnsavedChangesContext";
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
import TextWithHelp from "./help/TextWithHelp";
export type PageReloadAction = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "onClick"> & PlatformInterfaceIdentityProps & {
onReload: () => void;
@@ -25,8 +27,13 @@ type RefreshablePageActions = {
};
type PageActionBarCommonProps = PlatformInterfaceIdentityProps &
Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
Omit<HTMLAttributes<HTMLDivElement>, "children" | "title"> & {
/** A visible context heading for workspaces without a separate page header. */
title?: ReactNode;
titleHelp?: ReactNode;
titleLevel?: 1 | 2 | 3;
contextActions?: ReactNode;
/** Non-documentation help actions; documentation belongs beside a title. */
helpAction?: ReactNode;
label?: string;
};
@@ -66,7 +73,8 @@ export type EditorPageActionBarProps = PageActionBarCommonProps & RefreshablePag
cleanDisabledReason?: ReactNode;
savingDisabledReason?: ReactNode;
invalidDisabledReason?: ReactNode;
discardAction: PageEditorAction;
/** Exit/cancel also works for a clean draft; reset/discard requires changes. */
discardAction: PageEditorAction & { behavior?: "reset" | "exit" };
saveAction: PageEditorAction;
primaryActions?: ReactNode;
destructiveActions?: ReactNode;
@@ -178,6 +186,7 @@ function EditorAction({
* actions, wording, permissions, blockers, and consequences placed in them.
*/
export default function PageActionBar(props: PageActionBarProps & SemanticActionBarPresentation) {
const { translateText } = usePlatformLanguage();
const normalizedProps = props as PageActionBarProps & SemanticActionBarPresentation & {
createAction?: ReactNode;
primaryActions?: ReactNode;
@@ -192,7 +201,7 @@ export default function PageActionBar(props: PageActionBarProps & SemanticAction
cleanDisabledReason?: ReactNode;
savingDisabledReason?: ReactNode;
invalidDisabledReason?: ReactNode;
discardAction?: PageEditorAction;
discardAction?: PageEditorAction & { behavior?: "reset" | "exit" };
saveAction?: PageEditorAction;
};
const {
@@ -202,6 +211,9 @@ export default function PageActionBar(props: PageActionBarProps & SemanticAction
surface,
refreshable = false,
reloadAction,
title,
titleHelp,
titleLevel = 2,
contextActions,
helpAction,
createAction,
@@ -227,6 +239,7 @@ export default function PageActionBar(props: PageActionBarProps & SemanticAction
helpTopicId,
...toolbarProps
} = normalizedProps;
const TitleElement = titleLevel === 1 ? "h1" : titleLevel === 3 ? "h3" : "h2";
let trailingActions: ReactNode;
if (variant === "overview") {
@@ -241,7 +254,10 @@ export default function PageActionBar(props: PageActionBarProps & SemanticAction
</>
);
} else if (variant === "editor") {
const discardDisabledReason = state === "saving" ? savingDisabledReason : state === "clean" ? cleanDisabledReason : undefined;
const { behavior: discardBehavior = "reset", ...discardButtonAction } = discardAction!;
const discardDisabledReason = state === "saving"
? savingDisabledReason
: state === "clean" && discardBehavior !== "exit" ? cleanDisabledReason : undefined;
const saveDisabledReason = state === "saving"
? savingDisabledReason
: state === "clean"
@@ -254,7 +270,7 @@ export default function PageActionBar(props: PageActionBarProps & SemanticAction
{primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null}
<DestructiveSlot>{destructiveActions}</DestructiveSlot>
<ActionSlot name="discard">
<EditorAction action={discardAction!} variant="ghost" disabledReason={discardDisabledReason} />
<EditorAction action={discardButtonAction} variant="ghost" disabledReason={discardDisabledReason} />
</ActionSlot>
<ActionSlot name="save">
<EditorAction action={saveAction!} variant="primary" disabledReason={saveDisabledReason} />
@@ -291,8 +307,11 @@ export default function PageActionBar(props: PageActionBarProps & SemanticAction
: undefined}
data-page-dirty={variant === "editor" ? (state === "clean" ? "false" : "true") : undefined}
>
{contextActions ? <ToolbarGroup className="page-action-bar-leading" data-page-action-group="leading">
<ActionSlot name="context">{contextActions}</ActionSlot>
{title || contextActions ? <ToolbarGroup className="page-action-bar-leading" data-page-action-group="leading">
{title && <TextWithHelp as="div" className="page-action-bar-title" data-help-anchor="title" help={titleHelp}>
<TitleElement>{translateReactNode(title, translateText)}</TitleElement>
</TextWithHelp>}
{contextActions ? <ActionSlot name="context">{contextActions}</ActionSlot> : null}
</ToolbarGroup> : null}
<ToolbarGroup className="page-action-bar-trailing" align="end" data-page-action-group="trailing">
{variant === "editor" ? (
+6 -1
View File
@@ -11,6 +11,7 @@ export type PageArchetype = "overview" | "collection" | "detail" | "editor" | "w
export type PageHeaderProps = {
title: ReactNode;
titleHelp?: ReactNode;
description?: ReactNode;
actions?: ReactNode;
loading?: boolean;
@@ -20,6 +21,7 @@ export type PageHeaderProps = {
export function PageHeader({
title,
titleHelp,
description,
actions,
loading = false,
@@ -38,7 +40,7 @@ export function PageHeader({
return (
<header className={classes}>
<div className="page-layout-heading-copy">
<PageTitle loading={loading}>{title}</PageTitle>
<PageTitle loading={loading} titleHelp={titleHelp}>{title}</PageTitle>
{description && (typeof description === "string"
? <p className="page-layout-description">{translateText(description)}</p>
: <div className="page-layout-description">{translateReactNode(description, translateText)}</div>)}
@@ -52,6 +54,7 @@ export type PageLayoutProps = PlatformInterfaceIdentityProps & {
/** Semantic page intent. This is independent from viewport/layout geometry. */
archetype: PageArchetype;
title: ReactNode;
titleHelp?: ReactNode;
description?: ReactNode;
actions?: ReactNode;
children: ReactNode;
@@ -75,6 +78,7 @@ export type PageLayoutProps = PlatformInterfaceIdentityProps & {
export default function PageLayout({
archetype,
title,
titleHelp,
description,
actions,
children,
@@ -123,6 +127,7 @@ export default function PageLayout({
{showHeader && (
<PageHeader
title={title}
titleHelp={titleHelp}
description={description}
actions={actions}
loading={headerLoading ?? loading}
+9 -5
View File
@@ -1,18 +1,22 @@
import LoadingIndicator from "./LoadingIndicator";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import TextWithHelp from "./help/TextWithHelp";
type PageTitleProps = {
children: React.ReactNode;
titleHelp?: React.ReactNode;
loading?: boolean;
};
export default function PageTitle({ children, loading = false }: PageTitleProps) {
export default function PageTitle({ children, titleHelp, loading = false }: PageTitleProps) {
const { translateText } = usePlatformLanguage();
const renderedChildren = typeof children === "string" ? translateText(children) : children;
return (
<h1 className="page-title-with-loader">
<span>{renderedChildren}</span>
<div className="page-title-with-loader">
<TextWithHelp as="div" data-help-anchor="title" help={titleHelp}>
<h1>{renderedChildren}</h1>
</TextWithHelp>
{loading && <LoadingIndicator label="i18n:govoplan-core.loading_page_data.85fe9edf" />}
</h1>);
</div>);
}
}
@@ -5,6 +5,7 @@ import PageLayout, { type PageArchetype } from "../PageLayout";
export type AdminPageLayoutProps = PlatformInterfaceIdentityProps & {
archetype?: PageArchetype;
title: string;
titleHelp?: ReactNode;
description: string;
loading?: boolean;
loadingLabel?: string;
@@ -18,6 +19,7 @@ export type AdminPageLayoutProps = PlatformInterfaceIdentityProps & {
export default function AdminPageLayout({
archetype = "detail",
title,
titleHelp,
description,
loading = false,
loadingLabel = "i18n:govoplan-core.loading_administration_data.643bd894",
@@ -35,6 +37,7 @@ export default function AdminPageLayout({
<PageLayout
archetype={archetype}
title={title}
titleHelp={titleHelp}
description={description}
loading={loading}
loadingLabel={loadingLabel}
+19
View File
@@ -0,0 +1,19 @@
import type { HTMLAttributes, ReactNode } from "react";
export type TextWithHelpProps = Omit<HTMLAttributes<HTMLElement>, "children"> & {
children: ReactNode;
help?: ReactNode;
/** Use a div when the text is a heading or another block element. */
as?: "span" | "div";
};
/** Keeps contextual help beside its visible text, outside its accessible name. */
export default function TextWithHelp({ children, help, as = "span", className = "", ...props }: TextWithHelpProps) {
const Element = as;
return (
<Element data-help-anchor="text" {...props} className={["text-with-help", className].filter(Boolean).join(" ")}>
{typeof children === "string" || typeof children === "number" ? <span>{children}</span> : children}
{help && <span className="text-with-help-help">{help}</span>}
</Element>
);
}
+3
View File
@@ -90,6 +90,9 @@ export type DataGridColumn<T> = {
* maxima only when that is required to consume the complete container.
*/
maxWidth?: number;
/** Automatic-fit preference only, not a manual resize limit. Cover fitting
* may exceed this to fill the container after other preferred widths. */
preferredMaxWidth?: number;
resizable?: boolean;
/** @deprecated Kept for source compatibility. Resize space is now distributed across resizable columns. */
fill?: boolean;
+32 -9
View File
@@ -3,6 +3,7 @@ export type DataGridSizingColumn = {
width?: number | string;
minWidth?: number;
maxWidth?: number;
preferredMaxWidth?: number;
resizable?: boolean;
/** @deprecated Retained for compatibility with older column definitions. */
fill?: boolean;
@@ -54,7 +55,8 @@ export function dataGridLayoutSignature(
column.sortable ? "sort" : "",
column.filterable ? "filter" : "",
column.columnType ?? "default",
column.sticky ?? ""
column.sticky ?? "",
...(column.preferredMaxWidth === undefined ? [] : [`preferred-max:${column.preferredMaxWidth}`])
].join(":")).join("|");
return `v3::${columnSignature}::${initialFit}::${resizeBehavior}`;
}
@@ -113,6 +115,18 @@ export function isFlexibleDataGridWidth(width?: string | number, fill = false):
|| minmaxParts(normalized) !== null;
}
/** Presentation-only automatic-fit ceiling; explicit user widths still use
* maxWidth. Cover may exceed preferred ceilings to avoid a blank filler. */
export function effectiveDataGridColumnPreferredMaxWidth(
column: DataGridSizingColumn,
minimum = effectiveDataGridColumnMinWidth(column)
): number {
const maximum = effectiveDataGridColumnMaxWidth(column, minimum);
return column.preferredMaxWidth !== undefined && Number.isFinite(column.preferredMaxWidth)
? Math.max(minimum, Math.min(maximum, column.preferredMaxWidth))
: maximum;
}
export function dataGridColumnResizeWeight(column: DataGridSizingColumn): number {
if (column.fill) return 1;
const width = column.width;
@@ -127,7 +141,7 @@ export function preferredDataGridColumnWidth(
measuredWidth?: number
): number {
const minimum = effectiveDataGridColumnMinWidth(column);
const maximum = effectiveDataGridColumnMaxWidth(column, minimum);
const maximum = effectiveDataGridColumnPreferredMaxWidth(column, minimum);
const width = column.width;
let preferred: number | null = null;
@@ -213,7 +227,7 @@ export function fitDataGridColumns(
.filter((column) => remaining >= 0
? dataGridColumnGrowthWeight(column) > 0
: isFlexibleDataGridWidth(column.width, column.fill) || Boolean(column.resizable))
.map((column) => resizeTargetForLayout(
.map((column) => fitTargetForLayout(
column,
widths,
remaining >= 0 ? dataGridColumnGrowthWeight(column) : dataGridColumnResizeWeight(column)
@@ -224,14 +238,14 @@ export function fitDataGridColumns(
widths,
remaining,
responsiveColumns.map((column) =>
resizeTargetForLayout(column, widths, dataGridColumnGrowthWeight(column))
fitTargetForLayout(column, widths, dataGridColumnGrowthWeight(column))
)
);
remaining = applyDataGridDistribution(
widths,
remaining,
automaticColumns.map((column) =>
resizeTargetForLayout(column, widths, widths[column.id])
fitTargetForLayout(column, widths, widths[column.id])
)
);
// Cover is a stronger invariant than maxWidth. Once preferred maxima have
@@ -240,7 +254,7 @@ export function fitDataGridColumns(
widths,
remaining,
coverageColumns.map((column) =>
resizeTargetForLayout(column, widths, widths[column.id], DATA_GRID_MAX_TRACK_WIDTH)
fitTargetForLayout(column, widths, widths[column.id], DATA_GRID_MAX_TRACK_WIDTH)
)
);
} else if (remaining < -0.01) {
@@ -249,13 +263,13 @@ export function fitDataGridColumns(
remaining,
responsiveColumns
.filter((column) => column.resizable || isFlexibleDataGridWidth(column.width, column.fill))
.map((column) => resizeTargetForLayout(column, widths, widths[column.id]))
.map((column) => fitTargetForLayout(column, widths, widths[column.id]))
);
remaining = applyDataGridDistribution(
widths,
remaining,
automaticColumns.map((column) =>
resizeTargetForLayout(column, widths, widths[column.id])
fitTargetForLayout(column, widths, widths[column.id])
)
);
if (responsiveUserLayout && remaining < -0.01) {
@@ -264,7 +278,7 @@ export function fitDataGridColumns(
remaining,
coverageColumns
.filter((column) => userWidths[column.id] !== undefined)
.map((column) => resizeTargetForLayout(column, widths, widths[column.id]))
.map((column) => fitTargetForLayout(column, widths, widths[column.id]))
);
}
// At the container where it was chosen, an explicit user layout remains
@@ -499,6 +513,15 @@ function resizeTargetForLayout(
};
}
function fitTargetForLayout(
column: DataGridSizingColumn,
widths: Record<string, number>,
weight: number,
maximum = effectiveDataGridColumnPreferredMaxWidth(column)
): DataGridResizeTarget {
return resizeTargetForLayout(column, widths, weight, maximum);
}
function applyDataGridDistribution(
widths: Record<string, number>,
requestedAmount: number,
@@ -148,10 +148,10 @@ export function RetentionPolicyScopeManager({
<div className="retention-policy-manager">
{targetSelectionRequired &&
<Card
title={i18nMessage("i18n:govoplan-core.value_scope", { value0: targetLabel })}
title={i18nMessage("i18n:govoplan-core.value_scope", { value0: targetLabel })} titleHelp={<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />}
helpContextId="policy.retention.target"
helpModuleId="policy"
actions={<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />}
>
<div className="retention-policy-target-row">
<FormField label={targetLabel} helpContextId="policy.retention.target" helpModuleId="policy">
@@ -344,17 +344,17 @@ export function RetentionPolicyEditor({
return (
<Card
title={title}
title={title} titleHelp={<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />}
helpContextId="policy.retention"
helpModuleId="policy"
actions={
<div className="button-row compact-actions">
<DocumentationHelpLink reference={RETENTION_DOCUMENTATION} label="i18n:govoplan-core.open_admin_documentation.6adbdae3" />
<Button helpContextId="policy.retention.action.reload" helpModuleId="policy" onClick={() => void loadPolicy()} disabled={Boolean(reloadDisabledReason)} disabledReason={reloadDisabledReason}>{loading ? "i18n:govoplan-core.loading.33ce4174" : "i18n:govoplan-core.reload.cce71553"}</Button>
<Button helpContextId="policy.retention.action.save" helpModuleId="policy" variant="primary" onClick={() => void savePolicy()} disabled={Boolean(saveDisabledReason)} disabledReason={saveDisabledReason}>{busy ? "i18n:govoplan-core.saving.56a2285c" : "i18n:govoplan-core.save_policy.77d67ce3"}</Button>
</div>
}>
<LoadingFrame loading={loading} label="i18n:govoplan-core.loading_retention_policy.dcd30fb6">
<div className="retention-policy-editor">
<p className="muted small-note retention-policy-description">{description ?? defaultDescription}</p>
+9 -9
View File
@@ -360,13 +360,13 @@ export default function SettingsPage({
>
<PageLayout
archetype={editorSection ? "editor" : "workspace"}
title="i18n:govoplan-core.settings.c7f73bb5"
title="i18n:govoplan-core.settings.c7f73bb5" titleHelp={<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />}
description="i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56"
actions={editorSection ? (
<PageActionBar
variant="editor"
state={editorSaving ? "saving" : editorDirty ? "dirty" : "clean"}
helpAction={<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />}
discardAction={{ label: "i18n:govoplan-core.discard.36fff63c", onClick: discardEditor }}
saveAction={{
label: active === "profile" ? "i18n:govoplan-core.save_profile.f597c0e8" : "i18n:govoplan-core.save_preferences.0f1a7e44",
@@ -374,7 +374,7 @@ export default function SettingsPage({
}}
/>
) : (
<PageActionBar variant="workspace" helpAction={<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />} />
<PageActionBar variant="workspace" />
)}
mode="workspace"
>
@@ -454,19 +454,19 @@ export default function SettingsPage({
help="i18n:govoplan-core.prepared_ui_preference_for_denser_tables_the_cur.45698d83"
checked={compactTables}
onChange={setCompactTables} />
<ToggleSwitch
label="i18n:govoplan-core.show_inline_help_hints.47cf5aaa"
help="i18n:govoplan-core.controls_contextual_ui_help_markers_once_persist.0eaef9c4"
checked={showHelpHints}
onChange={setShowHelpHints} />
<ToggleSwitch
label="i18n:govoplan-core.reduce_motion.25a5aef5"
help="i18n:govoplan-core.prepared_preference_for_users_who_prefer_fewer_a.b288e8ab"
checked={reduceMotion}
onChange={setReduceMotion} />
{uiResult && <DismissibleAlert tone={uiResultTone} resetKey={uiResult} floating>{uiResult}</DismissibleAlert>}
</FormGrid>
</Card>
@@ -534,14 +534,14 @@ export default function SettingsPage({
help="i18n:govoplan-core.keeps_campaign_and_admin_section_navigation_in_v.f3602938"
checked={stickySections}
onChange={setStickySections} />
<ToggleSwitch
label="i18n:govoplan-core.keep_page_shell_visible_while_loading.17fc142a"
help="i18n:govoplan-core.the_current_ui_already_keeps_the_section_shell_v.c9bbc227"
checked
disabled
onChange={() => undefined} />
{uiResult && <DismissibleAlert tone={uiResultTone} resetKey={uiResult} floating>{uiResult}</DismissibleAlert>}
</FormGrid>
</Card>
@@ -580,7 +580,7 @@ export default function SettingsPage({
value={settings.apiKey}
autoComplete="off"
onValueChange={(apiKey) => onSettingsChange({ ...settings, apiKey })} />
</FormField>
<div className="button-row compact-actions">
<Button
+2
View File
@@ -2,6 +2,7 @@ import type { PlatformTranslations } from "../types";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-core.operator_queue.72492fb5": "Operator queue",
"i18n:govoplan-core.required_account_action_unavailable": "A required account action must be completed before you can continue. The account module is loading or unavailable. If this persists, contact your administrator or sign out.",
"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.",
@@ -742,6 +743,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.temporal_selection_invalid": "The selected data state is invalid."
},
"de": {
"i18n:govoplan-core.operator_queue.72492fb5": "Operator-Warteschlange",
"i18n:govoplan-core.required_account_action_unavailable": "Bevor Sie fortfahren können, müssen Sie eine erforderliche Kontoaktion abschließen. Das Kontomodul wird geladen oder ist nicht verfügbar. Wenden Sie sich bei anhaltenden Problemen an die Administration oder melden Sie sich ab.",
"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.",
@@ -3,6 +3,9 @@ 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",
"inherited_status": "No override at this level. The standard layout groups available modules by product area; system and tenant defaults may customize it. Group headings become horizontal lines when collapsed.",
"grouped_status": "Custom layout with group separators. Use inherited layout to remove this override and restore the current defaults; Save applies the change.",
"flat_status": "Custom layout without group headings or dividers. Add a separator to create a group, or use inherited layout to restore the current defaults; Save applies the change.",
"available": "Available modules",
"all_added": "All available modules are included",
"add_module": "Add module",
@@ -25,6 +28,9 @@ export const navigationEditorTranslations: Record<string, Record<string, string>
"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",
"inherited_status": "Keine eigene Anordnung auf dieser Ebene. Die Standardanordnung gruppiert verfügbare Module nach Produktbereich; System- und Mandantenvorgaben können sie anpassen. Eingeklappt werden Gruppenüberschriften zu horizontalen Linien.",
"grouped_status": "Eigene Anordnung mit Gruppentrennern. Mit „Geerbte Anordnung verwenden“ entfernen Sie diese Anpassung und stellen die aktuellen Vorgaben wieder her; Speichern übernimmt die Änderung.",
"flat_status": "Eigene Anordnung ohne Gruppenüberschriften oder Trennlinien. Fügen Sie eine Trennlinie für eine Gruppe hinzu oder stellen Sie mit „Geerbte Anordnung verwenden“ die aktuellen Vorgaben wieder her; Speichern übernimmt die Änderung.",
"available": "Verfügbare Module",
"all_added": "Alle verfügbaren Module sind enthalten",
"add_module": "Modul hinzufügen",
+2
View File
@@ -240,6 +240,8 @@ export { default as EmailAddressInput } from "./components/email/EmailAddressInp
export { default as MailServerSettingsPanel, MailImapFolderMappingsEditor, MailServerActionResult, MailServerFolderLookupResultView, defaultImapPort, defaultSmtpPort, hasMailImapSettings, mailImapFolderMappingKeys, mailImapSettingsPayload, mailNumberOrDefault, mailNumberOrNull, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mailTransportCredentialsPayloadFromRecords, normalizeMailImapFolderMappings, normalizeMailServerSecurity } from "./components/mail/MailServerSettingsPanel";
export type { MailServerConnectionTestResult, MailServerCredentialSettings, MailServerFolderLookupResult, MailServerImapSettings, MailServerSecurity, MailServerSecurityOption, MailServerSettingsMode, MailServerSettingsPanelProps, MailServerSettingsSection, MailServerSmtpSettings } from "./components/mail/MailServerSettingsPanel";
export { default as FieldLabel } from "./components/help/FieldLabel";
export { default as TextWithHelp } from "./components/help/TextWithHelp";
export type { TextWithHelpProps } from "./components/help/TextWithHelp";
export { default as DocumentationHelpLink, DocumentationHelpProvider } from "./components/help/DocumentationHelpLink";
export { documentationHelpHref } from "./components/help/documentationHelp";
export type { DocumentationHelpReference } from "./components/help/documentationHelp";
+8 -1
View File
@@ -100,12 +100,19 @@ const topLevelRouteLabels: Record<string, string> = {
files: "i18n:govoplan-core.files.6ce6c512",
"address-book": "i18n:govoplan-core.address_book.f6327f59",
reports: "i18n:govoplan-core.reports.88bc3fe3",
operator: "i18n:govoplan-core.operator_queue.72492fb5",
settings: "i18n:govoplan-core.settings.c7f73bb5",
admin: "i18n:govoplan-core.admin.4e7afebc"
};
function labelFor(value: string, parts: string[], index: number): string {
if (parts[0] === "campaigns" && index === 1) return "i18n:govoplan-core.campaign.69390e16";
if (parts[0] === "campaigns" && index === 1) {
// These exact collection routes are module views, not campaign identifiers.
// A deeper campaign editor still uses its singular Campaign > Report label.
if (parts.length === 2 && value === "reports") return "i18n:govoplan-core.reports.88bc3fe3";
if (parts.length === 2 && value === "queue") return "i18n:govoplan-core.operator_queue.72492fb5";
return "i18n:govoplan-core.campaign.69390e16";
}
if (parts[0] === "campaigns" && index >= 2) {
const mapped = campaignRouteLabels[value];
if (mapped) return mapped;
+11 -1
View File
@@ -81,6 +81,16 @@ export function groupNavigationItems(
left.order - right.order ||
left.label.localeCompare(right.label)
);
// Keep an entry's declared area when known; use composed-owner aliases only
// when the placement owner does not have a direct area contribution.
const areaByItem = new Map(items.map((item) => {
const surfaceId = item.surfaceId;
const directArea = surfaceId
? areas.find((area) => area.surfaceIds.has(surfaceId))
: undefined;
const aliases = navigationItemAliases(item);
return [item, directArea ?? areas.find((area) => aliases.some((id) => area.surfaceIds.has(id)))] as const;
}));
const assigned = new Set<string>();
const groups: NavigationGroup[] = [];
const pinned = items.filter((item) => item.to === "/dashboard");
@@ -91,7 +101,7 @@ export function groupNavigationItems(
const areaItems = items.filter(
(item) =>
!assigned.has(item.to) &&
Boolean(item.surfaceId && area.surfaceIds.has(item.surfaceId))
areaByItem.get(item)?.id === area.id
);
if (!areaItems.length) continue;
areaItems.forEach((item) => assigned.add(item.to));
+38
View File
@@ -3,6 +3,44 @@
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
max-width: 100%;
}
.page-title-with-loader > .loading-indicator {
flex: 0 0 auto;
}
.text-with-help {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 100%;
}
.text-with-help > :first-child {
min-width: 0;
overflow-wrap: anywhere;
}
.text-with-help-help {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
gap: 6px;
font-size: 13px;
font-weight: normal;
line-height: 1;
}
.text-with-help-help:empty {
display: none;
}
.card-title-with-help,
.dialog-title-with-help {
min-width: 0;
}
.page-action-bar-title > :is(h1, h2, h3) {
margin: 0;
color: var(--text-strong);
font-size: 1rem;
font-weight: 600;
}
.loading-indicator {
display: inline-flex;
+1 -18
View File
@@ -263,24 +263,7 @@
.card-header { min-height: 56px; padding: 0 24px; border-bottom: var(--border-line); display: flex; align-items: center; background: var(--panel-header); border-top-left-radius: var(--radius); border-top-right-radius: var(--radius); }
.card-header h2 { margin: 0; font-size: 16px; color: var(--text-strong); }
.card-actions { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap;}
.card-body { padding: 22px 24px; }
/* Table surfaces have an explicit inset contract, independent of child count. */
.card-body.card-body-table { min-width: 0; padding: 0; }
.card-body-table > :is(.data-grid-shell, .admin-table-surface, .connection-tree),
.card-body-table > .loading-frame > :is(.data-grid-shell, .admin-table-surface, .connection-tree) {
width: 100%;
max-width: 100%;
margin: 0;
border: 0;
border-radius: 0;
box-shadow: none;
}
.card-body-table .admin-table-surface > .data-grid-shell {
border: 0;
border-radius: 0;
box-shadow: none;
}
.card-body { min-width: 0; padding: 22px 24px; }
.metric-group-layout {
--metric-group-column-minimum: 140px;
min-width: 0;
+26 -20
View File
@@ -168,37 +168,43 @@
box-shadow: var(--shadow-card);
}
.card-body:not(.card-body-table) > .admin-table-surface:only-child {
margin: -22px -24px;
width: calc(100% + 48px);
max-width: inherit;
/* The card owns its insets; a table never grows beyond its measured container.
Expanding a child with calc(100% + 48px) while retaining max-width: 100%
clips the expansion and leaves a gap. Remove the parent's padding instead.
Explicit table bodies also allow padded notices/pagination alongside a grid.
Legacy table-only bodies retain edge-to-edge layout through LoadingFrame;
its out-of-flow overlay must not change geometry when loading starts. */
.card-body.card-body-table,
.card-body:has(> :is(.data-grid-shell, .admin-table-surface, .connection-tree):only-child),
.card-body:has(> .loading-frame:only-child > :is(.data-grid-shell, .admin-table-surface, .connection-tree)):not(:has(> .loading-frame > :not(.data-grid-shell, .admin-table-surface, .connection-tree, .loading-frame-overlay))) {
padding: 0;
}
.card-body > .admin-table-surface:only-child > .data-grid-shell {
.card-body-table > :is(.data-grid-shell, .admin-table-surface, .connection-tree),
.card-body-table > .loading-frame > :is(.data-grid-shell, .admin-table-surface, .connection-tree),
.card-body > :is(.data-grid-shell, .admin-table-surface, .connection-tree):only-child,
.card-body > .loading-frame:only-child:not(:has(> :not(.data-grid-shell, .admin-table-surface, .connection-tree, .loading-frame-overlay))) > :is(.data-grid-shell, .admin-table-surface, .connection-tree) {
width: 100%;
max-width: 100%;
min-width: 0;
margin: 0;
border: 0;
border-radius: 0;
box-shadow: none;
}
.card-body:not(.card-body-table) > .data-grid-shell:only-child {
margin: -22px -24px;
width: calc(100% + 48px);
.card-body-table .admin-table-surface > .data-grid-shell,
.card-body > .admin-table-surface:only-child > .data-grid-shell,
.card-body > .loading-frame:only-child:not(:has(> :not(.admin-table-surface, .loading-frame-overlay))) > .admin-table-surface > .data-grid-shell {
border: 0;
border-radius: 0;
box-shadow: none;
}
.card-body:not(.card-body-table) > .connection-tree:only-child {
margin: -22px -24px;
width: calc(100% + 48px);
border: 0;
border-radius: 0;
}
.card-body:not(.card-body-table) > .loading-frame:only-child > .connection-tree:only-child {
margin: -22px -24px;
width: calc(100% + 48px);
border: 0;
border-radius: 0;
.card-body-table > .loading-frame > .loading-frame-overlay,
.card-body > .loading-frame:only-child:has(> :is(.data-grid-shell, .admin-table-surface, .connection-tree)):not(:has(> :not(.data-grid-shell, .admin-table-surface, .connection-tree, .loading-frame-overlay))) > .loading-frame-overlay {
/* A table body has no spare inset for LoadingFrame's content-mode bleed. */
margin: 0;
}
.data-grid-container {
+3
View File
@@ -1,4 +1,5 @@
import type { ComponentType, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
import type { DocumentationHelpReference } from "./components/help/documentationHelp";
export type ApiSettings = {
apiBaseUrl: string;
@@ -805,6 +806,8 @@ export type DashboardWidgetRenderContext = PlatformRouteContext & {
export type DashboardWidgetContribution = {
id: string;
title: string;
/** Module-owned contextual documentation displayed beside the widget title. */
documentation?: DocumentationHelpReference;
description?: string;
moduleId?: string;
category?: string;
+112
View File
@@ -0,0 +1,112 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import test from "node:test";
import vm from "node:vm";
const require = createRequire(import.meta.url);
const { transformSync } = require("esbuild");
const React = require("react");
const { renderToStaticMarkup } = require("react-dom/server");
const { MemoryRouter } = require("react-router");
function loadSource(path, bindings = {}) {
const context = vm.createContext({ module: { exports: {} }, require, ...bindings });
context.exports = context.module.exports;
vm.runInContext(transformSync(readFileSync(new URL(path, import.meta.url), "utf8"), {
loader: path.endsWith(".tsx") ? "tsx" : "ts", format: "cjs", jsx: "automatic"
}).code, context);
return { exports: context.module.exports, context };
}
const { generatedTranslations } = loadSource("../src/i18n/generatedTranslations.ts").exports;
const launch = loadSource("../src/platform/launchContext.ts").exports;
function harness(locale = "en") {
const navigation = [];
const translateText = (value) => {
if (typeof value === "string") return generatedTranslations[locale][value] ?? value;
let text = generatedTranslations[locale][value.key] ?? value.key;
for (const [key, replacement] of Object.entries(value.values)) {
text = text.replaceAll(`{${key}}`, replacement);
}
return text;
};
const loaded = loadSource("../src/layout/BreadcrumbBar.tsx", {
window: { history: { state: { idx: 7 } } },
require: (name) => {
if (name === "../i18n/LanguageContext") return {
usePlatformLanguage: () => ({ translateText }),
i18nMessage: (key, values) => ({ key, values })
};
if (name === "../components/UnsavedChangesGuard") return {
useGuardedNavigate: () => (...args) => navigation.push(args)
};
if (name === "../platform/launchContext") return launch;
return require(name);
}
});
const BreadcrumbBar = loaded.exports.default;
return {
...loaded, navigation, BreadcrumbBar,
links(pathname, search = "") {
const html = renderToStaticMarkup(React.createElement(
MemoryRouter, { initialEntries: [`${pathname}${search}`] },
React.createElement(BreadcrumbBar, { pathname })
));
return [...html.matchAll(/<a\b([^>]*)>(.*?)<\/a>/g)].map(([, attributes, label]) => ({
href: /href="([^"]*)"/.exec(attributes)?.[1], label: label.replaceAll("&amp;", "&")
}));
}
};
}
test("module-global reports and operator queue have their own bilingual labels and exact links", () => {
for (const locale of ["en", "de"]) {
const h = harness(locale);
const root = generatedTranslations[locale]["i18n:govoplan-core.campaigns.01a23a28"];
for (const [route, key] of [
["reports", "i18n:govoplan-core.reports.88bc3fe3"],
["queue", "i18n:govoplan-core.operator_queue.72492fb5"]
]) {
for (const trailingSlash of ["", "/"]) {
assert.deepEqual(h.links(`/campaigns/${route}${trailingSlash}`, "?campaign=selected-campaign"), [
{ href: "/campaigns", label: root },
{ href: `/campaigns/${route}`, label: generatedTranslations[locale][key] }
]);
}
}
assert.equal(h.links("/operator")[0].label, generatedTranslations[locale]["i18n:govoplan-core.operator_queue.72492fb5"]);
}
});
test("campaign editor report aliases and attachment deep links retain their singular campaign context", () => {
const h = harness();
const campaignId = "8ef65230-94d1-49a5-93a9-c5ef7c87ad45";
for (const suffix of ["report", "reports", "attachments"]) {
const links = h.links(`/campaigns/${campaignId}/${suffix}`);
assert.deepEqual(links.map((item) => item.href), ["/campaigns", `/campaigns/${campaignId}`, `/campaigns/${campaignId}/${suffix}`]);
assert.equal(links[1].label, generatedTranslations.en["i18n:govoplan-core.campaign.69390e16"]);
assert.equal(links[2].label, generatedTranslations.en[suffix === "attachments"
? "i18n:govoplan-core.attachments.6771ade6" : "i18n:govoplan-core.report.ee45c303"]);
}
});
test("quick-access return preserves guarded history back or the exact origin deep link", () => {
const origin = launch.createQuickAccessLaunchContext({
pathname: "/cases/case-fixture", search: "?tab=work", hash: "#campaigns", historyIndex: 6,
auth: { user: { account_id: "account-fixture" }, tenant: { id: "tenant-fixture" } },
temporalContext: { validityMode: "current" }
});
const h = harness();
for (const historyIndex of [7, 20]) {
h.context.window.history.state.idx = historyIndex;
const tree = h.BreadcrumbBar({ pathname: "/campaigns/reports", locationState: launch.quickAccessLaunchState(origin) });
const button = React.Children.toArray(tree.props.children).find((item) => item.type === "button");
assert.ok(button, "the launch return action remains visible");
button.props.onClick();
}
assert.deepEqual(h.navigation[0], [-1]);
assert.equal(h.navigation[1][0], "/cases/case-fixture?tab=work#campaigns");
assert.equal(h.navigation[1][1].replace, true);
});
+57
View File
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { componentSuites, runComponentTests, selectSuites } from "../scripts/run-component-tests.mjs";
test("component aliases select the existing contract and reject unknown input", () => {
assert.deepEqual(selectSuites([]), Object.keys(componentSuites));
assert.deepEqual(selectSuites(["page-layout", "page-layout"]), ["page-layout"]);
assert.deepEqual(componentSuites["data-grid-actions"], ["data-grid-actions", "data-grid-sizing"]);
assert.throws(() => selectSuites(["../../unowned"]), /Unknown component suite/);
});
test("every configured component test remains covered by the batch and standalone aliases", () => {
const config = JSON.parse(readFileSync(new URL("../tsconfig.component-tests.json", import.meta.url), "utf8"));
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
const configured = config.include.filter((file) => file.startsWith("tests/")).map((file) => file.replace(/^tests\//, "").replace(/\.test\.tsx?$/, "")).sort();
assert.deepEqual(Object.values(componentSuites).flat().sort(), configured);
for (const name of Object.keys(componentSuites)) {
assert.equal(packageJson.scripts[`test:${name}`], `node scripts/run-component-tests.mjs ${name}`);
}
assert.equal(packageJson.scripts["test:components"], "node scripts/run-component-tests.mjs");
});
test("one compile serves a batch and preserves structural follow-ups", async () => {
const root = mkdtempSync(join(tmpdir(), "govoplan-component-runner-"));
const commands = [];
try {
const result = await runComponentTests({ webuiRoot: root, compiler: "fixture-tsc", names: ["data-grid-actions", "dialog-focus"], run: async (argv) => commands.push(argv) });
assert.equal(result.compiled, 1);
assert.equal(commands.filter((argv) => argv[1] === "fixture-tsc").length, 1);
assert.equal(commands.length, 5);
assert(commands.at(-1)[1].endsWith("test-dialog-focus-structure.mjs"));
assert.deepEqual(readdirSync(root), []);
} finally { rmSync(root, { recursive: true, force: true }); }
});
test("overlapping runs have separate outputs and failures clean only owned output", async () => {
const root = mkdtempSync(join(tmpdir(), "govoplan-component-runner-"));
const outputs = [];
let unblock;
const bothStarted = new Promise((resolve) => { unblock = resolve; });
const run = async (argv) => {
if (argv[1] !== "fixture-tsc") return;
outputs.push(argv.at(-1));
if (outputs.length === 2) unblock();
await bothStarted;
throw new Error("fixture compilation failed");
};
try {
const results = await Promise.allSettled([1, 2].map(() => runComponentTests({ webuiRoot: root, compiler: "fixture-tsc", names: ["page-layout"], run })));
assert(results.every((result) => result.status === "rejected"));
assert.equal(new Set(outputs).size, 2);
assert.deepEqual(readdirSync(root), []);
} finally { rmSync(root, { recursive: true, force: true }); }
});
+71
View File
@@ -342,6 +342,77 @@ assertWidths(
"growth does not resize a fixed peer merely to avoid overflow"
);
const mixedPeerColumns: DataGridSizingColumn[] = [
{ id: "first", width: 200, minWidth: 100, maxWidth: 500, resizable: true },
{ id: "fixed", width: 120, minWidth: 120, maxWidth: 120 },
{ id: "last", width: 300, minWidth: 100, maxWidth: 500, resizable: true },
{ id: "actions", width: 100, sticky: "end" }
];
const mixedPeerBase = { first: 200, fixed: 120, last: 300, actions: 100 };
for (const mode of ["cover", "free", "constrained"] as const) {
const grown = resizeDataGridColumn(mixedPeerColumns, mixedPeerBase, "first", 80, mode);
assertWidths(grown.widths,
{ first: 280, fixed: 120, last: mode === "constrained" ? 220 : 300, actions: 100 },
`${mode} growth crosses a fixed intermediate column without changing it`);
const shrunk = resizeDataGridColumn(mixedPeerColumns, mixedPeerBase, "first", -80, mode);
assertWidths(shrunk.widths,
{ first: 120, fixed: 120, last: mode === "free" ? 300 : 380, actions: 100 },
`${mode} shrink uses only the appropriate resizable compensation peers`);
const limited = resizeDataGridColumn(mixedPeerColumns, mixedPeerBase, "first", 800, mode);
assertEqual(limited.widths.fixed, 120, `${mode} exhaustion never changes the fixed intermediate track`);
assertEqual(limited.widths.first, mode === "constrained" ? 400 : 500, `${mode} retains declared growth limits`);
}
const preferredLimitColumns: DataGridSizingColumn[] = [
{ id: "recipients", width: "minmax(320px, 1.4fr)", preferredMaxWidth: 640, resizable: true },
{ id: "active", width: 130 },
{ id: "delivery", width: "minmax(260px, 0.9fr)", preferredMaxWidth: 480, resizable: true },
{ id: "attachments", width: 180 },
...["first", "second", "third"].map((id) => ({ id, width: 190, minWidth: 160, preferredMaxWidth: 360, resizable: true })),
{ id: "actions", width: 180, sticky: "end" }
];
const hardLimitColumns = preferredLimitColumns.map(({ preferredMaxWidth, ...column }) => ({ ...column, maxWidth: preferredMaxWidth }));
for (const container of [900, 1600, 2400, 3085]) {
assertWidths(fitDataGridColumns(preferredLimitColumns, container).widths,
fitDataGridColumns(hardLimitColumns, container).widths,
`preferred caps preserve the previous automatic fit at ${container}px without imposing manual caps`);
}
const rightPeersAtOldCaps: DataGridSizingColumn[] = [
{ id: "recipients", width: "minmax(320px, 1.4fr)", preferredMaxWidth: 640, resizable: true },
{ id: "fixed", width: 130, minWidth: 130, maxWidth: 130 },
{ id: "first", width: 190, preferredMaxWidth: 360, resizable: true },
{ id: "second", width: 190, preferredMaxWidth: 360, resizable: true },
{ id: "actions", width: 180, sticky: "end" }
];
const oversizedRecipient = { recipients: 1000, fixed: 130, first: 360, second: 360, actions: 180 };
const oldManualCaps = rightPeersAtOldCaps.map(({ preferredMaxWidth, ...column }) => ({
...column, maxWidth: preferredMaxWidth ?? column.maxWidth
}));
assertEqual(resizeDataGridColumn(oldManualCaps, oversizedRecipient, "recipients", -120, "cover", 0).appliedDelta, 0,
"regression: at the scrolled-right boundary, old field caps block shrinking an oversized recipient column");
const shrunkAcrossPreferredCaps = resizeDataGridColumn(rightPeersAtOldCaps, oversizedRecipient, "recipients", -120, "cover", 0);
assertWidths(shrunkAcrossPreferredCaps.widths,
{ recipients: 880, fixed: 130, first: 420, second: 420, actions: 180 },
"preferred field caps allow cover compensation at the right scroll boundary without moving fixed neighbors");
assertWidths(fitDataGridColumns(rightPeersAtOldCaps, 2030, {}, shrunkAcrossPreferredCaps.widths, "cover", 2030).widths,
shrunkAcrossPreferredCaps.widths, "committing a compensated shrink never refills the wide recipient column");
for (const mode of ["cover", "free", "constrained"] as const) {
const expandedField = resizeDataGridColumn(rightPeersAtOldCaps, oversizedRecipient, "first", 160, mode);
assertEqual(expandedField.widths.first, 520, `${mode} direct resizing can exceed a presentation-only field cap`);
assertEqual(expandedField.widths.fixed, 130, `${mode} preferred caps do not change a fixed neighbor`);
}
const preferredWithHardLimit: DataGridSizingColumn[] = [{ id: "text", width: 300, preferredMaxWidth: 360, maxWidth: 500, resizable: true }];
assertEqual(resizeDataGridColumn(preferredWithHardLimit, { text: 360 }, "text", 200, "cover").widths.text, 500,
"an explicitly declared hard maximum still bounds direct resizing beyond a preferred cap");
assertEqual(fitDataGridColumns([{ id: "text", width: 500, preferredMaxWidth: 400, maxWidth: 300 }], 300, {}, {}, "free").widths.text, 300,
"a preferred cap never overrides a smaller hard maximum");
assertEqual(fitDataGridColumns([{ id: "text", width: 300, minWidth: 160, preferredMaxWidth: 100 }], 160, {}, {}, "free").widths.text, 160,
"preferred caps never reduce a column below its hard minimum");
assertEqual(dataGridLayoutSignature([{ id: "text", width: 190, resizable: true }], "container", "cover"),
"v3::text:190:::r::::default:::container::cover", "omitting preferred caps preserves the existing persisted signature format");
assertEqual(dataGridLayoutSignature(preferredLimitColumns, "container", "cover") === dataGridLayoutSignature(hardLimitColumns, "container", "cover"), false,
"new preferred sizing declarations invalidate only their own obsolete manual-width contract");
const coverBeyondPassiveMax = resizeDataGridColumn([
{ id: "first", width: 200, minWidth: 100, maxWidth: 200, resizable: true },
{ id: "second", width: 300, minWidth: 100, maxWidth: 300, resizable: true },
@@ -6,6 +6,14 @@ import { renderToStaticMarkup } from "react-dom/server";
import DocumentationHelpLink, { DocumentationHelpProvider } from "../src/components/help/DocumentationHelpLink";
import FieldLabel from "../src/components/help/FieldLabel";
import { documentationHelpHref } from "../src/components/help/documentationHelp";
import PageLayout from "../src/components/PageLayout";
import PageTitle from "../src/components/PageTitle";
import Card from "../src/components/Card";
import Dialog from "../src/components/Dialog";
import AdminPageLayout from "../src/components/admin/AdminPageLayout";
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
import TextWithHelp from "../src/components/help/TextWithHelp";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
assert(
documentationHelpHref({ topicId: "campaigns.workflow.complete-review" }) ===
@@ -49,3 +57,30 @@ const fieldMarkup = renderToStaticMarkup(
</DocumentationHelpProvider>
);
assert(fieldMarkup.includes("topic=access.reference.admin-access-fields"), "field labels can link to stable reference topics");
for (const language of ["en", "de"]) {
const helpLabel = language === "de" ? "Benutzerdokumentation öffnen" : "Open user documentation";
const help = <DocumentationHelpLink reference={{ contextId: "dashboard" }} />;
for (const [name, element] of [
["page", <PageLayout archetype="overview" title="Dashboard" titleHelp={help} headerLoading actions={<button>Configure</button>}>Data</PageLayout>],
["title", <PageTitle loading titleHelp={help}>Dashboard</PageTitle>],
["administration", <AdminPageLayout title="Dashboard" description="Summary" titleHelp={help}>Data</AdminPageLayout>],
["card", <Card title="Dashboard" titleHelp={help} collapsible>Data</Card>],
["dialog", <Dialog open title="Dashboard" titleHelp={help} onClose={() => undefined}>Data</Dialog>],
["workspace", <WorkspaceActionBar variant="workspace" title="Dashboard" titleHelp={help} titleLevel={1} primaryActions={<button>Edit</button>} />],
["section", <TextWithHelp as="div" help={help}><h3>Dashboard</h3></TextWithHelp>]
] as const) {
const markup = renderToStaticMarkup(<PlatformLanguageProvider preferredLanguageCode={language}>{element}</PlatformLanguageProvider>);
const heading = markup.match(/<h[123]\b[^>]*>[\s\S]*?<\/h[123]>/)?.[0];
assert(heading?.includes("Dashboard"), `${name} retains a visible semantic heading`);
assert(!heading?.includes("documentation-help-link") && !heading?.includes("loading-indicator"), `${name} heading name excludes help and progress controls`);
assert(markup.includes(`aria-label="${helpLabel}"`), `${name} keeps translated documentation access in ${language}`);
assert(markup.indexOf('class="documentation-help-link"') > markup.indexOf(heading!), `${name} help follows its visible heading`);
assert(!/<button\b[^>]*>[\s\S]*?documentation-help-link[\s\S]*?<\/button>/.test(markup), `${name} never nests documentation inside an action`);
}
}
const titleWithoutText = renderToStaticMarkup(<WorkspaceActionBar variant="workspace" titleHelp={<DocumentationHelpLink reference={{ contextId: "dashboard" }} />} />);
assert(!titleWithoutText.includes("documentation-help-link"), "workspace help cannot appear without a visible context heading");
const unheadedCard = renderToStaticMarkup(<Card titleHelp={<DocumentationHelpLink reference={{ contextId: "dashboard" }} />}>Data</Card>);
assert(!unheadedCard.includes("documentation-help-link"), "card help cannot appear without a visible title");
+22
View File
@@ -346,6 +346,28 @@ const aliasOrdered = groupNavigationItems(reorderedProductNavigation.primaryItem
assert(aliasOrdered[0]?.items[0]?.to === "/files" && aliasOrdered[1]?.items[0]?.to === "/messages" && aliasOrdered[1]?.label === "View group", "View ordering recognizes non-placement owner aliases and assigns the requested separator exactly once");
const unauthorizedLockedOwner = projectProductNavigation(reorderedOwners, messageSurfaceModules, { scopes: ["mail:mailbox:read"] } as AuthInfo).primaryItems.find((item) => item.to === "/messages");
assert(unauthorizedLockedOwner?.navigationLocked === false && !unauthorizedLockedOwner.navigationAliases?.includes("postbox.navigation.postbox"), "unavailable optional owners contribute neither aliases nor lock metadata");
const composedCommunicationArea = [{
id: "communication", moduleId: "mail", label: "Communication", iconName: "mail" as const,
surfaceIds: ["mail.navigation.mail"], order: 40
}];
const defaultComposedNavigation = reorderedProductNavigation.primaryItems.map((item) => ({
...item, navigationCustomLayout: false, navigationSection: null
}));
assert(groupNavigationItems(defaultComposedNavigation, composedCommunicationArea)[0]?.items[0]?.to === "/messages", "standard product-area grouping recognizes authorized composed-owner aliases even when another owner supplies the rail placement");
assert(groupNavigationItems(defaultComposedNavigation, composedCommunicationArea)[0]?.label === "Communication", "composed entries retain their standard section heading without a saved layout");
const competingOwnerAreas = [
{ ...composedCommunicationArea[0], order: 10 },
{ ...composedCommunicationArea[0], id: "direct-owner", label: "Direct owner area", surfaceIds: ["postbox.navigation.postbox"], order: 70 }
];
const directOwnerGroups = groupNavigationItems(defaultComposedNavigation, competingOwnerAreas);
assert(directOwnerGroups[0]?.id === "product-area:direct-owner" && directOwnerGroups[0]?.items[0]?.to === "/messages", "an authorized composed entry keeps its directly declared area even when another owner's alias belongs to an earlier area");
assert(directOwnerGroups.flatMap((group) => group.items).filter((item) => item.to === "/messages").length === 1, "competing owner areas never duplicate the composed destination");
const personalFlatNavigation = defaultComposedNavigation.map((item) => ({
...item, navigationCustomLayout: true, navigationLayoutSource: "user"
}));
assert(groupNavigationItems(personalFlatNavigation, composedCommunicationArea).every((group) => group.label === undefined), "standard sections never replace an explicitly flat personal layout");
const mailOnlyDefaultNavigation = projectProductNavigation(reorderedOwners, messageSurfaceModules, { scopes: ["mail:mailbox:read"] } as AuthInfo).primaryItems;
assert(groupNavigationItems(mailOnlyDefaultNavigation, [{ ...composedCommunicationArea[0], surfaceIds: ["postbox.navigation.postbox"] }])[0]?.id === "more-tools", "an unauthorized optional owner's area alias cannot affect default navigation grouping");
assert(
mailOnlyNavigation.primaryItems.map((item) => item.to).join(",") === "/messages",
"composition should remain stable when only one optional contributor is authorized"
+31
View File
@@ -91,3 +91,34 @@ const delegatedHeaderMarkup = renderToStaticMarkup(
assert(!delegatedHeaderMarkup.includes("page-layout-header"), "composite workspaces can delegate their visible heading to contributed content");
assert(delegatedHeaderMarkup.includes("page-layout-workspace content-pad workspace-data-page"), "headerless composite pages retain the central content frame");
for (const state of ["clean", "dirty", "invalid", "save-failed", "conflict", "saving"] as const) {
for (const behavior of ["reset", "exit"] as const) {
const markup = renderToStaticMarkup(
<PlatformLanguageProvider>
<PageActionBar variant="editor" state={state}
discardAction={{ label: "Cancel", behavior, "aria-label": "test-cancel" }}
saveAction={{ label: "Save", "aria-label": "test-save" }} />
</PlatformLanguageProvider>
);
const cancelButton = markup.match(/<button\b[^>]*aria-label="test-cancel"[^>]*>/)?.[0];
const saveButton = markup.match(/<button\b[^>]*aria-label="test-save"[^>]*>/)?.[0];
assert(cancelButton && saveButton, "editor actions remain visible in every draft state");
const disabled = (button: string | undefined) => /\bdisabled=""/.test(button ?? "");
assert(disabled(cancelButton) === (state === "saving" || (state === "clean" && behavior === "reset")),
`${behavior} action has the correct disabled state when ${state}`);
assert(disabled(saveButton) === ["clean", "invalid", "saving"].includes(state),
`cancel availability must not enable invalid/no-op/concurrent saving when ${state}`);
assert(!markup.includes('behavior="'), "the action contract must not leak non-HTML attributes");
}
}
const permissionBlockedExit = renderToStaticMarkup(
<PlatformLanguageProvider>
<PageActionBar variant="editor" state="clean"
discardAction={{ label: "Cancel", behavior: "exit", disabled: true, disabledReason: "Explicitly blocked", "aria-label": "test-blocked-cancel" }}
saveAction={{ label: "Save" }} />
</PlatformLanguageProvider>
);
assert(/\bdisabled=""/.test(permissionBlockedExit.match(/<button\b[^>]*aria-label="test-blocked-cancel"[^>]*>/)?.[0] ?? ""),
"exit semantics preserve an explicit owning-page blocker");
+17 -1
View File
@@ -38,7 +38,10 @@ const markup = renderToStaticMarkup(
);
assert(markup.includes('role="listbox"'), "the root exposes listbox semantics");
assert(markup.includes('class="selection-list domain-list"'), "root caller classes are preserved");
const rootClasses = markup.match(/\bclass="([^"]+)"/)?.[1].split(/\s+/) ?? [];
assert(rootClasses.includes("selection-list"), "the root retains its shared component class");
assert(rootClasses.includes("selection-list-plain"), "the root defaults to the plain variant");
assert(rootClasses.includes("domain-list"), "root caller classes are preserved");
assert(markup.includes('data-source="inbox"'), "root native properties pass through");
assert(markup.includes('aria-label="Notifications"'), "the accessible label is translated");
assert(markup.includes('role="option"'), "items expose option semantics");
@@ -59,6 +62,19 @@ const nativeLabelMarkup = renderToStaticMarkup(
);
assert(nativeLabelMarkup.includes('aria-label="Already translated"'), "native accessible labels pass through unchanged");
const navigationMarkup = renderToStaticMarkup(
<SelectionList variant="navigation" className="domain-navigation" aria-label="Navigation">
<SelectionListItem selected>Current section</SelectionListItem>
</SelectionList>
);
const navigationClasses = navigationMarkup.match(/\bclass="([^"]+)"/)?.[1].split(/\s+/) ?? [];
assert(navigationClasses.includes("selection-list"), "navigation retains the shared root class");
assert(navigationClasses.includes("selection-list-navigation"), "the navigation variant is explicit");
assert(!navigationClasses.includes("selection-list-plain"), "navigation does not inherit the plain variant class");
assert(navigationClasses.includes("domain-navigation"), "navigation preserves caller classes");
assert(navigationMarkup.includes('role="listbox"'), "navigation retains listbox semantics");
assert(navigationMarkup.includes('aria-label="Navigation"'), "navigation retains its accessible label");
const directoryOptions = [
{
value: "account-1",
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node"],
"paths": {
"@govoplan/core-webui": ["./conformance/QuickAccessCoreFacade.ts"],