Coverage for src/lexigram/admin/ui/filters/types/toggle.py: 0%
58 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
1"""Toggle filter type."""
3from __future__ import annotations
5from typing import Any
7from lexigram.admin.data.filter_specs import EqualSpec
8from lexigram.admin.ui.filters.base import Filter
9from lexigram.serialization import dumps_str
10from lexigram.ui import Zones
13class ToggleFilter(Filter):
14 """
15 Boolean toggle filter.
17 Example:
18 ToggleFilter(
19 label="Neutered Status",
20 true_label="Neutered/Spayed",
21 false_label="Not Neutered"
22 )
23 """
25 def __init__(
26 self,
27 name: str = "",
28 label: str | None = None,
29 true_label: str = "Yes",
30 false_label: str = "No",
31 ):
32 """
33 Initialize toggle filter.
35 Args:
36 name: Filter field name (auto-set by column if empty)
37 label: Display label
38 true_label: Label for true option
39 false_label: Label for false option
40 """
41 super().__init__(name, label)
42 self._true_label = true_label
43 self._false_label = false_label
45 def render(self, current_value: Any = None, url: str | None = None) -> str:
46 """Render as checkbox or radio buttons using atomic components."""
47 from lexigram.ui import Select
49 # Convert to select with three options: All, True, False
50 choices = [
51 ("", "All"),
52 ("true", self._true_label),
53 ("false", self._false_label),
54 ]
56 # Normalize current value
57 value = ""
58 current = current_value if current_value is not None else self.value
60 if current is not None:
61 if isinstance(current, bool):
62 value = "true" if current else "false"
63 elif str(current).lower() in ["true", "1", "yes"]:
64 value = "true"
65 elif str(current).lower() in ["false", "0", "no"]:
66 value = "false"
68 # Get state and resource prefix
69 state = getattr(self, "_state", None)
70 resource_prefix = getattr(state, "_resource_prefix", None) if state else url
71 base_url = resource_prefix.rstrip("/") if resource_prefix else ""
73 # Canonical HTMX attrs: prefer stored from FilterBar, else fallback
74 stored = self.get_htmx_attrs()
75 if stored:
76 htmx_attrs = stored
77 elif state:
78 params = state.to_query_params()
79 params.pop(self.name, None)
80 params.pop("page", None)
81 params.pop("cursor", None)
82 htmx_attrs = {
83 "hx-get": f"{base_url}/",
84 "hx-target": Zones.DATA.selector,
85 "hx-swap": Zones.DATA.swap_mode.value,
86 "hx-select": Zones.DATA.selector,
87 "hx-push-url": "true",
88 "hx-vals": dumps_str(params),
89 }
90 else:
91 htmx_attrs = {
92 "hx-get": f"{base_url}/",
93 "hx-trigger": "change",
94 "hx-target": Zones.DATA.selector,
95 "hx-swap": Zones.DATA.swap_mode.value,
96 "hx-select": Zones.DATA.selector,
97 "hx-push-url": "true",
98 "hx-include": f"{Zones.DATA.selector} [data-state='true'], #{Zones.SEARCH.id}",
99 "hx-params": "*",
100 }
102 # Convert to component-friendly names (hx_*)
103 comp_attrs = {k.replace("-", "_"): v for k, v in htmx_attrs.items()}
105 select = Select(
106 name=f"filter_{self.name}",
107 choices=choices,
108 value=value,
109 label=self.label,
110 **comp_attrs, # type: ignore[arg-type]
111 )
113 return select.render()
115 def from_url_param(self, param: Any) -> Any:
116 """Convert string param to boolean."""
117 if param is None or param == "":
118 return None
120 s = str(param).lower()
121 if s in ["true", "1", "yes"]:
122 return True
123 if s in ["false", "0", "no"]:
124 return False
125 return None
127 def apply(self, query: Any, value: Any) -> Any:
128 """
129 Apply toggle filter to query.
131 Converts string value to boolean.
132 """
133 parsed = self.from_url_param(value)
134 if parsed is None:
135 return query
137 return query.filter(**{self.name: parsed})
139 def to_spec(self, value: Any) -> Any | None:
140 """Convert to EqualSpec with boolean value."""
142 parsed = self.from_url_param(value)
143 if parsed is None:
144 return None
145 return EqualSpec(self.name, parsed)