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
+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}
/>
);
}