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