Coverage for src / lexigram / admin / ui / filters / types / selection.py: 0%
170 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 18:58 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 18:58 +0800
1"""Selection-based filter types."""
3from __future__ import annotations
5from typing import TYPE_CHECKING, Any
7from lexigram.admin.data.query import EqualSpec, InSpec
8from lexigram.admin.ui.filters.base import Filter
9from lexigram.serialization import dumps_str
10from lexigram.ui import Zones
12if TYPE_CHECKING:
13 from collections.abc import Callable
16class SelectFilter(Filter):
17 """
18 Dropdown select filter.
20 Example:
21 SelectFilter(options={
22 "active": "Active",
23 "inactive": "Inactive",
24 "pending": "Pending",
25 })
27 # Or with lambda for dynamic options
28 SelectFilter(options=lambda: get_active_statuses())
29 """
31 def __init__(
32 self,
33 name: str = "",
34 options: list[str] | dict[str, str] | Callable | None = None,
35 label: str | None = None,
36 multiple: bool = False,
37 default: Any = None,
38 ):
39 """
40 Initialize select filter.
42 Args:
43 name: Filter field name (auto-set by column if empty)
44 options: List of values, dict of value:label pairs, or callable
45 label: Display label
46 multiple: Allow multiple selection
47 default: Default value
48 """
49 super().__init__(name, label)
51 # Store options or callback
52 if options is None:
53 self.options: dict[str, str] = {}
54 self._options_callback = None
55 elif callable(options):
56 self.options = {}
57 self._options_callback = options
58 elif isinstance(options, list):
59 self.options = {}
60 for opt in options:
61 if isinstance(opt, dict):
62 val = str(opt.get("value", opt.get("id", "")))
63 lab = str(opt.get("label", opt.get("name", val)))
64 self.options[val] = lab
65 else:
66 self.options[str(opt)] = str(opt)
67 self._options_callback = None
68 else:
69 self.options = options
70 self._options_callback = None
72 self._multiple = multiple
73 if default is not None:
74 self._default = default
76 def multiple(self, multiple: bool = True) -> SelectFilter:
77 """Enable multiple selection."""
78 self._multiple = multiple
79 return self
81 def get_options(self) -> dict[str, str]:
82 """Get options, calling callback if dynamic."""
83 if self._options_callback:
84 opts = self._options_callback()
85 if isinstance(opts, list):
86 result = {}
87 for opt in opts:
88 if isinstance(opt, dict):
89 val = str(opt.get("value", opt.get("id", "")))
90 lab = str(opt.get("label", opt.get("name", val)))
91 result[val] = lab
92 else:
93 result[str(opt)] = str(opt)
94 return result
95 return opts
96 return self.options
98 def render(self, current_value: Any = None, url: str | None = None) -> str: # type: ignore[override]
99 """Render as select dropdown using atomic Select component."""
100 from lexigram.ui import Select
102 # Handle multiple values
103 value = current_value if current_value is not None else self.value
105 # Get options (dynamic or static)
106 options = self.get_options()
108 # Convert options dict to choices list format
109 placeholder = self.placeholder or f"Select {self.label}" # type: ignore[truthy-function]
110 choices = [("", placeholder)] # Add placeholder as first option
111 choices.extend([(kv[0], kv[1]) for kv in options.items()]) # type: ignore[misc]
113 # Get state and resource prefix
114 state = getattr(self, "_state", None)
115 resource_prefix = getattr(state, "_resource_prefix", None) if state else url
116 base_url = resource_prefix.rstrip("/") if resource_prefix else ""
118 # Canonical HTMX attrs: prefer stored from FilterBar, else fallback
119 stored = self.get_htmx_attrs()
120 if stored:
121 htmx_attrs = stored
122 elif state:
123 params = state.to_query_params()
124 params.pop(self.name, None)
125 params.pop("page", None)
126 params.pop("cursor", None)
127 htmx_attrs = {
128 "hx-get": f"{base_url}/",
129 "hx-target": Zones.DATA.selector,
130 "hx-swap": Zones.DATA.swap_mode.value,
131 "hx-select": Zones.DATA.selector,
132 "hx-push-url": "true",
133 "hx-vals": dumps_str(params),
134 }
135 else:
136 htmx_attrs = {
137 "hx-get": f"{base_url}/",
138 "hx-trigger": "change",
139 "hx-target": Zones.DATA.selector,
140 "hx-swap": Zones.DATA.swap_mode.value,
141 "hx-select": Zones.DATA.selector,
142 "hx-push-url": "true",
143 "hx-include": f"{Zones.DATA.selector} [data-state='true'], #{Zones.SEARCH.id}",
144 "hx-params": "*",
145 }
147 # Convert to component-friendly names (hx_*)
148 comp_attrs = {k.replace("-", "_"): v for k, v in htmx_attrs.items()}
150 select = Select(
151 name=f"filter_{self.name}",
152 choices=choices, # type: ignore[arg-type]
153 value=value,
154 label=self.label,
155 **comp_attrs, # type: ignore[arg-type]
156 )
158 return select.render()
160 def apply(self, query: Any, value: Any) -> Any:
161 """
162 Apply filter to query.
163 """
164 if not value:
165 return query
167 # Handle multiple values
168 if self._multiple and isinstance(value, list):
169 # Assuming SQLAlchemy-style query
170 return query.filter(getattr(query.model, self.name).in_(value))
171 # Single value
172 return query.filter(**{self.name: value})
174 def from_url_param(self, param: Any) -> Any:
175 """Parse value from URL param. Handles multiple if enabled."""
176 if param is None or param == "":
177 return None
179 if self._multiple:
180 if isinstance(param, list):
181 return param
182 return list(
183 filter(
184 lambda p: p.strip(),
185 (p.strip() for p in str(param).split(",")),
186 ),
187 )
189 return param
191 def to_spec(self, value: Any) -> Any | None:
192 """Convert to EqualSpec or InSpec."""
194 parsed = self.from_url_param(value)
195 if parsed is None:
196 return None
198 if self._multiple and isinstance(parsed, list):
199 return InSpec(self.name, parsed)
201 return EqualSpec(self.name, parsed)
204class MultiSelectFilter(Filter):
205 """
206 Multi-select filter with checkboxes.
208 Example:
209 MultiSelectFilter(
210 options={
211 "training": "Training",
212 "grooming": "Grooming",
213 "vet_visit": "Vet Visit",
214 },
215 label="Tags"
216 )
218 # Or with lambda for dynamic options
219 MultiSelectFilter(
220 options=lambda: get_active_tags(),
221 label="Tags"
222 )
223 """
225 def __init__(
226 self,
227 name: str = "",
228 options: list[str] | dict[str, str] | Callable | None = None,
229 label: str | None = None,
230 ):
231 """
232 Initialize multi-select filter.
234 Args:
235 name: Filter field name (auto-set by column if empty)
236 options: List of values, dict of value:label pairs, or callable
237 label: Display label
238 """
239 super().__init__(name, label)
241 # Convert list to dict if needed
242 if options is None:
243 self.options: dict[str, str] = {}
244 elif isinstance(options, list):
245 self.options = {}
246 for opt in options:
247 if isinstance(opt, dict):
248 val = str(opt.get("value", opt.get("id", "")))
249 lab = str(opt.get("label", opt.get("name", val)))
250 self.options[val] = lab
251 else:
252 self.options[str(opt)] = str(opt)
253 elif callable(options):
254 self._options_callback = options
255 self.options = {}
256 else:
257 self.options = options
259 self._options_callback = options if callable(options) else None # type: ignore[assignment]
261 def get_consumed_params(self) -> list[str]:
262 """Multi-select filter uses the name with [] suffix."""
263 return [self.name, f"{self.name}[]"]
265 def get_options(self) -> dict[str, str]:
266 """Get options, calling callback if dynamic."""
267 if self._options_callback: # type: ignore[truthy-function]
268 opts = self._options_callback()
269 if isinstance(opts, list):
270 result = {}
271 for opt in opts:
272 if isinstance(opt, dict):
273 val = str(opt.get("value", opt.get("id", "")))
274 lab = str(opt.get("label", opt.get("name", val)))
275 result[val] = lab
276 else:
277 result[str(opt)] = str(opt)
278 return result
279 return opts
280 return self.options
282 def render(self, current_value: Any = None, url: str | None = None) -> str: # type: ignore[override]
283 """Render as checkboxes using atomic components."""
284 from lexigram.ui import Checkbox
285 from lexigram.ui.core.base import el
287 # Get options (dynamic or static)
288 options = self.get_options()
290 # Normalize current values to list
291 selected = []
292 if current_value:
293 if isinstance(current_value, list):
294 selected = current_value
295 else:
296 selected = [current_value]
298 # Get state and resource prefix
299 state = getattr(self, "_state", None)
300 resource_prefix = getattr(state, "_resource_prefix", None) if state else url
301 base_url = resource_prefix.rstrip("/") if resource_prefix else ""
303 # Canonical HTMX attrs: prefer stored from FilterBar, else fallback
304 stored = self.get_htmx_attrs()
305 if stored:
306 htmx_attrs = stored
307 elif state:
308 params = state.to_query_params()
309 params.pop(self.name, None)
310 params.pop(f"{self.name}[]", None)
311 params.pop("page", None)
312 params.pop("cursor", None)
313 htmx_attrs = {
314 "hx-get": f"{base_url}/",
315 "hx-target": Zones.DATA.selector,
316 "hx-swap": Zones.DATA.swap_mode.value,
317 "hx-select": Zones.DATA.selector,
318 "hx-push-url": "true",
319 "hx-vals": dumps_str(params),
320 }
321 else:
322 htmx_attrs = {
323 "hx-get": f"{base_url}/",
324 "hx-trigger": "change",
325 "hx-target": Zones.DATA.selector,
326 "hx-swap": Zones.DATA.swap_mode.value,
327 "hx-select": Zones.DATA.selector,
328 "hx-push-url": "true",
329 "hx-include": f"{Zones.DATA.selector} [data-state='true'], #{Zones.SEARCH.id}",
330 "hx-params": "*",
331 }
333 # Convert to component-friendly names (hx_*)
334 comp_attrs = {k.replace("-", "_"): v for k, v in htmx_attrs.items()}
336 # Create checkbox for each option
337 checkboxes = []
338 for value, label in options.items():
339 checkbox = Checkbox(
340 name=f"filter_{self.name}[]", # Array notation for multiple values
341 value=value,
342 label=label,
343 checked=value in selected,
344 **comp_attrs,
345 )
346 checkboxes.append(checkbox.render())
348 # Wrap in container with label
349 return el(
350 "div",
351 el(
352 "label",
353 self.label,
354 class_="block text-sm font-medium text-foreground mb-1.5",
355 ),
356 el("div", *checkboxes, class_="space-y-2"),
357 class_="filter-item mb-2",
358 )
360 def apply(self, query: Any, value: Any) -> Any:
361 """
362 Apply multi-select filter to query.
363 """
364 if not value:
365 return query
367 # Ensure value is a list
368 if not isinstance(value, list):
369 value = [value]
371 # Filter with IN clause
372 return query.filter(getattr(query.model, self.name).in_(value))
374 def from_url_param(self, param: Any) -> Any:
375 """Parse multiple values from URL param."""
376 if param is None or param == "":
377 return None
379 if isinstance(param, list):
380 return param
382 # HTMX often sends multiple values as a comma-separated list or multiple params
383 return list(
384 filter(
385 lambda p: p.strip(),
386 (p.strip() for p in str(param).split(",")),
387 ),
388 )
390 def to_spec(self, value: Any) -> Any | None:
391 """Convert to InSpec."""
393 parsed = self.from_url_param(value)
394 if not parsed:
395 return None
396 return InSpec(self.name, parsed)