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

116 lines
5.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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":
```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.