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:
@@ -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
@@ -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>;
|
||||
}
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Executable
+38
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Executable
+102
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user