Coverage for src/lexigram/admin/dashboard/chart_widget.py: 14%

78 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:39 +0800

1from __future__ import annotations 

2 

3from typing import Any 

4from uuid import uuid4 

5 

6from lexigram.admin.dashboard.widget_types import ConfigField 

7from lexigram.ui import ( 

8 AreaChart, 

9 BarChart, 

10 ChartConfig, 

11 ChartDataPoint, 

12 ChartType, 

13 Component, 

14 LineChart, 

15 PieChart, 

16 el, 

17) 

18 

19_CHART_TYPE_MAP: dict[ChartType, type[Component]] = { 

20 ChartType.BAR: BarChart, 

21 ChartType.LINE: LineChart, 

22 ChartType.PIE: PieChart, 

23 ChartType.AREA: AreaChart, 

24} 

25 

26_COL_SPAN_MAP = {1: "", 2: "lg:col-span-2", 3: "lg:col-span-3", 4: "lg:col-span-4"} 

27 

28 

29class ChartWidget(Component): 

30 """A chart card with optional HTMX lazy-loading, filters and empty state. 

31 

32 Args: 

33 title: Card heading. 

34 chart_type: Chart kind (bar/line/pie/area). 

35 data: Static data points rendered inline. Ignored when ``data_source`` 

36 is set (the endpoint's response replaces the body). 

37 chart_config: Chart styling configuration. 

38 data_source: Optional endpoint URL fetched via HTMX. Filter changes 

39 re-fetch it with the filter values as query parameters. 

40 refresh_interval: Optional polling interval in seconds, only used 

41 when ``data_source`` is set and no filters are configured. 

42 description: Optional supporting text under the heading. 

43 col_span: Dashboard grid column span (1-4). 

44 filters: Optional filter schema (``ConfigField`` list) rendered above 

45 the chart body. Changing a filter re-fetches ``data_source`` with 

46 the current values; when filters are present the automatic poll 

47 trigger is suppressed so applied filters are not clobbered. 

48 empty_state_title: Override for the empty-state heading (default 

49 "No data"). 

50 empty_state_message: Optional empty-state supporting text. 

51 empty_state_icon: Optional empty-state icon glyph. 

52 

53 Example: 

54 ```python 

55 ChartWidget( 

56 title="Sales", 

57 chart_type=ChartType.BAR, 

58 data_source="/admin/widgets/sales/chart", 

59 filters=[ 

60 ConfigField( 

61 name="period", 

62 type="select", 

63 label="Period", 

64 options=[("30d", "Last 30 days"), ("90d", "Last 90 days")], 

65 default="30d", 

66 ), 

67 ], 

68 ) 

69 ``` 

70 """ 

71 

72 def __init__( 

73 self, 

74 title: str, 

75 chart_type: ChartType, 

76 data: list[ChartDataPoint] | None = None, 

77 *, 

78 chart_config: ChartConfig | None = None, 

79 data_source: str | None = None, 

80 refresh_interval: int | None = None, 

81 description: str = "", 

82 col_span: int = 1, 

83 filters: list[ConfigField] | None = None, 

84 empty_state_title: str | None = None, 

85 empty_state_message: str | None = None, 

86 empty_state_icon: str | None = None, 

87 ) -> None: 

88 super().__init__() 

89 self.title = title 

90 self.chart_type = chart_type 

91 self.data = data or [] 

92 self.chart_config = chart_config or ChartConfig() 

93 self.data_source = data_source 

94 self.refresh_interval = refresh_interval 

95 self.description = description 

96 self.col_span = col_span 

97 self.filters = filters or [] 

98 self.empty_state_title = empty_state_title or "No data" 

99 self.empty_state_message = empty_state_message 

100 self.empty_state_icon = empty_state_icon 

101 self._body_id = f"chart-body-{uuid4().hex[:8]}" 

102 

103 def _render_filters(self) -> Any: 

104 """Render the filter form, or ``None`` when no filters are configured.""" 

105 if not self.filters: 

106 return None 

107 

108 fields: list[Any] = [] 

109 for field_schema in self.filters: 

110 common_attrs: dict[str, Any] = { 

111 "name": field_schema.name, 

112 "class": "bg-card border border-border rounded-md px-2 py-1 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary", 

113 } 

114 if field_schema.description: 

115 common_attrs["title"] = field_schema.description 

116 if field_schema.type == "select" and field_schema.options: 

117 options = [] 

118 for option_value, option_label in field_schema.options: 

