From cf16a7b27a2eb0ace1b6fc354e13409a05b5a475 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 20 Aug 2026 01:47:04 +0200 Subject: [PATCH] feat(core): add layered side rail preferences --- docs/PAGE_LAYOUT_USAGE_GUIDELINES.md | 24 +++ src/govoplan_core/api/v1/schemas.py | 10 + src/govoplan_core/core/navigation.py | 175 ++++++++++++++++++ src/govoplan_core/server/platform.py | 133 +++++++++++-- tests/test_api_smoke.py | 60 ++++++ tests/test_navigation_preferences.py | 69 +++++++ .../components/NavigationPreferenceEditor.tsx | 138 ++++++++++++++ webui/src/features/settings/SettingsPage.tsx | 34 +++- webui/src/index.ts | 1 + webui/src/platform/modules.ts | 29 ++- webui/src/platform/productAreas.ts | 10 +- webui/src/styles/components.css | 63 +++++++ webui/src/types.ts | 36 ++++ 13 files changed, 763 insertions(+), 19 deletions(-) create mode 100644 src/govoplan_core/core/navigation.py create mode 100644 tests/test_navigation_preferences.py create mode 100644 webui/src/components/NavigationPreferenceEditor.tsx diff --git a/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md b/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md index 29e30ac..c4ec49d 100644 --- a/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md +++ b/docs/PAGE_LAYOUT_USAGE_GUIDELINES.md @@ -18,6 +18,30 @@ wording, authorization, consequences, and data state. - Do not reproduce shared page padding, heading, toolbar, form-grid, section, table, dialog, or breakpoint CSS in a module. +## Product Side Rail + +Module manifests contribute stable navigation surface identifiers, labels, +paths, icons, and default order. Core owns the side-rail composition and the +shared `NavigationPreferenceEditor`; modules must not fork this editor or +persist their own rail ordering. + +Navigation preferences are layered in this order: module defaults, system, +tenant, then user. Each higher layer may reorder or change visibility. System +and tenant administrators may lock an entry visible; a lower layer can still +move that entry, but cannot hide it. Personal preferences cannot create locks. +An unset preference inherits the complete lower layer, while “Use inherited +order” removes the current layer rather than copying its values. Unknown item +identifiers remain harmless so uninstalling, disabling, or later reinstalling +a module does not corrupt the rail. + +The platform module response projects module, system, and tenant layer states +alongside the effective user state. Editors must initialize from the layer +immediately below the scope they edit, so a system or tenant administrator's +personal preference is never promoted accidentally. Preference saves refresh +the platform module projection. View policy, permissions, and tenant module +entitlements remain independent final visibility gates; changing rail +preferences never grants access. + ## Semantic Page Archetypes | Archetype | Use when | diff --git a/src/govoplan_core/api/v1/schemas.py b/src/govoplan_core/api/v1/schemas.py index 6848b11..2b2128b 100644 --- a/src/govoplan_core/api/v1/schemas.py +++ b/src/govoplan_core/api/v1/schemas.py @@ -93,6 +93,15 @@ class TenantMembershipInfo(TenantInfo): is_active: bool = True +class NavigationPreferencesPayload(BaseModel): + model_config = ConfigDict(extra="forbid") + + contract_version: Literal["1"] = "1" + order: list[str] = Field(default_factory=list, max_length=256) + hidden: list[str] = Field(default_factory=list, max_length=256) + locked: list[str] = Field(default_factory=list, max_length=256) + + class UserUiPreferences(BaseModel): model_config = ConfigDict(extra="ignore") @@ -101,6 +110,7 @@ class UserUiPreferences(BaseModel): reduce_motion: bool = False sticky_section_sidebars: bool = True theme: Literal["system", "light", "dark"] = "system" + navigation: NavigationPreferencesPayload | None = None class UserInfo(BaseModel): diff --git a/src/govoplan_core/core/navigation.py b/src/govoplan_core/core/navigation.py new file mode 100644 index 0000000..34e5cb9 --- /dev/null +++ b/src/govoplan_core/core/navigation.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable, Mapping + +NAVIGATION_PREFERENCES_KEY = "navigation_preferences" +NAVIGATION_PREFERENCES_CONTRACT_VERSION = "1" +_MAX_ITEMS = 256 + + +@dataclass(frozen=True, slots=True) +class NavigationPreferences: + order: tuple[str, ...] = () + hidden: tuple[str, ...] = () + locked: tuple[str, ...] = () + + def as_dict(self) -> dict[str, object]: + return { + "contract_version": NAVIGATION_PREFERENCES_CONTRACT_VERSION, + "order": list(self.order), + "hidden": list(self.hidden), + "locked": list(self.locked), + } + + +@dataclass(frozen=True, slots=True) +class EffectiveNavigationItem: + id: str + order: int + visible: bool + locked: bool + order_source: str + visibility_source: str + lock_source: str | None = None + + def as_dict(self) -> dict[str, object]: + return { + "navigation_id": self.id, + "order": self.order, + "navigation_visible": self.visible, + "navigation_locked": self.locked, + "navigation_order_source": self.order_source, + "navigation_visibility_source": self.visibility_source, + "navigation_lock_source": self.lock_source, + } + + +def navigation_preferences_from_settings( + settings: object, +) -> NavigationPreferences | None: + if not isinstance(settings, Mapping): + return None + raw = settings.get(NAVIGATION_PREFERENCES_KEY) + if not isinstance(raw, Mapping): + return None + return navigation_preferences_from_mapping(raw) + + +def navigation_preferences_from_mapping( + raw: Mapping[str, Any], +) -> NavigationPreferences: + return NavigationPreferences( + order=_ids(raw.get("order")), + hidden=_ids(raw.get("hidden")), + locked=_ids(raw.get("locked")), + ) + + +def update_navigation_preferences( + settings: object, + preferences: NavigationPreferences | Mapping[str, Any] | None, +) -> dict[str, Any]: + updated = dict(settings) if isinstance(settings, Mapping) else {} + if preferences is None: + updated.pop(NAVIGATION_PREFERENCES_KEY, None) + else: + raw = preferences.as_dict() if isinstance(preferences, NavigationPreferences) else preferences + updated[NAVIGATION_PREFERENCES_KEY] = navigation_preferences_from_mapping( + raw + ).as_dict() + return updated + + +def resolve_navigation_preferences( + item_ids: Iterable[str], + *, + system: NavigationPreferences | None = None, + tenant: NavigationPreferences | None = None, + user: NavigationPreferences | None = None, +) -> dict[str, EffectiveNavigationItem]: + ordered = list(dict.fromkeys(_clean_id(item_id) for item_id in item_ids)) + ordered = [item_id for item_id in ordered if item_id] + available = set(ordered) + order_source = {item_id: "module" for item_id in ordered} + visibility = {item_id: True for item_id in ordered} + visibility_source = {item_id: "module" for item_id in ordered} + locks: dict[str, str] = {} + + for source, preferences, may_lock in ( + ("system", system, True), + ("tenant", tenant, True), + ("user", user, False), + ): + if preferences is None: + continue + requested_order = [item_id for item_id in preferences.order if item_id in available] + if requested_order: + requested = set(requested_order) + ordered = [*requested_order, *(item_id for item_id in ordered if item_id not in requested)] + for item_id in requested_order: + order_source[item_id] = source + + requested_hidden = set(preferences.hidden).intersection(available) + for item_id in available: + if item_id in locks: + visibility[item_id] = True + visibility_source[item_id] = locks[item_id] + continue + visibility[item_id] = item_id not in requested_hidden + visibility_source[item_id] = source + + if may_lock: + for item_id in preferences.locked: + if item_id not in available: + continue + locks[item_id] = source + visibility[item_id] = True + visibility_source[item_id] = source + + return { + item_id: EffectiveNavigationItem( + id=item_id, + order=index, + visible=visibility[item_id], + locked=item_id in locks, + order_source=order_source[item_id], + visibility_source=visibility_source[item_id], + lock_source=locks.get(item_id), + ) + for index, item_id in enumerate(ordered) + } + + +def _ids(value: object) -> tuple[str, ...]: + if not isinstance(value, (list, tuple)): + return () + cleaned = tuple( + dict.fromkeys( + item_id + for item in value[:_MAX_ITEMS] + if (item_id := _clean_id(item)) + ) + ) + return cleaned + + +def _clean_id(value: object) -> str: + if not isinstance(value, str): + return "" + clean = value.strip() + if not clean or len(clean) > 255 or any(ord(character) < 32 for character in clean): + return "" + return clean + + +__all__ = [ + "EffectiveNavigationItem", + "NAVIGATION_PREFERENCES_CONTRACT_VERSION", + "NAVIGATION_PREFERENCES_KEY", + "NavigationPreferences", + "navigation_preferences_from_mapping", + "navigation_preferences_from_settings", + "resolve_navigation_preferences", + "update_navigation_preferences", +] diff --git a/src/govoplan_core/server/platform.py b/src/govoplan_core/server/platform.py index dc5ed98..7a7e009 100644 --- a/src/govoplan_core/server/platform.py +++ b/src/govoplan_core/server/platform.py @@ -7,6 +7,12 @@ from govoplan_core.admin.models import SystemSettings from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID from govoplan_core.auth import ApiPrincipal, get_api_principal, require_any_scope from govoplan_core.core.maintenance import saved_maintenance_mode +from govoplan_core.core.navigation import ( + EffectiveNavigationItem, + navigation_preferences_from_mapping, + navigation_preferences_from_settings, + resolve_navigation_preferences, +) from govoplan_core.core.module_entitlements import ( module_entitlement_payload, tenant_module_entitlement_state, @@ -87,21 +93,116 @@ def _effective_manifest_state( ) -def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]: +def _effective_navigation_state( + principal: ApiPrincipal, + manifests: tuple[ModuleManifest, ...], +) -> dict[str, dict[str, EffectiveNavigationItem]]: + default_items: list[tuple[int, str, str]] = [] + for manifest in manifests: + frontend_items = ( + manifest.frontend.nav_items + if manifest.frontend is not None and manifest.frontend.nav_items + else manifest.nav_items + ) + for item in frontend_items: + navigation_id = item.surface_id or navigation_view_surface_id( + manifest.id, item.path + ) + default_items.append((item.order, item.label, navigation_id)) + default_ids = [ + item_id + for _order, _label, item_id in sorted( + default_items, key=lambda item: (item[0], item[1], item[2]) + ) + ] + + principal_ref = getattr(principal, "principal", None) + tenant_id = getattr(principal_ref, "tenant_id", None) + system_preferences = None + tenant_preferences = None + if tenant_id is not None: + try: + with get_database().session() as session: + system_item = session.get(SystemSettings, SYSTEM_SETTINGS_ID) + tenant = session.get(Tenant, tenant_id) + system_preferences = navigation_preferences_from_settings( + system_item.settings if system_item is not None else {} + ) + tenant_preferences = navigation_preferences_from_settings( + tenant.settings if tenant is not None else {} + ) + except (RuntimeError, SQLAlchemyError) as exc: + raise HTTPException( + status_code=503, + detail="Navigation preferences could not be resolved.", + ) from exc + + user_settings = getattr(getattr(principal, "user", None), "settings", {}) + user_ui = user_settings.get("ui") if isinstance(user_settings, dict) else {} + user_navigation = user_ui.get("navigation") if isinstance(user_ui, dict) else None + user_preferences = ( + navigation_preferences_from_mapping(user_navigation) + if isinstance(user_navigation, dict) + else None + ) return { + "module": resolve_navigation_preferences(default_ids), + "system": resolve_navigation_preferences( + default_ids, + system=system_preferences, + ), + "tenant": resolve_navigation_preferences( + default_ids, + system=system_preferences, + tenant=tenant_preferences, + ), + "user": resolve_navigation_preferences( + default_ids, + system=system_preferences, + tenant=tenant_preferences, + user=user_preferences, + ), + } + + +def _nav_item_payload( + item: NavItem, + module_id: str | None = None, + navigation: dict[str, dict[str, EffectiveNavigationItem]] | None = None, +) -> dict[str, object]: + navigation_id = ( + item.surface_id or navigation_view_surface_id(module_id, item.path) + if module_id + else item.surface_id + ) + effective = ( + navigation.get("user", {}).get(navigation_id) + if navigation_id and navigation + else None + ) + payload = { "path": item.path, "label": item.label, "icon": item.icon, "section": item.section, "required_all": list(item.required_all), "required_any": list(item.required_any), - "order": item.order, - "surface_id": ( - item.surface_id or navigation_view_surface_id(module_id, item.path) - if module_id - else item.surface_id - ), + "order": effective.order if effective is not None else item.order, + "surface_id": navigation_id, } + if effective is not None: + payload.update(effective.as_dict()) + payload["navigation_layers"] = { + scope: { + "order": scoped.order, + "visible": scoped.visible, + "locked": scoped.locked, + "lock_source": scoped.lock_source, + } + for scope in ("module", "system", "tenant") + if (scoped := navigation.get(scope, {}).get(navigation_id)) is not None + } + return payload def _frontend_route_payload(route: FrontendRoute, module_id: str | None = None) -> dict[str, object]: @@ -241,7 +342,10 @@ def _documentation_help_contexts(manifest: ModuleManifest) -> list[dict[str, obj return contexts -def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None: +def _frontend_payload( + manifest: ModuleManifest, + navigation: dict[str, dict[str, EffectiveNavigationItem]] | None = None, +) -> dict[str, object] | None: frontend = manifest.frontend if frontend is None: return None @@ -257,7 +361,10 @@ def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None: "public_routes": [ _public_frontend_route_payload(route) for route in frontend.public_routes ], - "nav": [_nav_item_payload(item, manifest.id) for item in frontend.nav_items], + "nav": [ + _nav_item_payload(item, manifest.id, navigation) + for item in frontend.nav_items + ], "settings_routes": [_frontend_route_payload(route, manifest.id) for route in frontend.settings_routes], "view_surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION, "view_surfaces": _frontend_view_surfaces(manifest), @@ -323,6 +430,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter: request, principal, ) + navigation_state = _effective_navigation_state(principal, manifests) principal_ref = getattr(principal, "principal", None) tenant_id = getattr(principal_ref, "tenant_id", None) return { @@ -351,8 +459,11 @@ def create_platform_router(settings: object | None = None) -> APIRouter: if key != "declarations" }, "help_contexts": _documentation_help_contexts(manifest), - "nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items], - "frontend": _frontend_payload(manifest), + "nav": [ + _nav_item_payload(item, manifest.id, navigation_state) + for item in manifest.nav_items + ], + "frontend": _frontend_payload(manifest, navigation_state), } for manifest in manifests ], diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py index 317e346..02f9232 100644 --- a/tests/test_api_smoke.py +++ b/tests/test_api_smoke.py @@ -5301,6 +5301,12 @@ class ApiSmokeTests(unittest.TestCase): "allow_tenant_api_keys": system_item["allow_tenant_api_keys"], "available_languages": available_languages, "enabled_language_codes": enabled_codes, + "navigation": { + "contract_version": "1", + "order": ["mail.navigation.mail", "files.navigation.files"], + "hidden": [], + "locked": ["mail.navigation.mail"], + }, }, ) self.assertEqual(updated_system.status_code, 200, updated_system.text) @@ -5314,7 +5320,29 @@ class ApiSmokeTests(unittest.TestCase): system_delta_payload = system_delta.json() self.assertFalse(system_delta_payload["full"]) self.assertIn("languages", system_delta_payload["changed_sections"]) + self.assertIn("navigation", system_delta_payload["changed_sections"]) self.assertIn("fr", system_delta_payload["sections"]["languages"]["enabled_language_codes"]) + self.assertEqual( + ["mail.navigation.mail"], + system_delta_payload["sections"]["navigation"]["locked"], + ) + + tenant_item = tenant_initial_payload["item"] + updated_tenant = self.client.patch( + "/api/v1/admin/tenant/settings", + headers=headers, + json={ + "default_locale": tenant_item["default_locale"], + "enabled_language_codes": tenant_item["enabled_language_codes"], + "navigation": { + "contract_version": "1", + "order": ["files.navigation.files"], + "hidden": ["files.navigation.files"], + "locked": [], + }, + }, + ) + self.assertEqual(updated_tenant.status_code, 200, updated_tenant.text) tenant_delta = self.client.get( "/api/v1/admin/tenant/settings/delta", @@ -5325,7 +5353,12 @@ class ApiSmokeTests(unittest.TestCase): tenant_delta_payload = tenant_delta.json() self.assertFalse(tenant_delta_payload["full"]) self.assertIn("languages", tenant_delta_payload["changed_sections"]) + self.assertIn("navigation", tenant_delta_payload["changed_sections"]) self.assertIn("fr", tenant_delta_payload["sections"]["languages"]["system_enabled_language_codes"]) + self.assertEqual( + ["files.navigation.files"], + tenant_delta_payload["sections"]["navigation"]["hidden"], + ) def test_tenant_admin_delta_tracks_create_and_update(self) -> None: headers, _ = self._login() @@ -6431,6 +6464,11 @@ class ApiSmokeTests(unittest.TestCase): "reduce_motion": True, "sticky_section_sidebars": False, "theme": "dark", + "navigation": { + "contract_version": "1", + "order": ["files.navigation.files", "mail.navigation.mail"], + "hidden": ["mail.navigation.mail"], + }, }, }, ) @@ -6445,8 +6483,30 @@ class ApiSmokeTests(unittest.TestCase): "reduce_motion": True, "sticky_section_sidebars": False, "theme": "dark", + "navigation": { + "contract_version": "1", + "order": ["files.navigation.files", "mail.navigation.mail"], + "hidden": ["mail.navigation.mail"], + "locked": [], + }, }, ) + rejected_lock = self.client.patch( + "/api/v1/auth/profile", + headers=headers, + json={ + "ui_preferences": { + **profile.json()["user"]["ui_preferences"], + "navigation": { + "contract_version": "1", + "order": [], + "hidden": [], + "locked": ["files.navigation.files"], + }, + }, + }, + ) + self.assertEqual(rejected_lock.status_code, 422, rejected_lock.text) refreshed = self.client.get("/api/v1/auth/me", headers=headers) self.assertEqual(refreshed.status_code, 200, refreshed.text) self.assertEqual(refreshed.json()["user"]["display_name"], "Global Account Name") diff --git a/tests/test_navigation_preferences.py b/tests/test_navigation_preferences.py new file mode 100644 index 0000000..c03bbc3 --- /dev/null +++ b/tests/test_navigation_preferences.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import unittest + +from govoplan_core.core.navigation import ( + NavigationPreferences, + navigation_preferences_from_settings, + resolve_navigation_preferences, + update_navigation_preferences, +) + + +class NavigationPreferenceTests(unittest.TestCase): + def test_user_order_overrides_tenant_and_system_order(self) -> None: + resolved = resolve_navigation_preferences( + ("dashboard", "files", "mail", "campaign"), + system=NavigationPreferences(order=("mail", "files")), + tenant=NavigationPreferences(order=("campaign", "mail")), + user=NavigationPreferences(order=("files", "campaign")), + ) + + self.assertEqual( + ["files", "campaign", "mail", "dashboard"], + [item.id for item in sorted(resolved.values(), key=lambda item: item.order)], + ) + self.assertEqual("user", resolved["files"].order_source) + self.assertEqual("user", resolved["campaign"].order_source) + + def test_lower_scope_cannot_hide_locked_item(self) -> None: + resolved = resolve_navigation_preferences( + ("dashboard", "files", "mail"), + system=NavigationPreferences(locked=("dashboard",)), + tenant=NavigationPreferences(hidden=("dashboard", "files"), locked=("mail",)), + user=NavigationPreferences(hidden=("dashboard", "mail")), + ) + + self.assertTrue(resolved["dashboard"].visible) + self.assertTrue(resolved["dashboard"].locked) + self.assertEqual("system", resolved["dashboard"].lock_source) + self.assertTrue(resolved["mail"].visible) + self.assertEqual("tenant", resolved["mail"].lock_source) + self.assertTrue(resolved["files"].visible) + self.assertEqual("user", resolved["files"].visibility_source) + + def test_higher_scope_visibility_replaces_inherited_preference(self) -> None: + resolved = resolve_navigation_preferences( + ("files", "mail"), + system=NavigationPreferences(hidden=("mail",)), + tenant=NavigationPreferences(hidden=("files",)), + ) + + self.assertTrue(resolved["mail"].visible) + self.assertFalse(resolved["files"].visible) + self.assertEqual("tenant", resolved["mail"].visibility_source) + + def test_settings_round_trip_is_bounded_and_normalized(self) -> None: + settings = update_navigation_preferences( + {"unrelated": {"preserved": True}}, + NavigationPreferences(order=(" files ", "files", "mail"), hidden=("mail",)), + ) + parsed = navigation_preferences_from_settings(settings) + + self.assertEqual(("files", "mail"), parsed.order if parsed else ()) + self.assertEqual(("mail",), parsed.hidden if parsed else ()) + self.assertEqual({"preserved": True}, settings["unrelated"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/components/NavigationPreferenceEditor.tsx b/webui/src/components/NavigationPreferenceEditor.tsx new file mode 100644 index 0000000..ab9449f --- /dev/null +++ b/webui/src/components/NavigationPreferenceEditor.tsx @@ -0,0 +1,138 @@ +import { ArrowDown, ArrowUp, LockKeyhole } from "lucide-react"; +import type { NavigationPreferences, PlatformNavItem } from "../types"; +import Button from "./Button"; +import IconButton from "./IconButton"; +import ToggleSwitch from "./ToggleSwitch"; +import { usePlatformLanguage } from "../i18n/LanguageContext"; + +type Scope = "system" | "tenant" | "user"; + +export default function NavigationPreferenceEditor({ + items, + value, + onChange, + scope, + disabled = false +}: { + items: PlatformNavItem[]; + value: NavigationPreferences | null; + onChange: (value: NavigationPreferences | null) => void; + scope: Scope; + disabled?: boolean; +}) { + const { translateText } = usePlatformLanguage(); + const inheritedScope = scope === "system" ? "module" : scope === "tenant" ? "system" : "tenant"; + const inherited = preferenceFromLayer(items, inheritedScope); + const editable = value ?? inherited; + const byId = new Map(items.map((item) => [navigationId(item), item])); + const inheritedIds = [...items] + .sort((left, right) => layerOrder(left, inheritedScope) - layerOrder(right, inheritedScope)) + .map(navigationId); + const orderedIds = [ + ...editable.order.filter((id) => byId.has(id)), + ...inheritedIds.filter((id) => !editable.order.includes(id)) + ]; + const hidden = new Set(editable.hidden); + const localLocks = new Set(editable.locked ?? []); + + function update(patch: Partial) { + onChange({ ...editable, ...patch, contract_version: "1" }); + } + + function move(id: string, offset: -1 | 1) { + const index = orderedIds.indexOf(id); + const target = index + offset; + if (index < 0 || target < 0 || target >= orderedIds.length) return; + const next = [...orderedIds]; + [next[index], next[target]] = [next[target], next[index]]; + update({ order: next }); + } + + function setVisible(id: string, visible: boolean) { + const next = new Set(hidden); + if (visible) next.delete(id); + else next.add(id); + update({ order: orderedIds, hidden: [...next] }); + } + + function setLocked(id: string, locked: boolean) { + const next = new Set(localLocks); + if (locked) next.add(id); + else next.delete(id); + const nextHidden = new Set(hidden); + if (locked) nextHidden.delete(id); + update({ order: orderedIds, hidden: [...nextHidden], locked: [...next] }); + } + + return ( +
+
+

