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
+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"