Coverage for src/lexigram/admin/navigation/nav_item_builder.py: 85%

52 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1"""NavItemBuilder — builds sidebar navigation and system menu from registered resources.""" 

2 

3from __future__ import annotations 

4 

5from typing import Any 

6 

7from lexigram.admin.config import AdminConfig 

8from lexigram.logging import get_logger 

9 

10_log = get_logger(__name__) 

11 

12 

13class NavItemBuilder: 

14 """Builds sidebar navigation items and system menu from registered resource instances. 

15 

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. 

19 

20 Constructor injection only — no setters. 

21 """ 

22 

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 [] 

31 

32 def set_resources(self, resolved_resources: dict[str, Any]) -> None: 

33 """Populate with resolved resource instances. 

34 

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. 

39 

40 Args: 

41 resolved_resources: Mapping of resource name to resolved instance. 

42 """ 

43 self._resolved_resources = resolved_resources 

44 

45 def set_system_menu_items(self, items: list[dict[str, Any]]) -> None: 

46 """Set system menu items (shown in sidebar footer). 

47 

48 Called by application code or bundle providers to populate the 

49 system/footer menu with links like Settings, Health, etc. 

50 

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) 

56 

57 def add_system_menu_item(self, item: dict[str, Any]) -> None: 

58 """Append a single item to the system menu. 

59 

60 Args: 

61 item: Item dict with ``label``, ``href``, and optional 

62 ``icon`` and ``render`` keys. 

63 """ 

64 self._system_menu_items.append(item) 

65 

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. 

68 

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``. 

73 

74 Returns: 

75 Flat list of dicts understood by ``AdminShell._prepare_navigation()``. 

76 """ 

77 from lexigram.admin.config import AdminNavigationGroup # noqa: F401 

78 

79 prefix = self._config.prefix.rstrip("/") 

80 

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 ) 

97 

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() 

102 

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) 

113 

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) 

121 

122 return result 

123 

124 def build_system_menu_items(self) -> list[dict[str, Any]]: 

125 """Build system-level menu items set by the application. 

126 

127 Returns: 

128 List of item dicts for the system footer section. 

129 """ 

130 return list(self._system_menu_items) 

131 

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 + "/") 

138 

139 

140__all__ = ["NavItemBuilder"]