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
« 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.
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
10All three modes render into ``Zones.SLIDE_OVER`` (``#slide-over-container``)
11via HTMX ``innerHTML`` swap, which is already wired in the AdminShell.
12"""
14from __future__ import annotations
16from typing import Any
18from lexigram.ui import SlideOver, Zones, el, raw, render_to_string
20# ---------------------------------------------------------------------------
21# Helper: render a SlideOver fragment into the SLIDE_OVER zone
22# ---------------------------------------------------------------------------
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``.
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).
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 )
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)
71# ---------------------------------------------------------------------------
72# Delete Confirmation Panel
73# ---------------------------------------------------------------------------
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.
90 The confirmation panel includes a danger warning block, the record name,
91 a "Type DELETE to confirm" text input, and Cancel / Delete buttons.
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
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 )
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 )
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 )
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 )
252# ---------------------------------------------------------------------------
253# Bulk Delete Confirmation Panel
254# ---------------------------------------------------------------------------
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.
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.
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
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 )
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 )
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 )
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 )
456__all__ = [
457 "render_bulk_delete_confirm",
458 "render_delete_confirm",
459 "render_slide_over_fragment",
460]