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
+85 -21
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react";
import { useLocation } from "react-router";
import QuickAccessRail from "../../../govoplan-quick-access/webui/src/components/QuickAccessRail";
import ActionToolbar from "../src/components/ActionToolbar";
import Button from "../src/components/Button";
import Card from "../src/components/Card";
@@ -23,17 +24,20 @@ import SelectionList, { SelectionListItem, SelectionListItemContent } from "../s
import StatePanel from "../src/components/StatePanel";
import WorkspaceFrame from "../src/components/WorkspaceFrame";
import WorkspaceLayout from "../src/components/WorkspaceLayout";
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
import BreadcrumbBar from "../src/layout/BreadcrumbBar";
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
import {
createQuickAccessLaunchContext,
quickAccessLaunchState
} from "../src/platform/launchContext";
import type { AuthInfo } from "../src/types";
import type { ApiSettings, AuthInfo, QuickAccessToolMetadata } from "../src/types";
export default function ConformanceApp() {
const location = useLocation();
const [dialogOpen, setDialogOpen] = useState(false);
const [editorDirty, setEditorDirty] = useState(true);
const [metricDrilldown, setMetricDrilldown] = useState("");
return (
<main className="conformance-root" data-conformance-id="shared-ui-lab">
@@ -49,9 +53,9 @@ export default function ConformanceApp() {
<PageActionBar
variant="editor"
refreshable
dirty={editorDirty}
state={editorDirty ? "dirty" : "clean"}
label="Bearbeitungsaktionen"
reloadAction={<Button variant="ghost">Neu laden</Button>}
reloadAction={{ onReload: () => undefined, label: "Neu laden" }}
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>}
discardAction={{ label: "Verwerfen", onClick: () => setEditorDirty(false) }}
@@ -73,11 +77,12 @@ export default function ConformanceApp() {
<section className="conformance-section" aria-labelledby="metrics-heading">
<h2 id="metrics-heading">Kennzahlen und Eigenschaften</h2>
<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="Klärung erforderlich" value="4" tone="warning" density="compact" />
<MetricCard label="Fehlgeschlagen" value="1" tone="danger" surface="subtle" />
</MetricGrid>
<p data-testid="metric-drilldown-result" role="status">{metricDrilldown}</p>
<ContentSection density="default" surface="subtle">
<h3>Nachvollziehbare Entscheidung</h3>
<DescriptionList columns={3} collapseAt="standard">
@@ -98,25 +103,31 @@ export default function ConformanceApp() {
primaryLabel="Vorgänge"
contentLabel="Ausgewählter Vorgang"
primary={
<SelectionList label="Vorgänge" variant="navigation">
<SelectionListItem selected>
<SelectionListItemContent leading={<FileText size={18} />} title="Anwohnerparkausweis" description="A-2026-004218 · Prüfung läuft" />
</SelectionListItem>
<SelectionListItem selected={false}>
<SelectionListItemContent leading={<FileText size={18} />} title="Sondernutzung öffentlicher Fläche" description="A-2026-004219 · Rückfrage offen" />
</SelectionListItem>
</SelectionList>
<>
<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">
<SelectionListItem selected>
<SelectionListItemContent leading={<FileText size={18} />} title="Anwohnerparkausweis" description="A-2026-004218 · Prüfung läuft" />
</SelectionListItem>
<SelectionListItem selected={false}>
<SelectionListItemContent leading={<FileText size={18} />} title="Sondernutzung öffentlicher Fläche" description="A-2026-004219 · Rückfrage offen" />
</SelectionListItem>
</SelectionList>
</>
}
>
<Card title="Anwohnerparkausweis" actions={<Button variant="primary">Bearbeiten</Button>}>
<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">
<FormGrid columns={2}>
<label>Zuständigkeit<input defaultValue="Bürgerdienste Mitte" /></label>
<label>Bearbeitungsfrist<input type="date" defaultValue="2026-08-28" /></label>
</FormGrid>
</FormSection>
</Card>
<>
<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>
<FormSection title="Nächster Arbeitsschritt" description="Die Entscheidung bleibt nachvollziehbar und kann vor Abschluss korrigiert werden." variant="panel">
<FormGrid columns={2}>
<label>Zuständigkeit<input defaultValue="Bürgerdienste Mitte" /></label>
<label>Bearbeitungsfrist<input type="date" defaultValue="2026-08-28" /></label>
</FormGrid>
</FormSection>
</Card>
</>
</WorkspaceLayout>
</WorkspaceFrame>
</section>
@@ -154,10 +165,36 @@ export default function ConformanceApp() {
</DialogSection>
</DialogForm>
</Dialog>
{new URLSearchParams(location.search).has("quick-access") ? <QuickAccessScenario /> : null}
</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() {
const location = useLocation();
const navigate = useGuardedNavigate();
@@ -210,6 +247,33 @@ const CONFORMANCE_AUTH = {
groups_loaded: true
} 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 {
const value = (window.history.state as { idx?: unknown } | null)?.idx;
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/components.css";
import "../src/styles/dialogs.css";
import "@govoplan/quick-access-webui/styles/quick-access.css";
import "./conformance.css";
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: "Ä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");
await opener.focus();
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$/);
});
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 }) => {
await page.setViewportSize({ width: 1440, height: 1000 });
await page.goto("/?theme=light");