Add product presentation extension contracts
This commit is contained in:
@@ -26,6 +26,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
|
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
|
||||||
|
SUPPORTED_PRESENTATION_CONTRACT_VERSION = "1"
|
||||||
|
|
||||||
PermissionLevel = Literal["system", "tenant"]
|
PermissionLevel = Literal["system", "tenant"]
|
||||||
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
|
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
|
||||||
@@ -97,6 +98,38 @@ class PublicFrontendRoute:
|
|||||||
order: int = 100
|
order: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ProductAreaContribution:
|
||||||
|
"""Assign module-owned surfaces to a user-facing product area."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
module_id: str
|
||||||
|
label: str
|
||||||
|
icon: str
|
||||||
|
surface_ids: tuple[str, ...]
|
||||||
|
description: str | None = None
|
||||||
|
order: int = 100
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class QuickAccessTool:
|
||||||
|
"""Declare a compact module-owned tool for an optional Quick Access rail."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
module_id: str
|
||||||
|
category_id: str
|
||||||
|
label: str
|
||||||
|
surface_id: str
|
||||||
|
icon: str
|
||||||
|
description: str | None = None
|
||||||
|
full_page_path: str | None = None
|
||||||
|
required_all: tuple[str, ...] = ()
|
||||||
|
required_any: tuple[str, ...] = ()
|
||||||
|
order: int = 100
|
||||||
|
default_enabled: bool = True
|
||||||
|
modes: tuple[str, ...] = ("browse",)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class FrontendModule:
|
class FrontendModule:
|
||||||
module_id: str
|
module_id: str
|
||||||
@@ -111,6 +144,8 @@ class FrontendModule:
|
|||||||
nav_items: tuple[NavItem, ...] = ()
|
nav_items: tuple[NavItem, ...] = ()
|
||||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||||
view_surfaces: tuple[ViewSurface, ...] = ()
|
view_surfaces: tuple[ViewSurface, ...] = ()
|
||||||
|
product_areas: tuple[ProductAreaContribution, ...] = ()
|
||||||
|
quick_access_tools: tuple[QuickAccessTool, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ PlatformInterfaceKind = Literal[
|
|||||||
"frontend_route",
|
"frontend_route",
|
||||||
"navigation",
|
"navigation",
|
||||||
"permission",
|
"permission",
|
||||||
|
"product_area",
|
||||||
"provided_interface",
|
"provided_interface",
|
||||||
"public_route",
|
"public_route",
|
||||||
"search_provider",
|
"search_provider",
|
||||||
"search_source",
|
"search_source",
|
||||||
"settings_route",
|
"settings_route",
|
||||||
|
"quick_access_tool",
|
||||||
"view_surface",
|
"view_surface",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -217,6 +219,45 @@ def manifest_interface_declarations(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
for area in frontend.product_areas:
|
||||||
|
declarations.append(
|
||||||
|
PlatformInterfaceDeclaration(
|
||||||
|
id=f"{manifest.id}.{area.id}",
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="product_area",
|
||||||
|
label=area.label,
|
||||||
|
required_all=(),
|
||||||
|
required_any=(),
|
||||||
|
metadata={
|
||||||
|
"area_id": area.id,
|
||||||
|
"icon": area.icon,
|
||||||
|
"description": area.description,
|
||||||
|
"order": area.order,
|
||||||
|
"surface_ids": list(area.surface_ids),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for tool in frontend.quick_access_tools:
|
||||||
|
declarations.append(
|
||||||
|
PlatformInterfaceDeclaration(
|
||||||
|
id=tool.id,
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="quick_access_tool",
|
||||||
|
label=tool.label,
|
||||||
|
path=tool.full_page_path,
|
||||||
|
required_all=tool.required_all,
|
||||||
|
required_any=tool.required_any,
|
||||||
|
metadata={
|
||||||
|
"category_id": tool.category_id,
|
||||||
|
"surface_id": tool.surface_id,
|
||||||
|
"icon": tool.icon,
|
||||||
|
"description": tool.description,
|
||||||
|
"order": tool.order,
|
||||||
|
"default_enabled": tool.default_enabled,
|
||||||
|
"modes": list(tool.modes),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
frontend_navigation = {
|
frontend_navigation = {
|
||||||
declaration.id: declaration
|
declaration.id: declaration
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
ProductAreaContribution,
|
||||||
PublicFrontendRoute,
|
PublicFrontendRoute,
|
||||||
|
QuickAccessTool,
|
||||||
ResourceAclProvider,
|
ResourceAclProvider,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
||||||
@@ -81,6 +83,10 @@ _WILDCARD_RE = re.compile(
|
|||||||
r"^([a-z][a-z0-9_]*|\*):\*$|^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:\*$"
|
r"^([a-z][a-z0-9_]*|\*):\*$|^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:\*$"
|
||||||
)
|
)
|
||||||
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||||
|
_PRESENTATION_ID_RE = re.compile(r"^[a-z][a-z0-9_-]{1,79}$")
|
||||||
|
_QUICK_ACCESS_TOOL_ID_RE = re.compile(
|
||||||
|
r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_-]*)+$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RegistryError(ValueError):
|
class RegistryError(ValueError):
|
||||||
@@ -633,6 +639,7 @@ class PlatformRegistry:
|
|||||||
)
|
)
|
||||||
permissions = _collect_manifest_permissions(ordered)
|
permissions = _collect_manifest_permissions(ordered)
|
||||||
_validate_public_frontend_route_uniqueness(ordered)
|
_validate_public_frontend_route_uniqueness(ordered)
|
||||||
|
_validate_presentation_catalog(ordered)
|
||||||
_validate_interface_closure(ordered)
|
_validate_interface_closure(ordered)
|
||||||
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
||||||
|
|
||||||
@@ -926,6 +933,31 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
|||||||
_validate_workflow_definition_contributions(manifest)
|
_validate_workflow_definition_contributions(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||||
|
area_definitions: dict[str, tuple[str, str]] = {}
|
||||||
|
tool_owners: dict[str, str] = {}
|
||||||
|
for manifest in manifests:
|
||||||
|
frontend = manifest.frontend
|
||||||
|
if frontend is None:
|
||||||
|
continue
|
||||||
|
for area in frontend.product_areas:
|
||||||
|
definition = (area.label, area.icon)
|
||||||
|
previous = area_definitions.get(area.id)
|
||||||
|
if previous is not None and previous != definition:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product area {area.id!r} has conflicting labels or icons"
|
||||||
|
)
|
||||||
|
area_definitions[area.id] = definition
|
||||||
|
for tool in frontend.quick_access_tools:
|
||||||
|
previous_owner = tool_owners.get(tool.id)
|
||||||
|
if previous_owner is not None:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Duplicate Quick Access tool {tool.id!r} in modules "
|
||||||
|
f"{previous_owner!r} and {manifest.id!r}"
|
||||||
|
)
|
||||||
|
tool_owners[tool.id] = manifest.id
|
||||||
|
|
||||||
|
|
||||||
def _validate_architecture_declarations(manifest: ModuleManifest) -> None:
|
def _validate_architecture_declarations(manifest: ModuleManifest) -> None:
|
||||||
architecture = manifest.architecture
|
architecture = manifest.architecture
|
||||||
if architecture is not None:
|
if architecture is not None:
|
||||||
@@ -1340,6 +1372,102 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
|||||||
if item.surface_id is not None:
|
if item.surface_id is not None:
|
||||||
_validate_view_surface_id(manifest.id, item.surface_id)
|
_validate_view_surface_id(manifest.id, item.surface_id)
|
||||||
_validate_view_surfaces(manifest)
|
_validate_view_surfaces(manifest)
|
||||||
|
_validate_presentation_contributions(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_presentation_contributions(manifest: ModuleManifest) -> None:
|
||||||
|
frontend = manifest.frontend
|
||||||
|
if frontend is None:
|
||||||
|
return
|
||||||
|
known_surface_ids = {
|
||||||
|
surface.id for surface in manifest_view_surfaces(manifest)
|
||||||
|
}
|
||||||
|
seen_area_memberships: set[tuple[str, str]] = set()
|
||||||
|
for area in frontend.product_areas:
|
||||||
|
_validate_product_area(manifest.id, area, known_surface_ids)
|
||||||
|
for surface_id in area.surface_ids:
|
||||||
|
membership = (area.id, surface_id)
|
||||||
|
if membership in seen_area_memberships:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Duplicate product-area membership {area.id!r}/{surface_id!r} "
|
||||||
|
f"in module {manifest.id!r}"
|
||||||
|
)
|
||||||
|
seen_area_memberships.add(membership)
|
||||||
|
seen_tools: set[str] = set()
|
||||||
|
for tool in frontend.quick_access_tools:
|
||||||
|
_validate_quick_access_tool(manifest.id, tool, known_surface_ids)
|
||||||
|
if tool.id in seen_tools:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Duplicate Quick Access tool {tool.id!r} in module {manifest.id!r}"
|
||||||
|
)
|
||||||
|
seen_tools.add(tool.id)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_product_area(
|
||||||
|
module_id: str,
|
||||||
|
area: ProductAreaContribution,
|
||||||
|
known_surface_ids: set[str],
|
||||||
|
) -> None:
|
||||||
|
if area.module_id != module_id:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product area contribution {area.id!r} belongs to {area.module_id!r}, "
|
||||||
|
f"not module {module_id!r}"
|
||||||
|
)
|
||||||
|
if not _PRESENTATION_ID_RE.fullmatch(area.id):
|
||||||
|
raise RegistryError(f"Invalid product area id: {area.id!r}")
|
||||||
|
if not area.label.strip() or not area.icon.strip():
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product area {area.id!r} in module {module_id!r} needs a label and icon"
|
||||||
|
)
|
||||||
|
if not area.surface_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product area {area.id!r} in module {module_id!r} has no surfaces"
|
||||||
|
)
|
||||||
|
unknown = set(area.surface_ids) - known_surface_ids
|
||||||
|
if unknown:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Product area {area.id!r} in module {module_id!r} references unknown "
|
||||||
|
f"surfaces: {', '.join(sorted(unknown))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_quick_access_tool(
|
||||||
|
module_id: str,
|
||||||
|
tool: QuickAccessTool,
|
||||||
|
known_surface_ids: set[str],
|
||||||
|
) -> None:
|
||||||
|
if tool.module_id != module_id:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Quick Access tool {tool.id!r} belongs to {tool.module_id!r}, "
|
||||||
|
f"not module {module_id!r}"
|
||||||
|
)
|
||||||
|
if not _QUICK_ACCESS_TOOL_ID_RE.fullmatch(tool.id) or not tool.id.startswith(
|
||||||
|
f"{module_id}."
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Quick Access tool id must be module-namespaced: {tool.id!r}"
|
||||||
|
)
|
||||||
|
if not _PRESENTATION_ID_RE.fullmatch(tool.category_id):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Invalid Quick Access category id: {tool.category_id!r}"
|
||||||
|
)
|
||||||
|
if tool.surface_id not in known_surface_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Quick Access tool {tool.id!r} references unknown surface "
|
||||||
|
f"{tool.surface_id!r}"
|
||||||
|
)
|
||||||
|
if not tool.label.strip() or not tool.icon.strip():
|
||||||
|
raise RegistryError(
|
||||||
|
f"Quick Access tool {tool.id!r} needs a label and icon"
|
||||||
|
)
|
||||||
|
if tool.full_page_path is not None and not tool.full_page_path.startswith("/"):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Quick Access tool {tool.id!r} has an invalid full-page path"
|
||||||
|
)
|
||||||
|
if not tool.modes or any(not mode.strip() for mode in tool.modes):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Quick Access tool {tool.id!r} must declare at least one valid mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_view_surfaces(manifest: ModuleManifest) -> None:
|
def _validate_view_surfaces(manifest: ModuleManifest) -> None:
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Literal, Protocol, runtime_checkable
|
from typing import Literal, Mapping, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
VIEWS_MODULE_ID = "views"
|
VIEWS_MODULE_ID = "views"
|
||||||
@@ -17,6 +17,8 @@ ViewSurfaceKind = Literal[
|
|||||||
"section",
|
"section",
|
||||||
"action",
|
"action",
|
||||||
"selector",
|
"selector",
|
||||||
|
"product_area",
|
||||||
|
"quick_access",
|
||||||
]
|
]
|
||||||
|
|
||||||
_SURFACE_ID_RE = re.compile(r"^[a-z][a-z0-9_.-]{2,159}$")
|
_SURFACE_ID_RE = re.compile(r"^[a-z][a-z0-9_.-]{2,159}$")
|
||||||
@@ -42,6 +44,7 @@ class EffectiveView:
|
|||||||
revision_id: str | None
|
revision_id: str | None
|
||||||
name: str | None
|
name: str | None
|
||||||
visible_surface_ids: frozenset[str]
|
visible_surface_ids: frozenset[str]
|
||||||
|
presentation: Mapping[str, object] = field(default_factory=dict)
|
||||||
locked: bool = False
|
locked: bool = False
|
||||||
projection_active: bool = False
|
projection_active: bool = False
|
||||||
provenance: tuple[dict[str, object], ...] = ()
|
provenance: tuple[dict[str, object], ...] = ()
|
||||||
|
|||||||
@@ -11,7 +11,16 @@ from govoplan_core.core.module_entitlements import (
|
|||||||
module_entitlement_payload,
|
module_entitlement_payload,
|
||||||
tenant_module_entitlement_state,
|
tenant_module_entitlement_state,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
|
from govoplan_core.core.modules import (
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
ProductAreaContribution,
|
||||||
|
PublicFrontendRoute,
|
||||||
|
QuickAccessTool,
|
||||||
|
SUPPORTED_PRESENTATION_CONTRACT_VERSION,
|
||||||
|
)
|
||||||
from govoplan_core.core.platform_interfaces import (
|
from govoplan_core.core.platform_interfaces import (
|
||||||
manifest_interface_catalog,
|
manifest_interface_catalog,
|
||||||
platform_interface_catalog,
|
platform_interface_catalog,
|
||||||
@@ -132,6 +141,36 @@ def _view_surface_payload(surface: ViewSurface) -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _product_area_payload(area: ProductAreaContribution) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": area.id,
|
||||||
|
"module_id": area.module_id,
|
||||||
|
"label": area.label,
|
||||||
|
"description": area.description,
|
||||||
|
"icon": area.icon,
|
||||||
|
"surface_ids": list(area.surface_ids),
|
||||||
|
"order": area.order,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _quick_access_tool_payload(tool: QuickAccessTool) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": tool.id,
|
||||||
|
"module_id": tool.module_id,
|
||||||
|
"category_id": tool.category_id,
|
||||||
|
"label": tool.label,
|
||||||
|
"description": tool.description,
|
||||||
|
"surface_id": tool.surface_id,
|
||||||
|
"icon": tool.icon,
|
||||||
|
"full_page_path": tool.full_page_path,
|
||||||
|
"required_all": list(tool.required_all),
|
||||||
|
"required_any": list(tool.required_any),
|
||||||
|
"order": tool.order,
|
||||||
|
"default_enabled": tool.default_enabled,
|
||||||
|
"modes": list(tool.modes),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _frontend_view_surfaces(manifest: ModuleManifest) -> list[dict[str, object]]:
|
def _frontend_view_surfaces(manifest: ModuleManifest) -> list[dict[str, object]]:
|
||||||
return [
|
return [
|
||||||
_view_surface_payload(surface)
|
_view_surface_payload(surface)
|
||||||
@@ -217,6 +256,13 @@ def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
|
|||||||
"settings_routes": [_frontend_route_payload(route, manifest.id) for route in frontend.settings_routes],
|
"settings_routes": [_frontend_route_payload(route, manifest.id) for route in frontend.settings_routes],
|
||||||
"view_surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION,
|
"view_surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION,
|
||||||
"view_surfaces": _frontend_view_surfaces(manifest),
|
"view_surfaces": _frontend_view_surfaces(manifest),
|
||||||
|
"presentation_contract_version": SUPPORTED_PRESENTATION_CONTRACT_VERSION,
|
||||||
|
"product_areas": [
|
||||||
|
_product_area_payload(area) for area in frontend.product_areas
|
||||||
|
],
|
||||||
|
"quick_access_tools": [
|
||||||
|
_quick_access_tool_payload(tool) for tool in frontend.quick_access_tools
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from govoplan_core.auth import get_api_principal
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
ModuleManifest,
|
||||||
|
ProductAreaContribution,
|
||||||
|
QuickAccessTool,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
from govoplan_core.server.platform import create_platform_router
|
||||||
|
|
||||||
|
|
||||||
|
def presentation_manifest() -> ModuleManifest:
|
||||||
|
return ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="test",
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id="example",
|
||||||
|
routes=(
|
||||||
|
FrontendRoute(
|
||||||
|
path="/example",
|
||||||
|
component="ExamplePage",
|
||||||
|
surface_id="example.route.main",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="example.quick.summary",
|
||||||
|
module_id="example",
|
||||||
|
kind="quick_access",
|
||||||
|
label="Example summary",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="work",
|
||||||
|
module_id="example",
|
||||||
|
label="Work",
|
||||||
|
icon="list-checks",
|
||||||
|
surface_ids=("example.route.main",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
quick_access_tools=(
|
||||||
|
QuickAccessTool(
|
||||||
|
id="example.summary",
|
||||||
|
module_id="example",
|
||||||
|
category_id="work",
|
||||||
|
label="Example summary",
|
||||||
|
surface_id="example.quick.summary",
|
||||||
|
icon="list-checks",
|
||||||
|
full_page_path="/example",
|
||||||
|
required_any=("example:item:read",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PresentationContractTests(unittest.TestCase):
|
||||||
|
def test_registry_accepts_owned_presentation_contributions(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(presentation_manifest())
|
||||||
|
|
||||||
|
snapshot = registry.validate()
|
||||||
|
|
||||||
|
self.assertEqual("example", snapshot.manifests[0].id)
|
||||||
|
|
||||||
|
def test_registry_rejects_unknown_tool_surface(self) -> None:
|
||||||
|
manifest = presentation_manifest()
|
||||||
|
frontend = manifest.frontend
|
||||||
|
assert frontend is not None
|
||||||
|
invalid = ModuleManifest(
|
||||||
|
id=manifest.id,
|
||||||
|
name=manifest.name,
|
||||||
|
version=manifest.version,
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id="example",
|
||||||
|
routes=frontend.routes,
|
||||||
|
quick_access_tools=(
|
||||||
|
QuickAccessTool(
|
||||||
|
id="example.summary",
|
||||||
|
module_id="example",
|
||||||
|
category_id="work",
|
||||||
|
label="Example summary",
|
||||||
|
surface_id="example.missing",
|
||||||
|
icon="list-checks",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(invalid)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "unknown surface"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
def test_platform_payload_exposes_presentation_catalogue(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(presentation_manifest())
|
||||||
|
registry.validate()
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.govoplan_registry = registry
|
||||||
|
app.include_router(create_platform_router(), prefix="/api/v1")
|
||||||
|
app.dependency_overrides[get_api_principal] = lambda: object()
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get("/api/v1/platform/modules")
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
frontend = response.json()["modules"][0]["frontend"]
|
||||||
|
self.assertEqual("1", frontend["presentation_contract_version"])
|
||||||
|
self.assertEqual("work", frontend["product_areas"][0]["id"])
|
||||||
|
self.assertEqual("example.summary", frontend["quick_access_tools"][0]["id"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Generated
+21
@@ -36,6 +36,7 @@
|
|||||||
"@govoplan/portal-webui": "file:../../govoplan-portal/webui",
|
"@govoplan/portal-webui": "file:../../govoplan-portal/webui",
|
||||||
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
|
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
|
||||||
"@govoplan/projects-webui": "file:../../govoplan-projects/webui",
|
"@govoplan/projects-webui": "file:../../govoplan-projects/webui",
|
||||||
|
"@govoplan/quick-access-webui": "file:../../govoplan-quick-access/webui",
|
||||||
"@govoplan/records-webui": "file:../../govoplan-records/webui",
|
"@govoplan/records-webui": "file:../../govoplan-records/webui",
|
||||||
"@govoplan/reporting-webui": "file:../../govoplan-reporting/webui",
|
"@govoplan/reporting-webui": "file:../../govoplan-reporting/webui",
|
||||||
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
|
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
|
||||||
@@ -557,6 +558,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../../govoplan-quick-access/webui": {
|
||||||
|
"name": "@govoplan/quick-access-webui",
|
||||||
|
"version": "0.1.18",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"../../govoplan-records/webui": {
|
"../../govoplan-records/webui": {
|
||||||
"name": "@govoplan/records-webui",
|
"name": "@govoplan/records-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.18",
|
||||||
@@ -1601,6 +1618,10 @@
|
|||||||
"resolved": "../../govoplan-projects/webui",
|
"resolved": "../../govoplan-projects/webui",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@govoplan/quick-access-webui": {
|
||||||
|
"resolved": "../../govoplan-quick-access/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@govoplan/records-webui": {
|
"node_modules/@govoplan/records-webui": {
|
||||||
"resolved": "../../govoplan-records/webui",
|
"resolved": "../../govoplan-records/webui",
|
||||||
"link": true
|
"link": true
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
"@govoplan/portal-webui": "file:../../govoplan-portal/webui",
|
"@govoplan/portal-webui": "file:../../govoplan-portal/webui",
|
||||||
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
|
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
|
||||||
"@govoplan/projects-webui": "file:../../govoplan-projects/webui",
|
"@govoplan/projects-webui": "file:../../govoplan-projects/webui",
|
||||||
|
"@govoplan/quick-access-webui": "file:../../govoplan-quick-access/webui",
|
||||||
"@govoplan/reporting-webui": "file:../../govoplan-reporting/webui",
|
"@govoplan/reporting-webui": "file:../../govoplan-reporting/webui",
|
||||||
"@govoplan/records-webui": "file:../../govoplan-records/webui",
|
"@govoplan/records-webui": "file:../../govoplan-records/webui",
|
||||||
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
|
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const packageByModule = {
|
|||||||
portal: "@govoplan/portal-webui",
|
portal: "@govoplan/portal-webui",
|
||||||
postbox: "@govoplan/postbox-webui",
|
postbox: "@govoplan/postbox-webui",
|
||||||
projects: "@govoplan/projects-webui",
|
projects: "@govoplan/projects-webui",
|
||||||
|
quick_access: "@govoplan/quick-access-webui",
|
||||||
reporting: "@govoplan/reporting-webui",
|
reporting: "@govoplan/reporting-webui",
|
||||||
records: "@govoplan/records-webui",
|
records: "@govoplan/records-webui",
|
||||||
risk_compliance: "@govoplan/risk-compliance-webui",
|
risk_compliance: "@govoplan/risk-compliance-webui",
|
||||||
@@ -81,6 +82,8 @@ const cases = [
|
|||||||
{ name: "postbox-only", modules: ["postbox"] },
|
{ name: "postbox-only", modules: ["postbox"] },
|
||||||
{ name: "portal-only", modules: ["portal"] },
|
{ name: "portal-only", modules: ["portal"] },
|
||||||
{ name: "projects-only", modules: ["projects"] },
|
{ name: "projects-only", modules: ["projects"] },
|
||||||
|
{ name: "quick-access-only", modules: ["quick_access"] },
|
||||||
|
{ name: "quick-access-with-providers", modules: ["quick_access", "tasks", "calendar", "mail", "postbox", "files"] },
|
||||||
{ name: "reporting-only", modules: ["reporting"] },
|
{ name: "reporting-only", modules: ["reporting"] },
|
||||||
{ name: "records-only", modules: ["access", "records"] },
|
{ name: "records-only", modules: ["access", "records"] },
|
||||||
{ name: "records-with-sources", modules: ["access", "cases", "files", "records"] },
|
{ name: "records-with-sources", modules: ["access", "cases", "files", "records"] },
|
||||||
@@ -97,7 +100,7 @@ const cases = [
|
|||||||
{ name: "tasks-only", modules: ["access", "tasks"] },
|
{ name: "tasks-only", modules: ["access", "tasks"] },
|
||||||
{ name: "tasks-with-contributors", modules: ["access", "approvals", "postbox", "workflow", "dashboard", "tasks"] },
|
{ name: "tasks-with-contributors", modules: ["access", "approvals", "postbox", "workflow", "dashboard", "tasks"] },
|
||||||
{ name: "voting-only", modules: ["access", "voting"] },
|
{ name: "voting-only", modules: ["access", "voting"] },
|
||||||
{ name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "templates", "workflow", "views", "organizations", "idm", "identity_trust", "encryption", "cases", "committee", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "portal", "postbox", "projects", "reporting", "records", "risk_compliance", "search", "tasks", "voting"] }
|
{ name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "templates", "workflow", "views", "organizations", "idm", "identity_trust", "encryption", "cases", "committee", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "portal", "postbox", "projects", "quick_access", "reporting", "records", "risk_compliance", "search", "tasks", "voting"] }
|
||||||
];
|
];
|
||||||
|
|
||||||
const npmExec = process.env.npm_execpath;
|
const npmExec = process.env.npm_execpath;
|
||||||
|
|||||||
@@ -76,6 +76,15 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.append_successfully_sent_messages_to_sent.002fd67e": "Append successfully sent messages to Sent",
|
"i18n:govoplan-core.append_successfully_sent_messages_to_sent.002fd67e": "Append successfully sent messages to Sent",
|
||||||
"i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder",
|
"i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder",
|
||||||
"i18n:govoplan-core.application_notices": "Application notices",
|
"i18n:govoplan-core.application_notices": "Application notices",
|
||||||
|
"i18n:govoplan-core.more_tools": "More tools",
|
||||||
|
"i18n:govoplan-core.product_area.work": "Work",
|
||||||
|
"i18n:govoplan-core.product_area.communication": "Communication",
|
||||||
|
"i18n:govoplan-core.product_area.records_documents": "Records and documents",
|
||||||
|
"i18n:govoplan-core.product_area.meetings_decisions": "Meetings and decisions",
|
||||||
|
"i18n:govoplan-core.product_area.work_description": "Tasks, approvals, handoffs, and other work requiring attention.",
|
||||||
|
"i18n:govoplan-core.product_area.communication_description": "Messages, campaigns, notifications, and institutional communication.",
|
||||||
|
"i18n:govoplan-core.product_area.records_documents_description": "Documents, files, evidence, templates, and records.",
|
||||||
|
"i18n:govoplan-core.product_area.meetings_decisions_description": "Scheduling, meetings, participation, and institutional decisions.",
|
||||||
"i18n:govoplan-core.application.b291beb8": "Application",
|
"i18n:govoplan-core.application.b291beb8": "Application",
|
||||||
"i18n:govoplan-core.attachment_base_path.66e9940b": "Attachment base path",
|
"i18n:govoplan-core.attachment_base_path.66e9940b": "Attachment base path",
|
||||||
"i18n:govoplan-core.attachment_value.01801a54": "Attachment {value0}",
|
"i18n:govoplan-core.attachment_value.01801a54": "Attachment {value0}",
|
||||||
@@ -734,6 +743,15 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-core.append_successfully_sent_messages_to_sent.002fd67e": "Append successfully sent messages to Sent",
|
"i18n:govoplan-core.append_successfully_sent_messages_to_sent.002fd67e": "Append successfully sent messages to Sent",
|
||||||
"i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder",
|
"i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder",
|
||||||
"i18n:govoplan-core.application_notices": "Anwendungshinweise",
|
"i18n:govoplan-core.application_notices": "Anwendungshinweise",
|
||||||
|
"i18n:govoplan-core.more_tools": "Weitere Werkzeuge",
|
||||||
|
"i18n:govoplan-core.product_area.work": "Arbeit",
|
||||||
|
"i18n:govoplan-core.product_area.communication": "Kommunikation",
|
||||||
|
"i18n:govoplan-core.product_area.records_documents": "Akten und Dokumente",
|
||||||
|
"i18n:govoplan-core.product_area.meetings_decisions": "Termine und Entscheidungen",
|
||||||
|
"i18n:govoplan-core.product_area.work_description": "Aufgaben, Freigaben, Übergaben und andere zu bearbeitende Arbeit.",
|
||||||
|
"i18n:govoplan-core.product_area.communication_description": "Nachrichten, Kampagnen, Benachrichtigungen und institutionelle Kommunikation.",
|
||||||
|
"i18n:govoplan-core.product_area.records_documents_description": "Akten, Dokumente, Nachweise, Vorlagen und Dateien.",
|
||||||
|
"i18n:govoplan-core.product_area.meetings_decisions_description": "Terminplanung, Sitzungen, Beteiligung und institutionelle Entscheidungen.",
|
||||||
"i18n:govoplan-core.application.b291beb8": "Application",
|
"i18n:govoplan-core.application.b291beb8": "Application",
|
||||||
"i18n:govoplan-core.attachment_base_path.66e9940b": "Attachment base path",
|
"i18n:govoplan-core.attachment_base_path.66e9940b": "Attachment base path",
|
||||||
"i18n:govoplan-core.attachment_value.01801a54": "Attachment {value0}",
|
"i18n:govoplan-core.attachment_value.01801a54": "Attachment {value0}",
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import type { ApiSettings, AuthInfo, AuthUpdate, PlatformNavItem } from "../types";
|
import { useMemo } from "react";
|
||||||
|
import type { ApiSettings, AuthInfo, AuthUpdate, PlatformNavItem, ProductAreaContribution, QuickAccessRuntimeUiCapability } from "../types";
|
||||||
import IconRail from "./IconRail";
|
import IconRail from "./IconRail";
|
||||||
import Titlebar from "./Titlebar";
|
import Titlebar from "./Titlebar";
|
||||||
import BreadcrumbBar from "./BreadcrumbBar";
|
import BreadcrumbBar from "./BreadcrumbBar";
|
||||||
|
import { usePlatformModules, usePlatformUiCapability } from "../platform/ModuleContext";
|
||||||
|
import { useEffectiveView, useViewSurfaces } from "../platform/ViewContext";
|
||||||
|
import { isViewSurfaceVisible } from "../platform/views";
|
||||||
|
import { hasAnyScope, hasScope } from "../utils/permissions";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -28,6 +33,23 @@ export default function AppShell({
|
|||||||
backendReachable = true
|
backendReachable = true
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const modules = usePlatformModules();
|
||||||
|
const quickAccess = usePlatformUiCapability<QuickAccessRuntimeUiCapability>("quickAccess.runtime");
|
||||||
|
const projection = useEffectiveView();
|
||||||
|
const surfaces = useViewSurfaces();
|
||||||
|
const quickAccessTools = useMemo(
|
||||||
|
() => modules.flatMap((module) => module.quickAccessTools ?? []).filter((tool) => {
|
||||||
|
if (tool.allOf?.length && !tool.allOf.every((scope) => hasScope(auth, scope))) return false;
|
||||||
|
if (tool.anyOf?.length && !hasAnyScope(auth, tool.anyOf)) return false;
|
||||||
|
return isViewSurfaceVisible(projection, tool.surfaceId, surfaces);
|
||||||
|
}).sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
|
||||||
|
[auth, modules, projection, surfaces]
|
||||||
|
);
|
||||||
|
const QuickAccessRail = quickAccess?.Rail;
|
||||||
|
const productAreas = useMemo<ProductAreaContribution[]>(
|
||||||
|
() => modules.flatMap((module) => module.productAreas ?? []),
|
||||||
|
[modules]
|
||||||
|
);
|
||||||
|
|
||||||
if (publicMode) {
|
if (publicMode) {
|
||||||
return (
|
return (
|
||||||
@@ -43,12 +65,20 @@ export default function AppShell({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<IconRail auth={auth} navItems={navItems} />
|
<IconRail
|
||||||
|
auth={auth}
|
||||||
|
navItems={navItems}
|
||||||
|
productAreas={productAreas}
|
||||||
|
presentation={projection?.presentation}
|
||||||
|
/>
|
||||||
<div className="app-main">
|
<div className="app-main">
|
||||||
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} />
|
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} />
|
||||||
<BreadcrumbBar pathname={location.pathname} />
|
<BreadcrumbBar pathname={location.pathname} />
|
||||||
<main className="app-content">{children}</main>
|
<main className="app-content">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
|
{QuickAccessRail && auth && (
|
||||||
|
<QuickAccessRail settings={settings} auth={auth} tools={quickAccessTools} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
|
import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
|
||||||
import { NavLink, useLocation } from "react-router";
|
import { NavLink, useLocation } from "react-router";
|
||||||
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||||
import type { AuthInfo, PlatformNavItem } from "../types";
|
import type {
|
||||||
|
AuthInfo,
|
||||||
|
PlatformNavItem,
|
||||||
|
ProductAreaContribution,
|
||||||
|
ViewPresentation
|
||||||
|
} from "../types";
|
||||||
import { hasAnyScope, hasScope } from "../utils/permissions";
|
import { hasAnyScope, hasScope } from "../utils/permissions";
|
||||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
|
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
|
||||||
|
import { groupNavigationItems } from "../platform/productAreas";
|
||||||
|
|
||||||
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
|
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
|
||||||
const RAIL_EXPANDED_STORAGE_KEY = "govoplan.iconRailExpanded";
|
const RAIL_EXPANDED_STORAGE_KEY = "govoplan.iconRailExpanded";
|
||||||
@@ -22,8 +28,16 @@ function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNa
|
|||||||
export default function IconRail({
|
export default function IconRail({
|
||||||
compact = false,
|
compact = false,
|
||||||
auth = null,
|
auth = null,
|
||||||
navItems = []
|
navItems = [],
|
||||||
}: {compact?: boolean;auth?: AuthInfo | null;navItems?: PlatformNavItem[];}) {
|
productAreas = [],
|
||||||
|
presentation
|
||||||
|
}: {
|
||||||
|
compact?: boolean;
|
||||||
|
auth?: AuthInfo | null;
|
||||||
|
navItems?: PlatformNavItem[];
|
||||||
|
productAreas?: ProductAreaContribution[];
|
||||||
|
presentation?: ViewPresentation;
|
||||||
|
}) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const items = visibleNavItems(auth, navItems);
|
const items = visibleNavItems(auth, navItems);
|
||||||
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
|
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
|
||||||
@@ -31,6 +45,10 @@ export default function IconRail({
|
|||||||
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
|
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
|
const navigationGroups = useMemo(
|
||||||
|
() => groupNavigationItems(items, productAreas, presentation),
|
||||||
|
[items, presentation, productAreas]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentRoot = topLevelItems.find((root) => modulePathActive(location.pathname, root));
|
const currentRoot = topLevelItems.find((root) => modulePathActive(location.pathname, root));
|
||||||
@@ -70,23 +88,38 @@ export default function IconRail({
|
|||||||
<>
|
<>
|
||||||
<div className="icon-rail-scroll">
|
<div className="icon-rail-scroll">
|
||||||
<nav className="icon-nav">
|
<nav className="icon-nav">
|
||||||
{items.map(({ to, label, icon: Icon }) => {
|
{navigationGroups.map((group) => (
|
||||||
const target = rememberedTargets[to] ?? to;
|
<div className="icon-nav-group" key={group.id}>
|
||||||
const active = modulePathActive(location.pathname, to);
|
{group.label && (
|
||||||
const renderedLabel = translateText(label);
|
<div className="icon-nav-group-label">
|
||||||
return (
|
{translateText(group.label)}
|
||||||
<NavLink
|
</div>
|
||||||
key={to}
|
)}
|
||||||
to={target}
|
{group.items.map(({ to, label, icon: Icon }) => {
|
||||||
className={`icon-nav-item ${active ? "active" : ""}`}
|
const target = rememberedTargets[to] ?? to;
|
||||||
title={renderedLabel}
|
const active = modulePathActive(location.pathname, to);
|
||||||
onClick={(event) => handleNavClick(event, target)}
|
const renderedLabel = translateText(label);
|
||||||
>
|
const areaLabel = group.areaLabel
|
||||||
{Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>}
|
? translateText(group.areaLabel)
|
||||||
<span className="icon-nav-label">{renderedLabel}</span>
|
: null;
|
||||||
</NavLink>
|
const title = areaLabel
|
||||||
);
|
? `${areaLabel}: ${renderedLabel}`
|
||||||
})}
|
: renderedLabel;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={target}
|
||||||
|
className={`icon-nav-item ${active ? "active" : ""}`}
|
||||||
|
title={title}
|
||||||
|
onClick={(event) => handleNavClick(event, target)}
|
||||||
|
>
|
||||||
|
{Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>}
|
||||||
|
<span className="icon-nav-label">{renderedLabel}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div className="icon-rail-bottom">
|
<div className="icon-rail-bottom">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Activity, Bell, BookUser, BriefcaseBusiness, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, FolderKanban, Form, Gavel, Inbox, Landmark, LayoutDashboard, LayoutTemplate, ListChecks, ListTree, Mail, Mails, RadioTower, Shield, ShieldCheck, Users, Vote, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react";
|
import { Activity, Bell, BookUser, BriefcaseBusiness, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, FolderKanban, Form, Gavel, Inbox, Landmark, LayoutDashboard, LayoutTemplate, ListChecks, ListTree, Mail, Mails, RadioTower, Shield, ShieldCheck, Users, Vote, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react";
|
||||||
import installedWebModuleLoaderSource from "virtual:govoplan-installed-modules";
|
import installedWebModuleLoaderSource from "virtual:govoplan-installed-modules";
|
||||||
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, EffectiveViewProjection, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformViewSurface, PlatformWebModule } from "../types";
|
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, EffectiveViewProjection, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformViewSurface, PlatformWebModule, ProductAreaContribution, QuickAccessToolMetadata } from "../types";
|
||||||
import {
|
import {
|
||||||
hasUiCapability as hasUiCapabilityForModules,
|
hasUiCapability as hasUiCapabilityForModules,
|
||||||
moduleInstalled as moduleInstalledForModules,
|
moduleInstalled as moduleInstalledForModules,
|
||||||
@@ -168,6 +168,36 @@ function routesWithServerMetadata(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function productAreasFromMetadata(info: PlatformModuleInfo): ProductAreaContribution[] {
|
||||||
|
return (info.frontend?.product_areas ?? []).map((area) => ({
|
||||||
|
id: area.id,
|
||||||
|
moduleId: area.module_id,
|
||||||
|
label: area.label,
|
||||||
|
description: area.description,
|
||||||
|
iconName: area.icon,
|
||||||
|
surfaceIds: area.surface_ids,
|
||||||
|
order: area.order
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function quickAccessToolsFromMetadata(info: PlatformModuleInfo): QuickAccessToolMetadata[] {
|
||||||
|
return (info.frontend?.quick_access_tools ?? []).map((tool) => ({
|
||||||
|
id: tool.id,
|
||||||
|
moduleId: tool.module_id,
|
||||||
|
categoryId: tool.category_id,
|
||||||
|
label: tool.label,
|
||||||
|
description: tool.description,
|
||||||
|
surfaceId: tool.surface_id,
|
||||||
|
iconName: tool.icon,
|
||||||
|
fullPagePath: tool.full_page_path,
|
||||||
|
allOf: tool.required_all,
|
||||||
|
anyOf: tool.required_any,
|
||||||
|
order: tool.order,
|
||||||
|
defaultEnabled: tool.default_enabled,
|
||||||
|
modes: tool.modes
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function runtimeUiCapabilitiesForModule(module: PlatformWebModule, info: PlatformModuleInfo) {
|
function runtimeUiCapabilitiesForModule(module: PlatformWebModule, info: PlatformModuleInfo) {
|
||||||
const enabledNames = new Set(info.runtime_ui_capabilities ?? []);
|
const enabledNames = new Set(info.runtime_ui_capabilities ?? []);
|
||||||
@@ -194,6 +224,8 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
|||||||
routes: routesWithServerMetadata(module, info),
|
routes: routesWithServerMetadata(module, info),
|
||||||
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
||||||
viewSurfaces: mergeViewSurfaces(module, info),
|
viewSurfaces: mergeViewSurfaces(module, info),
|
||||||
|
productAreas: productAreasFromMetadata(info),
|
||||||
|
quickAccessTools: quickAccessToolsFromMetadata(info),
|
||||||
helpContexts: info.help_contexts ?? module.helpContexts,
|
helpContexts: info.help_contexts ?? module.helpContexts,
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
...(module.uiCapabilities ?? {}),
|
...(module.uiCapabilities ?? {}),
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type {
|
||||||
|
PlatformNavItem,
|
||||||
|
ProductAreaContribution,
|
||||||
|
ViewPresentation
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
|
||||||
|
export type NavigationGroup = {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
areaLabel?: string;
|
||||||
|
items: PlatformNavItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function groupNavigationItems(
|
||||||
|
items: PlatformNavItem[],
|
||||||
|
contributions: ProductAreaContribution[],
|
||||||
|
presentation?: ViewPresentation
|
||||||
|
): NavigationGroup[] {
|
||||||
|
if (presentation?.navigationMode === "flat" || contributions.length === 0) {
|
||||||
|
return [{ id: "all-tools", items }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const areaById = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
order: number;
|
||||||
|
surfaceIds: Set<string>;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
for (const contribution of contributions) {
|
||||||
|
const existing = areaById.get(contribution.id);
|
||||||
|
if (existing) {
|
||||||
|
contribution.surfaceIds.forEach((id) => existing.surfaceIds.add(id));
|
||||||
|
existing.order = Math.min(existing.order, contribution.order ?? 100);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
areaById.set(contribution.id, {
|
||||||
|
id: contribution.id,
|
||||||
|
label:
|
||||||
|
presentation?.productAreaLabels?.[contribution.id] ??
|
||||||
|
contribution.label,
|
||||||
|
order: contribution.order ?? 100,
|
||||||
|
surfaceIds: new Set(contribution.surfaceIds)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredOrder = new Map(
|
||||||
|
(presentation?.productAreaOrder ?? []).map((id, index) => [id, index])
|
||||||
|
);
|
||||||
|
const areas = [...areaById.values()].sort(
|
||||||
|
(left, right) =>
|
||||||
|
(configuredOrder.get(left.id) ?? 10_000) -
|
||||||
|
(configuredOrder.get(right.id) ?? 10_000) ||
|
||||||
|
left.order - right.order ||
|
||||||
|
left.label.localeCompare(right.label)
|
||||||
|
);
|
||||||
|
const assigned = new Set<string>();
|
||||||
|
const groups: NavigationGroup[] = [];
|
||||||
|
const pinned = items.filter((item) => item.to === "/dashboard");
|
||||||
|
pinned.forEach((item) => assigned.add(item.to));
|
||||||
|
if (pinned.length) groups.push({ id: "overview", items: pinned });
|
||||||
|
|
||||||
|
for (const area of areas) {
|
||||||
|
const areaItems = items.filter(
|
||||||
|
(item) =>
|
||||||
|
!assigned.has(item.to) &&
|
||||||
|
Boolean(item.surfaceId && area.surfaceIds.has(item.surfaceId))
|
||||||
|
);
|
||||||
|
if (!areaItems.length) continue;
|
||||||
|
areaItems.forEach((item) => assigned.add(item.to));
|
||||||
|
groups.push({
|
||||||
|
id: `product-area:${area.id}`,
|
||||||
|
label: area.label,
|
||||||
|
areaLabel: area.label,
|
||||||
|
items: areaItems
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = items.filter((item) => !assigned.has(item.to));
|
||||||
|
if (remaining.length) {
|
||||||
|
groups.push({
|
||||||
|
id: "more-tools",
|
||||||
|
label: "i18n:govoplan-core.more_tools",
|
||||||
|
items: remaining
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.app-shell { height: 100vh; min-height: 0; display: grid; grid-template-columns: auto 1fr; overflow: hidden; }
|
.app-shell { height: 100vh; min-height: 0; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; overflow: hidden; }
|
||||||
.icon-rail { width: 58px; background: var(--rail-bg); color: var(--rail-text); display: flex; flex-direction: column; align-items: center; height: 100vh; min-height: 0; box-shadow: var(--shadow-rail); z-index: 1000; transition: width .16s ease; }
|
.icon-rail { width: 58px; background: var(--rail-bg); color: var(--rail-text); display: flex; flex-direction: column; align-items: center; height: 100vh; min-height: 0; box-shadow: var(--shadow-rail); z-index: 1000; transition: width .16s ease; }
|
||||||
.icon-rail.expanded { width: 208px; align-items: stretch; }
|
.icon-rail.expanded { width: 208px; align-items: stretch; }
|
||||||
.icon-rail-header { width: 100%; min-height: 63px; display: flex; flex: 0 0 auto; align-items: center; justify-content: center; padding: 0 10px; box-sizing: border-box; }
|
.icon-rail-header { width: 100%; min-height: 63px; display: flex; flex: 0 0 auto; align-items: center; justify-content: center; padding: 0 10px; box-sizing: border-box; }
|
||||||
@@ -18,6 +18,10 @@
|
|||||||
.icon-rail-toggle:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }
|
.icon-rail-toggle:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }
|
||||||
.icon-rail-scroll { width: 100%; min-width: 0; min-height: 0; flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-color: var(--rail-text-muted) transparent; scrollbar-width: thin; }
|
.icon-rail-scroll { width: 100%; min-width: 0; min-height: 0; flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-color: var(--rail-text-muted) transparent; scrollbar-width: thin; }
|
||||||
.icon-nav { width: 100%; display: flex; flex-direction: column; min-width: 0; }
|
.icon-nav { width: 100%; display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.icon-nav-group { min-width: 0; }
|
||||||
|
.icon-nav-group-label { display: none; min-height: 26px; align-items: end; padding: 6px 14px 4px 16px; color: var(--rail-text-muted); font-size: 11px; font-weight: 700; letter-spacing: 0; }
|
||||||
|
.icon-rail.expanded .icon-nav-group-label { display: flex; }
|
||||||
|
.icon-rail.expanded .icon-nav-group + .icon-nav-group .icon-nav-group-label { border-top: 1px solid var(--rail-bg-active); }
|
||||||
.icon-nav-item { width: 100%; height: 52px; display: grid; grid-template-columns: 55px minmax(0, 1fr); align-items: center; color: var(--rail-text-muted); border-left: 3px solid transparent; text-decoration: none; box-sizing: border-box; }
|
.icon-nav-item { width: 100%; height: 52px; display: grid; grid-template-columns: 55px minmax(0, 1fr); align-items: center; color: var(--rail-text-muted); border-left: 3px solid transparent; text-decoration: none; box-sizing: border-box; }
|
||||||
.icon-nav-item svg,
|
.icon-nav-item svg,
|
||||||
.icon-nav-item > :first-child:not(.icon-nav-label) { justify-self: center; }
|
.icon-nav-item > :first-child:not(.icon-nav-label) { justify-self: center; }
|
||||||
|
|||||||
+85
-1
@@ -244,7 +244,9 @@ export type PlatformViewSurfaceKind =
|
|||||||
| "route"
|
| "route"
|
||||||
| "section"
|
| "section"
|
||||||
| "action"
|
| "action"
|
||||||
| "selector";
|
| "selector"
|
||||||
|
| "product_area"
|
||||||
|
| "quick_access";
|
||||||
|
|
||||||
export type PlatformViewSurface = {
|
export type PlatformViewSurface = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -271,6 +273,32 @@ export type PlatformNavItem = {
|
|||||||
surfaceId?: string;
|
surfaceId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProductAreaContribution = {
|
||||||
|
id: string;
|
||||||
|
moduleId: string;
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
iconName: PlatformIconName;
|
||||||
|
surfaceIds: string[];
|
||||||
|
order?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuickAccessToolMetadata = {
|
||||||
|
id: string;
|
||||||
|
moduleId: string;
|
||||||
|
categoryId: string;
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
surfaceId: string;
|
||||||
|
iconName: PlatformIconName;
|
||||||
|
fullPagePath?: string | null;
|
||||||
|
allOf?: string[];
|
||||||
|
anyOf?: string[];
|
||||||
|
order?: number;
|
||||||
|
defaultEnabled?: boolean;
|
||||||
|
modes?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type PlatformRouteContext = {
|
export type PlatformRouteContext = {
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
auth: AuthInfo;
|
auth: AuthInfo;
|
||||||
@@ -424,6 +452,8 @@ export type PlatformWebModule = {
|
|||||||
uiCapabilities?: PlatformUiCapabilities;
|
uiCapabilities?: PlatformUiCapabilities;
|
||||||
runtimeUiCapabilities?: PlatformUiCapabilities;
|
runtimeUiCapabilities?: PlatformUiCapabilities;
|
||||||
viewSurfaces?: PlatformViewSurface[];
|
viewSurfaces?: PlatformViewSurface[];
|
||||||
|
productAreas?: ProductAreaContribution[];
|
||||||
|
quickAccessTools?: QuickAccessToolMetadata[];
|
||||||
helpContexts?: PlatformDocumentationHelpContext[];
|
helpContexts?: PlatformDocumentationHelpContext[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -439,6 +469,7 @@ export type EffectiveViewProjection = {
|
|||||||
activeRevisionId: string | null;
|
activeRevisionId: string | null;
|
||||||
activeViewName: string | null;
|
activeViewName: string | null;
|
||||||
visibleSurfaceIds: string[];
|
visibleSurfaceIds: string[];
|
||||||
|
presentation?: ViewPresentation;
|
||||||
projectionActive?: boolean;
|
projectionActive?: boolean;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
availableViews: EffectiveViewOption[];
|
availableViews: EffectiveViewOption[];
|
||||||
@@ -456,6 +487,12 @@ export type EffectiveViewProjection = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ViewPresentation = {
|
||||||
|
navigationMode?: "grouped" | "flat";
|
||||||
|
productAreaOrder?: string[];
|
||||||
|
productAreaLabels?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ViewSelectorProps = {
|
export type ViewSelectorProps = {
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
auth: AuthInfo;
|
auth: AuthInfo;
|
||||||
@@ -507,6 +544,28 @@ export type SearchRuntimeUiCapability = {
|
|||||||
anyOf?: string[];
|
anyOf?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QuickAccessRailProps = PlatformRouteContext & {
|
||||||
|
tools: QuickAccessToolMetadata[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuickAccessRuntimeUiCapability = {
|
||||||
|
Rail: ComponentType<QuickAccessRailProps>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuickAccessToolRenderContext = PlatformRouteContext & {
|
||||||
|
close: () => void;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuickAccessToolContribution = {
|
||||||
|
id: string;
|
||||||
|
render: (context: QuickAccessToolRenderContext) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QuickAccessToolsUiCapability = {
|
||||||
|
tools: QuickAccessToolContribution[];
|
||||||
|
};
|
||||||
|
|
||||||
export type DashboardWidgetSize = "small" | "medium" | "wide" | "full";
|
export type DashboardWidgetSize = "small" | "medium" | "wide" | "full";
|
||||||
|
|
||||||
export type DashboardWidgetTone = "neutral" | "good" | "warning" | "danger" | "info";
|
export type DashboardWidgetTone = "neutral" | "good" | "warning" | "danger" | "info";
|
||||||
@@ -1049,6 +1108,31 @@ export type PlatformFrontendModuleInfo = {
|
|||||||
default_visible: boolean;
|
default_visible: boolean;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
presentation_contract_version?: string | null;
|
||||||
|
product_areas?: Array<{
|
||||||
|
id: string;
|
||||||
|
module_id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
icon: string;
|
||||||
|
surface_ids: string[];
|
||||||
|
order: number;
|
||||||
|
}>;
|
||||||
|
quick_access_tools?: Array<{
|
||||||
|
id: string;
|
||||||
|
module_id: string;
|
||||||
|
category_id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string | null;
|
||||||
|
surface_id: string;
|
||||||
|
icon: string;
|
||||||
|
full_page_path?: string | null;
|
||||||
|
required_all: string[];
|
||||||
|
required_any: string[];
|
||||||
|
order: number;
|
||||||
|
default_enabled: boolean;
|
||||||
|
modes: string[];
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlatformDocumentationHelpContext = {
|
export type PlatformDocumentationHelpContext = {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
viewSurfaceCatalogueForModules,
|
viewSurfaceCatalogueForModules,
|
||||||
visibleRoutesForProjection
|
visibleRoutesForProjection
|
||||||
} from "../src/platform/views";
|
} from "../src/platform/views";
|
||||||
|
import { groupNavigationItems } from "../src/platform/productAreas";
|
||||||
import { scopeGrants } from "../src/utils/permissions";
|
import { scopeGrants } from "../src/utils/permissions";
|
||||||
|
|
||||||
function assert(condition: unknown, message: string): void {
|
function assert(condition: unknown, message: string): void {
|
||||||
@@ -148,6 +149,49 @@ functionAction.onClick({
|
|||||||
assert(selectedFunctionId === "function-1", "organization function actions receive their row context");
|
assert(selectedFunctionId === "function-1", "organization function actions receive their row context");
|
||||||
assert(!("render" in functionAction), "organization function actions expose metadata instead of arbitrary rendered controls");
|
assert(!("render" in functionAction), "organization function actions expose metadata instead of arbitrary rendered controls");
|
||||||
|
|
||||||
|
const productNavigation = [
|
||||||
|
{ to: "/dashboard", label: "Dashboard", surfaceId: "dashboard.nav.dashboard" },
|
||||||
|
{ to: "/tasks", label: "Tasks", surfaceId: "tasks.nav.tasks" },
|
||||||
|
{ to: "/files", label: "Files", surfaceId: "files.nav.files" },
|
||||||
|
{ to: "/admin", label: "Administration", surfaceId: "admin.nav.admin" }
|
||||||
|
];
|
||||||
|
const productAreas = [
|
||||||
|
{
|
||||||
|
id: "work",
|
||||||
|
moduleId: "tasks",
|
||||||
|
label: "Work",
|
||||||
|
iconName: "list-checks" as const,
|
||||||
|
surfaceIds: ["tasks.nav.tasks"],
|
||||||
|
order: 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "records-documents",
|
||||||
|
moduleId: "files",
|
||||||
|
label: "Records and documents",
|
||||||
|
iconName: "folder" as const,
|
||||||
|
surfaceIds: ["files.nav.files"],
|
||||||
|
order: 20
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const groupedNavigation = groupNavigationItems(productNavigation, productAreas, {
|
||||||
|
navigationMode: "grouped",
|
||||||
|
productAreaOrder: ["records-documents", "work"],
|
||||||
|
productAreaLabels: { work: "My work" }
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
groupedNavigation.map((group) => group.id).join(",") ===
|
||||||
|
"overview,product-area:records-documents,product-area:work,more-tools",
|
||||||
|
"product-area navigation should preserve dashboard, configured order, and unclassified tools"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
groupedNavigation[2]?.label === "My work",
|
||||||
|
"View presentation should override a product-area label"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
groupNavigationItems(productNavigation, productAreas, { navigationMode: "flat" })[0]?.items.length === 4,
|
||||||
|
"flat navigation should retain every authorized destination"
|
||||||
|
);
|
||||||
|
|
||||||
const viewAwareFiles: PlatformWebModule = {
|
const viewAwareFiles: PlatformWebModule = {
|
||||||
...files,
|
...files,
|
||||||
navItems: [{ to: "/files", label: "Files", order: 20 }],
|
navItems: [{ to: "/files", label: "Files", order: 20 }],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"tests/help-context.test.ts",
|
"tests/help-context.test.ts",
|
||||||
"tests/definition-graph.test.ts",
|
"tests/definition-graph.test.ts",
|
||||||
"src/platform/moduleLogic.ts",
|
"src/platform/moduleLogic.ts",
|
||||||
|
"src/platform/productAreas.ts",
|
||||||
"src/utils/helpContext.ts",
|
"src/utils/helpContext.ts",
|
||||||
"src/features/privacy/policyLogic.ts",
|
"src/features/privacy/policyLogic.ts",
|
||||||
"src/definitionGraph.ts",
|
"src/definitionGraph.ts",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const defaultWebModulePackages = [
|
|||||||
"@govoplan/portal-webui",
|
"@govoplan/portal-webui",
|
||||||
"@govoplan/postbox-webui",
|
"@govoplan/postbox-webui",
|
||||||
"@govoplan/projects-webui",
|
"@govoplan/projects-webui",
|
||||||
|
"@govoplan/quick-access-webui",
|
||||||
"@govoplan/reporting-webui",
|
"@govoplan/reporting-webui",
|
||||||
"@govoplan/records-webui",
|
"@govoplan/records-webui",
|
||||||
"@govoplan/risk-compliance-webui",
|
"@govoplan/risk-compliance-webui",
|
||||||
@@ -270,6 +271,7 @@ export default defineConfig({
|
|||||||
fileURLToPath(new URL('../../govoplan-policy/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-policy/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-portal/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-portal/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-postbox/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-postbox/webui', import.meta.url)),
|
||||||
|
fileURLToPath(new URL('../../govoplan-quick-access/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-risk-compliance/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-risk-compliance/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-search/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-search/webui', import.meta.url)),
|
||||||
|
|||||||
Reference in New Issue
Block a user