feat: compose stable product surfaces
Module Package Release / publish-packages (push) Successful in 13s
Module Package Release / publish-packages (push) Successful in 13s
This commit is contained in:
@@ -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
|
script with a reason, and removed when a capability/API/event contract replaces
|
||||||
it.
|
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
|
## Boundary Decision Register
|
||||||
|
|
||||||
These durable decisions close older exploratory core issues. Implementation
|
These durable decisions close older exploratory core issues. Implementation
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-core"
|
name = "govoplan-core"
|
||||||
version = "0.1.39"
|
version = "0.1.40"
|
||||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
|
|||||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
|
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
|
||||||
SUPPORTED_PRESENTATION_CONTRACT_VERSION = "1"
|
SUPPORTED_PRESENTATION_CONTRACT_VERSION = "1"
|
||||||
|
SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION = "1"
|
||||||
|
|
||||||
PermissionLevel = Literal["system", "tenant"]
|
PermissionLevel = Literal["system", "tenant"]
|
||||||
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
|
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
|
||||||
@@ -114,6 +115,55 @@ class ProductAreaContribution:
|
|||||||
order: int = 100
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class QuickAccessTool:
|
class QuickAccessTool:
|
||||||
"""Declare a versioned, bounded module-owned Quick Access tool."""
|
"""Declare a versioned, bounded module-owned Quick Access tool."""
|
||||||
@@ -153,6 +203,7 @@ class FrontendModule:
|
|||||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||||
view_surfaces: tuple[ViewSurface, ...] = ()
|
view_surfaces: tuple[ViewSurface, ...] = ()
|
||||||
product_areas: tuple[ProductAreaContribution, ...] = ()
|
product_areas: tuple[ProductAreaContribution, ...] = ()
|
||||||
|
product_surfaces: tuple[ProductSurfaceContribution, ...] = ()
|
||||||
quick_access_tools: tuple[QuickAccessTool, ...] = ()
|
quick_access_tools: tuple[QuickAccessTool, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ PlatformInterfaceKind = Literal[
|
|||||||
"navigation",
|
"navigation",
|
||||||
"permission",
|
"permission",
|
||||||
"product_area",
|
"product_area",
|
||||||
|
"product_surface",
|
||||||
"provided_interface",
|
"provided_interface",
|
||||||
"public_route",
|
"public_route",
|
||||||
"search_provider",
|
"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:
|
for tool in frontend.quick_access_tools:
|
||||||
declarations.append(
|
declarations.append(
|
||||||
PlatformInterfaceDeclaration(
|
PlatformInterfaceDeclaration(
|
||||||
|
|||||||
@@ -16,13 +16,16 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
ProductAvailabilityExplanation,
|
||||||
ProductAreaContribution,
|
ProductAreaContribution,
|
||||||
|
ProductSurfaceContribution,
|
||||||
PublicFrontendRoute,
|
PublicFrontendRoute,
|
||||||
QuickAccessTool,
|
QuickAccessTool,
|
||||||
ResourceAclProvider,
|
ResourceAclProvider,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
||||||
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
||||||
|
SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION,
|
||||||
TenantSummaryBatchProvider,
|
TenantSummaryBatchProvider,
|
||||||
TenantSummaryProvider,
|
TenantSummaryProvider,
|
||||||
user_workflow_scope_condition_issues,
|
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_]*)+$")
|
_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}$")
|
_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(
|
_QUICK_ACCESS_TOOL_ID_RE = re.compile(
|
||||||
r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_-]*)+$"
|
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:
|
def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||||
area_definitions: dict[str, tuple[str, str]] = {}
|
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] = {}
|
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:
|
for manifest in manifests:
|
||||||
frontend = manifest.frontend
|
frontend = manifest.frontend
|
||||||
if frontend is None:
|
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"
|
f"Product area {area.id!r} has conflicting labels or icons"
|
||||||
)
|
)
|
||||||
area_definitions[area.id] = definition
|
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:
|
for tool in frontend.quick_access_tools:
|
||||||
previous_owner = tool_owners.get(tool.id)
|
previous_owner = tool_owners.get(tool.id)
|
||||||
if previous_owner is not None:
|
if previous_owner is not None:
|
||||||
@@ -1474,6 +1519,14 @@ def _validate_presentation_contributions(manifest: ModuleManifest) -> None:
|
|||||||
f"in module {manifest.id!r}"
|
f"in module {manifest.id!r}"
|
||||||
)
|
)
|
||||||
seen_area_memberships.add(membership)
|
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()
|
seen_tools: set[str] = set()
|
||||||
for tool in frontend.quick_access_tools:
|
for tool in frontend.quick_access_tools:
|
||||||
_validate_quick_access_tool(manifest.id, tool, known_surface_ids)
|
_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(
|
def _validate_quick_access_tool(
|
||||||
module_id: str,
|
module_id: str,
|
||||||
tool: QuickAccessTool,
|
tool: QuickAccessTool,
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ from govoplan_core.core.modules import (
|
|||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
|
ProductAvailabilityExplanation,
|
||||||
ProductAreaContribution,
|
ProductAreaContribution,
|
||||||
|
ProductSurfaceContribution,
|
||||||
PublicFrontendRoute,
|
PublicFrontendRoute,
|
||||||
QuickAccessTool,
|
QuickAccessTool,
|
||||||
SUPPORTED_PRESENTATION_CONTRACT_VERSION,
|
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]:
|
def _quick_access_tool_payload(tool: QuickAccessTool) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"id": tool.id,
|
"id": tool.id,
|
||||||
@@ -372,6 +415,10 @@ def _frontend_payload(
|
|||||||
"product_areas": [
|
"product_areas": [
|
||||||
_product_area_payload(area) for area in frontend.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_tools": [
|
||||||
_quick_access_tool_payload(tool) for tool in frontend.quick_access_tools
|
_quick_access_tool_payload(tool) for tool in frontend.quick_access_tools
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -10,7 +11,9 @@ from govoplan_core.core.modules import (
|
|||||||
FrontendModule,
|
FrontendModule,
|
||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
|
ProductAvailabilityExplanation,
|
||||||
ProductAreaContribution,
|
ProductAreaContribution,
|
||||||
|
ProductSurfaceContribution,
|
||||||
QuickAccessTool,
|
QuickAccessTool,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||||
@@ -49,6 +52,26 @@ def presentation_manifest() -> ModuleManifest:
|
|||||||
surface_ids=("example.route.main",),
|
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=(
|
quick_access_tools=(
|
||||||
QuickAccessTool(
|
QuickAccessTool(
|
||||||
id="example.summary",
|
id="example.summary",
|
||||||
@@ -121,6 +144,16 @@ class PresentationContractTests(unittest.TestCase):
|
|||||||
frontend = response.json()["modules"][0]["frontend"]
|
frontend = response.json()["modules"][0]["frontend"]
|
||||||
self.assertEqual("1", frontend["presentation_contract_version"])
|
self.assertEqual("1", frontend["presentation_contract_version"])
|
||||||
self.assertEqual("work", frontend["product_areas"][0]["id"])
|
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("example.summary", frontend["quick_access_tools"][0]["id"])
|
||||||
self.assertEqual("1", frontend["quick_access_tools"][0]["contract_version"])
|
self.assertEqual("1", frontend["quick_access_tools"][0]["contract_version"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -132,6 +165,61 @@ class PresentationContractTests(unittest.TestCase):
|
|||||||
frontend["quick_access_tools"][0]["help_context_id"],
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.39",
|
"version": "0.1.40",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.39",
|
"version": "0.1.40",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.39",
|
"version": "0.1.40",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.39",
|
"version": "0.1.40",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.23",
|
"@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",
|
"@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/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/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/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/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/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",
|
"@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": {
|
"node_modules/@govoplan/mail-webui": {
|
||||||
"version": "0.1.24",
|
"version": "0.1.25",
|
||||||
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#48205e6e461f5f93d88380df8e23db54fdb719f7",
|
"resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#ecc1283de76f154919c786cb84354afb8f2299c1",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.39",
|
"version": "0.1.40",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"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: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: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: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-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: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",
|
"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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.39",
|
"version": "0.1.40",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"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/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/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/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/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/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",
|
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.18",
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ const sourceRoots = fs.readdirSync(workspaceRoot, { withFileTypes: true })
|
|||||||
.map((entry) => path.join(workspaceRoot, entry.name, "webui", "src"))
|
.map((entry) => path.join(workspaceRoot, entry.name, "webui", "src"))
|
||||||
.filter((sourceRoot) => fs.existsSync(sourceRoot));
|
.filter((sourceRoot) => fs.existsSync(sourceRoot));
|
||||||
|
|
||||||
const generatedCatalogs = sourceRoots
|
const generatedCatalogs = sourceRoots.flatMap((sourceRoot) =>
|
||||||
.map((sourceRoot) => path.join(sourceRoot, "i18n", "generatedTranslations.ts"))
|
fs.existsSync(path.join(sourceRoot, "i18n"))
|
||||||
.filter((file) => fs.existsSync(file));
|
? rgFiles(path.join(sourceRoot, "i18n")).filter((file) => /Translations\.ts$/.test(file))
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
function scanStructuralSourcePositions(roots) {
|
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 = [];
|
const findings = [];
|
||||||
for (const file of files) {
|
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);
|
const source = ts.createSourceFile(file, fs.readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
||||||
|
|||||||
+2
-1
@@ -30,6 +30,7 @@ import { applyAppearanceOverrides } from "./components/AppearanceOverridesEditor
|
|||||||
|
|
||||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||||
|
const ProductSurfaceRoute = lazy(() => import("./components/ProductSurfaceRoute"));
|
||||||
|
|
||||||
const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
|
const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
|
||||||
compact_tables: false,
|
compact_tables: false,
|
||||||
@@ -579,7 +580,7 @@ export default function App() {
|
|||||||
|
|
||||||
)}
|
)}
|
||||||
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
|
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
|
||||||
<Route path="*" element={<Navigate to={defaultRoute} replace />} />
|
<Route path="*" element={<ProductSurfaceRoute auth={auth} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</ModuleLoadBoundary>
|
</ModuleLoadBoundary>
|
||||||
{reloginMessage &&
|
{reloginMessage &&
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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 (
|
||||||
|
<StatePanel
|
||||||
|
aria-live="polite"
|
||||||
|
className={["product-availability-state", `product-availability-${state}`, className].filter(Boolean).join(" ")}
|
||||||
|
icon={state === "degraded" ? <TriangleAlert size={24} /> : <CircleOff size={24} />}
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
actions={actions}
|
||||||
|
size={size}
|
||||||
|
surface={surface}
|
||||||
|
tone="warning"
|
||||||
|
>
|
||||||
|
<p className="product-availability-resolution">{resolution}</p>
|
||||||
|
{responsibleRole ? (
|
||||||
|
<p className="product-availability-owner">
|
||||||
|
<strong>{labels.responsibleRole}: </strong>
|
||||||
|
{responsibleRole}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{technicalEntries.length ? (
|
||||||
|
<details className="product-availability-technical">
|
||||||
|
<summary>{labels.technicalDetails}</summary>
|
||||||
|
<dl>
|
||||||
|
{technicalEntries.map(([key, value]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<dt>{technicalLabel(key, labels)}</dt>
|
||||||
|
<dd>{String(value)}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</StatePanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 <Navigate to={`${target.routePath}${location.search}${location.hash}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!surface) {
|
||||||
|
return <Navigate to={firstAccessibleRoute(auth, modules, projection)} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const explanation = surface.contributors[0]?.unavailable;
|
||||||
|
return explanation ? (
|
||||||
|
<ProductAvailabilityState
|
||||||
|
state="unavailable"
|
||||||
|
explanation={explanation}
|
||||||
|
size="fill"
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -32,6 +32,8 @@ export * from "./platform/ModuleContext";
|
|||||||
export * from "./platform/moduleEvents";
|
export * from "./platform/moduleEvents";
|
||||||
export * from "./platform/ViewContext";
|
export * from "./platform/ViewContext";
|
||||||
export * from "./platform/views";
|
export * from "./platform/views";
|
||||||
|
export * from "./platform/productSurfaces";
|
||||||
|
export { generatedTranslations as messagesProductSurfaceTranslations } from "./i18n/productSurfaceTranslations";
|
||||||
export * from "./platform/temporal";
|
export * from "./platform/temporal";
|
||||||
export * from "./platform/TemporalContext";
|
export * from "./platform/TemporalContext";
|
||||||
export * from "./platform/ActiveObjectContext";
|
export * from "./platform/ActiveObjectContext";
|
||||||
@@ -215,6 +217,8 @@ export { default as SelectionList, SelectionListItem, SelectionListItemContent }
|
|||||||
export type { SelectionListItemContentProps, SelectionListItemProps, SelectionListProps } from "./components/SelectionList";
|
export type { SelectionListItemContentProps, SelectionListItemProps, SelectionListProps } from "./components/SelectionList";
|
||||||
export { default as StatePanel } from "./components/StatePanel";
|
export { default as StatePanel } from "./components/StatePanel";
|
||||||
export type { StatePanelProps, StatePanelSize, StatePanelSurface, StatePanelTone } 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 StatusBadge } from "./components/StatusBadge";
|
||||||
export { default as StageRail } from "./components/StageRail";
|
export { default as StageRail } from "./components/StageRail";
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
|||||||
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
||||||
viewSurfaces: mergeViewSurfaces(module, info),
|
viewSurfaces: mergeViewSurfaces(module, info),
|
||||||
productAreas: productAreasFromMetadata(info),
|
productAreas: productAreasFromMetadata(info),
|
||||||
|
productSurfaceMetadata: info.frontend?.product_surfaces,
|
||||||
quickAccessTools: quickAccessToolsFromMetadata(info),
|
quickAccessTools: quickAccessToolsFromMetadata(info),
|
||||||
helpContexts: info.help_contexts ?? module.helpContexts,
|
helpContexts: info.help_contexts ?? module.helpContexts,
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
|
|||||||
@@ -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<string, ComposedProductSurface>();
|
||||||
|
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<ProductSurfaceRouteResolvedEventDetail>(
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -330,6 +330,59 @@ export type ProductAreaContribution = {
|
|||||||
order?: number;
|
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 = {
|
export type QuickAccessToolMetadata = {
|
||||||
contractVersion: "1";
|
contractVersion: "1";
|
||||||
id: string;
|
id: string;
|
||||||
@@ -507,6 +560,8 @@ export type PlatformWebModule = {
|
|||||||
runtimeUiCapabilities?: PlatformUiCapabilities;
|
runtimeUiCapabilities?: PlatformUiCapabilities;
|
||||||
viewSurfaces?: PlatformViewSurface[];
|
viewSurfaces?: PlatformViewSurface[];
|
||||||
productAreas?: ProductAreaContribution[];
|
productAreas?: ProductAreaContribution[];
|
||||||
|
productSurfaces?: ProductSurfaceContribution[];
|
||||||
|
productSurfaceMetadata?: ProductSurfaceMetadata[];
|
||||||
quickAccessTools?: QuickAccessToolMetadata[];
|
quickAccessTools?: QuickAccessToolMetadata[];
|
||||||
helpContexts?: PlatformDocumentationHelpContext[];
|
helpContexts?: PlatformDocumentationHelpContext[];
|
||||||
};
|
};
|
||||||
@@ -1255,6 +1310,40 @@ export type PlatformFrontendModuleInfo = {
|
|||||||
surface_ids: string[];
|
surface_ids: string[];
|
||||||
order: number;
|
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<{
|
quick_access_tools?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
module_id: string;
|
module_id: string;
|
||||||
@@ -1277,6 +1366,8 @@ export type PlatformFrontendModuleInfo = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProductSurfaceMetadata = NonNullable<PlatformFrontendModuleInfo["product_surfaces"]>[number];
|
||||||
|
|
||||||
export type PlatformDocumentationHelpContext = {
|
export type PlatformDocumentationHelpContext = {
|
||||||
id: string;
|
id: string;
|
||||||
topic_id: string;
|
topic_id: string;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import MetricGrid from "../src/components/MetricGrid";
|
|||||||
import PageActionBar from "../src/components/PageActionBar";
|
import PageActionBar from "../src/components/PageActionBar";
|
||||||
import SelectionList, { SelectionListItem, SelectionListItemContent } from "../src/components/SelectionList";
|
import SelectionList, { SelectionListItem, SelectionListItemContent } from "../src/components/SelectionList";
|
||||||
import StatePanel from "../src/components/StatePanel";
|
import StatePanel from "../src/components/StatePanel";
|
||||||
|
import ProductAvailabilityState from "../src/components/ProductAvailabilityState";
|
||||||
import WorkspaceLayout from "../src/components/WorkspaceLayout";
|
import WorkspaceLayout from "../src/components/WorkspaceLayout";
|
||||||
import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
||||||
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
|
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("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");
|
assert(workspaceMarkup.includes("state-panel-size-fill"), "whole-surface states share sizing and action anatomy");
|
||||||
|
|
||||||
|
const availabilityMarkup = renderToStaticMarkup(
|
||||||
|
<PlatformLanguageProvider>
|
||||||
|
<ProductAvailabilityState
|
||||||
|
state="degraded"
|
||||||
|
explanation={{
|
||||||
|
reason: "provider_degraded",
|
||||||
|
title: "Messages are delayed",
|
||||||
|
description: "Saved messages remain available, but new provider results may be delayed.",
|
||||||
|
resolution: "Retry later or contact the integration operator.",
|
||||||
|
responsibleRole: "Integration operator"
|
||||||
|
}}
|
||||||
|
technical={{ moduleId: "mail", providerId: "smtp-primary", correlationId: "event-1" }}
|
||||||
|
actions={<button type="button">Retry</button>}
|
||||||
|
/>
|
||||||
|
</PlatformLanguageProvider>
|
||||||
|
);
|
||||||
|
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("<details"), "technical provenance remains available on demand");
|
||||||
|
assert(availabilityMarkup.includes("smtp-primary"), "technical details preserve exact provider provenance");
|
||||||
|
|
||||||
const frameMarkup = renderToStaticMarkup(
|
const frameMarkup = renderToStaticMarkup(
|
||||||
<PlatformLanguageProvider><WorkspaceFrame as="main" height="viewport" label="Planning workspace" surface="panel"><span>Body</span></WorkspaceFrame></PlatformLanguageProvider>
|
<PlatformLanguageProvider><WorkspaceFrame as="main" height="viewport" label="Planning workspace" surface="panel"><span>Body</span></WorkspaceFrame></PlatformLanguageProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
visibleRoutesForProjection
|
visibleRoutesForProjection
|
||||||
} from "../src/platform/views";
|
} from "../src/platform/views";
|
||||||
import { groupNavigationItems } from "../src/platform/productAreas";
|
import { groupNavigationItems } from "../src/platform/productAreas";
|
||||||
|
import { composeProductSurfaces } from "../src/platform/productSurfaces";
|
||||||
import { hasAnyScope, scopeGrants } from "../src/utils/permissions";
|
import { hasAnyScope, scopeGrants } from "../src/utils/permissions";
|
||||||
|
|
||||||
function assert(condition: unknown, message: string): void {
|
function assert(condition: unknown, message: string): void {
|
||||||
@@ -193,6 +194,72 @@ assert(
|
|||||||
"flat navigation should retain every authorized destination"
|
"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 = {
|
const viewAwareFiles: PlatformWebModule = {
|
||||||
...files,
|
...files,
|
||||||
navItems: [{ to: "/files", label: "Files", order: 20, anyOf: ["files:file:read"] }],
|
navItems: [{ to: "/files", label: "Files", order: 20, anyOf: ["files:file:read"] }],
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"tests/definition-graph.test.ts",
|
"tests/definition-graph.test.ts",
|
||||||
"src/platform/moduleLogic.ts",
|
"src/platform/moduleLogic.ts",
|
||||||
"src/platform/productAreas.ts",
|
"src/platform/productAreas.ts",
|
||||||
|
"src/platform/productSurfaces.ts",
|
||||||
"src/platform/launchContext.ts",
|
"src/platform/launchContext.ts",
|
||||||
"src/utils/helpContext.ts",
|
"src/utils/helpContext.ts",
|
||||||
"src/features/privacy/policyLogic.ts",
|
"src/features/privacy/policyLogic.ts",
|
||||||
|
|||||||
@@ -214,6 +214,10 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks: deferredVendorChunk,
|
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
|
// Keep dependencies of deferred BPMN packages in their lazy graph. The
|
||||||
// legacy Rollup behavior merged those dependencies into manual chunks
|
// legacy Rollup behavior merged those dependencies into manual chunks
|
||||||
// and hoisted the properties-panel runtime into the application entry.
|
// and hoisted the properties-panel runtime into the application entry.
|
||||||
|
|||||||
Reference in New Issue
Block a user