Add governed View surface runtime
This commit is contained in:
21
webui/package-lock.json
generated
21
webui/package-lock.json
generated
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>);
|
||||
|
||||
|
||||
58
webui/src/components/ViewSurfaceRouteBoundary.tsx
Normal file
58
webui/src/components/ViewSurfaceRouteBoundary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
73
webui/src/platform/ViewContext.tsx
Normal file
73
webui/src/platform/ViewContext.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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
133
webui/src/platform/views.ts
Normal 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";
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
];
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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))
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user