From ce9ef8d88f54607bd2cd5e52b82de51f6010212d Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 28 Jul 2026 21:04:54 +0200 Subject: [PATCH] Add governed View surface runtime --- README.md | 2 +- src/govoplan_core/core/modules.py | 5 + src/govoplan_core/core/registry.py | 143 ++++++++++ src/govoplan_core/core/views.py | 94 +++++++ src/govoplan_core/server/platform.py | 60 +++- src/govoplan_core/settings.py | 2 +- tests/test_view_surfaces.py | 260 ++++++++++++++++++ webui/package-lock.json | 21 ++ webui/package.json | 1 + webui/scripts/test-module-permutations.mjs | 5 +- webui/src/App.tsx | 68 ++++- .../components/ViewSurfaceRouteBoundary.tsx | 58 ++++ webui/src/features/settings/SettingsPage.tsx | 20 +- webui/src/index.ts | 3 + webui/src/layout/Titlebar.tsx | 11 +- webui/src/platform/ModuleContext.tsx | 19 +- webui/src/platform/ViewContext.tsx | 73 +++++ webui/src/platform/moduleLogic.ts | 8 +- webui/src/platform/modules.ts | 97 ++++++- webui/src/platform/views.ts | 133 +++++++++ webui/src/types.ts | 88 ++++++ webui/src/utils/permissions.ts | 6 +- webui/tests/module-capabilities.test.ts | 75 +++++ webui/vite.config.ts | 2 + 24 files changed, 1215 insertions(+), 39 deletions(-) create mode 100644 src/govoplan_core/core/views.py create mode 100644 tests/test_view_surfaces.py create mode 100644 webui/src/components/ViewSurfaceRouteBoundary.tsx create mode 100644 webui/src/platform/ViewContext.tsx create mode 100644 webui/src/platform/views.ts diff --git a/README.md b/README.md index d69640d..c4e572c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/govoplan_core/core/modules.py b/src/govoplan_core/core/modules.py index d243d3b..d51f0ff 100644 --- a/src/govoplan_core/core/modules.py +++ b/src/govoplan_core/core/modules.py @@ -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) diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py index 2339344..5dcb98b 100644 --- a/src/govoplan_core/core/registry.py +++ b/src/govoplan_core/core/registry.py @@ -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: diff --git a/src/govoplan_core/core/views.py b/src/govoplan_core/core/views.py new file mode 100644 index 0000000..1beb71d --- /dev/null +++ b/src/govoplan_core/core/views.py @@ -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", +] diff --git a/src/govoplan_core/server/platform.py b/src/govoplan_core/server/platform.py index 1883767..e407eaa 100644 --- a/src/govoplan_core/server/platform.py +++ b/src/govoplan_core/server/platform.py @@ -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() ] diff --git a/src/govoplan_core/settings.py b/src/govoplan_core/settings.py index 47095ce..b2dee97 100644 --- a/src/govoplan_core/settings.py +++ b/src/govoplan_core/settings.py @@ -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") diff --git a/tests/test_view_surfaces.py b/tests/test_view_surfaces.py new file mode 100644 index 0000000..ae7764c --- /dev/null +++ b/tests/test_view_surfaces.py @@ -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() diff --git a/webui/package-lock.json b/webui/package-lock.json index 88e8f4f..694793f 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -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 diff --git a/webui/package.json b/webui/package.json index 2ce3e56..15fd266 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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": { diff --git a/webui/scripts/test-module-permutations.mjs b/webui/scripts/test-module-permutations.mjs index edbe0f3..81838d0 100644 --- a/webui/scripts/test-module-permutations.mjs +++ b/webui/scripts/test-module-permutations.mjs @@ -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; diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 50f70a1..ed84b61 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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(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("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 ( +
@@ -322,6 +367,7 @@ export default function App() {
+
); @@ -331,6 +377,7 @@ export default function App() { return ( +

i18n:govoplan-core.loading_module.50161f3c

}> @@ -347,12 +394,13 @@ export default function App() {
+
); } - 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}> +

