feat: compose stable product surfaces
Module Package Release / publish-packages (push) Successful in 13s

This commit is contained in:
2026-08-24 13:21:24 +02:00
parent f98cf9ced8
commit 6e518fa6a2
25 changed files with 1012 additions and 17 deletions
+2 -1
View File
@@ -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() {
)}
<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>
</ModuleLoadBoundary>
{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;
+4
View File
@@ -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 {
+1
View File
@@ -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: {
+143
View File
@@ -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);
}
+91
View File
@@ -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<PlatformFrontendModuleInfo["product_surfaces"]>[number];
export type PlatformDocumentationHelpContext = {
id: string;
topic_id: string;