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

1"""Live polling / auto-refresh component. 

2 

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). 

6 

7Usage in a controller:: 

8 

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""" 

17 

18from __future__ import annotations 

19 

20from typing import Any 

21 

22from lexigram.ui import Component, el 

23 

24 

25class AutoRefreshWidget(Component): 

26 """HTMX polling container that re-fetches *url* every *interval_ms* ms. 

27 

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. 

31 

32 A "Pause / Resume" toggle button (Alpine.js) lets users stop polling 

33 without a page reload. 

34 

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 """ 

44 

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 

62 

63 def render(self) -> Any: 

64 controls = self._render_controls() if self.show_controls else "" 

65 

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 ) 

89 

90 # ------------------------------------------------------------------ 

91 # Private helpers 

92 # ------------------------------------------------------------------ 

93 

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 ) 

104 

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 ) 

135 

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 ) 

163 

164 

165class LiveDataTable(AutoRefreshWidget): 

166 """Auto-refreshing data table variant. 

167 

168 Convenience subclass with table-appropriate defaults: 

169 longer interval and no pause/resume controls by default. 

170 

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 """ 

177 

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 )