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

25 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-11 17:07 +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 f"You are about to permanently delete <strong>{record_label}</strong>. " 

108 "This action <strong>cannot be undone</strong>." 

109 ) 

110 

111 body = el( 

112 "div", 

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

114 # Danger icon + message block 

115 el( 

116 "div", 

117 { 

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

119 }, 

120 raw( 

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

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

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

124 '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"/>' 

125 "</svg>" 

126 "</div>" 

127 ), 

128 el( 

129 "div", 

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

131 el( 

132 "p", 

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

134 "Confirm Deletion", 

135 ), 

136 el( 

137 "p", 

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

139 raw(message or default_message), 

140 ), 

141 ), 

142 ), 

143 *( 

144 [ 

145 el( 

146 "p", 

147 { 

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

149 }, 

150 extra_warning, 

151 ) 

152 ] 

153 if extra_warning 

154 else [] 

155 ), 

156 # Type "DELETE" to confirm 

157 el( 

158 "div", 

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

160 el( 

161 "label", 

162 { 

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

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

165 }, 

166 'Type <span class="font-bold tracking-wider">DELETE</span> to confirm:', 

167 ), 

168 el( 

169 "input", 

170 { 

171 "type": "text", 

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

173 "name": "delete_confirm", 

174 "x-model": "confirmText", 

175 "placeholder": "Type DELETE here", 

176 "class": ( 

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

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

179 "text-foreground " 

180 "placeholder-muted-foreground dark:placeholder-muted-foreground " 

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

182 "transition-colors" 

183 ), 

184 "autocomplete": "off", 

185 }, 

186 ), 

187 ), 

188 ) 

189 

190 # Footer buttons 

191 cancel_btn = el( 

192 "button", 

193 { 

194 "type": "button", 

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

196 "class": ( 

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

198 "text-foreground bg-card " 

199 "border border-border " 

200 "hover:bg-muted dark:hover:bg-muted " 

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

202 "transition-colors" 

203 ), 

204 }, 

205 cancel_label, 

206 ) 

207 confirm_btn = el( 

208 "button", 

209 { 

210 "type": "button", 

211 "hx-delete": delete_url, 

212 "hx-target": target, 

213 "hx-swap": swap, 

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

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

216 "class": ( 

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

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

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

220 "transition-colors shadow-sm " 

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

222 ), 

223 }, 

224 raw( 

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

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

227 '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"/>' 

228 "</svg>" 

229 ), 

230 confirm_label, 

231 ) 

232 

233 return render_slide_over_fragment( 

234 title="Delete Record", 

235 content=body, 

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

237 footer=[cancel_btn, confirm_btn], 

238 size="md", 

239 variant="danger", 

240 ) 

241 

242 

243# --------------------------------------------------------------------------- 

244# Bulk Delete Confirmation Panel 

245# --------------------------------------------------------------------------- 

246 

247 

248def render_bulk_delete_confirm( 

249 *, 

250 record_count: int, 

251 bulk_url: str, 

252 action: str = "delete", 

253 cancel_label: str = "Cancel", 

254 confirm_label: str = "Delete", 

255 message: str | None = None, 

256 extra_warning: str | None = None, 

257 hx_target: str | None = None, 

258 hx_swap: str | None = None, 

259) -> str: 

260 """ 

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

262 

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

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

265 rather than a single ``hx-delete``. 

266 

267 Args: 

268 record_count: Number of records being deleted. 

269 bulk_url: HTMX POST endpoint for the bulk action. 

270 cancel_label: Cancel button label. 

271 confirm_label: Confirm button label. 

272 message: Custom body message (overrides default). 

273 extra_warning: Optional secondary warning paragraph. 

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

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

276 """ 

277 target = hx_target or Zones.DATA.selector 

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

279 

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

281 default_message = ( 

282 f"You are about to permanently delete <strong>{record_count}</strong> " 

283 f"record{suffix}. This action <strong>cannot be undone</strong>." 

284 ) 

285 

286 body = el( 

287 "div", 

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

289 # Danger icon + message block 

290 el( 

291 "div", 

292 { 

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

294 }, 

295 raw( 

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

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

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

299 '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"/>' 

300 "</svg>" 

301 "</div>" 

302 ), 

303 el( 

304 "div", 

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

306 el( 

307 "p", 

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

309 "Confirm Bulk Deletion", 

310 ), 

311 el( 

312 "p", 

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

314 raw(message or default_message), 

315 ), 

316 ), 

317 ), 

318 *( 

319 [ 

320 el( 

321 "p", 

322 { 

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

324 }, 

325 extra_warning, 

326 ) 

327 ] 

328 if extra_warning 

329 else [] 

330 ), 

331 # Type "DELETE" to confirm 

332 el( 

333 "div", 

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

335 el( 

336 "label", 

337 { 

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

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

340 }, 

341 'Type <span class="font-bold tracking-wider">DELETE</span> to confirm:', 

342 ), 

343 el( 

344 "input", 

345 { 

346 "type": "text", 

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

348 "name": "delete_confirm", 

349 "x-model": "confirmText", 

350 "placeholder": "Type DELETE here", 

351 "class": ( 

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

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

354 "text-foreground " 

355 "placeholder-muted-foreground dark:placeholder-muted-foreground " 

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

357 "transition-colors" 

358 ), 

359 "autocomplete": "off", 

360 }, 

361 ), 

362 ), 

363 ) 

364 

365 # Footer buttons 

366 cancel_btn = el( 

367 "button", 

368 { 

369 "type": "button", 

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

371 "class": ( 

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

373 "text-foreground bg-card " 

374 "border border-border " 

375 "hover:bg-muted dark:hover:bg-muted " 

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

377 "transition-colors" 

378 ), 

379 }, 

380 cancel_label, 

381 ) 

382 confirm_btn = el( 

383 "button", 

384 { 

385 "type": "button", 

386 "hx-post": bulk_url, 

387 "hx-target": target, 

388 "hx-swap": swap, 

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

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

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

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

393 "class": ( 

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

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

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

397 "transition-colors shadow-sm " 

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

399 ), 

400 }, 

401 raw( 

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

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

404 '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"/>' 

405 "</svg>" 

406 ), 

407 confirm_label, 

408 ) 

409 

410 return render_slide_over_fragment( 

411 title="Delete Records", 

412 content=body, 

413 subtitle=f"Deleting {record_count} record{'s' if record_count != 1 else ''}", 

414 footer=[cancel_btn, confirm_btn], 

415 size="md", 

416 variant="danger", 

417 ) 

418 

419 

420__all__ = [ 

421 "render_bulk_delete_confirm", 

422 "render_delete_confirm", 

423 "render_slide_over_fragment", 

424]