feat(webui): govern actions, quick access, and metrics

Refs #264, #285, #289
This commit is contained in:
2026-08-19 18:47:46 +02:00
parent 41db78c201
commit ffaab543d2
31 changed files with 753 additions and 125 deletions
+5 -3
View File
@@ -17,12 +17,12 @@ domain modules own their compositions.
| Full-canvas workspace | Navigation/content and list/detail canvases that own pane geometry and scrolling | Navigation and split variants, primary-pane width, pane-owned or contained scrolling, responsive stacking or navigation collapse, pane labels, and contextual-help identity are centralized without encoding domain navigation | `WorkspaceLayout.tsx`, `workspace-layout.test.tsx`, Core Settings, Access administration, Docs, Organizations, Campaign, Templates, Approvals, and `check-shared-webui-layouts.py`; the raw-workspace exception baseline is empty | | Full-canvas workspace | Navigation/content and list/detail canvases that own pane geometry and scrolling | Navigation and split variants, primary-pane width, pane-owned or contained scrolling, responsive stacking or navigation collapse, pane labels, and contextual-help identity are centralized without encoding domain navigation | `WorkspaceLayout.tsx`, `workspace-layout.test.tsx`, Core Settings, Access administration, Docs, Organizations, Campaign, Templates, Approvals, and `check-shared-webui-layouts.py`; the raw-workspace exception baseline is empty |
| Full-height module frame | Outer module landmark and viewport/container sizing | `WorkspaceFrame` centralizes surface, overflow, box sizing, accessible naming, help identity, and application-viewport height so modules do not copy the `100vh - shell` frame | `WorkspaceFrame.tsx`, `layout-primitives.test.tsx`, Dataflow, Workflow, Datasources, Distribution Lists, Notifications, Tasks, Scheduling, Forms, Portal, Projects, Records, and Reporting | | Full-height module frame | Outer module landmark and viewport/container sizing | `WorkspaceFrame` centralizes surface, overflow, box sizing, accessible naming, help identity, and application-viewport height so modules do not copy the `100vh - shell` frame | `WorkspaceFrame.tsx`, `layout-primitives.test.tsx`, Dataflow, Workflow, Datasources, Distribution Lists, Notifications, Tasks, Scheduling, Forms, Portal, Projects, Records, and Reporting |
| Responsive action toolbar | Domain-neutral action and filter grouping for pages, workspaces, editors, and overlays | Density, surface, grouping, flexible space, accessible naming, toolbar help identity, and responsive wrapping are centralized while modules retain action wording, authority, and consequence | `ActionToolbar.tsx`, `layout-primitives.test.tsx`, WYSIWYG, Calendar, Files, Forms, Templates, and the product-wide primitive check | | Responsive action toolbar | Domain-neutral action and filter grouping for pages, workspaces, editors, and overlays | Density, surface, grouping, flexible space, accessible naming, toolbar help identity, and responsive wrapping are centralized while modules retain action wording, authority, and consequence | `ActionToolbar.tsx`, `layout-primitives.test.tsx`, WYSIWYG, Calendar, Files, Forms, Templates, and the product-wide primitive check |
| Semantic page archetypes and action bar | Overview, collection, detail, editor, and workspace intent declared independently from frame geometry and composed over the responsive toolbar | Refreshable pages require leading Reload; editor persistence owns clean/dirty/saving feedback, guarded Discard and far-right Save; destructive actions occupy an explicit separated slot; read-only pages do not invent Save | `PageLayout.tsx`, `PageActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, component and browser conformance, every headed product page, and `check-shared-webui-layouts.py` | | Semantic page and pane action bars | Overview, collection, detail, editor, and workspace intent declared independently from frame geometry; full-canvas panes add workspace/collection/detail/editor scope | Core renders leading Reload from a guarded descriptor; editor persistence owns clean, dirty, invalid, saving, failed, and conflict feedback plus guarded Discard and far-right Save; destructive actions occupy an explicit named boundary; read-only surfaces do not invent Save | `PageActionBar.tsx`, `WorkspaceActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, component and browser conformance, every headed page and full-canvas workspace, and the discovery-based `check-shared-webui-layouts.py` |
| Catalogue and state composition | Search/filter bars, selectable navigation lists, count badges, and empty/blocked/error panels | Width, surface, wrap, selection geometry, title/description truncation, numeric emphasis, state sizing, tone and action placement are centralized; modules retain query behavior, object state and consequences | `FilterBar.tsx`, `SelectionList.tsx`, `CountBadge.tsx`, `StatePanel.tsx`, `layout-primitives.test.tsx`, and list/detail modules across Cases, Committee, Dataflow, Forms, Notifications, Portal, Postbox, Projects, Records, Reporting, Tasks, Templates, and Workflow | | Catalogue and state composition | Search/filter bars, selectable navigation lists, count badges, and empty/blocked/error panels | Width, surface, wrap, selection geometry, title/description truncation, numeric emphasis, state sizing, tone and action placement are centralized; modules retain query behavior, object state and consequences | `FilterBar.tsx`, `SelectionList.tsx`, `CountBadge.tsx`, `StatePanel.tsx`, `layout-primitives.test.tsx`, and list/detail modules across Cases, Committee, Dataflow, Forms, Notifications, Portal, Postbox, Projects, Records, Reporting, Tasks, Templates, and Workflow |
| Content and form grids | Equal-column content, field, and native-form geometry | Explicit 14 columns, standard gaps, item spans, alignment, and named narrow/workspace/standard/wide collapse points replace generic and module-prefixed copies; unequal domain tracks remain local | `ContentGrid.tsx`, `layout-primitives.test.tsx`, Core dashboard/settings/mail, Calendar dialogs, Forms editor, Datasources, Postbox, Campaign, administration surfaces, and the product-wide primitive check | | Content and form grids | Equal-column content, field, and native-form geometry | Explicit 14 columns, standard gaps, item spans, alignment, and named narrow/workspace/standard/wide collapse points replace generic and module-prefixed copies; unequal domain tracks remain local | `ContentGrid.tsx`, `layout-primitives.test.tsx`, Core dashboard/settings/mail, Calendar dialogs, Forms editor, Datasources, Postbox, Campaign, administration surfaces, and the product-wide primitive check |
| Content sections | Repeated editor/detail section surfaces | Border, surface, compact/default density, stacked flow and block rhythm are centralized without encoding section contents | `ContentSection.tsx`, `layout-primitives.test.tsx`, Datasources, Distribution Lists, Templates, Dataflow, and Workflow | | Content sections | Repeated editor/detail section surfaces | Border, surface, compact/default density, stacked flow and block rhythm are centralized without encoding section contents | `ContentSection.tsx`, `layout-primitives.test.tsx`, Datasources, Distribution Lists, Templates, Dataflow, and Workflow |
| Form sections | Reusable heading/description/action/content grouping inside forms | Plain, separated, and panel variants centralize hierarchy and narrow action placement without moving validation, permissions, values, or domain wording into Core | `FormSection.tsx`, `layout-primitives.test.tsx`, Addresses contact editing, and Quick Access preferences | | Form sections | Reusable heading/description/action/content grouping inside forms | Plain, separated, and panel variants centralize hierarchy and narrow action placement without moving validation, permissions, values, or domain wording into Core | `FormSection.tsx`, `layout-primitives.test.tsx`, Addresses contact editing, and Quick Access preferences |
| Metric groups | Reusable responsive grouping around metric cards | Fixed one-to-five and auto-fit columns, minimum card widths, density, block/inset/zero spacing, and named collapse points replace the product-wide `metric-grid` class and cross-module dashboard overrides | `MetricGrid.tsx`, `MetricCard.tsx`, `layout-primitives.test.tsx`, Core and Dashboard summaries, administration, Campaign, Ops, Files, Search, and dashboard widgets | | Metric groups and drill-downs | Reusable responsive grouping around metric cards with an explicit optional detail affordance | Fixed one-to-five and auto-fit columns, minimum card widths, density, block/inset/zero spacing, and named collapse points replace the product-wide `metric-grid` class and cross-module dashboard overrides; typed link or in-page drill-downs name their destination while summary-only, non-enumerable, derived, or privacy-suppressed values remain inert | `MetricGrid.tsx`, `MetricCard.tsx`, `metric-card.test.tsx`, `layout-primitives.test.tsx`, Core and Dashboard summaries, administration, Campaign, Ops, Files, Search, and dashboard widgets |
| Description lists | Semantic property and fact presentation | Stacked and inline variants, one-to-five list columns, density, term width, wrapping, and responsive collapse replace both `admin-details-grid` and `detail-list`; `DescriptionItem` preserves native `dt`/`dd` anatomy | `DescriptionList.tsx`, `layout-primitives.test.tsx`, Access and Tenancy administration, Audit, Policy, Campaign reports/imports, Docs, Settings, Ops, and Reporting | | Description lists | Semantic property and fact presentation | Stacked and inline variants, one-to-five list columns, density, term width, wrapping, and responsive collapse replace both `admin-details-grid` and `detail-list`; `DescriptionItem` preserves native `dt`/`dd` anatomy | `DescriptionList.tsx`, `layout-primitives.test.tsx`, Access and Tenancy administration, Audit, Policy, Campaign reports/imports, Docs, Settings, Ops, and Reporting |
| Dialog anatomy | Shared outer dialog plus composable body and footer regions | Size and administration variants, body padding, descriptions, notices, fixed action wrapping, native form flow, and section grouping are centralized; focus trapping and stack lifecycle remain unchanged | `Dialog.tsx`, `DialogAnatomy.tsx`, `dialog-focus.test.tsx`, `layout-primitives.test.tsx`, Addresses, Calendar, Records, Datasources, Distribution Lists, Files, and Templates | | Dialog anatomy | Shared outer dialog plus composable body and footer regions | Size and administration variants, body padding, descriptions, notices, fixed action wrapping, native form flow, and section grouping are centralized; focus trapping and stack lifecycle remain unchanged | `Dialog.tsx`, `DialogAnatomy.tsx`, `dialog-focus.test.tsx`, `layout-primitives.test.tsx`, Addresses, Calendar, Records, Datasources, Distribution Lists, Files, and Templates |
| Definition-editor visuals | Reusable graph palette, canvas chrome, node icon/port geometry, empty overlay and floating activity state | Core owns visual and responsive anatomy while node/edge types, validation, execution, provenance and workflow semantics remain in Dataflow or Workflow | `DefinitionPalette.tsx`, `DefinitionNodeIcon.tsx`, `FloatingStatus.tsx`, shared definition styles, Dataflow and Workflow structure/build checks | | Definition-editor visuals | Reusable graph palette, canvas chrome, node icon/port geometry, empty overlay and floating activity state | Core owns visual and responsive anatomy while node/edge types, validation, execution, provenance and workflow semantics remain in Dataflow or Workflow | `DefinitionPalette.tsx`, `DefinitionNodeIcon.tsx`, `FloatingStatus.tsx`, shared definition styles, Dataflow and Workflow structure/build checks |
@@ -48,4 +48,6 @@ chrome. Retired copies and module-local component definitions are rejected by
`check-shared-webui-primitives.py`. That check also requires standard dialog `check-shared-webui-primitives.py`. That check also requires standard dialog
widths to use `Dialog size` and keeps every remaining domain-specific width in widths to use `Dialog size` and keeps every remaining domain-specific width in
a reviewed, decrease-only exception baseline. The companion layout check now a reviewed, decrease-only exception baseline. The companion layout check now
has zero raw page-frame and zero raw workspace exceptions. has zero raw page-frame and zero raw workspace exceptions, discovers semantic
consumers without a hand-maintained route list, requires semantic action bars
on `WorkspaceFrame` routes, and rejects ad-hoc panel-header toolbars.
+42 -19
View File
@@ -35,9 +35,13 @@ filter is editable.
## Page Action Rules ## Page Action Rules
Pass one `PageActionBar` to the `PageLayout` `actions` slot. The variant makes Pass one `PageActionBar` to the `PageLayout` `actions` slot. Full-canvas
the page's intent inspectable and preserves the same keyboard and visual order workspaces use the same contract through `WorkspaceActionBar`, with an explicit
across modules. `workspace`, `collection-pane`, `detail-pane`, or `editor-pane` scope. The
variant makes the surface's intent inspectable and preserves the same keyboard
and visual order across modules. `ActionToolbar` remains the lower-level
component for section-local controls; it is not a substitute for a semantic
page or pane action bar.
| Page kind | Leading group | Trailing group | | Page kind | Leading group | Trailing group |
| --- | --- | --- | | --- | --- | --- |
@@ -55,14 +59,24 @@ persistent side panel. Save is present only where the page owns an editable
draft; a read-only detail page must not display a disabled or inert Save merely draft; a read-only detail page must not display a disabled or inert Save merely
to fill the slot. to fill the slot.
Editor bars always keep Discard and Save visible. They expose `clean`, `dirty`, Editor bars always keep Discard and Save visible. Their required `state`
and `saving` status through a live status label. In the clean or saving state, projection is one of `clean`, `dirty`, `invalid`, `saving`, `save-failed`, or
the central component disables both persistence actions and supplies the `conflict`, and the central component announces it through a live status label.
standard explanation. A module may add a more specific validation, policy, or Clean and saving states disable both persistence actions; invalid disables Save
permission blocker while the draft is dirty. The editor must register its while retaining Discard. Failed saves and conflicts keep the draft recoverable
draft with `useUnsavedDraftGuard` (or a shared hook that uses the same and allow an authorized retry after the module has shown the owning error or
registration contract), so browser unload, route navigation, section changes, conflict evidence. A module may add a more specific validation, policy, or
and the explicit Discard path cannot silently lose work. permission blocker. The editor must register its draft with
`useUnsavedDraftGuard` (or a shared hook that uses the same registration
contract), so browser unload, route navigation, section changes, Reload, and
the explicit Discard path cannot silently lose work.
Reload is rendered by Core from a descriptor rather than passed as arbitrary
button markup. It can project `current`, `stale`, `reloading`, or
`reload-failed`; `loading` is the shorthand for `reloading`. A failed refresh
must preserve usable loaded data, expose its stale/failure state, and leave
Reload available for recovery. Reload goes through the same unsaved-navigation
guard as route changes.
Destructive page actions use `destructiveActions`; never put a danger action in Destructive page actions use `destructiveActions`; never put a danger action in
`contextActions` or the ordinary primary group. Core renders a persistent `contextActions` or the ordinary primary group. Core renders a persistent
@@ -95,17 +109,26 @@ silently hide a normally applicable action.
- Use `MetricGrid`/`MetricCard` for summary measures, `Card` or - Use `MetricGrid`/`MetricCard` for summary measures, `Card` or
`ContentSection` for logical sections, and `DescriptionList` for labelled `ContentSection` for logical sections, and `DescriptionList` for labelled
facts. facts.
- Add a `MetricCard.drilldown` only when the displayed measure has a useful,
authorized underlying collection or detail. Name the destination explicitly
(for example, “Review failed deliveries”) and preserve the current scope and
filters in its `href` or action. The card itself remains non-interactive so
the action is visible and keyboard-predictable. Derived, privacy-suppressed,
non-enumerable, or purely informational aggregates remain plain metrics;
when an ordinarily available drill-down is temporarily blocked, keep its
action and provide `disabledReason`.
- Preserve loaded data after a refresh failure and mark it stale; offer Reload - Preserve loaded data after a refresh failure and mark it stale; offer Reload
as the recovery action. Distinguish initial loading, empty, unavailable, as the recovery action. Distinguish initial loading, empty, unavailable,
permission-blocked, conflict, success, and retry states. permission-blocked, conflict, success, and retry states.
## Review Evidence ## Review Evidence
Every new or changed page must have structural evidence for its page frame, Every new or changed page or workspace pane must have structural evidence for
semantic archetype and slot order, refresh declaration, shared component its frame, semantic archetype/scope and slot order, refresh declaration, shared
usage, stable disabled actions, dirty guard, destructive boundary, and component usage, stable disabled actions, dirty guard, destructive boundary,
module-owned help identity. Type checks enforce conditional Reload and editor and module-owned help identity. Type checks enforce conditional Reload and
persistence props. Product checks reject undeclared archetypes and ad-hoc editor persistence props. The product check discovers all consumers, rejects
headed action fragments. Browser conformance confirms keyboard order, live undeclared archetypes and `ActionToolbar` panel-header copies, and requires
dirty-state changes, accessibility, destructive separation, narrow wrapping, semantic actions for every `WorkspaceFrame` route. Browser conformance confirms
and screenshot geometry. keyboard order, lifecycle changes, accessibility, destructive separation,
narrow wrapping, and screenshot geometry.
+6 -1
View File
@@ -113,7 +113,7 @@ class ProductAreaContribution:
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class QuickAccessTool: class QuickAccessTool:
"""Declare a compact module-owned tool for an optional Quick Access rail.""" """Declare a versioned, bounded module-owned Quick Access tool."""
id: str id: str
module_id: str module_id: str
@@ -128,6 +128,11 @@ class QuickAccessTool:
order: int = 100 order: int = 100
default_enabled: bool = True default_enabled: bool = True
modes: tuple[str, ...] = ("browse",) modes: tuple[str, ...] = ("browse",)
contract_version: str = "1"
availability: Literal["global", "active_object"] = "global"
accepted_reference_kinds: tuple[str, ...] = ()
returned_reference_kinds: tuple[str, ...] = ()
help_context_id: str | None = None
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
+22
View File
@@ -1468,6 +1468,28 @@ def _validate_quick_access_tool(
raise RegistryError( raise RegistryError(
f"Quick Access tool {tool.id!r} must declare at least one valid mode" f"Quick Access tool {tool.id!r} must declare at least one valid mode"
) )
if tool.contract_version != "1":
raise RegistryError(
f"Quick Access tool {tool.id!r} uses unsupported contract version "
f"{tool.contract_version!r}"
)
if tool.availability not in {"global", "active_object"}:
raise RegistryError(
f"Quick Access tool {tool.id!r} has invalid availability"
)
reference_kinds = (*tool.accepted_reference_kinds, *tool.returned_reference_kinds)
if any(not _QUICK_ACCESS_TOOL_ID_RE.fullmatch(kind) for kind in reference_kinds):
raise RegistryError(
f"Quick Access tool {tool.id!r} contains an invalid reference kind"
)
if tool.availability == "active_object" and not tool.accepted_reference_kinds:
raise RegistryError(
f"Contextual Quick Access tool {tool.id!r} must declare accepted reference kinds"
)
if tool.help_context_id is not None and not validate_view_surface_id(tool.help_context_id):
raise RegistryError(
f"Quick Access tool {tool.id!r} has an invalid help context id"
)
def _validate_view_surfaces(manifest: ModuleManifest) -> None: def _validate_view_surfaces(manifest: ModuleManifest) -> None:
+5
View File
@@ -168,6 +168,11 @@ def _quick_access_tool_payload(tool: QuickAccessTool) -> dict[str, object]:
"order": tool.order, "order": tool.order,
"default_enabled": tool.default_enabled, "default_enabled": tool.default_enabled,
"modes": list(tool.modes), "modes": list(tool.modes),
"contract_version": tool.contract_version,
"availability": tool.availability,
"accepted_reference_kinds": list(tool.accepted_reference_kinds),
"returned_reference_kinds": list(tool.returned_reference_kinds),
"help_context_id": tool.help_context_id,
} }
+11
View File
@@ -59,6 +59,8 @@ def presentation_manifest() -> ModuleManifest:
icon="list-checks", icon="list-checks",
full_page_path="/example", full_page_path="/example",
required_any=("example:item:read",), required_any=("example:item:read",),
returned_reference_kinds=("example.item",),
help_context_id="example.quick.summary",
), ),
), ),
), ),
@@ -120,6 +122,15 @@ class PresentationContractTests(unittest.TestCase):
self.assertEqual("1", frontend["presentation_contract_version"]) self.assertEqual("1", frontend["presentation_contract_version"])
self.assertEqual("work", frontend["product_areas"][0]["id"]) self.assertEqual("work", frontend["product_areas"][0]["id"])
self.assertEqual("example.summary", frontend["quick_access_tools"][0]["id"]) self.assertEqual("example.summary", frontend["quick_access_tools"][0]["id"])
self.assertEqual("1", frontend["quick_access_tools"][0]["contract_version"])
self.assertEqual(
["example.item"],
frontend["quick_access_tools"][0]["returned_reference_kinds"],
)
self.assertEqual(
"example.quick.summary",
frontend["quick_access_tools"][0]["help_context_id"],
)
if __name__ == "__main__": if __name__ == "__main__":
+69 -5
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react"; import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import QuickAccessRail from "../../../govoplan-quick-access/webui/src/components/QuickAccessRail";
import ActionToolbar from "../src/components/ActionToolbar"; import ActionToolbar from "../src/components/ActionToolbar";
import Button from "../src/components/Button"; import Button from "../src/components/Button";
import Card from "../src/components/Card"; import Card from "../src/components/Card";
@@ -23,17 +24,20 @@ import SelectionList, { SelectionListItem, SelectionListItemContent } from "../s
import StatePanel from "../src/components/StatePanel"; import StatePanel from "../src/components/StatePanel";
import WorkspaceFrame from "../src/components/WorkspaceFrame"; import WorkspaceFrame from "../src/components/WorkspaceFrame";
import WorkspaceLayout from "../src/components/WorkspaceLayout"; import WorkspaceLayout from "../src/components/WorkspaceLayout";
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
import BreadcrumbBar from "../src/layout/BreadcrumbBar"; import BreadcrumbBar from "../src/layout/BreadcrumbBar";
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard"; import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
import { import {
createQuickAccessLaunchContext, createQuickAccessLaunchContext,
quickAccessLaunchState quickAccessLaunchState
} from "../src/platform/launchContext"; } from "../src/platform/launchContext";
import type { AuthInfo } from "../src/types"; import type { ApiSettings, AuthInfo, QuickAccessToolMetadata } from "../src/types";
export default function ConformanceApp() { export default function ConformanceApp() {
const location = useLocation();
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editorDirty, setEditorDirty] = useState(true); const [editorDirty, setEditorDirty] = useState(true);
const [metricDrilldown, setMetricDrilldown] = useState("");
return ( return (
<main className="conformance-root" data-conformance-id="shared-ui-lab"> <main className="conformance-root" data-conformance-id="shared-ui-lab">
@@ -49,9 +53,9 @@ export default function ConformanceApp() {
<PageActionBar <PageActionBar
variant="editor" variant="editor"
refreshable refreshable
dirty={editorDirty} state={editorDirty ? "dirty" : "clean"}
label="Bearbeitungsaktionen" label="Bearbeitungsaktionen"
reloadAction={<Button variant="ghost">Neu laden</Button>} reloadAction={{ onReload: () => undefined, label: "Neu laden" }}
contextActions={<Button>Vorschau öffnen</Button>} contextActions={<Button>Vorschau öffnen</Button>}
destructiveActions={<Button variant="danger" disabledReason="Nur die federführende Stelle darf diesen Vorgang endgültig löschen.">Löschen</Button>} destructiveActions={<Button variant="danger" disabledReason="Nur die federführende Stelle darf diesen Vorgang endgültig löschen.">Löschen</Button>}
discardAction={{ label: "Verwerfen", onClick: () => setEditorDirty(false) }} discardAction={{ label: "Verwerfen", onClick: () => setEditorDirty(false) }}
@@ -73,11 +77,12 @@ export default function ConformanceApp() {
<section className="conformance-section" aria-labelledby="metrics-heading"> <section className="conformance-section" aria-labelledby="metrics-heading">
<h2 id="metrics-heading">Kennzahlen und Eigenschaften</h2> <h2 id="metrics-heading">Kennzahlen und Eigenschaften</h2>
<MetricGrid columns={4} collapseAt="standard"> <MetricGrid columns={4} collapseAt="standard">
<MetricCard label="Offene Aufgaben" value="18" detail="3 heute fällig" /> <MetricCard label="Offene Aufgaben" value="18" detail="3 heute fällig" drilldown={{ label: "Offene Aufgaben prüfen", onActivate: () => setMetricDrilldown("18 offene Aufgaben") }} />
<MetricCard label="Fristgerecht" value="94 %" tone="good" detail="Letzte 30 Tage" /> <MetricCard label="Fristgerecht" value="94 %" tone="good" detail="Letzte 30 Tage" />
<MetricCard label="Klärung erforderlich" value="4" tone="warning" density="compact" /> <MetricCard label="Klärung erforderlich" value="4" tone="warning" density="compact" />
<MetricCard label="Fehlgeschlagen" value="1" tone="danger" surface="subtle" /> <MetricCard label="Fehlgeschlagen" value="1" tone="danger" surface="subtle" />
</MetricGrid> </MetricGrid>
<p data-testid="metric-drilldown-result" role="status">{metricDrilldown}</p>
<ContentSection density="default" surface="subtle"> <ContentSection density="default" surface="subtle">
<h3>Nachvollziehbare Entscheidung</h3> <h3>Nachvollziehbare Entscheidung</h3>
<DescriptionList columns={3} collapseAt="standard"> <DescriptionList columns={3} collapseAt="standard">
@@ -98,6 +103,8 @@ export default function ConformanceApp() {
primaryLabel="Vorgänge" primaryLabel="Vorgänge"
contentLabel="Ausgewählter Vorgang" contentLabel="Ausgewählter Vorgang"
primary={ primary={
<>
<WorkspaceActionBar scope="collection-pane" variant="collection" refreshable reloadAction={{ onReload: () => undefined, label: "Vorgänge neu laden" }} contextActions={<strong>Vorgänge</strong>} createAction={<Button variant="primary">Neu</Button>} />
<SelectionList label="Vorgänge" variant="navigation"> <SelectionList label="Vorgänge" variant="navigation">
<SelectionListItem selected> <SelectionListItem selected>
<SelectionListItemContent leading={<FileText size={18} />} title="Anwohnerparkausweis" description="A-2026-004218 · Prüfung läuft" /> <SelectionListItemContent leading={<FileText size={18} />} title="Anwohnerparkausweis" description="A-2026-004218 · Prüfung läuft" />
@@ -106,9 +113,12 @@ export default function ConformanceApp() {
<SelectionListItemContent leading={<FileText size={18} />} title="Sondernutzung öffentlicher Fläche" description="A-2026-004219 · Rückfrage offen" /> <SelectionListItemContent leading={<FileText size={18} />} title="Sondernutzung öffentlicher Fläche" description="A-2026-004219 · Rückfrage offen" />
</SelectionListItem> </SelectionListItem>
</SelectionList> </SelectionList>
</>
} }
> >
<Card title="Anwohnerparkausweis" actions={<Button variant="primary">Bearbeiten</Button>}> <>
<WorkspaceActionBar scope="detail-pane" variant="detail" contextActions={<strong>Anwohnerparkausweis</strong>} primaryActions={<Button variant="primary">Bearbeiten</Button>} destructiveActions={<Button variant="danger">Schließen</Button>} />
<Card title="Anwohnerparkausweis">
<p>Die Identität wurde geprüft. Ein aktueller Wohnsitznachweis muss noch bestätigt werden.</p> <p>Die Identität wurde geprüft. Ein aktueller Wohnsitznachweis muss noch bestätigt werden.</p>
<FormSection title="Nächster Arbeitsschritt" description="Die Entscheidung bleibt nachvollziehbar und kann vor Abschluss korrigiert werden." variant="panel"> <FormSection title="Nächster Arbeitsschritt" description="Die Entscheidung bleibt nachvollziehbar und kann vor Abschluss korrigiert werden." variant="panel">
<FormGrid columns={2}> <FormGrid columns={2}>
@@ -117,6 +127,7 @@ export default function ConformanceApp() {
</FormGrid> </FormGrid>
</FormSection> </FormSection>
</Card> </Card>
</>
</WorkspaceLayout> </WorkspaceLayout>
</WorkspaceFrame> </WorkspaceFrame>
</section> </section>
@@ -154,10 +165,36 @@ export default function ConformanceApp() {
</DialogSection> </DialogSection>
</DialogForm> </DialogForm>
</Dialog> </Dialog>
{new URLSearchParams(location.search).has("quick-access") ? <QuickAccessScenario /> : null}
</main> </main>
); );
} }
function QuickAccessScenario() {
const location = useLocation();
const launchContext = useMemo(() => createQuickAccessLaunchContext({
pathname: location.pathname,
search: location.search,
hash: location.hash,
historyIndex: browserHistoryIndex(),
auth: CONFORMANCE_AUTH,
activeObject: {
ownerModule: "cases",
kind: "case",
objectId: "case-1",
tenantId: "tenant-1",
label: "RPP-2026-0001 · Anwohnerparkausweis"
},
temporalContext: { validityMode: "current", validAt: null, recordedAt: null }
}), [location.hash, location.pathname, location.search]);
return <QuickAccessRail
settings={CONFORMANCE_SETTINGS}
auth={CONFORMANCE_AUTH}
tools={CONFORMANCE_QUICK_ACCESS_TOOLS}
launchContext={launchContext}
/>;
}
function LaunchContextScenario() { function LaunchContextScenario() {
const location = useLocation(); const location = useLocation();
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
@@ -210,6 +247,33 @@ const CONFORMANCE_AUTH = {
groups_loaded: true groups_loaded: true
} satisfies AuthInfo; } satisfies AuthInfo;
const CONFORMANCE_SETTINGS: ApiSettings = {
apiBaseUrl: "",
apiKey: "",
accessToken: ""
};
const CONFORMANCE_QUICK_ACCESS_TOOLS: QuickAccessToolMetadata[] = [{
contractVersion: "1",
id: "files.recent",
moduleId: "files",
categoryId: "files",
label: "Recent files",
description: "Select an authorized file without leaving this case.",
iconName: "files",
surfaceId: "files.route.files",
fullPagePath: "/files",
allOf: [],
anyOf: [],
order: 10,
defaultEnabled: true,
modes: ["select"],
availability: "global",
acceptedReferenceKinds: [],
returnedReferenceKinds: ["files.file-version"],
helpContextId: "files.quick_access.files"
}];
function browserHistoryIndex(): number | null { function browserHistoryIndex(): number | null {
const value = (window.history.state as { idx?: unknown } | null)?.idx; const value = (window.history.state as { idx?: unknown } | null)?.idx;
return typeof value === "number" && Number.isInteger(value) ? value : null; return typeof value === "number" && Number.isInteger(value) ? value : null;
@@ -0,0 +1,24 @@
// Narrow facade used only by the conformance build. It lets the optional
// Quick Access module exercise its real rail without pulling the composed
// application's generated module catalogue into this isolated test bundle.
export { apiFetch } from "../src/api/client";
export { default as DismissibleAlert } from "../src/components/DismissibleAlert";
export { default as DocumentationHelpLink } from "../src/components/help/DocumentationHelpLink";
export { default as IconButton } from "../src/components/IconButton";
export { default as LoadingFrame } from "../src/components/LoadingFrame";
export { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
export { usePlatformLanguage } from "../src/i18n/LanguageContext";
export {
dispatchQuickAccessResult,
quickAccessLaunchState
} from "../src/platform/launchContext";
export type {
ApiSettings,
QuickAccessRailProps,
QuickAccessToolsUiCapability
} from "../src/types";
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
void capabilityName;
return [];
}
+1
View File
@@ -10,6 +10,7 @@ import "../src/styles/tables.css";
import "../src/styles/badges.css"; import "../src/styles/badges.css";
import "../src/styles/components.css"; import "../src/styles/components.css";
import "../src/styles/dialogs.css"; import "../src/styles/dialogs.css";
import "@govoplan/quick-access-webui/styles/quick-access.css";
import "./conformance.css"; import "./conformance.css";
const theme = new URLSearchParams(window.location.search).get("theme"); const theme = new URLSearchParams(window.location.search).get("theme");
Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 227 KiB

@@ -62,6 +62,11 @@ test("shared components remain accessible and keyboard operable", async ({ page
await expect(editorActions.getByRole("button", { name: "Verwerfen" })).toBeDisabled(); await expect(editorActions.getByRole("button", { name: "Verwerfen" })).toBeDisabled();
await expect(editorActions.getByRole("button", { name: "Änderungen speichern" })).toBeDisabled(); await expect(editorActions.getByRole("button", { name: "Änderungen speichern" })).toBeDisabled();
const metricDrilldown = page.getByRole("button", { name: "Offene Aufgaben prüfen" });
await metricDrilldown.focus();
await page.keyboard.press("Enter");
await expect(page.getByTestId("metric-drilldown-result")).toHaveText("18 offene Aufgaben");
const opener = page.getByTestId("open-dialog"); const opener = page.getByTestId("open-dialog");
await opener.focus(); await opener.focus();
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
@@ -82,6 +87,64 @@ test("full-page tool launch preserves a bounded return context", async ({ page }
await expect(page).toHaveURL(/\/?\?theme=light$/); await expect(page).toHaveURL(/\/?\?theme=light$/);
}); });
test("Quick Access preserves focus and adapts to a narrow viewport", async ({ page }) => {
await page.route("**/api/v1/quick-access/effective*", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
categories: [{
id: "files",
label: "Files",
description: "Files beside the current task",
icon: "files",
order: 10,
enabled: true,
forced: false,
locked_by: null,
tools: [{
contract_version: "1",
id: "files.recent",
module_id: "files",
category_id: "files",
label: "Recent files",
description: "Select an authorized file without leaving this case.",
icon: "files",
surface_id: "files.route.files",
full_page_path: "/files",
required_all: [],
required_any: [],
order: 10,
default_enabled: true,
modes: ["select"],
availability: "global",
accepted_reference_kinds: [],
returned_reference_kinds: ["files.file-version"],
help_context_id: "files.quick_access.files",
enabled: true,
forced: false,
locked_by: null
}]
}],
diagnostics: []
})
});
});
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/?theme=light&quick-access=1");
const filesTrigger = page.getByRole("button", { name: "Files" });
await expect(filesTrigger).toBeVisible();
await filesTrigger.click();
const drawer = page.getByRole("dialog", { name: "Files" });
await expect(drawer).toBeVisible();
await expect(drawer.getByRole("button", { name: "Close" })).toBeFocused();
const drawerBounds = await drawer.boundingBox();
expect(drawerBounds?.x).toBeGreaterThanOrEqual(0);
expect((drawerBounds?.x ?? 0) + (drawerBounds?.width ?? 0)).toBeLessThanOrEqual(390);
await page.keyboard.press("Escape");
await expect(drawer).toBeHidden();
await expect(filesTrigger).toBeFocused();
});
test("light and dark desktop geometry remains stable", async ({ page }) => { test("light and dark desktop geometry remains stable", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 1000 }); await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto("/?theme=light"); await page.goto("/?theme=light");
@@ -20,6 +20,7 @@ const pageLayout = read("src/components/PageLayout.tsx");
const workspaceLayout = read("src/components/WorkspaceLayout.tsx"); const workspaceLayout = read("src/components/WorkspaceLayout.tsx");
const actionToolbar = read("src/components/ActionToolbar.tsx"); const actionToolbar = read("src/components/ActionToolbar.tsx");
const pageActionBar = read("src/components/PageActionBar.tsx"); const pageActionBar = read("src/components/PageActionBar.tsx");
const workspaceActionBar = read("src/components/WorkspaceActionBar.tsx");
const contentGrid = read("src/components/ContentGrid.tsx"); const contentGrid = read("src/components/ContentGrid.tsx");
const formSection = read("src/components/FormSection.tsx"); const formSection = read("src/components/FormSection.tsx");
const dialogAnatomy = read("src/components/DialogAnatomy.tsx"); const dialogAnatomy = read("src/components/DialogAnatomy.tsx");
@@ -29,7 +30,7 @@ const authGateStyles = read("src/styles/auth-gate.css");
assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation"); assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation");
assert.match(settings, /archetype=\{editorSection \? "editor" : "workspace"\}/, "settings declare editor intent only for draft-owning sections"); assert.match(settings, /archetype=\{editorSection \? "editor" : "workspace"\}/, "settings declare editor intent only for draft-owning sections");
assert.match(settings, /<PageActionBar[\s\S]*variant="editor"[\s\S]*dirty=\{editorDirty\}[\s\S]*discardAction=[\s\S]*saveAction=/, "settings use central dirty persistence actions"); assert.match(settings, /<PageActionBar[\s\S]*variant="editor"[\s\S]*state=\{editorSaving \? "saving" : editorDirty \? "dirty" : "clean"\}[\s\S]*discardAction=[\s\S]*saveAction=/, "settings use central lifecycle-aware persistence actions");
assert.match(retention, /<ActionBlockerHint/, "retention renders the shared actionable blocker"); assert.match(retention, /<ActionBlockerHint/, "retention renders the shared actionable blocker");
assert.match(retention, /contextId: "privacy\.retention"/, "retention exposes stable admin documentation"); assert.match(retention, /contextId: "privacy\.retention"/, "retention exposes stable admin documentation");
@@ -66,12 +67,14 @@ assert.match(pageActionBar, /variant: "detail"/, "detail pages have a semantic a
assert.match(pageActionBar, /variant: "editor"/, "editors have a semantic action-bar contract"); assert.match(pageActionBar, /variant: "editor"/, "editors have a semantic action-bar contract");
assert.match(pageActionBar, /variant: "overview"/, "overview pages have a semantic action-bar contract"); assert.match(pageActionBar, /variant: "overview"/, "overview pages have a semantic action-bar contract");
assert.match(pageActionBar, /variant: "workspace"/, "task workspaces have a semantic action-bar contract"); assert.match(pageActionBar, /variant: "workspace"/, "task workspaces have a semantic action-bar contract");
assert.match(pageActionBar, /refreshable: true;\s*reloadAction: ReactNode;/, "refreshable pages require a Reload action at type level"); assert.match(pageActionBar, /refreshable: true;\s*reloadAction: PageReloadAction;/, "refreshable pages require a typed Reload action");
assert.match(pageActionBar, /dirty: boolean;/, "editors require an explicit dirty state"); assert.match(pageActionBar, /state: PageEditorState;/, "editors require an explicit persistence lifecycle state");
assert.match(pageActionBar, /data-page-dirty-state=/, "editors announce clean, dirty, and saving states"); assert.match(pageActionBar, /data-page-dirty-state=/, "editors announce clean, dirty, and saving states");
assert.match(pageActionBar, /<ActionSlot name="reload">/, "page action bars keep reload in a named stable slot"); assert.match(pageActionBar, /<ActionSlot name="reload">/, "page action bars keep reload in a named stable slot");
assert.match(pageActionBar, /data-page-action-separation="destructive"/, "destructive page actions expose a separate semantic group"); assert.match(pageActionBar, /data-page-action-separation="destructive"/, "destructive page actions expose a separate semantic group");
assert.match(pageActionBar, /<ActionSlot name="discard">[\s\S]*<ActionSlot name="save">/, "editor save follows discard in the trailing group"); assert.match(pageActionBar, /<ActionSlot name="discard">[\s\S]*<ActionSlot name="save">/, "editor save follows discard in the trailing group");
assert.match(workspaceActionBar, /actionScope=\{scope\}/, "workspace and pane action bars project their semantic scope centrally");
assert.match(workspaceActionBar, /WorkspaceActionBarProps = PageActionBarProps/, "workspace editor panes reuse the page persistence contract");
assert.match(contentGrid, /content-grid-collapse-\$\{collapseAt\}/, "shared grids make their collapse point explicit"); assert.match(contentGrid, /content-grid-collapse-\$\{collapseAt\}/, "shared grids make their collapse point explicit");
assert.match(formSection, /form-section-header/, "shared form sections own heading and action placement"); assert.match(formSection, /form-section-header/, "shared form sections own heading and action placement");
assert.match(dialogAnatomy, /dialog-actions-\$\{align\}/, "shared dialog anatomy owns footer action placement"); assert.match(dialogAnatomy, /dialog-actions-\$\{align\}/, "shared dialog anatomy owns footer action placement");
+58 -1
View File
@@ -1,7 +1,29 @@
import { ArrowRight } from "lucide-react";
import type { HTMLAttributes, ReactNode } from "react"; import type { HTMLAttributes, ReactNode } from "react";
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext"; import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
import Button from "./Button";
export type MetricCardProps = HTMLAttributes<HTMLDivElement> & { type MetricDrilldownBase = {
label: ReactNode;
accessibleLabel?: string;
};
export type MetricDrilldown = MetricDrilldownBase & (
| {
href: string;
onActivate?: never;
disabled?: never;
disabledReason?: never;
}
| {
href?: never;
onActivate: () => void;
disabled?: boolean;
disabledReason?: ReactNode;
}
);
export type MetricCardProps = Omit<HTMLAttributes<HTMLDivElement>, "onClick" | "role" | "tabIndex"> & {
label: ReactNode; label: ReactNode;
value: ReactNode; value: ReactNode;
tone?: "neutral" | "good" | "warning" | "danger" | "info"; tone?: "neutral" | "good" | "warning" | "danger" | "info";
@@ -9,6 +31,7 @@ export type MetricCardProps = HTMLAttributes<HTMLDivElement> & {
valueTitle?: string; valueTitle?: string;
density?: "compact" | "default"; density?: "compact" | "default";
surface?: "card" | "subtle" | "flat"; surface?: "card" | "subtle" | "flat";
drilldown?: MetricDrilldown;
}; };
export default function MetricCard({ export default function MetricCard({
@@ -19,6 +42,7 @@ export default function MetricCard({
valueTitle, valueTitle,
density = "default", density = "default",
surface = "card", surface = "card",
drilldown,
className = "", className = "",
...props ...props
}: MetricCardProps) { }: MetricCardProps) {
@@ -26,6 +50,10 @@ export default function MetricCard({
const renderedLabel = translateReactNode(label, translateText); const renderedLabel = translateReactNode(label, translateText);
const renderedValue = translateReactNode(value, translateText); const renderedValue = translateReactNode(value, translateText);
const renderedDetail = translateReactNode(detail, translateText); const renderedDetail = translateReactNode(detail, translateText);
const renderedDrilldownLabel = translateReactNode(drilldown?.label, translateText);
const drilldownAccessibleLabel = drilldown?.accessibleLabel
? translateText(drilldown.accessibleLabel)
: undefined;
return ( return (
<div {...props} className={[ <div {...props} className={[
@@ -38,6 +66,35 @@ export default function MetricCard({
<div className="metric-label">{renderedLabel}</div> <div className="metric-label">{renderedLabel}</div>
<div className="metric-value" title={valueTitle}>{renderedValue}</div> <div className="metric-value" title={valueTitle}>{renderedValue}</div>
{renderedDetail ? <div className="metric-detail">{renderedDetail}</div> : null} {renderedDetail ? <div className="metric-detail">{renderedDetail}</div> : null}
{drilldown ? (
<div className="metric-card-drilldown-slot">
{drilldown.href !== undefined ? (
<a
className="btn btn-ghost metric-card-drilldown"
href={drilldown.href}
aria-label={drilldownAccessibleLabel}
data-metric-drilldown="link"
>
{renderedDrilldownLabel}
<ArrowRight size={14} aria-hidden="true" />
</a>
) : (
<Button
type="button"
variant="ghost"
className="metric-card-drilldown"
aria-label={drilldownAccessibleLabel}
onClick={drilldown.onActivate}
disabled={drilldown.disabled}
disabledReason={drilldown.disabledReason}
data-metric-drilldown="action"
>
{renderedDrilldownLabel}
<ArrowRight size={14} aria-hidden="true" />
</Button>
)}
</div>
) : null}
</div> </div>
); );
} }
+111 -20
View File
@@ -1,11 +1,24 @@
import { RefreshCw } from "lucide-react";
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react"; import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import type { PlatformInterfaceIdentityProps } from "../types"; import type { PlatformInterfaceIdentityProps } from "../types";
import ActionToolbar, { ToolbarGroup } from "./ActionToolbar"; import ActionToolbar, { ToolbarGroup, type ActionToolbarDensity, type ActionToolbarSurface } from "./ActionToolbar";
import Button from "./Button"; import Button from "./Button";
import { useUnsavedChanges } from "./UnsavedChangesContext";
export type PageReloadAction = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children" | "onClick"> & PlatformInterfaceIdentityProps & {
onReload: () => void;
label?: ReactNode;
state?: PageRefreshState;
loading?: boolean;
loadingLabel?: ReactNode;
disabledReason?: ReactNode;
};
export type PageRefreshState = "current" | "stale" | "reloading" | "reload-failed";
type RefreshablePageActions = { type RefreshablePageActions = {
refreshable: true; refreshable: true;
reloadAction: ReactNode; reloadAction: PageReloadAction;
} | { } | {
refreshable?: false; refreshable?: false;
reloadAction?: never; reloadAction?: never;
@@ -23,6 +36,8 @@ export type PageEditorAction = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "ch
disabledReason?: ReactNode; disabledReason?: ReactNode;
}; };
export type PageEditorState = "clean" | "dirty" | "invalid" | "saving" | "save-failed" | "conflict";
export type OverviewPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & { export type OverviewPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "overview"; variant: "overview";
primaryActions?: ReactNode; primaryActions?: ReactNode;
@@ -41,13 +56,16 @@ export type DetailPageActionBarProps = PageActionBarCommonProps & RefreshablePag
export type EditorPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & { export type EditorPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "editor"; variant: "editor";
dirty: boolean; state: PageEditorState;
saving?: boolean;
dirtyLabel?: ReactNode; dirtyLabel?: ReactNode;
cleanLabel?: ReactNode; cleanLabel?: ReactNode;
invalidLabel?: ReactNode;
savingLabel?: ReactNode; savingLabel?: ReactNode;
saveFailedLabel?: ReactNode;
conflictLabel?: ReactNode;
cleanDisabledReason?: ReactNode; cleanDisabledReason?: ReactNode;
savingDisabledReason?: ReactNode; savingDisabledReason?: ReactNode;
invalidDisabledReason?: ReactNode;
discardAction: PageEditorAction; discardAction: PageEditorAction;
saveAction: PageEditorAction; saveAction: PageEditorAction;
primaryActions?: ReactNode; primaryActions?: ReactNode;
@@ -67,6 +85,14 @@ export type PageActionBarProps =
| EditorPageActionBarProps | EditorPageActionBarProps
| WorkspacePageActionBarProps; | WorkspacePageActionBarProps;
export type SemanticActionBarScope = "page" | "workspace" | "collection-pane" | "detail-pane" | "editor-pane";
type SemanticActionBarPresentation = {
actionScope?: SemanticActionBarScope;
density?: ActionToolbarDensity;
surface?: ActionToolbarSurface;
};
function ActionSlot({ name, children }: { name: string; children: ReactNode }) { function ActionSlot({ name, children }: { name: string; children: ReactNode }) {
return ( return (
<span className={`page-action-slot page-action-slot-${name}`} data-page-action-slot={name}> <span className={`page-action-slot page-action-slot-${name}`} data-page-action-slot={name}>
@@ -87,13 +113,47 @@ function DestructiveSlot({ children }: { children: ReactNode }) {
if (!children) return null; if (!children) return null;
return ( return (
<ActionSlot name="destructive"> <ActionSlot name="destructive">
<span className="page-action-destructive-group" data-page-action-separation="destructive"> <span
className="page-action-destructive-group"
data-page-action-separation="destructive"
role="group"
aria-label="Destructive actions"
>
{children} {children}
</span> </span>
</ActionSlot> </ActionSlot>
); );
} }
function ReloadAction({ action }: { action: PageReloadAction }) {
const { requestNavigation } = useUnsavedChanges();
const {
onReload,
label = "i18n:govoplan-core.reload.cce71553",
state = "current",
loading = false,
loadingLabel = "Reloading…",
disabledReason,
disabled,
...buttonProps
} = action;
const reloading = loading || state === "reloading";
const reason = reloading ? "The page is already reloading." : disabledReason;
return (
<Button
{...buttonProps}
variant="ghost"
disabled={disabled || reloading}
disabledReason={reason}
aria-busy={reloading || undefined}
onClick={() => requestNavigation(onReload)}
>
<RefreshCw size={16} aria-hidden="true" />
{reloading ? loadingLabel : label}
</Button>
);
}
function EditorAction({ function EditorAction({
action, action,
variant, variant,
@@ -117,23 +177,29 @@ function EditorAction({
* The named slots deliberately encode ordering. Product modules still own the * The named slots deliberately encode ordering. Product modules still own the
* actions, wording, permissions, blockers, and consequences placed in them. * actions, wording, permissions, blockers, and consequences placed in them.
*/ */
export default function PageActionBar(props: PageActionBarProps) { export default function PageActionBar(props: PageActionBarProps & SemanticActionBarPresentation) {
const normalizedProps = props as PageActionBarProps & { const normalizedProps = props as PageActionBarProps & SemanticActionBarPresentation & {
createAction?: ReactNode; createAction?: ReactNode;
primaryActions?: ReactNode; primaryActions?: ReactNode;
destructiveActions?: ReactNode; destructiveActions?: ReactNode;
dirty?: boolean; state?: PageEditorState;
saving?: boolean;
dirtyLabel?: ReactNode; dirtyLabel?: ReactNode;
cleanLabel?: ReactNode; cleanLabel?: ReactNode;
invalidLabel?: ReactNode;
savingLabel?: ReactNode; savingLabel?: ReactNode;
saveFailedLabel?: ReactNode;
conflictLabel?: ReactNode;
cleanDisabledReason?: ReactNode; cleanDisabledReason?: ReactNode;
savingDisabledReason?: ReactNode; savingDisabledReason?: ReactNode;
invalidDisabledReason?: ReactNode;
discardAction?: PageEditorAction; discardAction?: PageEditorAction;
saveAction?: PageEditorAction; saveAction?: PageEditorAction;
}; };
const { const {
variant, variant,
actionScope = "page",
density,
surface,
refreshable = false, refreshable = false,
reloadAction, reloadAction,
contextActions, contextActions,
@@ -141,13 +207,16 @@ export default function PageActionBar(props: PageActionBarProps) {
createAction, createAction,
primaryActions, primaryActions,
destructiveActions, destructiveActions,
dirty = false, state = "clean",
saving = false,
dirtyLabel = "Unsaved changes", dirtyLabel = "Unsaved changes",
cleanLabel = "Saved", cleanLabel = "Saved",
invalidLabel = "Review required",
savingLabel = "Saving…", savingLabel = "Saving…",
saveFailedLabel = "Save failed",
conflictLabel = "Conflict",
cleanDisabledReason = "There are no unsaved changes.", cleanDisabledReason = "There are no unsaved changes.",
savingDisabledReason = "Changes are already being saved.", savingDisabledReason = "Changes are already being saved.",
invalidDisabledReason = "Resolve the validation problems before saving.",
discardAction, discardAction,
saveAction, saveAction,
label, label,
@@ -172,16 +241,23 @@ export default function PageActionBar(props: PageActionBarProps) {
</> </>
); );
} else if (variant === "editor") { } else if (variant === "editor") {
const persistenceDisabledReason = saving ? savingDisabledReason : !dirty ? cleanDisabledReason : undefined; const discardDisabledReason = state === "saving" ? savingDisabledReason : state === "clean" ? cleanDisabledReason : undefined;
const saveDisabledReason = state === "saving"
? savingDisabledReason
: state === "clean"
? cleanDisabledReason
: state === "invalid"
? invalidDisabledReason
: undefined;
trailingActions = ( trailingActions = (
<> <>
{primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null} {primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null}
<DestructiveSlot>{destructiveActions}</DestructiveSlot> <DestructiveSlot>{destructiveActions}</DestructiveSlot>
<ActionSlot name="discard"> <ActionSlot name="discard">
<EditorAction action={discardAction!} variant="ghost" disabledReason={persistenceDisabledReason} /> <EditorAction action={discardAction!} variant="ghost" disabledReason={discardDisabledReason} />
</ActionSlot> </ActionSlot>
<ActionSlot name="save"> <ActionSlot name="save">
<EditorAction action={saveAction!} variant="primary" disabledReason={persistenceDisabledReason} /> <EditorAction action={saveAction!} variant="primary" disabledReason={saveDisabledReason} />
</ActionSlot> </ActionSlot>
</> </>
); );
@@ -198,31 +274,46 @@ export default function PageActionBar(props: PageActionBarProps) {
<ActionToolbar <ActionToolbar
{...toolbarProps} {...toolbarProps}
className={["page-action-bar", `page-action-bar-${variant}`, className].filter(Boolean).join(" ")} className={["page-action-bar", `page-action-bar-${variant}`, className].filter(Boolean).join(" ")}
density="compact" density={density ?? "compact"}
justify="between" justify="between"
wrap="responsive" wrap="responsive"
label={label ?? defaultLabels[variant]} label={label ?? defaultLabels[variant]}
surface={surface}
interfaceId={interfaceId} interfaceId={interfaceId}
helpContextId={helpContextId} helpContextId={helpContextId}
helpModuleId={helpModuleId} helpModuleId={helpModuleId}
helpTopicId={helpTopicId} helpTopicId={helpTopicId}
data-page-action-archetype={variant} data-page-action-archetype={variant}
data-action-bar-scope={actionScope}
data-page-refreshable={refreshable ? "true" : "false"} data-page-refreshable={refreshable ? "true" : "false"}
data-page-dirty={variant === "editor" ? (dirty ? "true" : "false") : undefined} data-page-refresh-state={refreshable
? (reloadAction?.loading ? "reloading" : reloadAction?.state ?? "current")
: undefined}
data-page-dirty={variant === "editor" ? (state === "clean" ? "false" : "true") : undefined}
> >
<ToolbarGroup className="page-action-bar-leading" data-page-action-group="leading"> <ToolbarGroup className="page-action-bar-leading" data-page-action-group="leading">
{refreshable ? <ActionSlot name="reload">{reloadAction}</ActionSlot> : null} {refreshable ? <ActionSlot name="reload"><ReloadAction action={reloadAction!} /></ActionSlot> : null}
{contextActions ? <ActionSlot name="context">{contextActions}</ActionSlot> : null} {contextActions ? <ActionSlot name="context">{contextActions}</ActionSlot> : null}
</ToolbarGroup> </ToolbarGroup>
<ToolbarGroup className="page-action-bar-trailing" align="end" data-page-action-group="trailing"> <ToolbarGroup className="page-action-bar-trailing" align="end" data-page-action-group="trailing">
{variant === "editor" ? ( {variant === "editor" ? (
<span <span
className={`page-dirty-state page-dirty-state-${saving ? "saving" : dirty ? "dirty" : "clean"}`} className={`page-dirty-state page-dirty-state-${state}`}
data-page-dirty-state={saving ? "saving" : dirty ? "dirty" : "clean"} data-page-dirty-state={state}
role="status" role="status"
aria-live="polite" aria-live="polite"
> >
{saving ? savingLabel : dirty ? dirtyLabel : cleanLabel} {state === "saving"
? savingLabel
: state === "invalid"
? invalidLabel
: state === "save-failed"
? saveFailedLabel
: state === "conflict"
? conflictLabel
: state === "dirty"
? dirtyLabel
: cleanLabel}
</span> </span>
) : null} ) : null}
{helpAction ? <ActionSlot name="help">{helpAction}</ActionSlot> : null} {helpAction ? <ActionSlot name="help">{helpAction}</ActionSlot> : null}
@@ -0,0 +1,30 @@
import { createContext, useContext } from "react";
export type UnsavedNavigationAction = () => void;
export type UnsavedChangesRegistration = {
title?: string;
message?: string;
onSave: () => boolean | Promise<boolean>;
onDiscard?: () => void;
};
export type UnsavedChangesContextValue = {
hasUnsavedChanges: boolean;
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
requestNavigation: (action: UnsavedNavigationAction) => void;
requestDiscard: (action: UnsavedNavigationAction) => void;
};
export const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
hasUnsavedChanges: false,
registerUnsavedChanges: () => () => undefined,
requestNavigation: (action) => action(),
requestDiscard: (action) => action()
};
export function useUnsavedChanges() {
return useContext(UnsavedChangesContext) ?? fallbackUnsavedChangesContext;
}
+10 -33
View File
@@ -1,17 +1,18 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate, type NavigateFunction, type NavigateOptions, type To } from "react-router"; import { useNavigate, type NavigateFunction, type NavigateOptions, type To } from "react-router";
import Button from "./Button"; import Button from "./Button";
import Dialog from "./Dialog"; import Dialog from "./Dialog";
import DismissibleAlert from "./DismissibleAlert"; import DismissibleAlert from "./DismissibleAlert";
import {
UnsavedChangesContext,
useUnsavedChanges,
type UnsavedChangesContextValue,
type UnsavedChangesRegistration,
type UnsavedNavigationAction
} from "./UnsavedChangesContext";
export type UnsavedNavigationAction = () => void; export { useUnsavedChanges } from "./UnsavedChangesContext";
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "./UnsavedChangesContext";
export type UnsavedChangesRegistration = {
title?: string;
message?: string;
onSave: () => boolean | Promise<boolean>;
onDiscard?: () => void;
};
export type UnsavedDraftGuardOptions = { export type UnsavedDraftGuardOptions = {
dirty: boolean; dirty: boolean;
@@ -22,19 +23,6 @@ export type UnsavedDraftGuardOptions = {
enabled?: boolean; enabled?: boolean;
}; };
type UnsavedChangesContextValue = {
hasUnsavedChanges: boolean;
registerUnsavedChanges: (registration: UnsavedChangesRegistration | null) => () => void;
requestNavigation: (action: UnsavedNavigationAction) => void;
/**
* Route an explicit Discard button through the same confirmation used for
* dirty navigation. The action runs after either saving or discarding.
*/
requestDiscard: (action: UnsavedNavigationAction) => void;
};
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null);
export function UnsavedChangesProvider({ children }: {children: ReactNode;}) { export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
const navigate = useNavigate(); const navigate = useNavigate();
const [registrations, setRegistrations] = useState<Array<{id: number;registration: UnsavedChangesRegistration;}>>([]); const [registrations, setRegistrations] = useState<Array<{id: number;registration: UnsavedChangesRegistration;}>>([]);
@@ -190,17 +178,6 @@ export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
} }
const fallbackUnsavedChangesContext: UnsavedChangesContextValue = {
hasUnsavedChanges: false,
registerUnsavedChanges: () => () => undefined,
requestNavigation: (action) => action(),
requestDiscard: (action) => action()
};
export function useUnsavedChanges() {
return useContext(UnsavedChangesContext) ?? fallbackUnsavedChangesContext;
}
export function useRegisterUnsavedChanges(registration: UnsavedChangesRegistration | null) { export function useRegisterUnsavedChanges(registration: UnsavedChangesRegistration | null) {
const { registerUnsavedChanges } = useUnsavedChanges(); const { registerUnsavedChanges } = useUnsavedChanges();
@@ -0,0 +1,29 @@
import type { ComponentProps } from "react";
import PageActionBar, { type PageActionBarProps, type SemanticActionBarScope } from "./PageActionBar";
export type WorkspaceActionScope = Exclude<SemanticActionBarScope, "page">;
export type WorkspaceActionBarProps = PageActionBarProps & {
scope?: WorkspaceActionScope;
};
/**
* Semantic actions for full-canvas workspaces and their collection, detail,
* and editor panes. It deliberately shares the page action engine while using
* compact panel-header geometry.
*/
export default function WorkspaceActionBar({
scope = "workspace",
...props
}: WorkspaceActionBarProps) {
const actionProps = props as ComponentProps<typeof PageActionBar>;
return (
<PageActionBar
{...actionProps}
actionScope={scope}
density="compact"
surface="panel-header"
data-workspace-action-scope={scope}
/>
);
}
+1 -2
View File
@@ -337,8 +337,7 @@ export default function SettingsPage({
actions={editorSection ? ( actions={editorSection ? (
<PageActionBar <PageActionBar
variant="editor" variant="editor"
dirty={editorDirty} state={editorSaving ? "saving" : editorDirty ? "dirty" : "clean"}
saving={editorSaving}
helpAction={<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />} helpAction={<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />}
discardAction={{ label: "i18n:govoplan-core.discard.36fff63c", onClick: discardEditor }} discardAction={{ label: "i18n:govoplan-core.discard.36fff63c", onClick: discardEditor }}
saveAction={{ saveAction={{
+4 -2
View File
@@ -135,7 +135,7 @@ export { default as LoadingIndicator } from "./components/LoadingIndicator";
export { default as ExplorerTree } from "./components/ExplorerTree"; export { default as ExplorerTree } from "./components/ExplorerTree";
export type { ExplorerTreeNodeContext, ExplorerTreeProps } from "./components/ExplorerTree"; export type { ExplorerTreeNodeContext, ExplorerTreeProps } from "./components/ExplorerTree";
export { default as MetricCard } from "./components/MetricCard"; export { default as MetricCard } from "./components/MetricCard";
export type { MetricCardProps } from "./components/MetricCard"; export type { MetricCardProps, MetricDrilldown } from "./components/MetricCard";
export { default as MetricGrid } from "./components/MetricGrid"; export { default as MetricGrid } from "./components/MetricGrid";
export type { MetricGridCollapseAt, MetricGridColumns, MetricGridDensity, MetricGridMinimum, MetricGridProps, MetricGridSpacing } from "./components/MetricGrid"; export type { MetricGridCollapseAt, MetricGridColumns, MetricGridDensity, MetricGridMinimum, MetricGridProps, MetricGridSpacing } from "./components/MetricGrid";
export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel"; export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel";
@@ -144,7 +144,9 @@ export { default as PageTitle } from "./components/PageTitle";
export { default as PageLayout, PageHeader } from "./components/PageLayout"; export { default as PageLayout, PageHeader } from "./components/PageLayout";
export type { PageArchetype, PageHeaderProps, PageLayoutMode, PageLayoutProps } from "./components/PageLayout"; export type { PageArchetype, PageHeaderProps, PageLayoutMode, PageLayoutProps } from "./components/PageLayout";
export { default as PageActionBar } from "./components/PageActionBar"; export { default as PageActionBar } from "./components/PageActionBar";
export type { CollectionPageActionBarProps, DetailPageActionBarProps, EditorPageActionBarProps, OverviewPageActionBarProps, PageActionBarProps, PageEditorAction, WorkspacePageActionBarProps } from "./components/PageActionBar"; export type { CollectionPageActionBarProps, DetailPageActionBarProps, EditorPageActionBarProps, OverviewPageActionBarProps, PageActionBarProps, PageEditorAction, PageEditorState, PageRefreshState, PageReloadAction, SemanticActionBarScope, WorkspacePageActionBarProps } from "./components/PageActionBar";
export { default as WorkspaceActionBar } from "./components/WorkspaceActionBar";
export type { WorkspaceActionBarProps, WorkspaceActionScope } from "./components/WorkspaceActionBar";
export { default as PageScrollViewport } from "./components/PageScrollViewport"; export { default as PageScrollViewport } from "./components/PageScrollViewport";
export type { PageScrollViewportProps } from "./components/PageScrollViewport"; export type { PageScrollViewportProps } from "./components/PageScrollViewport";
export { default as WorkspaceLayout } from "./components/WorkspaceLayout"; export { default as WorkspaceLayout } from "./components/WorkspaceLayout";
+51 -6
View File
@@ -8,7 +8,9 @@ import type {
import type { TemporalDataSelection } from "./temporal"; import type { TemporalDataSelection } from "./temporal";
export const QUICK_ACCESS_LAUNCH_CONTEXT_VERSION = "1" as const; export const QUICK_ACCESS_LAUNCH_CONTEXT_VERSION = "2" as const;
export const QUICK_ACCESS_REFERENCE_CONTRACT_VERSION = "1" as const;
export const QUICK_ACCESS_RESULT_CONTRACT_VERSION = "1" as const;
export const QUICK_ACCESS_LAUNCH_STATE_KEY = "govoplanQuickAccessLaunch"; export const QUICK_ACCESS_LAUNCH_STATE_KEY = "govoplanQuickAccessLaunch";
export const QUICK_ACCESS_RESULT_EVENT = "govoplan:quick-access-result"; export const QUICK_ACCESS_RESULT_EVENT = "govoplan:quick-access-result";
@@ -37,6 +39,7 @@ export function createQuickAccessLaunchContext({
const principal = auth.principal; const principal = auth.principal;
return { return {
contractVersion: QUICK_ACCESS_LAUNCH_CONTEXT_VERSION, contractVersion: QUICK_ACCESS_LAUNCH_CONTEXT_VERSION,
referenceContractVersion: QUICK_ACCESS_REFERENCE_CONTRACT_VERSION,
origin: { origin: {
pathname: normalizePathname(pathname), pathname: normalizePathname(pathname),
search: normalizeSearch(search), search: normalizeSearch(search),
@@ -61,7 +64,9 @@ export function createQuickAccessLaunchContext({
? { ? {
viewId: viewContext.activeViewId, viewId: viewContext.activeViewId,
revisionId: viewContext.activeRevisionId, revisionId: viewContext.activeRevisionId,
name: viewContext.activeViewName name: viewContext.activeViewName,
recommendedToolIds: viewContext.presentation?.quickAccessRecommendedToolIds ?? [],
focusedToolIds: viewContext.presentation?.quickAccessFocusedToolIds ?? []
} }
: null : null
}; };
@@ -78,7 +83,11 @@ export function quickAccessLaunchContextFromState(
): QuickAccessLaunchContext | null { ): QuickAccessLaunchContext | null {
if (!isRecord(value)) return null; if (!isRecord(value)) return null;
const candidate = value[QUICK_ACCESS_LAUNCH_STATE_KEY]; const candidate = value[QUICK_ACCESS_LAUNCH_STATE_KEY];
if (!isRecord(candidate) || candidate.contractVersion !== QUICK_ACCESS_LAUNCH_CONTEXT_VERSION) { if (
!isRecord(candidate)
|| candidate.contractVersion !== QUICK_ACCESS_LAUNCH_CONTEXT_VERSION
|| candidate.referenceContractVersion !== QUICK_ACCESS_REFERENCE_CONTRACT_VERSION
) {
return null; return null;
} }
if (!isRecord(candidate.origin) || typeof candidate.origin.pathname !== "string") { if (!isRecord(candidate.origin) || typeof candidate.origin.pathname !== "string") {
@@ -101,12 +110,48 @@ export function quickAccessReturnPath(context: QuickAccessLaunchContext): string
export function dispatchQuickAccessResult( export function dispatchQuickAccessResult(
toolId: string, toolId: string,
launchContext: QuickAccessLaunchContext, launchContext: QuickAccessLaunchContext,
result: QuickAccessResult result: QuickAccessResult,
): void { returnedReferenceKinds?: readonly string[]
if (typeof window === "undefined") return; ): boolean {
if (
typeof window === "undefined"
|| !isQuickAccessResultAllowed(result, launchContext, returnedReferenceKinds)
) return false;
window.dispatchEvent(new CustomEvent(QUICK_ACCESS_RESULT_EVENT, { window.dispatchEvent(new CustomEvent(QUICK_ACCESS_RESULT_EVENT, {
detail: { toolId, launchContext, result } detail: { toolId, launchContext, result }
})); }));
return true;
}
export function isQuickAccessResultAllowed(
result: QuickAccessResult,
launchContext: QuickAccessLaunchContext,
returnedReferenceKinds?: readonly string[]
): boolean {
if (result.contractVersion !== QUICK_ACCESS_RESULT_CONTRACT_VERSION) return false;
if (result.outcome === "cancelled") {
return result.action === undefined
&& result.reference === undefined
&& (result.reason === undefined
|| (["user", "dismissed", "unavailable", "failed"] as const).includes(result.reason));
}
if (result.outcome !== "completed") return false;
if (!(["created", "selected", "updated", "completed"] as const).includes(result.action)) return false;
if (!result.reference) return true;
if (
result.reference.tenantId !== launchContext.tenantId
|| !result.reference.ownerModule
|| !result.reference.kind
|| !result.reference.objectId
|| (result.reference.path !== undefined
&& result.reference.path !== null
&& (!result.reference.path.startsWith("/") || result.reference.path.startsWith("//")))
) return false;
if (returnedReferenceKinds) {
const returnedKind = `${result.reference.ownerModule}.${result.reference.kind}`;
if (!returnedReferenceKinds.includes(returnedKind)) return false;
}
return true;
} }
function normalizePathname(value: string): string { function normalizePathname(value: string): string {
+6 -1
View File
@@ -182,6 +182,7 @@ function productAreasFromMetadata(info: PlatformModuleInfo): ProductAreaContribu
function quickAccessToolsFromMetadata(info: PlatformModuleInfo): QuickAccessToolMetadata[] { function quickAccessToolsFromMetadata(info: PlatformModuleInfo): QuickAccessToolMetadata[] {
return (info.frontend?.quick_access_tools ?? []).map((tool) => ({ return (info.frontend?.quick_access_tools ?? []).map((tool) => ({
contractVersion: tool.contract_version,
id: tool.id, id: tool.id,
moduleId: tool.module_id, moduleId: tool.module_id,
categoryId: tool.category_id, categoryId: tool.category_id,
@@ -194,7 +195,11 @@ function quickAccessToolsFromMetadata(info: PlatformModuleInfo): QuickAccessTool
anyOf: tool.required_any, anyOf: tool.required_any,
order: tool.order, order: tool.order,
defaultEnabled: tool.default_enabled, defaultEnabled: tool.default_enabled,
modes: tool.modes modes: tool.modes,
availability: tool.availability,
acceptedReferenceKinds: tool.accepted_reference_kinds,
returnedReferenceKinds: tool.returned_reference_kinds,
helpContextId: tool.help_context_id
})); }));
} }
+10 -1
View File
@@ -105,6 +105,9 @@
.page-dirty-state { display: inline-flex; align-items: center; gap: 6px; min-height: 28px; color: var(--text-soft); font-size: 12px; font-weight: 700; white-space: nowrap; } .page-dirty-state { display: inline-flex; align-items: center; gap: 6px; min-height: 28px; color: var(--text-soft); font-size: 12px; font-weight: 700; white-space: nowrap; }
.page-dirty-state::before { width: 8px; height: 8px; border-radius: var(--radius-pill); background: var(--success); content: ""; } .page-dirty-state::before { width: 8px; height: 8px; border-radius: var(--radius-pill); background: var(--success); content: ""; }
.page-dirty-state-dirty::before { background: var(--warning); } .page-dirty-state-dirty::before { background: var(--warning); }
.page-dirty-state-invalid::before,
.page-dirty-state-save-failed::before,
.page-dirty-state-conflict::before { background: var(--danger); }
.page-dirty-state-saving::before { background: var(--accent); } .page-dirty-state-saving::before { background: var(--accent); }
.app-content { min-height: 0; overflow: hidden; } .app-content { min-height: 0; overflow: hidden; }
.workspace { height: 100%; min-height: 0; display: grid; grid-template-columns: 198px minmax(0, 1fr); } .workspace { height: 100%; min-height: 0; display: grid; grid-template-columns: 198px minmax(0, 1fr); }
@@ -272,7 +275,7 @@
.metric-group-minimum-compact { --metric-group-column-minimum: 112px; } .metric-group-minimum-compact { --metric-group-column-minimum: 112px; }
.metric-group-minimum-default { --metric-group-column-minimum: 140px; } .metric-group-minimum-default { --metric-group-column-minimum: 140px; }
.metric-group-minimum-wide { --metric-group-column-minimum: 180px; } .metric-group-minimum-wide { --metric-group-column-minimum: 180px; }
.metric-card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow); border-top: 4px solid var(--line-dark); } .metric-card { background: var(--panel); border: var(--border-line); border-radius: var(--radius); padding: 18px; box-shadow: var(--shadow); border-top: 4px solid var(--line-dark); display: flex; flex-direction: column; min-width: 0; }
.metric-card-density-compact { padding: 10px 12px; border-top-width: 1px; box-shadow: none; } .metric-card-density-compact { padding: 10px 12px; border-top-width: 1px; box-shadow: none; }
.metric-card-density-compact .metric-label { font-size: 11px; } .metric-card-density-compact .metric-label { font-size: 11px; }
.metric-card-density-compact .metric-value { margin-top: 5px; font-size: 16px; } .metric-card-density-compact .metric-value { margin-top: 5px; font-size: 16px; }
@@ -283,6 +286,12 @@
.metric-label { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .05em; } .metric-label { color: var(--muted); font-size: 12px; text-transform: uppercase; font-weight: 800; letter-spacing: .05em; }
.metric-value { margin-top: 7px; font-size: 30px; color: var(--text-strong); font-weight: 700; } .metric-value { margin-top: 7px; font-size: 30px; color: var(--text-strong); font-weight: 700; }
.metric-detail { margin-top: 4px; color: var(--muted); font-size: 13px; } .metric-detail { margin-top: 4px; color: var(--muted); font-size: 13px; }
.metric-card-drilldown-slot { margin-top: auto; padding-top: 10px; }
.metric-card-drilldown.btn { min-height: 28px; max-width: 100%; padding: 3px 0; color: var(--text-strong); justify-content: flex-start; text-align: left; }
.metric-card-drilldown.btn:hover:not(:disabled), .metric-card-drilldown.btn:focus-visible { color: var(--text-strong); text-decoration: underline; }
.metric-card-drilldown.btn svg { flex: 0 0 auto; }
.metric-card-density-compact .metric-card-drilldown-slot { padding-top: 6px; }
.metric-card-density-compact .metric-card-drilldown.btn { min-height: 24px; font-size: 12px; }
.wizard-page { min-height: calc(100vh - 112px); display: grid; place-items: start center; padding: 42px; } .wizard-page { min-height: calc(100vh - 112px); display: grid; place-items: start center; padding: 42px; }
.wizard-card { width: min(980px, 100%); background: var(--panel); border: var(--border-line); box-shadow: var(--shadow); border-radius: var(--radius); display: grid; grid-template-columns: 290px 1fr; overflow: hidden; } .wizard-card { width: min(980px, 100%); background: var(--panel); border: var(--border-line); box-shadow: var(--shadow); border-radius: var(--radius); display: grid; grid-template-columns: 290px 1fr; overflow: hidden; }
.wizard-body { background: var(--panel-soft); padding: 28px; min-height: 620px; } .wizard-body { background: var(--panel-soft); padding: 28px; min-height: 620px; }
+27 -1
View File
@@ -284,6 +284,7 @@ export type ProductAreaContribution = {
}; };
export type QuickAccessToolMetadata = { export type QuickAccessToolMetadata = {
contractVersion: "1";
id: string; id: string;
moduleId: string; moduleId: string;
categoryId: string; categoryId: string;
@@ -297,6 +298,10 @@ export type QuickAccessToolMetadata = {
order?: number; order?: number;
defaultEnabled?: boolean; defaultEnabled?: boolean;
modes?: string[]; modes?: string[];
availability: "global" | "active_object";
acceptedReferenceKinds: string[];
returnedReferenceKinds: string[];
helpContextId?: string | null;
}; };
export type PlatformRouteContext = { export type PlatformRouteContext = {
@@ -493,6 +498,8 @@ export type ViewPresentation = {
navigationMode?: "grouped" | "flat"; navigationMode?: "grouped" | "flat";
productAreaOrder?: string[]; productAreaOrder?: string[];
productAreaLabels?: Record<string, string>; productAreaLabels?: Record<string, string>;
quickAccessRecommendedToolIds?: string[];
quickAccessFocusedToolIds?: string[];
}; };
export type ViewSelectorProps = { export type ViewSelectorProps = {
@@ -566,7 +573,8 @@ export type ActiveObjectReference = {
}; };
export type QuickAccessLaunchContext = { export type QuickAccessLaunchContext = {
contractVersion: "1"; contractVersion: "2";
referenceContractVersion: "1";
origin: { origin: {
pathname: string; pathname: string;
search: string; search: string;
@@ -589,12 +597,24 @@ export type QuickAccessLaunchContext = {
viewId: string; viewId: string;
revisionId: string | null; revisionId: string | null;
name: string | null; name: string | null;
recommendedToolIds: string[];
focusedToolIds: string[];
} | null; } | null;
}; };
export type QuickAccessCancellationReason = "user" | "dismissed" | "unavailable" | "failed";
export type QuickAccessResult = { export type QuickAccessResult = {
contractVersion: "1";
outcome: "completed";
action: "created" | "selected" | "updated" | "completed"; action: "created" | "selected" | "updated" | "completed";
reference?: ActiveObjectReference | null; reference?: ActiveObjectReference | null;
} | {
contractVersion: "1";
outcome: "cancelled";
reason?: QuickAccessCancellationReason;
action?: never;
reference?: never;
}; };
export type QuickAccessToolRenderContext = PlatformRouteContext & { export type QuickAccessToolRenderContext = PlatformRouteContext & {
@@ -602,6 +622,7 @@ export type QuickAccessToolRenderContext = PlatformRouteContext & {
active: boolean; active: boolean;
launchContext: QuickAccessLaunchContext; launchContext: QuickAccessLaunchContext;
complete: (result: QuickAccessResult) => void; complete: (result: QuickAccessResult) => void;
cancel: (reason?: QuickAccessCancellationReason) => void;
}; };
export type QuickAccessToolContribution = { export type QuickAccessToolContribution = {
@@ -1179,6 +1200,11 @@ export type PlatformFrontendModuleInfo = {
order: number; order: number;
default_enabled: boolean; default_enabled: boolean;
modes: string[]; modes: string[];
contract_version: "1";
availability: "global" | "active_object";
accepted_reference_kinds: string[];
returned_reference_kinds: string[];
help_context_id?: string | null;
}>; }>;
}; };
+64 -4
View File
@@ -1,6 +1,7 @@
import type { AuthInfo } from "../src/types"; import type { AuthInfo, QuickAccessResult } from "../src/types";
import { import {
createQuickAccessLaunchContext, createQuickAccessLaunchContext,
isQuickAccessResultAllowed,
quickAccessLaunchContextFromState, quickAccessLaunchContextFromState,
quickAccessLaunchState, quickAccessLaunchState,
quickAccessReturnPath quickAccessReturnPath
@@ -57,15 +58,21 @@ const context = createQuickAccessLaunchContext({
locked: false, locked: false,
availableViews: [], availableViews: [],
provenance: [], provenance: [],
diagnostics: [] diagnostics: [],
presentation: {
quickAccessRecommendedToolIds: ["mail.messages"],
quickAccessFocusedToolIds: ["mail.messages", "files.recent"]
}
} }
}); });
assert(context.contractVersion === "1", "launch context must be explicitly versioned"); assert(context.contractVersion === "2", "launch context must be explicitly versioned");
assert(context.referenceContractVersion === "1", "reference payloads must be independently versioned");
assert(context.activeObject?.objectId === "case-1", "active object reference must survive launch"); assert(context.activeObject?.objectId === "case-1", "active object reference must survive launch");
assert(context.actingContext?.assignmentId === "assignment-1", "acting assignment must survive launch"); assert(context.actingContext?.assignmentId === "assignment-1", "acting assignment must survive launch");
assert(context.temporalContext.validityMode === "at", "temporal selection must survive launch"); assert(context.temporalContext.validityMode === "at", "temporal selection must survive launch");
assert(context.viewContext?.revisionId === "view-revision-3", "exact View revision must survive launch"); assert(context.viewContext?.revisionId === "view-revision-3", "exact View revision must survive launch");
assert(context.viewContext?.recommendedToolIds[0] === "mail.messages", "View recommendations must survive launch without becoming authority");
assert( assert(
quickAccessReturnPath(context) === "/cases/case-1?tab=history#revision-4", quickAccessReturnPath(context) === "/cases/case-1?tab=history#revision-4",
"return path must preserve route, query and fragment" "return path must preserve route, query and fragment"
@@ -75,7 +82,7 @@ assert(
"router state must decode a valid launch context" "router state must decode a valid launch context"
); );
assert( assert(
quickAccessLaunchContextFromState({ govoplanQuickAccessLaunch: { contractVersion: "2" } }) === null, quickAccessLaunchContextFromState({ govoplanQuickAccessLaunch: { contractVersion: "3" } }) === null,
"unknown launch context versions must fail closed" "unknown launch context versions must fail closed"
); );
@@ -86,3 +93,56 @@ const crossTenant = createQuickAccessLaunchContext({
temporalContext: { validityMode: "current", validAt: null, recordedAt: null } temporalContext: { validityMode: "current", validAt: null, recordedAt: null }
}); });
assert(crossTenant.activeObject === null, "cross-tenant object references must be discarded"); assert(crossTenant.activeObject === null, "cross-tenant object references must be discarded");
const selectedFileResult = {
contractVersion: "1",
outcome: "completed",
action: "selected",
reference: {
ownerModule: "files",
kind: "file-version",
objectId: "version-1",
tenantId: "tenant-1",
label: "Permit evidence.pdf",
path: "/files?version=version-1"
}
} as const;
assert(
isQuickAccessResultAllowed(selectedFileResult, context, ["files.file-version"]),
"a declared same-tenant result reference is accepted"
);
assert(
!isQuickAccessResultAllowed(
{ ...selectedFileResult, reference: { ...selectedFileResult.reference, tenantId: "tenant-2" } },
context,
["files.file-version"]
),
"cross-tenant result references fail closed"
);
assert(
!isQuickAccessResultAllowed(selectedFileResult, context, ["records.record"]),
"undeclared result-reference kinds fail closed"
);
assert(
isQuickAccessResultAllowed({ contractVersion: "1", outcome: "cancelled", reason: "user" }, context),
"explicit cancellation is a valid terminal result"
);
assert(
!isQuickAccessResultAllowed(
{ ...selectedFileResult, outcome: "unknown" } as unknown as QuickAccessResult,
context,
["files.file-version"]
),
"unknown result outcomes fail closed"
);
assert(
!isQuickAccessResultAllowed(
{
...selectedFileResult,
reference: { ...selectedFileResult.reference, path: "//outside.example/files/version-1" }
},
context,
["files.file-version"]
),
"protocol-relative result paths fail closed"
);
+25 -4
View File
@@ -21,6 +21,7 @@ import SelectionList, { SelectionListItem, SelectionListItemContent } from "../s
import StatePanel from "../src/components/StatePanel"; import StatePanel from "../src/components/StatePanel";
import WorkspaceLayout from "../src/components/WorkspaceLayout"; import WorkspaceLayout from "../src/components/WorkspaceLayout";
import WorkspaceFrame from "../src/components/WorkspaceFrame"; import WorkspaceFrame from "../src/components/WorkspaceFrame";
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
// @ts-expect-error Refreshable pages must provide a Reload action. // @ts-expect-error Refreshable pages must provide a Reload action.
const refreshableWithoutReload = <PageActionBar variant="detail" refreshable />; const refreshableWithoutReload = <PageActionBar variant="detail" refreshable />;
@@ -52,9 +53,9 @@ const editorActionBarMarkup = renderToStaticMarkup(
<PageActionBar <PageActionBar
variant="editor" variant="editor"
refreshable refreshable
dirty state="dirty"
label="Editor actions" label="Editor actions"
reloadAction={<button type="button">Reload</button>} reloadAction={{ onReload: () => undefined, label: "Reload" }}
contextActions={<button type="button">Preview</button>} contextActions={<button type="button">Preview</button>}
helpAction={<button type="button">Help</button>} helpAction={<button type="button">Help</button>}
destructiveActions={<button type="button">Delete</button>} destructiveActions={<button type="button">Delete</button>}
@@ -73,13 +74,14 @@ assert(editorActionBarMarkup.indexOf('data-page-action-slot="reload"') < editorA
assert(editorActionBarMarkup.indexOf('data-page-action-slot="help"') < editorActionBarMarkup.indexOf('data-page-action-slot="discard"'), "help precedes editor persistence actions"); assert(editorActionBarMarkup.indexOf('data-page-action-slot="help"') < editorActionBarMarkup.indexOf('data-page-action-slot="discard"'), "help precedes editor persistence actions");
assert(editorActionBarMarkup.indexOf('data-page-action-slot="destructive"') < editorActionBarMarkup.indexOf('data-page-action-slot="discard"'), "destructive editor actions are separated from persistence actions"); assert(editorActionBarMarkup.indexOf('data-page-action-slot="destructive"') < editorActionBarMarkup.indexOf('data-page-action-slot="discard"'), "destructive editor actions are separated from persistence actions");
assert(editorActionBarMarkup.includes('data-page-action-separation="destructive"'), "destructive actions expose their visual boundary"); assert(editorActionBarMarkup.includes('data-page-action-separation="destructive"'), "destructive actions expose their visual boundary");
assert(editorActionBarMarkup.includes('role="group" aria-label="Destructive actions"'), "destructive actions expose an accessible named group");
assert(editorActionBarMarkup.indexOf('data-page-action-slot="discard"') < editorActionBarMarkup.indexOf('data-page-action-slot="save"'), "save remains the far-right editor action"); assert(editorActionBarMarkup.indexOf('data-page-action-slot="discard"') < editorActionBarMarkup.indexOf('data-page-action-slot="save"'), "save remains the far-right editor action");
const cleanEditorActionBarMarkup = renderToStaticMarkup( const cleanEditorActionBarMarkup = renderToStaticMarkup(
<PlatformLanguageProvider> <PlatformLanguageProvider>
<PageActionBar <PageActionBar
variant="editor" variant="editor"
dirty={false} state="clean"
discardAction={{ label: "Discard" }} discardAction={{ label: "Discard" }}
saveAction={{ label: "Save" }} saveAction={{ label: "Save" }}
/> />
@@ -94,13 +96,32 @@ const collectionActionBarMarkup = renderToStaticMarkup(
<PageActionBar <PageActionBar
variant="collection" variant="collection"
refreshable refreshable
reloadAction={<button type="button">Reload</button>} reloadAction={{ onReload: () => undefined, label: "Reload" }}
createAction={<button type="button">Create</button>} createAction={<button type="button">Create</button>}
/> />
</PlatformLanguageProvider> </PlatformLanguageProvider>
); );
assert(collectionActionBarMarkup.indexOf('data-page-action-slot="reload"') < collectionActionBarMarkup.indexOf('data-page-action-slot="create"'), "collection creation remains the far-right action"); assert(collectionActionBarMarkup.indexOf('data-page-action-slot="reload"') < collectionActionBarMarkup.indexOf('data-page-action-slot="create"'), "collection creation remains the far-right action");
const workspaceEditorActionBarMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<WorkspaceActionBar
scope="editor-pane"
variant="editor"
state="invalid"
refreshable
reloadAction={{ onReload: () => undefined, loading: true }}
destructiveActions={<button type="button">Delete</button>}
discardAction={{ label: "Discard" }}
saveAction={{ label: "Save" }}
/>
</PlatformLanguageProvider>
);
assert(workspaceEditorActionBarMarkup.includes('data-workspace-action-scope="editor-pane"'), "workspace actions expose their pane scope");
assert(workspaceEditorActionBarMarkup.includes('data-page-refresh-state="reloading"'), "reload activity is centrally projected");
assert(workspaceEditorActionBarMarkup.includes('data-page-dirty-state="invalid"'), "invalid editor state remains visible");
assert((workspaceEditorActionBarMarkup.match(/disabled=""/g) ?? []).length >= 2, "invalid editors disable Reload while active and Save while invalid");
const gridMarkup = renderToStaticMarkup( const gridMarkup = renderToStaticMarkup(
<ContentGrid columns={3} gap="compact" collapseAt="wide"> <ContentGrid columns={3} gap="compact" collapseAt="wide">
<GridItem>A</GridItem> <GridItem>A</GridItem>
+38 -1
View File
@@ -17,10 +17,47 @@ const translatedMarkup = renderToStaticMarkup(
</PlatformLanguageProvider> </PlatformLanguageProvider>
); );
assert(translatedMarkup.includes('class="metric-card metric-info"'), "the visual tone is preserved"); assert(translatedMarkup.includes('class="metric-card metric-info '), "the visual tone is preserved");
assert(translatedMarkup.includes('class="metric-label">Installed modules</div>'), "the label is translated"); assert(translatedMarkup.includes('class="metric-label">Installed modules</div>'), "the label is translated");
assert(translatedMarkup.includes('class="metric-value">Core only</div>'), "a string value is translated"); assert(translatedMarkup.includes('class="metric-value">Core only</div>'), "a string value is translated");
assert(!translatedMarkup.includes("i18n:govoplan-core."), "translation keys never leak into visible card text"); assert(!translatedMarkup.includes("i18n:govoplan-core."), "translation keys never leak into visible card text");
const numericMarkup = renderToStaticMarkup(<MetricCard label="Count" value={7} />); const numericMarkup = renderToStaticMarkup(<MetricCard label="Count" value={7} />);
assert(numericMarkup.includes('class="metric-value">7</div>'), "numeric values are preserved"); assert(numericMarkup.includes('class="metric-value">7</div>'), "numeric values are preserved");
assert(!numericMarkup.includes("data-metric-drilldown"), "summary-only metrics remain non-interactive");
const linkedMarkup = renderToStaticMarkup(
<MetricCard
label="Recipients"
value={17}
drilldown={{ label: "Review recipients", href: "/campaigns/42/recipients" }}
/>
);
assert(linkedMarkup.includes('data-metric-drilldown="link"'), "link drill-downs have an explicit affordance");
assert(linkedMarkup.includes('href="/campaigns/42/recipients"'), "link drill-downs preserve their destination");
assert(linkedMarkup.includes("Review recipients"), "link drill-downs name the resulting detail");
const actionMarkup = renderToStaticMarkup(
<MetricCard
label="Failures"
value={2}
drilldown={{ label: "Show failures", onActivate: () => undefined }}
/>
);
assert(actionMarkup.includes('data-metric-drilldown="action"'), "in-page drill-downs render as buttons");
assert(actionMarkup.includes('type="button"'), "in-page drill-downs do not submit an enclosing form");
const disabledMarkup = renderToStaticMarkup(
<MetricCard
label="Suppressed"
value="—"
drilldown={{
label: "Show records",
onActivate: () => undefined,
disabledReason: "Individual records are privacy-suppressed"
}}
/>
);
assert(disabledMarkup.includes('class="disabled-action-tooltip"'), "disabled drill-downs retain the shared explanation trigger");
assert(disabledMarkup.includes('tabindex="0"'), "disabled drill-down explanations remain keyboard reachable");
assert(disabledMarkup.includes("disabled"), "blocked drill-down actions cannot run");
+1 -1
View File
@@ -13,7 +13,7 @@ const standaloneMarkup = renderToStaticMarkup(
archetype="collection" archetype="collection"
title="Shared page" title="Shared page"
description="One page frame" description="One page frame"
actions={<PageActionBar variant="collection" refreshable reloadAction={<button type="button">Reload</button>} />} actions={<PageActionBar variant="collection" refreshable reloadAction={{ onReload: () => undefined }} />}
error="Could not load" error="Could not load"
success="Saved" success="Saved"
interfaceId="test.page" interfaceId="test.page"
+10 -1
View File
@@ -1,7 +1,16 @@
{ {
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"types": ["node"] "types": ["node"],
"paths": {
"@govoplan/core-webui": ["./conformance/QuickAccessCoreFacade.ts"],
"@govoplan/core-webui/app": ["./src/app.ts"],
"@govoplan/core-webui/wysiwyg": ["./src/wysiwyg.ts"],
"react": ["./node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime.d.ts"],
"react-router": ["./node_modules/react-router/dist/production/index.d.ts"],
"lucide-react": ["./node_modules/lucide-react/dist/lucide-react.d.ts"]
}
}, },
"include": ["src", "conformance", "vite.conformance.config.ts"] "include": ["src", "conformance", "vite.conformance.config.ts"]
} }
+8
View File
@@ -5,6 +5,14 @@ import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
root: resolve(import.meta.dirname, "conformance"), root: resolve(import.meta.dirname, "conformance"),
plugins: [react()], plugins: [react()],
resolve: {
alias: {
"@govoplan/core-webui": resolve(import.meta.dirname, "conformance/QuickAccessCoreFacade.ts"),
react: resolve(import.meta.dirname, "node_modules/react"),
"react-router": resolve(import.meta.dirname, "node_modules/react-router"),
"lucide-react": resolve(import.meta.dirname, "node_modules/lucide-react")
}
},
build: { build: {
outDir: resolve(import.meta.dirname, "dist-conformance"), outDir: resolve(import.meta.dirname, "dist-conformance"),
emptyOutDir: true emptyOutDir: true