Add governed View surface runtime

This commit is contained in:
2026-07-28 21:04:54 +02:00
parent 13bc3d3b4e
commit ce9ef8d88f
24 changed files with 1215 additions and 39 deletions

View File

@@ -26,6 +26,7 @@
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
"@govoplan/views-webui": "file:../../govoplan-views/webui",
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
},
"devDependencies": {
@@ -378,6 +379,22 @@
}
}
},
"../../govoplan-views/webui": {
"name": "@govoplan/views-webui",
"version": "0.1.0",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-workflow/webui": {
"name": "@govoplan/workflow-webui",
"version": "0.1.14",
@@ -1192,6 +1209,10 @@
"resolved": "../../govoplan-scheduling/webui",
"link": true
},
"node_modules/@govoplan/views-webui": {
"resolved": "../../govoplan-views/webui",
"link": true
},
"node_modules/@govoplan/workflow-webui": {
"resolved": "../../govoplan-workflow/webui",
"link": true

View File

@@ -57,6 +57,7 @@
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
"@govoplan/views-webui": "file:../../govoplan-views/webui",
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
},
"devDependencies": {

View File

@@ -19,6 +19,7 @@ const packageByModule = {
ops: "@govoplan/ops-webui",
policy: "@govoplan/policy-webui",
scheduling: "@govoplan/scheduling-webui",
views: "@govoplan/views-webui",
workflow: "@govoplan/workflow-webui"
};
@@ -35,6 +36,8 @@ const cases = [
{ name: "dataflow-with-datasources", modules: ["datasources", "dataflow"] },
{ name: "workflow-only", modules: ["workflow"] },
{ name: "workflow-with-dataflow", modules: ["datasources", "dataflow", "workflow"] },
{ name: "views-only", modules: ["views"] },
{ name: "views-with-administration", modules: ["access", "admin", "views"] },
{ name: "calendar-only", modules: ["calendar"] },
{ name: "files-only", modules: ["files"] },
{ name: "mail-only", modules: ["mail"] },
@@ -47,7 +50,7 @@ const cases = [
{ name: "scheduling-only", modules: ["scheduling"] },
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling"] }
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling"] }
];
const npmExec = process.env.npm_execpath;

View File

@@ -3,16 +3,19 @@ import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences } from "./types";
import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, EffectiveViewProjection, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences, ViewsRuntimeUiCapability } from "./types";
import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal";
import { PermissionBoundary } from "./components/AccessBoundary";
import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules";
import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules, uiCapability } from "./platform/modules";
import { PlatformModulesProvider } from "./platform/ModuleContext";
import { PlatformViewProvider } from "./platform/ViewContext";
import { PLATFORM_VIEW_CHANGED_EVENT } from "./platform/views";
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary";
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
@@ -37,18 +40,59 @@ export default function App() {
const [backendReachable, setBackendReachable] = useState(true);
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
const [reloginMessage, setReloginMessage] = useState("");
const [viewProjection, setViewProjection] = useState<EffectiveViewProjection | null>(null);
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
const navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
const viewsRuntime = useMemo(
() => uiCapability<ViewsRuntimeUiCapability>("views.runtime", webModules),
[webModules]
);
const navItems = useMemo(
() => navItemsForModules(webModules, viewProjection),
[viewProjection, webModules]
);
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
const contextModules = auth ? webModules : publicWebModules;
const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]);
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
useEffect(() => {
if (!auth || !viewsRuntime) {
setViewProjection(null);
return;
}
const currentAuth = auth;
const currentRuntime = viewsRuntime;
let cancelled = false;
async function loadEffectiveView() {
try {
const projection = await currentRuntime.loadEffectiveView(settings, currentAuth);
if (!cancelled) setViewProjection(projection);
} catch (error) {
if (!cancelled) setViewProjection(null);
console.error("Failed to load the effective View", error);
}
}
void loadEffectiveView();
window.addEventListener(PLATFORM_VIEW_CHANGED_EVENT, loadEffectiveView);
return () => {
cancelled = true;
window.removeEventListener(PLATFORM_VIEW_CHANGED_EVENT, loadEffectiveView);
};
}, [
auth?.user?.id,
auth?.active_tenant?.id,
auth?.tenant.id,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey,
viewsRuntime
]);
function updateSettings(next: ApiSettings) {
setSettings(next);
saveApiSettings(next);
@@ -311,6 +355,7 @@ export default function App() {
return (
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={publicWebModules}>
<PlatformViewProvider modules={publicWebModules} projection={null}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<div className="public-landing">
@@ -322,6 +367,7 @@ export default function App() {
</div>
</AppShell>
</UnsavedChangesProvider>
</PlatformViewProvider>
</PlatformModulesProvider>
</PlatformLanguageProvider>);
@@ -331,6 +377,7 @@ export default function App() {
return (
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={publicWebModules}>
<PlatformViewProvider modules={publicWebModules} projection={null}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
@@ -347,12 +394,13 @@ export default function App() {
</Suspense>
</AppShell>
</UnsavedChangesProvider>
</PlatformViewProvider>
</PlatformModulesProvider>
</PlatformLanguageProvider>);
}
const defaultRoute = firstAccessibleRoute(auth, webModules);
const defaultRoute = firstAccessibleRoute(auth, webModules, viewProjection);
const authAvailableLanguages = auth.available_languages?.map((item) => ({
code: item.code,
label: item.label,
@@ -373,6 +421,7 @@ export default function App() {
onLanguageChange={persistLanguagePreference}
moduleTranslations={moduleTranslations}>
<PlatformModulesProvider modules={webModules}>
<PlatformViewProvider modules={webModules} projection={viewProjection}>
<UnsavedChangesProvider>
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
@@ -392,8 +441,14 @@ export default function App() {
path={route.path}
element={
<PermissionBoundary auth={auth} anyOf={route.anyOf} allOf={route.allOf} fallback={defaultRoute}>
{route.render({ settings, auth, onAuthChange: updateAuth })}
</PermissionBoundary>
<ViewSurfaceRouteBoundary
surfaceId={route.surfaceId}
settings={settings}
runtime={viewsRuntime}
fallbackPath={defaultRoute}>
{route.render({ settings, auth, onAuthChange: updateAuth })}
</ViewSurfaceRouteBoundary>
</PermissionBoundary>
} />
)}
@@ -412,6 +467,7 @@ export default function App() {
}
</AppShell>
</UnsavedChangesProvider>
</PlatformViewProvider>
</PlatformModulesProvider>
</PlatformLanguageProvider>);

View File

@@ -0,0 +1,58 @@
import type { ReactNode } from "react";
import { EyeOff } from "lucide-react";
import type { ApiSettings, ViewsRuntimeUiCapability } from "../types";
import { useEffectiveView, useViewSurfaceVisible } from "../platform/ViewContext";
import { dispatchPlatformViewChanged } from "../platform/views";
import { useGuardedNavigate } from "./UnsavedChangesGuard";
import Button from "./Button";
import Card from "./Card";
export default function ViewSurfaceRouteBoundary({
surfaceId,
settings,
runtime,
fallbackPath,
children
}: {
surfaceId?: string;
settings: ApiSettings;
runtime: ViewsRuntimeUiCapability | null;
fallbackPath: string;
children: ReactNode;
}) {
const visible = useViewSurfaceVisible(surfaceId);
const projection = useEffectiveView();
const navigate = useGuardedNavigate();
if (visible) return <>{children}</>;
async function exitView() {
if (!runtime || projection?.locked) return;
await runtime.activateView(settings, null);
dispatchPlatformViewChanged();
}
return (
<div className="content-pad">
<Card title="Outside the current view">
<div className="empty-state">
<EyeOff size={24} aria-hidden="true" />
<p>
This area is available to your account, but hidden by
{projection?.activeViewName
? ` the ${projection.activeViewName} view`
: " the current view"}.
</p>
<div className="button-row">
{!projection?.locked && runtime && (
<Button variant="primary" onClick={() => void exitView()}>
Exit view
</Button>
)}
<Button onClick={() => navigate(fallbackPath)}>Back to view</Button>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -14,6 +14,8 @@ import DismissibleAlert from "../../components/DismissibleAlert";
import SegmentedControl from "../../components/SegmentedControl";
import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard";
import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext";
import { useEffectiveView, useViewSurfaces } from "../../platform/ViewContext";
import { isViewSurfaceVisible } from "../../platform/views";
import { hasAnyScope, hasScope } from "../../utils/permissions";
import { usePlatformLanguage } from "../../i18n/LanguageContext";
import CredentialEnvelopeManager from "../../components/CredentialEnvelopeManager";
@@ -91,10 +93,12 @@ export default function SettingsPage({
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
const settingsSectionCapabilities = usePlatformUiCapabilities<SettingsSectionsUiCapability>("settings.sections");
const effectiveView = useEffectiveView();
const viewSurfaces = useViewSurfaces();
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
const MailProfileScopeManager = mailProfilesUi?.MailProfileScopeManager ?? null;
const FileConnectorScopeManager = fileConnectorsUi?.FileConnectorScopeManager ?? null;
const canUseMailProfiles = Boolean(MailProfileScopeManager) && hasAnyScope(auth, [
const canUseMailProfiles = isViewSurfaceVisible(effectiveView, "mail.settings.profiles", viewSurfaces) && Boolean(MailProfileScopeManager) && hasAnyScope(auth, [
"mail_servers:read",
"mail_servers:write",
"mail_servers:manage_credentials",
@@ -103,8 +107,8 @@ export default function SettingsPage({
"admin:policies:read",
"admin:policies:write"
]);
const canUseFileConnectors = Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
const canUseCredentials = hasAnyScope(auth, [
const canUseFileConnectors = isViewSurfaceVisible(effectiveView, "files.settings.connectors", viewSurfaces) && Boolean(FileConnectorScopeManager) && hasAnyScope(auth, ["files:file:read", "files:file:admin", "admin:settings:read", "admin:settings:write"]);
const canUseCredentials = isViewSurfaceVisible(effectiveView, "access.settings.credentials", viewSurfaces) && hasAnyScope(auth, [
"access:credential:read",
"access:credential:write",
"access:credential:manage_own",
@@ -113,8 +117,14 @@ export default function SettingsPage({
"admin:settings:write"
]);
const contributedSections = useMemo(
() => settingsSectionCapabilities.flatMap((capability) => capability.sections ?? []).filter((section) => canUseSettingsContribution(auth, section)),
[auth, settingsSectionCapabilities]
() =>
settingsSectionCapabilities
.flatMap((capability) => capability.sections ?? [])
.filter((section) => canUseSettingsContribution(auth, section))
.filter((section) =>
isViewSurfaceVisible(effectiveView, section.surfaceId, viewSurfaces)
),
[auth, effectiveView, settingsSectionCapabilities, viewSurfaces]
);
const settingsSubnav = useMemo(() => settingsGroups(canUseMailProfiles, canUseFileConnectors, canUseCredentials, contributedSections), [canUseCredentials, canUseFileConnectors, canUseMailProfiles, contributedSections]);
const availableSectionIds = useMemo(() => new Set(settingsSubnav.flatMap((group) => group.items.flatMap((item) => "id" in item ? [item.id] : []))), [settingsSubnav]);

View File

@@ -28,6 +28,8 @@ export * from "./api/resourceAccess";
export * from "./platform/modules";
export * from "./platform/ModuleContext";
export * from "./platform/moduleEvents";
export * from "./platform/ViewContext";
export * from "./platform/views";
export * from "./utils/permissions";
export * from "./utils/fieldHelp";
@@ -71,6 +73,7 @@ export { default as Dialog } from "./components/Dialog";
export { default as DisabledActionTooltip } from "./components/DisabledActionTooltip";
export type { DisabledActionTooltipProps } from "./components/DisabledActionTooltip";
export { default as DismissibleAlert } from "./components/DismissibleAlert";
export { default as ViewSurfaceRouteBoundary } from "./components/ViewSurfaceRouteBoundary";
export { default as EffectivePolicyBlock, EffectivePolicyValue } from "./components/EffectivePolicyBlock";
export type { EffectivePolicyBlockProps } from "./components/EffectivePolicyBlock";
export { default as FileDropZone } from "./components/FileDropZone";

View File

@@ -1,6 +1,6 @@
import { useRef, useState, useEffect } from "react";
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse } from "../types";
import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, ViewsRuntimeUiCapability } from "../types";
import HelpMenu from "./HelpMenu";
import LanguageMenu from "./LanguageMenu";
import LoginModal from "../features/auth/LoginModal";
@@ -10,7 +10,8 @@ import { apiFetch, isApiError } from "../api/client";
import { logout, switchTenant } from "../api/auth";
import { hasAnyScope } from "../utils/permissions";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import { usePlatformModules } from "../platform/ModuleContext";
import { usePlatformModules, usePlatformUiCapability } from "../platform/ModuleContext";
import { useEffectiveView } from "../platform/ViewContext";
type NotificationSummary = {
unread: number;
@@ -39,6 +40,9 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
const tenantRef = useRef<HTMLDivElement>(null);
const { translateText } = usePlatformLanguage();
const modules = usePlatformModules();
const projection = useEffectiveView();
const viewsRuntime = usePlatformUiCapability<ViewsRuntimeUiCapability>("views.runtime");
const ViewSelector = viewsRuntime?.Selector ?? null;
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
@@ -218,6 +222,9 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
<div className="titlebar-spacer" />
{auth && ViewSelector &&
<ViewSelector settings={settings} auth={auth} projection={projection} />
}
<LanguageMenu />
<HelpMenu auth={auth} />

View File

@@ -1,6 +1,8 @@
import { createContext, type ReactNode, useContext } from "react";
import type { PlatformWebModule } from "../types";
import { moduleInstalled, uiCapabilities, uiCapability } from "./modules";
import { useEffectiveView, useViewSurfaces } from "./ViewContext";
import { isViewSurfaceVisible, moduleViewSurfaceId } from "./views";
const PlatformModulesContext = createContext<PlatformWebModule[]>([]);
@@ -17,9 +19,22 @@ export function usePlatformModuleInstalled(moduleId: string): boolean {
}
export function usePlatformUiCapability<T = unknown>(capabilityName: string): T | null {
return uiCapability<T>(capabilityName, usePlatformModules());
return uiCapability<T>(capabilityName, useViewVisibleModules());
}
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
return uiCapabilities<T>(capabilityName, usePlatformModules());
return uiCapabilities<T>(capabilityName, useViewVisibleModules());
}
function useViewVisibleModules(): PlatformWebModule[] {
const modules = usePlatformModules();
const projection = useEffectiveView();
const surfaces = useViewSurfaces();
return modules.filter((module) =>
isViewSurfaceVisible(
projection,
moduleViewSurfaceId(module.id),
surfaces
)
);
}

View File

@@ -0,0 +1,73 @@
import { createContext, type ReactNode, useContext, useMemo } from "react";
import type {
EffectiveViewProjection,
PlatformViewSurface,
PlatformWebModule
} from "../types";
import {
isViewSurfaceVisible,
viewSurfaceCatalogueForModules
} from "./views";
type ViewContextValue = {
projection: EffectiveViewProjection | null;
surfaces: PlatformViewSurface[];
isVisible: (surfaceId: string | null | undefined) => boolean;
};
const ViewContext = createContext<ViewContextValue>({
projection: null,
surfaces: [],
isVisible: () => true
});
export function PlatformViewProvider({
modules,
projection,
children
}: {
modules: PlatformWebModule[];
projection: EffectiveViewProjection | null;
children: ReactNode;
}) {
const surfaces = useMemo(
() => viewSurfaceCatalogueForModules(modules),
[modules]
);
const value = useMemo<ViewContextValue>(
() => ({
projection,
surfaces,
isVisible: (surfaceId) =>
isViewSurfaceVisible(projection, surfaceId, surfaces)
}),
[projection, surfaces]
);
return <ViewContext.Provider value={value}>{children}</ViewContext.Provider>;
}
export function useEffectiveView(): EffectiveViewProjection | null {
return useContext(ViewContext).projection;
}
export function useViewSurfaces(): PlatformViewSurface[] {
return useContext(ViewContext).surfaces;
}
export function useViewSurfaceVisible(
surfaceId: string | null | undefined
): boolean {
return useContext(ViewContext).isVisible(surfaceId);
}
export function ViewSurfaceBoundary({
surfaceId,
children,
fallback = null
}: {
surfaceId: string;
children: ReactNode;
fallback?: ReactNode;
}) {
return useViewSurfaceVisible(surfaceId) ? <>{children}</> : <>{fallback}</>;
}

View File

@@ -1,4 +1,5 @@
import type { PlatformPublicRouteContribution, PlatformRouteContribution, PlatformWebModule } from "../types";
import { routeViewSurfaceId } from "./views";
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
for (const module of modules) {
@@ -30,7 +31,12 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
}
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
return modules.flatMap((module) =>
(module.routes ?? []).map((route) => ({
...route,
surfaceId: route.surfaceId ?? routeViewSurfaceId(module.id, route.path)
}))
).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}
export function publicRouteContributionsForModules(modules: PlatformWebModule[]): PlatformPublicRouteContribution[] {

View File

@@ -1,6 +1,6 @@
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react";
import installedWebModules from "virtual:govoplan-installed-modules";
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformWebModule } from "../types";
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, EffectiveViewProjection, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformViewSurface, PlatformWebModule } from "../types";
import {
hasUiCapability as hasUiCapabilityForModules,
moduleInstalled as moduleInstalledForModules,
@@ -11,6 +11,12 @@ import {
uiCapability as uiCapabilityForModules } from
"./moduleLogic";
import { hasAnyScope, hasScope } from "../utils/permissions";
import {
isViewSurfaceVisible,
navigationViewSurfaceId,
routeViewSurfaceId,
viewSurfaceCatalogueForModules
} from "./views";
export const fallbackDashboardNavItem: PlatformNavItem =
{ to: "/dashboard", label: "i18n:govoplan-core.dashboard.d87f47b4", iconName: "dashboard", order: 10 };
@@ -86,10 +92,65 @@ function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavIt
iconName: item.icon ?? null,
allOf: item.required_all,
anyOf: item.required_any,
order: item.order
order: item.order,
surfaceId: item.surface_id ?? undefined
};
}
function viewSurfaceFromMetadata(
item: NonNullable<NonNullable<PlatformModuleInfo["frontend"]>["view_surfaces"]>[number]
): PlatformViewSurface {
return {
id: item.id,
moduleId: item.module_id,
kind: item.kind,
label: item.label,
parentId: item.parent_id,
description: item.description,
order: item.order,
defaultVisible: item.default_visible,
required: item.required
};
}
function mergeViewSurfaces(
module: PlatformWebModule,
info: PlatformModuleInfo
): PlatformViewSurface[] | undefined {
const surfaces = new Map<string, PlatformViewSurface>();
for (const surface of info.frontend?.view_surfaces ?? []) {
const normalized = viewSurfaceFromMetadata(surface);
surfaces.set(normalized.id, normalized);
}
for (const surface of module.viewSurfaces ?? []) {
surfaces.set(surface.id, {
...surfaces.get(surface.id),
...surface,
required: Boolean(surfaces.get(surface.id)?.required || surface.required)
});
}
return surfaces.size ? [...surfaces.values()] : undefined;
}
function routesWithServerMetadata(
module: PlatformWebModule,
info: PlatformModuleInfo
) {
const routesByPath = new Map(
(info.frontend?.routes ?? []).map((route) => [route.path, route])
);
return (module.routes ?? []).map((route) => {
const metadata = routesByPath.get(route.path);
return {
...route,
surfaceId:
metadata?.surface_id ??
route.surfaceId ??
routeViewSurfaceId(module.id, route.path)
};
});
}
function runtimeUiCapabilitiesForModule(module: PlatformWebModule, info: PlatformModuleInfo) {
const enabledNames = new Set(info.runtime_ui_capabilities ?? []);
@@ -113,7 +174,9 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
routes: routesWithServerMetadata(module, info),
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
viewSurfaces: mergeViewSurfaces(module, info),
uiCapabilities: {
...(module.uiCapabilities ?? {}),
...runtimeUiCapabilitiesForModule(module, info)
@@ -353,26 +416,42 @@ export function publicRouteContributionsForModules(modules: PlatformWebModule[])
return publicRouteContributionsForModuleList(modules);
}
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
export function dashboardWidgetsForModules(
modules: PlatformWebModule[] = localModules,
projection?: EffectiveViewProjection | null
): DashboardWidgetContribution[] {
const catalogue = viewSurfaceCatalogueForModules(modules);
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
flatMap((capability) => capability.widgets ?? []).
filter((widget) => isViewSurfaceVisible(projection, widget.surfaceId, catalogue)).
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}
export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
return [...shellNavItemsForModules(modules), ...modules.flatMap((module) => module.navItems ?? [])].
export function navItemsForModules(
modules: PlatformWebModule[],
projection?: EffectiveViewProjection | null
): PlatformNavItem[] {
const catalogue = viewSurfaceCatalogueForModules(modules);
const moduleItems = modules.flatMap((module) =>
(module.navItems ?? []).map((item) => ({
...item,
surfaceId: item.surfaceId ?? navigationViewSurfaceId(module.id, item.to)
}))
);
return [...shellNavItemsForModules(modules), ...moduleItems].
map(resolveNavItemIcon).
filter((item) => isViewSurfaceVisible(projection, item.surfaceId, catalogue)).
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
}
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules): PlatformNavItem[] {
return navItemsForModules(modules).filter((item) => {
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): PlatformNavItem[] {
return navItemsForModules(modules, projection).filter((item) => {
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
return true;
});
}
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules): string {
return visibleNavItems(auth, modules)[0]?.to ?? "/dashboard";
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): string {
return visibleNavItems(auth, modules, projection)[0]?.to ?? "/dashboard";
}

133
webui/src/platform/views.ts Normal file
View File

@@ -0,0 +1,133 @@
import type {
EffectiveViewProjection,
PlatformRouteContribution,
PlatformViewSurface,
PlatformWebModule
} from "../types";
export const PLATFORM_VIEW_CHANGED_EVENT = "govoplan:view-changed";
export function moduleViewSurfaceId(moduleId: string): string {
return `${moduleId}.module`;
}
export function navigationViewSurfaceId(moduleId: string, path: string): string {
return `${moduleId}.nav.${surfaceSlug(path)}`;
}
export function routeViewSurfaceId(moduleId: string, path: string): string {
return `${moduleId}.route.${surfaceSlug(path)}`;
}
export function viewSurfaceCatalogueForModules(
modules: PlatformWebModule[]
): PlatformViewSurface[] {
const surfaces = new Map<string, PlatformViewSurface>();
for (const module of modules) {
const rootId = moduleViewSurfaceId(module.id);
addSurface(surfaces, {
id: rootId,
moduleId: module.id,
kind: "module",
label: module.label,
order: Math.min(
...(module.navItems?.map((item) => item.order ?? 100) ?? [100])
)
});
for (const item of module.navItems ?? []) {
addSurface(surfaces, {
id: item.surfaceId ?? navigationViewSurfaceId(module.id, item.to),
moduleId: module.id,
kind: "navigation",
label: item.label,
parentId: rootId,
order: item.order
});
}
for (const route of module.routes ?? []) {
addSurface(surfaces, {
id: route.surfaceId ?? routeViewSurfaceId(module.id, route.path),
moduleId: module.id,
kind: "route",
label: route.path,
description: route.path,
parentId: rootId,
order: route.order
});
}
for (const surface of module.viewSurfaces ?? []) {
addSurface(surfaces, {
...surface,
parentId: surface.parentId ?? rootId
});
}
}
return [...surfaces.values()].sort(
(left, right) =>
(left.order ?? 100) - (right.order ?? 100) ||
left.label.localeCompare(right.label)
);
}
export function isViewSurfaceVisible(
projection: EffectiveViewProjection | null | undefined,
surfaceId: string | null | undefined,
catalogue: PlatformViewSurface[]
): boolean {
if (!surfaceId || !projection?.activeViewId) return true;
const byId = new Map(catalogue.map((surface) => [surface.id, surface]));
const surface = byId.get(surfaceId);
if (!surface) return true;
const visible = new Set(projection.visibleSurfaceIds);
let current: PlatformViewSurface | undefined = surface;
const visited = new Set<string>();
while (current) {
if (visited.has(current.id)) return false;
visited.add(current.id);
if (!current.required && !visible.has(current.id)) return false;
current = current.parentId ? byId.get(current.parentId) : undefined;
}
return true;
}
export function visibleRoutesForProjection(
modules: PlatformWebModule[],
projection: EffectiveViewProjection | null | undefined
): PlatformRouteContribution[] {
const catalogue = viewSurfaceCatalogueForModules(modules);
return modules.flatMap((module) =>
(module.routes ?? []).filter((route) =>
isViewSurfaceVisible(
projection,
route.surfaceId ?? routeViewSurfaceId(module.id, route.path),
catalogue
)
)
);
}
export function dispatchPlatformViewChanged(): void {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(PLATFORM_VIEW_CHANGED_EVENT));
}
}
function addSurface(
target: Map<string, PlatformViewSurface>,
surface: PlatformViewSurface
): void {
const existing = target.get(surface.id);
if (!existing) {
target.set(surface.id, surface);
return;
}
target.set(surface.id, {
...existing,
...surface,
required: Boolean(existing.required || surface.required)
});
}
function surfaceSlug(value: string): string {
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.+|\.+$/g, "") || "root";
}

View File

@@ -227,6 +227,26 @@ export type CampaignListItem = {
export type PlatformIconName = string;
export type PlatformViewSurfaceKind =
| "module"
| "navigation"
| "route"
| "section"
| "action"
| "selector";
export type PlatformViewSurface = {
id: string;
moduleId: string;
kind: PlatformViewSurfaceKind;
label: string;
parentId?: string | null;
description?: string | null;
order?: number;
defaultVisible?: boolean;
required?: boolean;
};
export type PlatformNavItem = {
to: string;
label: string;
@@ -237,6 +257,7 @@ export type PlatformNavItem = {
anyOf?: string[];
allOf?: string[];
order?: number;
surfaceId?: string;
};
export type PlatformRouteContext = {
@@ -266,6 +287,7 @@ export type AdminSectionContribution = {
order?: number;
anyOf?: string[];
allOf?: string[];
surfaceId?: string;
render: (context: AdminSectionRenderContext) => ReactNode;
};
@@ -289,6 +311,7 @@ export type SettingsSectionContribution = {
order?: number;
anyOf?: string[];
allOf?: string[];
surfaceId?: string;
render: (context: SettingsSectionRenderContext) => ReactNode;
};
@@ -301,6 +324,7 @@ export type PlatformRouteContribution = {
anyOf?: string[];
allOf?: string[];
order?: number;
surfaceId?: string;
render: (context: PlatformRouteContext) => ReactNode;
};
@@ -333,6 +357,53 @@ export type PlatformWebModule = {
translations?: PlatformTranslations;
uiCapabilities?: PlatformUiCapabilities;
runtimeUiCapabilities?: PlatformUiCapabilities;
viewSurfaces?: PlatformViewSurface[];
};
export type EffectiveViewOption = {
id: string;
name: string;
description?: string | null;
revisionId: string;
};
export type EffectiveViewProjection = {
activeViewId: string | null;
activeRevisionId: string | null;
activeViewName: string | null;
visibleSurfaceIds: string[];
locked: boolean;
availableViews: EffectiveViewOption[];
provenance: Array<{
source: string;
scopeType?: string | null;
scopeId?: string | null;
detail?: string | null;
}>;
diagnostics: Array<{
severity: "warning" | "error";
code: string;
message: string;
surfaceIds: string[];
}>;
};
export type ViewSelectorProps = {
settings: ApiSettings;
auth: AuthInfo;
projection: EffectiveViewProjection | null;
};
export type ViewsRuntimeUiCapability = {
loadEffectiveView: (
settings: ApiSettings,
auth: AuthInfo
) => Promise<EffectiveViewProjection>;
activateView: (
settings: ApiSettings,
viewId: string | null
) => Promise<EffectiveViewProjection>;
Selector?: ComponentType<ViewSelectorProps>;
};
export type DashboardWidgetSize = "small" | "medium" | "wide" | "full";
@@ -353,6 +424,7 @@ export type DashboardWidgetContribution = {
moduleId?: string;
category?: string;
order?: number;
surfaceId?: string;
defaultVisible?: boolean;
defaultSize?: DashboardWidgetSize;
supportedSizes?: DashboardWidgetSize[];
@@ -383,6 +455,7 @@ export type OrganizationFunctionActionContribution = {
order?: number;
anyOf?: string[];
allOf?: string[];
surfaceId?: string;
onClick: (context: OrganizationFunctionActionContext) => void;
};
@@ -800,6 +873,7 @@ export type PlatformFrontendRouteInfo = {
required_all: string[];
required_any: string[];
order: number;
surface_id?: string | null;
};
export type PlatformFrontendModuleInfo = {
@@ -824,8 +898,21 @@ export type PlatformFrontendModuleInfo = {
required_all: string[];
required_any: string[];
order: number;
surface_id?: string | null;
}>;
settings_routes: PlatformFrontendRouteInfo[];
view_surface_contract_version?: string | null;
view_surfaces?: Array<{
id: string;
module_id: string;
kind: PlatformViewSurfaceKind;
label: string;
parent_id?: string | null;
description?: string | null;
order: number;
default_visible: boolean;
required: boolean;
}>;
};
export type PlatformPublicModuleInfo = {
@@ -861,6 +948,7 @@ export type PlatformModuleInfo = {
required_all: string[];
required_any: string[];
order: number;
surface_id?: string | null;
}>;
frontend?: PlatformFrontendModuleInfo | null;
};

View File

@@ -163,5 +163,9 @@ export const adminReadScopes = [
"access:system_setting:read",
"access:system_credential:read",
"access:credential:read",
"access:governance:read"
"access:governance:read",
"views:definition:read",
"views:assignment:read",
"views:system_definition:read",
"views:system_assignment:read"
];

View File

@@ -10,6 +10,11 @@ import {
routeContributionsForModules,
uiCapability
} from "../src/platform/moduleLogic";
import {
isViewSurfaceVisible,
viewSurfaceCatalogueForModules,
visibleRoutesForProjection
} from "../src/platform/views";
import { scopeGrants } from "../src/utils/permissions";
function assert(condition: unknown, message: string): void {
@@ -96,3 +101,73 @@ functionAction.onClick({
});
assert(selectedFunctionId === "function-1", "organization function actions receive their row context");
assert(!("render" in functionAction), "organization function actions expose metadata instead of arbitrary rendered controls");
const viewAwareFiles: PlatformWebModule = {
...files,
navItems: [{ to: "/files", label: "Files", order: 20 }],
viewSurfaces: [
{
id: "files.settings.connectors",
moduleId: "files",
kind: "section",
label: "File connectors"
}
]
};
const viewCatalogue = viewSurfaceCatalogueForModules([viewAwareFiles]);
assert(
viewCatalogue.some((surface) => surface.id === "files.module"),
"view catalogue should derive a module root"
);
assert(
viewCatalogue.some((surface) => surface.id === "files.nav.files"),
"view catalogue should derive navigation surfaces"
);
assert(
viewCatalogue.some((surface) => surface.id === "files.route.files"),
"view catalogue should derive route surfaces"
);
const filesView = {
activeViewId: "view-files",
activeRevisionId: "revision-files",
activeViewName: "Files only",
visibleSurfaceIds: [
"files.module",
"files.nav.files",
"files.route.files"
],
locked: false,
availableViews: [],
provenance: [],
diagnostics: []
};
assert(
isViewSurfaceVisible(filesView, "files.route.files", viewCatalogue),
"selected child surfaces should remain visible when their ancestor is selected"
);
assert(
!isViewSurfaceVisible(filesView, "files.settings.connectors", viewCatalogue),
"unselected sections should be hidden"
);
assert(
visibleRoutesForProjection([viewAwareFiles], filesView).length === 1,
"selected routes should remain in the effective route list"
);
const missingParentView = {
...filesView,
visibleSurfaceIds: ["files.route.files"]
};
assert(
!isViewSurfaceVisible(
missingParentView,
"files.route.files",
viewCatalogue
),
"a selected child should stay hidden when its module ancestor is not selected"
);
assert(
isViewSurfaceVisible(filesView, "future.module.action", viewCatalogue),
"unknown surfaces should fail open for forward compatibility and recovery"
);

View File

@@ -29,6 +29,7 @@ const defaultWebModulePackages = [
"@govoplan/ops-webui",
"@govoplan/policy-webui",
"@govoplan/scheduling-webui",
"@govoplan/views-webui",
"@govoplan/workflow-webui"
];
@@ -125,6 +126,7 @@ export default defineConfig({
fileURLToPath(new URL('../../govoplan-ops/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-policy/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-views/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))
]
},