Coverage for src / lexigram / admin / navigation / nav_item_builder.py: 37%
52 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""NavItemBuilder — builds sidebar navigation and system menu from registered resources."""
3from __future__ import annotations
5from typing import Any
7from lexigram.admin.config import AdminConfig
8from lexigram.logging import get_logger
10_log = get_logger(__name__)
13class NavItemBuilder:
14 """Builds sidebar navigation items and system menu from registered resource instances.
16 Registered as a singleton by AdminProvider. Populated with resolved
17 resource instances by AdminProvider.mount_to_app() after the container
18 has resolved all resource classes.
20 Constructor injection only — no setters.
21 """
23 def __init__(
24 self,
25 config: AdminConfig,
26 system_menu_items: list[dict[str, Any]] | None = None,
27 ) -> None:
28 self._config = config
29 self._resolved_resources: dict[str, Any] = {}
30 self._system_menu_items = system_menu_items or []
32 def set_resources(self, resolved_resources: dict[str, Any]) -> None:
33 """Populate with resolved resource instances.
35 Called once by AdminProvider.mount_to_app() after the container
36 resolves all registered resource classes. Not a setter injection — this
37 is domain-data population that happens at a well-defined point in the
38 application lifecycle.
40 Args:
41 resolved_resources: Mapping of resource name to resolved instance.
42 """
43 self._resolved_resources = resolved_resources
45 def set_system_menu_items(self, items: list[dict[str, Any]]) -> None:
46 """Set system menu items (shown in sidebar footer).
48 Called by application code or bundle providers to populate the
49 system/footer menu with links like Settings, Health, etc.
51 Args:
52 items: List of item dicts with ``label``, ``href``, and optional
53 ``icon`` and ``render`` keys.
54 """
55 self._system_menu_items = list(items)
57 def add_system_menu_item(self, item: dict[str, Any]) -> None:
58 """Append a single item to the system menu.
60 Args:
61 item: Item dict with ``label``, ``href``, and optional
62 ``icon`` and ``render`` keys.
63 """
64 self._system_menu_items.append(item)
66 def build_nav_items(self, current_path: str | None = None) -> list[dict[str, Any]]:
67 """Build sidebar navigation items from registered resources and nav groups.
69 Args:
70 current_path: Current request path for active-state detection.
71 Items whose href matches the path (exact or sub-path) get
72 ``active=True``.
74 Returns:
75 Flat list of dicts understood by ``AdminShell._prepare_navigation()``.
76 """
77 from lexigram.admin.config import AdminNavigationGroup # noqa: F401
79 prefix = self._config.prefix.rstrip("/")
81 # Collect items per group from resolved resources
82 group_items: dict[str, list[dict[str, Any]]] = {}
83 for resource_name, resource_instance in self._resolved_resources.items():
84 if not getattr(resource_instance, "visible_in_sidebar", True):
85 continue
86 group_key = getattr(resource_instance, "group", None) or "default"
87 label = (
88 getattr(resource_instance, "label", None)
89 or resource_name.replace("_", " ").title()
90 )
91 icon = getattr(resource_instance, "icon", "box")
92 href = f"{prefix}/{resource_name}"
93 active = self._is_active(href, current_path)
94 group_items.setdefault(group_key, []).append(
95 {"label": label, "icon": icon, "href": href, "active": active}
96 )
98 # Build ordered flat nav list: group header then items
99 nav_groups_cfg: dict[str, Any] = self._config.navigation_groups or {}
100 result: list[dict[str, Any]] = []
101 seen_groups: set[str] = set()
103 # Emit groups that have config entries, sorted by order
104 for group_key, group_cfg in sorted(
105 nav_groups_cfg.items(), key=lambda kv: getattr(kv[1], "order", 999)
106 ):
107 items = group_items.get(group_key, [])
108 if not items:
109 continue
110 seen_groups.add(group_key)
111 result.append({"is_group": True, "label": group_cfg.label})
112 result.extend(items)
114 # Emit remaining groups that have no config entry
115 for group_key, items in group_items.items():
116 if group_key not in seen_groups:
117 result.append(
118 {"is_group": True, "label": group_key.replace("_", " ").title()}
119 )
120 result.extend(items)
122 return result
124 def build_system_menu_items(self) -> list[dict[str, Any]]:
125 """Build system-level menu items set by the application.
127 Returns:
128 List of item dicts for the system footer section.
129 """
130 return list(self._system_menu_items)
132 @staticmethod
133 def _is_active(href: str, current_path: str | None) -> bool:
134 """Determine if a nav item should be highlighted as active."""
135 if not current_path:
136 return False
137 return current_path == href or current_path.startswith(href + "/")
140__all__ = ["NavItemBuilder"]