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
|
||||
```
|
||||
|
||||
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
|
||||
cd /mnt/DATA/git/govoplan-core
|
||||
|
||||
@@ -4,6 +4,8 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import APIRouter
|
||||
|
||||
@@ -57,6 +59,7 @@ class NavItem:
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
surface_id: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -68,6 +71,7 @@ class FrontendRoute:
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
surface_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -92,6 +96,7 @@ class FrontendModule:
|
||||
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
||||
nav_items: tuple[NavItem, ...] = ()
|
||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||
view_surfaces: tuple[ViewSurface, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -25,6 +25,13 @@ from govoplan_core.core.modules import (
|
||||
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.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_]*$")
|
||||
_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, ...]:
|
||||
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, ...]:
|
||||
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)
|
||||
|
||||
|
||||
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(
|
||||
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}")
|
||||
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
|
||||
_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:
|
||||
_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:
|
||||
|
||||
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.settings import SYSTEM_SETTINGS_ID
|
||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem, PublicFrontendRoute
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
|
||||
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.i18n import system_i18n_payload
|
||||
|
||||
@@ -19,7 +25,7 @@ def _registry(request: Request) -> PlatformRegistry:
|
||||
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 {
|
||||
"path": item.path,
|
||||
"label": item.label,
|
||||
@@ -28,16 +34,26 @@ def _nav_item_payload(item: NavItem) -> dict[str, object]:
|
||||
"required_all": list(item.required_all),
|
||||
"required_any": list(item.required_any),
|
||||
"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 {
|
||||
"path": route.path,
|
||||
"component": route.component,
|
||||
"required_all": list(route.required_all),
|
||||
"required_any": list(route.required_any),
|
||||
"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:
|
||||
return None
|
||||
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_integrity": frontend.asset_manifest_integrity,
|
||||
"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_frontend_route_payload(route) for route in frontend.public_routes
|
||||
],
|
||||
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
|
||||
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
|
||||
"nav": [_nav_item_payload(item, manifest.id) for item in frontend.nav_items],
|
||||
"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),
|
||||
"enabled": True,
|
||||
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
|
||||
"nav": [_nav_item_payload(item) for item in manifest.nav_items],
|
||||
"frontend": _frontend_payload(manifest.frontend),
|
||||
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
|
||||
"frontend": _frontend_payload(manifest),
|
||||
}
|
||||
for manifest in registry.manifests()
|
||||
]
|
||||
|
||||
@@ -17,7 +17,7 @@ class Settings(BaseSettings):
|
||||
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_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")
|
||||
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
||||
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/policy-webui": "file:../../govoplan-policy/webui",
|
||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
|
||||
},
|
||||
"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": {
|
||||
"name": "@govoplan/workflow-webui",
|
||||
"version": "0.1.14",
|
||||
@@ -1192,6 +1209,10 @@
|
||||
"resolved": "../../govoplan-scheduling/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/views-webui": {
|
||||
"resolved": "../../govoplan-views/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/workflow-webui": {
|
||||
"resolved": "../../govoplan-workflow/webui",
|
||||
"link": true
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -19,6 +19,7 @@ const packageByModule = {
|
||||
ops: "@govoplan/ops-webui",
|
||||
policy: "@govoplan/policy-webui",
|
||||
scheduling: "@govoplan/scheduling-webui",
|
||||
views: "@govoplan/views-webui",
|
||||
workflow: "@govoplan/workflow-webui"
|
||||
};
|
||||
|
||||
@@ -35,6 +36,8 @@ const cases = [
|
||||
{ name: "dataflow-with-datasources", modules: ["datasources", "dataflow"] },
|
||||
{ name: "workflow-only", modules: ["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: "files-only", modules: ["files"] },
|
||||
{ name: "mail-only", modules: ["mail"] },
|
||||
@@ -47,7 +50,7 @@ const cases = [
|
||||
{ name: "scheduling-only", modules: ["scheduling"] },
|
||||
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
|
||||
{ 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;
|
||||
|
||||
@@ -3,16 +3,19 @@ import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
||||
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
||||
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 PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||
import LoginModal from "./features/auth/LoginModal";
|
||||
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 { PlatformViewProvider } from "./platform/ViewContext";
|
||||
import { PLATFORM_VIEW_CHANGED_EVENT } from "./platform/views";
|
||||
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
||||
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
|
||||
import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary";
|
||||
|
||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||
@@ -37,18 +40,59 @@ export default function App() {
|
||||
const [backendReachable, setBackendReachable] = useState(true);
|
||||
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
||||
const [reloginMessage, setReloginMessage] = useState("");
|
||||
const [viewProjection, setViewProjection] = useState<EffectiveViewProjection | null>(null);
|
||||
|
||||
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
||||
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
||||
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
|
||||
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 publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
|
||||
const contextModules = auth ? webModules : publicWebModules;
|
||||
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
|
||||
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) {
|
||||
setSettings(next);
|
||||
saveApiSettings(next);
|
||||
@@ -311,6 +355,7 @@ export default function App() {
|
||||
return (
|
||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={publicWebModules}>
|
||||
<PlatformViewProvider modules={publicWebModules} projection={null}>
|
||||
<UnsavedChangesProvider>
|
||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||
<div className="public-landing">
|
||||
@@ -322,6 +367,7 @@ export default function App() {
|
||||
</div>
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformViewProvider>
|
||||
</PlatformModulesProvider>
|
||||
</PlatformLanguageProvider>);
|
||||
|
||||
@@ -331,6 +377,7 @@ export default function App() {
|
||||
return (
|
||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={publicWebModules}>
|
||||
<PlatformViewProvider modules={publicWebModules} projection={null}>
|
||||
<UnsavedChangesProvider>
|
||||
<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>}>
|
||||
@@ -347,12 +394,13 @@ export default function App() {
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformViewProvider>
|
||||
</PlatformModulesProvider>
|
||||
</PlatformLanguageProvider>);
|
||||
|
||||
}
|
||||
|
||||
const defaultRoute = firstAccessibleRoute(auth, webModules);
|
||||
const defaultRoute = firstAccessibleRoute(auth, webModules, viewProjection);
|
||||
const authAvailableLanguages = auth.available_languages?.map((item) => ({
|
||||
code: item.code,
|
||||
label: item.label,
|
||||
@@ -373,6 +421,7 @@ export default function App() {
|
||||
onLanguageChange={persistLanguagePreference}
|
||||
moduleTranslations={moduleTranslations}>
|
||||
<PlatformModulesProvider modules={webModules}>
|
||||
<PlatformViewProvider modules={webModules} projection={viewProjection}>
|
||||
<UnsavedChangesProvider>
|
||||
<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>}>
|
||||
@@ -392,7 +441,13 @@ export default function App() {
|
||||
path={route.path}
|
||||
element={
|
||||
<PermissionBoundary auth={auth} anyOf={route.anyOf} allOf={route.allOf} fallback={defaultRoute}>
|
||||
<ViewSurfaceRouteBoundary
|
||||
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>
|
||||
</UnsavedChangesProvider>
|
||||
</PlatformViewProvider>
|
||||
</PlatformModulesProvider>
|
||||
</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 { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
|
||||
import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
|
||||
import { useEffectiveView, useViewSurfaces } from "../../platform/ViewContext";
|
||||
import { isViewSurfaceVisible } from "../../platform/views";
|
||||
import { hasAnyScope, hasScope } from "../../utils/permissions";
|
||||
import { usePlatformLanguage } from "../../i18n/LanguageContext";
|
||||
import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager";
|
||||
@@ -91,10 +93,12 @@ export default function SettingsPage({
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||
const settingsSectionCapabilities = usePlatformUiCapabilities<SettingsSectionsUiCapability>("settings.sections");
|
||||
const effectiveView = useEffectiveView();
|
||||
const viewSurfaces = useViewSurfaces();
|
||||
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
|
||||
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? 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:write",
|
||||
"mail_servers:manage_credentials",
|
||||
@@ -103,8 +107,8 @@ export default function SettingsPage({
|
||||
"admin:policies:read",
|
||||
"admin:policies:write"
|
||||
]);
|
||||
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
|
||||
const canUseCredentials = hasAnyScope(auth, [
|
||||
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 = isViewSurfaceVisible(effectiveView, "access.settings.credentials", viewSurfaces) && hasAnyScope(auth, [
|
||||
"access:credential:read",
|
||||
"access:credential:write",
|
||||
"access:credential:manage_own",
|
||||
@@ -113,8 +117,14 @@ export default function SettingsPage({
|
||||
"admin:settings:write"
|
||||
]);
|
||||
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 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/ModuleContext";
|
||||
export * from "./platform/moduleEvents";
|
||||
export * from "./platform/ViewContext";
|
||||
export * from "./platform/views";
|
||||
|
||||
export * from "./utils/permissions";
|
||||
export * from "./utils/fieldHelp";
|
||||
@@ -71,6 +73,7 @@ export { default as Dialog } from "./components/Dialog";
|
||||
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";
|
||||
export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip";
|
||||
export { default as DismissibleAlert } from "./components/DismissibleAlert";
|
||||
export { default as ViewSurfaceRouteBoundary } from "./components/ViewSurfaceRouteBoundary";
|
||||
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
|
||||
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
|
||||
export { default as FileDropZone } from "./components/FileDropZone";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState, useEffect } from "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 LanguageMenu from "./LanguageMenu";
|
||||
import LoginModal from "../features/auth/LoginModal";
|
||||
@@ -10,7 +10,8 @@ import { apiFetch, isApiError } from "../api/client";
|
||||
import { logout, switchTenant } from "../api/auth";
|
||||
import { hasAnyScope } from "../utils/permissions";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import { usePlatformModules } from "../platform/ModuleContext";
|
||||
import { usePlatformModules, usePlatformUiCapability } from "../platform/ModuleContext";
|
||||
import { useEffectiveView } from "../platform/ViewContext";
|
||||
|
||||
type NotificationSummary = {
|
||||
unread: number;
|
||||
@@ -39,6 +40,9 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
const tenantRef = useRef<HTMLDivElement>(null);
|
||||
const { translateText } = usePlatformLanguage();
|
||||
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 tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
|
||||
@@ -218,6 +222,9 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
|
||||
<div className="titlebar-spacer" />
|
||||
|
||||
{auth && ViewSelector &&
|
||||
<ViewSelector settings={settings} auth={auth} projection={projection} />
|
||||
}
|
||||
<LanguageMenu />
|
||||
<HelpMenu auth={auth} />
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import type { PlatformWebModule } from "../types";
|
||||
import { moduleInstalled, uiCapabilities, uiCapability } from "./modules";
|
||||
import { useEffectiveView, useViewSurfaces } from "./ViewContext";
|
||||
import { isViewSurfaceVisible, moduleViewSurfaceId } from "./views";
|
||||
|
||||
const PlatformModulesContext = createContext<PlatformWebModule[]>([]);
|
||||
|
||||
@@ -17,9 +19,22 @@ export function usePlatformModuleInstalled(moduleId: string): boolean {
|
||||
}
|
||||
|
||||
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[] {
|
||||
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 { routeViewSurfaceId } from "./views";
|
||||
|
||||
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
|
||||
for (const module of modules) {
|
||||
@@ -30,7 +31,12 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
|
||||
}
|
||||
|
||||
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[] {
|
||||
|
||||
@@ -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 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 {
|
||||
hasUiCapability as hasUiCapabilityForModules,
|
||||
moduleInstalled as moduleInstalledForModules,
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
uiCapability as uiCapabilityForModules } from
|
||||
"./moduleLogic";
|
||||
import { hasAnyScope, hasScope } from "../utils/permissions";
|
||||
import {
|
||||
isViewSurfaceVisible,
|
||||
navigationViewSurfaceId,
|
||||
routeViewSurfaceId,
|
||||
viewSurfaceCatalogueForModules
|
||||
} from "./views";
|
||||
|
||||
export const fallbackDashboardNavItem: PlatformNavItem =
|
||||
{ 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,
|
||||
allOf: item.required_all,
|
||||
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) {
|
||||
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,
|
||||
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
|
||||
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
||||
routes: routesWithServerMetadata(module, info),
|
||||
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
||||
viewSurfaces: mergeViewSurfaces(module, info),
|
||||
uiCapabilities: {
|
||||
...(module.uiCapabilities ?? {}),
|
||||
...runtimeUiCapabilitiesForModule(module, info)
|
||||
@@ -353,26 +416,42 @@ export function publicRouteContributionsForModules(modules: PlatformWebModule[])
|
||||
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).
|
||||
flatMap((capability) => capability.widgets ?? []).
|
||||
filter((widget) => isViewSurfaceVisible(projection, widget.surfaceId, catalogue)).
|
||||
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
|
||||
return [...shellNavItemsForModules(modules), ...modules.flatMap((module) => module.navItems ?? [])].
|
||||
export function navItemsForModules(
|
||||
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).
|
||||
filter((item) => isViewSurfaceVisible(projection, item.surfaceId, catalogue)).
|
||||
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||
}
|
||||
|
||||
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules): PlatformNavItem[] {
|
||||
return navItemsForModules(modules).filter((item) => {
|
||||
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): PlatformNavItem[] {
|
||||
return navItemsForModules(modules, projection).filter((item) => {
|
||||
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
|
||||
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules): string {
|
||||
return visibleNavItems(auth, modules)[0]?.to ?? "/dashboard";
|
||||
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): string {
|
||||
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 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 = {
|
||||
to: string;
|
||||
label: string;
|
||||
@@ -237,6 +257,7 @@ export type PlatformNavItem = {
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
order?: number;
|
||||
surfaceId?: string;
|
||||
};
|
||||
|
||||
export type PlatformRouteContext = {
|
||||
@@ -266,6 +287,7 @@ export type AdminSectionContribution = {
|
||||
order?: number;
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
surfaceId?: string;
|
||||
render: (context: AdminSectionRenderContext) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -289,6 +311,7 @@ export type SettingsSectionContribution = {
|
||||
order?: number;
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
surfaceId?: string;
|
||||
render: (context: SettingsSectionRenderContext) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -301,6 +324,7 @@ export type PlatformRouteContribution = {
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
order?: number;
|
||||
surfaceId?: string;
|
||||
render: (context: PlatformRouteContext) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -333,6 +357,53 @@ export type PlatformWebModule = {
|
||||
translations?: PlatformTranslations;
|
||||
uiCapabilities?: 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";
|
||||
@@ -353,6 +424,7 @@ export type DashboardWidgetContribution = {
|
||||
moduleId?: string;
|
||||
category?: string;
|
||||
order?: number;
|
||||
surfaceId?: string;
|
||||
defaultVisible?: boolean;
|
||||
defaultSize?: DashboardWidgetSize;
|
||||
supportedSizes?: DashboardWidgetSize[];
|
||||
@@ -383,6 +455,7 @@ export type OrganizationFunctionActionContribution = {
|
||||
order?: number;
|
||||
anyOf?: string[];
|
||||
allOf?: string[];
|
||||
surfaceId?: string;
|
||||
onClick: (context: OrganizationFunctionActionContext) => void;
|
||||
};
|
||||
|
||||
@@ -800,6 +873,7 @@ export type PlatformFrontendRouteInfo = {
|
||||
required_all: string[];
|
||||
required_any: string[];
|
||||
order: number;
|
||||
surface_id?: string | null;
|
||||
};
|
||||
|
||||
export type PlatformFrontendModuleInfo = {
|
||||
@@ -824,8 +898,21 @@ export type PlatformFrontendModuleInfo = {
|
||||
required_all: string[];
|
||||
required_any: string[];
|
||||
order: number;
|
||||
surface_id?: string | null;
|
||||
}>;
|
||||
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 = {
|
||||
@@ -861,6 +948,7 @@ export type PlatformModuleInfo = {
|
||||
required_all: string[];
|
||||
required_any: string[];
|
||||
order: number;
|
||||
surface_id?: string | null;
|
||||
}>;
|
||||
frontend?: PlatformFrontendModuleInfo | null;
|
||||
};
|
||||
|
||||
@@ -163,5 +163,9 @@ export const adminReadScopes = [
|
||||
"access:system_setting:read",
|
||||
"access:system_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,
|
||||
uiCapability
|
||||
} from "../src/platform/moduleLogic";
|
||||
import {
|
||||
isViewSurfaceVisible,
|
||||
viewSurfaceCatalogueForModules,
|
||||
visibleRoutesForProjection
|
||||
} from "../src/platform/views";
|
||||
import { scopeGrants } from "../src/utils/permissions";
|
||||
|
||||
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(!("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/policy-webui",
|
||||
"@govoplan/scheduling-webui",
|
||||
"@govoplan/views-webui",
|
||||
"@govoplan/workflow-webui"
|
||||
];
|
||||
|
||||
@@ -125,6 +126,7 @@ export default defineConfig({
|
||||
fileURLToPath(new URL('../../govoplan-ops/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-views/webui', import.meta.url)),
|
||||
fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user