Coverage for src/lexigram/admin/ui/organisms/topbar.py: 20%

71 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:39 +0800

1from __future__ import annotations 

2 

3from typing import Any 

4 

5from lexigram.ui import Component, el 

6 

7 

8class LanguageSwitcher(Component): 

9 """Dropdown language switcher for the admin UI. 

10 

11 Renders as a ``<select>`` that posts the chosen locale to *action_url* 

12 (default ``/admin/set-locale``). On change it submits the wrapping form 

13 automatically via Alpine.js ``x-on:change``. 

14 

15 Args: 

16 locales: Ordered list of ``(code, label)`` pairs, 

17 e.g. ``[("en", "English"), ("fr", "Français")]``. 

18 current_locale: The currently active locale code. 

19 action_url: URL that accepts a ``POST`` with ``locale=<code>``. 

20 **props: Extra HTML attributes forwarded to the wrapper element. 

21 """ 

22 

23 def __init__( 

24 self, 

25 locales: list[tuple[str, str]] | None = None, 

26 current_locale: str = "en", 

27 action_url: str = "/admin/set-locale", 

28 **props: Any, 

29 ) -> None: 

30 super().__init__(**props) 

31 self.locales = locales or [("en", "English")] 

32 self.current_locale = current_locale 

33 self.action_url = action_url 

34 

35 def render(self) -> Any: 

36 options = [ 

37 el( 

38 "option", 

39 label, 

40 value=code, 

41 selected=(code == self.current_locale) or None, 

42 ) 

43 for code, label in self.locales 

44 ] 

45 select = el( 

46 "select", 

47 *options, 

48 name="locale", 

49 class_=( 

50 "text-sm bg-transparent border border-border " 

51 "rounded px-2 py-1 text-foreground " 

52 "focus:outline-none focus:ring-2 focus:ring-ring cursor-pointer" 

53 ), 

54 **{"x-on:change": "$el.form.submit()"}, 

55 ) 

56 return el( 

57 "form", 

58 select, 

59 method="POST", 

60 action=self.action_url, 

61 class_="inline-block", 

62 ) 

63 

64 

65class TenantSwitcher(Component): 

66 """Superadmin-only tenant switcher for the admin topbar. 

67 

68 Mirrors ``LanguageSwitcher``'s plain ``<select>``-in-``<form>`` 

69 auto-submit shape (there is no dropdown-menu precedent in this file 

70 to follow instead). Renders nothing when *tenants* is empty — callers 

71 (``TopBar``) are responsible for only constructing this with data 

72 when tenancy is enabled and the requesting user is a superadmin; an 

73 empty list is what makes this a no-op in every other case. 

74 

75 Unlike ``LanguageSwitcher``, this form carries a CSRF hidden field: 

76 it is a genuine plain-form POST (not HTMX), so the shell's 

77 ``hx-headers`` CSRF injection does not apply, and 

78 ``request.state.csrf_token`` is not reliably populated on the pages 

79 this switcher appears on (see plan header for details). 

80 

81 Args: 

82 tenants: Ordered list of ``(tenant_id, name)`` pairs. 

83 current_tenant_id: The currently active tenant id, pre-selected. 

84 csrf_token: CSRF token embedded as a hidden form field, if given. 

85 action_url: URL that accepts a ``POST`` with ``tenant_id=<id>``. 

86 """ 

87 

88 def __init__( 

89 self, 

90 tenants: list[tuple[str, str]] | None = None, 

91 current_tenant_id: str | None = None, 

92 csrf_token: str | None = None, 

93 action_url: str = "/admin/set-tenant", 

94 **props: Any, 

95 ) -> None: 

96 super().__init__(**props) 

97 self.tenants = tenants or [] 

98 self.current_tenant_id = current_tenant_id 

99 self.csrf_token = csrf_token 

100 self.action_url = action_url 

101 

102 def render(self) -> Any: 

103 if not self.tenants: 

104 return "" 

105 options = [ 

106 el( 

107 "option", 

108 name, 

109 value=tenant_id, 

110 selected=(tenant_id == self.current_tenant_id) or None, 

111 ) 

112 for tenant_id, name in self.tenants 

113 ] 

114 select = el( 

115 "select", 

116 *options, 

117 name="tenant_id", 

118 class_=( 

119 "text-sm bg-transparent border border-border " 

120 "rounded px-2 py-1 text-foreground " 

121 "focus:outline-none focus:ring-2 focus:ring-ring cursor-pointer" 

122 ), 

123 **{"x-on:change": "$el.form.submit()"}, 

124 ) 

125 children: list[Any] = [select] 

126 if self.csrf_token: 

127 children.append( 

128 el( 

129 "input", 

130 type_="hidden", 

131 name="csrf_token", 

132 value=self.csrf_token, 

133 ) 

134 ) 

135 return el( 

136 "form", 

137 *children, 

138 method="POST", 

139 action=self.action_url, 

140 class_="inline-block", 

141 ) 

142 

143 

144class ThemeToggle(Component): 

145 """ 

146 Client-side theme toggle powered by Alpine.js and localStorage. 

147 """ 

