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