feat(webui): govern actions, quick access, and metrics
Refs #264, #285, #289
This commit is contained in:
@@ -17,12 +17,12 @@ domain modules own their compositions.
|
|||||||
| Full-canvas workspace | Navigation/content and list/detail canvases that own pane geometry and scrolling | Navigation and split variants, primary-pane width, pane-owned or contained scrolling, responsive stacking or navigation collapse, pane labels, and contextual-help identity are centralized without encoding domain navigation | `WorkspaceLayout.tsx`, `workspace-layout.test.tsx`, Core Settings, Access administration, Docs, Organizations, Campaign, Templates, Approvals, and `check-shared-webui-layouts.py`; the raw-workspace exception baseline is empty |
|
| Full-canvas workspace | Navigation/content and list/detail canvases that own pane geometry and scrolling | Navigation and split variants, primary-pane width, pane-owned or contained scrolling, responsive stacking or navigation collapse, pane labels, and contextual-help identity are centralized without encoding domain navigation | `WorkspaceLayout.tsx`, `workspace-layout.test.tsx`, Core Settings, Access administration, Docs, Organizations, Campaign, Templates, Approvals, and `check-shared-webui-layouts.py`; the raw-workspace exception baseline is empty |
|
||||||
| Full-height module frame | Outer module landmark and viewport/container sizing | `WorkspaceFrame` centralizes surface, overflow, box sizing, accessible naming, help identity, and application-viewport height so modules do not copy the `100vh - shell` frame | `WorkspaceFrame.tsx`, `layout-primitives.test.tsx`, Dataflow, Workflow, Datasources, Distribution Lists, Notifications, Tasks, Scheduling, Forms, Portal, Projects, Records, and Reporting |
|
| Full-height module frame | Outer module landmark and viewport/container sizing | `WorkspaceFrame` centralizes surface, overflow, box sizing, accessible naming, help identity, and application-viewport height so modules do not copy the `100vh - shell` frame | `WorkspaceFrame.tsx`, `layout-primitives.test.tsx`, Dataflow, Workflow, Datasources, Distribution Lists, Notifications, Tasks, Scheduling, Forms, Portal, Projects, Records, and Reporting |
|
||||||
| Responsive action toolbar | Domain-neutral action and filter grouping for pages, workspaces, editors, and overlays | Density, surface, grouping, flexible space, accessible naming, toolbar help identity, and responsive wrapping are centralized while modules retain action wording, authority, and consequence | `ActionToolbar.tsx`, `layout-primitives.test.tsx`, WYSIWYG, Calendar, Files, Forms, Templates, and the product-wide primitive check |
|
| Responsive action toolbar | Domain-neutral action and filter grouping for pages, workspaces, editors, and overlays | Density, surface, grouping, flexible space, accessible naming, toolbar help identity, and responsive wrapping are centralized while modules retain action wording, authority, and consequence | `ActionToolbar.tsx`, `layout-primitives.test.tsx`, WYSIWYG, Calendar, Files, Forms, Templates, and the product-wide primitive check |
|
||||||
| Semantic page archetypes and action bar | Overview, collection, detail, editor, and workspace intent declared independently from frame geometry and composed over the responsive toolbar | Refreshable pages require leading Reload; editor persistence owns clean/dirty/saving feedback, guarded Discard and far-right Save; destructive actions occupy an explicit separated slot; read-only pages do not invent Save | `PageLayout.tsx`, `PageActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, component and browser conformance, every headed product page, and `check-shared-webui-layouts.py` |
|
| Semantic page and pane action bars | Overview, collection, detail, editor, and workspace intent declared independently from frame geometry; full-canvas panes add workspace/collection/detail/editor scope | Core renders leading Reload from a guarded descriptor; editor persistence owns clean, dirty, invalid, saving, failed, and conflict feedback plus guarded Discard and far-right Save; destructive actions occupy an explicit named boundary; read-only surfaces do not invent Save | `PageActionBar.tsx`, `WorkspaceActionBar.tsx`, `PAGE_LAYOUT_USAGE_GUIDELINES.md`, component and browser conformance, every headed page and full-canvas workspace, and the discovery-based `check-shared-webui-layouts.py` |
|
||||||
| Catalogue and state composition | Search/filter bars, selectable navigation lists, count badges, and empty/blocked/error panels | Width, surface, wrap, selection geometry, title/description truncation, numeric emphasis, state sizing, tone and action placement are centralized; modules retain query behavior, object state and consequences | `FilterBar.tsx`, `SelectionList.tsx`, `CountBadge.tsx`, `StatePanel.tsx`, `layout-primitives.test.tsx`, and list/detail modules across Cases, Committee, Dataflow, Forms, Notifications, Portal, Postbox, Projects, Records, Reporting, Tasks, Templates, and Workflow |
|
| Catalogue and state composition | Search/filter bars, selectable navigation lists, count badges, and empty/blocked/error panels | Width, surface, wrap, selection geometry, title/description truncation, numeric emphasis, state sizing, tone and action placement are centralized; modules retain query behavior, object state and consequences | `FilterBar.tsx`, `SelectionList.tsx`, `CountBadge.tsx`, `StatePanel.tsx`, `layout-primitives.test.tsx`, and list/detail modules across Cases, Committee, Dataflow, Forms, Notifications, Portal, Postbox, Projects, Records, Reporting, Tasks, Templates, and Workflow |
|
||||||
| Content and form grids | Equal-column content, field, and native-form geometry | Explicit 1–4 columns, standard gaps, item spans, alignment, and named narrow/workspace/standard/wide collapse points replace generic and module-prefixed copies; unequal domain tracks remain local | `ContentGrid.tsx`, `layout-primitives.test.tsx`, Core dashboard/settings/mail, Calendar dialogs, Forms editor, Datasources, Postbox, Campaign, administration surfaces, and the product-wide primitive check |
|
| Content and form grids | Equal-column content, field, and native-form geometry | Explicit 1–4 columns, standard gaps, item spans, alignment, and named narrow/workspace/standard/wide collapse points replace generic and module-prefixed copies; unequal domain tracks remain local | `ContentGrid.tsx`, `layout-primitives.test.tsx`, Core dashboard/settings/mail, Calendar dialogs, Forms editor, Datasources, Postbox, Campaign, administration surfaces, and the product-wide primitive check |
|
||||||
| Content sections | Repeated editor/detail section surfaces | Border, surface, compact/default density, stacked flow and block rhythm are centralized without encoding section contents | `ContentSection.tsx`, `layout-primitives.test.tsx`, Datasources, Distribution Lists, Templates, Dataflow, and Workflow |
|
| Content sections | Repeated editor/detail section surfaces | Border, surface, compact/default density, stacked flow and block rhythm are centralized without encoding section contents | `ContentSection.tsx`, `layout-primitives.test.tsx`, Datasources, Distribution Lists, Templates, Dataflow, and Workflow |
|
||||||
| Form sections | Reusable heading/description/action/content grouping inside forms | Plain, separated, and panel variants centralize hierarchy and narrow action placement without moving validation, permissions, values, or domain wording into Core | `FormSection.tsx`, `layout-primitives.test.tsx`, Addresses contact editing, and Quick Access preferences |
|
| Form sections | Reusable heading/description/action/content grouping inside forms | Plain, separated, and panel variants centralize hierarchy and narrow action placement without moving validation, permissions, values, or domain wording into Core | `FormSection.tsx`, `layout-primitives.test.tsx`, Addresses contact editing, and Quick Access preferences |
|
||||||
| Metric groups | Reusable responsive grouping around metric cards | Fixed one-to-five and auto-fit columns, minimum card widths, density, block/inset/zero spacing, and named collapse points replace the product-wide `metric-grid` class and cross-module dashboard overrides | `MetricGrid.tsx`, `MetricCard.tsx`, `layout-primitives.test.tsx`, Core and Dashboard summaries, administration, Campaign, Ops, Files, Search, and dashboard widgets |
|
| Metric groups and drill-downs | Reusable responsive grouping around metric cards with an explicit optional detail affordance | Fixed one-to-five and auto-fit columns, minimum card widths, density, block/inset/zero spacing, and named collapse points replace the product-wide `metric-grid` class and cross-module dashboard overrides; typed link or in-page drill-downs name their destination while summary-only, non-enumerable, derived, or privacy-suppressed values remain inert | `MetricGrid.tsx`, `MetricCard.tsx`, `metric-card.test.tsx`, `layout-primitives.test.tsx`, Core and Dashboard summaries, administration, Campaign, Ops, Files, Search, and dashboard widgets |
|
||||||
| Description lists | Semantic property and fact presentation | Stacked and inline variants, one-to-five list columns, density, term width, wrapping, and responsive collapse replace both `admin-details-grid` and `detail-list`; `DescriptionItem` preserves native `dt`/`dd` anatomy | `DescriptionList.tsx`, `layout-primitives.test.tsx`, Access and Tenancy administration, Audit, Policy, Campaign reports/imports, Docs, Settings, Ops, and Reporting |
|
| Description lists | Semantic property and fact presentation | Stacked and inline variants, one-to-five list columns, density, term width, wrapping, and responsive collapse replace both `admin-details-grid` and `detail-list`; `DescriptionItem` preserves native `dt`/`dd` anatomy | `DescriptionList.tsx`, `layout-primitives.test.tsx`, Access and Tenancy administration, Audit, Policy, Campaign reports/imports, Docs, Settings, Ops, and Reporting |
|
||||||
| Dialog anatomy | Shared outer dialog plus composable body and footer regions | Size and administration variants, body padding, descriptions, notices, fixed action wrapping, native form flow, and section grouping are centralized; focus trapping and stack lifecycle remain unchanged | `Dialog.tsx`, `DialogAnatomy.tsx`, `dialog-focus.test.tsx`, `layout-primitives.test.tsx`, Addresses, Calendar, Records, Datasources, Distribution Lists, Files, and Templates |
|
| Dialog anatomy | Shared outer dialog plus composable body and footer regions | Size and administration variants, body padding, descriptions, notices, fixed action wrapping, native form flow, and section grouping are centralized; focus trapping and stack lifecycle remain unchanged | `Dialog.tsx`, `DialogAnatomy.tsx`, `dialog-focus.test.tsx`, `layout-primitives.test.tsx`, Addresses, Calendar, Records, Datasources, Distribution Lists, Files, and Templates |
|
||||||
| Definition-editor visuals | Reusable graph palette, canvas chrome, node icon/port geometry, empty overlay and floating activity state | Core owns visual and responsive anatomy while node/edge types, validation, execution, provenance and workflow semantics remain in Dataflow or Workflow | `DefinitionPalette.tsx`, `DefinitionNodeIcon.tsx`, `FloatingStatus.tsx`, shared definition styles, Dataflow and Workflow structure/build checks |
|
| Definition-editor visuals | Reusable graph palette, canvas chrome, node icon/port geometry, empty overlay and floating activity state | Core owns visual and responsive anatomy while node/edge types, validation, execution, provenance and workflow semantics remain in Dataflow or Workflow | `DefinitionPalette.tsx`, `DefinitionNodeIcon.tsx`, `FloatingStatus.tsx`, shared definition styles, Dataflow and Workflow structure/build checks |
|
||||||
@@ -48,4 +48,6 @@ chrome. Retired copies and module-local component definitions are rejected by
|
|||||||
`check-shared-webui-primitives.py`. That check also requires standard dialog
|
`check-shared-webui-primitives.py`. That check also requires standard dialog
|
||||||
widths to use `Dialog size` and keeps every remaining domain-specific width in
|
widths to use `Dialog size` and keeps every remaining domain-specific width in
|
||||||
a reviewed, decrease-only exception baseline. The companion layout check now
|
a reviewed, decrease-only exception baseline. The companion layout check now
|
||||||
has zero raw page-frame and zero raw workspace exceptions.
|
has zero raw page-frame and zero raw workspace exceptions, discovers semantic
|
||||||
|
consumers without a hand-maintained route list, requires semantic action bars
|
||||||
|
on `WorkspaceFrame` routes, and rejects ad-hoc panel-header toolbars.
|
||||||
|
|||||||
@@ -35,9 +35,13 @@ filter is editable.
|
|||||||
|
|
||||||
## Page Action Rules
|
## Page Action Rules
|
||||||
|
|
||||||
Pass one `PageActionBar` to the `PageLayout` `actions` slot. The variant makes
|
Pass one `PageActionBar` to the `PageLayout` `actions` slot. Full-canvas
|
||||||
the page's intent inspectable and preserves the same keyboard and visual order
|
workspaces use the same contract through `WorkspaceActionBar`, with an explicit
|
||||||
across modules.
|
`workspace`, `collection-pane`, `detail-pane`, or `editor-pane` scope. The
|
||||||
|
variant makes the surface's intent inspectable and preserves the same keyboard
|
||||||
|
and visual order across modules. `ActionToolbar` remains the lower-level
|
||||||
|
component for section-local controls; it is not a substitute for a semantic
|
||||||
|
page or pane action bar.
|
||||||
|
|
||||||
| Page kind | Leading group | Trailing group |
|
| Page kind | Leading group | Trailing group |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -55,14 +59,24 @@ persistent side panel. Save is present only where the page owns an editable
|
|||||||
draft; a read-only detail page must not display a disabled or inert Save merely
|
draft; a read-only detail page must not display a disabled or inert Save merely
|
||||||
to fill the slot.
|
to fill the slot.
|
||||||
|
|
||||||
Editor bars always keep Discard and Save visible. They expose `clean`, `dirty`,
|
Editor bars always keep Discard and Save visible. Their required `state`
|
||||||
and `saving` status through a live status label. In the clean or saving state,
|
projection is one of `clean`, `dirty`, `invalid`, `saving`, `save-failed`, or
|
||||||
the central component disables both persistence actions and supplies the
|
`conflict`, and the central component announces it through a live status label.
|
||||||
standard explanation. A module may add a more specific validation, policy, or
|
Clean and saving states disable both persistence actions; invalid disables Save
|
||||||
permission blocker while the draft is dirty. The editor must register its
|
while retaining Discard. Failed saves and conflicts keep the draft recoverable
|
||||||
draft with `useUnsavedDraftGuard` (or a shared hook that uses the same
|
and allow an authorized retry after the module has shown the owning error or
|
||||||
registration contract), so browser unload, route navigation, section changes,
|
conflict evidence. A module may add a more specific validation, policy, or
|
||||||
and the explicit Discard path cannot silently lose work.
|
permission blocker. The editor must register its draft with
|
||||||
|
`useUnsavedDraftGuard` (or a shared hook that uses the same registration
|
||||||
|
contract), so browser unload, route navigation, section changes, Reload, and
|
||||||
|
the explicit Discard path cannot silently lose work.
|
||||||
|
|
||||||
|
Reload is rendered by Core from a descriptor rather than passed as arbitrary
|
||||||
|
button markup. It can project `current`, `stale`, `reloading`, or
|
||||||
|
`reload-failed`; `loading` is the shorthand for `reloading`. A failed refresh
|
||||||
|
must preserve usable loaded data, expose its stale/failure state, and leave
|
||||||
|
Reload available for recovery. Reload goes through the same unsaved-navigation
|
||||||
|
guard as route changes.
|
||||||
|
|
||||||
Destructive page actions use `destructiveActions`; never put a danger action in
|
Destructive page actions use `destructiveActions`; never put a danger action in
|
||||||
`contextActions` or the ordinary primary group. Core renders a persistent
|
`contextActions` or the ordinary primary group. Core renders a persistent
|
||||||
@@ -95,17 +109,26 @@ silently hide a normally applicable action.
|
|||||||
- Use `MetricGrid`/`MetricCard` for summary measures, `Card` or
|
- Use `MetricGrid`/`MetricCard` for summary measures, `Card` or
|
||||||
`ContentSection` for logical sections, and `DescriptionList` for labelled
|
`ContentSection` for logical sections, and `DescriptionList` for labelled
|
||||||
facts.
|
facts.
|
||||||
|
- Add a `MetricCard.drilldown` only when the displayed measure has a useful,
|
||||||
|
authorized underlying collection or detail. Name the destination explicitly
|
||||||
|
(for example, “Review failed deliveries”) and preserve the current scope and
|
||||||
|
filters in its `href` or action. The card itself remains non-interactive so
|
||||||
|
the action is visible and keyboard-predictable. Derived, privacy-suppressed,
|
||||||
|
non-enumerable, or purely informational aggregates remain plain metrics;
|
||||||
|
when an ordinarily available drill-down is temporarily blocked, keep its
|
||||||
|
action and provide `disabledReason`.
|
||||||
- Preserve loaded data after a refresh failure and mark it stale; offer Reload
|
- Preserve loaded data after a refresh failure and mark it stale; offer Reload
|
||||||
as the recovery action. Distinguish initial loading, empty, unavailable,
|
as the recovery action. Distinguish initial loading, empty, unavailable,
|
||||||
permission-blocked, conflict, success, and retry states.
|
permission-blocked, conflict, success, and retry states.
|
||||||
|
|
||||||
## Review Evidence
|
## Review Evidence
|
||||||
|
|
||||||
Every new or changed page must have structural evidence for its page frame,
|
Every new or changed page or workspace pane must have structural evidence for
|
||||||
semantic archetype and slot order, refresh declaration, shared component
|
its frame, semantic archetype/scope and slot order, refresh declaration, shared
|
||||||
usage, stable disabled actions, dirty guard, destructive boundary, and
|
component usage, stable disabled actions, dirty guard, destructive boundary,
|
||||||
module-owned help identity. Type checks enforce conditional Reload and editor
|
and module-owned help identity. Type checks enforce conditional Reload and
|
||||||
persistence props. Product checks reject undeclared archetypes and ad-hoc
|
editor persistence props. The product check discovers all consumers, rejects
|
||||||
headed action fragments. Browser conformance confirms keyboard order, live
|
undeclared archetypes and `ActionToolbar` panel-header copies, and requires
|
||||||
dirty-state changes, accessibility, destructive separation, narrow wrapping,
|
semantic actions for every `WorkspaceFrame` route. Browser conformance confirms
|
||||||
and screenshot geometry.
|
keyboard order, lifecycle changes, accessibility, destructive separation,
|
||||||
|
narrow wrapping, and screenshot geometry.
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ class ProductAreaContribution:
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class QuickAccessTool:
|
class QuickAccessTool:
|
||||||
"""Declare a compact module-owned tool for an optional Quick Access rail."""
|
"""Declare a versioned, bounded module-owned Quick Access tool."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
module_id: str
|
module_id: str
|
||||||
@@ -128,6 +128,11 @@ class QuickAccessTool:
|
|||||||
order: int = 100
|
order: int = 100
|
||||||
default_enabled: bool = True
|
default_enabled: bool = True
|
||||||
modes: tuple[str, ...] = ("browse",)
|
modes: tuple[str, ...] = ("browse",)
|
||||||
|
contract_version: str = "1"
|
||||||
|
availability: Literal["global", "active_object"] = "global"
|
||||||
|
accepted_reference_kinds: tuple[str, ...] = ()
|
||||||
|
returned_reference_kinds: tuple[str, ...] = ()
|
||||||
|
help_context_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|||||||
@@ -1468,6 +1468,28 @@ def _validate_quick_access_tool(
|
|||||||
raise RegistryError(
|
raise RegistryError(
|
||||||
f"Quick Access tool {tool.id!r} must declare at least one valid mode"
|
f"Quick Access tool {tool.id!r} must declare at least one valid mode"
|
||||||
)
|
)
|
||||||
|
if tool.contract_version != "1":
|
||||||
|
raise RegistryError(
|
||||||
|
f"Quick Access tool {tool.id!r} uses unsupported contract version "
|
||||||
|
f"{tool.contract_version!r}"
|
||||||
|
)
|
||||||
|
if tool.availability not in {"global", "active_object"}:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Quick Access tool {tool.id!r} has invalid availability"
|
||||||
|
)
|
||||||
|
reference_kinds = (*tool.accepted_reference_kinds, *tool.returned_reference_kinds)
|
||||||
|
if any(not _QUICK_ACCESS_TOOL_ID_RE.fullmatch(kind) for kind in reference_kinds):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Quick Access tool {tool.id!r} contains an invalid reference kind"
|
||||||
|
)
|
||||||
|
if tool.availability == "active_object" and not tool.accepted_reference_kinds:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Contextual Quick Access tool {tool.id!r} must declare accepted reference kinds"
|
||||||
|
)
|
||||||
|
if tool.help_context_id is not None and not validate_view_surface_id(tool.help_context_id):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Quick Access tool {tool.id!r} has an invalid help context id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_view_surfaces(manifest: ModuleManifest) -> None:
|
def _validate_view_surfaces(manifest: ModuleManifest) -> None:
|
||||||
|
|||||||
@@ -168,6 +168,11 @@ def _quick_access_tool_payload(tool: QuickAccessTool) -> dict[str, object]:
|
|||||||
"order": tool.order,
|
"order": tool.order,
|
||||||
"default_enabled": tool.default_enabled,
|
"default_enabled": tool.default_enabled,
|
||||||
"modes": list(tool.modes),
|
"modes": list(tool.modes),
|
||||||
|
"contract_version": tool.contract_version,
|
||||||
|
"availability": tool.availability,
|
||||||
|
"accepted_reference_kinds": list(tool.accepted_reference_kinds),
|
||||||
|
"returned_reference_kinds": list(tool.returned_reference_kinds),
|
||||||
|
"help_context_id": tool.help_context_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ def presentation_manifest() -> ModuleManifest:
|
|||||||
icon="list-checks",
|
icon="list-checks",
|
||||||
full_page_path="/example",
|
full_page_path="/example",
|
||||||
required_any=("example:item:read",),
|
required_any=("example:item:read",),
|
||||||
|
returned_reference_kinds=("example.item",),
|
||||||
|
help_context_id="example.quick.summary",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -120,6 +122,15 @@ class PresentationContractTests(unittest.TestCase):
|
|||||||
self.assertEqual("1", frontend["presentation_contract_version"])
|
self.assertEqual("1", frontend["presentation_contract_version"])
|
||||||
self.assertEqual("work", frontend["product_areas"][0]["id"])
|
self.assertEqual("work", frontend["product_areas"][0]["id"])
|
||||||
self.assertEqual("example.summary", frontend["quick_access_tools"][0]["id"])
|
self.assertEqual("example.summary", frontend["quick_access_tools"][0]["id"])
|
||||||
|
self.assertEqual("1", frontend["quick_access_tools"][0]["contract_version"])
|
||||||
|
self.assertEqual(
|
||||||
|
["example.item"],
|
||||||
|
frontend["quick_access_tools"][0]["returned_reference_kinds"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"example.quick.summary",
|
||||||
|
frontend["quick_access_tools"][0]["help_context_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react";
|
import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
|
import QuickAccessRail from "../../../govoplan-quick-access/webui/src/components/QuickAccessRail";
|
||||||
import ActionToolbar from "../src/components/ActionToolbar";
|
import ActionToolbar from "../src/components/ActionToolbar";
|
||||||
import Button from "../src/components/Button";
|
import Button from "../src/components/Button";
|
||||||
import Card from "../src/components/Card";
|
import Card from "../src/components/Card";
|
||||||
@@ -23,17 +24,20 @@ import SelectionList, { SelectionListItem, SelectionListItemContent } from "../s
|
|||||||
import StatePanel from "../src/components/StatePanel";
|
import StatePanel from "../src/components/StatePanel";
|
||||||
import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
||||||
import WorkspaceLayout from "../src/components/WorkspaceLayout";
|
import WorkspaceLayout from "../src/components/WorkspaceLayout";
|
||||||
|
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
|
||||||
import BreadcrumbBar from "../src/layout/BreadcrumbBar";
|
import BreadcrumbBar from "../src/layout/BreadcrumbBar";
|
||||||
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
|
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
|
||||||
import {
|
import {
|
||||||
createQuickAccessLaunchContext,
|
createQuickAccessLaunchContext,
|
||||||
quickAccessLaunchState
|
quickAccessLaunchState
|
||||||
} from "../src/platform/launchContext";
|
} from "../src/platform/launchContext";
|
||||||
import type { AuthInfo } from "../src/types";
|
import type { ApiSettings, AuthInfo, QuickAccessToolMetadata } from "../src/types";
|
||||||
|
|
||||||
export default function ConformanceApp() {
|
export default function ConformanceApp() {
|
||||||
|
const location = useLocation();
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editorDirty, setEditorDirty] = useState(true);
|
const [editorDirty, setEditorDirty] = useState(true);
|
||||||
|
const [metricDrilldown, setMetricDrilldown] = useState("");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="conformance-root" data-conformance-id="shared-ui-lab">
|
<main className="conformance-root" data-conformance-id="shared-ui-lab">
|
||||||
@@ -49,9 +53,9 @@ export default function ConformanceApp() {
|
|||||||
<PageActionBar
|
<PageActionBar
|
||||||
variant="editor"
|
variant="editor"
|
||||||
refreshable
|
refreshable
|
||||||
dirty={editorDirty}
|
state={editorDirty ? "dirty" : "clean"}
|
||||||
label="Bearbeitungsaktionen"
|
label="Bearbeitungsaktionen"
|
||||||
reloadAction={<Button variant="ghost">Neu laden</Button>}
|
reloadAction={{ onReload: () => undefined, label: "Neu laden" }}
|
||||||
contextActions={<Button>Vorschau öffnen</Button>}
|
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>}
|
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) }}
|
discardAction={{ label: "Verwerfen", onClick: () => setEditorDirty(false) }}
|
||||||
@@ -73,11 +77,12 @@ export default function ConformanceApp() {
|
|||||||
<section className="conformance-section" aria-labelledby="metrics-heading">
|
<section className="conformance-section" aria-labelledby="metrics-heading">
|
||||||
<h2 id="metrics-heading">Kennzahlen und Eigenschaften</h2>
|
<h2 id="metrics-heading">Kennzahlen und Eigenschaften</h2>
|
||||||
<MetricGrid columns={4} collapseAt="standard">
|
<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="Fristgerecht" value="94 %" tone="good" detail="Letzte 30 Tage" />
|
||||||
<MetricCard label="Klärung erforderlich" value="4" tone="warning" density="compact" />
|
<MetricCard label="Klärung erforderlich" value="4" tone="warning" density="compact" />
|
||||||
<MetricCard label="Fehlgeschlagen" value="1" tone="danger" surface="subtle" />
|
<MetricCard label="Fehlgeschlagen" value="1" tone="danger" surface="subtle" />
|
||||||
</MetricGrid>
|
</MetricGrid>
|
||||||
|
<p data-testid="metric-drilldown-result" role="status">{metricDrilldown}</p>
|
||||||
<ContentSection density="default" surface="subtle">
|
<ContentSection density="default" surface="subtle">
|
||||||
<h3>Nachvollziehbare Entscheidung</h3>
|
<h3>Nachvollziehbare Entscheidung</h3>
|
||||||
<DescriptionList columns={3} collapseAt="standard">
|
<DescriptionList columns={3} collapseAt="standard">
|
||||||
@@ -98,25 +103,31 @@ export default function ConformanceApp() {
|
|||||||
primaryLabel="Vorgänge"
|
primaryLabel="Vorgänge"
|
||||||
contentLabel="Ausgewählter Vorgang"
|
contentLabel="Ausgewählter Vorgang"
|
||||||
primary={
|
primary={
|
||||||
<SelectionList label="Vorgänge" variant="navigation">
|
<>
|
||||||
<SelectionListItem selected>
|
<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>} />
|
||||||
<SelectionListItemContent leading={<FileText size={18} />} title="Anwohnerparkausweis" description="A-2026-004218 · Prüfung läuft" />
|
<SelectionList label="Vorgänge" variant="navigation">
|
||||||
</SelectionListItem>
|
<SelectionListItem selected>
|
||||||
<SelectionListItem selected={false}>
|
<SelectionListItemContent leading={<FileText size={18} />} title="Anwohnerparkausweis" description="A-2026-004218 · Prüfung läuft" />
|
||||||
<SelectionListItemContent leading={<FileText size={18} />} title="Sondernutzung öffentlicher Fläche" description="A-2026-004219 · Rückfrage offen" />
|
</SelectionListItem>
|
||||||
</SelectionListItem>
|
<SelectionListItem selected={false}>
|
||||||
</SelectionList>
|
<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>
|
<WorkspaceActionBar scope="detail-pane" variant="detail" contextActions={<strong>Anwohnerparkausweis</strong>} primaryActions={<Button variant="primary">Bearbeiten</Button>} destructiveActions={<Button variant="danger">Schließen</Button>} />
|
||||||
<FormSection title="Nächster Arbeitsschritt" description="Die Entscheidung bleibt nachvollziehbar und kann vor Abschluss korrigiert werden." variant="panel">
|
<Card title="Anwohnerparkausweis">
|
||||||
<FormGrid columns={2}>
|
<p>Die Identität wurde geprüft. Ein aktueller Wohnsitznachweis muss noch bestätigt werden.</p>
|
||||||
<label>Zuständigkeit<input defaultValue="Bürgerdienste Mitte" /></label>
|
<FormSection title="Nächster Arbeitsschritt" description="Die Entscheidung bleibt nachvollziehbar und kann vor Abschluss korrigiert werden." variant="panel">
|
||||||
<label>Bearbeitungsfrist<input type="date" defaultValue="2026-08-28" /></label>
|
<FormGrid columns={2}>
|
||||||
</FormGrid>
|
<label>Zuständigkeit<input defaultValue="Bürgerdienste Mitte" /></label>
|
||||||
</FormSection>
|
<label>Bearbeitungsfrist<input type="date" defaultValue="2026-08-28" /></label>
|
||||||
</Card>
|
</FormGrid>
|
||||||
|
</FormSection>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
</WorkspaceFrame>
|
</WorkspaceFrame>
|
||||||
</section>
|
</section>
|
||||||
@@ -154,10 +165,36 @@ export default function ConformanceApp() {
|
|||||||
</DialogSection>
|
</DialogSection>
|
||||||
</DialogForm>
|
</DialogForm>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
{new URLSearchParams(location.search).has("quick-access") ? <QuickAccessScenario /> : null}
|
||||||
</main>
|
</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() {
|
function LaunchContextScenario() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
@@ -210,6 +247,33 @@ const CONFORMANCE_AUTH = {
|
|||||||
groups_loaded: true
|
groups_loaded: true
|
||||||
} satisfies AuthInfo;
|
} 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 {
|
function browserHistoryIndex(): number | null {
|
||||||
const value = (window.history.state as { idx?: unknown } | null)?.idx;
|
const value = (window.history.state as { idx?: unknown } | null)?.idx;
|
||||||
return typeof value === "number" && Number.isInteger(value) ? value : null;
|
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 [];
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import "../src/styles/tables.css";
|
|||||||
import "../src/styles/badges.css";
|
import "../src/styles/badges.css";
|
||||||
import "../src/styles/components.css";
|
import "../src/styles/components.css";
|
||||||
import "../src/styles/dialogs.css";
|
import "../src/styles/dialogs.css";
|
||||||
|
import "@govoplan/quick-access-webui/styles/quick-access.css";
|
||||||
import "./conformance.css";
|
import "./conformance.css";
|
||||||
|
|
||||||
const theme = new URLSearchParams(window.location.search).get("theme");
|
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: "Verwerfen" })).toBeDisabled();
|
||||||
await expect(editorActions.getByRole("button", { name: "Änderungen speichern" })).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");
|
const opener = page.getByTestId("open-dialog");
|
||||||
await opener.focus();
|
await opener.focus();
|
||||||
await page.keyboard.press("Enter");
|
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$/);
|
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 }) => {
|
test("light and dark desktop geometry remains stable", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||||
await page.goto("/?theme=light");
|
await page.goto("/?theme=light");
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const pageLayout = read("src/components/PageLayout.tsx");
|
|||||||
const workspaceLayout = read("src/components/WorkspaceLayout.tsx");
|
const workspaceLayout = read("src/components/WorkspaceLayout.tsx");
|
||||||
const actionToolbar = read("src/components/ActionToolbar.tsx");
|
const actionToolbar = read("src/components/ActionToolbar.tsx");
|
||||||
const pageActionBar = read("src/components/PageActionBar.tsx");
|
const pageActionBar = read("src/components/PageActionBar.tsx");
|
||||||
|
const workspaceActionBar = read("src/components/WorkspaceActionBar.tsx");
|
||||||
const contentGrid = read("src/components/ContentGrid.tsx");
|
const contentGrid = read("src/components/ContentGrid.tsx");
|
||||||
const formSection = read("src/components/FormSection.tsx");
|
const formSection = read("src/components/FormSection.tsx");
|
||||||
const dialogAnatomy = read("src/components/DialogAnatomy.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, /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, /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, /<ActionBlockerHint/, "retention renders the shared actionable blocker");
|
||||||
assert.match(retention, /contextId: "privacy\.retention"/, "retention exposes stable admin documentation");
|
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: "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: "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, /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, /refreshable: true;\s*reloadAction: PageReloadAction;/, "refreshable pages require a typed Reload action");
|
||||||
assert.match(pageActionBar, /dirty: boolean;/, "editors require an explicit dirty state");
|
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, /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, /<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, /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(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(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(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");
|
assert.match(dialogAnatomy, /dialog-actions-\$\{align\}/, "shared dialog anatomy owns footer action placement");
|
||||||
|
|||||||
@@ -1,7 +1,29 @@
|
|||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
import type { HTMLAttributes, ReactNode } from "react";
|
import type { HTMLAttributes, ReactNode } from "react";
|
||||||
import { translateReactNode, usePlatformLanguage } from "../i18n/LanguageContext";
|
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;
|
label: ReactNode;
|
||||||
value: ReactNode;
|
value: ReactNode;
|
||||||
tone?: "neutral" | "good" | "warning" | "danger" | "info";
|
tone?: "neutral" | "good" | "warning" | "danger" | "info";
|
||||||
@@ -9,6 +31,7 @@ export type MetricCardProps = HTMLAttributes<HTMLDivElement> & {
|
|||||||
valueTitle?: string;
|
valueTitle?: string;
|
||||||
density?: "compact" | "default";
|
density?: "compact" | "default";
|
||||||
surface?: "card" | "subtle" | "flat";
|
surface?: "card" | "subtle" | "flat";
|
||||||
|
drilldown?: MetricDrilldown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MetricCard({
|
export default function MetricCard({
|
||||||
@@ -19,6 +42,7 @@ export default function MetricCard({
|
|||||||
valueTitle,
|
valueTitle,
|
||||||
density = "default",
|
density = "default",
|
||||||
surface = "card",
|
surface = "card",
|
||||||
|
drilldown,
|
||||||
className = "",
|
className = "",
|
||||||
...props
|
...props
|
||||||
}: MetricCardProps) {
|
}: MetricCardProps) {
|
||||||
@@ -26,6 +50,10 @@ export default function MetricCard({
|
|||||||
const renderedLabel = translateReactNode(label, translateText);
|
const renderedLabel = translateReactNode(label, translateText);
|
||||||
const renderedValue = translateReactNode(value, translateText);
|
const renderedValue = translateReactNode(value, translateText);
|
||||||
const renderedDetail = translateReactNode(detail, translateText);
|
const renderedDetail = translateReactNode(detail, translateText);
|
||||||
|
const renderedDrilldownLabel = translateReactNode(drilldown?.label, translateText);
|
||||||
|
const drilldownAccessibleLabel = drilldown?.accessibleLabel
|
||||||
|
? translateText(drilldown.accessibleLabel)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...props} className={[
|
<div {...props} className={[
|
||||||
@@ -38,6 +66,35 @@ export default function MetricCard({
|
|||||||
<div className="metric-label">{renderedLabel}</div>
|
<div className="metric-label">{renderedLabel}</div>
|
||||||
<div className="metric-value" title={valueTitle}>{renderedValue}</div>
|
<div className="metric-value" title={valueTitle}>{renderedValue}</div>
|
||||||
{renderedDetail ? <div className="metric-detail">{renderedDetail}</div> : null}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||||
import type { PlatformInterfaceIdentityProps } from "../types";
|
import type { PlatformInterfaceIdentityProps } from "../types";
|
||||||
import ActionToolbar, { ToolbarGroup } from "./ActionToolbar";
|
import ActionToolbar, { ToolbarGroup, type ActionToolbarDensity, type ActionToolbarSurface } from "./ActionToolbar";
|
||||||
import Button from "./Button";
|
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 = {
|
type RefreshablePageActions = {
|
||||||
refreshable: true;
|
refreshable: true;
|
||||||
reloadAction: ReactNode;
|
reloadAction: PageReloadAction;
|
||||||
} | {
|
} | {
|
||||||
refreshable?: false;
|
refreshable?: false;
|
||||||
reloadAction?: never;
|
reloadAction?: never;
|
||||||
@@ -23,6 +36,8 @@ export type PageEditorAction = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "ch
|
|||||||
disabledReason?: ReactNode;
|
disabledReason?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PageEditorState = "clean" | "dirty" | "invalid" | "saving" | "save-failed" | "conflict";
|
||||||
|
|
||||||
export type OverviewPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
|
export type OverviewPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
|
||||||
variant: "overview";
|
variant: "overview";
|
||||||
primaryActions?: ReactNode;
|
primaryActions?: ReactNode;
|
||||||
@@ -41,13 +56,16 @@ export type DetailPageActionBarProps = PageActionBarCommonProps & RefreshablePag
|
|||||||
|
|
||||||
export type EditorPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
|
export type EditorPageActionBarProps = PageActionBarCommonProps & RefreshablePageActions & {
|
||||||
variant: "editor";
|
variant: "editor";
|
||||||
dirty: boolean;
|
state: PageEditorState;
|
||||||
saving?: boolean;
|
|
||||||
dirtyLabel?: ReactNode;
|
dirtyLabel?: ReactNode;
|
||||||
cleanLabel?: ReactNode;
|
cleanLabel?: ReactNode;
|
||||||
|
invalidLabel?: ReactNode;
|
||||||
savingLabel?: ReactNode;
|
savingLabel?: ReactNode;
|
||||||
|
saveFailedLabel?: ReactNode;
|
||||||
|
conflictLabel?: ReactNode;
|
||||||
cleanDisabledReason?: ReactNode;
|
cleanDisabledReason?: ReactNode;
|
||||||
savingDisabledReason?: ReactNode;
|
savingDisabledReason?: ReactNode;
|
||||||
|
invalidDisabledReason?: ReactNode;
|
||||||
discardAction: PageEditorAction;
|
discardAction: PageEditorAction;
|
||||||
saveAction: PageEditorAction;
|
saveAction: PageEditorAction;
|
||||||
primaryActions?: ReactNode;
|
primaryActions?: ReactNode;
|
||||||
@@ -67,6 +85,14 @@ export type PageActionBarProps =
|
|||||||
| EditorPageActionBarProps
|
| EditorPageActionBarProps
|
||||||
| WorkspacePageActionBarProps;
|
| 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 }) {
|
function ActionSlot({ name, children }: { name: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<span className={`page-action-slot page-action-slot-${name}`} data-page-action-slot={name}>
|
<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;
|
if (!children) return null;
|
||||||
return (
|
return (
|
||||||
<ActionSlot name="destructive">
|
<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}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
</ActionSlot>
|
</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({
|
function EditorAction({
|
||||||
action,
|
action,
|
||||||
variant,
|
variant,
|
||||||
@@ -117,23 +177,29 @@ function EditorAction({
|
|||||||
* The named slots deliberately encode ordering. Product modules still own the
|
* The named slots deliberately encode ordering. Product modules still own the
|
||||||
* actions, wording, permissions, blockers, and consequences placed in them.
|
* actions, wording, permissions, blockers, and consequences placed in them.
|
||||||
*/
|
*/
|
||||||
export default function PageActionBar(props: PageActionBarProps) {
|
export default function PageActionBar(props: PageActionBarProps & SemanticActionBarPresentation) {
|
||||||
const normalizedProps = props as PageActionBarProps & {
|
const normalizedProps = props as PageActionBarProps & SemanticActionBarPresentation & {
|
||||||
createAction?: ReactNode;
|
createAction?: ReactNode;
|
||||||
primaryActions?: ReactNode;
|
primaryActions?: ReactNode;
|
||||||
destructiveActions?: ReactNode;
|
destructiveActions?: ReactNode;
|
||||||
dirty?: boolean;
|
state?: PageEditorState;
|
||||||
saving?: boolean;
|
|
||||||
dirtyLabel?: ReactNode;
|
dirtyLabel?: ReactNode;
|
||||||
cleanLabel?: ReactNode;
|
cleanLabel?: ReactNode;
|
||||||
|
invalidLabel?: ReactNode;
|
||||||
savingLabel?: ReactNode;
|
savingLabel?: ReactNode;
|
||||||
|
saveFailedLabel?: ReactNode;
|
||||||
|
conflictLabel?: ReactNode;
|
||||||
cleanDisabledReason?: ReactNode;
|
cleanDisabledReason?: ReactNode;
|
||||||
savingDisabledReason?: ReactNode;
|
savingDisabledReason?: ReactNode;
|
||||||
|
invalidDisabledReason?: ReactNode;
|
||||||
discardAction?: PageEditorAction;
|
discardAction?: PageEditorAction;
|
||||||
saveAction?: PageEditorAction;
|
saveAction?: PageEditorAction;
|
||||||
};
|
};
|
||||||
const {
|
const {
|
||||||
variant,
|
variant,
|
||||||
|
actionScope = "page",
|
||||||
|
density,
|
||||||
|
surface,
|
||||||
refreshable = false,
|
refreshable = false,
|
||||||
reloadAction,
|
reloadAction,
|
||||||
contextActions,
|
contextActions,
|
||||||
@@ -141,13 +207,16 @@ export default function PageActionBar(props: PageActionBarProps) {
|
|||||||
createAction,
|
createAction,
|
||||||
primaryActions,
|
primaryActions,
|
||||||
destructiveActions,
|
destructiveActions,
|
||||||
dirty = false,
|
state = "clean",
|
||||||
saving = false,
|
|
||||||
dirtyLabel = "Unsaved changes",
|
dirtyLabel = "Unsaved changes",
|
||||||
cleanLabel = "Saved",
|
cleanLabel = "Saved",
|
||||||
|
invalidLabel = "Review required",
|
||||||
savingLabel = "Saving…",
|
savingLabel = "Saving…",
|
||||||
|
saveFailedLabel = "Save failed",
|
||||||
|
conflictLabel = "Conflict",
|
||||||
cleanDisabledReason = "There are no unsaved changes.",
|
cleanDisabledReason = "There are no unsaved changes.",
|
||||||
savingDisabledReason = "Changes are already being saved.",
|
savingDisabledReason = "Changes are already being saved.",
|
||||||
|
invalidDisabledReason = "Resolve the validation problems before saving.",
|
||||||
discardAction,
|
discardAction,
|
||||||
saveAction,
|
saveAction,
|
||||||
label,
|
label,
|
||||||
@@ -172,16 +241,23 @@ export default function PageActionBar(props: PageActionBarProps) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
} else if (variant === "editor") {
|
} 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 = (
|
trailingActions = (
|
||||||
<>
|
<>
|
||||||
{primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null}
|
{primaryActions ? <ActionSlot name="primary">{primaryActions}</ActionSlot> : null}
|
||||||
<DestructiveSlot>{destructiveActions}</DestructiveSlot>
|
<DestructiveSlot>{destructiveActions}</DestructiveSlot>
|
||||||
<ActionSlot name="discard">
|
<ActionSlot name="discard">
|
||||||
<EditorAction action={discardAction!} variant="ghost" disabledReason={persistenceDisabledReason} />
|
<EditorAction action={discardAction!} variant="ghost" disabledReason={discardDisabledReason} />
|
||||||
</ActionSlot>
|
</ActionSlot>
|
||||||
<ActionSlot name="save">
|
<ActionSlot name="save">
|
||||||
<EditorAction action={saveAction!} variant="primary" disabledReason={persistenceDisabledReason} />
|
<EditorAction action={saveAction!} variant="primary" disabledReason={saveDisabledReason} />
|
||||||
</ActionSlot>
|
</ActionSlot>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -198,31 +274,46 @@ export default function PageActionBar(props: PageActionBarProps) {
|
|||||||
<ActionToolbar
|
<ActionToolbar
|
||||||
{...toolbarProps}
|
{...toolbarProps}
|
||||||
className={["page-action-bar", `page-action-bar-${variant}`, className].filter(Boolean).join(" ")}
|
className={["page-action-bar", `page-action-bar-${variant}`, className].filter(Boolean).join(" ")}
|
||||||
density="compact"
|
density={density ?? "compact"}
|
||||||
justify="between"
|
justify="between"
|
||||||
wrap="responsive"
|
wrap="responsive"
|
||||||
label={label ?? defaultLabels[variant]}
|
label={label ?? defaultLabels[variant]}
|
||||||
|
surface={surface}
|
||||||
interfaceId={interfaceId}
|
interfaceId={interfaceId}
|
||||||
helpContextId={helpContextId}
|
helpContextId={helpContextId}
|
||||||
helpModuleId={helpModuleId}
|
helpModuleId={helpModuleId}
|
||||||
helpTopicId={helpTopicId}
|
helpTopicId={helpTopicId}
|
||||||
data-page-action-archetype={variant}
|
data-page-action-archetype={variant}
|
||||||
|
data-action-bar-scope={actionScope}
|
||||||
data-page-refreshable={refreshable ? "true" : "false"}
|
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">
|
<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}
|
{contextActions ? <ActionSlot name="context">{contextActions}</ActionSlot> : null}
|
||||||
</ToolbarGroup>
|
</ToolbarGroup>
|
||||||
<ToolbarGroup className="page-action-bar-trailing" align="end" data-page-action-group="trailing">
|
<ToolbarGroup className="page-action-bar-trailing" align="end" data-page-action-group="trailing">
|
||||||
{variant === "editor" ? (
|
{variant === "editor" ? (
|
||||||
<span
|
<span
|
||||||
className={`page-dirty-state page-dirty-state-${saving ? "saving" : dirty ? "dirty" : "clean"}`}
|
className={`page-dirty-state page-dirty-state-${state}`}
|
||||||
data-page-dirty-state={saving ? "saving" : dirty ? "dirty" : "clean"}
|
data-page-dirty-state={state}
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
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>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{helpAction ? <ActionSlot name="help">{helpAction}</ActionSlot> : 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 { useNavigate, type NavigateFunction, type NavigateOptions, type To } from "react-router";
|
||||||
import Button from "./Button";
|
import Button from "./Button";
|
||||||
import Dialog from "./Dialog";
|
import Dialog from "./Dialog";
|
||||||
import DismissibleAlert from "./DismissibleAlert";
|
import DismissibleAlert from "./DismissibleAlert";
|
||||||
|
import {
|
||||||
|
UnsavedChangesContext,
|
||||||
|
useUnsavedChanges,
|
||||||
|
type UnsavedChangesContextValue,
|
||||||
|
type UnsavedChangesRegistration,
|
||||||
|
type UnsavedNavigationAction
|
||||||
|
} from "./UnsavedChangesContext";
|
||||||
|
|
||||||
export type UnsavedNavigationAction = () => void;
|
export { useUnsavedChanges } from "./UnsavedChangesContext";
|
||||||
|
export type { UnsavedChangesRegistration, UnsavedNavigationAction } from "./UnsavedChangesContext";
|
||||||
export type UnsavedChangesRegistration = {
|
|
||||||
title?: string;
|
|
||||||
message?: string;
|
|
||||||
onSave: () => boolean | Promise<boolean>;
|
|
||||||
onDiscard?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UnsavedDraftGuardOptions = {
|
export type UnsavedDraftGuardOptions = {
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
@@ -22,19 +23,6 @@ export type UnsavedDraftGuardOptions = {
|
|||||||
enabled?: boolean;
|
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;}) {
|
export function UnsavedChangesProvider({ children }: {children: ReactNode;}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [registrations, setRegistrations] = useState<Array<{id: number;registration: UnsavedChangesRegistration;}>>([]);
|
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) {
|
export function useRegisterUnsavedChanges(registration: UnsavedChangesRegistration | null) {
|
||||||
const { registerUnsavedChanges } = useUnsavedChanges();
|
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 ? (
|
actions={editorSection ? (
|
||||||
<PageActionBar
|
<PageActionBar
|
||||||
variant="editor"
|
variant="editor"
|
||||||
dirty={editorDirty}
|
state={editorSaving ? "saving" : editorDirty ? "dirty" : "clean"}
|
||||||
saving={editorSaving}
|
|
||||||
helpAction={<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />}
|
helpAction={<DocumentationHelpLink reference={SETTINGS_DOCUMENTATION} />}
|
||||||
discardAction={{ label: "i18n:govoplan-core.discard.36fff63c", onClick: discardEditor }}
|
discardAction={{ label: "i18n:govoplan-core.discard.36fff63c", onClick: discardEditor }}
|
||||||
saveAction={{
|
saveAction={{
|
||||||
|
|||||||
+4
-2
@@ -135,7 +135,7 @@ export { default as LoadingIndicator } from "./components/LoadingIndicator";
|
|||||||
export { default as ExplorerTree } from "./components/ExplorerTree";
|
export { default as ExplorerTree } from "./components/ExplorerTree";
|
||||||
export type { ExplorerTreeNodeContext, ExplorerTreeProps } from "./components/ExplorerTree";
|
export type { ExplorerTreeNodeContext, ExplorerTreeProps } from "./components/ExplorerTree";
|
||||||
export { default as MetricCard } from "./components/MetricCard";
|
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 { default as MetricGrid } from "./components/MetricGrid";
|
||||||
export type { MetricGridCollapseAt, MetricGridColumns, MetricGridDensity, MetricGridMinimum, MetricGridProps, MetricGridSpacing } from "./components/MetricGrid";
|
export type { MetricGridCollapseAt, MetricGridColumns, MetricGridDensity, MetricGridMinimum, MetricGridProps, MetricGridSpacing } from "./components/MetricGrid";
|
||||||
export { default as MessageDisplayPanel } from "./components/MessageDisplayPanel";
|
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 { default as PageLayout, PageHeader } from "./components/PageLayout";
|
||||||
export type { PageArchetype, PageHeaderProps, PageLayoutMode, PageLayoutProps } from "./components/PageLayout";
|
export type { PageArchetype, PageHeaderProps, PageLayoutMode, PageLayoutProps } from "./components/PageLayout";
|
||||||
export { default as PageActionBar } from "./components/PageActionBar";
|
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 { default as PageScrollViewport } from "./components/PageScrollViewport";
|
||||||
export type { PageScrollViewportProps } from "./components/PageScrollViewport";
|
export type { PageScrollViewportProps } from "./components/PageScrollViewport";
|
||||||
export { default as WorkspaceLayout } from "./components/WorkspaceLayout";
|
export { default as WorkspaceLayout } from "./components/WorkspaceLayout";
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import type {
|
|||||||
import type { TemporalDataSelection } from "./temporal";
|
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_LAUNCH_STATE_KEY = "govoplanQuickAccessLaunch";
|
||||||
export const QUICK_ACCESS_RESULT_EVENT = "govoplan:quick-access-result";
|
export const QUICK_ACCESS_RESULT_EVENT = "govoplan:quick-access-result";
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ export function createQuickAccessLaunchContext({
|
|||||||
const principal = auth.principal;
|
const principal = auth.principal;
|
||||||
return {
|
return {
|
||||||
contractVersion: QUICK_ACCESS_LAUNCH_CONTEXT_VERSION,
|
contractVersion: QUICK_ACCESS_LAUNCH_CONTEXT_VERSION,
|
||||||
|
referenceContractVersion: QUICK_ACCESS_REFERENCE_CONTRACT_VERSION,
|
||||||
origin: {
|
origin: {
|
||||||
pathname: normalizePathname(pathname),
|
pathname: normalizePathname(pathname),
|
||||||
search: normalizeSearch(search),
|
search: normalizeSearch(search),
|
||||||
@@ -61,7 +64,9 @@ export function createQuickAccessLaunchContext({
|
|||||||
? {
|
? {
|
||||||
viewId: viewContext.activeViewId,
|
viewId: viewContext.activeViewId,
|
||||||
revisionId: viewContext.activeRevisionId,
|
revisionId: viewContext.activeRevisionId,
|
||||||
name: viewContext.activeViewName
|
name: viewContext.activeViewName,
|
||||||
|
recommendedToolIds: viewContext.presentation?.quickAccessRecommendedToolIds ?? [],
|
||||||
|
focusedToolIds: viewContext.presentation?.quickAccessFocusedToolIds ?? []
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
};
|
};
|
||||||
@@ -78,7 +83,11 @@ export function quickAccessLaunchContextFromState(
|
|||||||
): QuickAccessLaunchContext | null {
|
): QuickAccessLaunchContext | null {
|
||||||
if (!isRecord(value)) return null;
|
if (!isRecord(value)) return null;
|
||||||
const candidate = value[QUICK_ACCESS_LAUNCH_STATE_KEY];
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
if (!isRecord(candidate.origin) || typeof candidate.origin.pathname !== "string") {
|
if (!isRecord(candidate.origin) || typeof candidate.origin.pathname !== "string") {
|
||||||
@@ -101,12 +110,48 @@ export function quickAccessReturnPath(context: QuickAccessLaunchContext): string
|
|||||||
export function dispatchQuickAccessResult(
|
export function dispatchQuickAccessResult(
|
||||||
toolId: string,
|
toolId: string,
|
||||||
launchContext: QuickAccessLaunchContext,
|
launchContext: QuickAccessLaunchContext,
|
||||||
result: QuickAccessResult
|
result: QuickAccessResult,
|
||||||
): void {
|
returnedReferenceKinds?: readonly string[]
|
||||||
if (typeof window === "undefined") return;
|
): boolean {
|
||||||
|
if (
|
||||||
|
typeof window === "undefined"
|
||||||
|
|| !isQuickAccessResultAllowed(result, launchContext, returnedReferenceKinds)
|
||||||
|
) return false;
|
||||||
window.dispatchEvent(new CustomEvent(QUICK_ACCESS_RESULT_EVENT, {
|
window.dispatchEvent(new CustomEvent(QUICK_ACCESS_RESULT_EVENT, {
|
||||||
detail: { toolId, launchContext, result }
|
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 {
|
function normalizePathname(value: string): string {
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ function productAreasFromMetadata(info: PlatformModuleInfo): ProductAreaContribu
|
|||||||
|
|
||||||
function quickAccessToolsFromMetadata(info: PlatformModuleInfo): QuickAccessToolMetadata[] {
|
function quickAccessToolsFromMetadata(info: PlatformModuleInfo): QuickAccessToolMetadata[] {
|
||||||
return (info.frontend?.quick_access_tools ?? []).map((tool) => ({
|
return (info.frontend?.quick_access_tools ?? []).map((tool) => ({
|
||||||
|
contractVersion: tool.contract_version,
|
||||||
id: tool.id,
|
id: tool.id,
|
||||||
moduleId: tool.module_id,
|
moduleId: tool.module_id,
|
||||||
categoryId: tool.category_id,
|
categoryId: tool.category_id,
|
||||||
@@ -194,7 +195,11 @@ function quickAccessToolsFromMetadata(info: PlatformModuleInfo): QuickAccessTool
|
|||||||
anyOf: tool.required_any,
|
anyOf: tool.required_any,
|
||||||
order: tool.order,
|
order: tool.order,
|
||||||
defaultEnabled: tool.default_enabled,
|
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 { 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::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-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); }
|
.page-dirty-state-saving::before { background: var(--accent); }
|
||||||
.app-content { min-height: 0; overflow: hidden; }
|
.app-content { min-height: 0; overflow: hidden; }
|
||||||
.workspace { height: 100%; min-height: 0; display: grid; grid-template-columns: 198px minmax(0, 1fr); }
|
.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-compact { --metric-group-column-minimum: 112px; }
|
||||||
.metric-group-minimum-default { --metric-group-column-minimum: 140px; }
|
.metric-group-minimum-default { --metric-group-column-minimum: 140px; }
|
||||||
.metric-group-minimum-wide { --metric-group-column-minimum: 180px; }
|
.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 { padding: 10px 12px; border-top-width: 1px; box-shadow: none; }
|
||||||
.metric-card-density-compact .metric-label { font-size: 11px; }
|
.metric-card-density-compact .metric-label { font-size: 11px; }
|
||||||
.metric-card-density-compact .metric-value { margin-top: 5px; font-size: 16px; }
|
.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-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-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-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-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-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; }
|
.wizard-body { background: var(--panel-soft); padding: 28px; min-height: 620px; }
|
||||||
|
|||||||
+27
-1
@@ -284,6 +284,7 @@ export type ProductAreaContribution = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type QuickAccessToolMetadata = {
|
export type QuickAccessToolMetadata = {
|
||||||
|
contractVersion: "1";
|
||||||
id: string;
|
id: string;
|
||||||
moduleId: string;
|
moduleId: string;
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
@@ -297,6 +298,10 @@ export type QuickAccessToolMetadata = {
|
|||||||
order?: number;
|
order?: number;
|
||||||
defaultEnabled?: boolean;
|
defaultEnabled?: boolean;
|
||||||
modes?: string[];
|
modes?: string[];
|
||||||
|
availability: "global" | "active_object";
|
||||||
|
acceptedReferenceKinds: string[];
|
||||||
|
returnedReferenceKinds: string[];
|
||||||
|
helpContextId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlatformRouteContext = {
|
export type PlatformRouteContext = {
|
||||||
@@ -493,6 +498,8 @@ export type ViewPresentation = {
|
|||||||
navigationMode?: "grouped" | "flat";
|
navigationMode?: "grouped" | "flat";
|
||||||
productAreaOrder?: string[];
|
productAreaOrder?: string[];
|
||||||
productAreaLabels?: Record<string, string>;
|
productAreaLabels?: Record<string, string>;
|
||||||
|
quickAccessRecommendedToolIds?: string[];
|
||||||
|
quickAccessFocusedToolIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ViewSelectorProps = {
|
export type ViewSelectorProps = {
|
||||||
@@ -566,7 +573,8 @@ export type ActiveObjectReference = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type QuickAccessLaunchContext = {
|
export type QuickAccessLaunchContext = {
|
||||||
contractVersion: "1";
|
contractVersion: "2";
|
||||||
|
referenceContractVersion: "1";
|
||||||
origin: {
|
origin: {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
search: string;
|
search: string;
|
||||||
@@ -589,12 +597,24 @@ export type QuickAccessLaunchContext = {
|
|||||||
viewId: string;
|
viewId: string;
|
||||||
revisionId: string | null;
|
revisionId: string | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
recommendedToolIds: string[];
|
||||||
|
focusedToolIds: string[];
|
||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QuickAccessCancellationReason = "user" | "dismissed" | "unavailable" | "failed";
|
||||||
|
|
||||||
export type QuickAccessResult = {
|
export type QuickAccessResult = {
|
||||||
|
contractVersion: "1";
|
||||||
|
outcome: "completed";
|
||||||
action: "created" | "selected" | "updated" | "completed";
|
action: "created" | "selected" | "updated" | "completed";
|
||||||
reference?: ActiveObjectReference | null;
|
reference?: ActiveObjectReference | null;
|
||||||
|
} | {
|
||||||
|
contractVersion: "1";
|
||||||
|
outcome: "cancelled";
|
||||||
|
reason?: QuickAccessCancellationReason;
|
||||||
|
action?: never;
|
||||||
|
reference?: never;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QuickAccessToolRenderContext = PlatformRouteContext & {
|
export type QuickAccessToolRenderContext = PlatformRouteContext & {
|
||||||
@@ -602,6 +622,7 @@ export type QuickAccessToolRenderContext = PlatformRouteContext & {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
launchContext: QuickAccessLaunchContext;
|
launchContext: QuickAccessLaunchContext;
|
||||||
complete: (result: QuickAccessResult) => void;
|
complete: (result: QuickAccessResult) => void;
|
||||||
|
cancel: (reason?: QuickAccessCancellationReason) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QuickAccessToolContribution = {
|
export type QuickAccessToolContribution = {
|
||||||
@@ -1179,6 +1200,11 @@ export type PlatformFrontendModuleInfo = {
|
|||||||
order: number;
|
order: number;
|
||||||
default_enabled: boolean;
|
default_enabled: boolean;
|
||||||
modes: string[];
|
modes: string[];
|
||||||
|
contract_version: "1";
|
||||||
|
availability: "global" | "active_object";
|
||||||
|
accepted_reference_kinds: string[];
|
||||||
|
returned_reference_kinds: string[];
|
||||||
|
help_context_id?: string | null;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AuthInfo } from "../src/types";
|
import type { AuthInfo, QuickAccessResult } from "../src/types";
|
||||||
import {
|
import {
|
||||||
createQuickAccessLaunchContext,
|
createQuickAccessLaunchContext,
|
||||||
|
isQuickAccessResultAllowed,
|
||||||
quickAccessLaunchContextFromState,
|
quickAccessLaunchContextFromState,
|
||||||
quickAccessLaunchState,
|
quickAccessLaunchState,
|
||||||
quickAccessReturnPath
|
quickAccessReturnPath
|
||||||
@@ -57,15 +58,21 @@ const context = createQuickAccessLaunchContext({
|
|||||||
locked: false,
|
locked: false,
|
||||||
availableViews: [],
|
availableViews: [],
|
||||||
provenance: [],
|
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.activeObject?.objectId === "case-1", "active object reference must survive launch");
|
||||||
assert(context.actingContext?.assignmentId === "assignment-1", "acting assignment 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.temporalContext.validityMode === "at", "temporal selection must survive launch");
|
||||||
assert(context.viewContext?.revisionId === "view-revision-3", "exact View revision 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(
|
assert(
|
||||||
quickAccessReturnPath(context) === "/cases/case-1?tab=history#revision-4",
|
quickAccessReturnPath(context) === "/cases/case-1?tab=history#revision-4",
|
||||||
"return path must preserve route, query and fragment"
|
"return path must preserve route, query and fragment"
|
||||||
@@ -75,7 +82,7 @@ assert(
|
|||||||
"router state must decode a valid launch context"
|
"router state must decode a valid launch context"
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
quickAccessLaunchContextFromState({ govoplanQuickAccessLaunch: { contractVersion: "2" } }) === null,
|
quickAccessLaunchContextFromState({ govoplanQuickAccessLaunch: { contractVersion: "3" } }) === null,
|
||||||
"unknown launch context versions must fail closed"
|
"unknown launch context versions must fail closed"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -86,3 +93,56 @@ const crossTenant = createQuickAccessLaunchContext({
|
|||||||
temporalContext: { validityMode: "current", validAt: null, recordedAt: null }
|
temporalContext: { validityMode: "current", validAt: null, recordedAt: null }
|
||||||
});
|
});
|
||||||
assert(crossTenant.activeObject === null, "cross-tenant object references must be discarded");
|
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 StatePanel from "../src/components/StatePanel";
|
||||||
import WorkspaceLayout from "../src/components/WorkspaceLayout";
|
import WorkspaceLayout from "../src/components/WorkspaceLayout";
|
||||||
import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
||||||
|
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
|
||||||
|
|
||||||
// @ts-expect-error Refreshable pages must provide a Reload action.
|
// @ts-expect-error Refreshable pages must provide a Reload action.
|
||||||
const refreshableWithoutReload = <PageActionBar variant="detail" refreshable />;
|
const refreshableWithoutReload = <PageActionBar variant="detail" refreshable />;
|
||||||
@@ -52,9 +53,9 @@ const editorActionBarMarkup = renderToStaticMarkup(
|
|||||||
<PageActionBar
|
<PageActionBar
|
||||||
variant="editor"
|
variant="editor"
|
||||||
refreshable
|
refreshable
|
||||||
dirty
|
state="dirty"
|
||||||
label="Editor actions"
|
label="Editor actions"
|
||||||
reloadAction={<button type="button">Reload</button>}
|
reloadAction={{ onReload: () => undefined, label: "Reload" }}
|
||||||
contextActions={<button type="button">Preview</button>}
|
contextActions={<button type="button">Preview</button>}
|
||||||
helpAction={<button type="button">Help</button>}
|
helpAction={<button type="button">Help</button>}
|
||||||
destructiveActions={<button type="button">Delete</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="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.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('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");
|
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(
|
const cleanEditorActionBarMarkup = renderToStaticMarkup(
|
||||||
<PlatformLanguageProvider>
|
<PlatformLanguageProvider>
|
||||||
<PageActionBar
|
<PageActionBar
|
||||||
variant="editor"
|
variant="editor"
|
||||||
dirty={false}
|
state="clean"
|
||||||
discardAction={{ label: "Discard" }}
|
discardAction={{ label: "Discard" }}
|
||||||
saveAction={{ label: "Save" }}
|
saveAction={{ label: "Save" }}
|
||||||
/>
|
/>
|
||||||
@@ -94,13 +96,32 @@ const collectionActionBarMarkup = renderToStaticMarkup(
|
|||||||
<PageActionBar
|
<PageActionBar
|
||||||
variant="collection"
|
variant="collection"
|
||||||
refreshable
|
refreshable
|
||||||
reloadAction={<button type="button">Reload</button>}
|
reloadAction={{ onReload: () => undefined, label: "Reload" }}
|
||||||
createAction={<button type="button">Create</button>}
|
createAction={<button type="button">Create</button>}
|
||||||
/>
|
/>
|
||||||
</PlatformLanguageProvider>
|
</PlatformLanguageProvider>
|
||||||
);
|
);
|
||||||
assert(collectionActionBarMarkup.indexOf('data-page-action-slot="reload"') < collectionActionBarMarkup.indexOf('data-page-action-slot="create"'), "collection creation remains the far-right action");
|
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(
|
const gridMarkup = renderToStaticMarkup(
|
||||||
<ContentGrid columns={3} gap="compact" collapseAt="wide">
|
<ContentGrid columns={3} gap="compact" collapseAt="wide">
|
||||||
<GridItem>A</GridItem>
|
<GridItem>A</GridItem>
|
||||||
|
|||||||
@@ -17,10 +17,47 @@ const translatedMarkup = renderToStaticMarkup(
|
|||||||
</PlatformLanguageProvider>
|
</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-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('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");
|
assert(!translatedMarkup.includes("i18n:govoplan-core."), "translation keys never leak into visible card text");
|
||||||
|
|
||||||
const numericMarkup = renderToStaticMarkup(<MetricCard label="Count" value={7} />);
|
const numericMarkup = renderToStaticMarkup(<MetricCard label="Count" value={7} />);
|
||||||
assert(numericMarkup.includes('class="metric-value">7</div>'), "numeric values are preserved");
|
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"
|
archetype="collection"
|
||||||
title="Shared page"
|
title="Shared page"
|
||||||
description="One page frame"
|
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"
|
error="Could not load"
|
||||||
success="Saved"
|
success="Saved"
|
||||||
interfaceId="test.page"
|
interfaceId="test.page"
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"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"]
|
"include": ["src", "conformance", "vite.conformance.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ import react from "@vitejs/plugin-react";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
root: resolve(import.meta.dirname, "conformance"),
|
root: resolve(import.meta.dirname, "conformance"),
|
||||||
plugins: [react()],
|
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: {
|
build: {
|
||||||
outDir: resolve(import.meta.dirname, "dist-conformance"),
|
outDir: resolve(import.meta.dirname, "dist-conformance"),
|
||||||
emptyOutDir: true
|
emptyOutDir: true
|
||||||
|
|||||||
Reference in New Issue
Block a user