Coverage for src/lexigram/admin/ui/organisms/admin_slide_over.py: 100%

26 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1""" 

2AdminSlideOver — Unified slide-over panel for all overlay interactions. 

3 

4Replaces all modal/popup usage in the admin UI with a consistent right-side 

5sliding panel. Supports three modes: 

6 - ``form`` : Create / Edit form (default) 

7 - ``confirm``: Delete / destructive action confirmation 

8 - ``info`` : Read-only detail view 

9 

10All three modes render into ``Zones.SLIDE_OVER`` (``#slide-over-container``) 

11via HTMX ``innerHTML`` swap, which is already wired in the AdminShell. 

12""" 

13 

14from __future__ import annotations 

15 

16from typing import Any 

17 

18from lexigram.ui import SlideOver, Zones, el, raw, render_to_string 

19 

20# --------------------------------------------------------------------------- 

21# Helper: render a SlideOver fragment into the SLIDE_OVER zone 

22# --------------------------------------------------------------------------- 

23 

24 

25def render_slide_over_fragment( 

26 title: str, 

27 content: Any, 

28 *, 

29 subtitle: str | None = None, 

30 footer: list[Any] | None = None, 

31 size: str = "xl", 

32 variant: str = "default", 

33) -> str: 

34 """ 

35 Render an AdminSlideOver fragment for direct HTMX ``innerHTML`` injection 

36 into ``#slide-over-container``. 

37 

38 Args: 

39 title: Panel heading text. 

40 content: Any renderable component or HTML string for the body. 

41 subtitle: Optional secondary heading text. 

42 footer: Optional list of footer components (buttons, etc.). 

43 size: SlideOver width — ``sm``, ``md``, ``lg``, ``xl``, ``2xl``, ``full``. 

44 variant: ``"default"`` or ``"danger"`` (red accent for destructive actions). 

45 

46 Returns: 

47 HTML string ready to swap into ``#slide-over-container``. 

48 """ 

49 content_html = ( 

50 raw(render_to_string(content)) 

51 if hasattr(content, "render") 

52 else raw(content) 

53 if isinstance(content, str) 

54 else content 

55 ) 

56 

57 panel = SlideOver( 

58 title=title, 

59 subtitle=subtitle, 

60 trigger=None, 

61 render_trigger=False, 

62 is_open=True, 

63 size=size, 

64 variant=variant, 

65 footer=footer or [], 

66 children=[content_html], 

67 ) 

68 return render_to_string(panel) 

69 

70 

71# --------------------------------------------------------------------------- 

72# Delete Confirmation Panel 

73# --------------------------------------------------------------------------- 

74 

75 

76def render_delete_confirm( 

77 *, 

78 record_label: str, 

79 delete_url: str, 

80 cancel_label: str = "Cancel", 

81 confirm_label: str = "Delete", 

82 message: str | None = None, 

83 extra_warning: str | None = None, 

84 hx_target: str | None = None, 

85 hx_swap: str | None = None, 

86) -> str: 

87 """ 

88 Render a delete-confirmation slide-over fragment. 

89 

90 The confirmation panel includes a danger warning block, the record name, 

91 a "Type DELETE to confirm" text input, and Cancel / Delete buttons. 

92 

93 Args: 

94 record_label: Human-readable label for the record being deleted. 

95 delete_url: HTMX DELETE endpoint URL. 

96 cancel_label: Cancel button label. 

97 confirm_label: Confirm button label. 

98 message: Custom body message (overrides default). 

99 extra_warning: Optional secondary warning paragraph. 

100 hx_target: HTMX target zone after deletion (default: DATA zone). 

101 hx_swap: HTMX swap mode (default: DATA zone swap mode). 

102 """ 

103 target = hx_target or Zones.DATA.selector 

104 swap = hx_swap or Zones.DATA.swap_mode.value 

105 

106 default_message = ( 

107 "You are about to permanently delete ", 

108 el("strong", record_label), 

109 ". This action ", 

110 el("strong", "cannot be undone"), 

111 ".", 

112 ) 

