diff --git a/.gitignore b/.gitignore index d218ddf..2ae28fd 100644 --- a/.gitignore +++ b/.gitignore @@ -138,6 +138,7 @@ dist # Local WebUI test/build scratch directories .component-test-build/ +.component-test-build-*/ .file-drop-test-build/ .module-test-build/ .policy-test-build/ diff --git a/AGENTS.md b/AGENTS.md index aad4d93..b5beccd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,18 +26,27 @@ cd /mnt/DATA/git/govoplan-core For WebUI checks: +```bash +cd /mnt/DATA/git/govoplan +./devkit doctor --repo core +./devkit check --profile ui --repo core --dry-run +./devkit check --profile ui --repo core +``` + +For an individually selected component batch (one compilation): + ```bash cd /mnt/DATA/git/govoplan-core/webui -PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:mail-components -PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:module-capabilities -PATH=/mnt/DATA/git/govoplan-core/webui/node_modules/.bin:/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run test:module-permutations +npm run test:components -- mail-components page-layout +npm run test:module-capabilities +npm run test:module-permutations ``` Run the consolidated focused check when a change touches module discovery, optional integrations, shared mail components, or mailbox listing: ```bash cd /mnt/DATA/git/govoplan -tools/checks/check-focused.sh +./devkit check --profile full ``` ## Working Rules diff --git a/docs/DATAGRID_SIZING_CONTRACT.md b/docs/DATAGRID_SIZING_CONTRACT.md index c78ce23..a87eb7e 100644 --- a/docs/DATAGRID_SIZING_CONTRACT.md +++ b/docs/DATAGRID_SIZING_CONTRACT.md @@ -6,6 +6,23 @@ occupies its normal space. `Card bodyLayout="table"` provides an explicit zero-inset surface, including with loading wrappers and padded notices. The Organizations/IDM browser fixtures assert row geometry, not just outer shells. +The containing card removes its body padding; tables stay at `width: 100%` and +`max-width: 100%`. Do not cancel padding with negative margins or an expanded +`calc()` width: the table's maximum width correctly clamps that expansion, and +the result is a visible gap. Existing cards with only a DataGrid, admin table +surface, or connection tree inherit the same zero-inset geometry, directly or +through a `LoadingFrame`. Its loading overlay does not count as content and +stays within the table body. Mixed prose/form content keeps the usual insets; +choose `bodyLayout="table"` explicitly when combining a full-width table with +separately padded notices. Ordinary standalone tables retain their border. + +Deutsch: Tabellenkarten entfernen den Innenabstand am Karteninhalt. Die Tabelle +bleibt bei 100 Prozent Breite; negative Ränder und verbreiternde `calc()`-Werte +sind nicht erforderlich. Das gilt auch während des Ladens. Karten mit Text +oder Formularen behalten ihren Innenabstand. Für eine randlose Tabelle neben +einem separat gepolsterten Hinweis ist `bodyLayout="table"` ausdrücklich zu +setzen; alleinstehende Tabellen behalten ihren Rahmen. + `DataGrid` turns every declared track into a deterministic pixel layout after its container has a measurable width. The same contract is used on initial layout, container resize, persisted-layout restore, and pointer/keyboard resize. @@ -23,6 +40,21 @@ layout, container resize, persisted-layout restore, and pointer/keyboard resize. - `maxWidth` bounds direct user growth and free/constrained compensation. In a cover layout it is a preferred maximum: passive tracks may exceed it when that is necessary to keep the table flush with its container. +- `preferredMaxWidth` is a presentation-only automatic-fit ceiling. It retains + sensible initial proportions without limiting direct user resizing or + right-side resize compensation. A declared `maxWidth` still wins as the + manual limit. Cover may exceed preferred ceilings after other automatic + sizing targets are exhausted, preserving the no-blank-filler contract. +- Do not give expandable text columns arbitrary `maxWidth` values merely to + control their initial appearance. At that limit, a resize handle can shrink + but cannot grow, regardless of whether the next column is resizable. Prefer + weighted `minmax(..., Nfr)` declarations with `preferredMaxWidth` when needed; + ordinary cover-mode growth may + create horizontal overflow without changing fixed neighbors. Campaign's + Recipient(s), Delivery and configurable recipient-field columns follow this + contract. The existing signature format is unchanged for other columns; + introducing a preferred ceiling invalidates only that grid's previous width + contract, while sort/filter preferences remain intact. - `columnType: "actions"` marks a custom action/control column. Canonical `TableActionGroup` content is recognized automatically, even in existing column declarations. Use `sticky: "end"` for the normal row-action surface. @@ -71,6 +103,14 @@ den laufenden Ziehvorgang. Am fokussierten Trenner ändern Links/Rechts die Brei um 10 px, mit Umschalt um 40 px. Eingabe oder Doppelklick setzt die persönliche Breite dieser Spalte zurück. Schmale Aktionenspalten umbrechen ihre Schaltflächen; breite Tabellen bleiben horizontal scrollbar. +Eine feste Maximalbreite stoppt das Vergrößern unabhängig von Nachbarspalten. +`preferredMaxWidth` begrenzt dagegen nur die automatische Anfangsaufteilung, +nicht persönliche Breiten oder den Ausgleich beim Ziehen. Empfänger, +Zustellung und konfigurierte Empfängerfelder verwenden diese Darstellungsgrenze. +Bei Bedarf verbreitert sich die Tabelle und bleibt scrollbar; persönliche +Breiten ändern keine Kampagnendaten. Am rechten Scrollrand kann eine breite +linke Spalte wieder verkleinert werden, weil rechte Textspalten über ihre +bevorzugte Anfangsbreite hinaus Platz aufnehmen dürfen. ## Layout Modes diff --git a/docs/LOCALIZATION_AND_HELP_QUALITY.md b/docs/LOCALIZATION_AND_HELP_QUALITY.md index c3c3eca..76daa12 100644 --- a/docs/LOCALIZATION_AND_HELP_QUALITY.md +++ b/docs/LOCALIZATION_AND_HELP_QUALITY.md @@ -137,3 +137,15 @@ Browser acceptance is part of the focused workspace gate and can be run alone: cd /mnt/DATA/git/govoplan-core/webui npm run test:conformance ``` + +For shared component regressions, `npm run test:components -- page-layout +documentation-help layout-primitives` compiles the component test configuration +once and executes the selected suites. With no suite names it runs every shared +component suite. Existing single-suite aliases such as `npm run test:page-layout` +remain supported. Every invocation owns an ignored, uniquely named temporary +output directory and removes it on completion, failure, or handled interruption; +parallel invocations cannot delete each other's compiled tests. A forcibly killed +process may leave its own ignored directory behind. The launcher itself is +checked with `npm run test:component-runner` without compiling the application or +starting a server. Browser conformance remains a separate verification layer; +passing component tests does not constitute a complete module review. diff --git a/docs/NAVIGATION_LAYOUT_CONTRACT.md b/docs/NAVIGATION_LAYOUT_CONTRACT.md index 5c0e958..83396e3 100644 --- a/docs/NAVIGATION_LAYOUT_CONTRACT.md +++ b/docs/NAVIGATION_LAYOUT_CONTRACT.md @@ -7,6 +7,38 @@ emits a draft only after a real edit. A drag onto the same position, keyboard pickup/drop without movement, and opening inherited settings do not save or create an override. +## Standard layout / Standardanordnung + +No configuration is required to enable sections. Installed modules contribute +the standard product-area membership, labels, and order: Work; Services and +cases; Records and documents; Communication; Meetings and decisions; Data and +assurance; People and responsibility. The dashboard stays first; unclassified +destinations remain available under More tools. Only nonempty, authorized and +View-visible groups appear. Composed product entries keep their placement +owner's directly declared area. If none exists, the navigation aliases of +other authorized owners preserve area membership. These are live defaults, +not a saved copy that needs replacing whenever optional modules change. + +The editor explains whether the current level is inherited, custom grouped, or +explicitly flat. To restore defaults, choose **Use inherited layout** and Save +on the owning page: personal Settings inherits the tenant, tenant settings +inherit the system, and system settings inherit the standard product areas. +An explicitly flat personal, tenant, system, or View configuration is never +silently replaced with standard groups. A View may also explicitly select flat +navigation. Reset the responsible override rather than changing permissions. + +Abschnitte sind ohne zusätzliche Konfiguration aktiv. Die Standardanordnung +gliedert verfügbare Module in Arbeit; Leistungen und Vorgänge; Akten und +Dokumente; Kommunikation; Termine und Entscheidungen; Daten und +Qualitätssicherung; Personen und Verantwortung. Das Dashboard steht davor, +nicht zugeordnete Ziele bleiben unter Weitere Werkzeuge erreichbar. Leere oder +nicht zugängliche Gruppen erscheinen nicht. Der gemeinsame Editor zeigt an, +ob eine geerbte, eigene gruppierte oder ausdrücklich ungegliederte Anordnung +vorliegt. **Geerbte Anordnung verwenden** und anschließendes Speichern entfernt +nur die Anpassung dieser Ebene: persönlich → Mandant → System → +Standardanordnung. Bewusst gespeicherte ungegliederte Anordnungen bleiben +erhalten. Diese Darstellung erteilt keine Berechtigungen und ändert keine Daten. + ## Stored document and precedence The version-1 navigation document retains `order`, `hidden` and `locked` and diff --git a/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md b/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md index d9d30f6..98c16d3 100644 --- a/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md +++ b/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md @@ -4,6 +4,12 @@ This document defines the binding composition grammar for headed GovOPlaN pages. Core owns the reusable anatomy; each module owns its domain actions, wording, authorization, consequences, and data state. +The cross-product [UI design principles](UI_DESIGN_PRINCIPLES.md) define the +display-first editing model, heading-adjacent help, and module-review process. +Normal overview/detail surfaces show readable facts; edit coherent settings in +scoped dialogs. Use an explicit editor mode for a justified broad-editing task, +not merely because a page also offers filters or contains an edit dialog (UI-02). + ## Required Page Frame - Use `PageLayout` for every headed standalone, workspace, or embedded page. @@ -69,11 +75,19 @@ page or pane action bar. | Page kind | Leading group | Trailing group | | --- | --- | --- | -| Overview | Context | Help, Reload when refreshable, then ordinary primary actions | -| Collection | Collection context such as export | Help, Reload when refreshable, then Create at the far right | -| Detail | Object context | Help, Reload when refreshable, ordinary primary actions, then a separated destructive group | -| Editor | Context | Dirty state, Help, Reload if distinctly safe, ordinary primary actions, separated destructive actions, Discard, then Save at the far right | -| Workspace | Task context | Help, Reload when refreshable, ordinary primary actions, then a separated destructive group | +| Overview | Context | Reload when refreshable, then ordinary primary actions | +| Collection | Collection context such as export | Reload when refreshable, then Create at the far right | +| Detail | Object context | Reload when refreshable, ordinary primary actions, then a separated destructive group | +| Editor | Context | Dirty state, Reload if distinctly safe, ordinary primary actions, separated destructive actions, Discard, then Save at the far right | +| Workspace | Task context | Reload when refreshable, ordinary primary actions, then a separated destructive group | + +Documentation is associated with text, not an action-group slot (UI-01). Put +`DocumentationHelpLink` in the heading component's `titleHelp`, use `TextWithHelp` +for existing contextual words, and retain `FormField`/`FieldLabel` documentation +beside field labels. Full-canvas `WorkspaceActionBar` surfaces can supply `title` +and `titleHelp` together on the leading side. Do not duplicate a heading or use +`helpAction` for a detached documentation icon. See the shared principle for +card/dialog accessibility and examples. Reload and Create belong to the same right-aligned group, in that order. A collection-wide toolbar stays above its workspace, not inside the left tree or @@ -82,12 +96,40 @@ not remove it. Permission-blocked creation remains visible with an explanation. On narrow screens the trailing group wraps while retaining right alignment and the same DOM/keyboard order. +An editor's **Cancel/Close** action is not the same as **Reset/Discard changes**. +When it exits an editing mode, set `discardAction.behavior` to `"exit"`: it stays +enabled for a clean draft, while Save remains disabled until there are changes. +The default `"reset"` behavior still requires changes. Both stay blocked during +an in-flight save; the owning page must use the shared discard confirmation before +exiting a dirty draft. Never require a meaningless edit just to leave configuration. + +Deutsch: Abbrechen oder Schließen beendet den Bearbeitungsmodus auch ohne +Änderungen (`discardAction.behavior="exit"`). Zurücksetzen setzt dagegen einen +geänderten Entwurf voraus. Beim Abbrechen eines geänderten Entwurfs vor dem +Verwerfen nachfragen; eine laufende Speicherung bleibt geschützt. + Use `Card bodyLayout="table"` for table surfaces, including tables wrapped by `LoadingFrame`. This removes body padding explicitly, without relying on the number of children or negative margins. Place any meaningful explanation or warning in a padded `ContentSection`; do not add a redundant tagline to every table. Use `ContentGrid` for sibling cards so spacing does not depend on fragments. +The shared stylesheet also recognizes existing table-only card bodies, either +directly or through `LoadingFrame`; loading must not add an inset, expand the +overlay outside the card, or change the table's measured width. Keep the table +at 100% of its container. Do not reintroduce module-local negative margins, +expanded `calc()` widths, or padding overrides to make a table fit. Mixed +content cards retain their normal padding unless they explicitly declare the +table layout. The browser conformance matrix checks all four card edges, +loading/collapse transitions, scrolling, and row actions at desktop and mobile +widths. + +Deutsch: Tabellen belegen den Karteninhalt ohne zusätzlichen Innenabstand, +auch während des Ladens. Neue Tabellenkarten verwenden ausdrücklich +`bodyLayout="table"`; ergänzende Hinweise erhalten ihren eigenen Innenabstand. +Negative Ränder oder modulbezogene Breitenkorrekturen sind nicht nötig. +Karten mit gemischtem Text- oder Formularinhalt behalten ihre normalen Abstände. + Use `MultiSelectFilter` for standalone list facets. It and DataGrid share the same checkbox body and Select all / Deselect all behavior. `null` means no restriction, `[]` means no matches, and multiple values mean OR within a facet. @@ -136,7 +178,8 @@ to fill the slot. Editor bars always keep Discard and Save visible. Their required `state` projection is one of `clean`, `dirty`, `invalid`, `saving`, `save-failed`, or `conflict`, and the central component announces it through a live status label. -Clean and saving states disable both persistence actions; invalid disables Save +Clean states disable Save and reset-style Discard, but keep exit-style Cancel +available; saving disables both persistence actions. Invalid disables Save while retaining Discard. Failed saves and conflicts keep the draft recoverable and allow an authorized retry after the module has shown the owning error or conflict evidence. A module may add a more specific validation, policy, or diff --git a/docs/UI_DESIGN_PRINCIPLES.md b/docs/UI_DESIGN_PRINCIPLES.md new file mode 100755 index 0000000..6254180 --- /dev/null +++ b/docs/UI_DESIGN_PRINCIPLES.md @@ -0,0 +1,150 @@ +# Product UI design principles + +These rules govern every GovOPlaN module, including administration, tenant and +user settings, public forms, widgets, dialogs and shared shell surfaces. Core +owns the reusable components; modules own their meaning and authorized behavior. +They complement the [page composition contract](PAGE_LAYOUT_USAGE_GUIDELINES.md). + +Initial rule revision: **UI-2026-09-08**. Record this identifier in review evidence; +subsequent rule changes need a dated change reference and a UI-09 propagation check. + +## UI-01 — Help belongs to visible text + +Place a small documentation book immediately after the heading or label whose +meaning it explains. For example, the Dashboard book belongs to **Dashboard**, +not the Reload, Edit or Save group. The same applies to page, pane, card and +dialog titles, field labels, widget summaries and contextual explanations. +Do not put an isolated documentation icon in an action bar or in an otherwise +empty row. A navigation icon is not a contextual documentation link. + +Use `titleHelp={}` on `PageLayout`, +`PageHeader`, `PageTitle`, `AdminPageLayout`, `Card` or `Dialog`. Full-canvas +workspaces use the visible `title` and `titleHelp` of `WorkspaceActionBar` (or +`PageActionBar`), separate from its action groups. Use `TextWithHelp` around +existing text for smaller contexts; fields use `FormField`/`FieldLabel` and +their documentation reference. Do not duplicate the heading to add help. + +Dashboard widgets provide their owning `documentation` reference in +`DashboardWidgetContribution`; Dashboard renders it beside the existing widget +card title. Do not add a second widget heading or a footer-only documentation icon. + +Keep the icon outside the heading's accessible name and outside other buttons +or links. It needs an accessible purpose, keyboard focus and a usable hit area. +Long German headings must wrap without colliding with actions. Collapsing a +card or configuring a dashboard must not change which title the book explains. +The existing optional Docs-module/hosted-documentation fallback remains intact; +placement does not change permissions or require the Docs module. + +## UI-02 — Display first; edit deliberately + +Ordinary overview and detail pages show compact, readable data. Group related +facts with shared description, summary and card components. Offer a clear Edit +action that opens a scoped dialog for a coherent group of settings. Avoid +presenting every possible input, select and save control simultaneously. + +The Campaign dashboard is a priority application: show the effective settings +and their state compactly, then edit related settings in focused dialogs. This +is review work to implement, not a claim that all current screens already comply. + +An explicit editing mode is a justified exception for genuinely broad editing, +large tables, graphical designers or other tasks poorly served by a modal. +Record why the exception helps the user. Keep reading and editing distinguishable; +enter editing deliberately and preserve shared Save/Cancel, validation, conflict, +permission and unsaved-draft behavior. Filters, search and selection do not need +an edit dialog: they change the view rather than the underlying data. + +Cancel/Close must work even without changes. Never save a draft merely because +a dialog opens, selection changes or navigation happens. Commit only the intended +fields; do not let unrelated incomplete settings block an independent edit. + +## UI-03 — Predictable actions and consequences + +Follow the shared semantic page/pane action contract: context on the left; +Reload on refreshable surfaces followed by Create on the right. Editable drafts +have the standard state, Cancel/Discard and Save controls. Keep positions stable +across selection and loading changes, and explain unavailable applicable actions. +Separate destructive actions visually and semantically. Reload is a read, not +an implicit synchronization, import, send or other mutation. + +## UI-04 — Shared geometry and readable collections + +Use central page, card, table, form-grid and dialog primitives. Table cards use +`bodyLayout="table"`, spanning the card body without negative margins or expanded +`calc()` widths. Keep row actions reachable and deliberate column resizing +predictable. Avoid redundant taglines, arbitrary gaps, fixed dialog widths and +horizontal scrolling where content can wrap. Use actual pagination and shared +list filters; every result and every validation detail must remain reachable. + +## UI-05 — Stable feedback and loading + +Preserve useful loaded content on refresh failure; distinguish initial loading, +empty, unavailable, stale, blocked, failed and successful states. Use the shared +scoped loading overlay. Long operations show truthful processed/total and outcome +counts when known, not simulated progress or whole-page background reloads. +Prevent duplicate submissions without trapping the user in a clean editing mode. + +## UI-06 — Predictable navigation and selection + +Use shared breadcrumbs, side rail and tree contracts. Tree disclosure controls +expand/collapse; labels select. Track the clicked occurrence when one topic or +record appears in several branches. Preserve context, filters and selection +where appropriate, without carrying data across authorization or tenant scopes. + +## UI-07 — Accessibility and German parity + +Check keyboard order, visible focus, accessible names, dialog focus restoration, +contrast, zoom and narrow layouts. Status must not rely on color alone. Use +plain, task-oriented wording and complete German UI and module-owned help for +the changed workflow. Long labels must not hide actions or force needless scroll. + +## UI-08 — Integrity and permissions are not visual shortcuts + +Preserve authorization, versioning, audit evidence, immutable historical data and +safe retries. Do not remove a guard to make an action look available. Keep drafts +recoverable after failures and conflicts. Loading, cancellation and dialog +transitions must not silently write, discard or overwrite data. Never verify UI +changes by sending real mail or changing production records without authorization. + +## UI-09 — Review incrementally and propagate lessons + +The [Meta UI-review epic](https://git.add-ideas.de/GovOPlaN/govoplan/issues/56) +links one review ledger per module. Each starts pending, including modules +touched by a focused shared-component pass. An implemented help-placement rule +does not constitute a full module review. + +Inventory the module's surfaces, inspect them with realistic data and permissions, +record findings and decisions, implement bounded fixes, then attach automated +and manual evidence. Distinguish **to do**, **implemented**, **verified**, and +**accepted exception**. Record the applicable principle IDs and actual screen +coverage; do not mark a module complete from a code search alone. + +When a review reveals a reusable lesson, update this document and the central +component/check first where possible. Record the rule change in Meta and create +a follow-up or reopen affected already-reviewed modules. The review ledger must +show which rule revision each module has actually verified. Prefer finishing +the active review slice before expanding functionality, while recording unrelated +bugs and decision-dependent work without losing them. + +## Kurzfassung auf Deutsch + +- **UI-01:** Das Dokumentationsbuch steht direkt rechts neben der zugehörigen + Überschrift oder Beschriftung, nicht zwischen Aktionsschaltflächen. +- **UI-02:** Daten zunächst kompakt anzeigen; zusammengehörige Einstellungen + gezielt in einem Dialog bearbeiten. Ein ausdrücklich aktivierter + Bearbeitungsmodus, etwa für große Tabellen, ist eine begründete Ausnahme. + Abbrechen funktioniert auch ohne Änderungen; Änderungen werden bewusst gespeichert. +- **UI-03–UI-08:** Einheitliche Aktionen und Abstände, erreichbare Tabelleninhalte, + verständlicher Fortschritt, vorhersehbare Navigation, Barrierefreiheit und + deutsche Texte dürfen Berechtigungen oder Datenintegrität nicht schwächen. +- **UI-09:** Alle Module werden einzeln geprüft. Neue Gestaltungsregeln werden + auch in bereits geprüften Modulen nachgezogen und mit Nachweisen dokumentiert. + +## Automated coverage and its limits + +`tools/checks/check-heading-help.mjs` in Meta checks JSX documentation-link +placement throughout the workspace, including aliases and simple local variables. +Core component and browser tests check heading association, accessible names, +focus, collapse/loading transitions and narrow/wide layouts. Existing layout, +action, dialog and DataGrid checks continue to apply. These tests protect shared +contracts; they do not certify every module's usability or replace the individual +screen reviews and their documented exceptions. diff --git a/webui/conformance/CampaignAttachmentsScenario.tsx b/webui/conformance/CampaignAttachmentsScenario.tsx index d13976a..9084700 100644 --- a/webui/conformance/CampaignAttachmentsScenario.tsx +++ b/webui/conformance/CampaignAttachmentsScenario.tsx @@ -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() { - + {new URLSearchParams(window.location.search).has("recipient-data") + ? + : } ; diff --git a/webui/conformance/CardTableLayoutScenario.tsx b/webui/conformance/CardTableLayoutScenario.tsx new file mode 100755 index 0000000..2706967 --- /dev/null +++ b/webui/conformance/CardTableLayoutScenario.tsx @@ -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[] = [ + { 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) => , onClick: () => setInspected(row.name) } + ]} /> } + ]; + const grid = (id: string) => row.id} />; + const tree = () => row.name }]} getRowKey={(row) => row.id} />; + return
+ + {inspected} + {grid("direct")} + {grid("collapsible")} + {grid("loading")} +
{grid("admin")}
+
{grid("loading-admin")}
+ {tree()} + {tree()} + {grid("explicit")} + + Meaningful table context + {grid("context")} + +

