Coverage for src/lexigram/admin/ui/filters/base.py: 0%
83 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"""
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.filter_specs import EqualSpec
25if TYPE_CHECKING:
26 from collections.abc import Callable
29class Filter:
30 """Base class for all table filters with fluent API.
32 This class implements the Builder pattern, allowing configuration
33 through method chaining. All configuration methods return `self`
34 to enable fluent syntax.
36 Attributes:
37 name: Filter field name (query parameter name)
38 label: Display label for filter UI
40 Example:
41 >>> SelectFilter("status", options={"active": "Active"})
42 >>> DateFilter("created_at").placeholder("Filter by Date")
43 """
45 def __init__(self, name: str, label: str | None = None):
46 """
47 Initialize a filter.
49 Args:
50 name: Filter field name (used as query parameter key)
51 label: Display label (defaults to title-cased name)
52 """
53 # Initialize filter state
54 self.name = name
55 self.label = label or name.replace("_", " ").title()
56 self._placeholder: str | None = None
57 self._default: Any = None
58 self.value: Any = None
59 self.errors: list[str] = []
61 # Additional Filter-specific state
62 self._default_callback: Callable | None = None
63 self._visible = True
64 self._visible_callback: Callable | None = None
66 def default(self, value: Any | Callable) -> Self:
67 """Set default value for the filter.
69 The default value is used when no value is present in the request
70 parameters. Can be a static value or a callable that returns a value.
72 Args:
73 value: The default value to use, or a callable that returns it
75 Returns:
76 Self for method chaining
78 Example:
79 >>> SelectFilter("status").default("active")
80 >>>
81 >>> # Dynamic default
82 >>> DateFilter("created").default(lambda: datetime.now() - timedelta(days=30))
83 """
84 if callable(value):
85 self._default_callback = value
86 self._default = None
87 else:
88 self._default = value
89 self._default_callback = None
90 return self
92 def get_default(self) -> Any:
93 """Get default value, calling callback if dynamic.
95 Returns:
96 Default value or result of callback
97 """
98 if self._default_callback:
99 return self._default_callback()
100 return self._default
102 def placeholder(self, text: str) -> Self:
103 """Set placeholder text for the filter input.
105 Args:
106 text: Placeholder text to display
108 Returns:
109 Self for method chaining
111 Example:
112 >>> TextFilter("search").placeholder("Search users...")
113 >>> SelectFilter("role").placeholder("All Roles")
114 """
115 self._placeholder = text
116 return self
118 def visible(self, visible: bool | Callable = True) -> Self:
119 """
120 Control filter visibility.
122 Can accept a boolean or a callable that returns a boolean,
123 allowing for dynamic visibility based on context (e.g., user permissions).
125 Args:
126 visible: Boolean or callable that returns boolean
128 Returns:
129 Self for method chaining
131 Example:
132 >>> # Static visibility
133 >>> DateFilter("archived_at").visible(False)
134 >>>
135 >>> # Dynamic visibility
136 >>> def show_if_manager(context):
137 ... return context.user.role == "manager"
138 >>> SelectFilter("department").visible(show_if_manager)
139 """
140 if callable(visible):
141 self._visible_callback = visible
142 else:
143 self._visible = visible
144 return self
146 def is_visible(self, context: dict | None = None) -> bool:
147 """Check if filter should be visible.
149 Args:
150 context: Context dictionary (e.g., with current user)
152 Returns:
153 True if filter should be displayed
154 """
155 if self._visible_callback:
156 return self._visible_callback(context or {})
157 return self._visible
159 def get_value_from_request(self, request_params: dict) -> Any:
160 """
161 Extract filter value from request parameters.
163 Args:
164 request_params: Request query parameters dict
166 Returns:
167 Filter value or default if not present
168 """
169 return request_params.get(self.name, self.get_default())
171 def get_consumed_params(self) -> list[str]:
172 """
173 Return a list of request parameter keys that this filter consumes.
174 Used to prevent these parameters from being included as extra filters.
175 """
176 return [self.name]
178 @abstractmethod
179 def render(self, current_value: Any = None, url: str | None = None) -> str:
180 """
181 Render the filter UI as HTML.
183 Args:
184 current_value: Current filter value to display
186 Returns:
187 HTML string of the filter component
188 """
190 @abstractmethod
191 def apply(self, query: Any, value: Any) -> Any:
192 """
193 Apply filter to a query.
195 Args:
196 query: Query object (SQLAlchemy, etc.)
197 value: Filter value to apply
199 Returns:
200 Modified query with filter applied
201 """
203 def to_spec(self, value: Any) -> Any | None:
204 """
205 Convert filter value to a specification object.
207 Args:
208 value: Filter value from request
210 Returns:
211 SpecificationProtocol object (e.g. EqualSpec) or None
212 """
214 parsed = self.from_url_param(value)
215 if parsed is None or parsed == "":
216 return None
217 return EqualSpec(self.name, parsed)
219 def to_url_param(self, value: Any) -> str:
220 """
221 Convert filter value to URL parameter string.
223 Args:
224 value: Filter value
226 Returns:
227 String representation safe for URL parameters
228 """
229 if value is None:
230 return ""
231 return str(value)
233 def from_url_param(self, param: str) -> Any:
234 """
235 Parse filter value from URL parameter.
237 Args:
238 param: URL parameter string
240 Returns:
241 Parsed value suitable for filtering logic
242 """
243 return param if param else None
245 def set_state(self, state: Any) -> None:
246 """Set the table state for URL generation."""
247 self._state = state
249 def get_state(self) -> Any:
250 """Get the table state for URL generation."""
251 return getattr(self, "_state", None)
253 def set_htmx_attrs(self, attrs: dict[str, str]) -> None:
254 """Set canonical HTMX attributes to use instead of building inline."""
255 self._htmx_attrs = attrs
257 def get_htmx_attrs(self) -> dict[str, str] | None:
258 """Get stored canonical HTMX attributes if set."""
259 return getattr(self, "_htmx_attrs", None)
261 def build_state_url(self, new_value: Any = None, base_url: str = "") -> str:
262 """
263 Build a complete URL with all state parameters baked in.
265 This follows the Pagination pattern: construct URLs that contain
266 the full state, eliminating the need for hx-include.
268 Args:
269 new_value: The new value for this filter (replaces current)
270 base_url: Base URL path (defaults to state's resource prefix)
272 Returns:
273 Complete URL with all query parameters
274 """
275 from urllib.parse import urlencode
277 state = getattr(self, "_state", None)
278 if not state:
279 # Fallback: just return base with filter param
280 if new_value:
281 return f"{base_url}?{self.name}={new_value}"
282 return base_url or "?"
284 # Get all current state params
285 params = state.to_query_params()
287 # Update with new filter value
288 if new_value is not None and new_value != "":
289 params[self.name] = str(new_value)
290 else:
291 # Remove filter if value is empty
292 params.pop(self.name, None)
294 # Reset page when filtering
295 params.pop("page", None)
296 params.pop("cursor", None)
298 # Construct URL
299 resource_prefix = getattr(state, "_resource_prefix", None) or base_url
300 query = urlencode(params, doseq=True) if params else ""
302 if query:
303 return f"{resource_prefix}?{query}"
304 return f"{resource_prefix}/" if resource_prefix else "?"