diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md index d90d979..aa9f7ca 100644 --- a/docs/MODULE_ARCHITECTURE.md +++ b/docs/MODULE_ARCHITECTURE.md @@ -1030,6 +1030,32 @@ Any future exception is extraction debt and must be temporary, documented in the script with a reason, and removed when a capability/API/event contract replaces it. +## Product Surface Contributions + +`FrontendModule.product_surfaces` is the versioned product-composition contract +for stable identities that may have one or more technical owners. A contribution +declares contract version 1, a product identity, common label/icon/description, +stable entry path, owner route and View surfaces, supported task/reader/admin/ +operator presentations, authorization requirements, capabilities, search +sources, help contexts, documentation topics, migration aliases, and standard +unavailable/degraded explanations. + +Core validates every reference against the owning manifest. Contributors that +share an identity must agree on its common product metadata and entry path; +entry and alias paths cannot belong to another product identity. The WebUI +composes valid owners by product id, filters them through authorization and the +effective View, and resolves the stable entry or migration alias to the first +available owner route. It emits `govoplan:product-surface-route-resolved` before +the redirect so migration telemetry can observe alias use without making the +technical module part of the ordinary label. + +Use `ProductAvailabilityState` for unavailable and degraded outcomes. The +ordinary state explains the attempted outcome, consequence, recovery path and +responsible role. Exact module, capability, provider and correlation values may +be supplied as a collapsed technical detail; they are not the primary error. +The state is presentation only and never grants authority or changes provider +health. + ## Boundary Decision Register These durable decisions close older exploratory core issues. Implementation diff --git a/pyproject.toml b/pyproject.toml index 7dcc680..c00f520 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-core" -version = "0.1.39" +version = "0.1.40" description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_core/core/modules.py b/src/govoplan_core/core/modules.py index 5583ee4..954195d 100644 --- a/src/govoplan_core/core/modules.py +++ b/src/govoplan_core/core/modules.py @@ -30,6 +30,7 @@ if TYPE_CHECKING: SUPPORTED_MANIFEST_CONTRACT_VERSION = "1" SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1" SUPPORTED_PRESENTATION_CONTRACT_VERSION = "1" +SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION = "1" PermissionLevel = Literal["system", "tenant"] SubjectType = Literal["account", "membership", "group", "service_account", "tenant"] @@ -114,6 +115,55 @@ class ProductAreaContribution: order: int = 100 +ProductSurfacePresentation = Literal["task", "reader", "admin", "operator"] +ProductAvailabilityReason = Literal[ + "authorization", + "policy", + "configuration", + "disabled", + "capability", + "offline", + "provider_degraded", +] + + +@dataclass(frozen=True, slots=True) +class ProductAvailabilityExplanation: + """Explain a product outcome without making package topology user-facing.""" + + reason: ProductAvailabilityReason + title: str + description: str + resolution: str + responsible_role: str | None = None + + +@dataclass(frozen=True, slots=True) +class ProductSurfaceContribution: + """Bind an owner route to a stable, cross-module product identity.""" + + id: str + module_id: str + label: str + icon: str + entry_path: str + route_path: str + surface_ids: tuple[str, ...] + unavailable: ProductAvailabilityExplanation + description: str | None = None + degraded: ProductAvailabilityExplanation | None = None + presentations: tuple[ProductSurfacePresentation, ...] = ("task",) + capability_ids: tuple[str, ...] = () + search_source_ids: tuple[str, ...] = () + help_context_ids: tuple[str, ...] = () + documentation_topic_ids: tuple[str, ...] = () + required_all: tuple[str, ...] = () + required_any: tuple[str, ...] = () + aliases: tuple[str, ...] = () + order: int = 100 + contract_version: str = SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION + + @dataclass(frozen=True, slots=True) class QuickAccessTool: """Declare a versioned, bounded module-owned Quick Access tool.""" @@ -153,6 +203,7 @@ class FrontendModule: settings_routes: tuple[FrontendRoute, ...] = () view_surfaces: tuple[ViewSurface, ...] = () product_areas: tuple[ProductAreaContribution, ...] = () + product_surfaces: tuple[ProductSurfaceContribution, ...] = () quick_access_tools: tuple[QuickAccessTool, ...] = () diff --git a/src/govoplan_core/core/platform_interfaces.py b/src/govoplan_core/core/platform_interfaces.py index 90c53d6..8641e82 100644 --- a/src/govoplan_core/core/platform_interfaces.py +++ b/src/govoplan_core/core/platform_interfaces.py @@ -22,6 +22,7 @@ PlatformInterfaceKind = Literal[ "navigation", "permission", "product_area", + "product_surface", "provided_interface", "public_route", "search_provider", @@ -237,6 +238,41 @@ def manifest_interface_declarations( }, ) ) + for surface in frontend.product_surfaces: + declarations.append( + PlatformInterfaceDeclaration( + id=f"{manifest.id}.{surface.id}", + module_id=manifest.id, + kind="product_surface", + label=surface.label, + path=surface.route_path, + required_all=surface.required_all, + required_any=surface.required_any, + metadata={ + "contract_version": surface.contract_version, + "product_surface_id": surface.id, + "description": surface.description, + "icon": surface.icon, + "entry_path": surface.entry_path, + "surface_ids": list(surface.surface_ids), + "presentations": list(surface.presentations), + "capability_ids": list(surface.capability_ids), + "search_source_ids": list(surface.search_source_ids), + "help_context_ids": list(surface.help_context_ids), + "documentation_topic_ids": list( + surface.documentation_topic_ids + ), + "aliases": list(surface.aliases), + "order": surface.order, + "unavailable_reason": surface.unavailable.reason, + "degraded_reason": ( + surface.degraded.reason + if surface.degraded is not None + else None + ), + }, + ) + ) for tool in frontend.quick_access_tools: declarations.append( PlatformInterfaceDeclaration( diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py index 314b69a..67b5856 100644 --- a/src/govoplan_core/core/registry.py +++ b/src/govoplan_core/core/registry.py @@ -16,13 +16,16 @@ from govoplan_core.core.modules import ( ModuleManifest, NavItem, PermissionDefinition, + ProductAvailabilityExplanation, ProductAreaContribution, + ProductSurfaceContribution, PublicFrontendRoute, QuickAccessTool, ResourceAclProvider, RoleTemplate, SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION, SUPPORTED_MANIFEST_CONTRACT_VERSION, + SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION, TenantSummaryBatchProvider, TenantSummaryProvider, user_workflow_scope_condition_issues, @@ -90,6 +93,9 @@ _WILDCARD_RE = re.compile( ) _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}$") +_PRODUCT_SURFACE_ID_RE = re.compile( + r"^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$" +) _QUICK_ACCESS_TOOL_ID_RE = re.compile( r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_-]*)+$" ) @@ -974,7 +980,19 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None: def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> None: area_definitions: dict[str, tuple[str, str]] = {} + surface_definitions: dict[str, tuple[str, str, str, str | None]] = {} + product_paths: dict[str, str] = {} tool_owners: dict[str, str] = {} + concrete_paths = { + route.path: manifest.id + for manifest in manifests + if manifest.frontend is not None + for route in ( + *manifest.frontend.routes, + *manifest.frontend.settings_routes, + *manifest.frontend.public_routes, + ) + } for manifest in manifests: frontend = manifest.frontend if frontend is None: @@ -987,6 +1005,33 @@ def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> Non f"Product area {area.id!r} has conflicting labels or icons" ) area_definitions[area.id] = definition + for surface in frontend.product_surfaces: + definition = ( + surface.label, + surface.icon, + surface.entry_path, + surface.description, + ) + previous = surface_definitions.get(surface.id) + if previous is not None and previous != definition: + raise RegistryError( + f"Product surface {surface.id!r} has conflicting product identity metadata" + ) + surface_definitions[surface.id] = definition + for path in (surface.entry_path, *surface.aliases): + concrete_owner = concrete_paths.get(path) + if concrete_owner is not None: + raise RegistryError( + f"Product path {path!r} collides with a concrete route " + f"owned by module {concrete_owner!r}" + ) + previous_id = product_paths.get(path) + if previous_id is not None and previous_id != surface.id: + raise RegistryError( + f"Product path {path!r} is shared by product surfaces " + f"{previous_id!r} and {surface.id!r}" + ) + product_paths[path] = surface.id for tool in frontend.quick_access_tools: previous_owner = tool_owners.get(tool.id) if previous_owner is not None: @@ -1474,6 +1519,14 @@ def _validate_presentation_contributions(manifest: ModuleManifest) -> None: f"in module {manifest.id!r}" ) seen_area_memberships.add(membership) + seen_product_surfaces: set[str] = set() + for surface in frontend.product_surfaces: + _validate_product_surface(manifest, surface, known_surface_ids) + if surface.id in seen_product_surfaces: + raise RegistryError( + f"Duplicate product surface {surface.id!r} in module {manifest.id!r}" + ) + seen_product_surfaces.add(surface.id) seen_tools: set[str] = set() for tool in frontend.quick_access_tools: _validate_quick_access_tool(manifest.id, tool, known_surface_ids) @@ -1512,6 +1565,151 @@ def _validate_product_area( ) +def _validate_product_surface( + manifest: ModuleManifest, + surface: ProductSurfaceContribution, + known_surface_ids: set[str], +) -> None: + module_id = manifest.id + frontend = manifest.frontend + assert frontend is not None + if surface.module_id != module_id: + raise RegistryError( + f"Product surface {surface.id!r} belongs to {surface.module_id!r}, " + f"not module {module_id!r}" + ) + if not _PRODUCT_SURFACE_ID_RE.fullmatch(surface.id): + raise RegistryError(f"Invalid product surface id: {surface.id!r}") + if surface.contract_version != SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION: + raise RegistryError( + f"Product surface {surface.id!r} uses unsupported contract version " + f"{surface.contract_version!r}" + ) + if not surface.label.strip() or not surface.icon.strip(): + raise RegistryError( + f"Product surface {surface.id!r} needs a label and icon" + ) + for label, path in ( + ("entry", surface.entry_path), + ("owner", surface.route_path), + *(("alias", alias) for alias in surface.aliases), + ): + if not path.startswith("/") or "?" in path or "#" in path: + raise RegistryError( + f"Product surface {surface.id!r} has an invalid {label} path {path!r}" + ) + if ( + surface.entry_path == surface.route_path + or surface.entry_path in surface.aliases + or surface.route_path in surface.aliases + ): + raise RegistryError( + f"Product surface {surface.id!r} must keep its stable entry distinct from owner and alias paths" + ) + if len(set(surface.aliases)) != len(surface.aliases): + raise RegistryError( + f"Product surface {surface.id!r} contains duplicate aliases" + ) + route_paths = {route.path for route in (*frontend.routes, *frontend.settings_routes)} + if surface.route_path not in route_paths: + raise RegistryError( + f"Product surface {surface.id!r} references unknown owner route " + f"{surface.route_path!r}" + ) + if not surface.surface_ids: + raise RegistryError( + f"Product surface {surface.id!r} has no owner surfaces" + ) + unknown_surfaces = set(surface.surface_ids) - known_surface_ids + if unknown_surfaces: + raise RegistryError( + f"Product surface {surface.id!r} references unknown surfaces: " + + ", ".join(sorted(unknown_surfaces)) + ) + allowed_presentations = {"task", "reader", "admin", "operator"} + if ( + not surface.presentations + or len(set(surface.presentations)) != len(surface.presentations) + or set(surface.presentations) - allowed_presentations + ): + raise RegistryError( + f"Product surface {surface.id!r} has invalid presentations" + ) + declared_capabilities = { + *manifest.required_capabilities, + *manifest.optional_capabilities, + *manifest.capability_factories, + *(provider.name for provider in manifest.provides_interfaces), + *(requirement.name for requirement in manifest.requires_interfaces), + } + unknown_capabilities = set(surface.capability_ids) - declared_capabilities + if unknown_capabilities: + raise RegistryError( + f"Product surface {surface.id!r} references undeclared capabilities: " + + ", ".join(sorted(unknown_capabilities)) + ) + search_source_ids = {source.id for source in manifest.search_sources} + unknown_search_sources = set(surface.search_source_ids) - search_source_ids + if unknown_search_sources: + raise RegistryError( + f"Product surface {surface.id!r} references unknown search sources: " + + ", ".join(sorted(unknown_search_sources)) + ) + topics = {topic.id: topic for topic in manifest.documentation} + unknown_topics = set(surface.documentation_topic_ids) - set(topics) + if unknown_topics: + raise RegistryError( + f"Product surface {surface.id!r} references unknown documentation topics: " + + ", ".join(sorted(unknown_topics)) + ) + documented_help_contexts: set[str] = set() + for topic in manifest.documentation: + contexts = topic.metadata.get("help_contexts", ()) + if isinstance(contexts, (list, tuple, set, frozenset)): + documented_help_contexts.update( + context for context in contexts if isinstance(context, str) + ) + unknown_help = set(surface.help_context_ids) - documented_help_contexts + if unknown_help: + raise RegistryError( + f"Product surface {surface.id!r} references undocumented help contexts: " + + ", ".join(sorted(unknown_help)) + ) + _validate_product_availability_explanation(surface.id, surface.unavailable) + if surface.degraded is not None: + _validate_product_availability_explanation(surface.id, surface.degraded) + + +def _validate_product_availability_explanation( + surface_id: str, + explanation: ProductAvailabilityExplanation, +) -> None: + allowed_reasons = { + "authorization", + "policy", + "configuration", + "disabled", + "capability", + "offline", + "provider_degraded", + } + if explanation.reason not in allowed_reasons: + raise RegistryError( + f"Product surface {surface_id!r} has an invalid availability reason" + ) + if any( + not value.strip() + for value in ( + explanation.title, + explanation.description, + explanation.resolution, + ) + ): + raise RegistryError( + f"Product surface {surface_id!r} has an incomplete availability explanation" + ) + + def _validate_quick_access_tool( module_id: str, tool: QuickAccessTool, diff --git a/src/govoplan_core/server/platform.py b/src/govoplan_core/server/platform.py index 7a7e009..965557f 100644 --- a/src/govoplan_core/server/platform.py +++ b/src/govoplan_core/server/platform.py @@ -22,7 +22,9 @@ from govoplan_core.core.modules import ( FrontendRoute, ModuleManifest, NavItem, + ProductAvailabilityExplanation, ProductAreaContribution, + ProductSurfaceContribution, PublicFrontendRoute, QuickAccessTool, SUPPORTED_PRESENTATION_CONTRACT_VERSION, @@ -254,6 +256,47 @@ def _product_area_payload(area: ProductAreaContribution) -> dict[str, object]: } +def _product_availability_payload( + explanation: ProductAvailabilityExplanation, +) -> dict[str, object]: + return { + "reason": explanation.reason, + "title": explanation.title, + "description": explanation.description, + "resolution": explanation.resolution, + "responsible_role": explanation.responsible_role, + } + + +def _product_surface_payload(surface: ProductSurfaceContribution) -> dict[str, object]: + return { + "contract_version": surface.contract_version, + "id": surface.id, + "module_id": surface.module_id, + "label": surface.label, + "description": surface.description, + "icon": surface.icon, + "entry_path": surface.entry_path, + "route_path": surface.route_path, + "surface_ids": list(surface.surface_ids), + "presentations": list(surface.presentations), + "capability_ids": list(surface.capability_ids), + "search_source_ids": list(surface.search_source_ids), + "help_context_ids": list(surface.help_context_ids), + "documentation_topic_ids": list(surface.documentation_topic_ids), + "required_all": list(surface.required_all), + "required_any": list(surface.required_any), + "aliases": list(surface.aliases), + "order": surface.order, + "unavailable": _product_availability_payload(surface.unavailable), + "degraded": ( + _product_availability_payload(surface.degraded) + if surface.degraded is not None + else None + ), + } + + def _quick_access_tool_payload(tool: QuickAccessTool) -> dict[str, object]: return { "id": tool.id, @@ -372,6 +415,10 @@ def _frontend_payload( "product_areas": [ _product_area_payload(area) for area in frontend.product_areas ], + "product_surfaces": [ + _product_surface_payload(surface) + for surface in frontend.product_surfaces + ], "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 index 903685b..420538d 100644 --- a/tests/test_presentation_contract.py +++ b/tests/test_presentation_contract.py @@ -1,6 +1,7 @@ from __future__ import annotations import unittest +from dataclasses import replace from fastapi import FastAPI from fastapi.testclient import TestClient @@ -10,7 +11,9 @@ from govoplan_core.core.modules import ( FrontendModule, FrontendRoute, ModuleManifest, + ProductAvailabilityExplanation, ProductAreaContribution, + ProductSurfaceContribution, QuickAccessTool, ) from govoplan_core.core.registry import PlatformRegistry, RegistryError @@ -49,6 +52,26 @@ def presentation_manifest() -> ModuleManifest: surface_ids=("example.route.main",), ), ), + product_surfaces=( + ProductSurfaceContribution( + id="work.examples", + module_id="example", + label="Examples", + description="Review and update governed examples.", + icon="list-checks", + entry_path="/work/examples", + route_path="/example", + surface_ids=("example.route.main",), + presentations=("task", "reader"), + unavailable=ProductAvailabilityExplanation( + reason="authorization", + title="Examples are unavailable", + description="Your current responsibility does not include examples.", + resolution="Ask the responsible administrator to review your assignment.", + responsible_role="Access administrator", + ), + ), + ), quick_access_tools=( QuickAccessTool( id="example.summary", @@ -121,6 +144,16 @@ class PresentationContractTests(unittest.TestCase): frontend = response.json()["modules"][0]["frontend"] self.assertEqual("1", frontend["presentation_contract_version"]) self.assertEqual("work", frontend["product_areas"][0]["id"]) + product_surface = frontend["product_surfaces"][0] + self.assertEqual("1", product_surface["contract_version"]) + self.assertEqual("work.examples", product_surface["id"]) + self.assertEqual("/work/examples", product_surface["entry_path"]) + self.assertEqual("/example", product_surface["route_path"]) + self.assertEqual(["task", "reader"], product_surface["presentations"]) + self.assertEqual( + "authorization", + product_surface["unavailable"]["reason"], + ) self.assertEqual("example.summary", frontend["quick_access_tools"][0]["id"]) self.assertEqual("1", frontend["quick_access_tools"][0]["contract_version"]) self.assertEqual( @@ -132,6 +165,61 @@ class PresentationContractTests(unittest.TestCase): frontend["quick_access_tools"][0]["help_context_id"], ) + def test_registry_rejects_product_surface_without_owner_route(self) -> None: + manifest = presentation_manifest() + frontend = manifest.frontend + assert frontend is not None + surface = frontend.product_surfaces[0] + invalid = ModuleManifest( + id=manifest.id, + name=manifest.name, + version=manifest.version, + frontend=FrontendModule( + module_id=manifest.id, + routes=frontend.routes, + product_surfaces=( + ProductSurfaceContribution( + id=surface.id, + module_id=surface.module_id, + label=surface.label, + description=surface.description, + icon=surface.icon, + entry_path=surface.entry_path, + route_path="/missing", + surface_ids=surface.surface_ids, + unavailable=surface.unavailable, + ), + ), + ), + ) + registry = PlatformRegistry() + registry.register(invalid) + + with self.assertRaisesRegex(RegistryError, "unknown owner route"): + registry.validate() + + def test_registry_rejects_product_alias_that_shadows_a_route(self) -> None: + manifest = presentation_manifest() + frontend = manifest.frontend + assert frontend is not None + surface = frontend.product_surfaces[0] + invalid = replace( + manifest, + frontend=replace( + frontend, + routes=( + *frontend.routes, + FrontendRoute(path="/shortcut", component="ShortcutPage"), + ), + product_surfaces=(replace(surface, aliases=("/shortcut",)),), + ), + ) + registry = PlatformRegistry() + registry.register(invalid) + + with self.assertRaisesRegex(RegistryError, "collides with a concrete route"): + registry.validate() + if __name__ == "__main__": unittest.main() diff --git a/webui/package-lock.json b/webui/package-lock.json index df68b3a..f839c7e 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@govoplan/core-webui", - "version": "0.1.39", + "version": "0.1.40", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.39", + "version": "0.1.40", "dependencies": { "@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", diff --git a/webui/package-lock.release.json b/webui/package-lock.release.json index 47278da..f70c61d 100644 --- a/webui/package-lock.release.json +++ b/webui/package-lock.release.json @@ -1,12 +1,12 @@ { "name": "@govoplan/core-webui", - "version": "0.1.39", + "version": "0.1.40", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.39", + "version": "0.1.40", "dependencies": { "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.23", "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22", @@ -19,7 +19,7 @@ "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.23", "@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.20", "@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.24", - "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.24", + "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.25", "@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.18", "@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.20", "@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.18", @@ -948,8 +948,8 @@ } }, "node_modules/@govoplan/mail-webui": { - "version": "0.1.24", - "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#48205e6e461f5f93d88380df8e23db54fdb719f7", + "version": "0.1.25", + "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#ecc1283de76f154919c786cb84354afb8f2299c1", "peerDependencies": { "@govoplan/core-webui": "^0.1.18", "lucide-react": "^1.23.0", diff --git a/webui/package.json b/webui/package.json index cf40a80..eee3746 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.39", + "version": "0.1.40", "private": true, "type": "module", "main": "src/index.ts", @@ -42,7 +42,7 @@ "test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs", "test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js", "test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js", - "test:layout-primitives": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/layout-primitives.test.js", + "test:layout-primitives": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && printf 'module.exports = {};\\n' > .component-test-build/src/components/ProductAvailabilityState.css && node .component-test-build/tests/layout-primitives.test.js", "test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js && node .module-test-build/tests/launch-context.test.js && node .module-test-build/tests/definition-graph.test.js", "test:module-permutations": "node scripts/test-module-permutations.mjs", "test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js", diff --git a/webui/package.release.json b/webui/package.release.json index 5dec2dc..c7b4d3f 100644 --- a/webui/package.release.json +++ b/webui/package.release.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.39", + "version": "0.1.40", "private": true, "type": "module", "main": "src/index.ts", @@ -36,7 +36,7 @@ "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.23", "@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.20", "@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.24", - "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.24", + "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.25", "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.27", "@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.20", "@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.18", diff --git a/webui/scripts/audit-i18n-structural.mjs b/webui/scripts/audit-i18n-structural.mjs index b0fb2fa..1d27eec 100644 --- a/webui/scripts/audit-i18n-structural.mjs +++ b/webui/scripts/audit-i18n-structural.mjs @@ -15,12 +15,14 @@ const sourceRoots = fs.readdirSync(workspaceRoot, { withFileTypes: true }) .map((entry) => path.join(workspaceRoot, entry.name, "webui", "src")) .filter((sourceRoot) => fs.existsSync(sourceRoot)); -const generatedCatalogs = sourceRoots - .map((sourceRoot) => path.join(sourceRoot, "i18n", "generatedTranslations.ts")) - .filter((file) => fs.existsSync(file)); +const generatedCatalogs = sourceRoots.flatMap((sourceRoot) => + fs.existsSync(path.join(sourceRoot, "i18n")) + ? rgFiles(path.join(sourceRoot, "i18n")).filter((file) => /Translations\.ts$/.test(file)) + : [] +); function scanStructuralSourcePositions(roots) { - const files = roots.flatMap((root) => rgFiles(root).filter((file) => /\.(tsx?|jsx?)$/.test(file) && !file.endsWith("/i18n/generatedTranslations.ts"))); + const files = roots.flatMap((root) => rgFiles(root).filter((file) => /\.(tsx?|jsx?)$/.test(file) && !/\/i18n\/[^/]*Translations\.ts$/.test(file))); const findings = []; for (const file of files) { const source = ts.createSourceFile(file, fs.readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS); diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 0db2af2..06918ea 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -30,6 +30,7 @@ import { applyAppearanceOverrides } from "./components/AppearanceOverridesEditor const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage")); const SettingsPage = lazy(() => import("./features/settings/SettingsPage")); +const ProductSurfaceRoute = lazy(() => import("./components/ProductSurfaceRoute")); const DEFAULT_UI_PREFERENCES: UserUiPreferences = { compact_tables: false, @@ -579,7 +580,7 @@ export default function App() { )} } /> - } /> + } /> {reloginMessage && diff --git a/webui/src/components/ProductAvailabilityState.css b/webui/src/components/ProductAvailabilityState.css new file mode 100644 index 0000000..be33d62 --- /dev/null +++ b/webui/src/components/ProductAvailabilityState.css @@ -0,0 +1,8 @@ +.product-availability-resolution, +.product-availability-owner { margin: 0; } +.product-availability-technical { width: min(100%, 560px); margin-top: 4px; color: var(--muted); text-align: start; } +.product-availability-technical summary { cursor: pointer; color: var(--text); font-weight: 700; } +.product-availability-technical dl { display: grid; gap: 6px; margin: 10px 0 0; } +.product-availability-technical dl > div { display: grid; grid-template-columns: minmax(110px, .4fr) minmax(0, 1fr); gap: 10px; } +.product-availability-technical dt { color: var(--muted); font-weight: 700; } +.product-availability-technical dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--text); font-family: var(--font-mono, monospace); font-size: 12px; } diff --git a/webui/src/components/ProductAvailabilityState.tsx b/webui/src/components/ProductAvailabilityState.tsx new file mode 100644 index 0000000..d928c51 --- /dev/null +++ b/webui/src/components/ProductAvailabilityState.tsx @@ -0,0 +1,114 @@ +import { CircleOff, TriangleAlert } from "lucide-react"; +import type { ReactNode } from "react"; +import { usePlatformLanguage } from "../i18n/LanguageContext"; +import type { ProductAvailabilityExplanation } from "../types"; +import StatePanel, { type StatePanelProps } from "./StatePanel"; +import "./ProductAvailabilityState.css"; + +export type ProductTechnicalProvenance = { + moduleId?: string | null; + capabilityId?: string | null; + providerId?: string | null; + correlationId?: string | null; +}; + +export type ProductAvailabilityStateProps = { + state: "unavailable" | "degraded"; + explanation: ProductAvailabilityExplanation; + actions?: ReactNode; + technical?: ProductTechnicalProvenance | null; + size?: StatePanelProps["size"]; + surface?: StatePanelProps["surface"]; + className?: string; +}; + +export default function ProductAvailabilityState({ + state, + explanation, + actions, + technical, + size = "default", + surface = "subtle", + className = "" +}: ProductAvailabilityStateProps) { + const { language, translateText } = usePlatformLanguage(); + const labels = AVAILABILITY_LABELS[language.split("-", 1)[0] === "de" ? "de" : "en"]; + const title = translateText(explanation.title); + const description = translateText(explanation.description); + const resolution = translateText(explanation.resolution); + const responsibleRole = explanation.responsibleRole + ? translateText(explanation.responsibleRole) + : null; + const technicalEntries = technical ? Object.entries(technical).filter((entry) => Boolean(entry[1])) : []; + + return ( + : } + title={title} + description={description} + actions={actions} + size={size} + surface={surface} + tone="warning" + > +