148 

149 def render(self) -> Any: 

150 from lexigram.ui import ToggleIcon 

151 

152 return ToggleIcon( 

153 icon_on="moon", 

154 icon_off="sun", 

155 state_var="darkMode", 

156 aria_label="Toggle theme", 

157 size="sm", 

158 ).render() 

159 

160 

161# UserBox has been moved to its own module for clarity and reusability. 

162 

163 

164class TopBar(Component): 

165 """ 

166 Evolution of TopBar with extensible slots and refined aesthetics. 

167 """ 

168 

169 def __init__( 

170 self, 

171 title: str = "Admin", 

172 user: Any | None = None, 

173 user_menu_items: list[dict[str, Any]] | None = None, 

174 left: Any | None = None, 

175 center: Any | None = None, 

176 right: Any | None = None, 

177 site_name: str = "", 

178 current_tenant_id: str | None = None, 

179 current_tenant_name: str = "", 

180 tenant_list: list[tuple[str, str]] | None = None, 

181 tenant_csrf_token: str | None = None, 

182 **props: Any, 

183 ) -> None: 

184 super().__init__(**props) 

185 self.title_val = title 

186 self.site_name = site_name 

187 self.left = left 

188 self.center = center 

189 self.right = right 

190 self.user = user 

191 self.user_menu_items = user_menu_items 

192 self.current_tenant_id = current_tenant_id 

193 self.current_tenant_name = current_tenant_name 

194 self.tenant_list = tenant_list or [] 

195 self.tenant_csrf_token = tenant_csrf_token 

196 

197 def render(self) -> Any: 

198 # Default Left: Mobile toggle + Title 

199 left_node = self.left 

200 if left_node is None: 

201 from lexigram.ui import ActionButton 

202 

203 toggle = ActionButton( 

204 label="", 

205 color="ghost", 

206 icon="menu", 

207 size="sm", 

208 **{ # type: ignore[arg-type] 

209 "x-on:click": "sidebarOpen = !sidebarOpen", 

210 "class_": "mr-4 lg:hidden", 

211 }, 

212 ).render() 

213 

214 title_text = self.title_val or "Admin" 

215 site_elements = [] 

216 if self.site_name: 

217 site_elements.append( 

218 el( 

219 "span", 

220 self.site_name, 

221 class_="text-xs text-muted-foreground uppercase tracking-wider", 

222 ), 

223 ) 

224 site_elements.append( 

225 el( 

226 "span", 

227 title_text, 

228 class_="text-lg font-bold text-foreground tracking-tight", 

229 ), 

230 ) 

231 left_node = el( 

232 "div", 

233 toggle, 

234 el("div", *site_elements, class_="flex flex-col"), 

235 class_="flex items-center", 

236 ) 

237 

238 # Default Right: TenantSwitcher (superadmin only) + NotificationBell + ThemeToggle 

239 right_node = self.right 

240 if right_node is None: 

241 from lexigram.ui import NotificationBell 

242 

243 right_elements: list[Any] = [] 

244 if self.current_tenant_id is not None: 

245 right_elements.append( 

246 TenantSwitcher( 

247 tenants=self.tenant_list, 

248 current_tenant_id=self.current_tenant_id, 

249 csrf_token=self.tenant_csrf_token, 

250 ) 

251 ) 

252 right_elements.append( 

253 NotificationBell(inbox_url="/admin/notifications").render() 

254 ) 

255 right_elements.append(ThemeToggle()) 

256 right_node = el( 

257 "div", 

258 *right_elements, 

259 class_="flex items-center space-x-3", 

260 ) 

261 

262 center_node = self.center or "" 

263 if not center_node: 

264 center_node = el( 

265 "div", 

266 el( 

267 "div", 

268 el( 

269 "svg", 

270 el( 

271 "path", 

272 stroke_linecap="round", 

273 stroke_linejoin="round", 

274 stroke_width="2", 

275 d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z", 

276 ), 

277 class_="w-5 h-5 text-muted-foreground", 

278 fill="none", 

279 viewBox="0 0 24 24", 

280 stroke="currentColor", 

281 ), 

282 el( 

283 "span", 

284 "Search (Cmd+K)", 

285 class_="ml-3 text-muted-foreground text-sm hidden sm:block", 

286 ), 

287 class_="flex items-center w-full max-w-md px-4 py-2 bg-muted rounded-lg cursor-pointer hover:bg-muted/80 transition-colors", 

288 **{"x-on:click": "$dispatch('open-command-palette')"}, 

289 ), 

290 class_="flex-1 flex justify-center max-w-2xl", 

291 ) 

292 

293 return el( 

294 "header", 

295 el( 

296 "div", 

297 el("div", left_node, class_="flex-shrink-0"), 

298 el("div", center_node, class_="flex-1 flex justify-center px-4"), 

299 el("div", right_node, class_="flex-shrink-0"), 

300 class_="px-4 h-16 flex items-center justify-between", 

301 ), 

302 class_="bg-background/80 backdrop-blur-md border-b border-border sticky top-0 z-20 shadow-sm transition-colors duration-300", 

303 )