Files
integreat/.claude/skills/ssr-form-migration/reference/form-vs-wizard.md
Bryce 3ecd115f76 docs(skill): distil ssr-form-migration skill from transaction-edit reference (Phase 1)
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.
2026-06-03 00:05:11 -07:00

5.4 KiB
Raw Blame History

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 1020 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":

(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.

{::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:

(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:

;; 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-steps 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.