119 option_attrs: dict[str, Any] = {} 

120 if field_schema.default is not None and str(option_value) == str( 

121 field_schema.default 

122 ): 

123 option_attrs["selected"] = True 

124 options.append( 

125 el( 

126 "option", 

127 option_label, 

128 value=str(option_value), 

129 **option_attrs, 

130 ) 

131 ) 

132 input_el = el("select", *options, **common_attrs) 

133 elif field_schema.type == "boolean": 

134 input_el = el( 

135 "input", 

136 type="checkbox", 

137 checked=bool(field_schema.default), 

138 **common_attrs, 

139 ) 

140 elif field_schema.type == "number": 

141 input_el = el( 

142 "input", 

143 type="number", 

144 value=str(field_schema.default or ""), 

145 **common_attrs, 

146 ) 

147 else: 

148 input_el = el( 

149 "input", 

150 type="text", 

151 value=str(field_schema.default or ""), 

152 **common_attrs, 

153 ) 

154 fields.append( 

155 el( 

156 "label", 

157 el( 

158 "span", 

159 field_schema.label, 

160 class_="block text-xs text-muted-foreground mb-1", 

161 ), 

162 input_el, 

163 class_="block", 

164 ) 

165 ) 

166 

167 form_attrs: dict[str, Any] = {} 

168 if self.data_source: 

169 form_attrs.update( 

170 { 

171 "hx-get": self.data_source, 

172 "hx-trigger": "change", 

173 "hx-target": f"#{self._body_id}", 

174 "hx-swap": "innerHTML", 

175 } 

176 ) 

177 return el( 

178 "form", 

179 *fields, 

180 class_="chart-filters flex flex-wrap gap-3 mb-3 items-end", 

181 **form_attrs, 

182 ) 

183 

184 def render(self) -> Any: 

185 span = _COL_SPAN_MAP.get(self.col_span, "") 

186 

187 header = [ 

188 el( 

189 "h3", 

190 self.title, 

191 class_="text-sm font-semibold text-foreground", 

192 ), 

193 ] 

194 if self.description: 

195 header.append( 

196 el( 

197 "p", 

198 self.description, 

199 class_="text-xs text-muted-foreground mt-0.5", 

200 ) 

201 ) 

202 

203 hx_attrs: dict[str, Any] = {} 

204 if self.data_source: 

205 triggers = ["load"] 

206 if not self.filters and self.refresh_interval and self.refresh_interval > 0: 

207 triggers.append(f"every {self.refresh_interval * 1000}ms") 

208 hx_attrs["hx-get"] = self.data_source 

209 hx_attrs["hx-trigger"] = ", ".join(triggers) 

210 hx_attrs["hx-target"] = f"#{self._body_id}" 

211 hx_attrs["hx-swap"] = "innerHTML" 

212 

213 body: Component 

214 if self.data: 

215 chart_cls = _CHART_TYPE_MAP.get(self.chart_type) 

216 if chart_cls: 

217 body = chart_cls(self.data, self.chart_config) 

218 else: 

219 body = el( 

220 "div", 

221 "Unsupported chart type", 

222 class_="text-sm text-muted-foreground text-center py-8", 

223 ) 

224 elif self.data_source: 

225 body = el( 

226 "div", 

227 el("div", class_="h-4 bg-muted rounded w-3/4 mb-2"), 

228 el("div", class_="h-4 bg-muted rounded w-1/2 mb-2"), 

229 class_="animate-pulse py-2", 

230 ) 

231 else: 

232 empty_children = [ 

233 el( 

234 "div", 

235 self.empty_state_title, 

236 class_="text-sm text-muted-foreground text-center", 

237 ) 

238 ] 

239 if self.empty_state_icon: 

240 empty_children.insert( 

241 0, 

242 el( 

243 "div", 

244 self.empty_state_icon, 

245 class_="text-lg mb-1 text-center", 

246 ), 

247 ) 

248 if self.empty_state_message: 

249 empty_children.append( 

250 el( 

251 "p", 

252 self.empty_state_message, 

253 class_="text-xs text-muted-foreground text-center mt-1", 

254 ) 

255 ) 

256 body = el("div", *empty_children, class_="py-8") 

257 

258 filters = self._render_filters() 

259 return el( 

260 "div", 

261 el("div", *header, class_="mb-4"), 

262 filters, 

263 el("div", body, id=self._body_id), 

264 class_=f"bg-card rounded-xl shadow-sm border border-border p-5 {span}".strip(), 

265 **hx_attrs, 

266 )