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
« 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.
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 f"You are about to permanently delete <strong>{record_label}</strong>. "
108 "This action <strong>cannot be undone</strong>."
109 )
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 )
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 )
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 )
243# ---------------------------------------------------------------------------
244# Bulk Delete Confirmation Panel
245# ---------------------------------------------------------------------------
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.
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``.
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
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 )
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 )
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 )
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 )
420__all__ = [
421 "render_bulk_delete_confirm",
422 "render_delete_confirm",
423 "render_slide_over_fragment",
424]