Skip to content

Commit d269a9a

Browse files
fsecada01claude
andauthored
docs: multi-step wizard recipe + example for FastAPI adapter (#33)
* docs: multi-step wizard recipe + example for FastAPI adapter (closes #29) Adds a runnable 3-step wizard example (ResumeWizard) built as a single stateful FormComponent that swaps its active schema per step, since CompositeComponent doesn't exist in the codebase despite being listed as a shipped Beta feature. Flags the unsigned-state and missing-CSRF gaps (Epic A1/A4) that a production wizard depends on. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPACXz9tygw7WFoseMvE8A * chore: fix pre-existing trailing whitespace in market research doc Unrelated to the wizard-recipe work in this PR — CI's prek run lints all files, not just the diff, and this pre-dated the branch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPACXz9tygw7WFoseMvE8A --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 9c8e0b9 commit d269a9a

5 files changed

Lines changed: 642 additions & 2 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ Generated from docstrings by pdoc and deployed to GitHub Pages on every push to
239239
| [Django Implementation](docs/DJANGO_IMPLEMENTATION.md) | Django adapter setup and patterns |
240240
| [Class-Based Views](docs/CBV_GUIDE.md) | CBV auth/permission patterns |
241241
| [E-Commerce Example](docs/examples/ecommerce.md) | Real-time cart + product demo |
242+
| [Multi-Step Wizard](docs/examples/wizard.md) | FastAPI wizard recipe (unsigned state — pending Epic A1) |
242243

243244
### AI / LLM Context
244245

@@ -404,6 +405,7 @@ component-framework/
404405
405406
├── examples/
406407
│ ├── fastapi_example.py # FastAPI demo
408+
│ ├── fastapi_wizard_example.py # Multi-step wizard demo
407409
│ ├── litestar_example.py # Litestar demo
408410
│ └── django_example/ # Complete Django app
409411
@@ -437,7 +439,9 @@ component-framework/
437439
│ ├── docs_settings.py # Minimal Django settings for pdoc
438440
│ ├── pdoc_templates/ # Custom pdoc templates (terminal brutalism)
439441
│ ├── update_gh_pages.py # CI helper: versions.json + root index
440-
│ └── examples/ecommerce.md # Real-time e-commerce walkthrough
442+
│ └── examples/
443+
│ ├── ecommerce.md # Real-time e-commerce walkthrough
444+
│ └── wizard.md # Multi-step wizard recipe (FastAPI)
441445
442446
├── .github/workflows/
443447
│ ├── ci.yml # Tests, lint, type check (Python 3.11–3.14)

claudedocs/research_liveview_market_2026-06-24.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ Below: the production-grade checklist synthesized from Phoenix LiveView (gold st
208208

209209
## 5. Competitor Weaknesses to Exploit (Confidence: Medium-High)
210210

211-
- **Reflex** ($5M seed): compiles to a Next.js SPA with a **mandatory live WS to Python** (scaling/latency), steep learning curve, **standalone (can't drop into an existing app)**, dashboard-leaning, Pydantic-v1 internals → **Python 3.14 build failures** (#5964).
211+
- **Reflex** ($5M seed): compiles to a Next.js SPA with a **mandatory live WS to Python** (scaling/latency), steep learning curve, **standalone (can't drop into an existing app)**, dashboard-leaning, Pydantic-v1 internals → **Python 3.14 build failures** (#5964).
212212
- **Django Unicorn / Tetra / Reactor / Sockpuppet****Django-only**; several require Channels/Redis; small bus factors; "unknown" production maturity.
213213
- **FastUI** — Pydantic-backed but **officially dormant** (#368) — a *funded project that stalled*, leaving the segment open.
214214
- **ReactPy** — every interaction is a server round-trip + VDOM diff → less responsive than client React.

docs/examples/wizard.md

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
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

Comments
 (0)