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");
@@ -20,6 +20,7 @@ const pageLayout = read("src/components/PageLayout.tsx");
const workspaceLayout = read("src/components/WorkspaceLayout.tsx");
const actionToolbar = read("src/components/ActionToolbar.tsx");
const pageActionBar = read("src/components/PageActionBar.tsx");
const workspaceActionBar = read("src/components/WorkspaceActionBar.tsx");
const contentGrid = read("src/components/ContentGrid.tsx");
const formSection = read("src/components/FormSection.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, /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, /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: "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, /refreshable: true;\s*reloadAction: ReactNode;/, "refreshable pages require a Reload action at type level");
assert.match(pageActionBar, /dirty: boolean;/, "editors require an explicit dirty state");
assert.match(pageActionBar, /refreshable: true;\s*reloadAction: PageReloadAction;/, "refreshable pages require a typed Reload action");
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, /<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, /<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(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");
+58 -1
View File
@@ -1,7 +1,29 @@
import { ArrowRight } from "lucide-react";
import type { HTMLAttributes, ReactNode } from "react";
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;
value: ReactNode;
tone?: "neutral" | "good" | "warning" | "danger" | "info";
@@ -9,6 +31,7 @@ export type MetricCardProps = HTMLAttributes<HTMLDivElement> & {
valueTitle?: string;
density?: "compact" | "default";
surface?: "card" | "subtle" | "flat";
drilldown?: MetricDrilldown;
};
export default function MetricCard({
@@ -19,6 +42,7 @@ export default function MetricCard({
valueTitle,
density = "default",
surface = "card",
drilldown,
className = "",
...props
}: MetricCardProps) {
@@ -26,6 +50,10 @@ export default function MetricCard({
const renderedLabel = translateReactNode(label, translateText);
const renderedValue = translateReactNode(value, translateText);
const renderedDetail = translateReactNode(detail, translateText);
const renderedDrilldownLabel = translateReactNode(drilldown?.label, translateText);
const drilldownAccessibleLabel = drilldown?.accessibleLabel
? translateText(drilldown.accessibleLabel)
: undefined;
return (
<div {...props} className={[
@@ -38,6 +66,35 @@ export default function MetricCard({
<div className="metric-label">{renderedLabel}</div>
<div className="metric-value" title={valueTitle}>{renderedValue}</div>
{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>
);
}
+111 -20
View File
@@ -1,11 +1,24 @@
import { RefreshCw } from "lucide-react";
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
import type { PlatformInterfaceIdentityProps } from "../types";
import ActionToolbar, { ToolbarGroup } from "./ActionToolbar";
import ActionToolbar, { ToolbarGroup, type ActionToolbarDensity, type ActionToolbarSurface } from "./ActionToolbar";
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 = {
refreshable: true;
reloadAction: ReactNode;
reloadAction: PageReloadAction;
} | {
refreshable?: false;
reloadAction?: never;
@@ -23,6 +36,8 @@ export type PageEditorAction = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "ch
disabledReason?: ReactNode;
};
export type PageEditorState = "clean" | "dirty" | "invalid" | "saving" | "save-failed" | "conflict";
export type OverviewPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "overview";
primaryActions?: ReactNode;
@@ -41,13 +56,16 @@ export type DetailPageActionBarProps = PageActionBarCommonProps & RefreshablePag
export type EditorPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
variant: "editor";
dirty: boolean;
saving?: boolean;
state: PageEditorState;
dirtyLabel?: ReactNode;
cleanLabel?: ReactNode;
invalidLabel?: ReactNode;
savingLabel?: ReactNode;
saveFailedLabel?: ReactNode;
conflictLabel?: ReactNode;
cleanDisabledReason?: ReactNode;
savingDisabledReason?: ReactNode;
invalidDisabledReason?: ReactNode;
discardAction: PageEditorAction;
saveAction: PageEditorAction;
primaryActions?: ReactNode;
@@ -67,6 +85,14 @@ export type PageActionBarProps =
| EditorPageActionBarProps
| 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 }) {
return (
<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;
return (
<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}
</span>
</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({
action,
variant,
@@ -117,23 +177,29 @@ function EditorAction({
* The named slots deliberately encode ordering. Product modules still own the
* actions, wording, permissions, blockers, and consequences placed in them.
*/
export default function PageActionBar(props: PageActionBarProps) {
const normalizedProps = props as PageActionBarProps & {
export default function PageActionBar(props: PageActionBarProps & SemanticActionBarPresentation) {
const normalizedProps = props as PageActionBarProps & SemanticActionBarPresentation & {
createAction?: ReactNode;
primaryActions?: ReactNode;
destructiveActions?: ReactNode;
dirty?: boolean;
saving?: boolean;
state?: PageEditorState;
dirtyLabel?: ReactNode;
cleanLabel?: ReactNode;
invalidLabel?: ReactNode;
savingLabel?: ReactNode;
saveFailedLabel?: ReactNode;
conflictLabel?: ReactNode;
cleanDisabledReason?: ReactNode;
savingDisabledReason?: ReactNode;
invalidDisabledReason?: ReactNode;
discardAction?: PageEditorAction;
saveAction?: PageEditorAction;
};
const {
variant,
actionScope = "page",
density,
surface,
refreshable = false,
reloadAction,
contextActions,
@@ -141,13 +207,16 @@ export default function PageActionBar(props: PageActionBarProps) {
createAction,
primaryActions,
destructiveActions,
dirty = false,
saving = false,
state = "clean",
dirtyLabel = "Unsaved changes",
cleanLabel = "Saved",
invalidLabel = "Review required",
savingLabel = "Saving…",
saveFailedLabel = "Save failed",
conflictLabel = "Conflict",
cleanDisabledReason = "There are no unsaved changes.",
savingDisabledReason = "Changes are already being saved.",
invalidDisabledReason = "Resolve the validation problems before saving.",
discardAction,
saveAction,
label,
@@ -172,16 +241,23 @@ export default function PageActionBar(props: PageActionBarProps) {
</>
);
} 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 = (
<>
{primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null}
<DestructiveSlot>{destructiveActions}</DestructiveSlot>
<ActionSlot name="discard">
<EditorAction action={discardAction!} variant="ghost" disabledReason={persistenceDisabledReason} />
<EditorAction action={discardAction!} variant="ghost" disabledReason={discardDisabledReason} />
</ActionSlot>
<ActionSlot name="save">
<EditorAction action={saveAction!} variant="primary" disabledReason={persistenceDisabledReason} />
<EditorAction action={saveAction!} variant="primary" disabledReason={saveDisabledReason} />
</ActionSlot>
</>
);
@@ -198,31 +274,46 @@ export default function PageActionBar(props: PageActionBarProps) {
<ActionToolbar
{...toolbarProps}
className={["page-action-bar", `page-action-bar-${variant}`, className].filter(Boolean).join(" ")}
density="compact"
density={density ?? "compact"}
justify="between"
wrap="responsive"
label={label ?? defaultLabels[variant]}
surface={surface}
interfaceId={interfaceId}
helpContextId={helpContextId}
helpModuleId={helpModuleId}
helpTopicId={helpTopicId}
data-page-action-archetype={variant}
data-action-bar-scope={actionScope}
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">
{refreshable ? <ActionSlot name="reload">{reloadAction}</ActionSlot> : null}
{refreshable ? <ActionSlot name="reload"><ReloadAction action={reloadAction!} /></ActionSlot> : null}
{contextActions ? <ActionSlot name="context">{contextActions}</ActionSlot> : null}
</ToolbarGroup>
<ToolbarGroup className="page-action-bar-trailing" align="end" data-page-action-group="trailing">
{variant === "editor" ? (
<span
className={`page-dirty-state page-dirty-state-${saving ? "saving" : dirty ? "dirty" : "clean"}`}
data-page-dirty-state={saving ? "saving" : dirty ? "dirty" : "clean"}
className={`page-dirty-state page-dirty-state-${state}`}
data-page-dirty-state={state}
role="status"
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>
) : 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 Button from "./Button";
import Dialog from "./Dialog";
import DismissibleAlert from "./DismissibleAlert";
import {
UnsavedChangesContext,
useUnsavedChanges,
type UnsavedChangesContextValue,
type UnsavedChangesRegistration,
type UnsavedNavigationAction
} from "./UnsavedChangesContext";
export type UnsavedNavigationAction = () => void;
export type UnsavedChangesRegistration = {
title?: string;
message?: string;
onSave: () => boolean | Promise<boolean>;
onDiscard?: () => void;
};
export { useUnsavedChanges } from "./UnsavedChangesContext";
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "./UnsavedChangesContext";
export type UnsavedDraftGuardOptions = {
dirty: boolean;
@@ -22,19 +23,6 @@ export type UnsavedDraftGuardOptions = {
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;}) {
const navigate = useNavigate();
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) {
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 ? (
<PageActionBar
variant="editor"
dirty={editorDirty}
saving={editorSaving}
state={editorSaving ? "saving" : editorDirty ? "dirty" : "clean"}
helpAction={<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />}
discardAction={{ label: "i18n:govoplan-core.discard.36fff63c", onClick: discardEditor }}
saveAction={{
+4 -2
View File
@@ -135,7 +135,7 @@ export { default as LoadingIndicator } from "./components/LoadingIndicator";
export { default as ExplorerTree } from "./components/ExplorerTree";
export type { ExplorerTreeNodeContext, ExplorerTreeProps } from "./components/ExplorerTree";
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 type { MetricGridCollapseAt, MetricGridColumns, MetricGridDensity, MetricGridMinimum, MetricGridProps, MetricGridSpacing } from "./components/MetricGrid";
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 type { PageArchetype, PageHeaderProps, PageLayoutMode, PageLayoutProps } from "./components/PageLayout";
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 type { PageScrollViewportProps } from "./components/PageScrollViewport";
export { default as WorkspaceLayout } from "./components/WorkspaceLayout";
+51 -6
View File
@@ -8,7 +8,9 @@ import type {
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_RESULT_EVENT = "govoplan:quick-access-result";
@@ -37,6 +39,7 @@ export function createQuickAccessLaunchContext({
const principal = auth.principal;
return {
contractVersion: QUICK_ACCESS_LAUNCH_CONTEXT_VERSION,
referenceContractVersion: QUICK_ACCESS_REFERENCE_CONTRACT_VERSION,
origin: {
pathname: normalizePathname(pathname),
search: normalizeSearch(search),
@@ -61,7 +64,9 @@ export function createQuickAccessLaunchContext({
? {
viewId: viewContext.activeViewId,
revisionId: viewContext.activeRevisionId,
name: viewContext.activeViewName
name: viewContext.activeViewName,
recommendedToolIds: viewContext.presentation?.quickAccessRecommendedToolIds ?? [],
focusedToolIds: viewContext.presentation?.quickAccessFocusedToolIds ?? []
}
: null
};
@@ -78,7 +83,11 @@ export function quickAccessLaunchContextFromState(
): QuickAccessLaunchContext | null {
if (!isRecord(value)) return null;
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;
}
if (!isRecord(candidate.origin) || typeof candidate.origin.pathname !== "string") {
@@ -101,12 +110,48 @@ export function quickAccessReturnPath(context: QuickAccessLaunchContext): string
export function dispatchQuickAccessResult(
toolId: string,
launchContext: QuickAccessLaunchContext,
result: QuickAccessResult
): void {
if (typeof window === "undefined") return;
result: QuickAccessResult,
returnedReferenceKinds?: readonly string[]
): boolean {
if (
typeof window === "undefined"
|| !isQuickAccessResultAllowed(result, launchContext, returnedReferenceKinds)
) return false;
window.dispatchEvent(new CustomEvent(QUICK_ACCESS_RESULT_EVENT, {
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 {
+6 -1
View File
@@ -182,6 +182,7 @@ function productAreasFromMetadata(info: PlatformModuleInfo): ProductAreaContribu
function quickAccessToolsFromMetadata(info: PlatformModuleInfo): QuickAccessToolMetadata[] {
return (info.frontend?.quick_access_tools ?? []).map((tool) => ({
contractVersion: tool.contract_version,
id: tool.id,
moduleId: tool.module_id,
categoryId: tool.category_id,
@@ -194,7 +195,11 @@ function quickAccessToolsFromMetadata(info: PlatformModuleInfo): QuickAccessTool
anyOf: tool.required_any,
order: tool.order,
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::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-invalid::before,
.page-dirty-state-save-failed::before,
.page-dirty-state-conflict::before { background: var(--danger); }
.page-dirty-state-saving::before { background: var(--accent); }
.app-content { min-height: 0; overflow: hidden; }
.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-default { --metric-group-column-minimum: 140px; }
.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 .metric-label { font-size: 11px; }
.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-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-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-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; }
+27 -1
View File
@@ -284,6 +284,7 @@ export type ProductAreaContribution = {
};
export type QuickAccessToolMetadata = {
contractVersion: "1";
id: string;
moduleId: string;
categoryId: string;
@@ -297,6 +298,10 @@ export type QuickAccessToolMetadata = {
order?: number;
defaultEnabled?: boolean;
modes?: string[];
availability: "global" | "active_object";
acceptedReferenceKinds: string[];
returnedReferenceKinds: string[];
helpContextId?: string | null;
};
export type PlatformRouteContext = {
@@ -493,6 +498,8 @@ export type ViewPresentation = {
navigationMode?: "grouped" | "flat";
productAreaOrder?: string[];
productAreaLabels?: Record<string, string>;
quickAccessRecommendedToolIds?: string[];
quickAccessFocusedToolIds?: string[];
};
export type ViewSelectorProps = {
@@ -566,7 +573,8 @@ export type ActiveObjectReference = {
};
export type QuickAccessLaunchContext = {
contractVersion: "1";
contractVersion: "2";
referenceContractVersion: "1";
origin: {
pathname: string;
search: string;
@@ -589,12 +597,24 @@ export type QuickAccessLaunchContext = {
viewId: string;
revisionId: string | null;
name: string | null;
recommendedToolIds: string[];
focusedToolIds: string[];
} | null;
};
export type QuickAccessCancellationReason = "user" | "dismissed" | "unavailable" | "failed";
export type QuickAccessResult = {
contractVersion: "1";
outcome: "completed";
action: "created" | "selected" | "updated" | "completed";
reference?: ActiveObjectReference | null;
} | {
contractVersion: "1";
outcome: "cancelled";
reason?: QuickAccessCancellationReason;
action?: never;
reference?: never;
};
export type QuickAccessToolRenderContext = PlatformRouteContext & {
@@ -602,6 +622,7 @@ export type QuickAccessToolRenderContext = PlatformRouteContext & {
active: boolean;
launchContext: QuickAccessLaunchContext;
complete: (result: QuickAccessResult) => void;
cancel: (reason?: QuickAccessCancellationReason) => void;
};
export type QuickAccessToolContribution = {
@@ -1179,6 +1200,11 @@ export type PlatformFrontendModuleInfo = {
order: number;
default_enabled: boolean;
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 {
createQuickAccessLaunchContext,
isQuickAccessResultAllowed,
quickAccessLaunchContextFromState,
quickAccessLaunchState,
quickAccessReturnPath
@@ -57,15 +58,21 @@ const context = createQuickAccessLaunchContext({
locked: false,
availableViews: [],
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.actingContext?.assignmentId === "assignment-1", "acting assignment 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?.recommendedToolIds[0] === "mail.messages", "View recommendations must survive launch without becoming authority");
assert(
quickAccessReturnPath(context) === "/cases/case-1?tab=history#revision-4",
"return path must preserve route, query and fragment"
@@ -75,7 +82,7 @@ assert(
"router state must decode a valid launch context"
);
assert(
quickAccessLaunchContextFromState({ govoplanQuickAccessLaunch: { contractVersion: "2" } }) === null,
quickAccessLaunchContextFromState({ govoplanQuickAccessLaunch: { contractVersion: "3" } }) === null,
"unknown launch context versions must fail closed"
);
@@ -86,3 +93,56 @@ const crossTenant = createQuickAccessLaunchContext({
temporalContext: { validityMode: "current", validAt: null, recordedAt: null }
});
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 WorkspaceLayout from "../src/components/WorkspaceLayout";
import WorkspaceFrame from "../src/components/WorkspaceFrame";
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
// @ts-expect-error Refreshable pages must provide a Reload action.
const refreshableWithoutReload = <PageActionBar variant="detail" refreshable />;
@@ -52,9 +53,9 @@ const editorActionBarMarkup = renderToStaticMarkup(
<PageActionBar
variant="editor"
refreshable
dirty
state="dirty"
label="Editor actions"
reloadAction={<button type="button">Reload</button>}
reloadAction={{ onReload: () => undefined, label: "Reload" }}
contextActions={<button type="button">Preview</button>}
helpAction={<button type="button">Help</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="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('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");
const cleanEditorActionBarMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<PageActionBar
variant="editor"
dirty={false}
state="clean"
discardAction={{ label: "Discard" }}
saveAction={{ label: "Save" }}
/>
@@ -94,13 +96,32 @@ const collectionActionBarMarkup = renderToStaticMarkup(
<PageActionBar
variant="collection"
refreshable
reloadAction={<button type="button">Reload</button>}
reloadAction={{ onReload: () => undefined, label: "Reload" }}
createAction={<button type="button">Create</button>}
/>
</PlatformLanguageProvider>
);
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(
<ContentGrid columns={3} gap="compact" collapseAt="wide">
<GridItem>A</GridItem>
+38 -1
View File
@@ -17,10 +17,47 @@ const translatedMarkup = renderToStaticMarkup(
</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-value">Core only</div>'), "a string value is translated");
assert(!translatedMarkup.includes("i18n:govoplan-core."), "translation keys never leak into visible card text");
const numericMarkup = renderToStaticMarkup(<MetricCard label="Count" value={7} />);
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"
title="Shared page"
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"
success="Saved"
interfaceId="test.page"
+10 -1
View File
@@ -1,7 +1,16 @@
{
"extends": "./tsconfig.json",
"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"]
}
+8
View File
@@ -5,6 +5,14 @@ import react from "@vitejs/plugin-react";
export default defineConfig({
root: resolve(import.meta.dirname, "conformance"),
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: {
outDir: resolve(import.meta.dirname, "dist-conformance"),
emptyOutDir: true