Coverage for src/lexigram/admin/ui/organisms/bulk_edit_modal.py: 0%
36 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
1"""
2Bulk Edit Slide-Over Component.
4Provides UI for bulk editing multiple records with field updates.
5Now rendered as a slide-over panel consistent with the unified overlay system.
6"""
8from __future__ import annotations
10from typing import Any
12from htpy import div, form, input_, label, option, p, select, span, textarea
14from lexigram.admin.actions.bulk_manager import BulkEditField
15from lexigram.admin.ui.organisms.admin_slide_over import render_slide_over_fragment
16from lexigram.ui import Button, el, raw
19def bulk_edit_modal(
20 selected_count: int,
21 fields: list[BulkEditField],
22 action_url: str,
23 preview_items: list[str] | None = None,
24) -> str:
25 """
26 Render a bulk-edit slide-over panel.
28 Args:
29 selected_count: Number of selected records
30 fields: List of editable fields
31 action_url: URL to submit the form to
32 preview_items: Optional list of item labels for preview
34 Returns:
35 HTML string for the SlideOver zone (``#slide-over-container``)
36 """
37 preview_block: Any = ""
38 if preview_items:
39 preview_block = el(
40 "div",
41 {
42 "class": "mb-5 rounded-xl bg-primary-50 dark:bg-primary-950/30 "
43 "border border-primary-200 dark:border-primary-800/50 p-4",
44 },
45 el(
46 "p",
47 {
48 "class": "text-sm font-semibold text-primary-800 dark:text-primary-200 mb-2"
49 },
50 "Selected records:",
51 ),
52 el(
53 "div",
54 {"class": "space-y-1"},
55 *[
56 el(
57 "p",
58 {
59 "class": "text-sm text-primary-700 dark:text-primary-300 truncate"
60 },
61 f"• {item}",
62 )
63 for item in preview_items[:5]
64 ],
65 *(
66 [
67 el(
68 "p",
69 {"class": "text-xs text-primary-500 mt-1"},
70 f"…and {len(preview_items) - 5} more",
71 )
72 ]
73 if len(preview_items) > 5
74 else []
75 ),
76 ),
77 )
79 body = el(
80 "div",
81 {"class": "space-y-5"},
82 preview_block,
83 raw(
84 f'<form id="bulk-edit-form" hx-post="{action_url}" '
85 'hx-target="#table-body" hx-swap="outerHTML">'
86 '<div class="space-y-4">'
87 + "".join(_render_field_html(f) for f in fields)
88 + "</div></form>"
89 ),
90 )
92 cancel_btn = el(
93 "button",
94 {
95 "type": "button",
96 "x-on:click": "open = false",
97 "class": (
98 "inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium "
99 "text-foreground bg-card "
100 "border border-border "
101 "hover:bg-muted dark:hover:bg-muted transition-colors"
102 ),
103 },
104 "Cancel",
105 )
106 submit_btn = el(
107 "button",
108 {
109 "type": "submit",
110 "form": "bulk-edit-form",
111 "x-on:click": "open = false",
112 "class": (
113 "inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium "
114 "text-white bg-primary-600 hover:bg-primary-700 "
115 "focus:outline-none focus:ring-2 focus:ring-primary-500 "
116 "transition-colors shadow-sm"
117 ),
118 },
119 "Update Records",
120 )
122 return render_slide_over_fragment(
123 title=f"Bulk Edit {selected_count} Record{'s' if selected_count != 1 else ''}",
124 content=body,
125 subtitle="Changes will be applied to all selected records.",
126 footer=[cancel_btn, submit_btn],
127 size="xl",
128 )
131def _render_field_html(field: BulkEditField) -> str:
132 """Render a single form field as an HTML string (used in slide-over body)."""
133 field_id = f"bulk-edit-{field.name}"
134 input_cls = (
135 "mt-1 block w-full rounded-lg border border-border "
136 "bg-card text-foreground px-3 py-2 text-sm "
137 "focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors"
138 )
139 required_attr = "required" if field.required else ""
140 req_star = (
141 '<span class="text-destructive ml-0.5">*</span>' if field.required else ""
142 )
143 label_html = (
144 f'<label for="{field_id}" class="block text-sm font-medium '
145 f'text-foreground mb-1">{field.label}{req_star}</label>'
146 )
148 if field.field_type == "select" and field.options:
149 options_html = '<option value="">-- No change --</option>' + "".join(
150 f'<option value="{v}">{lbl}</option>' for v, lbl in field.options
151 )
152 input_html = f'<select id="{field_id}" name="{field.name}" {required_attr} class="{input_cls}">{options_html}</select>'
153 elif field.field_type == "textarea":
154 input_html = f'<textarea id="{field_id}" name="{field.name}" rows="3" {required_attr} class="{input_cls}"></textarea>'
155 elif field.field_type == "checkbox":
156 input_html = (
157 f'<input type="checkbox" id="{field_id}" name="{field.name}" value="true" '
158 f'class="mt-1 h-4 w-4 rounded border-border text-primary-600 focus:ring-primary-500">'
159 )
160 else:
161 input_html = (
162 f'<input type="{field.field_type}" id="{field_id}" name="{field.name}" '
163 f'{required_attr} class="{input_cls}">'
164 )
166 help_html = (
167 f'<p class="mt-1 text-xs text-muted-foreground">{field.help_text}</p>'
168 if field.help_text
169 else ""
170 )
171 return f'<div class="space-y-1">{label_html}{input_html}{help_html}</div>'
173 """Render a single form field."""
174 field_id = f"bulk-edit-{field.name}"
176 # Label
177 label_elem = label(
178 for_=field_id,
179 class_="block text-sm font-medium text-foreground",
180 )[
181 field.label,
182 span(class_="text-destructive")[" *"] if field.required else None,
183 ]
185 # Input element based on type
186 if field.field_type == "select" and field.options:
187 input_elem = select(
188 id=field_id,
189 name=field.name,
190 required=field.required,
191 class_="mt-1 block w-full rounded-md border-border dark:bg-muted dark:text-foreground shadow-sm focus:border-ring focus:ring-ring sm:text-sm",
192 )[
193 option(value="")["-- No change --"],
194 [option(value=str(vl[0]))[vl[1]] for vl in field.options],
195 ]
196 elif field.field_type == "textarea":
197 input_elem = textarea(
198 id=field_id,
199 name=field.name,
200 required=field.required,
201 rows="3",
202 class_="mt-1 block w-full rounded-md border-border dark:bg-muted dark:text-foreground shadow-sm focus:border-ring focus:ring-ring sm:text-sm",
203 )
204 elif field.field_type == "checkbox":
205 input_elem = input_(
206 type="checkbox",
207 id=field_id,
208 name=field.name,
209 value="true",
210 class_="mt-1 h-4 w-4 rounded border-border text-primary focus:ring-ring",
211 )
212 else:
213 input_elem = input_(
214 type=field.field_type,
215 id=field_id,
216 name=field.name,
217 required=field.required,
218 class_="mt-1 block w-full rounded-md border-border dark:bg-muted dark:text-foreground shadow-sm focus:border-ring focus:ring-ring sm:text-sm",
219 )
221 # Help text
222 help_elem = (
223 p(class_="mt-1 text-sm text-muted-foreground")[field.help_text]
224 if field.help_text
225 else None
226 )
228 return div(class_="form-field")[label_elem, input_elem, help_elem]
231def bulk_assign_modal(
232 selected_count: int,
233 field_label: str,
234 options: list[tuple[Any, str]],
235 action_url: str,
236 field_name: str = "value",
237 allow_null: bool = False,
238 confirm_message: str | None = None,
239) -> Any:
240 """
241 Render a modal for bulk assign operations (status, owner, etc.).
243 Args:
244 selected_count: Number of selected records
245 field_label: Label for the field being assigned
246 options: List of (value, label) tuples
247 action_url: URL to submit the form to
248 field_name: Name attribute for the select field
249 allow_null: Whether to show "Unassign" option
250 confirm_message: Optional custom confirmation message
252 Returns:
253 htpy component for the modal
254 """
255 return div(
256 class_="fixed inset-0 bg-muted bg-opacity-50 hidden",
257 id="bulk-assign-modal",
258 )[
259 div(class_="flex items-center justify-center min-h-screen px-4")[
260 div(
261 class_="bg-card rounded-lg shadow-xl max-w-lg w-full",
262 )[
263 # Header
264 div(class_="px-6 py-4 border-b border-border")[
265 div(class_="flex items-center justify-between")[
266 p(class_="text-lg font-semibold text-foreground")[
267 f"Bulk Assign {field_label}"
268 ],
269 Button(
270 type="button",
271 color="ghost",
272 onclick="document.getElementById('bulk-assign-modal').classList.add('hidden')",
273 )["✕"], # type: ignore[index]
274 ],
275 ],
276 # Body
277 div(class_="px-6 py-4")[
278 (
279 div(
280 class_="mb-4 p-3 bg-warning/10 rounded-lg",
281 )[
282 p(class_="text-sm text-warning")[
283 confirm_message
284 or f"This will update {selected_count} record(s)."
285 ]
286 ]
287 ),
288 form(
289 id="bulk-assign-form",
290 hx_post=action_url,
291 hx_target="#table-body",
292 hx_swap="outerHTML",
293 )[
294 label(
295 for_="bulk-assign-value",
296 class_="block text-sm font-medium text-foreground mb-2",
297 )[f"Select {field_label}"],
298 select(
299 id="bulk-assign-value",
300 name=field_name,
301 required=not allow_null,
302 class_="block w-full rounded-md border-border dark:bg-muted dark:text-foreground shadow-sm focus:border-ring focus:ring-ring",
303 )[
304 (
305 option(value="", selected=True)["-- Unassign --"]
306 if allow_null
307 else None
308 ),
309 [
310 option(value=str(val))[label_text]
311 for val, label_text in options
312 ],
313 ],
314 ],
315 ],
316 # Footer
317 div(
318 class_="px-6 py-4 border-t border-border flex justify-end space-x-3",
319 )[
320 Button(
321 type="button",
322 color="secondary",
323 onclick="document.getElementById('bulk-assign-modal').classList.add('hidden')",
324 )["Cancel"], # type: ignore[index]
325 Button(
326 type="submit",
327 form="bulk-assign-form",
328 color="primary",
329 onclick="document.getElementById('bulk-assign-modal').classList.add('hidden')",
330 )["Assign"], # type: ignore[index]
331 ],
332 ]
333 ]
334 ]
337def bulk_confirm_dialog(
338 action_name: str,
339 selected_count: int,
340 preview_items: list[str] | None = None,
341 is_danger: bool = False,
342 action_url: str = "",
343) -> Any:
344 """
345 Render a confirmation dialog for bulk actions.
347 Args:
348 action_name: Name of the action (e.g., "delete", "archive")
349 selected_count: Number of selected records
350 preview_items: Optional preview of affected items
351 is_danger: Whether this is a dangerous action (red styling)
352 action_url: URL to submit the action to
354 Returns:
355 htpy component for the confirmation dialog
356 """
358 return div(
359 class_="fixed inset-0 bg-muted bg-opacity-50 hidden",
360 id="bulk-confirm-dialog",
361 )[
362 div(class_="flex items-center justify-center min-h-screen px-4")[
363 div(
364 class_="bg-card rounded-lg shadow-xl max-w-md w-full",
365 )[
366 # Header
367 div(class_="px-6 py-4")[
368 p(class_="text-lg font-semibold text-foreground")[
369 f"Confirm {action_name.title()}"
370 ],
371 p(class_="mt-2 text-sm text-muted-foreground")[
372 f"Are you sure you want to {action_name} {selected_count} record(s)?"
373 ],
374 ],
375 # Preview
376 (
377 div(class_="px-6 py-2")[
378 div(
379 class_="max-h-40 overflow-y-auto border border-border rounded p-3 bg-muted dark:bg-background",
380 )[
381 [
382 p(
383 class_="text-sm text-foreground truncate",
384 )[f"• {item}"]
385 for item in (preview_items or [])[:10]
386 ],
387 (
388 p(
389 class_="text-sm text-muted-foreground mt-2",
390 )[f"...and {len(preview_items) - 10} more"]
391 if preview_items and len(preview_items) > 10
392 else None
393 ),
394 ]
395 ]
396 if preview_items
397 else None
398 ),
399 # Footer
400 div(
401 class_="px-6 py-4 border-t border-border flex justify-end space-x-3",
402 )[
403 Button(
404 type="button",
405 color="secondary",
406 onclick="document.getElementById('bulk-confirm-dialog').classList.add('hidden')",
407 )["Cancel"], # type: ignore[index]
408 Button(
409 type="button",
410 color="danger" if is_danger else "primary",
411 hx_post=action_url,
412 hx_target="#table-body",
413 hx_swap="outerHTML",
414 onclick="document.getElementById('bulk-confirm-dialog').classList.add('hidden')",
415 )[action_name.title()], # type: ignore[index]
416 ],
417 ]
418 ]
419 ]
422def bulk_progress_indicator(
423 action_name: str,
424 progress_url: str,
425) -> Any:
426 """
427 Render a progress indicator for slow bulk actions.
429 Polls the progress_url to update the progress bar.
431 Args:
432 action_name: Name of the action being performed
433 progress_url: URL to poll for progress updates
435 Returns:
436 htpy component for the progress indicator
437 """
438 return div(
439 class_="fixed inset-0 bg-muted bg-opacity-50 flex items-center justify-center",
440 id="bulk-progress",
441 )[
442 div(
443 class_="bg-card rounded-lg shadow-xl max-w-md w-full p-6",
444 )[
445 p(class_="text-lg font-semibold text-foreground mb-4")[
446 f"{action_name.title()} in Progress..."
447 ],
448 # Progress bar
449 div(class_="w-full bg-muted rounded-full h-2.5 mb-2")[
450 div(
451 id="progress-bar",
452 class_="bg-primary h-2.5 rounded-full transition-all duration-300",
453 style="width: 0%",
454 )
455 ],
456 # Status text
457 div(
458 id="progress-status",
459 class_="text-sm text-muted-foreground text-center",
460 hx_get=progress_url,
461 hx_trigger="every 500ms",
462 hx_swap="innerHTML",
463 )["Starting..."],
464 # Errors
465 div(
466 id="progress-errors",
467 class_="mt-4 text-sm text-destructive",
468 ),
469 ]
470 ]