|
| 1 | +# Multi-Step Wizard (FastAPI) |
| 2 | + |
| 3 | +> Runnable example: [`examples/fastapi_wizard_example.py`](../../examples/fastapi_wizard_example.py) |
| 4 | +
|
| 5 | +A guided, multi-step form — collect a few fields, validate them, move on, |
| 6 | +let the user go back and see what they already entered, then do something |
| 7 | +with the accumulated data on the final step. This is the pattern behind |
| 8 | +things like onboarding flows, checkout, and (the concrete driver for this |
| 9 | +recipe) a resume-tailoring wizard. |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +## Before you build one: read this |
| 14 | + |
| 15 | +**A production-grade wizard needs signed state.** Every component-framework |
| 16 | +request round-trips the component's state to the browser and back |
| 17 | +(`dispatch(state=...)` → `result["state"]`). Today that round-trip is |
| 18 | +**unsigned** — nothing stops a client from editing the serialized state blob |
| 19 | +before posting it back. For a single counter that's harmless. For a wizard |
| 20 | +that accumulates several steps of validated data, a tampered state blob |
| 21 | +could let a client skip validation on an earlier step, or submit the final |
| 22 | +step with fabricated `collected` data for an earlier one. |
| 23 | + |
| 24 | +Signed state is tracked as **Epic A / A1** and does not exist yet as of this |
| 25 | +writing (no `CorruptStateError`, no `sign_state`, nothing in |
| 26 | +`core/component.py`'s `StateSerializer`). **A4** (CSRF coverage for the |
| 27 | +FastAPI adapter) is also outstanding — the FastAPI adapter has no CSRF |
| 28 | +handling at all right now, unlike the Django adapter. |
| 29 | + |
| 30 | +This recipe and its example are written against the **current, unsigned** |
| 31 | +state model, so you can build a wizard today. Two things to do once A1/A4 |
| 32 | +land: |
| 33 | + |
| 34 | +- Swap the plain `state=...` round-trip for whatever signed-state API A1 |
| 35 | + introduces — no change to the component's own logic should be needed. |
| 36 | +- Add CSRF protection at the app level for the wizard's POST route (a |
| 37 | + double-submit-cookie middleware, or whatever your app already uses for |
| 38 | + other POST endpoints) until A4 ships a first-class mechanism. |
| 39 | + |
| 40 | +Until then: **don't put anything in a wizard's accumulated state that you |
| 41 | +wouldn't be comfortable with a malicious client tampering with.** If the |
| 42 | +final step's handler is about to write to your database, re-validate the |
| 43 | +data you actually need server-side rather than trusting `collected` |
| 44 | +blindly (e.g. re-check foreign keys exist, re-check ownership). |
| 45 | + |
| 46 | +--- |
| 47 | + |
| 48 | +## Why not `CompositeComponent` per step? |
| 49 | + |
| 50 | +If you've read the framework's docs/README, you may expect a |
| 51 | +`CompositeComponent` host with one child `FormComponent` per step, wired |
| 52 | +via slots. **That primitive doesn't exist in the codebase today** — only |
| 53 | +`Component.fill_slot()` / `render_slots()` and the `compose()` helper in |
| 54 | +`core/composition.py`, which compose components for **rendering** (a parent |
| 55 | +template with pre-rendered child HTML dropped into named slots). |
| 56 | + |
| 57 | +That's a fundamentally different problem than a wizard's needs. Each POST to |
| 58 | +`/components/{name}` dispatches **exactly one** registered component — hydrate |
| 59 | +→ handle event → render → dehydrate — for that one component only. There's |
| 60 | +no framework machinery that would hydrate a parent *and* an independently |
| 61 | +addressable per-step child component in the same request and keep both |
| 62 | +in sync across steps. Modeling a wizard as a parent + slotted children |
| 63 | +would mean inventing your own protocol for shuttling child state through |
| 64 | +the parent's state anyway — at which point you've just built the pattern |
| 65 | +below with extra ceremony. |
| 66 | + |
| 67 | +**The pattern that actually works:** one component owns the whole wizard. |
| 68 | +It tracks which step is active and the data collected so far, and swaps |
| 69 | +which schema it validates against based on the active step. |
| 70 | + |
| 71 | +--- |
| 72 | + |
| 73 | +## The Component |
| 74 | + |
| 75 | +```python |
| 76 | +# resume_wizard.py |
| 77 | +from typing import ClassVar |
| 78 | + |
| 79 | +from pydantic import BaseModel, EmailStr, Field |
| 80 | + |
| 81 | +from component_framework.core import FormComponent, registry |
| 82 | + |
| 83 | + |
| 84 | +class ContactStepSchema(BaseModel): |
| 85 | + name: str = Field(min_length=2, max_length=100) |
| 86 | + email: EmailStr |
| 87 | + |
| 88 | + |
| 89 | +class TargetRoleStepSchema(BaseModel): |
| 90 | + job_title: str = Field(min_length=2, max_length=100) |
| 91 | + company: str = Field(min_length=2, max_length=100) |
| 92 | + |
| 93 | + |
| 94 | +# The final "review" step has no fields to validate, so no schema. |
| 95 | +STEPS: list[dict] = [ |
| 96 | + {"key": "contact", "title": "Contact Info", "schema": ContactStepSchema}, |
| 97 | + {"key": "target_role", "title": "Target Role", "schema": TargetRoleStepSchema}, |
| 98 | + {"key": "review", "title": "Review & Generate", "schema": None}, |
| 99 | +] |
| 100 | + |
| 101 | + |
| 102 | +@registry.register("resume_wizard") |
| 103 | +class ResumeWizard(FormComponent): |
| 104 | + steps: ClassVar[list[dict]] = STEPS |
| 105 | + template_name = "Wizard" |
| 106 | + |
| 107 | + def mount(self): |
| 108 | + super().mount() |
| 109 | + self.state.setdefault("step_index", 0) |
| 110 | + self.state.setdefault("collected", {}) |
| 111 | + self._load_current_step_form_data() |
| 112 | + |
| 113 | + def _current_step(self) -> dict: |
| 114 | + return self.steps[self.state["step_index"]] |
| 115 | + |
| 116 | + def _load_current_step_form_data(self): |
| 117 | + """Pre-fill from previously-entered data when navigating back.""" |
| 118 | + step = self._current_step() |
| 119 | + self.state["form_data"] = self.state["collected"].get(step["key"], {}) |
| 120 | + self.field_errors = {} |
| 121 | + |
| 122 | + @property |
| 123 | + def schema(self): |
| 124 | + """FormComponent.validate() reads this — point it at the active step.""" |
| 125 | + return self._current_step().get("schema") |
| 126 | + |
| 127 | + def on_advance(self, form_data: dict): |
| 128 | + step = self._current_step() |
| 129 | + self.state["form_data"] = form_data |
| 130 | + |
| 131 | + if step.get("schema") and not self.validate(form_data): |
| 132 | + return # field_errors populated by validate(); stay on this step |
| 133 | + |
| 134 | + if step.get("schema"): |
| 135 | + self.state["collected"][step["key"]] = self.validated_data |
| 136 | + |
| 137 | + if self.state["step_index"] < len(self.steps) - 1: |
| 138 | + self.state["step_index"] += 1 |
| 139 | + self._load_current_step_form_data() |
| 140 | + |
| 141 | + def on_back(self): |
| 142 | + if self.state["step_index"] > 0: |
| 143 | + self.state["step_index"] -= 1 |
| 144 | + self._load_current_step_form_data() |
| 145 | + |
| 146 | + def on_submit(self): |
| 147 | + """ |
| 148 | + Final step. This is where an app persists the wizard's result — |
| 149 | + the framework does not do this for you. For example: |
| 150 | +
|
| 151 | + ResumeDraft.objects.create(**self.state["collected"]["contact"], ...) |
| 152 | +
|
| 153 | + Re-validate anything security-sensitive here rather than trusting |
| 154 | + ``collected`` outright — see the state-signing caveat above. |
| 155 | + """ |
| 156 | + self.state["completed"] = True |
| 157 | + |
| 158 | + def get_context(self) -> dict: |
| 159 | + context = super().get_context() |
| 160 | + context.update({ |
| 161 | + "step_index": self.state["step_index"], |
| 162 | + "step_key": self._current_step()["key"], |
| 163 | + "step_title": self._current_step()["title"], |
| 164 | + "step_titles": [s["title"] for s in self.steps], |
| 165 | + "is_last_step": self.state["step_index"] == len(self.steps) - 1, |
| 166 | + "collected": self.state.get("collected", {}), |
| 167 | + "completed": self.state.get("completed", False), |
| 168 | + }) |
| 169 | + return context |
| 170 | +``` |
| 171 | + |
| 172 | +Three things make this work: |
| 173 | + |
| 174 | +1. **`schema` becomes a property, not a fixed `ClassVar`.** `FormComponent.validate()` |
| 175 | + reads `self.schema` — overriding it as a property lets each step supply its |
| 176 | + own Pydantic model without needing per-step component subclasses. |
| 177 | +2. **`on_advance` validates and *then* decides whether to move.** A failed |
| 178 | + validation leaves `step_index` untouched and populates `field_errors` |
| 179 | + exactly like a single-page `FormComponent` would — the difference is just |
| 180 | + that we route through `validate()` manually instead of going through |
| 181 | + `handle_event`'s built-in `"submit"` branch. |
| 182 | +3. **The last step reuses `"submit"`, not `"advance"`.** Since the review |
| 183 | + step has `schema: None`, `FormComponent.handle_event`'s existing |
| 184 | + `"submit"` handling (validate → call `on_submit`) works unchanged — no |
| 185 | + special-casing needed for the terminal step. |
| 186 | + |
| 187 | +## The Template |
| 188 | + |
| 189 | +The template switches which fields it renders based on `step_key`, and |
| 190 | +posts each field's live value explicitly via `hx-vals='js:{...}'` (rather |
| 191 | +than relying on `hx-include`, which doesn't nest into the component |
| 192 | +endpoint's `payload.form_data` shape). See |
| 193 | +[`templates/components/Wizard.jinja`](../../templates/components/Wizard.jinja) |
| 194 | +for the full template — the "Next" button on the contact step looks like: |
| 195 | + |
| 196 | +```html |
| 197 | +<button |
| 198 | + type="button" |
| 199 | + hx-post="/components/resume_wizard" |
| 200 | + hx-vals='js:{"event": "advance", "payload": {"form_data": {"name": document.getElementById("wiz-name").value, "email": document.getElementById("wiz-email").value}}, "state": {{ state | tojson }}, "params": {"component_id": "{{ component_id }}"}}' |
| 201 | + hx-target="#{{ component_id }}" |
| 202 | + hx-swap="outerHTML" |
| 203 | +>Next</button> |
| 204 | +``` |
| 205 | + |
| 206 | +"Back" posts an `"back"` event with an empty payload; the final step's |
| 207 | +"Generate" button posts `"submit"`. |
| 208 | + |
| 209 | +## What happens to state when you navigate |
| 210 | + |
| 211 | +- **In-flight wizard data lives entirely in the client-held state blob** |
| 212 | + (unsigned today, signed once A1 lands) — not on the server, not in a |
| 213 | + database. If the |
| 214 | + user closes the tab mid-wizard, everything they entered is gone unless |
| 215 | + your app persists it somewhere (e.g. autosaving `collected` after each |
| 216 | + step). |
| 217 | +- **Going back doesn't discard anything.** `on_back` only moves |
| 218 | + `step_index`; `collected` is untouched, so `_load_current_step_form_data` |
| 219 | + re-populates the earlier step's fields from what's already stored. |
| 220 | +- **The framework never writes anything server-side.** `on_submit` is where |
| 221 | + *your app* takes `self.state["collected"]` and does something durable |
| 222 | + with it (save a model, kick off a background job, etc.) — component-framework's |
| 223 | + job ends at handing you validated, accumulated data. |
| 224 | + |
| 225 | +## Running it |
| 226 | + |
| 227 | +```bash |
| 228 | +uv run python examples/fastapi_wizard_example.py |
| 229 | +``` |
| 230 | + |
| 231 | +Open `http://localhost:8000` — fill in contact info and target role (try an |
| 232 | +invalid email to see per-field validation), navigate back and confirm your |
| 233 | +answers are still there, then generate on the review step. |
0 commit comments