113 

114 body = el( 

115 "div", 

116 {"x-data": "{ confirmText: '' }", "class": "space-y-4"}, 

117 # Danger icon + message block 

118 el( 

119 "div", 

120 { 

121 "class": "flex items-start gap-4 rounded-xl bg-destructive/10 border border-destructive/30 p-4" 

122 }, 

123 raw( 

124 '<div class="flex-shrink-0 mt-0.5">' 

125 '<svg class="h-6 w-6 text-destructive" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">' 

126 '<path stroke-linecap="round" stroke-linejoin="round" ' 

127 'd="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>' 

128 "</svg>" 

129 "</div>" 

130 ), 

131 el( 

132 "div", 

133 {"class": "flex-1 min-w-0"}, 

134 el( 

135 "p", 

136 {"class": "text-sm font-semibold text-destructive"}, 

137 "Confirm Deletion", 

138 ), 

139 el( 

140 "p", 

141 {"class": "mt-1 text-sm text-destructive leading-relaxed"}, 

142 *([raw(message)] if message is not None else list(default_message)), 

143 ), 

144 ), 

145 ), 

146 *( 

147 [ 

148 el( 

149 "p", 

150 { 

151 "class": "text-sm text-muted-foreground dark:text-muted-foreground italic" 

152 }, 

153 extra_warning, 

154 ) 

155 ] 

156 if extra_warning 

157 else [] 

158 ), 

159 # Type "DELETE" to confirm 

160 el( 

161 "div", 

162 {"class": "mt-4"}, 

163 el( 

164 "label", 

165 { 

166 "for": "delete-confirm-input", 

167 "class": "block text-sm font-medium text-foreground mb-1", 

168 }, 

169 "Type ", 

170 el( 

171 "span", 

172 {"class": "font-bold tracking-wider"}, 

173 "DELETE", 

174 ), 

175 " to confirm:", 

176 ), 

177 el( 

178 "input", 

179 { 

180 "type": "text", 

181 "id": "delete-confirm-input", 

182 "name": "delete_confirm", 

183 "x-model": "confirmText", 

184 "placeholder": "Type DELETE here", 

185 "class": ( 

186 "block w-full rounded-lg border border-border " 

187 "bg-background px-3 py-2 text-sm " 

188 "text-foreground " 

189 "placeholder-muted-foreground dark:placeholder-muted-foreground " 

190 "focus:outline-none focus:ring-2 focus:ring-destructive focus:border-destructive " 

191 "transition-colors" 

192 ), 

193 "autocomplete": "off", 

194 }, 

195 ), 

196 ), 

197 ) 

198 

199 # Footer buttons 

200 cancel_btn = el( 

201 "button", 

202 { 

203 "type": "button", 

204 "x-on:click": "open = false", 

205 "class": ( 

206 "inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium " 

207 "text-foreground bg-card " 

208 "border border-border " 

209 "hover:bg-muted dark:hover:bg-muted " 

210 "focus:outline-none focus:ring-2 focus:ring-primary-500 " 

211 "transition-colors" 

212 ), 

213 }, 

214 cancel_label, 

215 ) 

216 confirm_btn = el( 

217 "button", 

218 { 

219 "type": "button", 

220 "hx-delete": delete_url, 

221 "hx-target": target, 

222 "hx-swap": swap, 

223 "x-on:click": "open = false", 

224 "x-bind:disabled": "confirmText !== 'DELETE'", 

225 "class": ( 

226 "inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium " 

227 "text-white bg-destructive hover:bg-destructive/90 " 

228 "focus:outline-none focus:ring-2 focus:ring-destructive focus:ring-offset-2 " 

229 "transition-colors shadow-sm " 

230 "disabled:opacity-50 disabled:cursor-not-allowed" 

231 ), 

232 }, 

233 raw( 

234 '<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">' 

235 '<path stroke-linecap="round" stroke-linejoin="round" ' 

236 'd="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/>' 

237 "</svg>" 

238 ), 

239 confirm_label, 

240 ) 

