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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
1"""AdminLayout - Main layout wrapper for admin pages.
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
11Uses inheritance from BaseLayout for code reuse.
13UI-08: AdminLayout implementation.
14"""
16from __future__ import annotations
18from dataclasses import dataclass, field
19from typing import Any
21from markupsafe import Markup, escape
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
44@dataclass
45class AdminLayoutConfig(BaseLayoutConfig):
46 """Configuration for admin layout.
48 Extends BaseLayoutConfig with admin-specific options.
49 """
51 # Branding
52 app_name: str = "Admin"
53 app_logo: str | None = None
54 app_logo_alt: str = "Logo"
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
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
71@dataclass
72class NavItemConfig:
73 """Navigation item configuration."""
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
85@dataclass
86class AdminLayoutContext(BaseLayoutContext):
87 """Context for admin layout rendering.
89 Extends BaseLayoutContext with admin-specific data.
90 """
92 # Current page
93 page_title: str = "Dashboard"
94 page_description: str | None = None
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
102 # Navigation
103 nav_items: list[NavItemConfig] = field(default_factory=list)
104 current_path: str = "/"
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"
112 # Notifications
113 notifications: list[dict[str, Any]] = field(default_factory=list)
114 unread_count: int = 0
116 # Messages/Toasts
117 flash_messages: list[tuple[str, str]] = field(default_factory=list)
119 # CSRF
120 csrf_token: str | None = None
122 # State
123 sidebar_collapsed: bool = False
126class AdminLayout(LayoutBase):
127 """Admin layout with sidebar, header, and footer.
129 Extends BaseLayout with admin-specific components and rendering.
130 """
132 def __init__(
133 self,
134 config: AdminLayoutConfig | None = None,
135 context: AdminLayoutContext | None = None,
136 ):
137 """Initialize admin layout.
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()
146 # Initialize base layout
147 super().__init__(self.admin_config)
149 # Set up component renderers
150 self._setup_components()
152 def _setup_components(self) -> None:
153 """Set up layout component renderers."""
154 ctx = self.admin_context
155 cfg = self.admin_config
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 )
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 )
194 # Footer
195 self.footer_renderer = FooterRenderer(
196 config=FooterConfig(
197 copyright_holder=cfg.app_name,
198 show_version=False,
199 ),
200 )
202 # Toast
203 self.toast_renderer = ServerToastChannel(
204 config=ToastConfig(
205 position="top-right",
206 default_duration_ms=5000,
207 ),
208 )
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]
214 if items:
215 return [NavGroup(label=None, items=items)]
216 return []
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]
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 )
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 )
240 def render_head_content(self, **kwargs: Any) -> str:
241 """Render additional head content.
243 Returns admin-specific CSS and theme variables.
244 """
245 cfg = self.admin_config
246 ctx = self.admin_context
248 parts: list[str] = []
250 # Page title
251 parts.append(
252 f"<title>{escape(ctx.page_title)} | {escape(cfg.app_name)}</title>",
253 )
255 if ctx.page_description:
256 parts.append(
257 f'<meta name="description" content="{escape(ctx.page_description)}">',
258 )
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 """)
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)
275 # Lucide icons
276 parts.append('<script src="https://unpkg.com/lucide@latest"></script>')
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 )
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 )
300 return "\n".join(parts)
302 def render_body_content(self, content: str = "", **kwargs: Any) -> str:
303 """Render the body content.
305 Args:
306 content: Main page content
308 Returns:
309 Complete body inner HTML
310 """
311 cfg = self.admin_config
312 ctx = self.admin_context
314 parts: list[str] = []
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 )
321 # Layout wrapper
322 collapsed_class = "sidebar-collapsed" if ctx.sidebar_collapsed else ""
323 parts.append(f'<div class="admin-wrapper {collapsed_class}">')
325 # Sidebar
326 parts.append(self.sidebar_renderer.render(ctx.current_path))
328 # Main area
329 parts.append('<div class="admin-main">')
331 # Header
332 parts.append(
333 self.header_renderer.render(
334 notifications=ctx.notifications,
335 unread_count=ctx.unread_count,
336 ),
337 )
339 # Main content
340 parts.append('<main id="main-content" class="admin-content">')
341 parts.append(content)
342 parts.append("</main>")
344 # Footer
345 if cfg.show_footer:
346 parts.append(self.footer_renderer.render())
348 parts.append("</div>") # admin-main
349 parts.append("</div>") # admin-wrapper
351 # Toast container with flash messages
352 toasts = flash_to_toast(ctx.flash_messages)
353 parts.append(self.toast_renderer.render_container(toasts))
355 # Initialize Lucide icons
356 parts.append("""
357 <script>
358 document.addEventListener('DOMContentLoaded', function() {
359 if (window.lucide) lucide.createIcons();
360 });
361 </script>
362 """)
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 """
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 """)
383 # Core admin JS (served from admin router's static mount)
384 parts.append('<script src="/admin/static/js/admin.js"></script>')
386 return "\n".join(parts)
388 def get_body_attrs(self) -> dict[str, str]:
389 """Get body tag attributes."""
390 cfg = self.admin_config
391 ctx = self.admin_context
393 attrs = super().get_body_attrs() # type: ignore[misc]
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")
403 attrs["class"] = " ".join(classes)
405 return attrs
408def admin_layout(
409 content: str | Markup,
410 config: AdminLayoutConfig,
411 context: AdminLayoutContext,
412) -> Markup:
413 """Render the complete admin layout.
415 Convenience function that creates AdminLayout and renders.
417 Args:
418 content: Page content to wrap
419 config: Layout configuration
420 context: Context (user, nav, etc.)
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
429__all__ = [
430 "AdminLayout",
431 "AdminLayoutConfig",
432 "AdminLayoutContext",
433 "NavItemConfig",
434 "admin_layout",
435]