Coverage for src/lexigram/admin/ui/filters/types/standard.py: 27%
95 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:56 +0800
1"""Standard filter types (range, toggle)."""
3from __future__ import annotations
5from typing import TYPE_CHECKING, Any
7from lexigram.admin.data.query import GreaterThanOrEqualSpec, LessThanOrEqualSpec
8from lexigram.admin.ui.filters.base import Filter
9from lexigram.serialization import dumps_str
10from lexigram.ui import Zones, el
12if TYPE_CHECKING:
13 from collections.abc import Callable
16class RangeFilter(Filter):
17 """
18 Generic range filter for dates and numbers.
19 Base class for specialized range filters.
20 """
22 def __init__(
23 self,
24 name: str = "",
25 label: str | None = None,
26 min_value: Any | None = None,
27 max_value: Any | None = None,
28 step: Any | None = None,
29 default: Any | Callable | None = None,
30 input_type: str = "text",
31 ):
32 super().__init__(name, label)
33 self._min = min_value
34 self._max = max_value
35 self._step = step
36 self._input_type = input_type
37 if callable(default):
38 self._default_callback = default
39 self._default = None
40 else:
41 self._default = default
42 self._default_callback = None
44 def get_consumed_params(self) -> list[str]:
45 """Range filter uses two fields in the request."""
46 return [f"{self.name}_from", f"{self.name}_to"]
48 def get_default(self) -> Any:
49 if self._default_callback:
50 return self._default_callback()
51 return self.default
53 def _render_input(
54 self,
55 suffix: str,
56 value: Any,
57 placeholder: str,
58 url: str | None,
59 ) -> str:
60 """Internal helper to render an input field."""
61 from lexigram.ui import NumberInput, TextInput
63 name = f"filter_{self.name}_{suffix}"
65 # Get state and resource prefix
66 state = getattr(self, "_state", None)
67 resource_prefix = getattr(state, "_resource_prefix", None) if state else url
68 base_url = resource_prefix.rstrip("/") if resource_prefix else ""
70 # Canonical HTMX attrs: prefer stored from FilterBar, else fallback
71 stored = self.get_htmx_attrs()
72 if stored:
73 htmx_attrs = stored
74 elif state:
75 param_key = f"{self.name}_{suffix}"
76 params = state.to_query_params()
77 params.pop(param_key, None)
78 params.pop("page", None)
79 params.pop("cursor", None)
80 htmx_attrs = {
81 "hx-get": f"{base_url}/",
82 "hx-target": Zones.DATA.selector,
83 "hx-swap": Zones.DATA.swap_mode.value,
84 "hx-select": Zones.DATA.selector,
85 "hx-push-url": "true",
86 "hx-vals": dumps_str(params),
87 }
88 else:
89 htmx_attrs = {
90 "hx-get": f"{base_url}/",
91 "hx-trigger": "change",
92 "hx-target": Zones.DATA.selector,
93 "hx-swap": Zones.DATA.swap_mode.value,
94 "hx-select": Zones.DATA.selector,
95 "hx-push-url": "true",
96 "hx-include": f"{Zones.DATA.selector} [data-state='true'], #{Zones.SEARCH.id}",
97 "hx-params": "*",
98 }
100 # Convert to component-friendly names (hx_*)
101 comp_attrs = {k.replace("-", "_"): v for k, v in htmx_attrs.items()}
103 common = {
104 "name": name,
105 "value": value,
106 "label": None,
107 "placeholder": placeholder,
108 **comp_attrs,
109 }
111 if self._input_type == "number":
112 return NumberInput(
113 min=self._min,
114 max=self._max,
115 step=self._step,
116 **common,
117 ).render()
119 return TextInput(type=self._input_type, **common).render()
121 def render(self, current_value: Any = None, url: str | None = None) -> str:
122 from_value = ""
123 to_value = ""
125 if isinstance(current_value, dict):
126 from_value = current_value.get("from", "")
127 to_value = current_value.get("to", "")
128 elif isinstance(self.value, dict):
129 from_value = self.value.get("from", "")
130 to_value = self.value.get("to", "")
132 from_input = self._render_input("from", from_value, "From", url)
133 to_input = self._render_input("to", to_value, "To", url)
135 return el(
136 "div",
137 el(
138 "label",
139 self.label,
140 class_="block text-sm font-medium text-foreground mb-1.5",
141 ),
142 el("div", from_input, to_input, class_="flex flex-col sm:flex-row gap-2"),
143 class_="filter-item mb-2",
144 )
146 def apply(self, query: Any, value: Any) -> Any:
147 if not value or not isinstance(value, dict):
148 return query
150 from_val = value.get("from")
151 to_val = value.get("to")
153 if from_val:
154 query = query.filter(getattr(query.model, self.name) >= from_val)
155 if to_val:
156 query = query.filter(getattr(query.model, self.name) <= to_val)
158 return query
160 def get_value_from_request(self, request_params: dict) -> Any:
161 from_val = request_params.get(f"{self.name}_from")
162 to_val = request_params.get(f"{self.name}_to")
164 # Only return the range if BOTH values are present and non-empty
165 if not from_val or not to_val:
166 return None
168 return {"from": from_val, "to": to_val}
170 def from_url_param(self, param: Any) -> Any:
171 return param
173 def to_spec(self, value: Any) -> Any | None:
174 if not isinstance(value, dict):
175 return None
177 from_val = value.get("from")
178 to_val = value.get("to")
180 specs = []
181 if from_val:
182 specs.append(GreaterThanOrEqualSpec(self.name, from_val))
183 if to_val:
184 specs.append(LessThanOrEqualSpec(self.name, to_val)) # type: ignore[arg-type]
186 if not specs:
187 return None
189 if len(specs) == 1:
190 return specs[0]
192 return specs[0] & specs[1]
195class NumericRangeFilter(RangeFilter):
196 """Filter for numeric ranges."""
198 def __init__(self, *args: Any, **kwargs: Any) -> None:
199 kwargs["input_type"] = "number"
200 super().__init__(*args, **kwargs)