Ordinary content keeps its inset.

{grid("mixed")}
+

Ordinary content keeps its inset.

{grid("loading-mixed")}
+
{grid("standalone")}
+
; +} diff --git a/webui/conformance/ConformanceApp.tsx b/webui/conformance/ConformanceApp.tsx index 67227a5..2db33d2 100644 --- a/webui/conformance/ConformanceApp.tsx +++ b/webui/conformance/ConformanceApp.tsx @@ -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 ; + if (new URLSearchParams(location.search).has("password-lifecycle")) return ; if (new URLSearchParams(location.search).has("credential-references")) return ; @@ -92,6 +96,7 @@ export default function ConformanceApp() { if (new URLSearchParams(location.search).has("campaign-workspace")) return ; if (new URLSearchParams(location.search).has("campaign-report")) return ; if (new URLSearchParams(location.search).has("module-layouts")) return ; + if (new URLSearchParams(location.search).has("dashboard-configuration")) return ; if (new URLSearchParams(location.search).has("help-center")) return ; if (new URLSearchParams(location.search).has("notification-filter")) return ; if (new URLSearchParams(location.search).has("multi-select-filter")) return ; diff --git a/webui/conformance/DashboardConfigurationScenario.tsx b/webui/conformance/DashboardConfigurationScenario.tsx new file mode 100755 index 0000000..5f53be7 --- /dev/null +++ b/webui/conformance/DashboardConfigurationScenario.tsx @@ -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 + + + + ; +} diff --git a/webui/conformance/DataGridLayoutScenario.tsx b/webui/conformance/DataGridLayoutScenario.tsx index cfe4f24..12ee8be 100644 --- a/webui/conformance/DataGridLayoutScenario.tsx +++ b/webui/conformance/DataGridLayoutScenario.tsx @@ -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 ; + return ; +} + +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[]>(() => [ - { 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() { : group; } } - ], [behavior, composite, extraAction, mode]); + ], [behavior, composite, extraAction, mixedColumns, mode, preferredLimits]); return (

Data grid layout conformance

@@ -52,7 +61,7 @@ export default function DataGridLayoutScenario() { {clicked}
{mounted && row.id} diff --git a/webui/conformance/HeadingHelpScenario.tsx b/webui/conformance/HeadingHelpScenario.tsx new file mode 100755 index 0000000..d527876 --- /dev/null +++ b/webui/conformance/HeadingHelpScenario.tsx @@ -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 ; + const help = ; + const longTitle = `Dashboard ${"DokumentationsüberschriftOhneLeerzeichen".repeat(5)}`; + return +
+ setLoading(value => !value)}>Toggle loading} />}> + Refresh} /> + +

