feat(webui): govern actions, quick access, and metrics
Refs #264, #285, #289
This commit is contained in:
@@ -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 [];
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user