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