Saved data

+
+

Section

+
+ {"UnbrokenLabel".repeat(8)} +
+ + setOpen(false)} + footer={}> + + +
+
+
; +} diff --git a/webui/conformance/QuickAccessCoreFacade.ts b/webui/conformance/QuickAccessCoreFacade.ts index 5d140ec..d226c09 100644 --- a/webui/conformance/QuickAccessCoreFacade.ts +++ b/webui/conformance/QuickAccessCoreFacade.ts @@ -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 diff --git a/webui/conformance/WidgetHeadingHelpScenario.tsx b/webui/conformance/WidgetHeadingHelpScenario.tsx new file mode 100755 index 0000000..a2a28be --- /dev/null +++ b/webui/conformance/WidgetHeadingHelpScenario.tsx @@ -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: () =>

Existing requests

+}; +const ignore = () => undefined; + +export default function WidgetHeadingHelpScenario() { + const [configuring, setConfiguring] = useState(false); + const [preview, setPreview] = useState(false); + return +
+ + + +
+
; +} diff --git a/webui/conformance/tests/campaign-attachments.spec.ts b/webui/conformance/tests/campaign-attachments.spec.ts index 4bc88ac..3cfc171 100644 --- a/webui/conformance/tests/campaign-attachments.spec.ts +++ b/webui/conformance/tests/campaign-attachments.spec.ts @@ -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[] = []; + 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 = {}; + 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(`.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([]); +}); diff --git a/webui/conformance/tests/card-table-layout.spec.ts b/webui/conformance/tests/card-table-layout.spec.ts new file mode 100755 index 0000000..b8fc545 --- /dev/null +++ b/webui/conformance/tests/card-table-layout.spec.ts @@ -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"); +}); diff --git a/webui/conformance/tests/dashboard-configuration.spec.ts b/webui/conformance/tests/dashboard-configuration.spec.ts new file mode 100755 index 0000000..5df9f89 --- /dev/null +++ b/webui/conformance/tests/dashboard-configuration.spec.ts @@ -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(resolve => { releaseSave = resolve; }); + let saved: Record | null = null; + const updates: Record[] = []; + 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([]); +}); diff --git a/webui/conformance/tests/data-grid-layout.spec.ts b/webui/conformance/tests/data-grid-layout.spec.ts index 4d7efaa..f79687c 100644 --- a/webui/conformance/tests/data-grid-layout.spec.ts +++ b/webui/conformance/tests/data-grid-layout.spec.ts @@ -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); diff --git a/webui/conformance/tests/heading-help.spec.ts b/webui/conformance/tests/heading-help.spec.ts new file mode 100755 index 0000000..6f4bb08 --- /dev/null +++ b/webui/conformance/tests/heading-help.spec.ts @@ -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); +}); diff --git a/webui/conformance/tests/navigation-layout.spec.ts b/webui/conformance/tests/navigation-layout.spec.ts index 533814b..e3725c5 100644 --- a/webui/conformance/tests/navigation-layout.spec.ts +++ b/webui/conformance/tests/navigation-layout.spec.ts @@ -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 }) => { diff --git a/webui/package.json b/webui/package.json index fb49099..09ccee5 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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", diff --git a/webui/scripts/run-component-tests.mjs b/webui/scripts/run-component-tests.mjs new file mode 100755 index 0000000..53972bf --- /dev/null +++ b/webui/scripts/run-component-tests.mjs @@ -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); + } +} diff --git a/webui/src/components/ActionBlockerHint.tsx b/webui/src/components/ActionBlockerHint.tsx index 181beb3..c19bb07 100644 --- a/webui/src/components/ActionBlockerHint.tsx +++ b/webui/src/components/ActionBlockerHint.tsx @@ -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({
); diff --git a/webui/src/components/Card.tsx b/webui/src/components/Card.tsx index 2df3684..02ab66f 100644 --- a/webui/src/components/Card.tsx +++ b/webui/src/components/Card.tsx @@ -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, "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 &&
- {title && (typeof title === "string" ?

{translateText(title)}

:
{title}
)} + {title && + {typeof title === "string" ?

{translateText(title)}

:
{title}
} +
} {(actions || collapsible) &&
{actions} diff --git a/webui/src/components/CredentialEnvelopeManager.tsx b/webui/src/components/CredentialEnvelopeManager.tsx index 5f53ae6..3032eb1 100644 --- a/webui/src/components/CredentialEnvelopeManager.tsx +++ b/webui/src/components/CredentialEnvelopeManager.tsx @@ -450,11 +450,11 @@ export default function CredentialEnvelopeManager({ )} } helpContextId="access.credentials" helpModuleId="access" actions={
- +

+ {navigationText(`${layoutStatus}_status`)} +