Coverage for src/lexigram/admin/resources/wizard_renderer.py: 0%
60 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"""Multi-step Alpine.js wizard form rendering for admin resources."""
3from __future__ import annotations
5from typing import TYPE_CHECKING, Any
7from starlette.responses import HTMLResponse
9from lexigram.admin.exceptions import AdminValidationError
10from lexigram.admin.state.context import wants_fragment
11from lexigram.logging import get_logger
12from lexigram.ui import el, render_to_string
14logger = get_logger(__name__)
16if TYPE_CHECKING:
17 from lexigram.admin.config import AdminConfig
18 from lexigram.admin.engine.renderer import AdminRenderer
21class WizardRendererMixin:
22 """Renders multi-step wizard forms; composed into ``FormRenderer``."""
24 resource_name: str
25 _config: AdminConfig
26 _renderer: AdminRenderer
27 _create_field_component: Any
29 async def render_wizard(
30 self,
31 request: Any,
32 resource: Any,
33 steps: list[dict],
34 action_url: str,
35 submit_label: str = "Submit",
36 ) -> HTMLResponse:
37 """Render a multi-step wizard form driven by Alpine.js.
39 Each step is defined by a dict with ``"title"`` and ``"fields"`` keys.
40 Only the step whose index matches the Alpine ``currentStep`` variable
41 is visible at any time. Previous / Next buttons advance or retreat
42 through the steps, and the final step shows a Submit button that POSTs
43 the whole form to ``action_url``. A step indicator line (e.g.
44 "Step 2 of 4") is shown above the step body.
46 Args:
47 request: Incoming HTTP request.
48 resource: Admin resource instance (used to build field components).
49 steps: Step definitions. Each item must be a dict with at minimum
50 ``"title": str`` and ``"fields": list[str]`` keys.
51 action_url: Form ``action`` / HTMX ``hx-post`` target URL.
52 submit_label: Label for the submit button on the final step.
54 Returns:
55 ``HTMLResponse`` with the wizard form fragment or full page.
56 """
57 label = self.resource_name.replace("_", " ").title()
58 total_steps = len(steps)
60 # Build Alpine.js data initialiser - currentStep is 0-indexed.
61 alpine_data = "{ currentStep: 0 }"
63 step_els: list[Any] = []
64 for idx, step_def in enumerate(steps):
65 step_title = step_def.get("title", f"Step {idx + 1}")
66 step_fields_names: list[str] = step_def.get("fields", [])
68 # Attempt to render each named field via the field registry.
69 field_html_parts: list[Any] = []
70 if resource and resource.model:
71 try:
72 from lexigram.admin.forms.components import FormSchemaGenerator
74 generator = FormSchemaGenerator()
75 schema = generator.from_pydantic(resource.model)
76 schema_map = {f.name: f for f in schema.fields}
78 for fname in step_fields_names:
79 field_schema = schema_map.get(fname)
80 if field_schema is None:
81 field_html_parts.append(
82 el(
83 "p",
84 f"Unknown field: {fname}",
85 class_="text-xs text-destructive",
86 )
87 )
88 continue
89 field_component = self._create_field_component(
90 field_schema, field_schema.default
91 )
92 if field_component:
93 raw = field_component.render()
94 field_html_parts.append(
95 el("div", raw, class_="wizard-field mb-4")
96 )
97 except AdminValidationError as exc:
98 logger.debug(
99 "render_wizard field generation failed resource=%s: %s",
100 self.resource_name,
101 exc,
102 )
103 field_html_parts.append(
104 el(
105 "p",
106 f"Error building fields: {exc}",
107 class_="text-destructive text-sm",
108 )
109 )
110 else:
111 for fname in step_fields_names:
112 field_html_parts.append(
113 el(
114 "div",
115 el(
116 "input",
117 type="text",
118 name=fname,
119 placeholder=fname.replace("_", " ").title(),
120 class_=(
121 "block w-full rounded-md border border-border "
122 "dark:border-border bg-muted "
123 "text-foreground px-3 py-2 text-sm "
124 "focus:outline-none focus:ring-2 focus:ring-primary-500"
125 ),
126 ),
127 class_="wizard-field mb-4",
128 )
129 )
131 # Navigation buttons
132 nav_buttons: list[Any] = []
133 if idx > 0:
134 nav_buttons.append(
135 el(
136 "button",
137 "← Previous",
138 type="button",
139 class_=(
140 "px-4 py-2 text-sm font-medium text-foreground "
141 "border border-border rounded-lg "
142 "hover:bg-muted dark:hover:bg-muted transition-colors"
143 ),
144 **{"@click": "currentStep--"},
145 )
146 )
148 if idx < total_steps - 1:
149 nav_buttons.append(
150 el(
151 "button",
152 "Next →",
153 type="button",
154 class_=(
155 "px-4 py-2 text-sm font-medium text-white bg-primary-600 "
156 "hover:bg-primary-700 rounded-lg focus:outline-none "
157 "focus:ring-2 focus:ring-primary-500 transition-colors"
158 ),
159 **{"@click": "currentStep++"},
160 )
161 )
162 else:
163 nav_buttons.append(
164 el(
165 "button",
166 submit_label,
167 type="submit",
168 class_=(
169 "px-4 py-2 text-sm font-medium text-white bg-success "
170 "hover:bg-success/90 rounded-lg focus:outline-none "
171 "focus:ring-2 focus:ring-ring transition-colors"
172 ),
173 )
174 )
176 step_indicator = el(
177 "p",
178 f"Step {idx + 1} of {total_steps}",
179 class_="text-xs text-muted-foreground mb-1",
180 )
181 step_heading = el(
182 "h3",
183 step_title,
184 class_="text-base font-semibold text-foreground mb-4",
185 )
186 step_els.append(
187 el(
188 "div",
189 step_indicator,
190 step_heading,
191 *field_html_parts,
192 el(
193 "div",
194 *nav_buttons,
195 class_="flex items-center justify-between mt-6 gap-3",
196 ),
197 class_="wizard-step",
198 **{"x-show": f"currentStep === {idx}"},
199 )
200 )
202 # Progress bar / step dots
203 step_dots: list[Any] = [
204 el(
205 "span",
206 str(i + 1),
207 class_=(
208 f"wizard-dot inline-flex items-center justify-center w-7 h-7 "
209 f"rounded-full text-xs font-semibold transition-colors "
210 f"{'bg-primary-600 text-white' if i == 0 else 'bg-muted text-muted-foreground dark:text-muted-foreground'}"
211 ),
212 **{
213 ":class": (
214 f"currentStep === {i} "
215 f"? 'bg-primary-600 text-white' "
216 f": currentStep > {i} "
217 f"? 'bg-success text-success-foreground' "
218 f": 'bg-muted text-muted-foreground dark:text-muted-foreground'"
219 )
220 },
221 )
222 for i in range(total_steps)
223 ]
224 progress_bar = el(
225 "div",
226 *step_dots,
227 class_="wizard-progress flex items-center gap-2 mb-6",
228 )
230 form_el = el(
231 "form",
232 progress_bar,
233 *step_els,
234 action=action_url,
235 method="post",
236 class_="wizard-form",
237 **{
238 "x-data": alpine_data,
239 "hx-post": action_url,
240 "hx-target": "#main-content",
241 "hx-swap": "innerHTML",
242 },
243 )
245 is_htmx = wants_fragment(request)
246 if is_htmx:
247 return HTMLResponse(render_to_string(form_el))
249 content = el(
250 "div",
251 el(
252 "div",
253 el(
254 "a",
255 f"← Back to {label}",
256 href=f"{self._config.prefix}/{self.resource_name}",
257 class_="text-primary-600 hover:text-primary-900",
258 ),
259 el(
260 "h1",
261 f"Create {label}",
262 class_="text-2xl font-bold text-foreground mt-2",
263 ),
264 class_="mb-6",
265 ),
266 el(
267 "div",
268 form_el,
269 class_="bg-card shadow rounded-lg p-6",
270 ),
271 class_="resource-content",
272 )
274 return self._renderer.render_page(
275 content,
276 request=request,
277 title=f"Create {label}",
278 breadcrumbs=[
279 {"label": "Dashboard", "url": self._config.prefix},
280 {"label": label, "url": f"{self._config.prefix}/{self.resource_name}"},
281 {
282 "label": "Create",
283 "url": f"{self._config.prefix}/{self.resource_name}/create",
284 },
285 ],
286 )
289__all__ = ["WizardRendererMixin"]