Coverage for src/lexigram/admin/ui/layouts/admin_layout.py: 67%

136 statements  

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

1"""AdminLayout - Main layout wrapper for admin pages. 

2 

3This module provides the AdminLayout class that renders admin pages with: 

4- HTML head with meta, CSS, JS 

5- Navigation header 

6- Sidebar navigation 

7- Main content area 

8- Footer 

9- Toast notifications area 

10 

11Uses inheritance from BaseLayout for code reuse. 

12 

13UI-08: AdminLayout implementation. 

14""" 

15 

16from __future__ import annotations 

17 

18from dataclasses import dataclass, field 

19from typing import Any 

20 

21from markupsafe import Markup, escape 

22 

23from lexigram.admin.theme.tailwind import ( 

24 DARK_BOOTSTRAP_SCRIPT, 

25 THEME_BRIDGE_SCRIPT, 

26) 

27from lexigram.admin.ui.layouts.components import ( 

28 FooterConfig, 

29 FooterRenderer, 

30 HeaderConfig, 

31 HeaderRenderer, 

32 NavGroup, 

33 NavItem, 

34 ServerToastChannel, 

35 SidebarConfig, 

36 SidebarRenderer, 

37 ToastConfig, 

38 UserInfo, 

39 flash_to_toast, 

40) 

41from lexigram.ui import BaseLayoutConfig, BaseLayoutContext, LayoutBase 

42 

43 

44@dataclass 

45class AdminLayoutConfig(BaseLayoutConfig): 

46 """Configuration for admin layout. 

47 

48 Extends BaseLayoutConfig with admin-specific options. 

49 """ 

50 

51 # Branding 

52 app_name: str = "Admin" 

53 app_logo: str | None = None 

54 app_logo_alt: str = "Logo" 

55 

56 # Layout options 

57 sidebar_collapsed: bool = False 

58 sidebar_width: str = "256px" 

59 sidebar_collapsed_width: str = "64px" 

60 fixed_header: bool = True 

61 fixed_sidebar: bool = True 

62 

63 # Features 

64 show_search: bool = True 

65 show_notifications: bool = True 

66 show_user_menu: bool = True 

67 show_footer: bool = True 

68 show_breadcrumbs: bool = True 

69 

70 

71@dataclass 

72class NavItemConfig: 

73 """Navigation item configuration.""" 

74 

75 label: str 

76 url: str 

77 icon: str | None = None 

78 badge: str | None = None 

79 badge_variant: str = "primary" 

80 active: bool = False 

81 children: list[NavItemConfig] = field(default_factory=list) 

82 permission: str | None = None 

83 

84 

85@dataclass 

86class AdminLayoutContext(BaseLayoutContext): 

87 """Context for admin layout rendering. 

88 

89 Extends BaseLayoutContext with admin-specific data. 

90 """ 

91 

92 # Current page 

93 page_title: str = "Dashboard" 

94 page_description: str | None = None 

95 

96 # Current user 

97 user_name: str | None = None 

98 user_email: str | None = None 

99 user_avatar: str | None = None 

100 user_role: str | None = None 

101 

102 # Navigation 

103 nav_items: list[NavItemConfig] = field(default_factory=list) 

104 current_path: str = "/" 

105 

106 # URLs 

107 base_url: str = "/admin" 

108 logout_url: str = "/admin/logout" 

109 profile_url: str = "/admin/profile" 

110 settings_url: str = "/admin/settings" 

111 

112 # Notifications 

113 notifications: list[dict[str, Any]] = field(default_factory=list) 

114 unread_count: int = 0 

115 

116 # Messages/Toasts 

117 flash_messages: list[tuple[str, str]] = field(default_factory=list) 

118 

119 # CSRF 

120 csrf_token: str | None = None 

121 

122 # State 

123 sidebar_collapsed: bool = False 

124 

125 

126class AdminLayout(LayoutBase): 

127 """Admin layout with sidebar, header, and footer. 

128 

129 Extends BaseLayout with admin-specific components and rendering. 

130 """ 

131 

132 def __init__( 

133 self, 

134 config: AdminLayoutConfig | None = None, 

135 context: AdminLayoutContext | None = None, 

136 ): 

137 """Initialize admin layout. 

138 

139 Args: 

140 config: Layout configuration 

141 context: Layout context with user, nav, etc. 

142 """ 

143 self.admin_config = config or AdminLayoutConfig() 

144 self.admin_context = context or AdminLayoutContext() 

145 

146 # Initialize base layout 

147 super().__init__(self.admin_config) 

148 

149 # Set up component renderers 

150 self._setup_components() 

151 

152 def _setup_components(self) -> None: 

153 """Set up layout component renderers.""" 

154 ctx = self.admin_context 

155 cfg = self.admin_config 

156 

157 # Header 

