docs: multi-step wizards use session-stored step state (Django formtools)

Replace the EDN snapshot + piecewise merge for multi-step wizards with per-step
form state stored in the session, combined only at the end -- the Django
formtools WizardView / SessionStorage model. Cite the inspiration and refs.

Adds rationale 2.4, reworks the engine snippet in 3.3 to thread session state
keyed by wizard-id (no snapshot, no merge), and updates goal 3, the Phase 6
engine tasks, the risk row, and Open decision 1 accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 22:09:40 -07:00
parent 917b7f3857
commit 0e02c489e0

View File

@@ -28,7 +28,9 @@ skill* that makes the next migration cheaper.
position.) position.)
3. **Stop forcing single-step forms through wizard machinery.** Most "wizards" 3. **Stop forcing single-step forms through wizard machinery.** Most "wizards"
are single-step; they become plain forms. Genuine multi-step flows use a are single-step; they become plain forms. Genuine multi-step flows use a
small data-driven engine instead of protocols + middleware stacking. small data-driven engine instead of protocols + middleware stacking, and
**store each step's data in the session** (combined only at the end) instead
of round-tripping and merging an EDN snapshot — the Django `formtools` model.
4. **Render HTML with Selmer templates** (Jinja-style) instead of Hiccup for the 4. **Render HTML with Selmer templates** (Jinja-style) instead of Hiccup for the
interactive, attribute-heavy components, so Alpine/HTMX attributes are interactive, attribute-heavy components, so Alpine/HTMX attributes are
first-class HTML rather than a mix of Clojure keywords and strings. first-class HTML rather than a mix of Clojure keywords and strings.
@@ -83,7 +85,15 @@ serialize an EDN snapshot with custom readers into hidden fields, and register
1020 routes with stacked middleware — all for a single-step form. That is pure 1020 routes with stacked middleware — all for a single-step form. That is pure
overhead. overhead.
### 2.4 Hiccup makes Alpine/HTMX attributes ambiguous ### 2.4 Multi-step wizards round-trip and merge a snapshot
The genuine multi-step wizards carry the whole accumulating form state as an EDN
snapshot in hidden fields, then rebuild it each request by merging the posted
pieces back into the snapshot. The serialization needs custom readers, the merge
logic is error-prone, and the page payload grows with every step. The fix is to
**store each step's data in the session under its own key and combine only at the
end** — the Django `formtools` model (§3.3) — so no snapshot is built or merged.
### 2.5 Hiccup makes Alpine/HTMX attributes ambiguous
The same attribute is sometimes a keyword and sometimes a string in the same The same attribute is sometimes a keyword and sometimes a string in the same
file, and event handlers must be strings while structural Alpine attrs are file, and event handlers must be strings while structural Alpine attrs are
keywords. There is no rule a reader (or an LLM) can rely on: keywords. There is no rule a reader (or an LLM) can rely on:
@@ -220,7 +230,21 @@ deep node, and never keep a second `*-no-cursor*` copy of the markup.
::route/edit-submit (fn [req] (validate-and-save req))} ::route/edit-submit (fn [req] (validate-and-save req))}
``` ```
- **Genuinely multi-step → data-driven engine.** A wizard is *data*: - **Genuinely multi-step → data-driven engine with session-stored step state.**
> **Inspiration — Django `formtools` `WizardView`.** Django's wizard does *not*
> round-trip a serialized blob of the whole form through the page. Each step's
> validated (cleaned) data is written to a **storage backend (the user session
> by default)** under that step's key, and the steps are combined only at the
> very end via `get_all_cleaned_data()`. We adopt the same model: **replace the
> EDN snapshot + piecewise merging with per-step form state stored in the
> session.** A step writes its own data under its own key; nothing is merged
> into a snapshot and nothing about other steps rides through the form.
> Refs: `formtools.wizard.views.WizardView`, its `storage` backends
> (`SessionStorage`), and `get_all_cleaned_data()`
> (https://django-formtools.readthedocs.io/en/latest/wizard.html).
A wizard is *data*:
```clojure ```clojure
(def vendor-wizard-config (def vendor-wizard-config
{:steps [{:key :info :schema info-schema :fields [...] :render render-info-step {:steps [{:key :info :schema info-schema :fields [...] :render render-info-step
@@ -231,41 +255,57 @@ deep node, and never keep a second `*-no-cursor*` copy of the markup.
:submit-route "/admin/vendor/wizard/submit" :submit-route "/admin/vendor/wizard/submit"
:done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))}) :done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))})
``` ```
with a tiny engine (no protocols) and server-side state keyed by a UUID token: with a tiny engine (no protocols) whose state lives **in the session**, keyed
by a wizard instance id, with each step's data stored under its own step key —
the formtools `SessionStorage` model. No snapshot, no custom EDN readers, no
merge-into-snapshot:
```clojure ```clojure
;; state: an atom keyed by wizard-id (add a timestamp + TTL sweep) ;; Storage backed by the Ring session (replaces the hidden EDN snapshot).
(defonce ^:private store (atom {})) ;; Path in session: [:wizards <wizard-id> :step-data <step-key>]
(defn create-wizard! [init] (let [id (str (java.util.UUID/randomUUID))] (defn create-wizard! [session config]
(swap! store assoc id {:current-step (-> init :steps first :key) (let [id (str (java.util.UUID/randomUUID))]
:step-data {} :created-at (System/currentTimeMillis)}) [id (assoc-in session [:wizards id]
id)) {:current-step (-> config :steps first :key) :step-data {}})]))
(defn update-step! [id k data] (swap! store update-in [id :step-data k] merge data))
(defn get-all [id] (apply merge (vals (:step-data (@store id)))))
(defn render-wizard [{:keys [wizard-id config request]}] (defn put-step [session id k data] (assoc-in session [:wizards id :step-data k] data)) ; replace, not merge
(let [{:keys [current-step step-data]} (@store wizard-id) (defn set-step [session id k] (assoc-in session [:wizards id :current-step] k))
(defn get-all [session id] (->> (get-in session [:wizards id :step-data]) vals (apply merge)))
(defn forget [session id] (update session :wizards dissoc id))
(defn render-wizard [{:keys [wizard-id config session request]}]
(let [{:keys [current-step step-data]} (get-in session [:wizards wizard-id])
step (first (filter #(= (:key %) current-step) (:steps config)))] step (first (filter #(= (:key %) current-step) (:steps config)))]
[:form#wizard-form {:hx-post (:submit-route config) [:form#wizard-form {:hx-post (:submit-route config)
:hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"} :hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"}
;; only a reference token rides in the form -- not the form's state
(com/hidden {:name "wizard-id" :value wizard-id}) (com/hidden {:name "wizard-id" :value wizard-id})
(com/hidden {:name "current-step" :value (name current-step)}) (com/hidden {:name "current-step" :value (name current-step)})
((:render step) (assoc request :step-data (get step-data current-step {})))])) ((:render step) (assoc request :step-data (get step-data current-step {})))]))
(defn handle-step-submit [config request] ;; Handlers thread the (possibly updated) session back into the Ring response.
(defn handle-step-submit [config {:keys [session] :as request}]
(let [{:strs [wizard-id current-step]} (:form-params request) (let [{:strs [wizard-id current-step]} (:form-params request)
step (first (filter #(= (:key %) (keyword current-step)) (:steps config))) step (first (filter #(= (:key %) (keyword current-step)) (:steps config)))
data (select-keys (:form-params request) (map name (:fields step)))] data (select-keys (:form-params request) (map name (:fields step)))]
(if-let [errors (mc/explain (:schema step) data)] (if-let [errors (mc/explain (:schema step) data)]
(render-wizard {:wizard-id wizard-id :config config :request (assoc request :errors errors)}) (-> (render-wizard {:wizard-id wizard-id :config config :session session
(do (update-step! wizard-id (keyword current-step) data) :request (assoc request :errors errors)})
(let [nxt ((:next step) data)] html-response)
(if (= nxt :done) (let [session' (put-step session wizard-id (keyword current-step) data)
(let [all (get-all wizard-id)] (swap! store dissoc wizard-id) ((:done-fn config) all request)) nxt ((:next step) data)]
(do (swap! store assoc-in [wizard-id :current-step] nxt) (if (= nxt :done)
(render-wizard {:wizard-id wizard-id :config config :request request})))))))) (-> ((:done-fn config) (get-all session' wizard-id) request) ; combine only at the end
(assoc :session (forget session' wizard-id)))
(let [session'' (set-step session' wizard-id nxt)]
(-> (html-response (render-wizard {:wizard-id wizard-id :config config
:session session'' :request request}))
(assoc :session session''))))))))
``` ```
Two routes per wizard: open (`partial open-wizard config`) and submit Two routes per wizard: open (`partial open-wizard config`) and submit
(`partial handle-step-submit config`). (`partial handle-step-submit config`). State is namespaced by `wizard-id` inside
the session, so multiple in-flight wizards (and tabs) don't collide, and it is
discarded on completion (`forget`). See Open decision 1 for the storage-backend
choice (Ring session store vs. a durable store for long-lived wizards).
### 3.4 Selmer templates ### 3.4 Selmer templates
@@ -556,15 +596,22 @@ apply it cold." Single-step form currently wearing a wizard costume.
### Phase 6 — Build the wizard engine + migrate Transaction Rule (2-step wizard) ### Phase 6 — Build the wizard engine + migrate Transaction Rule (2-step wizard)
**Rationale:** the first genuinely multi-step modal, and the simplest one — the **Rationale:** the first genuinely multi-step modal, and the simplest one — the
right place to introduce the data-driven engine (§3.3) and server-side state. right place to introduce the data-driven engine (§3.3) and **session-stored
per-step state** (the Django `formtools` model), replacing the EDN snapshot +
merge.
**Engine (do once, here):** **Engine (do once, here):**
- [ ] Create `components/wizard_state.clj` (atom store, `create-wizard!`, - [ ] Create `components/wizard_state.clj` backed by the **Ring session**:
`update-step!`, `get-all`, `destroy!`, **TTL sweep** for abandoned wizards). `create-wizard!`, `put-step` (replace step data, do **not** merge into a
Test the lifecycle via REPL. snapshot), `set-step`, `get-all` (combine only at the end), `forget`. State is
namespaced by `wizard-id` inside the session (`[:wizards <id> ...]`) so tabs
and concurrent wizards don't collide. Each fn returns the updated session for
the handler to thread into the Ring response. Test the lifecycle via REPL.
- [ ] Create `components/wizard2.clj` (`render-wizard`, `handle-step-submit`, - [ ] Create `components/wizard2.clj` (`render-wizard`, `handle-step-submit`,
`open-wizard`). Test render + step navigation. `open-wizard`) — engine threads session through and only `wizard-id` rides in
- [ ] Document the engine usage in `reference/form-vs-wizard.md`. the form. Test render + step navigation + that no snapshot is emitted.
- [ ] Document the engine usage and the formtools inspiration in
`reference/form-vs-wizard.md`.
**Modal migration (run the §8 loop), specifics:** **Modal migration (run the §8 loop), specifics:**
- [ ] Extract `render-edit-step` and `render-test-step` (the test step shows a - [ ] Extract `render-edit-step` and `render-test-step` (the test step shows a
@@ -663,7 +710,7 @@ matches, emails, contact methods). Deliberately last, when the skill is richest.
| Risk | Mitigation | | Risk | Mitigation |
|------|------------| |------|------------|
| Server restart loses in-flight wizard state | Server state only for true multi-step wizards; forms hold none. TTL + sweep; consider a durable store if a wizard is long-lived. | | In-flight wizard state lost (restart / session expiry) | State lives in the session (formtools model), scoped to true multi-step wizards; plain forms hold none. Lifetime follows the session; for long-lived wizards choose a durable session backend or store (Open decision 1). `forget` on completion prevents session bloat. |
| Mixed Hiccup/Selmer interop gets messy | Build + prove the interop bridge in Phase 2 before broad use; strangler keeps both valid. | | Mixed Hiccup/Selmer interop gets messy | Build + prove the interop bridge in Phase 2 before broad use; strangler keeps both valid. |
| Selmer loses Hiccup's structural testability | Lean on e2e + DB assertions; unit-test the data-prep functions, not markup. | | Selmer loses Hiccup's structural testability | Lean on e2e + DB assertions; unit-test the data-prep functions, not markup. |
| Large files hide behavior (`clients.clj`, `edit.clj`) | They go last, after the skill is rich; characterization e2e first; split per step. | | Large files hide behavior (`clients.clj`, `edit.clj`) | They go last, after the skill is rich; characterization e2e first; split per step. |
@@ -676,8 +723,12 @@ matches, emails, contact methods). Deliberately last, when the skill is richest.
## 11. Open decisions ## 11. Open decisions
1. **Server state scope** — server-side state only for multi-step wizards, none 1. **Wizard state storage** — store multi-step state in the **Ring session**
for plain forms? *(recommended: yes)* (Django `formtools` `SessionStorage` model), keyed by `wizard-id`, none for
plain forms? Confirm the session backend in use (in-memory vs. durable) is
acceptable for in-flight wizard lifetime, or pick a durable store for
long-lived flows. *(recommended: session storage, scoped to multi-step
wizards only)*
2. **Selmer scope** — convert only interactive/attribute-heavy components first 2. **Selmer scope** — convert only interactive/attribute-heavy components first
(hybrid), or all SSR files (full sweep)? *(recommended: hybrid, revisit in (hybrid), or all SSR files (full sweep)? *(recommended: hybrid, revisit in
Phase 11)* Phase 11)*