Capture the proven whole-form hx-select swap method as a reusable skill so every later modal migration is cheaper and consistent. No app code changes. - SKILL.md: the per-migration playbook (classify → baseline → characterize → consolidate render fns → templatize → wire HTMX → collapse routes → verify → commit → feed skill) + Growth contract + non-negotiables. - reference/swap-doctrine.md: the four swap rules, focus invariant, Alpine-survives- swap hardening, target-selector strategy — worked from the real edit.clj swaps (memo no-request, account→location targeted cell, amount→totals sibling-tbody, vendor/mode/row whole-form). 0 OOB. - reference/render-functions.md: explicit-data or top-rooted cursor; the MapCursor fake + transaction-account-row-no-cursor* twin as the smell to remove. - reference/form-vs-wizard.md: classification + the data-driven session-backed (formtools SessionStorage) engine that replaces the snapshot round-trip + protocol. - reference/selmer-conventions.md: STUB, validated in Phase 2. - component-cookbook.md / gotchas.md / test-recipes.md / scorecard.md: seeded from what transaction-edit proves (7 cookbook entries, caret-survival + typeahead test recipes, scorecard baseline LOC 1608 / ~12 routes / 1 no-cursor twin / 2 faked roots / 0 OOB). Scorecard (Transaction Edit baseline, before Phase 2): LOC 1608, routes ~12, no-cursor twins 1, faked-cursor roots 2, snapshot merges ~75, OOB 0, mixed hx- 8.
116 lines
5.4 KiB
Markdown
116 lines
5.4 KiB
Markdown
# Forms vs. wizards (and the data-driven wizard engine)
|
||
|
||
## Classify first
|
||
|
||
| Signal | Classification |
|
||
|--------|----------------|
|
||
| One logical step — even with a `?mode=` toggle, $/% radio, or add/remove rows | **plain form** |
|
||
| The user genuinely advances through ordered steps, each validated before the next | **wizard** |
|
||
| In doubt | **form** |
|
||
|
||
Most "wizards" in this codebase are single-step forms wearing wizard costumes: they
|
||
implement the multi-step protocol (`mm/ModalWizardStep` + friends), serialize an EDN
|
||
snapshot into hidden fields, and register 10–20 stacked-middleware routes — all for one
|
||
step. That is pure overhead to delete.
|
||
|
||
## The machinery being replaced
|
||
|
||
`transaction/edit.clj` today still carries the old shape, useful as the "before":
|
||
|
||
```clojure
|
||
(defrecord LinksStep [linear-wizard]
|
||
mm/ModalWizardStep
|
||
(step-name [_] "Transaction Actions")
|
||
(step-key [_] :links)
|
||
(edit-path [_ _] [])
|
||
(step-schema [_] (mm/form-schema linear-wizard))
|
||
(render-step [this {{:keys [snapshot step-params]} :multi-form-state :as request}] ...))
|
||
```
|
||
|
||
…plus the snapshot round-trip: the whole accumulating form state is serialized to hidden
|
||
fields (custom EDN readers), then rebuilt every request by merging the posted pieces back
|
||
into the snapshot (`:multi-form-state :snapshot` is read ~75× in `edit.clj`). The
|
||
serialization needs custom readers, the merge is error-prone, and the payload grows each
|
||
step.
|
||
|
||
---
|
||
|
||
## Single-step → plain form
|
||
|
||
Two routes: `GET` (render) and `POST` (validate + save). State is plain form fields + an
|
||
entity id. No snapshot, no server state, no protocol.
|
||
|
||
```clojure
|
||
{::route/edit (fn [req] (html-response (render-edit-form {:entity (get-entity req)})))
|
||
::route/edit-submit (fn [req] (validate-and-save req))}
|
||
```
|
||
|
||
A `?mode=` toggle is just the `GET` re-rendering with a different query param — still a
|
||
plain form. An add-row interaction is one extra `POST` that appends a fresh row and
|
||
re-renders (the `+1` route).
|
||
|
||
---
|
||
|
||
## 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 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 Ring 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`, `SessionStorage`, `get_all_cleaned_data()`
|
||
> (https://django-formtools.readthedocs.io/en/latest/wizard.html).
|
||
|
||
A wizard is **data**:
|
||
|
||
```clojure
|
||
(def vendor-wizard-config
|
||
{:steps [{:key :info :schema info-schema :fields [...] :render render-info-step
|
||
:next (fn [data] :terms)}
|
||
{:key :terms :schema terms-schema :fields [...] :render render-terms-step
|
||
:next (fn [data] :done)}]
|
||
:init-fn (fn [req] {...})
|
||
:submit-route "/admin/vendor/wizard/submit"
|
||
:done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))})
|
||
```
|
||
|
||
with a tiny engine (no protocols) whose state lives **in the session**, keyed by a wizard
|
||
instance id, each step's data under its own step key — the formtools `SessionStorage`
|
||
model. No snapshot, no custom EDN readers, no merge-into-snapshot:
|
||
|
||
```clojure
|
||
;; Storage backed by the Ring session. Path: [:wizards <wizard-id> :step-data <step-key>]
|
||
(defn create-wizard! [session config]
|
||
(let [id (str (java.util.UUID/randomUUID))]
|
||
[id (assoc-in session [:wizards id]
|
||
{:current-step (-> config :steps first :key) :step-data {}})]))
|
||
|
||
(defn put-step [session id k data] (assoc-in session [:wizards id :step-data k] data)) ; replace, not merge
|
||
(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))
|
||
```
|
||
|
||
The render emits only a **reference token** (`wizard-id`, `current-step`) in the form —
|
||
never the form's state. The submit handler validates the posted step, `put-step`s it,
|
||
computes `:next`, and either advances (`set-step`) or finishes (`get-all` + `:done-fn` +
|
||
`forget`). Every fn returns the updated session for the handler to thread into the Ring
|
||
response (`(assoc resp :session session')`).
|
||
|
||
**Two routes per wizard:** open (`partial open-wizard config`) and submit
|
||
(`partial handle-step-submit config`). State is namespaced by `wizard-id` inside the
|
||
session, so multiple in-flight wizards (and browser tabs) don't collide, and it is
|
||
discarded on completion (`forget`).
|
||
|
||
### Storage lifetime (Open decision 1)
|
||
|
||
State lives in the Ring session, scoped to true multi-step wizards (plain forms hold
|
||
none). Lifetime follows the session; `forget` on completion prevents session bloat. For
|
||
long-lived wizards, confirm the session backend (in-memory vs. durable) is acceptable or
|
||
pick a durable store. **This engine is built in Phase 6** (Transaction Rule) — until then
|
||
this file describes the target; validate `components/wizard_state.clj` +
|
||
`components/wizard2.clj` against it when they land, and update this doc from the real
|
||
implementation.
|