241 

242 return render_slide_over_fragment( 

243 title="Delete Record", 

244 content=body, 

245 subtitle=f'Deleting: "{record_label}"', 

246 footer=[cancel_btn, confirm_btn], 

247 size="md", 

248 variant="danger", 

249 ) 

250 

251 

252# --------------------------------------------------------------------------- 

253# Bulk Delete Confirmation Panel 

254# --------------------------------------------------------------------------- 

255 

256 

257def render_bulk_delete_confirm( 

258 *, 

259 record_count: int, 

260 bulk_url: str, 

261 action: str = "delete", 

262 title: str = "Delete Records", 

263 heading: str = "Confirm Bulk Deletion", 

264 confirm_phrase: str = "DELETE", 

265 subtitle: str | None = None, 

266 cancel_label: str = "Cancel", 

267 confirm_label: str = "Delete", 

268 message: str | None = None, 

269 extra_warning: str | None = None, 

270 hx_target: str | None = None, 

271 hx_swap: str | None = None, 

272 variant: str = "danger", 

273 confirm_button_class: str | None = None, 

274) -> str: 

275 """ 

276 Render a bulk-delete-confirmation slide-over fragment. 

277 

278 Like ``render_delete_confirm`` but for multiple records — the confirm 

279 button issues an ``hx-post`` with ``hx-include`` for the checked IDs 

280 rather than a single ``hx-delete``. Reusable for any bulk action via 

281 the ``action``, ``title``, ``heading``, ``confirm_phrase``, ``variant``, 

282 and ``confirm_button_class`` parameters. 

283 

284 Args: 

285 record_count: Number of records being affected. 

286 bulk_url: HTMX POST endpoint for the bulk action. 

287 action: Value posted in the ``action`` field (default ``"delete"``). 

288 title: Slide-over panel title (default ``"Delete Records"``). 

289 heading: Body heading text (default ``"Confirm Bulk Deletion"``). 

290 confirm_phrase: Phrase the user must type to confirm (default ``"DELETE"``). 

291 subtitle: Secondary heading text (default ``"Deleting N records"``). 

292 cancel_label: Cancel button label. 

293 confirm_label: Confirm button label. 

294 message: Custom body message (overrides default). 

295 extra_warning: Optional secondary warning paragraph. 

296 hx_target: HTMX target zone after the action (default: DATA zone). 

297 hx_swap: HTMX swap mode (default: DATA zone swap mode). 

298 variant: Slide-over variant (``"default"`` or ``"danger"``). 

299 confirm_button_class: Tailwind classes for the confirm button 

300 (default: destructive styling). 

301 """ 

302 target = hx_target or Zones.DATA.selector 

303 swap = hx_swap or Zones.DATA.swap_mode.value 

304 

305 suffix = "s" if record_count != 1 else "" 

306 default_message = ( 

307 "You are about to permanently delete ", 

308 el("strong", str(record_count)), 

309 f" record{suffix}. This action ", 

310 el("strong", "cannot be undone"), 

311 ".", 

312 ) 

313 

