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

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.ui import Component, el 

12 

13if TYPE_CHECKING: 

14 from collections.abc import Callable 

15 

16 from lexigram.admin.forms.fields import AbstractField 

17 

18 

19@dataclass 

20class WizardDraft: 

21 """Wizard draft data for persistence.""" 

22 

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

30 

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 ) 

42 

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 } 

53 

54 

55class WizardStep: 

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

57 

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 

75 

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) 

80 

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 

90 

91 

92class FormWizard: 

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

94 

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

112 

113 def get_current_step(self) -> WizardStep: 

114 return self.steps[self.current_step] 

115 

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

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

118 

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 

129 

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 

135 

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 

143 

144 target_step = self.steps[step_idx] 

145 if target_step not in visible: 

146 return False 

147 

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 

154 

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) 

158 

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) 

163 

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 

174 

175 self._save_draft() 

176 return success, errors 

177 

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 

190 

191 def skip_step(self) -> bool: 

192 current = self.get_current_step() 

193 if not current.can_skip: 

194 return False 

195 

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) 

199 

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 

208 

209 self._save_draft() 

210 return True 

211 

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) 

226 

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 } 

236 

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

243 

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) 

253 

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) 

265 

266 def save_draft(self) -> Any: 

267 self._save_draft() 

268 

269 

270class WizardRenderer(Component): 

271 """UI Renderer for FormWizard.""" 

272 

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

274 super().__init__(**props) 

275 self.wizard = wizard 

276 

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 ) 

285 

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 ) 

297 

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

312 

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 )