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

1"""Multi-step Alpine.js wizard form rendering for admin resources.""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING, Any 

6 

7from starlette.responses import HTMLResponse 

8 

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 

13 

14logger = get_logger(__name__) 

15 

16if TYPE_CHECKING: 

17 from lexigram.admin.config import AdminConfig 

18 from lexigram.admin.engine.renderer import AdminRenderer 

19 

20 

21class WizardRendererMixin: 

22 """Renders multi-step wizard forms; composed into ``FormRenderer``.""" 

23 

24 resource_name: str 

25 _config: AdminConfig 

26 _renderer: AdminRenderer 

27 _create_field_component: Any 

28 

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. 

38 

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. 

45 

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. 

53 

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) 

59 

60 # Build Alpine.js data initialiser - currentStep is 0-indexed. 

61 alpine_data = "{ currentStep: 0 }" 

62 

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", []) 

67 

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 

73 

74 generator = FormSchemaGenerator() 

75 schema = generator.from_pydantic(resource.model) 

76 schema_map = {f.name: f for f in schema.fields} 

77 

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 ) 

130 

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 ) 

147 

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 ) 

175 

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 ) 

201 

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 ) 

229 

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 ) 

244 

245 is_htmx = wants_fragment(request) 

246 if is_htmx: 

247 return HTMLResponse(render_to_string(form_el)) 

248 

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 ) 

273 

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 ) 

287 

288 

289__all__ = ["WizardRendererMixin"]