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:
@@ -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
|
|||||||
10–20 routes with stacked middleware — all for a single-step form. That is pure
|
10–20 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)*
|
||||||
|
|||||||
Reference in New Issue
Block a user