Coverage for src / lexigram / admin / ui / htmx_attrs.py: 94%
99 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 17:07 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 17:07 +0800
1"""
2Centralized HTMX attribute generation for Lexigram Admin.
4This module provides a type-safe, consistent way to generate HTMX attributes
5for different action types. All components should use these builders rather
6than constructing hx-* attributes manually.
8Key Patterns:
91. Full Refresh - Replace entire table (layout/view changes)
102. Data Refresh - Update data zone only (filter/sort/page changes)
113. Modal/SlideOver - Open overlay for forms
124. OOB - Out-of-band updates for multiple zones
14The "Baked URL" Pattern:
15Instead of using hx-include to gather inputs at request time, we "bake"
16all state into the URL upfront. This is more reliable and easier to debug.
18 # BEFORE (problematic)
19 attrs = {"hx-get": "/users/", "hx-include": "#table [name]"}
21 # AFTER (robust)
22 attrs = HTMXAttrs.for_data_refresh(state, "/users/")
23 # Results in: {"hx-get": "/users/?page=1&search=...", "hx-params": "none"}
24"""
26from __future__ import annotations
28from dataclasses import dataclass, field
29from typing import TYPE_CHECKING, Literal
30from urllib.parse import urlencode
32from lexigram.ui import Zone, Zones
34if TYPE_CHECKING:
35 from lexigram.ui.state import TableState
38ActionType = Literal[
39 "full_refresh", # Replace TABLE zone
40 "data_refresh", # Replace DATA zone
41 "modal", # Open in MODAL zone
42 "slide_over", # Open in SLIDE_OVER zone
43 "oob", # Out-of-band update
44]
47@dataclass
48class HTMXAttrsBuilder:
49 """
50 Builder for consistent HTMX attributes.
52 This class encapsulates the logic for generating correct HTMX attributes
53 for different action types. Use the static methods on HTMXAttrs for
54 convenience unless you need custom configuration.
56 Attributes:
57 action: The type of action (determines target zone and swap mode)
58 state: The current TableState (used for baked URLs)
59 resource_prefix: The base URL for the resource (e.g., "/admin/users")
60 extra_params: Additional query parameters to include
61 push_url: Whether to update browser history (default: True for refresh actions)
62 confirm_message: Optional confirmation dialog message
63 """
65 action: ActionType
66 state: TableState
67 resource_prefix: str
68 extra_params: dict = field(default_factory=dict)
69 push_url: bool | None = None # None = use action default
70 confirm_message: str | None = None
72 def build(self) -> dict[str, str]:
73 """
74 Generate HTMX attributes based on action type.
76 Returns a dict of hx-* attributes ready to be spread onto an element.
77 """
78 params = self.state.to_query_params()
79 if self.extra_params:
80 params.update(self.extra_params)
82 base_url = self.resource_prefix.rstrip("/")
84 if self.action == "full_refresh":
85 return self._build_full_refresh(base_url, params)
86 if self.action == "data_refresh":
87 return self._build_data_refresh(base_url, params)
88 if self.action == "modal":
89 return self._build_modal(base_url)
90 if self.action == "slide_over":
91 return self._build_slide_over(base_url)
92 if self.action == "oob":
93 return self._build_oob(base_url, params)
95 raise ValueError(f"Unknown action type: {self.action}")
97 def _build_full_refresh(self, base_url: str, params: dict) -> dict[str, str]:
98 """Full refresh: Replace entire table zone with outerHTML."""
99 query = urlencode(params) if params else ""
100 url = f"{base_url}/?{query}" if query else f"{base_url}/"
102 push = self.push_url if self.push_url is not None else True
104 attrs = {
105 "hx-get": url,
106 "hx-target": Zones.TABLE.selector,
107 "hx-swap": Zones.TABLE.swap_mode.value,
108 "hx-params": "none", # URL has everything
109 "hx-push-url": "true" if push else "false",
110 }
112 if self.confirm_message:
113 attrs["hx-confirm"] = self.confirm_message
115 return attrs
117 def _build_data_refresh(self, base_url: str, params: dict) -> dict[str, str]:
118 """Data refresh: Replace data zone, extract from full response."""
119 query = urlencode(params) if params else ""
120 url = f"{base_url}/?{query}" if query else f"{base_url}/"
122 push = self.push_url if self.push_url is not None else True
124 attrs = {
125 "hx-get": url,
126 "hx-target": Zones.DATA.selector,
127 "hx-swap": Zones.DATA.swap_mode.value,
128 "hx-select": Zones.DATA.selector, # Extract only DATA from response
129 "hx-params": "none",
130 "hx-push-url": "true" if push else "false",
131 }
133 if self.confirm_message:
134 attrs["hx-confirm"] = self.confirm_message
136 return attrs
138 def _build_modal(self, base_url: str) -> dict[str, str]:
139 """Modal: Load content into modal container."""
140 return {
141 "hx-get": base_url,
142 "hx-target": Zones.MODAL.selector,
143 "hx-swap": Zones.MODAL.swap_mode.value,
144 "hx-push-url": "false", # Don't update URL for modals
145 }
147 def _build_slide_over(self, base_url: str) -> dict[str, str]:
148 """Slide-over: Load content into side panel."""
149 return {
150 "hx-get": base_url,
151 "hx-target": Zones.SLIDE_OVER.selector,
152 "hx-swap": Zones.SLIDE_OVER.swap_mode.value,
153 "hx-push-url": "false",
154 }
156 def _build_oob(self, base_url: str, params: dict) -> dict[str, str]:
157 """OOB: Request that returns out-of-band fragments."""
158 query = urlencode(params) if params else ""
159 url = f"{base_url}/?{query}" if query else f"{base_url}/"
161 # OOB requests typically don't need a primary target
162 # The server response includes hx-swap-oob fragments
163 return {
164 "hx-get": url,
165 "hx-params": "none",
166 }
169class HTMXAttrs:
170 """
171 Factory for HTMX attributes.
173 This class provides convenient static methods for generating HTMX
174 attributes for common use cases. For more control, use HTMXAttrsBuilder
175 directly.
177 Examples:
178 # Data refresh (filter, sort, paginate)
179 attrs = HTMXAttrs.for_data_refresh(state, "/admin/users")
181 # Full refresh (layout/view change)
182 attrs = HTMXAttrs.for_full_refresh(state, "/admin/users")
184 # Delete with confirmation
185 attrs = HTMXAttrs.for_delete("/admin/users/123", confirm="Delete this user?")
187 # Bulk action
188 attrs = HTMXAttrs.for_bulk_action("/admin/users/bulk/delete", "DELETE")
189 """
191 @staticmethod
192 def for_full_refresh(
193 state: TableState,
194 resource_prefix: str,
195 push_url: bool = True,
196 **extra_params,
197 ) -> dict[str, str]:
198 """
199 Generate HTMX attributes for a full table refresh.
201 Use for: Layout changes, view changes, clearing all filters.
203 Args:
204 state: Current table state
205 resource_prefix: Base URL (e.g., "/admin/users")
206 push_url: Update browser history (default True)
207 **extra_params: Additional query parameters
209 Returns:
210 Dict of hx-* attributes
211 """
212 return HTMXAttrsBuilder(
213 action="full_refresh",
214 state=state,
215 resource_prefix=resource_prefix,
216 extra_params=extra_params or {},
217 push_url=push_url,
218 ).build()
220 @staticmethod
221 def for_data_refresh(
222 state: TableState,
223 resource_prefix: str,
224 push_url: bool = True,
225 **extra_params,
226 ) -> dict[str, str]:
227 """
228 Generate HTMX attributes for a data zone refresh.
230 Use for: Filtering, sorting, pagination, search.
232 Args:
233 state: Current table state
234 resource_prefix: Base URL
235 push_url: Update browser history (default True)
236 **extra_params: Additional query parameters
238 Returns:
239 Dict of hx-* attributes
240 """
241 return HTMXAttrsBuilder(
242 action="data_refresh",
243 state=state,
244 resource_prefix=resource_prefix,
245 extra_params=extra_params or {},
246 push_url=push_url,
247 ).build()
249 @staticmethod
250 def for_modal(
251 url: str,
252 ) -> dict[str, str]:
253 """
254 Generate HTMX attributes for opening a modal.
256 Args:
257 url: URL to load into modal
259 Returns:
260 Dict of hx-* attributes
261 """
262 return {
263 "hx-get": url,
264 "hx-target": Zones.MODAL.selector,
265 "hx-swap": Zones.MODAL.swap_mode.value,
266 "hx-push-url": "false",
267 }
269 @staticmethod
270 def for_slide_over(
271 url: str,
272 ) -> dict[str, str]:
273 """
274 Generate HTMX attributes for opening a slide-over panel.
276 Args:
277 url: URL to load into slide-over
279 Returns:
280 Dict of hx-* attributes
281 """
282 return {
283 "hx-get": url,
284 "hx-target": Zones.SLIDE_OVER.selector,
285 "hx-swap": Zones.SLIDE_OVER.swap_mode.value,
286 "hx-push-url": "false",
287 }
289 @staticmethod
290 def for_delete(
291 url: str,
292 target_zone: Zone | None = None,
293 confirm_message: str | None = None,
294 ) -> dict[str, str]:
295 """
296 Generate HTMX attributes for a delete action.
298 Args:
299 url: Delete endpoint URL
300 target_zone: Zone to update after delete (default: DATA)
301 confirm_message: Optional confirmation dialog
303 Returns:
304 Dict of hx-* attributes
305 """
306 zone = target_zone or Zones.DATA
308 attrs = {
309 "hx-delete": url,
310 "hx-target": zone.selector,
311 "hx-swap": zone.swap_mode.value,
312 }
314 if confirm_message:
315 attrs["hx-confirm"] = confirm_message
317 return attrs
319 @staticmethod
320 def for_bulk_action(
321 url: str,
322 method: str = "POST",
323 confirm_message: str | None = None,
324 action_name: str | None = None,
325 ) -> dict[str, str]:
326 """
327 Generate HTMX attributes for a bulk action.
329 Bulk actions include the checked checkboxes from the table
330 plus the action name so the server can dispatch correctly.
332 Args:
333 url: Bulk action endpoint URL
334 method: HTTP method (POST, DELETE, etc.)
335 confirm_message: Optional confirmation dialog
336 action_name: Action identifier sent as ``action`` form value
338 Returns:
339 Dict of hx-* attributes
340 """
341 attrs = {
342 f"hx-{method.lower()}": url,
343 "hx-target": Zones.DATA.selector,
344 "hx-swap": Zones.DATA.swap_mode.value,
345 "hx-params": "none",
346 # Only include checked checkboxes
347 "hx-include": f"{Zones.TABLE.selector} [name='ids']:checked",
348 }
350 if action_name:
351 attrs["hx-vals"] = f'{{"action":"{action_name}"}}'
353 if confirm_message:
354 attrs["hx-confirm"] = confirm_message
356 return attrs
358 @staticmethod
359 def for_form_submit(
360 url: str,
361 method: str = "POST",
362 target_zone: Zone | None = None,
363 _close_on_success: bool = True,
364 ) -> dict[str, str]:
365 """
366 Generate HTMX attributes for form submission.
368 Args:
369 url: Form action URL
370 method: HTTP method
371 target_zone: Zone to update on success (default: DATA)
372 close_on_success: Whether to close modal/slide-over
374 Returns:
375 Dict of hx-* attributes
376 """
377 zone = target_zone or Zones.DATA
379 return {
380 f"hx-{method.lower()}": url,
381 "hx-target": zone.selector,
382 "hx-swap": zone.swap_mode.value,
383 }
385 # On success, server should return OOB fragments to close overlays
386 # This is handled server-side, not in attributes
388 @staticmethod
389 def for_live_table_input(
390 _state: TableState,
391 resource_prefix: str,
392 input_name: str | None = None,
393 ) -> dict[str, str]:
394 """
395 Generate HTMX attributes for a live table input (search-as-you-type).
397 Exception to the baked-URL pattern: live inputs use hx-include to send
398 the current input value with each keystroke, rather than baking state
399 into the URL. The input value changes too frequently for URL-based state.
401 Args:
402 state: Current table state (not baked into URL — used for hx-include scope)
403 resource_prefix: Base URL (e.g., "/admin/users")
404 input_name: Optional custom name attribute for the live input selector.
405 Defaults to the SEARCH zone ID.
407 Returns:
408 Dict of hx-* attributes for a live table input.
409 """
410 base_url = resource_prefix.rstrip("/") + "/"
412 if input_name:
413 search_selector = f'[name="{input_name}"]'
414 else:
415 search_selector = f"#{Zones.SEARCH.id}"
417 return {
418 "hx-get": base_url,
419 "hx-target": Zones.DATA.selector,
420 "hx-swap": Zones.DATA.swap_mode.value,
421 "hx-select": Zones.DATA.selector,
422 "hx-include": (
423 f"{Zones.DATA.selector} [data-state='true'], {search_selector}"
424 ),
425 "hx-push-url": "true",
426 "hx-params": "*",
427 }
429 @staticmethod
430 def merge(*attr_dicts: dict[str, str]) -> dict[str, str]:
431 """
432 Merge multiple HTMX attribute dictionaries.
434 Later values override earlier ones.
436 Args:
437 *attr_dicts: Attribute dictionaries to merge
439 Returns:
440 Merged dictionary
441 """
442 result = {}
443 for d in attr_dicts:
444 result.update(d)
445 return result