Coverage for src / lexigram / admin / forms / wizard.py: 24%
182 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +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.ui import Component, el
13if TYPE_CHECKING:
14 from collections.abc import Callable
16 from lexigram.admin.forms.fields import AbstractField
19@dataclass
20class WizardDraft:
21 """Wizard draft data for persistence."""
23 wizard_id: str
24 current_step: int
25 form_data: dict[str, Any]
26 step_errors: dict[int, dict[str, str]] = field(default_factory=dict)
27 completed_steps: list[int] = field(default_factory=list)
28 created_at: str = field(default_factory=lambda: datetime.now().isoformat())
29 updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
31 @classmethod
32 def from_dict(cls, data: dict[str, Any]) -> WizardDraft:
33 return cls(
34 wizard_id=data["wizard_id"],
35 current_step=data["current_step"],
36 form_data=data["form_data"],
37 step_errors={int(k): v for k, v in data.get("step_errors", {}).items()},
38 completed_steps=data.get("completed_steps", []),
39 created_at=data.get("created_at", datetime.now().isoformat()),
40 updated_at=data.get("updated_at", datetime.now().isoformat()),
41 )
43 def to_dict(self) -> dict[str, Any]:
44 return {
45 "wizard_id": self.wizard_id,
46 "current_step": self.current_step,
47 "form_data": self.form_data,
48 "step_errors": self.step_errors,
49 "completed_steps": self.completed_steps,
50 "created_at": self.created_at,
51 "updated_at": self.updated_at,
52 }
55class WizardStep:
56 """A single step in a multi-step form wizard."""
58 def __init__(
59 self,
60 name: str,
61 title: str,
62 fields: list[AbstractField],
63 description: str | None = None,
64 is_conditional: bool = False,
65 condition_func: Callable[[dict[str, Any]], bool] | None = None,
66 can_skip: bool = False,
67 ):
68 self.name = name
69 self.title = title
70 self.fields = fields
71 self.description = description
72 self.is_conditional = is_conditional
73 self.condition_func = condition_func
74 self.can_skip = can_skip
76 def is_visible(self, data: dict[str, Any]) -> bool:
77 if not self.is_conditional or not self.condition_func:
78 return True
79 return self.condition_func(data)
81 def validate(self, data: dict[str, Any]) -> tuple[bool, dict[str, str]]:
82 errors = {}
83 for form_field in self.fields:
84 value = data.get(form_field.name)
85 try:
86 form_field.validate(value)
87 except ValueError as e:
88 errors[form_field.name] = str(e)
89 return len(errors) == 0, errors
92class FormWizard:
93 """Stateful logic for multi-step forms."""
95 def __init__(
96 self,
97 wizard_id: str,
98 steps: list[WizardStep],
99 draft_storage: Callable[[str], WizardDraft | None] | None = None,
100 draft_saver: Callable[[WizardDraft], None] | None = None,
101 ):
102 self.wizard_id = wizard_id
103 self.steps = steps
104 self.current_step = 0
105 self.form_data: dict[str, Any] = {}
106 self.step_errors: dict[int, dict[str, str]] = {}
107 self.completed_steps: set[int] = set()
108 self.draft_storage = draft_storage
109 self.draft_saver = draft_saver
110 if self.draft_storage:
111 self._load_draft()
113 def get_current_step(self) -> WizardStep:
114 return self.steps[self.current_step]
116 def get_visible_steps(self) -> list[WizardStep]:
117 return list(filter(lambda s: s.is_visible(self.form_data), self.steps))
119 def get_progress(self) -> float:
120 visible = self.get_visible_steps()
121 if not visible:
122 return 100.0
123 current_step_obj = self.get_current_step()
124 try:
125 current_visible_idx = visible.index(current_step_obj)
126 except ValueError:
127 current_visible_idx = 0
128 return ((current_visible_idx + 1) / len(visible)) * 100
130 def jump_to_step(self, step_idx: int) -> bool:
131 if not self.can_proceed_to_step(step_idx):
132 return False
133 self.current_step = step_idx
134 return True
136 def can_proceed_to_step(self, step_idx: int) -> bool:
137 if step_idx == 0:
138 return True
139 # Simplified: can proceed if all visible steps before this one are completed
140 visible = self.get_visible_steps()
141 if step_idx < 0 or step_idx >= len(self.steps):
142 return False
144 target_step = self.steps[step_idx]
145 if target_step not in visible:
146 return False
148 target_visible_idx = visible.index(target_step)
149 for i in range(target_visible_idx):
150 actual_idx = self.steps.index(visible[i])
151 if actual_idx not in self.completed_steps:
152 return False
153 return True
155 def next_step(self, data: dict[str, Any]) -> tuple[bool, dict[str, str]]:
156 current = self.get_current_step()
157 success, errors = current.validate(data)
159 self.form_data.update(data)
160 if success:
161 self.completed_steps.add(self.current_step)
162 self.step_errors.pop(self.current_step, None)
164 visible = self.get_visible_steps()
165 try:
166 current_visible_idx = visible.index(current)
167 if current_visible_idx < len(visible) - 1:
168 next_step_obj = visible[current_visible_idx + 1]
169 self.current_step = self.steps.index(next_step_obj)
170 except ValueError:
171 pass
172 else:
173 self.step_errors[self.current_step] = errors
175 self._save_draft()
176 return success, errors
178 def previous_step(self) -> bool:
179 visible = self.get_visible_steps()
180 current = self.get_current_step()
181 try:
182 current_visible_idx = visible.index(current)
183 if current_visible_idx > 0:
184 prev_step_obj = visible[current_visible_idx - 1]
185 self.current_step = self.steps.index(prev_step_obj)
186 return True
187 except ValueError:
188 pass
189 return False
191 def skip_step(self) -> bool:
192 current = self.get_current_step()
193 if not current.can_skip:
194 return False
196 self.completed_steps.add(self.current_step)
197 # Clear any errors for this step since we are skipping
198 self.step_errors.pop(self.current_step, None)
200 visible = self.get_visible_steps()
201 try:
202 current_visible_idx = visible.index(current)
203 if current_visible_idx < len(visible) - 1:
204 next_step_obj = visible[current_visible_idx + 1]
205 self.current_step = self.steps.index(next_step_obj)
206 except ValueError:
207 pass
209 self._save_draft()
210 return True
212 def add_review_step(
213 self,
214 title: str = "Review",
215 description: str = "",
216 name: str = "review",
217 ) -> Any:
218 """Add a review step at the end."""
219 review_step = WizardStep(
220 name=name,
221 title=title,
222 fields=[],
223 description=description,
224 )
225 self.steps.append(review_step)
227 def get_step_validation_summary(self, step_idx: int) -> dict[str, Any]:
228 step = self.steps[step_idx]
229 errors = self.step_errors.get(step_idx, {})
230 return {
231 "valid": len(errors) == 0,
232 "error_count": len(errors),
233 "step_title": step.title,
234 "errors": errors,
235 }
237 def reset(self) -> Any:
238 self.current_step = 0
239 self.form_data = {}
240 self.step_errors = {}
241 self.completed_steps = set()
242 self._save_draft()
244 def _load_draft(self) -> Any:
245 if not self.draft_storage:
246 return
247 draft = self.draft_storage(self.wizard_id)
248 if draft:
249 self.current_step = draft.current_step
250 self.form_data = draft.form_data
251 self.step_errors = draft.step_errors
252 self.completed_steps = set(draft.completed_steps)
254 def _save_draft(self) -> Any:
255 if self.draft_saver:
256 draft = WizardDraft(
257 wizard_id=self.wizard_id,
258 current_step=self.current_step,
259 form_data=self.form_data,
260 step_errors=dict(self.step_errors.items()),
261 completed_steps=list(self.completed_steps),
262 updated_at=datetime.now().isoformat(),
263 )
264 self.draft_saver(draft)
266 def save_draft(self) -> Any:
267 self._save_draft()
270class WizardRenderer(Component):
271 """UI Renderer for FormWizard."""
273 def __init__(self, wizard: FormWizard, **props: Any) -> None:
274 super().__init__(**props)
275 self.wizard = wizard
277 def render(self) -> Any:
278 return el(
279 "div",
280 self.render_progress_bar(),
281 self.render_step_navigation(),
282 self.render_current_step(),
283 class_="wizard-container p-6 bg-card rounded-xl shadow-lg",
284 )
286 def render_progress_bar(self) -> Any:
287 progress = self.wizard.get_progress()
288 return el(
289 "div",
290 el(
291 "div",
292 class_="h-2 bg-primary-600 transition-all duration-500",
293 style=f"width: {progress}%",
294 ),
295 class_="w-full h-2 bg-muted rounded-full overflow-hidden mb-8",
296 )
298 def render_step_navigation(self) -> Any:
299 steps = self.wizard.get_visible_steps()
300 current = self.wizard.get_current_step()
301 nav_items = []
302 for _i, step in enumerate(steps):
303 is_active = step == current
304 nav_items.append(
305 el(
306 "div",
307 step.title,
308 class_=f"text-sm font-medium {'text-primary-600' if is_active else 'text-muted-foreground'}",
309 ),
310 )
311 return el("div", *nav_items, class_="flex justify-between mb-6")
313 def render_current_step(self) -> Any:
314 step = self.wizard.get_current_step()
315 fields_html = [f.render() for f in step.fields]
316 return el(
317 "div",
318 el("h2", step.title, class_="text-xl font-bold mb-2"),
319 el("p", step.description or "", class_="text-muted-foreground mb-6"),
320 el("div", *fields_html, class_="space-y-4"),
321 class_="step-content",
322 )