# 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 :step-data ] (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.