Coverage for src / lexigram / admin / resources / form_renderer.py: 0%
185 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:07 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:07 +0800
1from __future__ import annotations
3"""Form rendering for admin resources."""
5from typing import Any
7from starlette.responses import HTMLResponse
9from lexigram.admin.auth.services.csrf_service import AdminCsrfService
10from lexigram.admin.config import AdminConfig
11from lexigram.admin.engine.renderer import AdminRenderer
12from lexigram.admin.exceptions import AdminValidationError
13from lexigram.admin.rbac.service import PermissionService
14from lexigram.admin.state.context import wants_fragment
15from lexigram.di.decorators import inject
16from lexigram.logging import get_logger
17from lexigram.ui import Form, SlideOver, el, render_to_string
19logger = get_logger(__name__)
22@inject
23class FormRenderer:
24 """Handles rendering of create/edit forms for admin resources."""
26 def __init__(
27 self,
28 config: AdminConfig,
29 resource_name: str,
30 renderer: AdminRenderer,
31 permission_service: PermissionService | None = None,
32 ):
33 self._config = config
34 self.resource_name = resource_name
35 self._renderer = renderer
36 self._permission_service = permission_service
37 self._csrf_service = AdminCsrfService(secret=config.auth.session_secret)
39 async def render_create(
40 self,
41 request,
42 resource,
43 user=None,
44 errors: dict[str, list[str]] | None = None,
45 ) -> HTMLResponse:
46 """Render create form using Modal/SlideOver components based on form_display_mode."""
47 label = self.resource_name.replace("_", " ").title()
49 # Check if HTMX request (for modal/slider loading)
50 is_htmx = wants_fragment(request)
52 # Get form display mode from resource configuration
53 display_mode = "modal" # default
54 if resource and hasattr(resource, "get_form_display_mode"):
55 display_mode = resource.get_form_display_mode()
56 elif resource and hasattr(resource, "form_display_mode"):
57 display_mode = resource.form_display_mode
59 # Build form component
60 await self._ensure_csrf_token(request)
61 form_component = await self._build_form_component(
62 resource,
63 label,
64 mode="create",
65 user=user,
66 errors=errors,
67 )
68 form_component._request = request
70 # If HTMX request, always render as SlideOver
71 if is_htmx:
72 overlay = SlideOver(
73 title=f"Create {label}",
74 subtitle=f"Fill in the details to create a new {label.lower()} record.",
75 trigger=None,
76 render_trigger=False,
77 is_open=True,
78 size="xl",
79 children=[form_component],
80 )
81 return HTMLResponse(render_to_string(overlay))
83 # Full page render
84 content = el(
85 "div",
86 el(
87 "div",
88 el(
89 "a",
90 f"← Back to {label}",
91 href=f"{self._config.prefix}/{self.resource_name}",
92 class_="text-primary-600 hover:text-primary-900",
93 ),
94 el(
95 "h1",
96 f"Create {label}",
97 class_="text-2xl font-bold text-foreground mt-2",
98 ),
99 class_="mb-6",
100 ),
101 el(
102 "div",
103 form_component,
104 class_="bg-card shadow rounded-lg p-6",
105 ),
106 class_="resource-content",
107 )
109 return self._renderer.render_page(
110 content,
111 request=request,
112 title=f"Create {label}",
113 breadcrumbs=[
114 {"label": "Dashboard", "url": self._config.prefix},
115 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"},
116 {
117 "label": "Create",
118 "url": f"{self._config.prefix}/{self.resource_name}/create",
119 },
120 ],
121 )
123 async def render_edit(
124 self,
125 request,
126 resource,
127 item_id: str,
128 user=None,
129 errors: dict[str, list[str]] | None = None,
130 ) -> HTMLResponse:
131 """Render edit form using Modal/SlideOver components based on form_display_mode."""
132 label = self.resource_name.replace("_", " ").title()
134 # Check if HTMX request (for modal/slide-over loading)
135 is_htmx = wants_fragment(request)
137 # Get form display mode from resource configuration
138 display_mode = "slider" # default for edit
139 if resource and hasattr(resource, "get_form_display_mode"):
140 display_mode = resource.get_form_display_mode()
141 elif resource and hasattr(resource, "form_display_mode"):
142 display_mode = resource.form_display_mode
144 # Fetch existing item data for edit
145 initial_data = await self._fetch_item_data(resource, item_id)
147 # Build form component with initial data
148 await self._ensure_csrf_token(request)
149 form_component = await self._build_form_component(
150 resource,
151 label,
152 mode="edit",
153 initial_data=initial_data,
154 record_id=item_id,
155 user=user,
156 errors=errors,
157 )
158 form_component._request = request
160 # If HTMX request, always render as SlideOver
161 if is_htmx:
162 overlay = SlideOver(
163 title=f"Edit {label}",
164 subtitle=f"Editing record #{item_id}",
165 trigger=None,
166 render_trigger=False,
167 is_open=True,
168 size="xl",
169 children=[form_component],
170 )
171 return HTMLResponse(render_to_string(overlay))
173 # Full page render
174 content = el(
175 "div",
176 el(
177 "div",
178 el(
179 "a",
180 f"← Back to {label} #{item_id}",
181 href=f"{self._config.prefix}/{self.resource_name}/{item_id}",
182 class_="text-primary-600 hover:text-primary-900",
183 ),
184 el(
185 "h1",
186 f"Edit {label} #{item_id}",
187 class_="text-2xl font-bold text-foreground mt-2",
188 ),
189 class_="mb-6",
190 ),
191 el(
192 "div",
193 form_component,
194 class_="bg-card shadow rounded-lg p-6",
195 ),
196 class_="resource-content",
197 )
199 return self._renderer.render_page(
200 content,
201 request=request,
202 title=f"Edit {label} #{item_id}",
203 breadcrumbs=[
204 {"label": "Dashboard", "url": self._config.prefix},
205 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"},
206 {
207 "label": f"#{item_id}",
208 "url": f"{self._config.prefix}/{self.resource_name}/{item_id}",
209 },
210 {
211 "label": "Edit",
212 "url": f"{self._config.prefix}/{self.resource_name}/{item_id}/edit",
213 },
214 ],
215 )
217 async def render_wizard(
218 self,
219 request,
220 resource,
221 steps: list[dict],
222 action_url: str,
223 submit_label: str = "Submit",
224 ) -> HTMLResponse:
225 """Render a multi-step wizard form driven by Alpine.js.
227 Each step is defined by a dict with ``"title"`` and ``"fields"`` keys.
228 Only the step whose index matches the Alpine ``currentStep`` variable
229 is visible at any time. Previous / Next buttons advance or retreat
230 through the steps, and the final step shows a Submit button that POSTs
231 the whole form to ``action_url``. A step indicator line (e.g.
232 "Step 2 of 4") is shown above the step body.
234 Args:
235 request: Incoming HTTP request.
236 resource: Admin resource instance (used to build field components).
237 steps: Step definitions. Each item must be a dict with at minimum
238 ``"title": str`` and ``"fields": list[str]`` keys.
239 action_url: Form ``action`` / HTMX ``hx-post`` target URL.
240 submit_label: Label for the submit button on the final step.
242 Returns:
243 ``HTMLResponse`` with the wizard form fragment or full page.
244 """
245 label = self.resource_name.replace("_", " ").title()
246 total_steps = len(steps)
248 # Build Alpine.js data initialiser - currentStep is 0-indexed.
249 alpine_data = "{ currentStep: 0 }"
251 step_els: list[Any] = []
252 for idx, step_def in enumerate(steps):
253 step_title = step_def.get("title", f"Step {idx + 1}")
254 step_fields_names: list[str] = step_def.get("fields", [])
256 # Attempt to render each named field via the field registry.
257 field_html_parts: list[Any] = []
258 if resource and resource.model:
259 try:
260 from lexigram.admin.forms.components import FormSchemaGenerator
262 generator = FormSchemaGenerator()
263 schema = generator.from_pydantic(resource.model)
264 schema_map = {f.name: f for f in schema.fields}
266 for fname in step_fields_names:
267 field_schema = schema_map.get(fname)
268 if field_schema is None:
269 field_html_parts.append(
270 el(
271 "p",
272 f"Unknown field: {fname}",
273 class_="text-xs text-destructive",
274 )
275 )
276 continue
277 field_component = self._create_field_component(
278 field_schema, field_schema.default
279 )
280 if field_component:
281 raw = field_component.render()
282 field_html_parts.append(
283 el("div", raw, class_="wizard-field mb-4")
284 )
285 except AdminValidationError as exc:
286 logger.debug(
287 "render_wizard field generation failed resource=%s: %s",
288 self.resource_name,
289 exc,
290 )
291 field_html_parts.append(
292 el(
293 "p",
294 f"Error building fields: {exc}",
295 class_="text-destructive text-sm",
296 )
297 )
298 else:
299 for fname in step_fields_names:
300 field_html_parts.append(
301 el(
302 "div",
303 el(
304 "input",
305 type="text",
306 name=fname,
307 placeholder=fname.replace("_", " ").title(),
308 class_=(
309 "block w-full rounded-md border border-border "
310 "dark:border-border bg-muted "
311 "text-foreground px-3 py-2 text-sm "
312 "focus:outline-none focus:ring-2 focus:ring-primary-500"
313 ),
314 ),
315 class_="wizard-field mb-4",
316 )
317 )
319 # Navigation buttons
320 nav_buttons: list[Any] = []
321 if idx > 0:
322 nav_buttons.append(
323 el(
324 "button",
325 "← Previous",
326 type="button",
327 class_=(
328 "px-4 py-2 text-sm font-medium text-foreground "
329 "border border-border rounded-lg "
330 "hover:bg-muted dark:hover:bg-muted transition-colors"
331 ),
332 **{"@click": "currentStep--"},
333 )
334 )
336 if idx < total_steps - 1:
337 nav_buttons.append(
338 el(
339 "button",
340 "Next →",
341 type="button",
342 class_=(
343 "px-4 py-2 text-sm font-medium text-white bg-primary-600 "
344 "hover:bg-primary-700 rounded-lg focus:outline-none "
345 "focus:ring-2 focus:ring-primary-500 transition-colors"
346 ),
347 **{"@click": "currentStep++"},
348 )
349 )
350 else:
351 nav_buttons.append(
352 el(
353 "button",
354 submit_label,
355 type="submit",
356 class_=(
357 "px-4 py-2 text-sm font-medium text-white bg-success "
358 "hover:bg-success/90 rounded-lg focus:outline-none "
359 "focus:ring-2 focus:ring-ring transition-colors"
360 ),
361 )
362 )
364 step_indicator = el(
365 "p",
366 f"Step {idx + 1} of {total_steps}",
367 class_="text-xs text-muted-foreground mb-1",
368 )
369 step_heading = el(
370 "h3",
371 step_title,
372 class_="text-base font-semibold text-foreground mb-4",
373 )
374 step_els.append(
375 el(
376 "div",
377 step_indicator,
378 step_heading,
379 *field_html_parts,
380 el(
381 "div",
382 *nav_buttons,
383 class_="flex items-center justify-between mt-6 gap-3",
384 ),
385 class_="wizard-step",
386 **{"x-show": f"currentStep === {idx}"},
387 )
388 )
390 # Progress bar / step dots
391 step_dots: list[Any] = [
392 el(
393 "span",
394 str(i + 1),
395 class_=(
396 f"wizard-dot inline-flex items-center justify-center w-7 h-7 "
397 f"rounded-full text-xs font-semibold transition-colors "
398 f"{'bg-primary-600 text-white' if i == 0 else 'bg-muted text-muted-foreground dark:text-muted-foreground'}"
399 ),
400 **{
401 ":class": (
402 f"currentStep === {i} "
403 f"? 'bg-primary-600 text-white' "
404 f": currentStep > {i} "
405 f"? 'bg-success text-success-foreground' "
406 f": 'bg-muted text-muted-foreground dark:text-muted-foreground'"
407 )
408 },
409 )
410 for i in range(total_steps)
411 ]
412 progress_bar = el(
413 "div",
414 *step_dots,
415 class_="wizard-progress flex items-center gap-2 mb-6",
416 )
418 form_el = el(
419 "form",
420 progress_bar,
421 *step_els,
422 action=action_url,
423 method="post",
424 class_="wizard-form",
425 **{
426 "x-data": alpine_data,
427 "hx-post": action_url,
428 "hx-target": "#main-content",
429 "hx-swap": "innerHTML",
430 },
431 )
433 is_htmx = wants_fragment(request)
434 if is_htmx:
435 return HTMLResponse(render_to_string(form_el))
437 content = el(
438 "div",
439 el(
440 "div",
441 el(
442 "a",
443 f"← Back to {label}",
444 href=f"{self._config.prefix}/{self.resource_name}",
445 class_="text-primary-600 hover:text-primary-900",
446 ),
447 el(
448 "h1",
449 f"Create {label}",
450 class_="text-2xl font-bold text-foreground mt-2",
451 ),
452 class_="mb-6",
453 ),
454 el(
455 "div",
456 form_el,
457 class_="bg-card shadow rounded-lg p-6",
458 ),
459 class_="resource-content",
460 )
462 return self._renderer.render_page(
463 content,
464 request=request,
465 title=f"Create {label}",
466 breadcrumbs=[
467 {"label": "Dashboard", "url": self._config.prefix},
468 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"},
469 {
470 "label": "Create",
471 "url": f"{self._config.prefix}/{self.resource_name}/create",
472 },
473 ],
474 )
476 async def _fetch_item_data(self, resource, item_id: str) -> dict:
477 """Fetch existing item data for edit mode."""
478 initial_data: dict[str, Any] = {}
479 try:
480 if (
481 hasattr(resource, "service")
482 and resource.service
483 and hasattr(resource.service, "get")
484 ):
485 item = await resource.service.get(item_id)
486 if item:
487 initial_data = (
488 item.model_dump() if hasattr(item, "model_dump") else dict(item)
489 )
490 elif (
491 hasattr(resource, "_data_source")
492 and resource._data_source
493 and hasattr(resource._data_source, "find_one")
494 ):
495 item = await resource._data_source.find_one(item_id)
496 if item:
497 initial_data = (
498 item.model_dump() if hasattr(item, "model_dump") else dict(item)
499 )
500 except (AttributeError, TypeError, ValueError, RuntimeError) as e:
501 logger.debug(
502 "Failed to get item %s/%s for edit: %s",
503 self.resource_name,
504 item_id,
505 e,
506 )
507 return initial_data
509 async def _ensure_csrf_token(self, request) -> None:
510 if getattr(getattr(request, "state", None), "csrf_token", None):
511 return
512 session_id = request.session.get("admin_user_id", "")
513 request.state.csrf_token = self._csrf_service.generate_token(session_id)
515 async def _build_form_component(
516 self,
517 resource,
518 label: str,
519 mode: str = "create",
520 initial_data: dict | None = None,
521 record_id: str | None = None,
522 user=None,
523 errors: dict[str, list[str]] | None = None,
524 ) -> Any:
525 """Build a Form component from resource model or form_class.
527 Args:
528 resource: The resource class
529 label: Human-readable label for the resource
530 mode: 'create' or 'edit'
531 initial_data: Initial values for edit mode
532 record_id: Record ID for edit mode
534 Returns:
535 Form component ready for rendering
536 """
537 initial_data = initial_data or {}
539 # Determine form action URL
540 if mode == "edit" and record_id:
541 action_url = f"{self._config.prefix}/{self.resource_name}/{record_id}/edit"
542 else:
543 action_url = f"{self._config.prefix}/{self.resource_name}/create"
545 # Try to use resource's form_class first
546 form_class = None
547 if resource and hasattr(resource, "get_form_class"):
548 form_class = resource.get_form_class()
549 elif resource and hasattr(resource, "form_class"):
550 form_class = resource.form_class
552 if form_class:
553 # Use the declared form class
554 return form_class(initial=initial_data, action=action_url)
556 # Generate form from Pydantic model
557 if resource and resource.model:
558 try:
559 from lexigram.admin.forms.components import FormSchemaGenerator
560 from lexigram.admin.forms.fields import FieldType
562 generator = FormSchemaGenerator()
563 schema = generator.from_pydantic(resource.model)
565 # Populate relation field options from the related resource's data source
566 for field_schema in schema.fields:
567 if field_schema.type == FieldType.BELONGS_TO:
568 related_resource_name = field_schema.related_resource
569 if related_resource_name and hasattr(
570 resource, "_admin_registry"
571 ):
572 try:
573 related_resource_cls = resource._admin_registry.get(
574 related_resource_name
575 )
576 if related_resource_cls:
577 related_instance = related_resource_cls()
578 if (
579 hasattr(related_instance, "_data_source")
580 and related_instance._data_source
581 ):
582 ds = related_instance._data_source
583 if hasattr(ds, "list_all"):
584 records = await ds.list_all()
585 field_schema.options = [
586 {
587 "value": str(getattr(r, "id", r)),
588 "label": str(r),
589 }
590 for r in records
591 ]
592 except Exception:
593 import logging
595 logging.getLogger(__name__).debug(
596 "Failed to load options for %s.%s",
597 self.resource_name,
598 field_schema.name,
599 )
601 # Build field components from schema
602 field_components = []
603 for field_schema in schema.fields:
604 # Skip auto-generated fields
605 if field_schema.name in ("id", "created_at", "updated_at"):
606 continue
608 # --- Field-level RBAC enforcement ---
609 if user:
610 if self._permission_service is not None:
611 _perm_svc = self._permission_service
612 # Hide field if user lacks view permission
613 if not _perm_svc.can_view_field(
614 user, self.resource_name, field_schema.name
615 ):
616 continue
617 # Mark field non-editable if user lacks edit permission
618 if mode == "edit" and not _perm_svc.can_edit_field(
619 user, self.resource_name, field_schema.name
620 ):
621 field_schema.editable = False
622 # --- end RBAC ---
624 field_value = initial_data.get(
625 field_schema.name,
626 field_schema.default,
627 )
629 # Create the appropriate field component based on type
630 field_component = self._create_field_component(
631 field_schema,
632 field_value,
633 )
634 if field_component:
635 if errors and field_schema.name in errors:
636 field_component.errors = errors[field_schema.name]
637 field_components.append(field_component.render())
639 submit_label = "Update" if mode == "edit" else "Create"
641 # Create Form component with fields
642 form = Form(
643 action_url=action_url,
644 method="post",
645 submit_label=submit_label,
646 hx_target="#main-content",
647 hx_swap="innerHTML",
648 )
649 form.children = field_components
650 return form
652 except AdminValidationError as e:
653 logger.debug(
654 "Failed to generate form for %s: %s",
655 self.resource_name,
656 e,
657 )
658 return el("p", f"Error generating form: {e}", class_="text-destructive")
660 return el(
661 "p",
662 "No form configuration available for this resource.",
663 class_="text-muted-foreground",
664 )
666 def _create_field_component(self, field_schema, value) -> Any:
667 """Create a field component from a FieldSchema.
669 Args:
670 field_schema: The field schema definition
671 value: Initial value for the field
673 Returns:
674 Field component instance
675 """
676 from lexigram.admin.resources.field_renderer import _field_renderer_registry
678 common_args = {
679 "label": field_schema.label,
680 "required": field_schema.required,
681 "disabled": not field_schema.editable,
682 "help_text": field_schema.help_text,
683 "default": field_schema.default,
684 "placeholder": field_schema.placeholder,
685 "name": field_schema.name,
686 }
688 # Use registry to get the appropriate renderer and field instance
689 renderer = _field_renderer_registry.get_renderer(
690 field_schema.type,
691 field_schema,
692 )
693 return renderer.render_field(field_schema, value, common_args).bind(value)
696__all__ = ["FormRenderer"]