158 self.header_renderer = HeaderRenderer( 

159 config=HeaderConfig( 

160 site_name=cfg.app_name, 

161 logo_url=cfg.app_logo, 

162 logo_alt=cfg.app_logo_alt, 

163 show_search=cfg.show_search, 

164 show_notifications=cfg.show_notifications, 

165 show_user_menu=cfg.show_user_menu, 

166 home_url=ctx.base_url, 

167 profile_url=ctx.profile_url, 

168 settings_url=ctx.settings_url, 

169 logout_url=ctx.logout_url, 

170 ), 

171 user=UserInfo( 

172 name=ctx.user_name or "User", 

173 email=ctx.user_email or "", 

174 avatar_url=ctx.user_avatar, 

175 role=ctx.user_role, 

176 ) 

177 if ctx.user_name 

178 else None, 

179 ) 

180 

181 # Sidebar 

182 nav_groups = self._build_nav_groups() 

183 self.sidebar_renderer = SidebarRenderer( 

184 config=SidebarConfig( 

185 width=cfg.sidebar_width, 

186 collapsed_width=cfg.sidebar_collapsed_width, 

187 default_collapsed=cfg.sidebar_collapsed, 

188 show_logo=False, # Logo in header 

189 site_name=cfg.app_name, 

190 ), 

191 groups=nav_groups, 

192 ) 

193 

194 # Footer 

195 self.footer_renderer = FooterRenderer( 

196 config=FooterConfig( 

197 copyright_holder=cfg.app_name, 

198 show_version=False, 

199 ), 

200 ) 

201 

202 # Toast 

203 self.toast_renderer = ServerToastChannel( 

204 config=ToastConfig( 

205 position="top-right", 

206 default_duration_ms=5000, 

207 ), 

208 ) 

209 

210 def _build_nav_groups(self) -> list[NavGroup]: 

211 """Build navigation groups from NavItemConfig list.""" 

212 items = [self._convert_nav_item(item) for item in self.admin_context.nav_items] 

213 

214 if items: 

215 return [NavGroup(label=None, items=items)] 

216 return [] 

217 

218 def _convert_nav_item(self, item: NavItemConfig) -> NavItem: 

219 """Convert NavItemConfig to NavItem.""" 

220 children = [self._convert_nav_item(child) for child in item.children] 

221 

222 is_active = ( 

223 item.active 

224 or self.admin_context.current_path == item.url 

225 or self.admin_context.current_path.startswith(item.url + "/") 

226 ) 

227 

228 return NavItem( 

229 label=item.label, 

230 url=item.url, 

231 icon=item.icon or "circle", 

232 badge=item.badge, 

233 badge_color="blue" 

234 if item.badge_variant == "primary" 

235 else item.badge_variant, 

236 is_active=is_active, 

237 children=children, 

238 ) 

239 

240 def render_head_content(self, **kwargs: Any) -> str: 

241 """Render additional head content. 

242 

243 Returns admin-specific CSS and theme variables. 

244 """ 

245 cfg = self.admin_config 

246 ctx = self.admin_context 

247 

248 parts: list[str] = [] 

249 

250 # Page title 

251 parts.append( 

252 f"<title>{escape(ctx.page_title)} | {escape(cfg.app_name)}</title>", 

253 ) 

254 

255 if ctx.page_description: 

256 parts.append( 

257 f'<meta name="description" content="{escape(ctx.page_description)}">', 

258 ) 

259 

260 # Theme CSS variables 

261 parts.append(f""" 

262 <style> 

263 :root {{ 

264 --admin-sidebar-width: {escape(cfg.sidebar_width)}; 

265 --admin-sidebar-collapsed-width: {escape(cfg.sidebar_collapsed_width)}; 

266 }} 

267 </style> 

268 """) 

269 

270 # Tailwind CSS (static build) 

271 parts.append('<link rel="stylesheet" href="/admin/static/css/tailwind.css">') 

272 parts.append(DARK_BOOTSTRAP_SCRIPT) 

273 parts.append(THEME_BRIDGE_SCRIPT) 

274 

275 # Lucide icons 

276 parts.append('<script src="https://unpkg.com/lucide@latest"></script>') 

277 

278 # SortableJS for dashboard widget drag-and-drop 

279 parts.append( 

280 '<script src="https://unpkg.com/sortablejs@1.15.0/Sortable.min.js"></script>' 

281 ) 

282 

283 # Alpine.js plugins (loaded before Alpine core) 

284 parts.append( 

285 '<script defer src="/admin/static/js/alpine-focus.min.js"></script>', 

286 ) 

287 # Alpine.js for dropdowns, modals, slide-overs 

288 parts.append( 

289 '<script defer src="/admin/static/js/alpine.min.js"></script>', 

290 ) 

291 # Patch Alpine's transition handler to catch isFromCancelledTransition 

292 parts.append( 

293 "<script defer>var origToggle=Element.prototype._x_toggleAndCascadeWithTransitions;origToggle&&(Element.prototype._x_toggleAndCascadeWithTransitions=function(e,t,r,n){var o=origToggle.call(this,e,t,r,n);if(!t&&this._x_hidePromise)this._x_hidePromise.catch(function(a){});return o})</script>", 

294 ) 

