Coverage for src/lexigram/admin/ui/organisms/topbar.py: 100%
71 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 17:12 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 17:12 +0800
1from __future__ import annotations
3from typing import Any
5from lexigram.ui import Component, el
8class LanguageSwitcher(Component):
9 """Dropdown language switcher for the admin UI.
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``.
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 """
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
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 )
65class TenantSwitcher(Component):
66 """Superadmin-only tenant switcher for the admin topbar.
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.
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).
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 """
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
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 )
144class ThemeToggle(Component):
145 """
146 Client-side theme toggle powered by Alpine.js and localStorage.
147 """
149 def render(self) -> Any:
150 from lexigram.ui import ToggleIcon
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()
161# UserBox has been moved to its own module for clarity and reusability.
164class TopBar(Component):
165 """
166 Evolution of TopBar with extensible slots and refined aesthetics.
167 """
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
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
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()
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 )
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
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 )
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 )
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 )