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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:39 +0800
1from __future__ import annotations
3from typing import Any
4from uuid import uuid4
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)
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}
26_COL_SPAN_MAP = {1: "", 2: "lg:col-span-2", 3: "lg:col-span-3", 4: "lg:col-span-4"}
29class ChartWidget(Component):
30 """A chart card with optional HTMX lazy-loading, filters and empty state.
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.
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 """
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]}"
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
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 )
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 )
184 def render(self) -> Any:
185 span = _COL_SPAN_MAP.get(self.col_span, "")
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 )
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"
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")
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 )