295 # Suppress Alpine's harmless transition-cancelled promise rejections 

296 parts.append( 

297 '<script>window.addEventListener("unhandledrejection",function(e){e.promise&&e.promise.catch(function(){});if(!e.reason)return;var r=e.reason;if(r.isFromCancelledTransition||r instanceof TypeError){e.preventDefault();e.stopImmediatePropagation()}})</script>', 

298 ) 

299 

300 return "\n".join(parts) 

301 

302 def render_body_content(self, content: str = "", **kwargs: Any) -> str: 

303 """Render the body content. 

304 

305 Args: 

306 content: Main page content 

307 

308 Returns: 

309 Complete body inner HTML 

310 """ 

311 cfg = self.admin_config 

312 ctx = self.admin_context 

313 

314 parts: list[str] = [] 

315 

316 # Skip link for accessibility 

317 parts.append( 

318 '<a href="#main-content" class="skip-link sr-only focus:not-sr-only">Skip to content</a>', 

319 ) 

320 

321 # Layout wrapper 

322 collapsed_class = "sidebar-collapsed" if ctx.sidebar_collapsed else "" 

323 parts.append(f'<div class="admin-wrapper {collapsed_class}">') 

324 

325 # Sidebar 

326 parts.append(self.sidebar_renderer.render(ctx.current_path)) 

327 

328 # Main area 

329 parts.append('<div class="admin-main">') 

330 

331 # Header 

332 parts.append( 

333 self.header_renderer.render( 

334 notifications=ctx.notifications, 

335 unread_count=ctx.unread_count, 

336 ), 

337 ) 

338 

339 # Main content 

340 parts.append('<main id="main-content" class="admin-content">') 

341 parts.append(content) 

342 parts.append("</main>") 

343 

344 # Footer 

345 if cfg.show_footer: 

346 parts.append(self.footer_renderer.render()) 

347 

348 parts.append("</div>") # admin-main 

349 parts.append("</div>") # admin-wrapper 

350 

351 # Toast container with flash messages 

352 toasts = flash_to_toast(ctx.flash_messages) 

353 parts.append(self.toast_renderer.render_container(toasts)) 

354 

355 # Initialize Lucide icons 

356 parts.append(""" 

357 <script> 

358 document.addEventListener('DOMContentLoaded', function() { 

359 if (window.lucide) lucide.createIcons(); 

360 }); 

361 </script> 

362 """) 

363 

364 # HTMX re-init icons after swap 

365 if cfg.htmx_enabled: 

366 csrf_header = "" 

367 if ctx.csrf_token: 

368 csrf_header = f""" 

369 document.body.addEventListener('htmx:configRequest', function(evt) {{ 

370 evt.detail.headers['X-CSRF-Token'] = '{escape(ctx.csrf_token)}'; 

371 }}); 

372 """ 

373 

374 parts.append(f""" 

375 <script> 

376 {csrf_header} 

377 document.body.addEventListener('htmx:afterSwap', function() {{ 

378 if (window.lucide) lucide.createIcons(); 

379 }}); 

380 </script> 

381 """) 

382 

383 # Core admin JS (served from admin router's static mount) 

384 parts.append('<script src="/admin/static/js/admin.js"></script>') 

385 

386 return "\n".join(parts) 

387 

388 def get_body_attrs(self) -> dict[str, str]: 

389 """Get body tag attributes.""" 

390 cfg = self.admin_config 

391 ctx = self.admin_context 

392 

393 attrs = super().get_body_attrs() # type: ignore[misc] 

394 

395 classes = ["admin-layout"] 

396 if cfg.fixed_header: 

397 classes.append("fixed-header") 

398 if cfg.fixed_sidebar: 

399 classes.append("fixed-sidebar") 

400 if ctx.sidebar_collapsed: 

401 classes.append("sidebar-collapsed") 

402 

403 attrs["class"] = " ".join(classes) 

404 

405 return attrs 

406 

407 

408def admin_layout( 

409 content: str | Markup, 

410 config: AdminLayoutConfig, 

411 context: AdminLayoutContext, 

412) -> Markup: 

413 """Render the complete admin layout. 

414 

415 Convenience function that creates AdminLayout and renders. 

416 

417 Args: 

418 content: Page content to wrap 

419 config: Layout configuration 

420 context: Context (user, nav, etc.) 

421 

422 Returns: 

423 Complete HTML page markup 

424 """ 

425 layout = AdminLayout(config=config, context=context) 

426 return Markup(layout.render(str(content))) # noqa: S704 — framework-composed trusted HTML 

427 

428 

429__all__ = [ 

430 "AdminLayout", 

431 "AdminLayoutConfig", 

432 "AdminLayoutContext", 

433 "NavItemConfig", 

434 "admin_layout", 

435]