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

1""" 

2Centralized HTMX attribute generation for Lexigram Admin. 

3 

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. 

7 

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 

13 

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. 

17 

18 # BEFORE (problematic) 

19 attrs = {"hx-get": "/users/", "hx-include": "#table [name]"} 

20 

21 # AFTER (robust) 

22 attrs = HTMXAttrs.for_data_refresh(state, "/users/") 

23 # Results in: {"hx-get": "/users/?page=1&search=...", "hx-params": "none"} 

24""" 

25 

26from __future__ import annotations 

27 

28from dataclasses import dataclass, field 

29from typing import TYPE_CHECKING, Literal 

30from urllib.parse import urlencode 

31 

32from lexigram.ui import Zone, Zones 

33 

34if TYPE_CHECKING: 

35 from lexigram.ui.state import TableState 

36 

37 

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] 

45 

46 

47@dataclass 

48class HTMXAttrsBuilder: 

49 """ 

50 Builder for consistent HTMX attributes. 

51 

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. 

55 

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 """ 

64 

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 

71 

72 def build(self) -> dict[str, str]: 

73 """ 

74 Generate HTMX attributes based on action type. 

75 

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) 

81 

82 base_url = self.resource_prefix.rstrip("/") 

83 

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) 

94 

95 raise ValueError(f"Unknown action type: {self.action}") 

96 

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}/" 

101 

102 push = self.push_url if self.push_url is not None else True 

103 

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 } 

111 

112 if self.confirm_message: 

113 attrs["hx-confirm"] = self.confirm_message 

114 

115 return attrs 

116 

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}/" 

121 

122 push = self.push_url if self.push_url is not None else True 

123 

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 } 

132 

133 if self.confirm_message: 

134 attrs["hx-confirm"] = self.confirm_message 

135 

136 return attrs 

137 

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 } 

146 

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 } 

155 

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}/" 

160 

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 } 

167 

168 

169class HTMXAttrs: 

170 """ 

171 Factory for HTMX attributes. 

172 

173 This class provides convenient static methods for generating HTMX 

174 attributes for common use cases. For more control, use HTMXAttrsBuilder 

175 directly. 

176 

177 Examples: 

178 # Data refresh (filter, sort, paginate) 

179 attrs = HTMXAttrs.for_data_refresh(state, "/admin/users") 

180 

181 # Full refresh (layout/view change) 

182 attrs = HTMXAttrs.for_full_refresh(state, "/admin/users") 

183 

184 # Delete with confirmation 

185 attrs = HTMXAttrs.for_delete("/admin/users/123", confirm="Delete this user?") 

186 

187 # Bulk action 

188 attrs = HTMXAttrs.for_bulk_action("/admin/users/bulk/delete", "DELETE") 

189 """ 

190 

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. 

200 

201 Use for: Layout changes, view changes, clearing all filters. 

202 

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 

208 

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() 

219 

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. 

229 

230 Use for: Filtering, sorting, pagination, search. 

231 

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 

237 

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() 

248 

249 @staticmethod 

250 def for_modal( 

251 url: str, 

252 ) -> dict[str, str]: 

253 """ 

254 Generate HTMX attributes for opening a modal. 

255 

256 Args: 

257 url: URL to load into modal 

258 

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 } 

268 

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. 

275 

276 Args: 

277 url: URL to load into slide-over 

278 

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 } 

288 

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. 

297 

298 Args: 

299 url: Delete endpoint URL 

300 target_zone: Zone to update after delete (default: DATA) 

301 confirm_message: Optional confirmation dialog 

302 

303 Returns: 

304 Dict of hx-* attributes 

305 """ 

306 zone = target_zone or Zones.DATA 

307 

308 attrs = { 

309 "hx-delete": url, 

310 "hx-target": zone.selector, 

311 "hx-swap": zone.swap_mode.value, 

312 } 

313 

314 if confirm_message: 

315 attrs["hx-confirm"] = confirm_message 

316 

317 return attrs 

318 

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. 

328 

329 Bulk actions include the checked checkboxes from the table 

330 plus the action name so the server can dispatch correctly. 

331 

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 

337 

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 } 

349 

350 if action_name: 

351 attrs["hx-vals"] = f'{{"action":"{action_name}"}}' 

352 

353 if confirm_message: 

354 attrs["hx-confirm"] = confirm_message 

355 

356 return attrs 

357 

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. 

367 

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 

373 

374 Returns: 

375 Dict of hx-* attributes 

376 """ 

377 zone = target_zone or Zones.DATA 

378 

379 return { 

380 f"hx-{method.lower()}": url, 

381 "hx-target": zone.selector, 

382 "hx-swap": zone.swap_mode.value, 

383 } 

384 

385 # On success, server should return OOB fragments to close overlays 

386 # This is handled server-side, not in attributes 

387 

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). 

396 

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. 

400 

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. 

406 

407 Returns: 

408 Dict of hx-* attributes for a live table input. 

409 """ 

410 base_url = resource_prefix.rstrip("/") + "/" 

411 

412 if input_name: 

413 search_selector = f'[name="{input_name}"]' 

414 else: 

415 search_selector = f"#{Zones.SEARCH.id}" 

416 

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 } 

428 

429 @staticmethod 

430 def merge(*attr_dicts: dict[str, str]) -> dict[str, str]: 

431 """ 

432 Merge multiple HTMX attribute dictionaries. 

433 

434 Later values override earlier ones. 

435 

436 Args: 

437 *attr_dicts: Attribute dictionaries to merge 

438 

439 Returns: 

440 Merged dictionary 

441 """ 

442 result = {} 

443 for d in attr_dicts: 

444 result.update(d) 

445 return result