Coverage for src/lexigram/admin/forms/wizard.py: 0%
188 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:39 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:39 +0800
1"""Unified Wizard Component.
2Combines wizard logic, persistence, and rendering.
3"""
5from __future__ import annotations
7from dataclasses import dataclass, field
8from datetime import datetime
9from typing import TYPE_CHECKING, Any
11from lexigram.admin.schema import SchemaField
12from lexigram.ui import Component, el
14if TYPE_CHECKING:
15 from collections.abc import Callable
18@dataclass
19class WizardDraft:
20 """Wizard draft data for persistence."""
22 wizard_id: str
23 current_step: int
24 form_data: dict[str, Any]
25 step_errors: dict[int, dict[str, str]] = field(default_factory=dict)
26 completed_steps: list[int] = field(default_factory=list)
27 created_at: str = field(default_factory=lambda: datetime.now().isoformat())
28 updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
30 @classmethod
31 def from_dict(cls, data: dict[str, Any]) -> WizardDraft:
32 return cls(
33 wizard_id=data["wizard_id"],
34 current_step=data["current_step"],
35 form_data=data["form_data"],
36 step_errors={int(k): v for k, v in data.get("step_errors", {}).items()},
37 completed_steps=data.get("completed_steps", []),
38 created_at=data.get("created_at", datetime.now().isoformat()),
39 updated_at=data.get("updated_at", datetime.now().isoformat()),
40 )
42 def to_dict(self) -> dict[str, Any]:
43 return {
44 "wizard_id": self.wizard_id,
45 "current_step": self.current_step,
46 "form_data": self.form_data,
47 "step_errors": self.step_errors,
48 "completed_steps": self.completed_steps,
49 "created_at": self.created_at,
50 "updated_at": self.updated_at,
51 }
54class WizardStep:
55 """A single step in a multi-step form wizard."""
57 def __init__(
58 self,
59 name: str,
60 title: str,
61 fields: list[SchemaField],
62 description: str | None = None,
63 is_conditional: bool = False,
64 condition_func: Callable[[dict[str, Any]], bool] | None = None,
65 can_skip: bool = False,
66 ):
67 self.name = name
68 self.title = title
69 self.fields = fields
70 self.description = description
71 self.is_conditional = is_conditional
72 self.condition_func = condition_func
73 self.can_skip = can_skip
75 def is_visible(self, data: dict[str, Any]) -> bool:
76 if not self.is_conditional or not self.condition_func:
77 return True
78 return self.condition_func(data)
80 def validate(self, data: dict[str, Any]) -> tuple[bool, dict[str, str]]:
81 errors = {}
82 for form_field in self.fields:
83 value = data.get(form_field.name)
84 raw = value if value is None or isinstance(value, str) else str(value)
85 result = form_field.from_form(raw)
86 if result.is_err():
87 errors[form_field.name] = str(result.unwrap_err())
88 continue
89 cleaned = result.unwrap()
90 if form_field.required and (
91 cleaned is None or (isinstance(cleaned, str) and not cleaned)
92 ):
93 errors[form_field.name] = "This field is required."
94 return len(errors) == 0, errors
97class FormWizard:
98 """Stateful logic for multi-step forms."""
100 def __init__(
101 self,
102 wizard_id: str,
103 steps: list[WizardStep],
104 draft_storage: Callable[[str], WizardDraft | None] | None = None,
105 draft_saver: Callable[[WizardDraft], None] | None = None,
106 ):
107 self.wizard_id = wizard_id
108 self.steps = steps
109 self.current_step = 0
110 self.form_data: dict[str, Any] = {}
111 self.step_errors: dict[int, dict[str, str]] = {}
112 self.completed_steps: set[int] = set()
113 self.draft_storage = draft_storage
114 self.draft_saver = draft_saver
115 if self.draft_storage:
116 self._load_draft()
118 def get_current_step(self) -> WizardStep:
119 return self.steps[self.current_step]
121 def get_visible_steps(self) -> list[WizardStep]:
122 return list(filter(lambda s: s.is_visible(self.form_data), self.steps))
124 def get_progress(self) -> float:
125 visible = self.get_visible_steps()
126 if not visible:
127 return 100.0
128 current_step_obj = self.get_current_step()
129 try:
130 current_visible_idx = visible.index(current_step_obj)
131 except ValueError:
132 current_visible_idx = 0
133 return ((current_visible_idx + 1) / len(visible)) * 100
135 def jump_to_step(self, step_idx: int) -> bool:
136 if not self.can_proceed_to_step(step_idx):
137 return False
138 self.current_step = step_idx
139 return True
141 def can_proceed_to_step(self, step_idx: int) -> bool:
142 if step_idx == 0:
143 return True
144 # Simplified: can proceed if all visible steps before this one are completed
145 visible = self.get_visible_steps()
146 if step_idx < 0 or step_idx >= len(self.steps):
147 return False
149 target_step = self.steps[step_idx]
150 if target_step not in visible:
151 return False
153 target_visible_idx = visible.index(target_step)
154 for i in range(target_visible_idx):
155 actual_idx = self.steps.index(visible[i])
156 if actual_idx not in self.completed_steps:
157 return False
158 return True
160 def next_step(self, data: dict[str, Any]) -> tuple[bool, dict[str, str]]:
161 current = self.get_current_step()
162 success, errors = current.validate(data)
164 self.form_data.update(data)
165 if success:
166 self.completed_steps.add(self.current_step)
167 self.step_errors.pop(self.current_step, None)
169 visible = self.get_visible_steps()
170 try:
171 current_visible_idx = visible.index(current)
172 if current_visible_idx < len(visible) - 1:
173 next_step_obj = visible[current_visible_idx + 1]
174 self.current_step = self.steps.index(next_step_obj)
175 except ValueError:
176 pass
177 else:
178 self.step_errors[self.current_step] = errors
180 self._save_draft()
181 return success, errors
183 def previous_step(self) -> bool:
184 visible = self.get_visible_steps()
185 current = self.get_current_step()
186 try:
187 current_visible_idx = visible.index(current)
188 if current_visible_idx > 0:
189 prev_step_obj = visible[current_visible_idx - 1]
190 self.current_step = self.steps.index(prev_step_obj)
191 return True
192 except ValueError:
193 pass
194 return False
196 def skip_step(self) -> bool:
197 current = self.get_current_step()
198 if not current.can_skip:
199 return False
201 self.completed_steps.add(self.current_step)
202 # Clear any errors for this step since we are skipping
203 self.step_errors.pop(self.current_step, None)
205 visible = self.get_visible_steps()
206 try:
207 current_visible_idx = visible.index(current)
208 if current_visible_idx < len(visible) - 1:
209 next_step_obj = visible[current_visible_idx + 1]
210 self.current_step = self.steps.index(next_step_obj)
211 except ValueError:
212 pass
214 self._save_draft()
215 return True
217 def add_review_step(
218 self,
219 title: str = "Review",
220 description: str = "",
221 name: str = "review",
222 ) -> Any:
223 """Add a review step at the end."""
224 review_step = WizardStep(
225 name=name,
226 title=title,
227 fields=[],
228 description=description,
229 )
230 self.steps.append(review_step)
232 def get_step_validation_summary(self, step_idx: int) -> dict[str, Any]:
233 step = self.steps[step_idx]
234 errors = self.step_errors.get(step_idx, {})
235 return {
236 "valid": len(errors) == 0,
237 "error_count": len(errors),
238 "step_title": step.title,
239 "errors": errors,
240 }
242 def reset(self) -> Any:
243 self.current_step = 0
244 self.form_data = {}
245 self.step_errors = {}
246 self.completed_steps = set()
247 self._save_draft()
249 def _load_draft(self) -> Any:
250 if not self.draft_storage:
251 return
252 draft = self.draft_storage(self.wizard_id)
253 if draft:
254 self.current_step = draft.current_step
255 self.form_data = draft.form_data
256 self.step_errors = draft.step_errors
257 self.completed_steps = set(draft.completed_steps)
259 def _save_draft(self) -> Any:
260 if self.draft_saver:
261 draft = WizardDraft(
262 wizard_id=self.wizard_id,
263 current_step=self.current_step,
264 form_data=self.form_data,
265 step_errors=dict(self.step_errors.items()),
266 completed_steps=list(self.completed_steps),
267 updated_at=datetime.now().isoformat(),
268 )
269 self.draft_saver(draft)
271 def save_draft(self) -> Any:
272 self._save_draft()
275class WizardRenderer(Component):
276 """UI Renderer for FormWizard."""
278 def __init__(self, wizard: FormWizard, **props: Any) -> None:
279 super().__init__(**props)
280 self.wizard = wizard
282 def render(self) -> Any:
283 return el(
284 "div",
285 self.render_progress_bar(),
286 self.render_step_navigation(),
287 self.render_current_step(),
288 class_="wizard-container p-6 bg-card rounded-xl shadow-lg",
289 )
291 def render_progress_bar(self) -> Any:
292 progress = self.wizard.get_progress()
293 return el(
294 "div",
295 el(
296 "div",
297 class_="h-2 bg-primary-600 transition-all duration-500",
298 style=f"width: {progress}%",
299 ),
300 class_="w-full h-2 bg-muted rounded-full overflow-hidden mb-8",
301 )
303 def render_step_navigation(self) -> Any:
304 steps = self.wizard.get_visible_steps()
305 current = self.wizard.get_current_step()
306 nav_items = []
307 for _i, step in enumerate(steps):
308 is_active = step == current
309 nav_items.append(
310 el(
311 "div",
312 step.title,
313 class_=f"text-sm font-medium {'text-primary-600' if is_active else 'text-muted-foreground'}",
314 ),
315 )
316 return el("div", *nav_items, class_="flex justify-between mb-6")
318 def render_current_step(self) -> Any:
319 step = self.wizard.get_current_step()
320 values = self.wizard.form_data
321 fields_html = [f.render_form(values.get(f.name)) for f in step.fields]
322 return el(
323 "div",
324 el("h2", step.title, class_="text-xl font-bold mb-2"),
325 el("p", step.description or "", class_="text-muted-foreground mb-6"),
326 el("div", *fields_html, class_="space-y-4"),
327 class_="step-content",
328 )