Coverage for src / lexigram / admin / ui / organisms / live_polling.py: 43%
23 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
1"""Live polling / auto-refresh component.
3Provides an HTMX-powered ``AutoRefreshWidget`` that automatically
4re-fetches content at a configurable interval — the "Live polling /
5auto-refresh" feature from the benchmark matrix (FilamentPHP Y, React Admin Y).
7Usage in a controller::
9 widget = AutoRefreshWidget(
10 url="/admin/dashboard/stats",
11 interval_ms=5000,
12 target_id="stats-panel",
13 label="Auto-refresh",
14 )
15 # Render the container div once; HTMX polls automatically.
16"""
18from __future__ import annotations
20from typing import Any
22from lexigram.ui import Component, el
25class AutoRefreshWidget(Component):
26 """HTMX polling container that re-fetches *url* every *interval_ms* ms.
28 The component renders a ``<div>`` with ``hx-get`` and
29 ``hx-trigger="every <N>ms"`` attributes. The polled response should
30 return an HTML fragment that replaces ``innerHTML`` of the container.
32 A "Pause / Resume" toggle button (Alpine.js) lets users stop polling
33 without a page reload.
35 Args:
36 url: Endpoint to poll (HTMX GET).
37 interval_ms: Polling interval in milliseconds (default 5000).
38 target_id: ``id`` given to the wrapper element, used as the HTMX
39 target selector for OOB swaps.
40 content: Optional initial HTML content rendered before first poll.
41 label: Optional label shown next to the pause/resume button.
42 show_controls: Whether to render the pause/resume button.
43 """
45 def __init__(
46 self,
47 url: str,
48 interval_ms: int = 5000,
49 target_id: str = "auto-refresh-widget",
50 content: Any = "",
51 label: str = "",
52 show_controls: bool = True,
53 **props: Any,
54 ) -> None:
55 super().__init__(**props)
56 self.url = url
57 self.interval_ms = interval_ms
58 self.target_id = target_id
59 self.content = content
60 self.label = label
61 self.show_controls = show_controls
63 def render(self) -> Any:
64 controls = self._render_controls() if self.show_controls else ""
66 return el(
67 "div",
68 controls,
69 el(
70 "div",
71 self.content,
72 id=self.target_id + "-content",
73 **{
74 "hx-get": self.url,
75 "hx-trigger": f"every {self.interval_ms}ms [!paused]",
76 "hx-target": "this",
77 "hx-swap": "innerHTML",
78 "hx-indicator": f"#{self.target_id}-spinner",
79 "x-bind:data-paused": "paused",
80 },
81 ),
82 self._render_spinner(),
83 id=self.target_id,
84 class_="relative",
85 **{
86 "x-data": "{ paused: false }",
87 },
88 )
90 # ------------------------------------------------------------------
91 # Private helpers
92 # ------------------------------------------------------------------
94 def _render_controls(self) -> Any:
95 label_el = (
96 el(
97 "span",
98 self.label,
99 class_="text-xs text-muted-foreground mr-2",
100 )
101 if self.label
102 else ""
103 )
105 return el(
106 "div",
107 label_el,
108 # Pause button
109 el(
110 "button",
111 el(
112 "span",
113 "Pause",
114 **{"x-show": "!paused"},
115 style="display:inline",
116 ),
117 el(
118 "span",
119 "Resume",
120 **{"x-show": "paused", "x-cloak": ""},
121 ),
122 type="button",
123 class_=(
124 "inline-flex items-center gap-1 px-2 py-1 text-xs "
125 "rounded border border-border "
126 "bg-card "
127 "text-muted-foreground dark:text-foreground "
128 "hover:bg-muted dark:hover:bg-muted "
129 "transition-colors"
130 ),
131 **{"@click": "paused = !paused"},
132 ),
133 class_="flex items-center justify-end mb-2",
134 )
136 def _render_spinner(self) -> Any:
137 return el(
138 "div",
139 el(
140 "svg",
141 el(
142 "circle",
143 cx="12",
144 cy="12",
145 r="10",
146 stroke="currentColor",
147 **{"stroke-width": "4", "fill": "none", "class": "opacity-25"},
148 ),
149 el(
150 "path",
151 fill="currentColor",
152 d="M4 12a8 8 0 018-8v8z",
153 **{"class": "opacity-75"},
154 ),
155 xmlns="http://www.w3.org/2000/svg",
156 fill="none",
157 viewBox="0 0 24 24",
158 class_="animate-spin w-4 h-4 text-primary",
159 ),
160 id=self.target_id + "-spinner",
161 class_="htmx-indicator absolute top-2 right-2",
162 )
165class LiveDataTable(AutoRefreshWidget):
166 """Auto-refreshing data table variant.
168 Convenience subclass with table-appropriate defaults:
169 longer interval and no pause/resume controls by default.
171 Args:
172 url: Endpoint returning updated table HTML fragment.
173 interval_ms: Polling interval (default 30 000 ms = 30 s).
174 target_id: Wrapper element ID.
175 show_controls: Whether to show pause button (default True).
176 """
178 def __init__(
179 self,
180 url: str,
181 interval_ms: int = 30_000,
182 target_id: str = "live-data-table",
183 show_controls: bool = True,
184 **props: Any,
185 ) -> None:
186 super().__init__(
187 url=url,
188 interval_ms=interval_ms,
189 target_id=target_id,
190 show_controls=show_controls,
191 **props,
192 )