# 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. > **Done — Transaction Edit is now a plain form.** `LinksStep`/`EditWizard` and all `mm/*` > usage were deleted from `transaction/edit.clj`; the worked example below is realized, not > aspirational. See "Single-step → plain form (realized)". ## The machinery being replaced The old shape (kept here 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). ### Single-step → plain form (realized: Transaction Edit) What replacing the wizard actually looked like, end to end: 1. **Delete the records + middleware.** `EditWizard`/`LinksStep`, `mm/open-wizard-handler`, `mm/next-handler`, `mm/submit-handler`, `mm/wrap-wizard`, `mm/wrap-decode-multi-form-state`, and the `edit-wizard-navigate` route all go. `render-step` becomes a plain `render-form`. 2. **Rename the fields off `step-params[...]`.** Field names are now the schema path directly (`(path->name2 :transaction/accounts 0 :transaction-account/account)` → `transaction/accounts[0][transaction-account/account]`). They decode straight into the form schema via the unchanged `wrap-nested-form-params` + `mc/decode` — no two-key snapshot/step-params decode. **Strip stray keys after decode** (`select-keys` to the schema's keys) or a non-schema input like the tab group's `method` hidden 500s the save (see `gotchas.md`). 3. **Flat state.** `wrap-derive-state` builds a plain `{:snapshot :edit-path :step-params}` map (not the `MultiStepFormState` record): `entity-only` fields from the entity, editable fields from the live posted form (absent = cleared). The ~34 `:snapshot` reads keep working. 4. **Validation/error flow without `wrap-ensure-step`.** Reuse the generic `wrap-form-4xx-2` directly: `(-> submit-edit (wrap-form-4xx-2 render-form-response) …)`. `submit-edit` runs `assert-schema` then dispatches the save; on a throw, `wrap-form-4xx-2` re-renders the whole form with `:form-errors` keyed by schema paths. A `*errors*` dynamic var (bound by `render-form`) replaces the form-cursor's `*form-errors*` for field lookups. 5. **Routes shrink to** `edit-wizard` (GET open), `edit-submit` (POST), `edit-form-changed` (POST whole-form re-render for dependent changes), `location-select` (GET), `unlink-payment` (POST). --- ## 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. ## The engine — REALIZED (Phase 6) Built and REPL-proven in Phase 6 as two namespaces (no protocols, no defrecords): - **`auto-ap.ssr.components.wizard-state`** — the pure session-storage layer (the skeleton above, fleshed out): `create-wizard!` / `instance` / `exists?` / `current-step` / `context` / `step-data` / `put-step` (replace) / `set-step` / `get-all` / `forget`. Each is `session -> session'` (or a read); nothing mutates global state. `:context` holds read-only data the steps need (e.g. an entity id) **outside** `:step-data`, so it never gets merged into the combined result. - **`auto-ap.ssr.components.wizard2`** — the engine: `open-wizard`, `render-wizard`, `handle-step-submit`, and the `wizard-form` shell. A wizard is a **config map**: ```clojure {:name :vendor :form-id "wizard-form" :submit-route "" :init-fn (fn [request] {:context {...} :init-data {step-key data}}) :done-fn (fn [all-data request] ring-response) :steps [{:key :info :decode (fn [request] -> data-map) ; parse this step's posted fields :validate (fn [data request] -> errors|nil) ; optional :render (fn [ctx] -> hiccup) ; step body; engine wraps the
:next (fn [data] -> next-step-key | :done)} ...]} ``` The step's `:render` gets `{:wizard-id :current-step :context :all-data :step-data :errors :request :config}`. `:all-data` (every step combined so far) is exactly what a **read-only summary/preview step** consumes. Nav buttons post a `direction` field: `"next"` (validate+advance via `:next`), `"back"` (no validate), `"submit"` (== next, for the last step). Only `wizard-id` + `current-step` ride in the form — **no snapshot**. **Two routes per wizard:** `(partial open-wizard config)` (GET) and `(partial handle-step-submit config)` (POST). No `wrap-wizard` / `wrap-decode-multi-form-state` stack — the engine threads the session itself and `(assoc resp :session session')`. **Proven via REPL** (lifecycle, before any modal used it): open seeds session state and renders step 1 with no accumulated data in the form; next stores `{step-key data}` and advances; an invalid step re-renders itself with errors (no advance); the final step's `:done` calls `done-fn` with the combined `get-all` data and `forget`s the instance; back navigates without validating; an unknown/expired `wizard-id` re-opens fresh instead of 500-ing. See the lifecycle eval in the Phase 6 commit message. **Note (Phase 6 fit).** Transaction Rule itself is *edit + read-only preview of one entity*, not a true multi-data-step flow — so it exercises the engine's render/navigation/ preview path (`:all-data` feeds the test table) but not the cross-step *merge*. The merge (`get-all` combining independent steps) gets its real workout in Phase 7+ (Invoice Pay, New Invoice, Vendor, Client), where steps collect genuinely different fields. ## Conditional `:next` + dual-purpose (new+edit) — New Invoice (Phase 8) A step's `:next` is just `(fn [data] -> next-step-key | :done)`, so **branching the flow is a one-liner** — no `CustomNext` protocol, no 308-redirect-to-submit hack: ```clojure {:key :basic-details :next (fn [data] (if (= :customize (:customize-accounts data)) :accounts :done))} ``` `:default` skips the expense-accounts step entirely (the done-fn uses the vendor's default account); `:customize` routes through the grid. The old wizard expressed this with `mm/CustomNext` returning either `navigate-handler{:to :accounts}` or a 308 to the submit route — and the 308 path was broken (see `gotchas.md`, the `{}`→nil 500). The engine's conditional `:next` is both simpler and correct. **Dual-purpose (create *and* edit) = one config, one `:init-fn` that branches on a route id:** ```clojure (defn new-init-fn [request] (if-let [id (->db-id (get-in request [:route-params :db/id]))] {:init-data {:basic-details (… entity prefilled, :customize-accounts :customize) :accounts {:invoice/expense-accounts (… existing rows)}}} ; edit {:init-data {:basic-details {:invoice/date (coerce/to-date (time/now)) ; new :customize-accounts :default}}})) ``` `create-wizard!` stores `:init-data` **as the per-step `:step-data` map directly**, so seeding `{:basic-details … :accounts …}` opens both steps populated — the edit case repopulates the grid without a separate hydrate. Two open routes (`new-wizard`, `edit-wizard`) both reduce to `(partial wizard2/open-wizard config)`; the done-fn branches on `(:db/id all-data)` to return the next-steps modal (create) vs the swapped table row (edit). **Async step fragments read the posted form, not multi-form-state.** The basic-details fragments (account-prediction radio, due-date / scheduled-payment suggestions) and the accounts totals all post the whole `#wizard-form`; in the engine that form carries the flat `invoice/*` fields + the opaque `wizard-id`, so a fragment decodes what it needs straight from `form-params` (and, for a cross-step value like the invoice total on the accounts step, reads `ws/get-all` via the posted `wizard-id`). No `mm/wrap-decode-multi-form-state` stack survives. ## Sub-editor: a parameterized sub-step on the linear engine (Phase 10, bank accounts) The engine's steps are a flat list — it has no nested/parameterized step like the old mm `[:bank-account which]`. When a step owns a *collection you edit one item at a time* (a list view ⇄ a per-item editor, with accept/discard/sort), don't try to bend the step list. Model it as a **sub-editor of that step**, entirely in whole-form swaps: - **The step renders the list view** (cards/rows + an "add" affordance). Each item's edit/new control is an `hx-get` that targets `#wizard-form` with `hx-swap outerHTML` and carries `?wizard-id=&index=N` (the wizard-id is in the render ctx). - **The editor is its own ``** (so it swaps cleanly and the next swap replaces it) with the item's fields + hidden `wizard-id` + a hidden item index. Its Accept `hx-post`s an accept route; Discard `hx-get`s a discard route. It is NOT a wizard step and does NOT go through `handle-step-submit`. - **Dedicated routes mutate the step's data in the session directly** and re-render the list via the engine: read `(ws/step-data session wid )`, splice the decoded item into the vector (`assoc` at index, or `conj` to append for new), `ws/put-step`, then `(wizard2/render-wizard {:config … :wizard-id wid :session session' :request request})` and `(assoc :session session')`. Discard just re-renders from the unchanged session. - **The step's own `:decode` is a pass-through.** Because the list lives in the session (managed by the sub-editor, not by in-form inputs), the step's Next must re-affirm it, not decode it from a near-empty form. Read it back with the wizard-id — but the engine strips `wizard-id`/`current-step`/`direction` from form-params before `:decode`, so smuggle it through an extra hidden the engine leaves alone (we used `wiz`): `(or (ws/step-data (:session request) (get-in request [:form-params "wiz"]) ) {…})`. - Give the step a no-op `:validate` (`(fn [_ _] nil)`) — items are validated on Accept. - Clean control keys out of the decoded item before storage (`select-keys` to `:db/id` + the entity's own namespace) so `wizard-id`/index/`:new?` never reach datomic. This keeps the doctrine intact (every byte is a whole-form swap of `#wizard-form`; no EDN snapshot rides the page) while giving the linear engine an add/edit/sort sub-flow it has no native concept for.