Coverage for src / lexigram / admin / dashboard / widgets.py: 46%
140 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""Admin dashboard widget types and infrastructure.
3Provides WidgetType, WidgetConfig, DashboardConfig and supporting
4store/registry infrastructure for admin dashboard assembly.
5Widget definitions from lexigram-contracts are also re-exported here.
6"""
8from __future__ import annotations
10from dataclasses import dataclass, field
11from datetime import datetime
12from enum import StrEnum
13from typing import Any, Protocol
15from lexigram.contracts.admin.types import (
16 DashboardWidgetDefinition,
17 WidgetCategory,
18 WidgetSize,
19)
20from lexigram.ui import el, render_to_string
23class WidgetType(StrEnum):
24 """Widget types for legacy dashboard builder."""
26 METRIC = "metric"
27 CHART = "chart"
28 TABLE = "table"
29 TEXT = "text"
30 CUSTOM = "custom"
31 STAT_CARD = "stat_card"
32 ACTIVITY = "activity"
33 HEALTH = "health"
36@dataclass
37class WidgetConfig:
38 """Widget configuration."""
40 id: str
41 type: WidgetType
42 title: str
43 config: dict[str, Any] = field(default_factory=dict)
44 position: dict[str, int] = field(
45 default_factory=lambda: {"x": 0, "y": 0, "w": 1, "h": 1},
46 )
48 def to_dict(self) -> dict[str, Any]:
49 """Serialize to dictionary."""
50 return {
51 "id": self.id,
52 "type": self.type.value,
53 "title": self.title,
54 "config": self.config,
55 "position": self.position,
56 }
58 @classmethod
59 def from_dict(cls, data: dict[str, Any]) -> WidgetConfig:
60 """Deserialize from dictionary."""
61 return cls(
62 id=data["id"],
63 type=WidgetType(data["type"]),
64 title=data["title"],
65 config=data.get("config", {}),
66 position=data.get("position", {"x": 0, "y": 0, "w": 1, "h": 1}),
67 )
70@dataclass
71class DashboardConfig:
72 """Dashboard configuration."""
74 id: str
75 name: str
76 widgets: list[WidgetConfig] = field(default_factory=list)
77 layout: dict[str, Any] = field(default_factory=dict)
78 created_at: datetime = field(default_factory=datetime.now)
79 updated_at: datetime = field(default_factory=datetime.now)
81 def to_dict(self) -> dict[str, Any]:
82 """Serialize to dictionary."""
83 return {
84 "id": self.id,
85 "name": self.name,
86 "widgets": [w.to_dict() for w in self.widgets],
87 "layout": self.layout,
88 "created_at": self.created_at.isoformat(),
89 "updated_at": self.updated_at.isoformat(),
90 }
92 @classmethod
93 def from_dict(cls, data: dict[str, Any]) -> DashboardConfig:
94 """Deserialize from dictionary."""
95 return cls(
96 id=data["id"],
97 name=data["name"],
98 widgets=[WidgetConfig.from_dict(w) for w in data.get("widgets", [])],
99 layout=data.get("layout", {}),
100 created_at=datetime.fromisoformat(
101 data.get("created_at", datetime.now().isoformat()),
102 ),
103 updated_at=datetime.fromisoformat(
104 data.get("updated_at", datetime.now().isoformat()),
105 ),
106 )
109class IWidget(Protocol):
110 """Protocol for dashboard widgets."""
112 async def render(self, config: dict[str, Any]) -> str:
113 """Render widget HTML (async)."""
114 ...
116 def get_default_config(self) -> dict[str, Any]:
117 """Get default widget configuration."""
118 ...
121class IDashboardStore(Protocol):
122 """Protocol for dashboard persistence (async)."""
124 async def save(self, dashboard: DashboardConfig) -> bool:
125 """Save dashboard configuration."""
126 ...
128 async def load(self, dashboard_id: str) -> DashboardConfig | None:
129 """Load dashboard configuration."""
130 ...
132 async def list(self) -> list[DashboardConfig]:
133 """List all dashboards."""
134 ...
136 async def delete(self, dashboard_id: str) -> bool:
137 """Delete dashboard."""
138 ...
141class InMemoryDashboardStore:
142 """In-memory dashboard storage implementation."""
144 def __init__(self) -> None:
145 """Initialize store."""
146 self.dashboards: dict[str, DashboardConfig] = {}
148 async def save(self, dashboard: DashboardConfig) -> bool:
149 """Save dashboard."""
150 dashboard.updated_at = datetime.now()
151 self.dashboards[dashboard.id] = dashboard
152 return True
154 async def load(self, dashboard_id: str) -> DashboardConfig | None:
155 """Load dashboard."""
156 return self.dashboards.get(dashboard_id)
158 async def list(self) -> list[DashboardConfig]:
159 """List all dashboards."""
160 return list(self.dashboards.values())
162 async def delete(self, dashboard_id: str) -> bool:
163 """Delete dashboard."""
164 if dashboard_id in self.dashboards:
165 del self.dashboards[dashboard_id]
166 return True
167 return False
170class WidgetRegistry:
171 """Registry for dashboard widget implementations."""
173 def __init__(self) -> None:
174 """Initialize registry."""
175 self._widgets: dict[str, type[IWidget]] = {}
177 def register(self, widget_type: str, widget_class: type[IWidget]) -> None:
178 """Register widget type."""
179 self._widgets[widget_type] = widget_class
181 def get(self, widget_type: str) -> type[IWidget] | None:
182 """Get widget class by type."""
183 return self._widgets.get(widget_type)
185 def list_types(self) -> list[str]:
186 """List registered widget types."""
187 return list(self._widgets.keys())
189 def create_widget(self, widget_type: str) -> IWidget | None:
190 """Create widget instance."""
191 widget_class = self.get(widget_type)
192 if widget_class:
193 return widget_class()
194 return None
196 def render_contributor_widgets(
197 self,
198 contributor_widgets: list[DashboardWidgetDefinition],
199 width: str = "100%",
200 ) -> str:
201 """Render HTML for all contributor-supplied ``DashboardWidgetDefinition`` items.
203 For each widget definition the registry is consulted using the widget's
204 ``name`` as the lookup key. If a matching ``IWidget`` class is found,
205 a rich card is rendered that loads its content via an HTMX ``hx-get``
206 request to the definition's ``render_endpoint``. When no match is
207 found a lightweight ``<div class="widget-placeholder">`` card is
208 rendered instead so contributors can always see their widget slot.
210 Widget sizing from ``DashboardWidgetDefinition.size`` maps to CSS
211 grid column spans (SMALL=1, MEDIUM=2, LARGE=3, FULL=4). If
212 ``refresh_interval_seconds`` is set, the card body auto-refreshes
213 via ``hx-trigger="every <N>ms"``.
215 Args:
216 contributor_widgets: Widget definitions supplied by contributors.
217 width: CSS ``width`` value applied to each card wrapper.
219 Returns:
220 Concatenated HTML string for all widget cards.
221 """
222 if not contributor_widgets:
223 return render_to_string(
224 el(
225 "div",
226 el(
227 "div",
228 class_="text-muted-foreground text-lg mb-1",
229 ),
230 el(
231 "p",
232 "No contributor widgets configured.",
233 class_="text-sm text-muted-foreground",
234 ),
235 class_="widget-empty-state bg-muted border border-border rounded-lg p-6 text-center",
236 )
237 )
239 parts: list[str] = []
240 for widget_index, widget_def in enumerate(contributor_widgets):
241 # Map widget size to grid column span
242 size_col_map = {
243 WidgetSize.SMALL: "",
244 WidgetSize.MEDIUM: "lg:col-span-2",
245 WidgetSize.LARGE: "lg:col-span-3",
246 WidgetSize.FULL: "lg:col-span-4",
247 }
248 col_span = size_col_map.get(widget_def.size, "")
250 # Build refresh trigger if interval is set
251 refresh_trigger = ""
252 if (
253 widget_def.refresh_interval_seconds
254 and widget_def.refresh_interval_seconds > 0
255 ):
256 interval_ms = widget_def.refresh_interval_seconds * 1000
257 refresh_trigger = f"every {interval_ms}ms"
259 # Resolve a matching IWidget class from the registry.
260 # Primary key is the definition name; fall back to category value.
261 lookup_key = getattr(widget_def, "widget_type", widget_def.name)
262 widget_class = self.get(lookup_key) or self.get(widget_def.name)
264 # Build common HTMX trigger: load on page render, plus optional polling.
265 # Initial loads are staggered so the dashboard does not fire every
266 # widget request at once — that would saturate the browser's HTTP/1.1
267 # connection pool (~6 per origin) and starve sidebar navigation
268 # requests for the whole drain.
269 load_trigger = f"load delay:{widget_index * 350}ms"
270 if refresh_trigger:
271 load_trigger = f"{load_trigger}, {refresh_trigger}"
273 # Build the title bar with optional icon and config cog.
274 icon_html = (
275 el("span", widget_def.icon, class_="widget-icon mr-1")
276 if widget_def.icon
277 else None
278 )
279 title_children: list[Any] = []
280 if icon_html is not None:
281 title_children.append(icon_html)
282 title_children.append(widget_def.title)
284 cog = el(
285 "button",
286 "⚙",
287 **{
288 "hx-get": f"/admin/core/widgets/{widget_def.name}/config",
289 "hx-target": "#slide-over-container",
290 "hx-swap": "innerHTML",
291 "hx-push-url": "false",
292 },
293 class_="opacity-0 group-hover:opacity-100 transition-opacity ml-auto text-muted-foreground hover:text-muted-foreground text-sm cursor-pointer",
294 )
295 title_row = el(
296 "div",
297 *title_children,
298 cog,
299 class_="widget-title text-sm font-semibold text-foreground mb-2 flex items-center",
300 )
302 # Contributor label and description
303 subtitle_parts: list[str] = []
304 if widget_def.contributor:
305 subtitle_parts.append(widget_def.contributor)
306 if widget_def.description:
307 subtitle_parts.append(widget_def.description)
309 # Loading skeleton shown while HTMX request is in flight
310 skeleton = el(
311 "div",
312 el("div", class_="h-4 bg-muted rounded w-3/4 mb-2"),
313 el("div", class_="h-4 bg-muted rounded w-1/2 mb-2"),
314 el("div", class_="h-4 bg-muted rounded w-5/6"),
315 class_="animate-pulse py-2",
316 )
318 card_children: list[Any] = [
319 title_row,
320 ]
322 if subtitle_parts:
323 card_children.append(
324 el(
325 "div",
326 " · ".join(subtitle_parts),
327 class_="text-xs text-muted-foreground mb-3",
328 ),
329 )
331 body_kwargs: dict[str, Any] = {
332 "class": "widget-body",
333 "id": f"widget-{widget_def.name}-body",
334 "hx-get": widget_def.render_endpoint,
335 "hx-trigger": load_trigger,
336 "hx-swap": "innerHTML",
337 }
338 card_children.append(
339 el(
340 "div",
341 skeleton,
342 **body_kwargs,
343 ),
344 )
346 card = el(
347 "div",
348 *card_children,
349 id=f"widget-card-{widget_def.name}",
350 data_widget_name=widget_def.name,
351 class_=f"widget-card bg-card rounded-lg shadow p-4 group {col_span}".strip(),
352 style=f"width:{width};",
353 )
355 parts.append(render_to_string(card))
357 return "".join(parts)
360def render_dashboard_widgets(
361 definitions: list[DashboardWidgetDefinition],
362 registry: WidgetRegistry,
363) -> str:
364 """Render all dashboard widget definitions using the provided registry.
366 Convenience wrapper around :meth:`WidgetRegistry.render_contributor_widgets`
367 for callers that already hold a ``WidgetRegistry`` instance and a list of
368 ``DashboardWidgetDefinition`` objects.
370 Args:
371 definitions: Widget definitions to render.
372 registry: Widget registry to look up implementations.
374 Returns:
375 Concatenated HTML string for all widget cards.
376 """
377 return registry.render_contributor_widgets(definitions)
380from lexigram.admin.dashboard.widget_types import ConfigField
383def render_widget_config_popup(
384 widget_name: str,
385 title: str,
386 fields: list[ConfigField],
387 current_values: dict[str, Any],
388 enabled: bool = True,
389) -> str:
390 """Render HTML for a widget config dialog."""
391 rows: list[Any] = [
392 el(
393 "label",
394 el(
395 "input",
396 type_="checkbox",
397 name="enabled",
398 checked="checked" if enabled else None,
399 ),
400 " Show on dashboard",
401 class_="flex items-center gap-2 text-sm mb-4",
402 )
403 ]
405 for f in fields:
406 if isinstance(f, dict):
407 f = ConfigField(**f)
408 value = current_values.get(f.name, f.default)
409 rows.append(
410 el(
411 "div",
412 el("label", f.label, class_="block text-sm font-medium mb-1"),
413 _render_field_input(f, value),
414 class_="mb-3",
415 ),
416 )
418 from lexigram.admin.ui.organisms.admin_slide_over import (
419 render_slide_over_fragment,
420 )
422 form = el(
423 "form",
424 *rows,
425 el("input", type_="hidden", name="widget_name", value=widget_name),
426 id=f"widget-config-form-{widget_name}",
427 **{
428 "hx-post": "/admin/core/widgets/config",
429 "hx-swap": "none",
430 "hx-on:htmx:after-request": "if(event.detail.successful){window.location.reload();}",
431 },
432 class_="space-y-3",
433 )
435 return render_slide_over_fragment(
436 title=f"Configure: {title}",
437 subtitle="Update this widget's settings.",
438 content=form,
439 size="md",
440 footer=[
441 el(
442 "button",
443 "Cancel",
444 type_="button",
445 **{"x-on:click": "open = false"},
446 class_="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium text-foreground bg-card border border-border hover:bg-muted transition-colors",
447 ),
448 el(
449 "button",
450 "Save",
451 type_="submit",
452 form=f"widget-config-form-{widget_name}",
453 class_="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-primary hover:bg-primary/90 transition-colors",
454 ),
455 ],
456 )
459def _render_field_input(
460 field: ConfigField, current: Any, widget_name: str | None = None
461) -> str:
462 prefix = f"param__{widget_name}__" if widget_name else "param_"
463 if field.type == "select" and field.options:
464 opts = [
465 el(
466 "option",
467 label,
468 value=str(val),
469 selected="selected" if val == current else None,
470 )
471 for val, label in field.options
472 ]
473 return str(
474 el(
475 "select",
476 *opts,
477 name=f"{prefix}{field.name}",
478 class_="w-full border rounded px-2 py-1 text-sm",
479 )
480 )
481 if field.type == "number":
482 return str(
483 el(
484 "input",
485 type_="number",
486 name=f"{prefix}{field.name}",
487 value=str(current) if current is not None else "",
488 class_="w-full border rounded px-2 py-1 text-sm",
489 )
490 )
491 if field.type == "boolean":
492 return str(
493 el(
494 "input",
495 type_="checkbox",
496 name=f"{prefix}{field.name}",
497 checked="checked" if current else None,
498 )
499 )
500 return str(
501 el(
502 "input",
503 type_="text",
504 name=f"{prefix}{field.name}",
505 value=str(current) if current is not None else "",
506 class_="w-full border rounded px-2 py-1 text-sm",
507 )
508 )
511__all__ = [
512 "ConfigField",
513 "DashboardConfig",
514 "DashboardWidgetDefinition",
515 "IDashboardStore",
516 "IWidget",
517 "InMemoryDashboardStore",
518 "WidgetCategory",
519 "WidgetConfig",
520 "WidgetRegistry",
521 "WidgetSize",
522 "WidgetType",
523 "render_dashboard_widgets",
524 "render_widget_config_popup",
525]