314 body = el( 

315 "div", 

316 {"x-data": "{ confirmText: '' }", "class": "space-y-4"}, 

317 # Danger icon + message block 

318 el( 

319 "div", 

320 { 

321 "class": "flex items-start gap-4 rounded-xl bg-destructive/10 border border-destructive/30 p-4" 

322 }, 

323 raw( 

324 '<div class="flex-shrink-0 mt-0.5">' 

325 '<svg class="h-6 w-6 text-destructive" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">' 

326 '<path stroke-linecap="round" stroke-linejoin="round" ' 

327 'd="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>' 

328 "</svg>" 

329 "</div>" 

330 ), 

331 el( 

332 "div", 

333 {"class": "flex-1 min-w-0"}, 

334 el( 

335 "p", 

336 {"class": "text-sm font-semibold text-destructive"}, 

337 heading, 

338 ), 

339 el( 

340 "p", 

341 {"class": "mt-1 text-sm text-destructive leading-relaxed"}, 

342 *([raw(message)] if message is not None else list(default_message)), 

343 ), 

344 ), 

345 ), 

346 *( 

347 [ 

348 el( 

349 "p", 

350 { 

351 "class": "text-sm text-muted-foreground dark:text-muted-foreground italic" 

352 }, 

353 extra_warning, 

354 ) 

355 ] 

356 if extra_warning 

357 else [] 

358 ), 

359 # Type "DELETE" to confirm 

360 el( 

361 "div", 

362 {"class": "mt-4"}, 

363 el( 

364 "label", 

365 { 

366 "for": "bulk-delete-confirm-input", 

367 "class": "block text-sm font-medium text-foreground mb-1", 

368 }, 

369 "Type ", 

370 el( 

371 "span", 

372 {"class": "font-bold tracking-wider"}, 

373 confirm_phrase, 

374 ), 

375 " to confirm:", 

376 ), 

377 el( 

378 "input", 

379 { 

380 "type": "text", 

381 "id": "bulk-delete-confirm-input", 

382 "name": "delete_confirm", 

383 "x-model": "confirmText", 

384 "placeholder": f"Type {confirm_phrase} here", 

385 "class": ( 

386 "block w-full rounded-lg border border-border " 

387 "bg-background px-3 py-2 text-sm " 

388 "text-foreground " 

389 "placeholder-muted-foreground dark:placeholder-muted-foreground " 

390 "focus:outline-none focus:ring-2 focus:ring-destructive focus:border-destructive " 

391 "transition-colors" 

392 ), 

393 "autocomplete": "off", 

394 }, 

395 ), 

396 ), 

397 ) 

398 

399 # Footer buttons 

400 cancel_btn = el( 

401 "button", 

402 { 

403 "type": "button", 

404 "x-on:click": "open = false", 

405 "class": ( 

406 "inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium " 

407 "text-foreground bg-card " 

408 "border border-border " 

409 "hover:bg-muted dark:hover:bg-muted " 

410 "focus:outline-none focus:ring-2 focus:ring-primary-500 " 

411 "transition-colors" 

412 ), 

413 }, 

414 cancel_label, 

415 ) 

416 default_button_class = ( 

417 "inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium " 

418 "text-white bg-destructive hover:bg-destructive/90 " 

419 "focus:outline-none focus:ring-2 focus:ring-destructive focus:ring-offset-2 " 

420 "transition-colors shadow-sm " 

421 "disabled:opacity-50 disabled:cursor-not-allowed" 

422 ) 

423 confirm_btn = el( 

424 "button", 

425 { 

426 "type": "button", 

427 "hx-post": bulk_url, 

428 "hx-target": target, 

429 "hx-swap": swap, 

430 "hx-vals": f'{{"action":"{action}"}}', 

431 "hx-include": "#lexigram-table [name='ids']:checked", 

432 "x-on:click": "open = false", 

433 "x-bind:disabled": f"confirmText !== '{confirm_phrase}'", 

434 "class": confirm_button_class or default_button_class, 

435 }, 

436 raw( 

437 '<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">' 

438 '<path stroke-linecap="round" stroke-linejoin="round" ' 

439 'd="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/>' 

440 "</svg>" 

441 ), 

442 confirm_label, 

443 ) 

444 

445 return render_slide_over_fragment( 

446 title=title, 

447 content=body, 

448 subtitle=subtitle 

449 or f"Deleting {record_count} record{'s' if record_count != 1 else ''}", 

450 footer=[cancel_btn, confirm_btn], 

451 size="md", 

452 variant=variant, 

453 ) 

454 

455 

456__all__ = [ 

457 "render_bulk_delete_confirm", 

458 "render_delete_confirm", 

459 "render_slide_over_fragment", 

460]