Coverage for src / lexigram / admin / state / url.py: 0%
111 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
1"""URL state management for lexigram-admin.
3Provides utilities for managing URL state in HTMX applications,
4including filters, sorting, pagination, and other query parameters.
5"""
7from __future__ import annotations
9from dataclasses import dataclass, field
10from typing import TYPE_CHECKING, Any
11from urllib.parse import urlencode
13if TYPE_CHECKING:
14 from starlette.requests import Request
17@dataclass
18class URLState:
19 """Represents the current URL state for admin views.
21 Captures all query parameters that affect the view:
22 - Pagination: page, per_page
23 - Sorting: sort, order
24 - Filtering: Various filter parameters
25 - Search: q (search query)
26 - View options: columns, view_mode
28 URLState is immutable - methods return new instances.
29 """
31 # Pagination
32 page: int = 1
33 per_page: int = 20
34 cursor: str | None = None
36 # Sorting
37 sort: str | None = None
38 order: str = "asc" # "asc" or "desc"
40 # Search
41 search: str | None = None
43 # Filters (field -> value or [values])
44 filters: dict[str, Any] = field(default_factory=dict)
46 # View options
47 columns: list[str] = field(default_factory=list)
48 view_mode: str = "table" # "table", "grid", "list"
50 # Expanded/selected items (for optimistic UI)
51 expanded: list[str] = field(default_factory=list)
52 selected: list[str] = field(default_factory=list)
54 @classmethod
55 def from_request(cls, request: Request) -> URLState:
56 """Create URLState from request query parameters."""
57 params = dict(request.query_params)
59 # Parse pagination
60 page = int(params.get("page", 1))
61 per_page = int(params.get("per_page", 20))
62 cursor = params.get("cursor")
64 # Parse sorting
65 sort = params.get("sort")
66 order = params.get("order", "asc")
67 if order not in ("asc", "desc"):
68 order = "asc"
70 # Parse search
71 search = params.get("q") or params.get("search")
73 # Parse filters (filter_field = value pattern)
74 filters: dict[str, Any] = {}
75 for key, value in params.items():
76 if key.startswith("filter_"):
77 field_name = key[7:]
78 # Handle multiple values
79 if "," in value:
80 filters[field_name] = value.split(",")
81 else:
82 filters[field_name] = value
84 # Parse columns
85 columns: list[str] = []
86 if "columns" in params:
87 columns = params["columns"].split(",")
89 # Parse view mode
90 view_mode = params.get("view", "table")
92 # Parse expanded/selected
93 expanded = (
94 params.get("expanded", "").split(",") if params.get("expanded") else []
95 )
96 selected = (
97 params.get("selected", "").split(",") if params.get("selected") else []
98 )
100 return cls(
101 page=page,
102 per_page=per_page,
103 cursor=cursor,
104 sort=sort,
105 order=order,
106 search=search,
107 filters=filters,
108 columns=columns,
109 view_mode=view_mode,
110 expanded=expanded,
111 selected=selected,
112 )
114 def to_query_string(self) -> str:
115 """Convert to URL query string."""
116 params: dict[str, str] = {}
118 # Pagination (only if not defaults)
119 if self.cursor:
120 params["cursor"] = self.cursor
121 elif self.page != 1:
122 params["page"] = str(self.page)
123 if self.per_page != 20:
124 params["per_page"] = str(self.per_page)
126 # Sorting
127 if self.sort:
128 params["sort"] = self.sort
129 if self.order != "asc":
130 params["order"] = self.order
132 # Search
133 if self.search:
134 params["q"] = self.search
136 # Filters
137 for field_name, value in self.filters.items():
138 if isinstance(value, list):
139 params[f"filter_{field_name}"] = ",".join(str(v) for v in value)
140 else:
141 params[f"filter_{field_name}"] = str(value)
143 # Columns
144 if self.columns:
145 params["columns"] = ",".join(self.columns)
147 # View mode
148 if self.view_mode != "table":
149 params["view"] = self.view_mode
151 return urlencode(params)
153 def to_dict(self) -> dict[str, Any]:
154 """Convert to dictionary."""
155 return {
156 "page": self.page,
157 "per_page": self.per_page,
158 "sort": self.sort,
159 "order": self.order,
160 "search": self.search,
161 "filters": self.filters,
162 "columns": self.columns,
163 "view_mode": self.view_mode,
164 "expanded": self.expanded,
165 "selected": self.selected,
166 }
168 # Immutable update methods
170 def with_page(self, page: int) -> URLState:
171 """Return new state with different page."""
172 return URLState(
173 page=page,
174 per_page=self.per_page,
175 sort=self.sort,
176 order=self.order,
177 search=self.search,
178 filters=dict(self.filters),
179 columns=list(self.columns),
180 view_mode=self.view_mode,
181 expanded=list(self.expanded),
182 selected=list(self.selected),
183 )
185 def with_sort(self, sort: str, order: str = "asc") -> URLState:
186 """Return new state with different sort."""
187 return URLState(
188 page=1, # Reset to first page on sort change
189 per_page=self.per_page,
190 sort=sort,
191 order=order,
192 search=self.search,
193 filters=dict(self.filters),
194 columns=list(self.columns),
195 view_mode=self.view_mode,
196 expanded=list(self.expanded),
197 selected=list(self.selected),
198 )
200 def with_filter(self, field: str, value: Any) -> URLState:
201 """Return new state with filter added/updated."""
202 new_filters = dict(self.filters)
203 if value is None:
204 new_filters.pop(field, None)
205 else:
206 new_filters[field] = value
208 return URLState(
209 page=1, # Reset to first page on filter change
210 per_page=self.per_page,
211 sort=self.sort,
212 order=self.order,
213 search=self.search,
214 filters=new_filters,
215 columns=list(self.columns),
216 view_mode=self.view_mode,
217 expanded=list(self.expanded),
218 selected=list(self.selected),
219 )
221 def with_search(self, search: str | None) -> URLState:
222 """Return new state with search term."""
223 return URLState(
224 page=1, # Reset to first page on search
225 per_page=self.per_page,
226 sort=self.sort,
227 order=self.order,
228 search=search,
229 filters=dict(self.filters),
230 columns=list(self.columns),
231 view_mode=self.view_mode,
232 expanded=list(self.expanded),
233 selected=list(self.selected),
234 )
236 def clear_filters(self) -> URLState:
237 """Return new state with all filters cleared."""
238 return URLState(
239 page=1,
240 per_page=self.per_page,
241 sort=self.sort,
242 order=self.order,
243 search=self.search,
244 filters={},
245 columns=list(self.columns),
246 view_mode=self.view_mode,
247 expanded=list(self.expanded),
248 selected=list(self.selected),
249 )
251 def toggle_expanded(self, item_id: str) -> URLState:
252 """Toggle an item's expanded state."""
253 new_expanded = list(self.expanded)
254 if item_id in new_expanded:
255 new_expanded.remove(item_id)
256 else:
257 new_expanded.append(item_id)
259 return URLState(
260 page=self.page,
261 per_page=self.per_page,
262 sort=self.sort,
263 order=self.order,
264 search=self.search,
265 filters=dict(self.filters),
266 columns=list(self.columns),
267 view_mode=self.view_mode,
268 expanded=new_expanded,
269 selected=list(self.selected),
270 )
272 def toggle_selected(self, item_id: str) -> URLState:
273 """Toggle an item's selected state."""
274 new_selected = list(self.selected)
275 if item_id in new_selected:
276 new_selected.remove(item_id)
277 else:
278 new_selected.append(item_id)
280 return URLState(
281 page=self.page,
282 per_page=self.per_page,
283 sort=self.sort,
284 order=self.order,
285 search=self.search,
286 filters=dict(self.filters),
287 columns=list(self.columns),
288 view_mode=self.view_mode,
289 expanded=list(self.expanded),
290 selected=new_selected,
291 )
293 def select_all(self, ids: list[str]) -> URLState:
294 """Select all given IDs."""
295 return URLState(
296 page=self.page,
297 per_page=self.per_page,
298 sort=self.sort,
299 order=self.order,
300 search=self.search,
301 filters=dict(self.filters),
302 columns=list(self.columns),
303 view_mode=self.view_mode,
304 expanded=list(self.expanded),
305 selected=list(ids),
306 )
308 def clear_selection(self) -> URLState:
309 """Clear all selections."""
310 return URLState(
311 page=self.page,
312 per_page=self.per_page,
313 sort=self.sort,
314 order=self.order,
315 search=self.search,
316 filters=dict(self.filters),
317 columns=list(self.columns),
318 view_mode=self.view_mode,
319 expanded=list(self.expanded),
320 selected=[],
321 )
324def url_for_state(
325 base_url: str,
326 state: URLState,
327 **overrides: Any,
328) -> str:
329 """Generate URL with state as query parameters.
331 Args:
332 base_url: Base URL without query string
333 state: Current URL state
334 **overrides: Override specific state values
336 Returns:
337 Full URL with query string
338 """
339 # Apply overrides
340 if overrides:
341 state_dict = state.to_dict()
342 state_dict.update(overrides)
343 state = URLState(**state_dict)
345 query_string = state.to_query_string()
346 if query_string:
347 return f"{base_url}?{query_string}"
348 return base_url
351def htmx_url_attributes(
352 url: str,
353 target: str = "#content",
354 swap: str = "innerHTML",
355 push_url: bool = True,
356) -> str:
357 """Generate HTMX attributes for URL navigation.
359 Args:
360 url: Target URL
361 target: HTMX target selector
362 swap: HTMX swap strategy
363 push_url: Whether to push URL to history
365 Returns:
366 String of HTMX attributes
367 """
368 attrs = [
369 f'hx-get="{url}"',
370 f'hx-target="{target}"',
371 f'hx-swap="{swap}"',
372 ]
374 if push_url:
375 attrs.append(f'hx-push-url="{url}"')
377 return " ".join(attrs)