Add governed View surface runtime
This commit is contained in:
@@ -47,7 +47,7 @@ python3 -m venv .venv
|
|||||||
./.venv/bin/python -m pip install -r requirements-dev.txt
|
./.venv/bin/python -m pip install -r requirements-dev.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to `tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops`; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
|
Run the platform server from core through the module-aware development runner. The default config reads `ENABLED_MODULES` and discovers installed module entry points. Local development defaults to the modules listed by `govoplan_core.settings.Settings.enabled_modules`, including Views when its package is installed; set `ENABLED_MODULES` explicitly when testing a smaller module permutation.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core
|
cd /mnt/DATA/git/govoplan-core
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||||
|
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
@@ -57,6 +59,7 @@ class NavItem:
|
|||||||
required_all: tuple[str, ...] = ()
|
required_all: tuple[str, ...] = ()
|
||||||
required_any: tuple[str, ...] = ()
|
required_any: tuple[str, ...] = ()
|
||||||
order: int = 100
|
order: int = 100
|
||||||
|
surface_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -68,6 +71,7 @@ class FrontendRoute:
|
|||||||
required_all: tuple[str, ...] = ()
|
required_all: tuple[str, ...] = ()
|
||||||
required_any: tuple[str, ...] = ()
|
required_any: tuple[str, ...] = ()
|
||||||
order: int = 100
|
order: int = 100
|
||||||
|
surface_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -92,6 +96,7 @@ class FrontendModule:
|
|||||||
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
||||||
nav_items: tuple[NavItem, ...] = ()
|
nav_items: tuple[NavItem, ...] = ()
|
||||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||||
|
view_surfaces: tuple[ViewSurface, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ from govoplan_core.core.modules import (
|
|||||||
user_workflow_scope_condition_issues,
|
user_workflow_scope_condition_issues,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||||
|
from govoplan_core.core.views import (
|
||||||
|
ViewSurface,
|
||||||
|
module_view_surface_id,
|
||||||
|
navigation_view_surface_id,
|
||||||
|
route_view_surface_id,
|
||||||
|
validate_view_surface_id,
|
||||||
|
)
|
||||||
|
|
||||||
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||||
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
|
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
|
||||||
@@ -113,6 +120,13 @@ class PlatformRegistry:
|
|||||||
def nav_items(self) -> tuple[NavItem, ...]:
|
def nav_items(self) -> tuple[NavItem, ...]:
|
||||||
return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order))
|
return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order))
|
||||||
|
|
||||||
|
def view_surfaces(self) -> tuple[ViewSurface, ...]:
|
||||||
|
return tuple(
|
||||||
|
surface
|
||||||
|
for manifest in self.manifests()
|
||||||
|
for surface in manifest_view_surfaces(manifest)
|
||||||
|
)
|
||||||
|
|
||||||
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
||||||
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
|
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
|
||||||
|
|
||||||
@@ -230,6 +244,53 @@ class PlatformRegistry:
|
|||||||
return (self._manifests[module_id] for module_id in ordered)
|
return (self._manifests[module_id] for module_id in ordered)
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_view_surfaces(manifest: ModuleManifest) -> tuple[ViewSurface, ...]:
|
||||||
|
frontend = manifest.frontend
|
||||||
|
if frontend is None:
|
||||||
|
return ()
|
||||||
|
root_id = module_view_surface_id(manifest.id)
|
||||||
|
surfaces = [
|
||||||
|
ViewSurface(
|
||||||
|
id=root_id,
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="module",
|
||||||
|
label=manifest.name,
|
||||||
|
order=min((item.order for item in frontend.nav_items), default=100),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
surfaces.extend(
|
||||||
|
ViewSurface(
|
||||||
|
id=item.surface_id or navigation_view_surface_id(manifest.id, item.path),
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="navigation",
|
||||||
|
label=item.label,
|
||||||
|
parent_id=root_id,
|
||||||
|
order=item.order,
|
||||||
|
)
|
||||||
|
for item in frontend.nav_items
|
||||||
|
)
|
||||||
|
surfaces.extend(
|
||||||
|
ViewSurface(
|
||||||
|
id=route.surface_id or route_view_surface_id(manifest.id, route.path),
|
||||||
|
module_id=manifest.id,
|
||||||
|
kind="route",
|
||||||
|
label=route.component,
|
||||||
|
parent_id=root_id,
|
||||||
|
description=route.path,
|
||||||
|
order=route.order,
|
||||||
|
)
|
||||||
|
for route in (*frontend.routes, *frontend.settings_routes)
|
||||||
|
)
|
||||||
|
surfaces.extend(
|
||||||
|
replace(
|
||||||
|
surface,
|
||||||
|
parent_id=surface.parent_id or root_id,
|
||||||
|
)
|
||||||
|
for surface in frontend.view_surfaces
|
||||||
|
)
|
||||||
|
return tuple(surfaces)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_delete_veto_result(
|
def _normalize_delete_veto_result(
|
||||||
result: object,
|
result: object,
|
||||||
*,
|
*,
|
||||||
@@ -438,8 +499,90 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
|||||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||||
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
|
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
|
||||||
_validate_frontend_route(manifest.id, route.path, route.component)
|
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||||
|
for route in (*frontend.routes, *frontend.settings_routes):
|
||||||
|
if route.surface_id is not None:
|
||||||
|
_validate_view_surface_id(manifest.id, route.surface_id)
|
||||||
for item in frontend.nav_items:
|
for item in frontend.nav_items:
|
||||||
_validate_nav_item(manifest.id, item)
|
_validate_nav_item(manifest.id, item)
|
||||||
|
if item.surface_id is not None:
|
||||||
|
_validate_view_surface_id(manifest.id, item.surface_id)
|
||||||
|
_validate_view_surfaces(manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_view_surfaces(manifest: ModuleManifest) -> None:
|
||||||
|
frontend = manifest.frontend
|
||||||
|
if frontend is None:
|
||||||
|
return
|
||||||
|
root_id = module_view_surface_id(manifest.id)
|
||||||
|
_validate_view_surface_id(manifest.id, root_id)
|
||||||
|
known_ids = {root_id}
|
||||||
|
for item in frontend.nav_items:
|
||||||
|
surface_id = item.surface_id or navigation_view_surface_id(manifest.id, item.path)
|
||||||
|
_validate_view_surface_id(manifest.id, surface_id)
|
||||||
|
if surface_id in known_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Duplicate view surface id {surface_id!r} in module {manifest.id!r}"
|
||||||
|
)
|
||||||
|
known_ids.add(surface_id)
|
||||||
|
for route in (*frontend.routes, *frontend.settings_routes):
|
||||||
|
surface_id = route.surface_id or route_view_surface_id(manifest.id, route.path)
|
||||||
|
_validate_view_surface_id(manifest.id, surface_id)
|
||||||
|
if surface_id in known_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Duplicate view surface id {surface_id!r} in module {manifest.id!r}"
|
||||||
|
)
|
||||||
|
known_ids.add(surface_id)
|
||||||
|
for surface in frontend.view_surfaces:
|
||||||
|
if surface.module_id != manifest.id:
|
||||||
|
raise RegistryError(
|
||||||
|
f"View surface {surface.id!r} belongs to {surface.module_id!r}, "
|
||||||
|
f"not module {manifest.id!r}"
|
||||||
|
)
|
||||||
|
_validate_view_surface_id(manifest.id, surface.id)
|
||||||
|
if surface.id in known_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Duplicate view surface id {surface.id!r} in module {manifest.id!r}"
|
||||||
|
)
|
||||||
|
known_ids.add(surface.id)
|
||||||
|
for surface in frontend.view_surfaces:
|
||||||
|
parent_id = surface.parent_id or root_id
|
||||||
|
if parent_id not in known_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"View surface {surface.id!r} in module {manifest.id!r} "
|
||||||
|
f"references unknown parent {parent_id!r}"
|
||||||
|
)
|
||||||
|
if surface.required and not surface.default_visible:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Required view surface {surface.id!r} in module {manifest.id!r} "
|
||||||
|
"must be visible by default"
|
||||||
|
)
|
||||||
|
parent_by_id = {
|
||||||
|
surface.id: surface.parent_id or root_id
|
||||||
|
for surface in frontend.view_surfaces
|
||||||
|
}
|
||||||
|
for surface_id in parent_by_id:
|
||||||
|
path: set[str] = set()
|
||||||
|
current_id: str | None = surface_id
|
||||||
|
while current_id in parent_by_id:
|
||||||
|
if current_id in path:
|
||||||
|
raise RegistryError(
|
||||||
|
f"View surface hierarchy in module {manifest.id!r} "
|
||||||
|
f"contains a cycle at {current_id!r}"
|
||||||
|
)
|
||||||
|
path.add(current_id)
|
||||||
|
current_id = parent_by_id[current_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_view_surface_id(module_id: str, surface_id: str) -> None:
|
||||||
|
if not validate_view_surface_id(surface_id):
|
||||||
|
raise RegistryError(
|
||||||
|
f"View surface id for module {module_id!r} is invalid: {surface_id!r}"
|
||||||
|
)
|
||||||
|
if not surface_id.startswith(f"{module_id}."):
|
||||||
|
raise RegistryError(
|
||||||
|
f"View surface id for module {module_id!r} must start with "
|
||||||
|
f"{module_id!r} followed by '.': {surface_id!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
||||||
|
|||||||
94
src/govoplan_core/core/views.py
Normal file
94
src/govoplan_core/core/views.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
|
VIEWS_MODULE_ID = "views"
|
||||||
|
CAPABILITY_VIEWS_RESOLVER = "views.resolver"
|
||||||
|
VIEW_SURFACE_CONTRACT_VERSION = "1"
|
||||||
|
|
||||||
|
ViewSurfaceKind = Literal[
|
||||||
|
"module",
|
||||||
|
"navigation",
|
||||||
|
"route",
|
||||||
|
"section",
|
||||||
|
"action",
|
||||||
|
"selector",
|
||||||
|
]
|
||||||
|
|
||||||
|
_SURFACE_ID_RE = re.compile(r"^[a-z][a-z0-9_.-]{2,159}$")
|
||||||
|
_SURFACE_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ViewSurface:
|
||||||
|
id: str
|
||||||
|
module_id: str
|
||||||
|
kind: ViewSurfaceKind
|
||||||
|
label: str
|
||||||
|
parent_id: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
order: int = 100
|
||||||
|
default_visible: bool = True
|
||||||
|
required: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class EffectiveView:
|
||||||
|
view_id: str | None
|
||||||
|
revision_id: str | None
|
||||||
|
name: str | None
|
||||||
|
visible_surface_ids: frozenset[str]
|
||||||
|
locked: bool = False
|
||||||
|
provenance: tuple[dict[str, object], ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class ViewResolver(Protocol):
|
||||||
|
def resolve_effective_view(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
account_id: str,
|
||||||
|
group_ids: Iterable[str] = (),
|
||||||
|
) -> EffectiveView: ...
|
||||||
|
|
||||||
|
|
||||||
|
def module_view_surface_id(module_id: str) -> str:
|
||||||
|
return f"{module_id}.module"
|
||||||
|
|
||||||
|
|
||||||
|
def navigation_view_surface_id(module_id: str, path: str) -> str:
|
||||||
|
return f"{module_id}.nav.{_surface_slug(path)}"
|
||||||
|
|
||||||
|
|
||||||
|
def route_view_surface_id(module_id: str, path: str) -> str:
|
||||||
|
return f"{module_id}.route.{_surface_slug(path)}"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_view_surface_id(value: str) -> bool:
|
||||||
|
return bool(_SURFACE_ID_RE.fullmatch(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _surface_slug(value: str) -> str:
|
||||||
|
normalized = _SURFACE_SLUG_RE.sub(".", value.strip().lower()).strip(".")
|
||||||
|
return normalized or "root"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_VIEWS_RESOLVER",
|
||||||
|
"EffectiveView",
|
||||||
|
"VIEWS_MODULE_ID",
|
||||||
|
"VIEW_SURFACE_CONTRACT_VERSION",
|
||||||
|
"ViewResolver",
|
||||||
|
"ViewSurface",
|
||||||
|
"ViewSurfaceKind",
|
||||||
|
"module_view_surface_id",
|
||||||
|
"navigation_view_surface_id",
|
||||||
|
"route_view_surface_id",
|
||||||
|
"validate_view_surface_id",
|
||||||
|
]
|
||||||
@@ -6,8 +6,14 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||||||
from govoplan_core.admin.models import SystemSettings
|
from govoplan_core.admin.models import SystemSettings
|
||||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem, PublicFrontendRoute
|
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces
|
||||||
|
from govoplan_core.core.views import (
|
||||||
|
VIEW_SURFACE_CONTRACT_VERSION,
|
||||||
|
ViewSurface,
|
||||||
|
navigation_view_surface_id,
|
||||||
|
route_view_surface_id,
|
||||||
|
)
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
from govoplan_core.i18n import system_i18n_payload
|
from govoplan_core.i18n import system_i18n_payload
|
||||||
|
|
||||||
@@ -19,7 +25,7 @@ def _registry(request: Request) -> PlatformRegistry:
|
|||||||
return registry
|
return registry
|
||||||
|
|
||||||
|
|
||||||
def _nav_item_payload(item: NavItem) -> dict[str, object]:
|
def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"path": item.path,
|
"path": item.path,
|
||||||
"label": item.label,
|
"label": item.label,
|
||||||
@@ -28,16 +34,26 @@ def _nav_item_payload(item: NavItem) -> dict[str, object]:
|
|||||||
"required_all": list(item.required_all),
|
"required_all": list(item.required_all),
|
||||||
"required_any": list(item.required_any),
|
"required_any": list(item.required_any),
|
||||||
"order": item.order,
|
"order": item.order,
|
||||||
|
"surface_id": (
|
||||||
|
item.surface_id or navigation_view_surface_id(module_id, item.path)
|
||||||
|
if module_id
|
||||||
|
else item.surface_id
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
|
def _frontend_route_payload(route: FrontendRoute, module_id: str | None = None) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"path": route.path,
|
"path": route.path,
|
||||||
"component": route.component,
|
"component": route.component,
|
||||||
"required_all": list(route.required_all),
|
"required_all": list(route.required_all),
|
||||||
"required_any": list(route.required_any),
|
"required_any": list(route.required_any),
|
||||||
"order": route.order,
|
"order": route.order,
|
||||||
|
"surface_id": (
|
||||||
|
route.surface_id or route_view_surface_id(module_id, route.path)
|
||||||
|
if module_id
|
||||||
|
else route.surface_id
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -49,7 +65,29 @@ def _public_frontend_route_payload(route: PublicFrontendRoute) -> dict[str, obje
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
|
def _view_surface_payload(surface: ViewSurface) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": surface.id,
|
||||||
|
"module_id": surface.module_id,
|
||||||
|
"kind": surface.kind,
|
||||||
|
"label": surface.label,
|
||||||
|
"parent_id": surface.parent_id,
|
||||||
|
"description": surface.description,
|
||||||
|
"order": surface.order,
|
||||||
|
"default_visible": surface.default_visible,
|
||||||
|
"required": surface.required,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _frontend_view_surfaces(manifest: ModuleManifest) -> list[dict[str, object]]:
|
||||||
|
return [
|
||||||
|
_view_surface_payload(surface)
|
||||||
|
for surface in manifest_view_surfaces(manifest)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
|
||||||
|
frontend = manifest.frontend
|
||||||
if frontend is None:
|
if frontend is None:
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
@@ -60,12 +98,14 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
|
|||||||
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
|
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
|
||||||
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
||||||
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
||||||
"routes": [_frontend_route_payload(route) for route in frontend.routes],
|
"routes": [_frontend_route_payload(route, manifest.id) for route in frontend.routes],
|
||||||
"public_routes": [
|
"public_routes": [
|
||||||
_public_frontend_route_payload(route) for route in frontend.public_routes
|
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||||
],
|
],
|
||||||
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
|
"nav": [_nav_item_payload(item, manifest.id) for item in frontend.nav_items],
|
||||||
"settings_routes": [_frontend_route_payload(route) 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_surfaces": _frontend_view_surfaces(manifest),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -125,8 +165,8 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
|||||||
"optional_dependencies": list(manifest.optional_dependencies),
|
"optional_dependencies": list(manifest.optional_dependencies),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
|
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
|
||||||
"nav": [_nav_item_payload(item) for item in manifest.nav_items],
|
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
|
||||||
"frontend": _frontend_payload(manifest.frontend),
|
"frontend": _frontend_payload(manifest),
|
||||||
}
|
}
|
||||||
for manifest in registry.manifests()
|
for manifest in registry.manifests()
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class Settings(BaseSettings):
|
|||||||
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
|
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
|
||||||
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
|
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
|
||||||
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
|
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
|
||||||
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,workflow,notifications,docs,ops", alias="ENABLED_MODULES")
|
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,workflow,views,notifications,docs,ops", alias="ENABLED_MODULES")
|
||||||
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
|
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
|
||||||
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
||||||
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
|
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
|
||||||
|
|||||||
260
tests/test_view_surfaces.py
Normal file
260
tests/test_view_surfaces.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import (
|
||||||
|
FrontendModule,
|
||||||
|
FrontendRoute,
|
||||||
|
ModuleManifest,
|
||||||
|
NavItem,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.registry import (
|
||||||
|
PlatformRegistry,
|
||||||
|
RegistryError,
|
||||||
|
manifest_view_surfaces,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
from govoplan_core.server.platform import create_platform_router
|
||||||
|
from govoplan_core.server.registry import available_module_manifests
|
||||||
|
|
||||||
|
|
||||||
|
def example_manifest(
|
||||||
|
*,
|
||||||
|
custom_surfaces: tuple[ViewSurface, ...] = (),
|
||||||
|
) -> ModuleManifest:
|
||||||
|
return ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="test",
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id="example",
|
||||||
|
nav_items=(
|
||||||
|
NavItem(
|
||||||
|
path="/example",
|
||||||
|
label="Example",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
routes=(
|
||||||
|
FrontendRoute(
|
||||||
|
path="/example",
|
||||||
|
component="ExamplePage",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
view_surfaces=custom_surfaces,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ViewSurfaceContractTests(unittest.TestCase):
|
||||||
|
def test_manifest_derives_module_navigation_and_route_surfaces(self) -> None:
|
||||||
|
surfaces = manifest_view_surfaces(example_manifest())
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"example.module",
|
||||||
|
"example.nav.example",
|
||||||
|
"example.route.example",
|
||||||
|
],
|
||||||
|
[surface.id for surface in surfaces],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"example.module",
|
||||||
|
next(
|
||||||
|
surface.parent_id
|
||||||
|
for surface in surfaces
|
||||||
|
if surface.id == "example.route.example"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_registry_includes_valid_custom_surface(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
example_manifest(
|
||||||
|
custom_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="example.settings.connection",
|
||||||
|
module_id="example",
|
||||||
|
kind="section",
|
||||||
|
label="Connection settings",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
custom_surface = next(
|
||||||
|
surface
|
||||||
|
for surface in registry.view_surfaces()
|
||||||
|
if surface.id == "example.settings.connection"
|
||||||
|
)
|
||||||
|
self.assertEqual("example.module", custom_surface.parent_id)
|
||||||
|
|
||||||
|
def test_registry_rejects_surface_owned_by_another_module(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
example_manifest(
|
||||||
|
custom_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="other.settings.connection",
|
||||||
|
module_id="other",
|
||||||
|
kind="section",
|
||||||
|
label="Connection settings",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "belongs to"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
def test_registry_rejects_unknown_surface_parent(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
example_manifest(
|
||||||
|
custom_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="example.settings.connection",
|
||||||
|
module_id="example",
|
||||||
|
kind="section",
|
||||||
|
label="Connection settings",
|
||||||
|
parent_id="example.missing",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "unknown parent"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
def test_registry_rejects_cyclic_surface_hierarchy(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
example_manifest(
|
||||||
|
custom_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="example.section.one",
|
||||||
|
module_id="example",
|
||||||
|
kind="section",
|
||||||
|
label="One",
|
||||||
|
parent_id="example.section.two",
|
||||||
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="example.section.two",
|
||||||
|
module_id="example",
|
||||||
|
kind="section",
|
||||||
|
label="Two",
|
||||||
|
parent_id="example.section.one",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "contains a cycle"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
def test_required_surface_must_be_visible_by_default(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
example_manifest(
|
||||||
|
custom_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="example.recovery",
|
||||||
|
module_id="example",
|
||||||
|
kind="action",
|
||||||
|
label="Recovery",
|
||||||
|
required=True,
|
||||||
|
default_visible=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "visible by default"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
def test_registry_validates_automatically_derived_surface_ids(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="test",
|
||||||
|
frontend=FrontendModule(
|
||||||
|
module_id="example",
|
||||||
|
routes=(
|
||||||
|
FrontendRoute(
|
||||||
|
path=f"/{'segment-' * 30}",
|
||||||
|
component="LongRoutePage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RegistryError, "View surface id"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
def test_platform_module_payload_exposes_normalized_surface_contract(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
example_manifest(
|
||||||
|
custom_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="example.section.details",
|
||||||
|
module_id="example",
|
||||||
|
kind="section",
|
||||||
|
label="Details",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.validate()
|
||||||
|
app = FastAPI()
|
||||||
|
app.state.govoplan_registry = registry
|
||||||
|
app.include_router(create_platform_router(), prefix="/api/v1")
|
||||||
|
|
||||||
|
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["view_surface_contract_version"])
|
||||||
|
self.assertEqual(
|
||||||
|
"example.route.example",
|
||||||
|
frontend["routes"][0]["surface_id"],
|
||||||
|
)
|
||||||
|
surfaces = {
|
||||||
|
surface["id"]: surface
|
||||||
|
for surface in frontend["view_surfaces"]
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
"example.module",
|
||||||
|
surfaces["example.section.details"]["parent_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_access_admin_route_accepts_view_manager_permissions(self) -> None:
|
||||||
|
access = available_module_manifests()["access"]
|
||||||
|
admin_route = next(
|
||||||
|
route
|
||||||
|
for route in access.frontend.routes
|
||||||
|
if route.path == "/admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"views:definition:read",
|
||||||
|
"views:assignment:read",
|
||||||
|
"views:system_definition:read",
|
||||||
|
"views:system_assignment:read",
|
||||||
|
}.issubset(admin_route.required_any)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
21
webui/package-lock.json
generated
21
webui/package-lock.json
generated
@@ -26,6 +26,7 @@
|
|||||||
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
||||||
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
||||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||||
|
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||||
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
|
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -378,6 +379,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../../govoplan-views/webui": {
|
||||||
|
"name": "@govoplan/views-webui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.14",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"../../govoplan-workflow/webui": {
|
"../../govoplan-workflow/webui": {
|
||||||
"name": "@govoplan/workflow-webui",
|
"name": "@govoplan/workflow-webui",
|
||||||
"version": "0.1.14",
|
"version": "0.1.14",
|
||||||
@@ -1192,6 +1209,10 @@
|
|||||||
"resolved": "../../govoplan-scheduling/webui",
|
"resolved": "../../govoplan-scheduling/webui",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@govoplan/views-webui": {
|
||||||
|
"resolved": "../../govoplan-views/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@govoplan/workflow-webui": {
|
"node_modules/@govoplan/workflow-webui": {
|
||||||
"resolved": "../../govoplan-workflow/webui",
|
"resolved": "../../govoplan-workflow/webui",
|
||||||
"link": true
|
"link": true
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||||
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
||||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||||
|
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||||
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
|
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const packageByModule = {
|
|||||||
ops: "@govoplan/ops-webui",
|
ops: "@govoplan/ops-webui",
|
||||||
policy: "@govoplan/policy-webui",
|
policy: "@govoplan/policy-webui",
|
||||||
scheduling: "@govoplan/scheduling-webui",
|
scheduling: "@govoplan/scheduling-webui",
|
||||||
|
views: "@govoplan/views-webui",
|
||||||
workflow: "@govoplan/workflow-webui"
|
workflow: "@govoplan/workflow-webui"
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ const cases = [
|
|||||||
{ name: "dataflow-with-datasources", modules: ["datasources", "dataflow"] },
|
{ name: "dataflow-with-datasources", modules: ["datasources", "dataflow"] },
|
||||||
{ name: "workflow-only", modules: ["workflow"] },
|
{ name: "workflow-only", modules: ["workflow"] },
|
||||||
{ name: "workflow-with-dataflow", modules: ["datasources", "dataflow", "workflow"] },
|
{ name: "workflow-with-dataflow", modules: ["datasources", "dataflow", "workflow"] },
|
||||||
|
{ name: "views-only", modules: ["views"] },
|
||||||
|
{ name: "views-with-administration", modules: ["access", "admin", "views"] },
|
||||||
{ name: "calendar-only", modules: ["calendar"] },
|
{ name: "calendar-only", modules: ["calendar"] },
|
||||||
{ name: "files-only", modules: ["files"] },
|
{ name: "files-only", modules: ["files"] },
|
||||||
{ name: "mail-only", modules: ["mail"] },
|
{ name: "mail-only", modules: ["mail"] },
|
||||||
@@ -47,7 +50,7 @@ const cases = [
|
|||||||
{ name: "scheduling-only", modules: ["scheduling"] },
|
{ name: "scheduling-only", modules: ["scheduling"] },
|
||||||
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
|
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
|
||||||
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
||||||
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling"] }
|
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling"] }
|
||||||
];
|
];
|
||||||
|
|
||||||
const npmExec = process.env.npm_execpath;
|
const npmExec = process.env.npm_execpath;
|
||||||
|
|||||||
@@ -3,16 +3,19 @@ import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
|||||||
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
||||||
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
||||||
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||||
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
|
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, EffectiveViewProjection, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences, ViewsRuntimeUiCapability } from "./types";
|
||||||
import AppShell from "./layout/AppShell";
|
import AppShell from "./layout/AppShell";
|
||||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||||
import LoginModal from "./features/auth/LoginModal";
|
import LoginModal from "./features/auth/LoginModal";
|
||||||
import { PermissionBoundary } from "./components/AccessBoundary";
|
import { PermissionBoundary } from "./components/AccessBoundary";
|
||||||
import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
|
import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules, uiCapability } from "./platform/modules";
|
||||||
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
||||||
|
import { PlatformViewProvider } from "./platform/ViewContext";
|
||||||
|
import { PLATFORM_VIEW_CHANGED_EVENT } from "./platform/views";
|
||||||
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
||||||
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||||
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
|
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
|
||||||
|
import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary";
|
||||||
|
|
||||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||||
@@ -37,18 +40,59 @@ export default function App() {
|
|||||||
const [backendReachable, setBackendReachable] = useState(true);
|
const [backendReachable, setBackendReachable] = useState(true);
|
||||||
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
||||||
const [reloginMessage, setReloginMessage] = useState("");
|
const [reloginMessage, setReloginMessage] = useState("");
|
||||||
|
const [viewProjection, setViewProjection] = useState<EffectiveViewProjection | null>(null);
|
||||||
|
|
||||||
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
||||||
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
||||||
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
|
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
|
||||||
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
|
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
|
||||||
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
const viewsRuntime = useMemo(
|
||||||
|
() => uiCapability<ViewsRuntimeUiCapability>("views.runtime", webModules),
|
||||||
|
[webModules]
|
||||||
|
);
|
||||||
|
const navItems = useMemo(
|
||||||
|
() => navItemsForModules(webModules, viewProjection),
|
||||||
|
[viewProjection, webModules]
|
||||||
|
);
|
||||||
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
|
||||||
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
|
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
|
||||||
const contextModules = auth ? webModules : publicWebModules;
|
const contextModules = auth ? webModules : publicWebModules;
|
||||||
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
|
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
|
||||||
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
|
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth || !viewsRuntime) {
|
||||||
|
setViewProjection(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentAuth = auth;
|
||||||
|
const currentRuntime = viewsRuntime;
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadEffectiveView() {
|
||||||
|
try {
|
||||||
|
const projection = await currentRuntime.loadEffectiveView(settings, currentAuth);
|
||||||
|
if (!cancelled) setViewProjection(projection);
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) setViewProjection(null);
|
||||||
|
console.error("Failed to load the effective View", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadEffectiveView();
|
||||||
|
window.addEventListener(PLATFORM_VIEW_CHANGED_EVENT, loadEffectiveView);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.removeEventListener(PLATFORM_VIEW_CHANGED_EVENT, loadEffectiveView);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
auth?.user?.id,
|
||||||
|
auth?.active_tenant?.id,
|
||||||
|
auth?.tenant.id,
|
||||||
|
settings.accessToken,
|
||||||
|
settings.apiBaseUrl,
|
||||||
|
settings.apiKey,
|
||||||
|
viewsRuntime
|
||||||
|
]);
|
||||||
|
|
||||||
function updateSettings(next: ApiSettings) {
|
function updateSettings(next: ApiSettings) {
|
||||||
setSettings(next);
|
setSettings(next);
|
||||||
saveApiSettings(next);
|
saveApiSettings(next);
|
||||||
@@ -311,6 +355,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||||
<PlatformModulesProvider modules={publicWebModules}>
|
<PlatformModulesProvider modules={publicWebModules}>
|
||||||
|
<PlatformViewProvider modules={publicWebModules} projection={null}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<div className="public-landing">
|
<div className="public-landing">
|
||||||
@@ -322,6 +367,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
|
</PlatformViewProvider>
|
||||||
</PlatformModulesProvider>
|
</PlatformModulesProvider>
|
||||||
</PlatformLanguageProvider>);
|
</PlatformLanguageProvider>);
|
||||||
|
|
||||||
@@ -331,6 +377,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||||
<PlatformModulesProvider modules={publicWebModules}>
|
<PlatformModulesProvider modules={publicWebModules}>
|
||||||
|
<PlatformViewProvider modules={publicWebModules} projection={null}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
||||||
@@ -347,12 +394,13 @@ export default function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
|
</PlatformViewProvider>
|
||||||
</PlatformModulesProvider>
|
</PlatformModulesProvider>
|
||||||
</PlatformLanguageProvider>);
|
</PlatformLanguageProvider>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultRoute = firstAccessibleRoute(auth, webModules);
|
const defaultRoute = firstAccessibleRoute(auth, webModules, viewProjection);
|
||||||
const authAvailableLanguages = auth.available_languages?.map((item) => ({
|
const authAvailableLanguages = auth.available_languages?.map((item) => ({
|
||||||
code: item.code,
|
code: item.code,
|
||||||
label: item.label,
|
label: item.label,
|
||||||
@@ -373,6 +421,7 @@ export default function App() {
|
|||||||
onLanguageChange={persistLanguagePreference}
|
onLanguageChange={persistLanguagePreference}
|
||||||
moduleTranslations={moduleTranslations}>
|
moduleTranslations={moduleTranslations}>
|
||||||
<PlatformModulesProvider modules={webModules}>
|
<PlatformModulesProvider modules={webModules}>
|
||||||
|
<PlatformViewProvider modules={webModules} projection={viewProjection}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
||||||
@@ -392,8 +441,14 @@ export default function App() {
|
|||||||
path={route.path}
|
path={route.path}
|
||||||
element={
|
element={
|
||||||
<PermissionBoundary auth={auth} anyOf={route.anyOf} allOf={route.allOf} fallback={defaultRoute}>
|
<PermissionBoundary auth={auth} anyOf={route.anyOf} allOf={route.allOf} fallback={defaultRoute}>
|
||||||
{route.render({ settings, auth, onAuthChange: updateAuth })}
|
<ViewSurfaceRouteBoundary
|
||||||
</PermissionBoundary>
|
surfaceId={route.surfaceId}
|
||||||
|
settings={settings}
|
||||||
|
runtime={viewsRuntime}
|
||||||
|
fallbackPath={defaultRoute}>
|
||||||
|
{route.render({ settings, auth, onAuthChange: updateAuth })}
|
||||||
|
</ViewSurfaceRouteBoundary>
|
||||||
|
</PermissionBoundary>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
)}
|
)}
|
||||||
@@ -412,6 +467,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
|
</PlatformViewProvider>
|
||||||
</PlatformModulesProvider>
|
</PlatformModulesProvider>
|
||||||
</PlatformLanguageProvider>);
|
</PlatformLanguageProvider>);
|
||||||
|
|
||||||
|
|||||||
58
webui/src/components/ViewSurfaceRouteBoundary.tsx
Normal file
58
webui/src/components/ViewSurfaceRouteBoundary.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { EyeOff } from "lucide-react";
|
||||||
|
import type { ApiSettings, ViewsRuntimeUiCapability } from "../types";
|
||||||
|
import { useEffectiveView, useViewSurfaceVisible } from "../platform/ViewContext";
|
||||||
|
import { dispatchPlatformViewChanged } from "../platform/views";
|
||||||
|
import { useGuardedNavigate } from "./UnsavedChangesGuard";
|
||||||
|
import Button from "./Button";
|
||||||
|
import Card from "./Card";
|
||||||
|
|
||||||
|
export default function ViewSurfaceRouteBoundary({
|
||||||
|
surfaceId,
|
||||||
|
settings,
|
||||||
|
runtime,
|
||||||
|
fallbackPath,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
surfaceId?: string;
|
||||||
|
settings: ApiSettings;
|
||||||
|
runtime: ViewsRuntimeUiCapability | null;
|
||||||
|
fallbackPath: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const visible = useViewSurfaceVisible(surfaceId);
|
||||||
|
const projection = useEffectiveView();
|
||||||
|
const navigate = useGuardedNavigate();
|
||||||
|
|
||||||
|
if (visible) return <>{children}</>;
|
||||||
|
|
||||||
|
async function exitView() {
|
||||||
|
if (!runtime || projection?.locked) return;
|
||||||
|
await runtime.activateView(settings, null);
|
||||||
|
dispatchPlatformViewChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="content-pad">
|
||||||
|
<Card title="Outside the current view">
|
||||||
|
<div className="empty-state">
|
||||||
|
<EyeOff size={24} aria-hidden="true" />
|
||||||
|
<p>
|
||||||
|
This area is available to your account, but hidden by
|
||||||
|
{projection?.activeViewName
|
||||||
|
? ` the ${projection.activeViewName} view`
|
||||||
|
: " the current view"}.
|
||||||
|
</p>
|
||||||
|
<div className="button-row">
|
||||||
|
{!projection?.locked && runtime && (
|
||||||
|
<Button variant="primary" onClick={() => void exitView()}>
|
||||||
|
Exit view
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={() => navigate(fallbackPath)}>Back to view</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import DismissibleAlert from "../../components/DismissibleAlert";
|
|||||||
import SegmentedControl from "../../components/SegmentedControl";
|
import SegmentedControl from "../../components/SegmentedControl";
|
||||||
import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
|
import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
|
||||||
import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
|
import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||||
|
import { useEffectiveView, useViewSurfaces } from "../../platform/ViewContext";
|
||||||
|
import { isViewSurfaceVisible } from "../../platform/views";
|
||||||
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
||||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||||
import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager";
|
import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager";
|
||||||
@@ -91,10 +93,12 @@ export default function SettingsPage({
|
|||||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||||
const settingsSectionCapabilities = usePlatformUiCapabilities<SettingsSectionsUiCapability>("settings.sections");
|
const settingsSectionCapabilities = usePlatformUiCapabilities<SettingsSectionsUiCapability>("settings.sections");
|
||||||
|
const effectiveView = useEffectiveView();
|
||||||
|
const viewSurfaces = useViewSurfaces();
|
||||||
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
||||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
|
||||||
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
|
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
|
||||||
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, [
|
const canUseMailProfiles = isViewSurfaceVisible(effectiveView, "mail.settings.profiles", viewSurfaces) && Boolean(MailProfileScopeManager) && hasAnyScope(auth, [
|
||||||
"mail_servers:read",
|
"mail_servers:read",
|
||||||
"mail_servers:write",
|
"mail_servers:write",
|
||||||
"mail_servers:manage_credentials",
|
"mail_servers:manage_credentials",
|
||||||
@@ -103,8 +107,8 @@ export default function SettingsPage({
|
|||||||
"admin:policies:read",
|
"admin:policies:read",
|
||||||
"admin:policies:write"
|
"admin:policies:write"
|
||||||
]);
|
]);
|
||||||
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
const canUseFileConnectors = isViewSurfaceVisible(effectiveView, "files.settings.connectors", viewSurfaces) && Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
||||||
const canUseCredentials = hasAnyScope(auth, [
|
const canUseCredentials = isViewSurfaceVisible(effectiveView, "access.settings.credentials", viewSurfaces) && hasAnyScope(auth, [
|
||||||
"access:credential:read",
|
"access:credential:read",
|
||||||
"access:credential:write",
|
"access:credential:write",
|
||||||
"access:credential:manage_own",
|
"access:credential:manage_own",
|
||||||
@@ -113,8 +117,14 @@ export default function SettingsPage({
|
|||||||
"admin:settings:write"
|
"admin:settings:write"
|
||||||
]);
|
]);
|
||||||
const contributedSections = useMemo(
|
const contributedSections = useMemo(
|
||||||
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
|
() =>
|
||||||
[auth, settingsSectionCapabilities]
|
settingsSectionCapabilities
|
||||||
|
.flatMap((capability) => capability.sections ?? [])
|
||||||
|
.filter((section) => canUseSettingsContribution(auth, section))
|
||||||
|
.filter((section) =>
|
||||||
|
isViewSurfaceVisible(effectiveView, section.surfaceId, viewSurfaces)
|
||||||
|
),
|
||||||
|
[auth, effectiveView, settingsSectionCapabilities, viewSurfaces]
|
||||||
);
|
);
|
||||||
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors, canUseCredentials, contributedSections), [canUseCredentials, canUseFileConnectors, canUseMailProfiles, contributedSections]);
|
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors, canUseCredentials, contributedSections), [canUseCredentials, canUseFileConnectors, canUseMailProfiles, contributedSections]);
|
||||||
const availableSectionIds = useMemo(() => new Set(settingsSubnav.flatMap((group) => group.items.flatMap((item) => "id" in item ? [item.id] : []))), [settingsSubnav]);
|
const availableSectionIds = useMemo(() => new Set(settingsSubnav.flatMap((group) => group.items.flatMap((item) => "id" in item ? [item.id] : []))), [settingsSubnav]);
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export * from "./api/resourceAccess";
|
|||||||
export * from "./platform/modules";
|
export * from "./platform/modules";
|
||||||
export * from "./platform/ModuleContext";
|
export * from "./platform/ModuleContext";
|
||||||
export * from "./platform/moduleEvents";
|
export * from "./platform/moduleEvents";
|
||||||
|
export * from "./platform/ViewContext";
|
||||||
|
export * from "./platform/views";
|
||||||
|
|
||||||
export * from "./utils/permissions";
|
export * from "./utils/permissions";
|
||||||
export * from "./utils/fieldHelp";
|
export * from "./utils/fieldHelp";
|
||||||
@@ -71,6 +73,7 @@ export { default as Dialog } from "./components/Dialog";
|
|||||||
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";
|
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";
|
||||||
export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip";
|
export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip";
|
||||||
export { default as DismissibleAlert } from "./components/DismissibleAlert";
|
export { default as DismissibleAlert } from "./components/DismissibleAlert";
|
||||||
|
export { default as ViewSurfaceRouteBoundary } from "./components/ViewSurfaceRouteBoundary";
|
||||||
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
|
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
|
||||||
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
|
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
|
||||||
export { default as FileDropZone } from "./components/FileDropZone";
|
export { default as FileDropZone } from "./components/FileDropZone";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef, useState, useEffect } from "react";
|
import { useRef, useState, useEffect } from "react";
|
||||||
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
|
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
|
||||||
import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse } from "../types";
|
import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, ViewsRuntimeUiCapability } from "../types";
|
||||||
import HelpMenu from "./HelpMenu";
|
import HelpMenu from "./HelpMenu";
|
||||||
import LanguageMenu from "./LanguageMenu";
|
import LanguageMenu from "./LanguageMenu";
|
||||||
import LoginModal from "../features/auth/LoginModal";
|
import LoginModal from "../features/auth/LoginModal";
|
||||||
@@ -10,7 +10,8 @@ import { apiFetch, isApiError } from "../api/client";
|
|||||||
import { logout, switchTenant } from "../api/auth";
|
import { logout, switchTenant } from "../api/auth";
|
||||||
import { hasAnyScope } from "../utils/permissions";
|
import { hasAnyScope } from "../utils/permissions";
|
||||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
import { usePlatformModules } from "../platform/ModuleContext";
|
import { usePlatformModules, usePlatformUiCapability } from "../platform/ModuleContext";
|
||||||
|
import { useEffectiveView } from "../platform/ViewContext";
|
||||||
|
|
||||||
type NotificationSummary = {
|
type NotificationSummary = {
|
||||||
unread: number;
|
unread: number;
|
||||||
@@ -39,6 +40,9 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
const tenantRef = useRef<HTMLDivElement>(null);
|
const tenantRef = useRef<HTMLDivElement>(null);
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
const modules = usePlatformModules();
|
const modules = usePlatformModules();
|
||||||
|
const projection = useEffectiveView();
|
||||||
|
const viewsRuntime = usePlatformUiCapability<ViewsRuntimeUiCapability>("views.runtime");
|
||||||
|
const ViewSelector = viewsRuntime?.Selector ?? null;
|
||||||
|
|
||||||
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
|
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
|
||||||
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
|
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
|
||||||
@@ -218,6 +222,9 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
|
|
||||||
<div className="titlebar-spacer" />
|
<div className="titlebar-spacer" />
|
||||||
|
|
||||||
|
{auth && ViewSelector &&
|
||||||
|
<ViewSelector settings={settings} auth={auth} projection={projection} />
|
||||||
|
}
|
||||||
<LanguageMenu />
|
<LanguageMenu />
|
||||||
<HelpMenu auth={auth} />
|
<HelpMenu auth={auth} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { createContext, type ReactNode, useContext } from "react";
|
import { createContext, type ReactNode, useContext } from "react";
|
||||||
import type { PlatformWebModule } from "../types";
|
import type { PlatformWebModule } from "../types";
|
||||||
import { moduleInstalled, uiCapabilities, uiCapability } from "./modules";
|
import { moduleInstalled, uiCapabilities, uiCapability } from "./modules";
|
||||||
|
import { useEffectiveView, useViewSurfaces } from "./ViewContext";
|
||||||
|
import { isViewSurfaceVisible, moduleViewSurfaceId } from "./views";
|
||||||
|
|
||||||
const PlatformModulesContext = createContext<PlatformWebModule[]>([]);
|
const PlatformModulesContext = createContext<PlatformWebModule[]>([]);
|
||||||
|
|
||||||
@@ -17,9 +19,22 @@ export function usePlatformModuleInstalled(moduleId: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePlatformUiCapability<T = unknown>(capabilityName: string): T | null {
|
export function usePlatformUiCapability<T = unknown>(capabilityName: string): T | null {
|
||||||
return uiCapability<T>(capabilityName, usePlatformModules());
|
return uiCapability<T>(capabilityName, useViewVisibleModules());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
|
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
|
||||||
return uiCapabilities<T>(capabilityName, usePlatformModules());
|
return uiCapabilities<T>(capabilityName, useViewVisibleModules());
|
||||||
|
}
|
||||||
|
|
||||||
|
function useViewVisibleModules(): PlatformWebModule[] {
|
||||||
|
const modules = usePlatformModules();
|
||||||
|
const projection = useEffectiveView();
|
||||||
|
const surfaces = useViewSurfaces();
|
||||||
|
return modules.filter((module) =>
|
||||||
|
isViewSurfaceVisible(
|
||||||
|
projection,
|
||||||
|
moduleViewSurfaceId(module.id),
|
||||||
|
surfaces
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
73
webui/src/platform/ViewContext.tsx
Normal file
73
webui/src/platform/ViewContext.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { createContext, type ReactNode, useContext, useMemo } from "react";
|
||||||
|
import type {
|
||||||
|
EffectiveViewProjection,
|
||||||
|
PlatformViewSurface,
|
||||||
|
PlatformWebModule
|
||||||
|
} from "../types";
|
||||||
|
import {
|
||||||
|
isViewSurfaceVisible,
|
||||||
|
viewSurfaceCatalogueForModules
|
||||||
|
} from "./views";
|
||||||
|
|
||||||
|
type ViewContextValue = {
|
||||||
|
projection: EffectiveViewProjection | null;
|
||||||
|
surfaces: PlatformViewSurface[];
|
||||||
|
isVisible: (surfaceId: string | null | undefined) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ViewContext = createContext<ViewContextValue>({
|
||||||
|
projection: null,
|
||||||
|
surfaces: [],
|
||||||
|
isVisible: () => true
|
||||||
|
});
|
||||||
|
|
||||||
|
export function PlatformViewProvider({
|
||||||
|
modules,
|
||||||
|
projection,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
modules: PlatformWebModule[];
|
||||||
|
projection: EffectiveViewProjection | null;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const surfaces = useMemo(
|
||||||
|
() => viewSurfaceCatalogueForModules(modules),
|
||||||
|
[modules]
|
||||||
|
);
|
||||||
|
const value = useMemo<ViewContextValue>(
|
||||||
|
() => ({
|
||||||
|
projection,
|
||||||
|
surfaces,
|
||||||
|
isVisible: (surfaceId) =>
|
||||||
|
isViewSurfaceVisible(projection, surfaceId, surfaces)
|
||||||
|
}),
|
||||||
|
[projection, surfaces]
|
||||||
|
);
|
||||||
|
return <ViewContext.Provider value={value}>{children}</ViewContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEffectiveView(): EffectiveViewProjection | null {
|
||||||
|
return useContext(ViewContext).projection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useViewSurfaces(): PlatformViewSurface[] {
|
||||||
|
return useContext(ViewContext).surfaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useViewSurfaceVisible(
|
||||||
|
surfaceId: string | null | undefined
|
||||||
|
): boolean {
|
||||||
|
return useContext(ViewContext).isVisible(surfaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ViewSurfaceBoundary({
|
||||||
|
surfaceId,
|
||||||
|
children,
|
||||||
|
fallback = null
|
||||||
|
}: {
|
||||||
|
surfaceId: string;
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return useViewSurfaceVisible(surfaceId) ? <>{children}</> : <>{fallback}</>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { PlatformPublicRouteContribution, PlatformRouteContribution, PlatformWebModule } from "../types";
|
import type { PlatformPublicRouteContribution, PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||||
|
import { routeViewSurfaceId } from "./views";
|
||||||
|
|
||||||
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
|
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
|
||||||
for (const module of modules) {
|
for (const module of modules) {
|
||||||
@@ -30,7 +31,12 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
||||||
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
return modules.flatMap((module) =>
|
||||||
|
(module.routes ?? []).map((route) => ({
|
||||||
|
...route,
|
||||||
|
surfaceId: route.surfaceId ?? routeViewSurfaceId(module.id, route.path)
|
||||||
|
}))
|
||||||
|
).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function publicRouteContributionsForModules(modules: PlatformWebModule[]): PlatformPublicRouteContribution[] {
|
export function publicRouteContributionsForModules(modules: PlatformWebModule[]): PlatformPublicRouteContribution[] {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react";
|
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react";
|
||||||
import installedWebModules from "virtual:govoplan-installed-modules";
|
import installedWebModules from "virtual:govoplan-installed-modules";
|
||||||
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformWebModule } from "../types";
|
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, EffectiveViewProjection, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformViewSurface, PlatformWebModule } from "../types";
|
||||||
import {
|
import {
|
||||||
hasUiCapability as hasUiCapabilityForModules,
|
hasUiCapability as hasUiCapabilityForModules,
|
||||||
moduleInstalled as moduleInstalledForModules,
|
moduleInstalled as moduleInstalledForModules,
|
||||||
@@ -11,6 +11,12 @@ import {
|
|||||||
uiCapability as uiCapabilityForModules } from
|
uiCapability as uiCapabilityForModules } from
|
||||||
"./moduleLogic";
|
"./moduleLogic";
|
||||||
import { hasAnyScope, hasScope } from "../utils/permissions";
|
import { hasAnyScope, hasScope } from "../utils/permissions";
|
||||||
|
import {
|
||||||
|
isViewSurfaceVisible,
|
||||||
|
navigationViewSurfaceId,
|
||||||
|
routeViewSurfaceId,
|
||||||
|
viewSurfaceCatalogueForModules
|
||||||
|
} from "./views";
|
||||||
|
|
||||||
export const fallbackDashboardNavItem: PlatformNavItem =
|
export const fallbackDashboardNavItem: PlatformNavItem =
|
||||||
{ to: "/dashboard", label: "i18n:govoplan-core.dashboard.d87f47b4", iconName: "dashboard", order: 10 };
|
{ to: "/dashboard", label: "i18n:govoplan-core.dashboard.d87f47b4", iconName: "dashboard", order: 10 };
|
||||||
@@ -86,10 +92,65 @@ function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavIt
|
|||||||
iconName: item.icon ?? null,
|
iconName: item.icon ?? null,
|
||||||
allOf: item.required_all,
|
allOf: item.required_all,
|
||||||
anyOf: item.required_any,
|
anyOf: item.required_any,
|
||||||
order: item.order
|
order: item.order,
|
||||||
|
surfaceId: item.surface_id ?? undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function viewSurfaceFromMetadata(
|
||||||
|
item: NonNullable<NonNullable<PlatformModuleInfo["frontend"]>["view_surfaces"]>[number]
|
||||||
|
): PlatformViewSurface {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
moduleId: item.module_id,
|
||||||
|
kind: item.kind,
|
||||||
|
label: item.label,
|
||||||
|
parentId: item.parent_id,
|
||||||
|
description: item.description,
|
||||||
|
order: item.order,
|
||||||
|
defaultVisible: item.default_visible,
|
||||||
|
required: item.required
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeViewSurfaces(
|
||||||
|
module: PlatformWebModule,
|
||||||
|
info: PlatformModuleInfo
|
||||||
|
): PlatformViewSurface[] | undefined {
|
||||||
|
const surfaces = new Map<string, PlatformViewSurface>();
|
||||||
|
for (const surface of info.frontend?.view_surfaces ?? []) {
|
||||||
|
const normalized = viewSurfaceFromMetadata(surface);
|
||||||
|
surfaces.set(normalized.id, normalized);
|
||||||
|
}
|
||||||
|
for (const surface of module.viewSurfaces ?? []) {
|
||||||
|
surfaces.set(surface.id, {
|
||||||
|
...surfaces.get(surface.id),
|
||||||
|
...surface,
|
||||||
|
required: Boolean(surfaces.get(surface.id)?.required || surface.required)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return surfaces.size ? [...surfaces.values()] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routesWithServerMetadata(
|
||||||
|
module: PlatformWebModule,
|
||||||
|
info: PlatformModuleInfo
|
||||||
|
) {
|
||||||
|
const routesByPath = new Map(
|
||||||
|
(info.frontend?.routes ?? []).map((route) => [route.path, route])
|
||||||
|
);
|
||||||
|
return (module.routes ?? []).map((route) => {
|
||||||
|
const metadata = routesByPath.get(route.path);
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
surfaceId:
|
||||||
|
metadata?.surface_id ??
|
||||||
|
route.surfaceId ??
|
||||||
|
routeViewSurfaceId(module.id, route.path)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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 ?? []);
|
||||||
@@ -113,7 +174,9 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
|||||||
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
|
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
|
||||||
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
|
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
|
||||||
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
||||||
|
routes: routesWithServerMetadata(module, info),
|
||||||
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
||||||
|
viewSurfaces: mergeViewSurfaces(module, info),
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
...(module.uiCapabilities ?? {}),
|
...(module.uiCapabilities ?? {}),
|
||||||
...runtimeUiCapabilitiesForModule(module, info)
|
...runtimeUiCapabilitiesForModule(module, info)
|
||||||
@@ -353,26 +416,42 @@ export function publicRouteContributionsForModules(modules: PlatformWebModule[])
|
|||||||
return publicRouteContributionsForModuleList(modules);
|
return publicRouteContributionsForModuleList(modules);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
|
export function dashboardWidgetsForModules(
|
||||||
|
modules: PlatformWebModule[] = localModules,
|
||||||
|
projection?: EffectiveViewProjection | null
|
||||||
|
): DashboardWidgetContribution[] {
|
||||||
|
const catalogue = viewSurfaceCatalogueForModules(modules);
|
||||||
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
|
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
|
||||||
flatMap((capability) => capability.widgets ?? []).
|
flatMap((capability) => capability.widgets ?? []).
|
||||||
|
filter((widget) => isViewSurfaceVisible(projection, widget.surfaceId, catalogue)).
|
||||||
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
|
export function navItemsForModules(
|
||||||
return [...shellNavItemsForModules(modules), ...modules.flatMap((module) => module.navItems ?? [])].
|
modules: PlatformWebModule[],
|
||||||
|
projection?: EffectiveViewProjection | null
|
||||||
|
): PlatformNavItem[] {
|
||||||
|
const catalogue = viewSurfaceCatalogueForModules(modules);
|
||||||
|
const moduleItems = modules.flatMap((module) =>
|
||||||
|
(module.navItems ?? []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
surfaceId: item.surfaceId ?? navigationViewSurfaceId(module.id, item.to)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
return [...shellNavItemsForModules(modules), ...moduleItems].
|
||||||
map(resolveNavItemIcon).
|
map(resolveNavItemIcon).
|
||||||
|
filter((item) => isViewSurfaceVisible(projection, item.surfaceId, catalogue)).
|
||||||
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules): PlatformNavItem[] {
|
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): PlatformNavItem[] {
|
||||||
return navItemsForModules(modules).filter((item) => {
|
return navItemsForModules(modules, projection).filter((item) => {
|
||||||
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
|
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
|
||||||
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
|
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules): string {
|
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): string {
|
||||||
return visibleNavItems(auth, modules)[0]?.to ?? "/dashboard";
|
return visibleNavItems(auth, modules, projection)[0]?.to ?? "/dashboard";
|
||||||
}
|
}
|
||||||
|
|||||||
133
webui/src/platform/views.ts
Normal file
133
webui/src/platform/views.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import type {
|
||||||
|
EffectiveViewProjection,
|
||||||
|
PlatformRouteContribution,
|
||||||
|
PlatformViewSurface,
|
||||||
|
PlatformWebModule
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export const PLATFORM_VIEW_CHANGED_EVENT = "govoplan:view-changed";
|
||||||
|
|
||||||
|
export function moduleViewSurfaceId(moduleId: string): string {
|
||||||
|
return `${moduleId}.module`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navigationViewSurfaceId(moduleId: string, path: string): string {
|
||||||
|
return `${moduleId}.nav.${surfaceSlug(path)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routeViewSurfaceId(moduleId: string, path: string): string {
|
||||||
|
return `${moduleId}.route.${surfaceSlug(path)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function viewSurfaceCatalogueForModules(
|
||||||
|
modules: PlatformWebModule[]
|
||||||
|
): PlatformViewSurface[] {
|
||||||
|
const surfaces = new Map<string, PlatformViewSurface>();
|
||||||
|
for (const module of modules) {
|
||||||
|
const rootId = moduleViewSurfaceId(module.id);
|
||||||
|
addSurface(surfaces, {
|
||||||
|
id: rootId,
|
||||||
|
moduleId: module.id,
|
||||||
|
kind: "module",
|
||||||
|
label: module.label,
|
||||||
|
order: Math.min(
|
||||||
|
...(module.navItems?.map((item) => item.order ?? 100) ?? [100])
|
||||||
|
)
|
||||||
|
});
|
||||||
|
for (const item of module.navItems ?? []) {
|
||||||
|
addSurface(surfaces, {
|
||||||
|
id: item.surfaceId ?? navigationViewSurfaceId(module.id, item.to),
|
||||||
|
moduleId: module.id,
|
||||||
|
kind: "navigation",
|
||||||
|
label: item.label,
|
||||||
|
parentId: rootId,
|
||||||
|
order: item.order
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const route of module.routes ?? []) {
|
||||||
|
addSurface(surfaces, {
|
||||||
|
id: route.surfaceId ?? routeViewSurfaceId(module.id, route.path),
|
||||||
|
moduleId: module.id,
|
||||||
|
kind: "route",
|
||||||
|
label: route.path,
|
||||||
|
description: route.path,
|
||||||
|
parentId: rootId,
|
||||||
|
order: route.order
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const surface of module.viewSurfaces ?? []) {
|
||||||
|
addSurface(surfaces, {
|
||||||
|
...surface,
|
||||||
|
parentId: surface.parentId ?? rootId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...surfaces.values()].sort(
|
||||||
|
(left, right) =>
|
||||||
|
(left.order ?? 100) - (right.order ?? 100) ||
|
||||||
|
left.label.localeCompare(right.label)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isViewSurfaceVisible(
|
||||||
|
projection: EffectiveViewProjection | null | undefined,
|
||||||
|
surfaceId: string | null | undefined,
|
||||||
|
catalogue: PlatformViewSurface[]
|
||||||
|
): boolean {
|
||||||
|
if (!surfaceId || !projection?.activeViewId) return true;
|
||||||
|
const byId = new Map(catalogue.map((surface) => [surface.id, surface]));
|
||||||
|
const surface = byId.get(surfaceId);
|
||||||
|
if (!surface) return true;
|
||||||
|
const visible = new Set(projection.visibleSurfaceIds);
|
||||||
|
let current: PlatformViewSurface | undefined = surface;
|
||||||
|
const visited = new Set<string>();
|
||||||
|
while (current) {
|
||||||
|
if (visited.has(current.id)) return false;
|
||||||
|
visited.add(current.id);
|
||||||
|
if (!current.required && !visible.has(current.id)) return false;
|
||||||
|
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleRoutesForProjection(
|
||||||
|
modules: PlatformWebModule[],
|
||||||
|
projection: EffectiveViewProjection | null | undefined
|
||||||
|
): PlatformRouteContribution[] {
|
||||||
|
const catalogue = viewSurfaceCatalogueForModules(modules);
|
||||||
|
return modules.flatMap((module) =>
|
||||||
|
(module.routes ?? []).filter((route) =>
|
||||||
|
isViewSurfaceVisible(
|
||||||
|
projection,
|
||||||
|
route.surfaceId ?? routeViewSurfaceId(module.id, route.path),
|
||||||
|
catalogue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchPlatformViewChanged(): void {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent(PLATFORM_VIEW_CHANGED_EVENT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSurface(
|
||||||
|
target: Map<string, PlatformViewSurface>,
|
||||||
|
surface: PlatformViewSurface
|
||||||
|
): void {
|
||||||
|
const existing = target.get(surface.id);
|
||||||
|
if (!existing) {
|
||||||
|
target.set(surface.id, surface);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
target.set(surface.id, {
|
||||||
|
...existing,
|
||||||
|
...surface,
|
||||||
|
required: Boolean(existing.required || surface.required)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function surfaceSlug(value: string): string {
|
||||||
|
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.+|\.+$/g, "") || "root";
|
||||||
|
}
|
||||||
@@ -227,6 +227,26 @@ export type CampaignListItem = {
|
|||||||
|
|
||||||
export type PlatformIconName = string;
|
export type PlatformIconName = string;
|
||||||
|
|
||||||
|
export type PlatformViewSurfaceKind =
|
||||||
|
| "module"
|
||||||
|
| "navigation"
|
||||||
|
| "route"
|
||||||
|
| "section"
|
||||||
|
| "action"
|
||||||
|
| "selector";
|
||||||
|
|
||||||
|
export type PlatformViewSurface = {
|
||||||
|
id: string;
|
||||||
|
moduleId: string;
|
||||||
|
kind: PlatformViewSurfaceKind;
|
||||||
|
label: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
order?: number;
|
||||||
|
defaultVisible?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type PlatformNavItem = {
|
export type PlatformNavItem = {
|
||||||
to: string;
|
to: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -237,6 +257,7 @@ export type PlatformNavItem = {
|
|||||||
anyOf?: string[];
|
anyOf?: string[];
|
||||||
allOf?: string[];
|
allOf?: string[];
|
||||||
order?: number;
|
order?: number;
|
||||||
|
surfaceId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlatformRouteContext = {
|
export type PlatformRouteContext = {
|
||||||
@@ -266,6 +287,7 @@ export type AdminSectionContribution = {
|
|||||||
order?: number;
|
order?: number;
|
||||||
anyOf?: string[];
|
anyOf?: string[];
|
||||||
allOf?: string[];
|
allOf?: string[];
|
||||||
|
surfaceId?: string;
|
||||||
render: (context: AdminSectionRenderContext) => ReactNode;
|
render: (context: AdminSectionRenderContext) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -289,6 +311,7 @@ export type SettingsSectionContribution = {
|
|||||||
order?: number;
|
order?: number;
|
||||||
anyOf?: string[];
|
anyOf?: string[];
|
||||||
allOf?: string[];
|
allOf?: string[];
|
||||||
|
surfaceId?: string;
|
||||||
render: (context: SettingsSectionRenderContext) => ReactNode;
|
render: (context: SettingsSectionRenderContext) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -301,6 +324,7 @@ export type PlatformRouteContribution = {
|
|||||||
anyOf?: string[];
|
anyOf?: string[];
|
||||||
allOf?: string[];
|
allOf?: string[];
|
||||||
order?: number;
|
order?: number;
|
||||||
|
surfaceId?: string;
|
||||||
render: (context: PlatformRouteContext) => ReactNode;
|
render: (context: PlatformRouteContext) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -333,6 +357,53 @@ export type PlatformWebModule = {
|
|||||||
translations?: PlatformTranslations;
|
translations?: PlatformTranslations;
|
||||||
uiCapabilities?: PlatformUiCapabilities;
|
uiCapabilities?: PlatformUiCapabilities;
|
||||||
runtimeUiCapabilities?: PlatformUiCapabilities;
|
runtimeUiCapabilities?: PlatformUiCapabilities;
|
||||||
|
viewSurfaces?: PlatformViewSurface[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EffectiveViewOption = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
revisionId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EffectiveViewProjection = {
|
||||||
|
activeViewId: string | null;
|
||||||
|
activeRevisionId: string | null;
|
||||||
|
activeViewName: string | null;
|
||||||
|
visibleSurfaceIds: string[];
|
||||||
|
locked: boolean;
|
||||||
|
availableViews: EffectiveViewOption[];
|
||||||
|
provenance: Array<{
|
||||||
|
source: string;
|
||||||
|
scopeType?: string | null;
|
||||||
|
scopeId?: string | null;
|
||||||
|
detail?: string | null;
|
||||||
|
}>;
|
||||||
|
diagnostics: Array<{
|
||||||
|
severity: "warning" | "error";
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
surfaceIds: string[];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ViewSelectorProps = {
|
||||||
|
settings: ApiSettings;
|
||||||
|
auth: AuthInfo;
|
||||||
|
projection: EffectiveViewProjection | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ViewsRuntimeUiCapability = {
|
||||||
|
loadEffectiveView: (
|
||||||
|
settings: ApiSettings,
|
||||||
|
auth: AuthInfo
|
||||||
|
) => Promise<EffectiveViewProjection>;
|
||||||
|
activateView: (
|
||||||
|
settings: ApiSettings,
|
||||||
|
viewId: string | null
|
||||||
|
) => Promise<EffectiveViewProjection>;
|
||||||
|
Selector?: ComponentType<ViewSelectorProps>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardWidgetSize = "small" | "medium" | "wide" | "full";
|
export type DashboardWidgetSize = "small" | "medium" | "wide" | "full";
|
||||||
@@ -353,6 +424,7 @@ export type DashboardWidgetContribution = {
|
|||||||
moduleId?: string;
|
moduleId?: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
order?: number;
|
order?: number;
|
||||||
|
surfaceId?: string;
|
||||||
defaultVisible?: boolean;
|
defaultVisible?: boolean;
|
||||||
defaultSize?: DashboardWidgetSize;
|
defaultSize?: DashboardWidgetSize;
|
||||||
supportedSizes?: DashboardWidgetSize[];
|
supportedSizes?: DashboardWidgetSize[];
|
||||||
@@ -383,6 +455,7 @@ export type OrganizationFunctionActionContribution = {
|
|||||||
order?: number;
|
order?: number;
|
||||||
anyOf?: string[];
|
anyOf?: string[];
|
||||||
allOf?: string[];
|
allOf?: string[];
|
||||||
|
surfaceId?: string;
|
||||||
onClick: (context: OrganizationFunctionActionContext) => void;
|
onClick: (context: OrganizationFunctionActionContext) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -800,6 +873,7 @@ export type PlatformFrontendRouteInfo = {
|
|||||||
required_all: string[];
|
required_all: string[];
|
||||||
required_any: string[];
|
required_any: string[];
|
||||||
order: number;
|
order: number;
|
||||||
|
surface_id?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlatformFrontendModuleInfo = {
|
export type PlatformFrontendModuleInfo = {
|
||||||
@@ -824,8 +898,21 @@ export type PlatformFrontendModuleInfo = {
|
|||||||
required_all: string[];
|
required_all: string[];
|
||||||
required_any: string[];
|
required_any: string[];
|
||||||
order: number;
|
order: number;
|
||||||
|
surface_id?: string | null;
|
||||||
}>;
|
}>;
|
||||||
settings_routes: PlatformFrontendRouteInfo[];
|
settings_routes: PlatformFrontendRouteInfo[];
|
||||||
|
view_surface_contract_version?: string | null;
|
||||||
|
view_surfaces?: Array<{
|
||||||
|
id: string;
|
||||||
|
module_id: string;
|
||||||
|
kind: PlatformViewSurfaceKind;
|
||||||
|
label: string;
|
||||||
|
parent_id?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
order: number;
|
||||||
|
default_visible: boolean;
|
||||||
|
required: boolean;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PlatformPublicModuleInfo = {
|
export type PlatformPublicModuleInfo = {
|
||||||
@@ -861,6 +948,7 @@ export type PlatformModuleInfo = {
|
|||||||
required_all: string[];
|
required_all: string[];
|
||||||
required_any: string[];
|
required_any: string[];
|
||||||
order: number;
|
order: number;
|
||||||
|
surface_id?: string | null;
|
||||||
}>;
|
}>;
|
||||||
frontend?: PlatformFrontendModuleInfo | null;
|
frontend?: PlatformFrontendModuleInfo | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -163,5 +163,9 @@ export const adminReadScopes = [
|
|||||||
"access:system_setting:read",
|
"access:system_setting:read",
|
||||||
"access:system_credential:read",
|
"access:system_credential:read",
|
||||||
"access:credential:read",
|
"access:credential:read",
|
||||||
"access:governance:read"
|
"access:governance:read",
|
||||||
|
"views:definition:read",
|
||||||
|
"views:assignment:read",
|
||||||
|
"views:system_definition:read",
|
||||||
|
"views:system_assignment:read"
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import {
|
|||||||
routeContributionsForModules,
|
routeContributionsForModules,
|
||||||
uiCapability
|
uiCapability
|
||||||
} from "../src/platform/moduleLogic";
|
} from "../src/platform/moduleLogic";
|
||||||
|
import {
|
||||||
|
isViewSurfaceVisible,
|
||||||
|
viewSurfaceCatalogueForModules,
|
||||||
|
visibleRoutesForProjection
|
||||||
|
} from "../src/platform/views";
|
||||||
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 {
|
||||||
@@ -96,3 +101,73 @@ 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 viewAwareFiles: PlatformWebModule = {
|
||||||
|
...files,
|
||||||
|
navItems: [{ to: "/files", label: "Files", order: 20 }],
|
||||||
|
viewSurfaces: [
|
||||||
|
{
|
||||||
|
id: "files.settings.connectors",
|
||||||
|
moduleId: "files",
|
||||||
|
kind: "section",
|
||||||
|
label: "File connectors"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const viewCatalogue = viewSurfaceCatalogueForModules([viewAwareFiles]);
|
||||||
|
assert(
|
||||||
|
viewCatalogue.some((surface) => surface.id === "files.module"),
|
||||||
|
"view catalogue should derive a module root"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
viewCatalogue.some((surface) => surface.id === "files.nav.files"),
|
||||||
|
"view catalogue should derive navigation surfaces"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
viewCatalogue.some((surface) => surface.id === "files.route.files"),
|
||||||
|
"view catalogue should derive route surfaces"
|
||||||
|
);
|
||||||
|
|
||||||
|
const filesView = {
|
||||||
|
activeViewId: "view-files",
|
||||||
|
activeRevisionId: "revision-files",
|
||||||
|
activeViewName: "Files only",
|
||||||
|
visibleSurfaceIds: [
|
||||||
|
"files.module",
|
||||||
|
"files.nav.files",
|
||||||
|
"files.route.files"
|
||||||
|
],
|
||||||
|
locked: false,
|
||||||
|
availableViews: [],
|
||||||
|
provenance: [],
|
||||||
|
diagnostics: []
|
||||||
|
};
|
||||||
|
assert(
|
||||||
|
isViewSurfaceVisible(filesView, "files.route.files", viewCatalogue),
|
||||||
|
"selected child surfaces should remain visible when their ancestor is selected"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!isViewSurfaceVisible(filesView, "files.settings.connectors", viewCatalogue),
|
||||||
|
"unselected sections should be hidden"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
visibleRoutesForProjection([viewAwareFiles], filesView).length === 1,
|
||||||
|
"selected routes should remain in the effective route list"
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingParentView = {
|
||||||
|
...filesView,
|
||||||
|
visibleSurfaceIds: ["files.route.files"]
|
||||||
|
};
|
||||||
|
assert(
|
||||||
|
!isViewSurfaceVisible(
|
||||||
|
missingParentView,
|
||||||
|
"files.route.files",
|
||||||
|
viewCatalogue
|
||||||
|
),
|
||||||
|
"a selected child should stay hidden when its module ancestor is not selected"
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
isViewSurfaceVisible(filesView, "future.module.action", viewCatalogue),
|
||||||
|
"unknown surfaces should fail open for forward compatibility and recovery"
|
||||||
|
);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const defaultWebModulePackages = [
|
|||||||
"@govoplan/ops-webui",
|
"@govoplan/ops-webui",
|
||||||
"@govoplan/policy-webui",
|
"@govoplan/policy-webui",
|
||||||
"@govoplan/scheduling-webui",
|
"@govoplan/scheduling-webui",
|
||||||
|
"@govoplan/views-webui",
|
||||||
"@govoplan/workflow-webui"
|
"@govoplan/workflow-webui"
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ export default defineConfig({
|
|||||||
fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-policy/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-policy/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-views/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))
|
fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user