i18n:govoplan-core.loading_module.50161f3c

}> @@ -392,8 +441,14 @@ export default function App() { path={route.path} element={ - {route.render({ settings, auth, onAuthChange: updateAuth })} - + + {route.render({ settings, auth, onAuthChange: updateAuth })} + + } /> )} @@ -412,6 +467,7 @@ export default function App() { }
+
); diff --git a/webui/src/components/ViewSurfaceRouteBoundary.tsx b/webui/src/components/ViewSurfaceRouteBoundary.tsx new file mode 100644 index 0000000..43a0588 --- /dev/null +++ b/webui/src/components/ViewSurfaceRouteBoundary.tsx @@ -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 ( +
+ +
+
+
+
+ ); +} diff --git a/webui/src/features/settings/SettingsPage.tsx b/webui/src/features/settings/SettingsPage.tsx index de01aed..6ee8839 100644 --- a/webui/src/features/settings/SettingsPage.tsx +++ b/webui/src/features/settings/SettingsPage.tsx @@ -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("mail.profiles"); const fileConnectorsUi = usePlatformUiCapability("files.connectors"); const settingsSectionCapabilities = usePlatformUiCapabilities("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]); diff --git a/webui/src/index.ts b/webui/src/index.ts index 1dac630..3839f06 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -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"; diff --git a/webui/src/layout/Titlebar.tsx b/webui/src/layout/Titlebar.tsx index 7fac9d9..377ba7e 100644 --- a/webui/src/layout/Titlebar.tsx +++ b/webui/src/layout/Titlebar.tsx @@ -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(null); const { translateText } = usePlatformLanguage(); const modules = usePlatformModules(); + const projection = useEffectiveView(); + const viewsRuntime = usePlatformUiCapability("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
+ {auth && ViewSelector && + + } diff --git a/webui/src/platform/ModuleContext.tsx b/webui/src/platform/ModuleContext.tsx index da5a05f..f25c0e1 100644 --- a/webui/src/platform/ModuleContext.tsx +++ b/webui/src/platform/ModuleContext.tsx @@ -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([]); @@ -17,9 +19,22 @@ export function usePlatformModuleInstalled(moduleId: string): boolean { } export function usePlatformUiCapability(capabilityName: string): T | null { - return uiCapability(capabilityName, usePlatformModules()); + return uiCapability(capabilityName, useViewVisibleModules()); } export function usePlatformUiCapabilities(capabilityName: string): T[] { - return uiCapabilities(capabilityName, usePlatformModules()); + return uiCapabilities(capabilityName, useViewVisibleModules()); +} + +function useViewVisibleModules(): PlatformWebModule[] { + const modules = usePlatformModules(); + const projection = useEffectiveView(); + const surfaces = useViewSurfaces(); + return modules.filter((module) => + isViewSurfaceVisible( + projection, + moduleViewSurfaceId(module.id), + surfaces + ) + ); } diff --git a/webui/src/platform/ViewContext.tsx b/webui/src/platform/ViewContext.tsx new file mode 100644 index 0000000..1a47363 --- /dev/null +++ b/webui/src/platform/ViewContext.tsx @@ -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({ + 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( + () => ({ + projection, + surfaces, + isVisible: (surfaceId) => + isViewSurfaceVisible(projection, surfaceId, surfaces) + }), + [projection, surfaces] + ); + return {children}; +} + +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}; +} diff --git a/webui/src/platform/moduleLogic.ts b/webui/src/platform/moduleLogic.ts index 06ca646..13d6669 100644 --- a/webui/src/platform/moduleLogic.ts +++ b/webui/src/platform/moduleLogic.ts @@ -1,4 +1,5 @@ import type { PlatformPublicRouteContribution, PlatformRouteContribution, PlatformWebModule } from "../types"; +import { routeViewSurfaceId } from "./views"; export function uiCapability(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[] { diff --git a/webui/src/platform/modules.ts b/webui/src/platform/modules.ts index f674b5a..cd20342 100644 --- a/webui/src/platform/modules.ts +++ b/webui/src/platform/modules.ts @@ -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["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(); + 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("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"; } diff --git a/webui/src/platform/views.ts b/webui/src/platform/views.ts new file mode 100644 index 0000000..bdf69b1 --- /dev/null +++ b/webui/src/platform/views.ts @@ -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(); + 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(); + 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, + 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"; +} diff --git a/webui/src/types.ts b/webui/src/types.ts index 3f550ec..3290cfe 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -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; + activateView: ( + settings: ApiSettings, + viewId: string | null + ) => Promise; + Selector?: ComponentType; }; 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; }; diff --git a/webui/src/utils/permissions.ts b/webui/src/utils/permissions.ts index cce0733..d19b5f5 100644 --- a/webui/src/utils/permissions.ts +++ b/webui/src/utils/permissions.ts @@ -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" ]; diff --git a/webui/tests/module-capabilities.test.ts b/webui/tests/module-capabilities.test.ts index 21c70d2..ddb533a 100644 --- a/webui/tests/module-capabilities.test.ts +++ b/webui/tests/module-capabilities.test.ts @@ -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" +); diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 5f310c1..0839f95 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -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)) ] },