feat(core): add layered side rail preferences
This commit is contained in:
@@ -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 |
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user