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, - Do not reproduce shared page padding, heading, toolbar, form-grid, section,
table, dialog, or breakpoint CSS in a module. 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 ## Semantic Page Archetypes
| Archetype | Use when | | Archetype | Use when |
+10
View File
@@ -93,6 +93,15 @@ class TenantMembershipInfo(TenantInfo):
is_active: bool = True 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): class UserUiPreferences(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
@@ -101,6 +110,7 @@ class UserUiPreferences(BaseModel):
reduce_motion: bool = False reduce_motion: bool = False
sticky_section_sidebars: bool = True sticky_section_sidebars: bool = True
theme: Literal["system", "light", "dark"] = "system" theme: Literal["system", "light", "dark"] = "system"
navigation: NavigationPreferencesPayload | None = None
class UserInfo(BaseModel): 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.admin.settings import SYSTEM_SETTINGS_ID
from govoplan_core.auth import ApiPrincipal, get_api_principal, require_any_scope 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.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 ( from govoplan_core.core.module_entitlements import (
module_entitlement_payload, module_entitlement_payload,
tenant_module_entitlement_state, 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 { 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, "path": item.path,
"label": item.label, "label": item.label,
"icon": item.icon, "icon": item.icon,
"section": item.section, "section": item.section,
"required_all": list(item.required_all), "required_all": list(item.required_all),
"required_any": list(item.required_any), "required_any": list(item.required_any),
"order": item.order, "order": effective.order if effective is not None else item.order,
"surface_id": ( "surface_id": navigation_id,
item.surface_id or navigation_view_surface_id(module_id, item.path)
if module_id
else item.surface_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]: 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 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 frontend = manifest.frontend
if frontend is None: if frontend is None:
return None return None
@@ -257,7 +361,10 @@ def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
"public_routes": [ "public_routes": [
_public_frontend_route_payload(route) for route in frontend.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], "settings_routes": [_frontend_route_payload(route, manifest.id) for route in frontend.settings_routes],
"view_surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION, "view_surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION,
"view_surfaces": _frontend_view_surfaces(manifest), "view_surfaces": _frontend_view_surfaces(manifest),
@@ -323,6 +430,7 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
request, request,
principal, principal,
) )
navigation_state = _effective_navigation_state(principal, manifests)
principal_ref = getattr(principal, "principal", None) principal_ref = getattr(principal, "principal", None)
tenant_id = getattr(principal_ref, "tenant_id", None) tenant_id = getattr(principal_ref, "tenant_id", None)
return { return {
@@ -351,8 +459,11 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
if key != "declarations" if key != "declarations"
}, },
"help_contexts": _documentation_help_contexts(manifest), "help_contexts": _documentation_help_contexts(manifest),
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items], "nav": [
"frontend": _frontend_payload(manifest), _nav_item_payload(item, manifest.id, navigation_state)
for item in manifest.nav_items
],
"frontend": _frontend_payload(manifest, navigation_state),
} }
for manifest in manifests 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"], "allow_tenant_api_keys": system_item["allow_tenant_api_keys"],
"available_languages": available_languages, "available_languages": available_languages,
"enabled_language_codes": enabled_codes, "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) self.assertEqual(updated_system.status_code, 200, updated_system.text)
@@ -5314,7 +5320,29 @@ class ApiSmokeTests(unittest.TestCase):
system_delta_payload = system_delta.json() system_delta_payload = system_delta.json()
self.assertFalse(system_delta_payload["full"]) self.assertFalse(system_delta_payload["full"])
self.assertIn("languages", system_delta_payload["changed_sections"]) 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.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( tenant_delta = self.client.get(
"/api/v1/admin/tenant/settings/delta", "/api/v1/admin/tenant/settings/delta",
@@ -5325,7 +5353,12 @@ class ApiSmokeTests(unittest.TestCase):
tenant_delta_payload = tenant_delta.json() tenant_delta_payload = tenant_delta.json()
self.assertFalse(tenant_delta_payload["full"]) self.assertFalse(tenant_delta_payload["full"])
self.assertIn("languages", tenant_delta_payload["changed_sections"]) 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.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: def test_tenant_admin_delta_tracks_create_and_update(self) -> None:
headers, _ = self._login() headers, _ = self._login()
@@ -6431,6 +6464,11 @@ class ApiSmokeTests(unittest.TestCase):
"reduce_motion": True, "reduce_motion": True,
"sticky_section_sidebars": False, "sticky_section_sidebars": False,
"theme": "dark", "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, "reduce_motion": True,
"sticky_section_sidebars": False, "sticky_section_sidebars": False,
"theme": "dark", "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) refreshed = self.client.get("/api/v1/auth/me", headers=headers)
self.assertEqual(refreshed.status_code, 200, refreshed.text) self.assertEqual(refreshed.status_code, 200, refreshed.text)
self.assertEqual(refreshed.json()["user"]["display_name"], "Global Account Name") 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 ContentGrid, { FormGrid } from "../../components/ContentGrid";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router"; 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 Card from "../../components/Card";
import FormField from "../../components/FormField"; import FormField from "../../components/FormField";
import PasswordField from "../../components/PasswordField"; import PasswordField from "../../components/PasswordField";
@@ -12,11 +12,14 @@ import PageActionBar from "../../components/PageActionBar";
import ToggleSwitch from "../../components/ToggleSwitch"; import ToggleSwitch from "../../components/ToggleSwitch";
import { apiFetch } from "../../api/client"; import { apiFetch } from "../../api/client";
import { fetchAuthProfile, fetchAuthRoles, updateProfile } from "../../api/auth"; import { fetchAuthProfile, fetchAuthRoles, updateProfile } from "../../api/auth";
import { dispatchPlatformModulesChanged } from "../../platform/moduleEvents";
import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav"; import ModuleSubnav, { type ModuleSubnavGroup } from "../../layout/ModuleSubnav";
import DismissibleAlert from "../../components/DismissibleAlert"; import DismissibleAlert from "../../components/DismissibleAlert";
import SegmentedControl from "../../components/SegmentedControl"; import SegmentedControl from "../../components/SegmentedControl";
import { useUnsavedChanges, useUnsavedDraftGuard } from "../../components/UnsavedChangesGuard"; 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 { useEffectiveView, useViewSurfaces } from "../../platform/ViewContext";
import { isViewSurfaceVisible } from "../../platform/views"; import { isViewSurfaceVisible } from "../../platform/views";
import { hasAnyScope, hasScope } from "../../utils/permissions"; import { hasAnyScope, hasScope } from "../../utils/permissions";
@@ -103,6 +106,8 @@ export default function SettingsPage({
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles"); const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors"); const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
const settingsSectionCapabilities = usePlatformUiCapabilities<SettingsSectionsUiCapability>("settings.sections"); const settingsSectionCapabilities = usePlatformUiCapabilities<SettingsSectionsUiCapability>("settings.sections");
const platformModules = usePlatformModules();
const navigationItems = useMemo(() => configurableNavigationItemsForModules(platformModules), [platformModules]);
const effectiveView = useEffectiveView(); const effectiveView = useEffectiveView();
const viewSurfaces = useViewSurfaces(); const viewSurfaces = useViewSurfaces();
const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage(); const { language, languageLabel, selectableLanguages, availableLanguages, enabledLanguages, setLanguage } = usePlatformLanguage();
@@ -150,6 +155,7 @@ export default function SettingsPage({
const [reduceMotion, setReduceMotion] = useState(currentUiPreferences.reduce_motion); const [reduceMotion, setReduceMotion] = useState(currentUiPreferences.reduce_motion);
const [stickySections, setStickySections] = useState(currentUiPreferences.sticky_section_sidebars); const [stickySections, setStickySections] = useState(currentUiPreferences.sticky_section_sidebars);
const [theme, setTheme] = useState<UserUiTheme>(currentUiPreferences.theme); const [theme, setTheme] = useState<UserUiTheme>(currentUiPreferences.theme);
const [navigation, setNavigation] = useState<NavigationPreferences | null>(currentUiPreferences.navigation ?? null);
const [uiBusy, setUiBusy] = useState(false); const [uiBusy, setUiBusy] = useState(false);
const [uiResult, setUiResult] = useState(""); const [uiResult, setUiResult] = useState("");
const [uiResultTone, setUiResultTone] = useState<"success" | "warning">("success"); const [uiResultTone, setUiResultTone] = useState<"success" | "warning">("success");
@@ -164,7 +170,8 @@ export default function SettingsPage({
showHelpHints !== currentUiPreferences.show_inline_help_hints || showHelpHints !== currentUiPreferences.show_inline_help_hints ||
reduceMotion !== currentUiPreferences.reduce_motion || reduceMotion !== currentUiPreferences.reduce_motion ||
stickySections !== currentUiPreferences.sticky_section_sidebars || stickySections !== currentUiPreferences.sticky_section_sidebars ||
theme !== currentUiPreferences.theme; theme !== currentUiPreferences.theme ||
JSON.stringify(navigation) !== JSON.stringify(currentUiPreferences.navigation ?? null);
useUnsavedDraftGuard({ useUnsavedDraftGuard({
dirty: active === "profile" && profileDirty, dirty: active === "profile" && profileDirty,
@@ -218,12 +225,14 @@ export default function SettingsPage({
setReduceMotion(currentUiPreferences.reduce_motion); setReduceMotion(currentUiPreferences.reduce_motion);
setStickySections(currentUiPreferences.sticky_section_sidebars); setStickySections(currentUiPreferences.sticky_section_sidebars);
setTheme(currentUiPreferences.theme); setTheme(currentUiPreferences.theme);
setNavigation(currentUiPreferences.navigation ?? null);
}, [ }, [
currentUiPreferences.compact_tables, currentUiPreferences.compact_tables,
currentUiPreferences.show_inline_help_hints, currentUiPreferences.show_inline_help_hints,
currentUiPreferences.reduce_motion, currentUiPreferences.reduce_motion,
currentUiPreferences.sticky_section_sidebars, currentUiPreferences.sticky_section_sidebars,
currentUiPreferences.theme currentUiPreferences.theme,
currentUiPreferences.navigation
]); ]);
function selectSection(section: SettingsSection) { function selectSection(section: SettingsSection) {
@@ -262,6 +271,7 @@ export default function SettingsPage({
setReduceMotion(currentUiPreferences.reduce_motion); setReduceMotion(currentUiPreferences.reduce_motion);
setStickySections(currentUiPreferences.sticky_section_sidebars); setStickySections(currentUiPreferences.sticky_section_sidebars);
setTheme(currentUiPreferences.theme); setTheme(currentUiPreferences.theme);
setNavigation(currentUiPreferences.navigation ?? null);
} }
function uiPreferencePayload(): UserUiPreferences { function uiPreferencePayload(): UserUiPreferences {
@@ -270,7 +280,8 @@ export default function SettingsPage({
show_inline_help_hints: showHelpHints, show_inline_help_hints: showHelpHints,
reduce_motion: reduceMotion, reduce_motion: reduceMotion,
sticky_section_sidebars: stickySections, sticky_section_sidebars: stickySections,
theme theme,
navigation
}; };
} }
@@ -280,6 +291,7 @@ export default function SettingsPage({
try { try {
const next = await updateProfile(settings, { ui_preferences: uiPreferencePayload() }); const next = await updateProfile(settings, { ui_preferences: uiPreferencePayload() });
onAuthChange(next); onAuthChange(next);
dispatchPlatformModulesChanged();
setUiResultTone("success"); setUiResultTone("success");
setUiResult("i18n:govoplan-core.preferences_saved.c8cd3501"); setUiResult("i18n:govoplan-core.preferences_saved.c8cd3501");
return true; return true;
@@ -503,6 +515,15 @@ export default function SettingsPage({
<span>i18n:govoplan-core.template_placeholder_chips_and_preview_overlays.11634d55</span> <span>i18n:govoplan-core.template_placeholder_chips_and_preview_overlays.11634d55</span>
</div> </div>
</Card> </Card>
<Card title="Navigation order">
<NavigationPreferenceEditor
items={navigationItems}
value={navigation}
onChange={setNavigation}
scope="user"
disabled={uiBusy}
/>
</Card>
</ContentGrid> </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), 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), 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), 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 BreadcrumbBar } from "./layout/BreadcrumbBar";
export { default as HelpMenu } from "./layout/HelpMenu"; export { default as HelpMenu } from "./layout/HelpMenu";
export { default as IconRail } from "./layout/IconRail"; export { default as IconRail } from "./layout/IconRail";
export { default as NavigationPreferenceEditor } from "./components/NavigationPreferenceEditor";
export { default as LanguageMenu } from "./layout/LanguageMenu"; export { default as LanguageMenu } from "./layout/LanguageMenu";
export { default as ModuleSubnav } from "./layout/ModuleSubnav"; export { default as ModuleSubnav } from "./layout/ModuleSubnav";
export type { ModuleSubnavGroup, ModuleSubnavItem } 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, allOf: item.required_all,
anyOf: item.required_any, anyOf: item.required_any,
order: item.order, 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]. return [...shellNavItemsForModules(modules), ...moduleItems].
map(resolveNavItemIcon). map(resolveNavItemIcon).
filter((item) => item.navigationVisible !== false).
filter((item) => isViewSurfaceVisible(projection, item.surfaceId, catalogue)). filter((item) => isViewSurfaceVisible(projection, item.surfaceId, catalogue)).
sort((left, right) => (left.order ?? 100) - (right.order ?? 100)); 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[] { export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = installedLocalWebModules(), projection?: EffectiveViewProjection | null): PlatformNavItem[] {
return navItemsForModules(modules, projection).filter((item) => { return navItemsForModules(modules, projection).filter((item) => {
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false; 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 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 { .admin-section-page {
display: grid; display: grid;
gap: 16px; gap: 16px;
+36
View File
@@ -48,6 +48,7 @@ export type UserUiPreferences = {
reduce_motion: boolean; reduce_motion: boolean;
sticky_section_sidebars: boolean; sticky_section_sidebars: boolean;
theme: UserUiTheme; theme: UserUiTheme;
navigation?: NavigationPreferences | null;
}; };
export type AuthTenant = { export type AuthTenant = {
@@ -271,6 +272,27 @@ export type PlatformNavItem = {
allOf?: string[]; allOf?: string[];
order?: number; order?: number;
surfaceId?: string; 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 = { export type ProductAreaContribution = {
@@ -1167,6 +1189,13 @@ export type PlatformFrontendModuleInfo = {
required_any: string[]; required_any: string[];
order: number; order: number;
surface_id?: string | null; 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[]; settings_routes: PlatformFrontendRouteInfo[];
view_surface_contract_version?: string | null; view_surface_contract_version?: string | null;
@@ -1318,6 +1347,13 @@ export type PlatformModuleInfo = {
required_any: string[]; required_any: string[];
order: number; order: number;
surface_id?: string | null; 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; frontend?: PlatformFrontendModuleInfo | null;
}; };