feat(core): add layered side rail preferences

This commit is contained in:
2026-08-20 01:47:04 +02:00
parent 51bf14f376
commit cf16a7b27a
13 changed files with 763 additions and 19 deletions
+24
View File
@@ -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 |
+10
View File
@@ -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):
+175
View File
@@ -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",
]
+122 -11
View File
@@ -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
],
+60
View File
@@ -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")
+69
View File
@@ -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()
@@ -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<NavigationPreferences>) {
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 (
<div className="navigation-preference-editor" data-navigation-preference-scope={scope}>
<div className="navigation-preference-toolbar">
<p className="muted small-note">
Higher personal settings take precedence over tenant and system order. Locked entries remain visible.
</p>
<Button onClick={() => onChange(null)} disabled={disabled || value === null}>
Use inherited order
</Button>
</div>
<ol className="navigation-preference-list">
{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 (
<li key={id} data-navigation-id={id} data-navigation-locked={locked ? "true" : "false"}>
<div className="navigation-preference-order-actions">
<IconButton label={`Move ${label} up`} icon={<ArrowUp size={16} />} onClick={() => move(id, -1)} disabled={disabled || index === 0} />
<IconButton label={`Move ${label} down`} icon={<ArrowDown size={16} />} onClick={() => move(id, 1)} disabled={disabled || index === orderedIds.length - 1} />
</div>
<div className="navigation-preference-label">
<strong>{label}</strong>
<span>{id}</span>
</div>
<ToggleSwitch
label="Visible"
checked={locked || !hidden.has(id)}
disabled={disabled || locked}
help={locked ? `Locked by ${inheritedState?.lock_source ?? scope}` : undefined}
onChange={(visible) => setVisible(id, visible)}
/>
{scope !== "user" && (
<ToggleSwitch
label={<><LockKeyhole size={14} aria-hidden="true" /> Locked</>}
checked={locked}
disabled={disabled || ancestorLocked}
help={ancestorLocked ? `Locked by ${inheritedState?.lock_source}` : "Lower scopes cannot hide this entry."}
onChange={(next) => setLocked(id, next)}
/>
)}
</li>
);
})}
</ol>
</div>
);
}
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;
}
+28 -6
View File
@@ -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<MailProfilesUiCapability>("mail.profiles");
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
const settingsSectionCapabilities = usePlatformUiCapabilities<SettingsSectionsUiCapability>("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<UserUiTheme>(currentUiPreferences.theme);
const [navigation, setNavigation] = useState<NavigationPreferences | null>(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({
<span>i18n:govoplan-core.template_placeholder_chips_and_preview_overlays.11634d55</span>
</div>
</Card>
<Card title="Navigation order">
<NavigationPreferenceEditor
items={navigationItems}
value={navigation}
onChange={setNavigation}
scope="user"
disabled={uiBusy}
/>
</Card>
</ContentGrid>
}
@@ -594,7 +615,8 @@ function normalizeUiPreferences(value: Partial<UserUiPreferences> | 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
};
}
+1
View File
@@ -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";
+28 -1
View File
@@ -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;
+9 -1
View File
@@ -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);
}
+63
View File
@@ -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;
+36
View File
@@ -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<Record<"module" | "system" | "tenant", NavigationLayerState>>;
};
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<Record<"module" | "system" | "tenant", NavigationLayerState>>;
}>;
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<Record<"module" | "system" | "tenant", NavigationLayerState>>;
}>;
frontend?: PlatformFrontendModuleInfo | null;
};