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

1"""Unified Wizard Component. 

2Combines wizard logic, persistence, and rendering. 

3""" 

4 

5from __future__ import annotations 

6 

7from dataclasses import dataclass, field 

8from datetime import datetime 

9from typing import TYPE_CHECKING, Any 

10 

11from lexigram.admin.schema import SchemaField 

12from lexigram.ui import Component, el 

13 

14if TYPE_CHECKING: 

15 from collections.abc import Callable 

16 

17 

18@dataclass 

19class WizardDraft: 

20 """Wizard draft data for persistence.""" 

21 

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()) 

29 

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 ) 

41 

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 } 

52 

53 

54class WizardStep: 

55 """A single step in a multi-step form wizard.""" 

56 

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 

74 

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) 

79 

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 

95 

96 

97class FormWizard: 

98 """Stateful logic for multi-step forms.""" 

99 

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() 

117 

118 def get_current_step(self) -> WizardStep: 

119 return self.steps[self.current_step] 

120 

121 def get_visible_steps(self) -> list[WizardStep]: 

122 return list(filter(lambda s: s.is_visible(self.form_data), self.steps)) 

123 

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 

134 

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 

140 

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 

148 

149 target_step = self.steps[step_idx] 

150 if target_step not in visible: 

151 return False 

152 

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 

159 

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) 

163 

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) 

168 

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 

179 

180 self._save_draft() 

181 return success, errors 

182 

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 

195 

196 def skip_step(self) -> bool: 

197 current = self.get_current_step() 

198 if not current.can_skip: 

199 return False 

200 

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) 

204 

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 

213 

214 self._save_draft() 

215 return True 

216 

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) 

231 

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 } 

241 

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() 

248 

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) 

258 

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) 

270 

271 def save_draft(self) -> Any: 

272 self._save_draft() 

273 

274 

275class WizardRenderer(Component): 

276 """UI Renderer for FormWizard.""" 

277 

278 def __init__(self, wizard: FormWizard, **props: Any) -> None: 

279 super().__init__(**props) 

280 self.wizard = wizard 

281 

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 ) 

290 

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 ) 

302 

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") 

317 

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 )