+ Higher personal settings take precedence over tenant and system order. Locked entries remain visible. +

+ +
+
    + {orderedIds.map((id, index) => { + const item = byId.get(id); + if (!item) return null; + const inheritedState = item.navigationLayers?.[inheritedScope]; + const ancestorLocked = Boolean(inheritedState?.locked); + const locked = ancestorLocked || localLocks.has(id); + const label = translateText(item.label); + return ( +
  1. +
    + } onClick={() => move(id, -1)} disabled={disabled || index === 0} /> + } onClick={() => move(id, 1)} disabled={disabled || index === orderedIds.length - 1} /> +
    +
    + {label} + {id} +
    + setVisible(id, visible)} + /> + {scope !== "user" && ( +
  2. + ); + })} +
+
+ ); +} + +function navigationId(item: PlatformNavItem): string { + return item.navigationId ?? item.surfaceId ?? item.to; +} + +function preferenceFromLayer( + items: PlatformNavItem[], + layer: "module" | "system" | "tenant" +): NavigationPreferences { + const ordered = [...items].sort((left, right) => layerOrder(left, layer) - layerOrder(right, layer)); + return { + contract_version: "1", + order: ordered.map(navigationId), + hidden: ordered.filter((item) => item.navigationLayers?.[layer]?.visible === false).map(navigationId), + locked: [] + }; +} + +function layerOrder(item: PlatformNavItem, layer: "module" | "system" | "tenant"): number { + return item.navigationLayers?.[layer]?.order ?? item.order ?? 100; +} diff --git a/webui/src/features/settings/SettingsPage.tsx b/webui/src/features/settings/SettingsPage.tsx index 6f22940..c9993ab 100644 --- a/webui/src/features/settings/SettingsPage.tsx +++ b/webui/src/features/settings/SettingsPage.tsx @@ -2,7 +2,7 @@ import DescriptionList from "../../components/DescriptionList"; import ContentGrid, { FormGrid } from "../../components/ContentGrid"; import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router"; -import type { ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPreferences, UserUiTheme } from "../../types"; +import type { ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, NavigationPreferences, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPreferences, UserUiTheme } from "../../types"; import Card from "../../components/Card"; import FormField from "../../components/FormField"; import PasswordField from "../../components/PasswordField"; @@ -12,11 +12,14 @@ import PageActionBar from "../../components/PageActionBar"; import ToggleSwitch from "../../components/ToggleSwitch"; import { apiFetch } from "../../api/client"; import { fetchAuthProfile, fetchAuthRoles, updateProfile } from "../../api/auth"; +import { dispatchPlatformModulesChanged } from "../../platform/moduleEvents"; import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav"; import DismissibleAlert from "../../components/DismissibleAlert"; import SegmentedControl from "../../components/SegmentedControl"; import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard"; -import { usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext"; +import { usePlatformModules, usePlatformUiCapabilities, usePlatformUiCapability } from "../../platform/ModuleContext"; +import { configurableNavigationItemsForModules } from "../../platform/modules"; +import NavigationPreferenceEditor from "../../components/NavigationPreferenceEditor"; import { useEffectiveView, useViewSurfaces } from "../../platform/ViewContext"; import { isViewSurfaceVisible } from "../../platform/views"; import { hasAnyScope, hasScope } from "../../utils/permissions"; @@ -103,6 +106,8 @@ export default function SettingsPage({ const mailProfilesUi = usePlatformUiCapability("mail.profiles"); const fileConnectorsUi = usePlatformUiCapability("files.connectors"); const settingsSectionCapabilities = usePlatformUiCapabilities("settings.sections"); + const platformModules = usePlatformModules(); + const navigationItems = useMemo(() => configurableNavigationItemsForModules(platformModules), [platformModules]); const effectiveView = useEffectiveView(); const viewSurfaces = useViewSurfaces(); const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage(); @@ -150,6 +155,7 @@ export default function SettingsPage({ const [reduceMotion, setReduceMotion] = useState(currentUiPreferences.reduce_motion); const [stickySections, setStickySections] = useState(currentUiPreferences.sticky_section_sidebars); const [theme, setTheme] = useState(currentUiPreferences.theme); + const [navigation, setNavigation] = useState(currentUiPreferences.navigation ?? null); const [uiBusy, setUiBusy] = useState(false); const [uiResult, setUiResult] = useState(""); const [uiResultTone, setUiResultTone] = useState<"success" | "warning">("success"); @@ -164,7 +170,8 @@ export default function SettingsPage({ showHelpHints !== currentUiPreferences.show_inline_help_hints || reduceMotion !== currentUiPreferences.reduce_motion || stickySections !== currentUiPreferences.sticky_section_sidebars || - theme !== currentUiPreferences.theme; + theme !== currentUiPreferences.theme || + JSON.stringify(navigation) !== JSON.stringify(currentUiPreferences.navigation ?? null); useUnsavedDraftGuard({ dirty: active === "profile" && profileDirty, @@ -218,12 +225,14 @@ export default function SettingsPage({ setReduceMotion(currentUiPreferences.reduce_motion); setStickySections(currentUiPreferences.sticky_section_sidebars); setTheme(currentUiPreferences.theme); + setNavigation(currentUiPreferences.navigation ?? null); }, [ currentUiPreferences.compact_tables, currentUiPreferences.show_inline_help_hints, currentUiPreferences.reduce_motion, currentUiPreferences.sticky_section_sidebars, - currentUiPreferences.theme + currentUiPreferences.theme, + currentUiPreferences.navigation ]); function selectSection(section: SettingsSection) { @@ -262,6 +271,7 @@ export default function SettingsPage({ setReduceMotion(currentUiPreferences.reduce_motion); setStickySections(currentUiPreferences.sticky_section_sidebars); setTheme(currentUiPreferences.theme); + setNavigation(currentUiPreferences.navigation ?? null); } function uiPreferencePayload(): UserUiPreferences { @@ -270,7 +280,8 @@ export default function SettingsPage({ show_inline_help_hints: showHelpHints, reduce_motion: reduceMotion, sticky_section_sidebars: stickySections, - theme + theme, + navigation }; } @@ -280,6 +291,7 @@ export default function SettingsPage({ try { const next = await updateProfile(settings, { ui_preferences: uiPreferencePayload() }); onAuthChange(next); + dispatchPlatformModulesChanged(); setUiResultTone("success"); setUiResult("i18n:govoplan-core.preferences_saved.c8cd3501"); return true; @@ -503,6 +515,15 @@ export default function SettingsPage({ i18n:govoplan-core.template_placeholder_chips_and_preview_overlays.11634d55 + + + } @@ -594,7 +615,8 @@ function normalizeUiPreferences(value: Partial | null | undef show_inline_help_hints: Boolean(value?.show_inline_help_hints ?? DEFAULT_UI_PREFERENCES.show_inline_help_hints), reduce_motion: Boolean(value?.reduce_motion ?? DEFAULT_UI_PREFERENCES.reduce_motion), sticky_section_sidebars: Boolean(value?.sticky_section_sidebars ?? DEFAULT_UI_PREFERENCES.sticky_section_sidebars), - theme + theme, + navigation: value?.navigation ?? null }; } diff --git a/webui/src/index.ts b/webui/src/index.ts index 49ab93b..bae1f17 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -240,6 +240,7 @@ export { default as AppShell } from "./layout/AppShell"; export { default as BreadcrumbBar } from "./layout/BreadcrumbBar"; export { default as HelpMenu } from "./layout/HelpMenu"; export { default as IconRail } from "./layout/IconRail"; +export { default as NavigationPreferenceEditor } from "./components/NavigationPreferenceEditor"; export { default as LanguageMenu } from "./layout/LanguageMenu"; export { default as ModuleSubnav } from "./layout/ModuleSubnav"; export type { ModuleSubnavGroup, ModuleSubnavItem } from "./layout/ModuleSubnav"; diff --git a/webui/src/platform/modules.ts b/webui/src/platform/modules.ts index 56bbc76..3d13c49 100644 --- a/webui/src/platform/modules.ts +++ b/webui/src/platform/modules.ts @@ -110,7 +110,14 @@ function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavIt allOf: item.required_all, anyOf: item.required_any, order: item.order, - surfaceId: item.surface_id ?? undefined + surfaceId: item.surface_id ?? undefined, + navigationId: item.navigation_id ?? item.surface_id ?? undefined, + navigationVisible: item.navigation_visible, + navigationLocked: item.navigation_locked, + navigationOrderSource: item.navigation_order_source, + navigationVisibilitySource: item.navigation_visibility_source, + navigationLockSource: item.navigation_lock_source, + navigationLayers: item.navigation_layers }; } @@ -541,10 +548,30 @@ export function navItemsForModules( ); return [...shellNavItemsForModules(modules), ...moduleItems]. map(resolveNavItemIcon). + filter((item) => item.navigationVisible !== false). filter((item) => isViewSurfaceVisible(projection, item.surfaceId, catalogue)). sort((left, right) => (left.order ?? 100) - (right.order ?? 100)); } +export function configurableNavigationItemsForModules( + modules: PlatformWebModule[] +): PlatformNavItem[] { + return modules + .flatMap((module) => + (module.navItems ?? []).map((item) => ({ + ...item, + surfaceId: item.surfaceId ?? navigationViewSurfaceId(module.id, item.to), + navigationId: item.navigationId ?? item.surfaceId ?? navigationViewSurfaceId(module.id, item.to) + })) + ) + .map(resolveNavItemIcon) + .sort((left, right) => + (left.navigationLayers?.module?.order ?? left.order ?? 100) + - (right.navigationLayers?.module?.order ?? right.order ?? 100) + || left.label.localeCompare(right.label) + ); +} + export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = installedLocalWebModules(), projection?: EffectiveViewProjection | null): PlatformNavItem[] { return navItemsForModules(modules, projection).filter((item) => { if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false; diff --git a/webui/src/platform/productAreas.ts b/webui/src/platform/productAreas.ts index 556bd59..0ac48f3 100644 --- a/webui/src/platform/productAreas.ts +++ b/webui/src/platform/productAreas.ts @@ -87,5 +87,13 @@ export function groupNavigationItems( items: remaining }); } - return groups; + 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); } diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css index 44fa25c..7387ded 100644 --- a/webui/src/styles/components.css +++ b/webui/src/styles/components.css @@ -1010,6 +1010,69 @@ } } +.navigation-preference-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-3); +} + +.navigation-preference-toolbar p { + margin: 0; +} + +.navigation-preference-list { + display: grid; + gap: var(--space-2); + margin: 0; + padding: 0; + list-style: none; +} + +.navigation-preference-list > li { + display: grid; + grid-template-columns: auto minmax(12rem, 1fr) auto auto; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); + border: var(--border-soft-line); + border-radius: var(--radius-md); + background: var(--surface-raised); +} + +.navigation-preference-order-actions { + display: flex; + gap: var(--space-1); +} + +.navigation-preference-label { + display: grid; + gap: 2px; + min-width: 0; +} + +.navigation-preference-label span { + overflow: hidden; + color: var(--text-muted); + font-family: var(--font-mono, monospace); + font-size: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 760px) { + .navigation-preference-toolbar, + .navigation-preference-list > li { + align-items: stretch; + grid-template-columns: 1fr; + } + + .navigation-preference-toolbar { + display: grid; + } +} + .admin-section-page { display: grid; gap: 16px; diff --git a/webui/src/types.ts b/webui/src/types.ts index d3c3f1e..3e0466c 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -48,6 +48,7 @@ export type UserUiPreferences = { reduce_motion: boolean; sticky_section_sidebars: boolean; theme: UserUiTheme; + navigation?: NavigationPreferences | null; }; export type AuthTenant = { @@ -271,6 +272,27 @@ export type PlatformNavItem = { allOf?: string[]; order?: number; surfaceId?: string; + navigationId?: string; + navigationVisible?: boolean; + navigationLocked?: boolean; + navigationOrderSource?: string; + navigationVisibilitySource?: string; + navigationLockSource?: string | null; + navigationLayers?: Partial>; +}; + +export type NavigationLayerState = { + order: number; + visible: boolean; + locked: boolean; + lock_source?: string | null; +}; + +export type NavigationPreferences = { + contract_version: "1"; + order: string[]; + hidden: string[]; + locked?: string[]; }; export type ProductAreaContribution = { @@ -1167,6 +1189,13 @@ export type PlatformFrontendModuleInfo = { required_any: string[]; order: number; surface_id?: string | null; + navigation_id?: string | null; + navigation_visible?: boolean; + navigation_locked?: boolean; + navigation_order_source?: string; + navigation_visibility_source?: string; + navigation_lock_source?: string | null; + navigation_layers?: Partial>; }>; settings_routes: PlatformFrontendRouteInfo[]; view_surface_contract_version?: string | null; @@ -1318,6 +1347,13 @@ export type PlatformModuleInfo = { required_any: string[]; order: number; surface_id?: string | null; + navigation_id?: string | null; + navigation_visible?: boolean; + navigation_locked?: boolean; + navigation_order_source?: string; + navigation_visibility_source?: string; + navigation_lock_source?: string | null; + navigation_layers?: Partial>; }>; frontend?: PlatformFrontendModuleInfo | null; };