Add governed View surface runtime

This commit is contained in:
2026-07-28 21:04:54 +02:00
parent 13bc3d3b4e
commit ce9ef8d88f
24 changed files with 1215 additions and 39 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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:

View 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",
]

View File

@@ -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()
] ]

View File

@@ -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
View 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()

View File

@@ -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

View File

@@ -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": {

View File

@@ -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;

View File

@@ -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,7 +441,13 @@ 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}>
<ViewSurfaceRouteBoundary
surfaceId={route.surfaceId}
settings={settings}
runtime={viewsRuntime}
fallbackPath={defaultRoute}>
{route.render({ settings, auth, onAuthChange: updateAuth })} {route.render({ settings, auth, onAuthChange: updateAuth })}
</ViewSurfaceRouteBoundary>
</PermissionBoundary> </PermissionBoundary>
} /> } />
@@ -412,6 +467,7 @@ export default function App() {
} }
</AppShell> </AppShell>
</UnsavedChangesProvider> </UnsavedChangesProvider>
</PlatformViewProvider>
</PlatformModulesProvider> </PlatformModulesProvider>
</PlatformLanguageProvider>); </PlatformLanguageProvider>);

View 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>
);
}

View File

@@ -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]);

View File

@@ -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";

View File

@@ -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} />

View File

@@ -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
)
);
} }

View 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}</>;
}

View File

@@ -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[] {

View File

@@ -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
View 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";
}

View File

@@ -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;
}; };

View File

@@ -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"
]; ];

View File

@@ -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"
);

View File

@@ -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))
] ]
}, },