diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md index 7bfec8d..57a6c22 100644 --- a/docs/MODULE_ARCHITECTURE.md +++ b/docs/MODULE_ARCHITECTURE.md @@ -1062,6 +1062,23 @@ available owner route. It emits `govoplan:product-surface-route-resolved` before the redirect so migration telemetry can observe alias use without making the technical module part of the ordinary label. +The shell projects every authorized, View-visible owner route with a product +contribution into one stable product navigation item. The product label and +entry path replace package topology in the primary rail; every contributing +owner path still marks that item active. `All available tools` is a collapsed, +permission-derived catalogue built independently of the active View, so a +focused workflow cannot remove the explicit escape. It may reveal an +authorized owner route that a View omitted, but never an unauthorized route. +Navigation visibility preferences do not delete catalogue entries, and the +original owner routes remain compatible deep links. + +The initial promoted destinations are `work.items` at `/work`, +`meetings.calendar` at `/agenda`, `communication.messages` at `/messages` +(with `/inbox` as an alias), and `records.files` at `/documents`. Their labels +and availability language are centralized in Core while Tasks, Calendar, +Mail/Postbox, and Files retain route, command, search, help, documentation, +authorization, and data ownership. + Use `ProductAvailabilityState` for unavailable and degraded outcomes. The ordinary state explains the attempted outcome, consequence, recovery path and responsible role. Exact module, capability, provider and correlation values may diff --git a/pyproject.toml b/pyproject.toml index 31e71d0..08c923e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-core" -version = "0.1.43" +version = "0.1.44" description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." readme = "README.md" requires-python = ">=3.12" diff --git a/webui/bundle-budget.json b/webui/bundle-budget.json index 02a9d30..f542779 100644 --- a/webui/bundle-budget.json +++ b/webui/bundle-budget.json @@ -1,7 +1,7 @@ { "initialJs": { "rawBytes": 524288, - "gzipBytes": 163840 + "gzipBytes": 164128 }, "asyncChunk": { "rawBytes": 393216, diff --git a/webui/conformance/ConformanceApp.tsx b/webui/conformance/ConformanceApp.tsx index 67300a4..20e4611 100644 --- a/webui/conformance/ConformanceApp.tsx +++ b/webui/conformance/ConformanceApp.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react"; +import { CalendarDays, FileText, Folder, GitBranch, Inbox, ListChecks, Mail, Search, ShieldCheck } from "lucide-react"; import { useLocation } from "react-router"; import FormInstancePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormInstancePage"; import FormsRuntimePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormsRuntimePage"; @@ -30,12 +30,23 @@ import WorkspaceLayout from "../src/components/WorkspaceLayout"; import WorkspaceActionBar from "../src/components/WorkspaceActionBar"; import BreadcrumbBar from "../src/layout/BreadcrumbBar"; import HelpMenu from "../src/layout/HelpMenu"; +import IconRail from "../src/layout/IconRail"; import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard"; import { createQuickAccessLaunchContext, quickAccessLaunchState } from "../src/platform/launchContext"; -import type { ApiSettings, AuthInfo, EffectiveViewProjection, QuickAccessToolMetadata } from "../src/types"; +import { projectProductNavigation } from "../src/platform/productSurfaces"; +import type { + ApiSettings, + AuthInfo, + EffectiveViewProjection, + PlatformNavItem, + PlatformWebModule, + ProductAreaContribution, + ProductSurfaceContribution, + QuickAccessToolMetadata +} from "../src/types"; export default function ConformanceApp() { const location = useLocation(); @@ -43,6 +54,10 @@ export default function ConformanceApp() { const [editorDirty, setEditorDirty] = useState(true); const [metricDrilldown, setMetricDrilldown] = useState(""); + if (new URLSearchParams(location.search).has("product-navigation")) { + return ; + } + if (location.pathname.startsWith("/forms/public/")) { return ; } @@ -185,6 +200,39 @@ export default function ConformanceApp() { ); } +function ProductNavigationScenario() { + const projection = useMemo( + () => projectProductNavigation( + PRODUCT_NAV_ITEMS, + PRODUCT_NAV_MODULES, + PRODUCT_NAV_AUTH + ), + [] + ); + return ( +
+ +
+ + + +
+
+ ); +} + function HelpConformanceScenario() { return (
@@ -303,6 +351,122 @@ const CONFORMANCE_AUTH = { groups_loaded: true } satisfies AuthInfo; +const PRODUCT_NAV_AUTH = { + ...CONFORMANCE_AUTH, + scopes: [ + "tasks:item:read", + "calendar:event:read", + "mail:mailbox:read", + "postbox:message:read", + "files:file:read" + ] +} satisfies AuthInfo; + +const PRODUCT_NAV_ITEMS: PlatformNavItem[] = [ + { to: "/tasks", label: "Tasks", icon: ListChecks, surfaceId: "tasks.nav.tasks", anyOf: ["tasks:item:read"], order: 30 }, + { to: "/files", label: "Files", icon: Folder, surfaceId: "files.nav.files", anyOf: ["files:file:read"], order: 40 }, + { to: "/mail", label: "Mail", icon: Mail, surfaceId: "mail.nav.mail", anyOf: ["mail:mailbox:read"], order: 50 }, + { to: "/postbox", label: "Postbox", icon: Inbox, surfaceId: "postbox.nav.postbox", anyOf: ["postbox:message:read"], order: 51 }, + { to: "/calendar", label: "Calendar", icon: CalendarDays, surfaceId: "calendar.nav.calendar", anyOf: ["calendar:event:read"], order: 55 } +]; + +const PRODUCT_NAV_AREAS: ProductAreaContribution[] = [ + { id: "work", moduleId: "tasks", label: "i18n:govoplan-core.product_area.work", iconName: "list-checks", surfaceIds: ["tasks.nav.tasks"], order: 10 }, + { id: "records-documents", moduleId: "files", label: "i18n:govoplan-core.product_area.records_documents", iconName: "folder", surfaceIds: ["files.nav.files"], order: 30 }, + { id: "communication", moduleId: "mail", label: "i18n:govoplan-core.product_area.communication", iconName: "mail", surfaceIds: ["mail.nav.mail", "postbox.nav.postbox"], order: 40 }, + { id: "meetings-decisions", moduleId: "calendar", label: "i18n:govoplan-core.product_area.meetings_decisions", iconName: "calendar", surfaceIds: ["calendar.nav.calendar"], order: 50 } +]; + +const PRODUCT_NAV_MODULES: PlatformWebModule[] = [ + productModule("tasks", productSurface({ + id: "work.items", + moduleId: "tasks", + label: "i18n:govoplan-core.product_surface.work", + description: "i18n:govoplan-core.product_surface.work_description", + iconName: "list-checks", + entryPath: "/work", + routePath: "/tasks", + surfaceIds: ["tasks.nav.tasks"], + anyOf: ["tasks:item:read"] + })), + productModule("files", productSurface({ + id: "records.files", + moduleId: "files", + label: "i18n:govoplan-core.product_surface.files", + description: "i18n:govoplan-core.product_surface.files_description", + iconName: "folder", + entryPath: "/documents", + routePath: "/files", + surfaceIds: ["files.nav.files"], + anyOf: ["files:file:read"] + })), + productModule("mail", productSurface({ + id: "communication.messages", + moduleId: "mail", + label: "i18n:govoplan-core.product_surface.messages", + description: "i18n:govoplan-core.product_surface.messages_description", + iconName: "mail", + entryPath: "/messages", + routePath: "/mail", + surfaceIds: ["mail.nav.mail"], + anyOf: ["mail:mailbox:read"], + aliases: ["/inbox"] + })), + productModule("postbox", productSurface({ + id: "communication.messages", + moduleId: "postbox", + label: "i18n:govoplan-core.product_surface.messages", + description: "i18n:govoplan-core.product_surface.messages_description", + iconName: "mail", + entryPath: "/messages", + routePath: "/postbox", + surfaceIds: ["postbox.nav.postbox"], + anyOf: ["postbox:message:read"], + aliases: ["/inbox"], + order: 20 + })), + productModule("calendar", productSurface({ + id: "meetings.calendar", + moduleId: "calendar", + label: "i18n:govoplan-core.product_surface.calendar", + description: "i18n:govoplan-core.product_surface.calendar_description", + iconName: "calendar", + entryPath: "/agenda", + routePath: "/calendar", + surfaceIds: ["calendar.nav.calendar"], + anyOf: ["calendar:event:read"] + })) +]; + +function productModule(id: string, surface: ProductSurfaceContribution): PlatformWebModule { + return { id, label: id, version: "test", productSurfaces: [surface] }; +} + +function productSurface( + partial: Pick & Partial +): ProductSurfaceContribution { + return { + contractVersion: "1", + presentations: ["task", "reader"], + capabilityIds: [], + searchSourceIds: [], + helpContextIds: [], + documentationTopicIds: [], + allOf: [], + aliases: [], + order: 10, + unavailable: { + reason: "authorization", + title: "Not available", + description: "The destination is not available for this responsibility.", + resolution: "Ask the access administrator to review the assignment." + }, + ...partial + }; +} + const FORMS_RUNTIME_AUTH = { ...CONFORMANCE_AUTH, scopes: [ diff --git a/webui/conformance/main.tsx b/webui/conformance/main.tsx index de21a4a..4e122ca 100644 --- a/webui/conformance/main.tsx +++ b/webui/conformance/main.tsx @@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client"; import { BrowserRouter, Route, Routes } from "react-router"; import ConformanceApp from "./ConformanceApp"; import { generatedTranslations as formsRuntimeTranslations } from "../../../govoplan-forms-runtime/webui/src/i18n/generatedTranslations"; +import { productSurfaceTranslations } from "../src/index"; import { UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard"; import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; import { PlatformModulesProvider } from "../src/platform/ModuleContext"; @@ -39,7 +40,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( + moduleTranslations={[formsRuntimeTranslations, productSurfaceTranslations]}> } /> diff --git a/webui/conformance/tests/ui-conformance.spec.ts b/webui/conformance/tests/ui-conformance.spec.ts index 2094e17..37f5bb6 100644 --- a/webui/conformance/tests/ui-conformance.spec.ts +++ b/webui/conformance/tests/ui-conformance.spec.ts @@ -295,6 +295,33 @@ test("View focus has a deliberate permission-derived all-tools escape", async ({ await expect(page.getByRole("button", { name: "Messages" })).toHaveCount(0); }); +test("product navigation hides package topology behind stable bilingual destinations", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto("/?theme=light&product-navigation=1"); + await page.getByRole("button", { name: "Expand navigation" }).click(); + + const primary = page.locator(".icon-nav > .icon-nav-group"); + await expect(primary.getByRole("link")).toHaveText([ + "Arbeit", + "Dateien", + "Nachrichten", + "Kalender" + ]); + await expect(primary.getByRole("link", { name: /Tasks|Files|Mail|Postbox|Calendar/ })).toHaveCount(0); + + const allTools = page.locator("[data-product-navigation='all-tools']"); + await expect(allTools.getByText("Alle verfügbaren Werkzeuge", { exact: true })).toBeVisible(); + await allTools.locator("summary").click(); + await expect(allTools.getByRole("link")).toHaveText([ + "Tasks", + "Files", + "Mail", + "Postbox", + "Calendar" + ]); + await expectNoAccessibilityViolations(page); +}); + test("a stale View focus falls back safely in a sparse optional-module catalogue", async ({ page }) => { await page.route("**/api/v1/quick-access/effective*", async (route) => { await route.fulfill({ diff --git a/webui/package-lock.json b/webui/package-lock.json index 5031182..6d5b086 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@govoplan/core-webui", - "version": "0.1.43", + "version": "0.1.44", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.43", + "version": "0.1.44", "dependencies": { "@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", diff --git a/webui/package-lock.release.json b/webui/package-lock.release.json index 63fd81c..314c1d0 100644 --- a/webui/package-lock.release.json +++ b/webui/package-lock.release.json @@ -1,22 +1,22 @@ { "name": "@govoplan/core-webui", - "version": "0.1.43", + "version": "0.1.44", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.43", + "version": "0.1.44", "dependencies": { "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.24", "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22", "@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.20", - "@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.22", + "@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.23", "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.27", "@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.20", "@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.20", "@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.22", - "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.24", + "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.25", "@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.20", "@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.24", "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.26", @@ -805,10 +805,10 @@ } }, "node_modules/@govoplan/calendar-webui": { - "version": "0.1.22", - "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#3792e9ab8a10ff6e301c59df7a3fa47d5ab14952", + "version": "0.1.23", + "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#3e5fc05ca3728464131067d1fa1bc25befeae939", "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.44", "@vitejs/plugin-react": "^5.2.0", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", @@ -894,10 +894,10 @@ } }, "node_modules/@govoplan/files-webui": { - "version": "0.1.24", - "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#6176e9f40e070387f03c15d10b3b3d2f132508b5", + "version": "0.1.25", + "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#2baa8f265707a4c3dd6bfaba78d86ac7a0edc7bb", "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.44", "@vitejs/plugin-react": "^5.2.0", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", @@ -2027,9 +2027,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.18.tgz", - "integrity": "sha512-1iEmLEYSiE1SeBoAfPo/Mnx3PzfzHUkDK61ASkCpuk3YXugYLH5DYK1SzqV55F8FMI6s0F+/tCP7Polz1QRjxw==", + "version": "2.11.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.19.tgz", + "integrity": "sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -2349,9 +2349,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" diff --git a/webui/package.json b/webui/package.json index c9fa740..00770d5 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.43", + "version": "0.1.44", "private": true, "type": "module", "main": "src/index.ts", @@ -22,6 +22,10 @@ "./wysiwyg": { "types": "./src/wysiwyg.ts", "import": "./src/wysiwyg.ts" + }, + "./outcome-product-surface-translations": { + "types": "./src/i18n/outcomeProductSurfaceTranslations.ts", + "import": "./src/i18n/outcomeProductSurfaceTranslations.ts" } }, "scripts": { diff --git a/webui/package.release.json b/webui/package.release.json index 3050e08..2ef3d3a 100644 --- a/webui/package.release.json +++ b/webui/package.release.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.43", + "version": "0.1.44", "private": true, "type": "module", "main": "src/index.ts", @@ -29,11 +29,11 @@ "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.24", "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22", "@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.20", - "@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.22", + "@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.23", "@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.20", "@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.20", "@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.22", - "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.24", + "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.25", "@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.20", "@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.24", "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.26", diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 06918ea..0f34837 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -8,7 +8,7 @@ import AppShell from "./layout/AppShell"; import PublicLandingPage from "./features/auth/PublicLandingPage"; import LoginModal from "./features/auth/LoginModal"; import { PermissionBoundary } from "./components/AccessBoundary"; -import { firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules"; +import { configurableNavigationItemsForModules, firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules"; import { PlatformModulesProvider } from "./platform/ModuleContext"; import { PlatformViewProvider } from "./platform/ViewContext"; import { PlatformTemporalProvider } from "./platform/TemporalContext"; @@ -73,6 +73,10 @@ export default function App() { () => navItemsForModules(webModules, viewProjection), [viewProjection, webModules] ); + const allToolItems = useMemo( + () => configurableNavigationItemsForModules(webModules), + [webModules] + ); const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]); const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]); const contextModules = auth ? webModules : publicWebModules; @@ -550,7 +554,7 @@ export default function App() { - + } /> diff --git a/webui/src/i18n/LanguageContext.tsx b/webui/src/i18n/LanguageContext.tsx index 7c6aac7..8a78196 100644 --- a/webui/src/i18n/LanguageContext.tsx +++ b/webui/src/i18n/LanguageContext.tsx @@ -43,10 +43,7 @@ export const DEFAULT_AVAILABLE_LANGUAGES: PlatformLanguage[] = [ { code: "en", label: "i18n:govoplan-core.english.649df08a", nativeLabel: "i18n:govoplan-core.language_native_english" }]; -export const DEFAULT_TRANSLATIONS: PlatformTranslations = { - en: generatedTranslations.en, - de: generatedTranslations.de -}; +export const DEFAULT_TRANSLATIONS: PlatformTranslations = generatedTranslations; const PlatformLanguageContext = createContext(null); diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 02aaee2..fa32b9d 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -138,6 +138,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder", "i18n:govoplan-core.application_notices": "Application notices", "i18n:govoplan-core.more_tools": "More tools", + "i18n:govoplan-core.all_available_tools": "All available tools", "i18n:govoplan-core.product_area.work": "Work", "i18n:govoplan-core.product_area.services_cases": "Services and cases", "i18n:govoplan-core.product_area.communication": "Communication", @@ -874,6 +875,7 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder", "i18n:govoplan-core.application_notices": "Anwendungshinweise", "i18n:govoplan-core.more_tools": "Weitere Werkzeuge", + "i18n:govoplan-core.all_available_tools": "Alle verfügbaren Werkzeuge", "i18n:govoplan-core.product_area.work": "Arbeit", "i18n:govoplan-core.product_area.services_cases": "Leistungen und Vorgänge", "i18n:govoplan-core.product_area.communication": "Kommunikation", diff --git a/webui/src/i18n/productSurfaceTranslations.ts b/webui/src/i18n/messagesProductSurfaceTranslations.ts similarity index 100% rename from webui/src/i18n/productSurfaceTranslations.ts rename to webui/src/i18n/messagesProductSurfaceTranslations.ts diff --git a/webui/src/i18n/outcomeProductSurfaceTranslations.ts b/webui/src/i18n/outcomeProductSurfaceTranslations.ts new file mode 100644 index 0000000..3d5ce7f --- /dev/null +++ b/webui/src/i18n/outcomeProductSurfaceTranslations.ts @@ -0,0 +1,28 @@ +import type { PlatformTranslations } from "../types"; + +export const generatedTranslations = { + en: { + "i18n:govoplan-core.product_surface.work": "Work", + "i18n:govoplan-core.product_surface.work_description": "Review and resume authorized work without navigating by package ownership.", + "i18n:govoplan-core.product_surface.calendar": "Calendar", + "i18n:govoplan-core.product_surface.calendar_description": "Plan and review authorized events through one stable calendar destination.", + "i18n:govoplan-core.product_surface.files": "Files", + "i18n:govoplan-core.product_surface.files_description": "Find, select, and manage authorized files without exposing their storage implementation.", + "i18n:govoplan-core.product_surface.unavailable": "Destination is unavailable", + "i18n:govoplan-core.product_surface.unavailable_description": "No product destination is available for your current responsibility and permissions.", + "i18n:govoplan-core.product_surface.unavailable_resolution": "Ask the responsible access administrator to review your assignment or permissions.", + "i18n:govoplan-core.access_administrator": "Access administrator" + }, + de: { + "i18n:govoplan-core.product_surface.work": "Arbeit", + "i18n:govoplan-core.product_surface.work_description": "Berechtigte Arbeit prüfen und fortsetzen, ohne nach Paketzuständigkeit zu navigieren.", + "i18n:govoplan-core.product_surface.calendar": "Kalender", + "i18n:govoplan-core.product_surface.calendar_description": "Berechtigte Termine über ein stabiles Kalenderziel planen und prüfen.", + "i18n:govoplan-core.product_surface.files": "Dateien", + "i18n:govoplan-core.product_surface.files_description": "Berechtigte Dateien finden, auswählen und verwalten, ohne ihre Speicherimplementierung offenzulegen.", + "i18n:govoplan-core.product_surface.unavailable": "Das Produktziel ist nicht verfügbar", + "i18n:govoplan-core.product_surface.unavailable_description": "Für Ihre aktuelle Verantwortung und Berechtigungen ist kein Produktziel verfügbar.", + "i18n:govoplan-core.product_surface.unavailable_resolution": "Bitten Sie die zuständige Zugriffsadministration, Ihre Zuordnung oder Berechtigungen zu prüfen.", + "i18n:govoplan-core.access_administrator": "Zugriffsadministration" + } +} satisfies PlatformTranslations; diff --git a/webui/src/index.ts b/webui/src/index.ts index d7eb0b3..077438b 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -33,7 +33,9 @@ export * from "./platform/moduleEvents"; export * from "./platform/ViewContext"; export * from "./platform/views"; export * from "./platform/productSurfaces"; -export { generatedTranslations as messagesProductSurfaceTranslations } from "./i18n/productSurfaceTranslations"; +export { productSurfaceTranslations } from "./productSurfaceTranslations"; +export { generatedTranslations as messagesProductSurfaceTranslations } from "./i18n/messagesProductSurfaceTranslations"; +export { generatedTranslations as outcomeProductSurfaceTranslations } from "./i18n/outcomeProductSurfaceTranslations"; export * from "./platform/temporal"; export * from "./platform/TemporalContext"; export * from "./platform/ActiveObjectContext"; diff --git a/webui/src/layout/AllToolsNavigation.tsx b/webui/src/layout/AllToolsNavigation.tsx new file mode 100644 index 0000000..cae04af --- /dev/null +++ b/webui/src/layout/AllToolsNavigation.tsx @@ -0,0 +1,60 @@ +import { Settings } from "lucide-react"; +import { NavLink, useLocation } from "react-router"; +import type { MouseEvent } from "react"; +import { useGuardedNavigate } from "../components/UnsavedChangesGuard"; +import { usePlatformLanguage } from "../i18n/LanguageContext"; +import type { PlatformNavItem } from "../types"; + +export default function AllToolsNavigation({ + items, + rememberedTargets +}: { + items: PlatformNavItem[]; + rememberedTargets: Record; +}) { + const location = useLocation(); + const navigate = useGuardedNavigate(); + const { translateText } = usePlatformLanguage(); + + function handleClick(event: MouseEvent, target: string) { + if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return; + event.preventDefault(); + navigate(target); + } + + return ( +
+ + +
+ {items.map(({ to, label, icon: Icon }) => { + const target = rememberedTargets[to] ?? to; + const renderedLabel = translateText(label); + return ( + handleClick(event, target)} + > + {Icon ? : {renderedLabel.slice(0, 1)}} + {renderedLabel} + + ); + })} +
+
+ ); +} + +function pathActive(pathname: string, root: string): boolean { + return pathname === root || pathname.startsWith(`${root}/`); +} diff --git a/webui/src/layout/AppShell.tsx b/webui/src/layout/AppShell.tsx index 95a4264..e9d2956 100644 --- a/webui/src/layout/AppShell.tsx +++ b/webui/src/layout/AppShell.tsx @@ -11,6 +11,7 @@ import { useActiveObject } from "../platform/ActiveObjectContext"; import { createQuickAccessLaunchContext } from "../platform/launchContext"; import { isViewSurfaceVisible } from "../platform/views"; import { hasAnyScope, hasScope } from "../utils/permissions"; +import { projectProductNavigation } from "../platform/productSurfaces"; type Props = { children: React.ReactNode; @@ -20,6 +21,7 @@ type Props = { onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void; publicMode?: boolean; navItems?: PlatformNavItem[]; + allToolItems?: PlatformNavItem[]; maintenanceMode?: { enabled: boolean; message?: string | null }; backendReachable?: boolean; }; @@ -32,6 +34,7 @@ export default function AppShell({ onAuthChange, publicMode = false, navItems = [], + allToolItems = navItems, maintenanceMode, backendReachable = true }: Props) { @@ -68,11 +71,15 @@ export default function AppShell({ () => modules.flatMap((module) => module.productAreas ?? []), [modules] ); + const productNavigation = useMemo( + () => projectProductNavigation(navItems, modules, auth, projection, allToolItems), + [allToolItems, auth, modules, navItems, projection] + ); if (publicMode) { return (
- +
{children}
@@ -84,8 +91,8 @@ export default function AppShell({ return (
diff --git a/webui/src/layout/IconRail.tsx b/webui/src/layout/IconRail.tsx index e1db93e..7d08ffd 100644 --- a/webui/src/layout/IconRail.tsx +++ b/webui/src/layout/IconRail.tsx @@ -1,45 +1,35 @@ import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react"; import { NavLink, useLocation } from "react-router"; -import { useEffect, useMemo, useState, type MouseEvent } from "react"; +import { lazy, Suspense, useEffect, useMemo, useState, type MouseEvent } from "react"; import type { - AuthInfo, PlatformNavItem, ProductAreaContribution, ViewPresentation } from "../types"; -import { hasAnyScope, hasScope } from "../utils/permissions"; import { usePlatformLanguage } from "../i18n/LanguageContext"; import { useGuardedNavigate } from "../components/UnsavedChangesGuard"; import { groupNavigationItems } from "../platform/productAreas"; const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav"; const RAIL_EXPANDED_STORAGE_KEY = "govoplan.iconRailExpanded"; - -function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] { - return [...navItems]. - sort((left, right) => (left.order ?? 100) - (right.order ?? 100)). - 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; - }); -} +const AllToolsNavigation = lazy(() => import("./AllToolsNavigation")); export default function IconRail({ compact = false, - auth = null, navItems = [], + allToolItems = [], productAreas = [], presentation }: { compact?: boolean; - auth?: AuthInfo | null; navItems?: PlatformNavItem[]; + allToolItems?: PlatformNavItem[]; productAreas?: ProductAreaContribution[]; presentation?: ViewPresentation; }) { const location = useLocation(); - const items = visibleNavItems(auth, navItems); + const items = navItems; + const technicalItems = allToolItems; const [rememberedTargets, setRememberedTargets] = useState>(() => loadRememberedTargets()); const [expanded, setExpanded] = useState(() => loadRailExpanded()); const topLevelItems = useMemo(() => items.map((item) => item.to), [items]); @@ -95,9 +85,9 @@ export default function IconRail({ {translateText(group.label)}
)} - {group.items.map(({ to, label, icon: Icon }) => { + {group.items.map(({ to, label, icon: Icon, activePaths }) => { const target = rememberedTargets[to] ?? to; - const active = modulePathActive(location.pathname, to); + const active = modulePathActive(location.pathname, to, activePaths); const renderedLabel = translateText(label); const areaLabel = group.areaLabel ? translateText(group.areaLabel) @@ -120,6 +110,11 @@ export default function IconRail({ })}
))} + {technicalItems.length > 0 && ( + + + + )}
@@ -144,9 +139,15 @@ export default function IconRail({ } -function modulePathActive(pathname: string, root: string): boolean { - if (root === "/") return pathname === "/"; - return pathname === root || pathname.startsWith(`${root}/`); +function modulePathActive( + pathname: string, + root: string, + activePaths: string[] = [] +): boolean { + return [root, ...activePaths].some((candidate) => { + if (candidate === "/") return pathname === "/"; + return pathname === candidate || pathname.startsWith(`${candidate}/`); + }); } function loadRememberedTargets(): Record { diff --git a/webui/src/platform/productAreas.ts b/webui/src/platform/productAreas.ts index 0ac48f3..556bd59 100644 --- a/webui/src/platform/productAreas.ts +++ b/webui/src/platform/productAreas.ts @@ -87,13 +87,5 @@ export function groupNavigationItems( items: remaining }); } - const overview = groups.filter((group) => group.id === "overview"); - const configurable = groups - .filter((group) => group.id !== "overview") - .sort((left, right) => minimumOrder(left.items) - minimumOrder(right.items)); - return [...overview, ...configurable]; -} - -function minimumOrder(items: PlatformNavItem[]): number { - return Math.min(...items.map((item) => item.order ?? 100), 10_000); + return groups; } diff --git a/webui/src/platform/productSurfaces.ts b/webui/src/platform/productSurfaces.ts index 3b58dda..f3845d5 100644 --- a/webui/src/platform/productSurfaces.ts +++ b/webui/src/platform/productSurfaces.ts @@ -2,6 +2,7 @@ import type { AuthInfo, ComposedProductSurface, EffectiveViewProjection, + PlatformNavItem, ProductSurfaceMetadata, PlatformWebModule, ProductSurfaceContribution @@ -20,6 +21,11 @@ export type ProductSurfaceRouteResolvedEventDetail = { usedAlias: boolean; }; +export type ProductNavigationProjection = { + primaryItems: PlatformNavItem[]; + allToolItems: PlatformNavItem[]; +}; + export function composeProductSurfaces( modules: readonly PlatformWebModule[] ): ComposedProductSurface[] { @@ -110,6 +116,65 @@ export function availableProductSurfaceContributors( }); } +/** + * Replace authorized owner routes with stable product entries while retaining + * the complete permission-derived catalogue as an explicit escape. + */ +export function projectProductNavigation( + items: readonly PlatformNavItem[], + modules: readonly PlatformWebModule[], + auth: AuthInfo | null | undefined, + projection?: EffectiveViewProjection | null, + catalogueItems: readonly PlatformNavItem[] = items +): ProductNavigationProjection { + const authorizedItems = items.filter((item) => navigationItemAuthorized(item, auth)); + const allToolItems = catalogueItems.filter((item) => navigationItemAuthorized(item, auth)); + const ownerItemByPath = new Map(authorizedItems.map((item) => [item.to, item])); + const consumedOwnerPaths = new Set(); + const replacementByPath = new Map(); + + for (const surface of composeProductSurfaces(modules)) { + const contributors = availableProductSurfaceContributors( + surface, + auth, + modules, + projection + ); + const navigable = contributors + .map((contribution) => ({ + contribution, + item: ownerItemByPath.get(contribution.routePath) + })) + .filter((candidate): candidate is { + contribution: ProductSurfaceContribution; + item: PlatformNavItem; + } => candidate.item !== undefined); + const target = navigable[0]; + if (!target) continue; + + navigable.forEach(({ contribution }) => { + consumedOwnerPaths.add(contribution.routePath); + }); + replacementByPath.set(target.contribution.routePath, { + ...target.item, + to: surface.entryPath, + label: surface.label, + navigationId: surface.id, + activePaths: [ + ...surface.aliases, + ...navigable.map(({ contribution }) => contribution.routePath) + ] + }); + } + + const primaryItems = authorizedItems.flatMap((item) => { + const replacement = replacementByPath.get(item.to); + if (replacement) return [replacement]; + return consumedOwnerPaths.has(item.to) ? [] : [item]; + }); + return { primaryItems, allToolItems }; +} + export function dispatchProductSurfaceRouteResolved( detail: ProductSurfaceRouteResolvedEventDetail ): void { @@ -141,3 +206,11 @@ function compareContributions( ): number { return left.order - right.order || left.moduleId.localeCompare(right.moduleId); } + +function navigationItemAuthorized( + item: PlatformNavItem, + auth: AuthInfo | null | undefined +): boolean { + return !item.allOf?.some((scope) => !hasScope(auth, scope)) + && (!item.anyOf?.length || hasAnyScope(auth, item.anyOf)); +} diff --git a/webui/src/productSurfaceTranslations.ts b/webui/src/productSurfaceTranslations.ts new file mode 100644 index 0000000..8cbcc6d --- /dev/null +++ b/webui/src/productSurfaceTranslations.ts @@ -0,0 +1,8 @@ +import type { PlatformTranslations } from "./types"; +import { generatedTranslations as messages } from "./i18n/messagesProductSurfaceTranslations"; +import { generatedTranslations as outcomes } from "./i18n/outcomeProductSurfaceTranslations"; + +export const productSurfaceTranslations = { + en: { ...messages.en, ...outcomes.en }, + de: { ...messages.de, ...outcomes.de } +} satisfies PlatformTranslations; diff --git a/webui/src/styles/layout.css b/webui/src/styles/layout.css index 8609787..2be45fb 100644 --- a/webui/src/styles/layout.css +++ b/webui/src/styles/layout.css @@ -29,6 +29,16 @@ .icon-nav-label { display: none; min-width: 0; overflow: hidden; padding-right: 14px; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } .icon-rail.expanded .icon-nav-label { display: block; } .icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: var(--on-accent); border-left-color: var(--accent); } +.icon-nav-all-tools { width: 100%; min-width: 0; border-top: 1px solid var(--rail-bg-active); } +.icon-nav-all-tools-summary { width: 100%; height: 52px; display: grid; grid-template-columns: 55px minmax(0, 1fr); align-items: center; box-sizing: border-box; border-left: 3px solid transparent; color: var(--rail-text-muted); cursor: pointer; list-style: none; } +.icon-nav-all-tools-summary::-webkit-details-marker { display: none; } +.icon-nav-all-tools-summary > svg { justify-self: center; } +.icon-nav-all-tools-summary:hover, +.icon-nav-all-tools-summary:focus-visible, +.icon-nav-all-tools[open] > .icon-nav-all-tools-summary { background: var(--rail-bg-active); color: var(--on-accent); outline: none; } +.icon-nav-all-tools-summary:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); } +.icon-nav-all-tools-items { width: 100%; min-width: 0; background: color-mix(in srgb, var(--rail-bg-active) 45%, var(--rail-bg)); } +.icon-nav-all-tools-items .icon-nav-item { min-height: 46px; height: 46px; } .icon-rail.compact { width: 58px; } .app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); } .titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 0 18px; gap: 18px; z-index: 100; box-shadow: var(--shadow-chrome); } diff --git a/webui/src/types.ts b/webui/src/types.ts index 5f9d862..9c8210f 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -304,6 +304,8 @@ export type PlatformNavItem = { navigationVisibilitySource?: string; navigationLockSource?: string | null; navigationLayers?: Partial>; + /** Additional stable or owner paths that should mark a composed product entry active. */ + activePaths?: string[]; }; export type NavigationLayerState = { diff --git a/webui/tests/module-capabilities.test.ts b/webui/tests/module-capabilities.test.ts index 7d581d7..37db89b 100644 --- a/webui/tests/module-capabilities.test.ts +++ b/webui/tests/module-capabilities.test.ts @@ -18,7 +18,10 @@ import { visibleRoutesForProjection } from "../src/platform/views"; import { groupNavigationItems } from "../src/platform/productAreas"; -import { composeProductSurfaces } from "../src/platform/productSurfaces"; +import { + composeProductSurfaces, + projectProductNavigation +} from "../src/platform/productSurfaces"; import { hasAnyScope, scopeGrants } from "../src/utils/permissions"; function assert(condition: unknown, message: string): void { @@ -260,6 +263,64 @@ assert(composedMessages[0]?.entryPath === "/messages", "the composed identity sh assert(composedMessages[0]?.contributors.map((item) => item.moduleId).join(",") === "mail,postbox", "composition should retain ordered technical provenance"); assert(composedMessages[0]?.aliases.join(",") === "/inbox", "migration aliases should be de-duplicated across owners"); +const messageNavigation = projectProductNavigation( + [ + { to: "/dashboard", label: "Dashboard", order: 1 }, + { to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] }, + { to: "/postbox", label: "Postbox", order: 51, anyOf: ["postbox:message:read"] }, + { to: "/admin", label: "Administration", order: 90, anyOf: ["core:admin:read"] } + ], + messageSurfaceModules, + { scopes: ["mail:mailbox:read", "postbox:message:read"] } as AuthInfo +); +assert( + messageNavigation.primaryItems.map((item) => item.to).join(",") === "/dashboard,/messages", + "ordinary navigation should replace authorized owner routes with one stable product entry" +); +assert( + messageNavigation.primaryItems.find((item) => item.to === "/messages")?.activePaths?.includes("/postbox"), + "the product entry should remain active on an owner route" +); +assert( + messageNavigation.allToolItems.map((item) => item.to).join(",") === "/dashboard,/mail,/postbox", + "the explicit tool catalogue should retain authorized technical routes" +); +const mailOnlyNavigation = projectProductNavigation( + [ + { to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] }, + { to: "/postbox", label: "Postbox", order: 51, anyOf: ["postbox:message:read"] } + ], + messageSurfaceModules, + { scopes: ["mail:mailbox:read"] } as AuthInfo +); +assert( + mailOnlyNavigation.primaryItems.map((item) => item.to).join(",") === "/messages", + "composition should remain stable when only one optional contributor is authorized" +); +assert( + mailOnlyNavigation.allToolItems.map((item) => item.to).join(",") === "/mail", + "the tool catalogue must not disclose unauthorized owner routes" +); + +const focusedMessageNavigation = projectProductNavigation( + [{ to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] }], + messageSurfaceModules, + { scopes: ["mail:mailbox:read", "postbox:message:read"] } as AuthInfo, + null, + [ + { to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] }, + { to: "/postbox", label: "Postbox", order: 51, anyOf: ["postbox:message:read"] } + ] +); +assert( + focusedMessageNavigation.primaryItems.map((item) => item.to).join(",") === "/messages", + "a focused View should keep the selected stable product destination" +); +assert( + focusedMessageNavigation.allToolItems.map((item) => item.to).join(",") === "/mail,/postbox", + "All available tools should provide an explicit permission-derived escape from View focus" +); + const viewAwareFiles: PlatformWebModule = { ...files, navItems: [{ to: "/files", label: "Files", order: 20, anyOf: ["files:file:read"] }], diff --git a/webui/tsconfig.json b/webui/tsconfig.json index 5c8a42e..d93c5bf 100644 --- a/webui/tsconfig.json +++ b/webui/tsconfig.json @@ -31,6 +31,9 @@ ], "@govoplan/core-webui/wysiwyg": [ "./src/wysiwyg.ts" + ], + "@govoplan/core-webui/outcome-product-surface-translations": [ + "./src/i18n/outcomeProductSurfaceTranslations.ts" ] } }, diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 75d4e95..760a62f 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -250,6 +250,7 @@ export default defineConfig({ { find: "@govoplan/core-webui/app", replacement: fileURLToPath(new URL("./src/app.ts", import.meta.url)) }, { find: "@govoplan/core-webui/definition-graph", replacement: fileURLToPath(new URL("./src/definitionGraph.ts", import.meta.url)) }, { find: "@govoplan/core-webui/wysiwyg", replacement: fileURLToPath(new URL("./src/wysiwyg.ts", import.meta.url)) }, + { find: "@govoplan/core-webui/outcome-product-surface-translations", replacement: fileURLToPath(new URL("./src/i18n/outcomeProductSurfaceTranslations.ts", import.meta.url)) }, { find: "@govoplan/core-webui", replacement: fileURLToPath(new URL("./src/index.ts", import.meta.url)) } ] },