Coverage for src / lexigram / admin / ui / filters / base.py: 0%
80 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"""
2Filter base class for DataTable filtering.
4Provides a fluent API (Builder pattern) for defining filters with URL state persistence,
5query application, and custom rendering. All configuration methods return `self`
6to enable method chaining.
8Example:
9 >>> from lexigram.admin.ui.filters import SelectFilter, DateFilter
10 >>>
11 >>> # Fluent API with method chaining
12 >>> role_filter = (SelectFilter("role")
13 ... .placeholder("Select Role")
14 ... .default("user")
15 ... .visible(lambda ctx: ctx.user.is_admin))
16"""
18from __future__ import annotations
20from abc import abstractmethod
21from typing import TYPE_CHECKING, Any, Self
23from lexigram.admin.data.query import EqualSpec
24from lexigram.admin.forms.fields import AbstractField
26if TYPE_CHECKING:
27 from collections.abc import Callable
30class Filter(AbstractField):
31 """Base class for all table filters with fluent API.
33 This class implements the Builder pattern, allowing configuration
34 through method chaining. All configuration methods return `self`
35 to enable fluent syntax.
37 Attributes:
38 name: Filter field name (query parameter name)
39 label: Display label for filter UI
41 Example:
42 >>> SelectFilter("status", options={"active": "Active"})
43 >>> DateFilter("created_at").placeholder("Filter by Date")
44 """
46 def __init__(self, name: str, label: str | None = None):
47 """
48 Initialize a filter.
50 Args:
51 name: Filter field name (used as query parameter key)
52 label: Display label (defaults to title-cased name)
53 """
54 # Initialize Field with label
55 generated_label = label or name.replace("_", " ").title()
56 super().__init__(label=generated_label, name=name)
58 # Additional Filter-specific state
59 self._default_callback: Callable | None = None
60 self._visible = True
61 self._visible_callback: Callable | None = None
63 def default(self, value: Any | Callable) -> Self:
64 """Set default value for the filter.
66 The default value is used when no value is present in the request
67 parameters. Can be a static value or a callable that returns a value.
69 Args:
70 value: The default value to use, or a callable that returns it
72 Returns:
73 Self for method chaining
75 Example:
76 >>> SelectFilter("status").default("active")
77 >>>
78 >>> # Dynamic default
79 >>> DateFilter("created").default(lambda: datetime.now() - timedelta(days=30))
80 """
81 if callable(value):
82 self._default_callback = value
83 self.default = None # type: ignore[method-assign, assignment]
84 else:
85 self.default = value # type: ignore[method-assign]
86 self._default_callback = None
87 return self
89 def get_default(self) -> Any:
90 """Get default value, calling callback if dynamic.
92 Returns:
93 Default value or result of callback
94 """
95 if self._default_callback:
96 return self._default_callback()
97 return self.default
99 def placeholder(self, text: str) -> Self: # type: ignore[override]
100 """Set placeholder text for the filter input.
102 Args:
103 text: Placeholder text to display
105 Returns:
106 Self for method chaining
108 Example:
109 >>> TextFilter("search").placeholder("Search users...")
110 >>> SelectFilter("role").placeholder("All Roles")
111 """
112 self.placeholder = text # type: ignore[method-assign, assignment]
113 return self
115 def visible(self, visible: bool | Callable = True) -> Self:
116 """
117 Control filter visibility.
119 Can accept a boolean or a callable that returns a boolean,
120 allowing for dynamic visibility based on context (e.g., user permissions).
122 Args:
123 visible: Boolean or callable that returns boolean
125 Returns:
126 Self for method chaining
128 Example:
129 >>> # Static visibility
130 >>> DateFilter("archived_at").visible(False)
131 >>>
132 >>> # Dynamic visibility
133 >>> def show_if_manager(context):
134 ... return context.user.role == "manager"
135 >>> SelectFilter("department").visible(show_if_manager)
136 """
137 if callable(visible):
138 self._visible_callback = visible
139 else:
140 self._visible = visible
141 return self
143 def is_visible(self, context: dict | None = None) -> bool:
144 """Check if filter should be visible.
146 Args:
147 context: Context dictionary (e.g., with current user)
149 Returns:
150 True if filter should be displayed
151 """
152 if self._visible_callback:
153 return self._visible_callback(context or {})
154 return self._visible
156 def get_value_from_request(self, request_params: dict) -> Any:
157 """
158 Extract filter value from request parameters.
160 Args:
161 request_params: Request query parameters dict
163 Returns:
164 Filter value or default if not present
165 """
166 return request_params.get(self.name, self.get_default())
168 def get_consumed_params(self) -> list[str]:
169 """
170 Return a list of request parameter keys that this filter consumes.
171 Used to prevent these parameters from being included as extra filters.
172 """
173 return [self.name]
175 @abstractmethod
176 def render(self, current_value: Any = None, url: str | None = None) -> str: # type: ignore[override]
177 """
178 Render the filter UI as HTML.
180 Args:
181 current_value: Current filter value to display
183 Returns:
184 HTML string of the filter component
185 """
187 @abstractmethod
188 def apply(self, query: Any, value: Any) -> Any:
189 """
190 Apply filter to a query.
192 Args:
193 query: Query object (SQLAlchemy, Django ORM, etc.)
194 value: Filter value to apply
196 Returns:
197 Modified query with filter applied
198 """
200 def to_spec(self, value: Any) -> Any | None:
201 """
202 Convert filter value to a specification object.
204 Args:
205 value: Filter value from request
207 Returns:
208 SpecificationProtocol object (e.g. EqualSpec) or None
209 """
211 parsed = self.from_url_param(value)
212 if parsed is None or parsed == "":
213 return None
214 return EqualSpec(self.name, parsed)
216 def to_url_param(self, value: Any) -> str:
217 """
218 Convert filter value to URL parameter string.
220 Args:
221 value: Filter value
223 Returns:
224 String representation safe for URL parameters
225 """
226 if value is None:
227 return ""
228 return str(value)
230 def from_url_param(self, param: str) -> Any:
231 """
232 Parse filter value from URL parameter.
234 Args:
235 param: URL parameter string
237 Returns:
238 Parsed value suitable for filtering logic
239 """
240 return param if param else None
242 def set_state(self, state: Any) -> None:
243 """Set the table state for URL generation."""
244 self._state = state
246 def get_state(self) -> Any:
247 """Get the table state for URL generation."""
248 return getattr(self, "_state", None)
250 def set_htmx_attrs(self, attrs: dict[str, str]) -> None:
251 """Set canonical HTMX attributes to use instead of building inline."""
252 self._htmx_attrs = attrs
254 def get_htmx_attrs(self) -> dict[str, str] | None:
255 """Get stored canonical HTMX attributes if set."""
256 return getattr(self, "_htmx_attrs", None)
258 def build_state_url(self, new_value: Any = None, base_url: str = "") -> str:
259 """
260 Build a complete URL with all state parameters baked in.
262 This follows the Pagination pattern: construct URLs that contain
263 the full state, eliminating the need for hx-include.
265 Args:
266 new_value: The new value for this filter (replaces current)
267 base_url: Base URL path (defaults to state's resource prefix)
269 Returns:
270 Complete URL with all query parameters
271 """
272 from urllib.parse import urlencode
274 state = getattr(self, "_state", None)
275 if not state:
276 # Fallback: just return base with filter param
277 if new_value:
278 return f"{base_url}?{self.name}={new_value}"
279 return base_url or "?"
281 # Get all current state params
282 params = state.to_query_params()
284 # Update with new filter value
285 if new_value is not None and new_value != "":
286 params[self.name] = str(new_value)
287 else:
288 # Remove filter if value is empty
289 params.pop(self.name, None)
291 # Reset page when filtering
292 params.pop("page", None)
293 params.pop("cursor", None)
295 # Construct URL
296 resource_prefix = getattr(state, "_resource_prefix", None) or base_url
297 query = urlencode(params, doseq=True) if params else ""
299 if query:
300 return f"{resource_prefix}?{query}"
301 return f"{resource_prefix}/" if resource_prefix else "?"