{resolution}

+ {responsibleRole ? ( +

+ {labels.responsibleRole}: + {responsibleRole} +

+ ) : null} + {technicalEntries.length ? ( +
+ {labels.technicalDetails} +
+ {technicalEntries.map(([key, value]) => ( +
+
{technicalLabel(key, labels)}
+
{String(value)}
+
+ ))} +
+
+ ) : null} +
+ ); +} + +type AvailabilityLabels = { + responsibleRole: string; + technicalDetails: string; + module: string; + capability: string; + provider: string; + correlationId: string; +}; + +const AVAILABILITY_LABELS: Record<"en" | "de", AvailabilityLabels> = { + en: { + responsibleRole: "Responsible role", + technicalDetails: "Technical details", + module: "Module", + capability: "Capability", + provider: "Provider", + correlationId: "Correlation ID" + }, + de: { + responsibleRole: "Zuständige Rolle", + technicalDetails: "Technische Details", + module: "Modul", + capability: "Fähigkeit", + provider: "Anbieter", + correlationId: "Korrelations-ID" + } +}; + +function technicalLabel(key: string, labels: AvailabilityLabels): string { + if (key === "moduleId") return labels.module; + if (key === "capabilityId") return labels.capability; + if (key === "providerId") return labels.provider; + if (key === "correlationId") return labels.correlationId; + return key; +} diff --git a/webui/src/components/ProductSurfaceRoute.tsx b/webui/src/components/ProductSurfaceRoute.tsx new file mode 100644 index 0000000..55b7759 --- /dev/null +++ b/webui/src/components/ProductSurfaceRoute.tsx @@ -0,0 +1,63 @@ +import { lazy, useEffect, useMemo } from "react"; +import { Navigate, useLocation } from "react-router"; +import type { AuthInfo } from "../types"; +import { usePlatformModules } from "../platform/ModuleContext"; +import { firstAccessibleRoute } from "../platform/modules"; +import { + availableProductSurfaceContributors, + composeProductSurfaces, + dispatchProductSurfaceRouteResolved +} from "../platform/productSurfaces"; +import { useEffectiveView } from "../platform/ViewContext"; + +const ProductAvailabilityState = lazy(() => import("./ProductAvailabilityState")); + +export default function ProductSurfaceRoute({ + auth +}: { + auth: AuthInfo; +}) { + const location = useLocation(); + const modules = usePlatformModules(); + const projection = useEffectiveView(); + const surface = useMemo( + () => composeProductSurfaces(modules).find((candidate) => + candidate.entryPath === location.pathname || candidate.aliases.includes(location.pathname) + ) ?? null, + [location.pathname, modules] + ); + const contributors = useMemo( + () => surface ? availableProductSurfaceContributors(surface, auth, modules, projection) : [], + [auth, modules, projection, surface] + ); + const target = contributors[0] ?? null; + + useEffect(() => { + if (!surface || !target) return; + dispatchProductSurfaceRouteResolved({ + contractVersion: "1", + productSurfaceId: surface.id, + requestedPath: location.pathname, + targetPath: target.routePath, + contributorModuleId: target.moduleId, + usedAlias: location.pathname !== surface.entryPath + }); + }, [location.pathname, surface, target]); + + if (target) { + return ; + } + + if (!surface) { + return ; + } + + const explanation = surface.contributors[0]?.unavailable; + return explanation ? ( + + ) : null; +} diff --git a/webui/src/i18n/productSurfaceTranslations.ts b/webui/src/i18n/productSurfaceTranslations.ts new file mode 100644 index 0000000..9315807 --- /dev/null +++ b/webui/src/i18n/productSurfaceTranslations.ts @@ -0,0 +1,28 @@ +import type { PlatformTranslations } from "../types"; + +export const generatedTranslations = { + en: { + "i18n:govoplan-core.product_surface.messages": "Messages", + "i18n:govoplan-core.product_surface.messages_description": "Read and act on messages without merging channel custody, policy, or delivery state.", + "i18n:govoplan-core.product_surface.messages_unavailable": "Messages are unavailable", + "i18n:govoplan-core.product_surface.messages_unavailable_description": "No message source is available for your current responsibility and permissions.", + "i18n:govoplan-core.product_surface.messages_unavailable_resolution": "Ask the responsible access administrator to review your assignment or permissions.", + "i18n:govoplan-core.product_surface.messages_degraded": "Messages are temporarily limited", + "i18n:govoplan-core.product_surface.messages_degraded_description": "Saved messages remain available, but a channel or provider may not be current.", + "i18n:govoplan-core.product_surface.messages_degraded_resolution": "Retry later or ask the integration operator to review provider health.", + "i18n:govoplan-core.access_administrator": "Access administrator", + "i18n:govoplan-core.integration_operator": "Integration operator" + }, + de: { + "i18n:govoplan-core.product_surface.messages": "Nachrichten", + "i18n:govoplan-core.product_surface.messages_description": "Nachrichten lesen und bearbeiten, ohne Verwahrung, Regeln oder Zustellstatus der Kanäle zusammenzuführen.", + "i18n:govoplan-core.product_surface.messages_unavailable": "Nachrichten sind nicht verfügbar", + "i18n:govoplan-core.product_surface.messages_unavailable_description": "Für Ihre aktuelle Verantwortung und Berechtigungen ist keine Nachrichtenquelle verfügbar.", + "i18n:govoplan-core.product_surface.messages_unavailable_resolution": "Bitten Sie die zuständige Zugriffsadministration, Ihre Zuordnung oder Berechtigungen zu prüfen.", + "i18n:govoplan-core.product_surface.messages_degraded": "Nachrichten sind vorübergehend eingeschränkt", + "i18n:govoplan-core.product_surface.messages_degraded_description": "Gespeicherte Nachrichten bleiben verfügbar, ein Kanal oder Anbieter ist jedoch möglicherweise nicht aktuell.", + "i18n:govoplan-core.product_surface.messages_degraded_resolution": "Versuchen Sie es später erneut oder bitten Sie die Integrationsadministration, den Anbieterstatus zu prüfen.", + "i18n:govoplan-core.access_administrator": "Zugriffsadministration", + "i18n:govoplan-core.integration_operator": "Integrationsadministration" + } +} satisfies PlatformTranslations; diff --git a/webui/src/index.ts b/webui/src/index.ts index 3c08096..d7eb0b3 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -32,6 +32,8 @@ export * from "./platform/ModuleContext"; export * from "./platform/moduleEvents"; export * from "./platform/ViewContext"; export * from "./platform/views"; +export * from "./platform/productSurfaces"; +export { generatedTranslations as messagesProductSurfaceTranslations } from "./i18n/productSurfaceTranslations"; export * from "./platform/temporal"; export * from "./platform/TemporalContext"; export * from "./platform/ActiveObjectContext"; @@ -215,6 +217,8 @@ export { default as SelectionList, SelectionListItem, SelectionListItemContent } export type { SelectionListItemContentProps, SelectionListItemProps, SelectionListProps } from "./components/SelectionList"; export { default as StatePanel } from "./components/StatePanel"; export type { StatePanelProps, StatePanelSize, StatePanelSurface, StatePanelTone } from "./components/StatePanel"; +export { default as ProductAvailabilityState } from "./components/ProductAvailabilityState"; +export type { ProductAvailabilityStateProps, ProductTechnicalProvenance } from "./components/ProductAvailabilityState"; export { default as StatusBadge } from "./components/StatusBadge"; export { default as StageRail } from "./components/StageRail"; export type { diff --git a/webui/src/platform/modules.ts b/webui/src/platform/modules.ts index 3d13c49..671ffb0 100644 --- a/webui/src/platform/modules.ts +++ b/webui/src/platform/modules.ts @@ -237,6 +237,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes), viewSurfaces: mergeViewSurfaces(module, info), productAreas: productAreasFromMetadata(info), + productSurfaceMetadata: info.frontend?.product_surfaces, quickAccessTools: quickAccessToolsFromMetadata(info), helpContexts: info.help_contexts ?? module.helpContexts, uiCapabilities: { diff --git a/webui/src/platform/productSurfaces.ts b/webui/src/platform/productSurfaces.ts new file mode 100644 index 0000000..3b58dda --- /dev/null +++ b/webui/src/platform/productSurfaces.ts @@ -0,0 +1,143 @@ +import type { + AuthInfo, + ComposedProductSurface, + EffectiveViewProjection, + ProductSurfaceMetadata, + PlatformWebModule, + ProductSurfaceContribution +} from "../types"; +import { hasAnyScope, hasScope } from "../utils/permissions"; +import { isViewSurfaceVisible, viewSurfaceCatalogueForModules } from "./views"; + +export const PRODUCT_SURFACE_ROUTE_RESOLVED_EVENT = "govoplan:product-surface-route-resolved"; + +export type ProductSurfaceRouteResolvedEventDetail = { + contractVersion: "1"; + productSurfaceId: string; + requestedPath: string; + targetPath: string; + contributorModuleId: string; + usedAlias: boolean; +}; + +export function composeProductSurfaces( + modules: readonly PlatformWebModule[] +): ComposedProductSurface[] { + const composed = new Map(); + const contributions = modules.flatMap((module) => [ + ...(module.productSurfaces ?? []), + ...(module.productSurfaceMetadata ?? []).map(productSurfaceFromMetadata) + ]); + for (const contribution of contributions) { + const existing = composed.get(contribution.id); + if (!existing) { + composed.set(contribution.id, { + contractVersion: contribution.contractVersion, + id: contribution.id, + label: contribution.label, + description: contribution.description, + iconName: contribution.iconName, + entryPath: contribution.entryPath, + presentations: [...contribution.presentations], + contributors: [contribution], + aliases: [...contribution.aliases], + order: contribution.order + }); + continue; + } + assertSharedIdentity(existing, contribution); + existing.contributors.push(contribution); + existing.aliases = [...new Set([...existing.aliases, ...contribution.aliases])]; + existing.order = Math.min(existing.order, contribution.order); + } + return [...composed.values()] + .map((surface) => ({ + ...surface, + contributors: [...surface.contributors].sort(compareContributions), + aliases: [...surface.aliases].sort() + })) + .sort((left, right) => left.order - right.order || left.label.localeCompare(right.label)); +} + +function productSurfaceFromMetadata(surface: ProductSurfaceMetadata): ProductSurfaceContribution { + return { + contractVersion: surface.contract_version, + id: surface.id, + moduleId: surface.module_id, + label: surface.label, + description: surface.description, + iconName: surface.icon, + entryPath: surface.entry_path, + routePath: surface.route_path, + surfaceIds: surface.surface_ids, + presentations: surface.presentations, + capabilityIds: surface.capability_ids, + searchSourceIds: surface.search_source_ids, + helpContextIds: surface.help_context_ids, + documentationTopicIds: surface.documentation_topic_ids, + allOf: surface.required_all, + anyOf: surface.required_any, + aliases: surface.aliases, + order: surface.order, + unavailable: { + ...surface.unavailable, + responsibleRole: surface.unavailable.responsible_role + }, + degraded: surface.degraded ? { + ...surface.degraded, + responsibleRole: surface.degraded.responsible_role + } : null + }; +} + +export function availableProductSurfaceContributors( + surface: ComposedProductSurface, + auth: AuthInfo | null | undefined, + modules: readonly PlatformWebModule[], + projection?: EffectiveViewProjection | null +): ProductSurfaceContribution[] { + const catalogue = viewSurfaceCatalogueForModules([...modules]); + return surface.contributors.filter((contribution) => { + if (contribution.allOf.length && !contribution.allOf.every((scope) => hasScope(auth, scope))) { + return false; + } + if (contribution.anyOf.length && !hasAnyScope(auth, contribution.anyOf)) { + return false; + } + return contribution.surfaceIds.some((surfaceId) => + isViewSurfaceVisible(projection, surfaceId, catalogue) + ); + }); +} + +export function dispatchProductSurfaceRouteResolved( + detail: ProductSurfaceRouteResolvedEventDetail +): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent( + PRODUCT_SURFACE_ROUTE_RESOLVED_EVENT, + { detail } + )); +} + +function assertSharedIdentity( + existing: ComposedProductSurface, + contribution: ProductSurfaceContribution +): void { + if ( + existing.contractVersion !== contribution.contractVersion + || existing.label !== contribution.label + || existing.iconName !== contribution.iconName + || existing.entryPath !== contribution.entryPath + || existing.description !== contribution.description + ) { + throw new Error(`Conflicting product surface identity: ${contribution.id}`); + } +} + +function compareContributions( + left: ProductSurfaceContribution, + right: ProductSurfaceContribution +): number { + return left.order - right.order || left.moduleId.localeCompare(right.moduleId); +} diff --git a/webui/src/types.ts b/webui/src/types.ts index ca3a91a..5f9d862 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -330,6 +330,59 @@ export type ProductAreaContribution = { order?: number; }; +export type ProductSurfacePresentation = "task" | "reader" | "admin" | "operator"; + +export type ProductAvailabilityReason = + | "authorization" + | "policy" + | "configuration" + | "disabled" + | "capability" + | "offline" + | "provider_degraded"; + +export type ProductAvailabilityExplanation = { + reason: ProductAvailabilityReason; + title: string; + description: string; + resolution: string; + responsibleRole?: string | null; +}; + +export type ProductSurfaceContribution = { + contractVersion: "1"; + id: string; + moduleId: string; + label: string; + description?: string | null; + iconName: PlatformIconName; + entryPath: string; + routePath: string; + surfaceIds: string[]; + presentations: ProductSurfacePresentation[]; + capabilityIds: string[]; + searchSourceIds: string[]; + helpContextIds: string[]; + documentationTopicIds: string[]; + allOf: string[]; + anyOf: string[]; + aliases: string[]; + order: number; + unavailable: ProductAvailabilityExplanation; + degraded?: ProductAvailabilityExplanation | null; +}; + +export type ComposedProductSurface = Omit< + ProductSurfaceContribution, + "moduleId" | "routePath" | "surfaceIds" | "capabilityIds" | + "searchSourceIds" | "helpContextIds" | "documentationTopicIds" | + "allOf" | "anyOf" | "aliases" | "order" | "unavailable" | "degraded" +> & { + contributors: ProductSurfaceContribution[]; + aliases: string[]; + order: number; +}; + export type QuickAccessToolMetadata = { contractVersion: "1"; id: string; @@ -507,6 +560,8 @@ export type PlatformWebModule = { runtimeUiCapabilities?: PlatformUiCapabilities; viewSurfaces?: PlatformViewSurface[]; productAreas?: ProductAreaContribution[]; + productSurfaces?: ProductSurfaceContribution[]; + productSurfaceMetadata?: ProductSurfaceMetadata[]; quickAccessTools?: QuickAccessToolMetadata[]; helpContexts?: PlatformDocumentationHelpContext[]; }; @@ -1255,6 +1310,40 @@ export type PlatformFrontendModuleInfo = { surface_ids: string[]; order: number; }>; + product_surfaces?: Array<{ + contract_version: "1"; + id: string; + module_id: string; + label: string; + description?: string | null; + icon: string; + entry_path: string; + route_path: string; + surface_ids: string[]; + presentations: ProductSurfacePresentation[]; + capability_ids: string[]; + search_source_ids: string[]; + help_context_ids: string[]; + documentation_topic_ids: string[]; + required_all: string[]; + required_any: string[]; + aliases: string[]; + order: number; + unavailable: { + reason: ProductAvailabilityReason; + title: string; + description: string; + resolution: string; + responsible_role?: string | null; + }; + degraded?: { + reason: ProductAvailabilityReason; + title: string; + description: string; + resolution: string; + responsible_role?: string | null; + } | null; + }>; quick_access_tools?: Array<{ id: string; module_id: string; @@ -1277,6 +1366,8 @@ export type PlatformFrontendModuleInfo = { }>; }; +export type ProductSurfaceMetadata = NonNullable[number]; + export type PlatformDocumentationHelpContext = { id: string; topic_id: string; diff --git a/webui/tests/layout-primitives.test.tsx b/webui/tests/layout-primitives.test.tsx index 9dade47..37a6417 100644 --- a/webui/tests/layout-primitives.test.tsx +++ b/webui/tests/layout-primitives.test.tsx @@ -19,6 +19,7 @@ import MetricGrid from "../src/components/MetricGrid"; import PageActionBar from "../src/components/PageActionBar"; import SelectionList, { SelectionListItem, SelectionListItemContent } from "../src/components/SelectionList"; import StatePanel from "../src/components/StatePanel"; +import ProductAvailabilityState from "../src/components/ProductAvailabilityState"; import WorkspaceLayout from "../src/components/WorkspaceLayout"; import WorkspaceFrame from "../src/components/WorkspaceFrame"; import WorkspaceActionBar from "../src/components/WorkspaceActionBar"; @@ -183,6 +184,27 @@ assert(workspaceMarkup.includes("selection-list-navigation"), "resource navigati assert(workspaceMarkup.includes("selection-list-item-content"), "selection-list copy owns title and description typography"); assert(workspaceMarkup.includes("state-panel-size-fill"), "whole-surface states share sizing and action anatomy"); +const availabilityMarkup = renderToStaticMarkup( + + Retry} + /> + +); +assert(availabilityMarkup.includes("product-availability-degraded"), "product availability uses one semantic state primitive"); +assert(availabilityMarkup.includes("Retry later or contact the integration operator."), "availability states include an actionable recovery path"); +assert(availabilityMarkup.includes("Body ); diff --git a/webui/tests/module-capabilities.test.ts b/webui/tests/module-capabilities.test.ts index 22de4fc..7d581d7 100644 --- a/webui/tests/module-capabilities.test.ts +++ b/webui/tests/module-capabilities.test.ts @@ -18,6 +18,7 @@ import { visibleRoutesForProjection } from "../src/platform/views"; import { groupNavigationItems } from "../src/platform/productAreas"; +import { composeProductSurfaces } from "../src/platform/productSurfaces"; import { hasAnyScope, scopeGrants } from "../src/utils/permissions"; function assert(condition: unknown, message: string): void { @@ -193,6 +194,72 @@ assert( "flat navigation should retain every authorized destination" ); +const productSurfaceExplanation = { + reason: "authorization" as const, + title: "Messages are unavailable", + description: "No message source is available for the current responsibility.", + resolution: "Ask the responsible administrator to review the assignment." +}; +const messageSurfaceModules: PlatformWebModule[] = [ + { + id: "mail", + label: "Mail", + version: "test", + productSurfaces: [{ + contractVersion: "1", + id: "communication.messages", + moduleId: "mail", + label: "Messages", + description: "Read messages without merging channel custody.", + iconName: "mail", + entryPath: "/messages", + routePath: "/mail", + surfaceIds: ["mail.route.mail"], + presentations: ["task", "reader"], + capabilityIds: [], + searchSourceIds: ["mail.mailbox_messages"], + helpContextIds: ["mail.quick_access.messages"], + documentationTopicIds: ["mail.quick-access-and-product-area"], + allOf: [], + anyOf: ["mail:mailbox:read"], + aliases: ["/inbox"], + order: 10, + unavailable: productSurfaceExplanation + }] + }, + { + id: "postbox", + label: "Postbox", + version: "test", + productSurfaceMetadata: [{ + contract_version: "1", + id: "communication.messages", + module_id: "postbox", + label: "Messages", + description: "Read messages without merging channel custody.", + icon: "mail", + entry_path: "/messages", + route_path: "/postbox", + surface_ids: ["postbox.route.postbox"], + presentations: ["task", "reader"], + capability_ids: [], + search_source_ids: ["postbox.messages"], + help_context_ids: ["postbox.quick_access.messages"], + documentation_topic_ids: ["postbox.quick-access-and-product-area"], + required_all: [], + required_any: ["postbox:message:read"], + aliases: ["/inbox"], + order: 20, + unavailable: productSurfaceExplanation + }] + } +]; +const composedMessages = composeProductSurfaces(messageSurfaceModules); +assert(composedMessages.length === 1, "related owner routes should compose into one product identity"); +assert(composedMessages[0]?.entryPath === "/messages", "the composed identity should keep its stable entry path"); +assert(composedMessages[0]?.contributors.map((item) => item.moduleId).join(",") === "mail,postbox", "composition should retain ordered technical provenance"); +assert(composedMessages[0]?.aliases.join(",") === "/inbox", "migration aliases should be de-duplicated across owners"); + const viewAwareFiles: PlatformWebModule = { ...files, navItems: [{ to: "/files", label: "Files", order: 20, anyOf: ["files:file:read"] }], diff --git a/webui/tsconfig.module-tests.json b/webui/tsconfig.module-tests.json index efe43c0..9cae1e3 100644 --- a/webui/tsconfig.module-tests.json +++ b/webui/tsconfig.module-tests.json @@ -22,6 +22,7 @@ "tests/definition-graph.test.ts", "src/platform/moduleLogic.ts", "src/platform/productAreas.ts", + "src/platform/productSurfaces.ts", "src/platform/launchContext.ts", "src/utils/helpContext.ts", "src/features/privacy/policyLogic.ts", diff --git a/webui/vite.config.ts b/webui/vite.config.ts index af9a45a..75d4e95 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -214,6 +214,10 @@ export default defineConfig({ rollupOptions: { output: { manualChunks: deferredVendorChunk, + // Dynamic chunk names are implementation details. Keeping only the + // content hash avoids shipping every source/module name in Vite's + // preload table, which is part of the initial application payload. + chunkFileNames: "assets/c-[hash].js", // Keep dependencies of deferred BPMN packages in their lazy graph. The // legacy Rollup behavior merged those dependencies into manual chunks // and hoisted the properties-panel runtime into the application entry.