feat(ssr): Phase 6a — session-backed wizard engine (the formtools model)
Builds the reusable multi-step wizard engine the plan front-loads in Phase 6, as
two protocol-free namespaces. This replaces the EDN-snapshot-in-a-hidden-field
round-trip for genuine multi-step flows: per-step validated data lives in the Ring
session and is combined only at the end — only an opaque wizard-id rides in the form.
- components/wizard_state.clj — pure session storage (Django formtools SessionStorage
model): create-wizard!, instance, exists?, current-step, context, step-data,
put-step (REPLACE not merge), set-step, get-all (combine at end), forget. State
namespaced by wizard-id at [:wizards <id> ...]; :context holds read-only step inputs
outside :step-data so it never merges into the result. Each fn is session -> session'.
- components/wizard2.clj — the engine: open-wizard, render-wizard, handle-step-submit,
wizard-form. A wizard is a config map (steps with :decode/:validate/:render/:next,
plus :init-fn/:done-fn/:submit-route). Steps' :render get {wizard-id, current-step,
context, all-data, step-data, errors, request}; nav posts a `direction` field
(next/back/submit). Two routes per wizard (open + submit); the engine threads the
session into the response itself — no wrap-wizard / wrap-decode-multi-form-state stack.
REPL-proven lifecycle (before wiring any modal):
1. OPEN -> seeds session state, renders step 1, form leaks NO accumulated data
2. NEXT -> stores {:info {:name "Acme"}}, advances to :terms
3. INVALID -> re-renders the same step with errors, no advance
4. DONE -> done-fn gets combined {:name "Acme" :days 30} (get-all), instance forgotten
5. BACK -> :terms -> :info, no validation
6. EXPIRED -> unknown wizard-id re-opens fresh instead of 500-ing
Inert infrastructure — nothing imports it yet (Transaction Rule migrates onto it next),
so the e2e suite is unaffected. cljfmt clean. Skill: form-vs-wizard.md updated from
aspirational to the realized engine API + the Phase-6 fit note (Transaction Rule
exercises render/nav/preview; the cross-step merge gets its workout in Phase 7+).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -140,7 +140,52 @@ discarded on completion (`forget`).
|
|||||||
State lives in the Ring session, scoped to true multi-step wizards (plain forms hold
|
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
|
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
|
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
|
pick a durable store.
|
||||||
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
|
## The engine — REALIZED (Phase 6)
|
||||||
implementation.
|
|
||||||
|
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 "<resolved url>"
|
||||||
|
: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 <form>
|
||||||
|
: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.
|
||||||
|
|||||||
142
src/clj/auto_ap/ssr/components/wizard2.clj
Normal file
142
src/clj/auto_ap/ssr/components/wizard2.clj
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
(ns auto-ap.ssr.components.wizard2
|
||||||
|
"Data-driven multi-step wizard engine — no protocols, no defrecords, no middleware
|
||||||
|
stacking. A wizard is a plain *config map*; per-step validated state lives in the Ring
|
||||||
|
session (see `wizard-state`), combined only at the end. Two routes per wizard: open
|
||||||
|
(GET) and submit (POST). Only an opaque `wizard-id` + the `current-step` ride in the
|
||||||
|
form — never the accumulated data, so there is no EDN snapshot to serialize or merge.
|
||||||
|
|
||||||
|
## Config shape
|
||||||
|
|
||||||
|
{:name :vendor ; instance label (for debugging)
|
||||||
|
:form-id \"wizard-form\" ; the <form> id (swap target)
|
||||||
|
:submit-route \"/admin/vendor/wizard\" ; resolved URL the form posts to
|
||||||
|
:form-attrs {...} ; extra <form> attrs (hx-ext, etc.)
|
||||||
|
:init-fn (fn [request] {:context {...} :init-data {step-key data}})
|
||||||
|
:done-fn (fn [all-data request] ring-response) ; called when a step's :next = :done
|
||||||
|
: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) ; renders the step body
|
||||||
|
:next (fn [data] -> next-step-key | :done)}
|
||||||
|
...]}
|
||||||
|
|
||||||
|
The engine wraps each step's body in the wizard <form> (adding the wizard-id /
|
||||||
|
current-step hiddens + hx-post). A step's `:render` receives a ctx map:
|
||||||
|
|
||||||
|
{:wizard-id :current-step :context :all-data :step-data :errors :request :config}
|
||||||
|
|
||||||
|
`:step-data` is the previously-stored data for this step (so editing repopulates), or
|
||||||
|
the just-posted data on a validation re-render. `:all-data` is every step combined so
|
||||||
|
far (handy for a read-only preview/summary step). Navigation buttons post a `direction`
|
||||||
|
field: \"next\" (validate+advance), \"back\" (no validate), \"submit\" (== next, for the
|
||||||
|
last step). See `reference/form-vs-wizard.md`."
|
||||||
|
(:require
|
||||||
|
[auto-ap.ssr.components :as com]
|
||||||
|
[auto-ap.ssr.components.wizard-state :as ws]
|
||||||
|
[auto-ap.ssr.utils :refer [html-response]]))
|
||||||
|
|
||||||
|
(defn- step-by-key [config k]
|
||||||
|
(first (filter #(= (:key %) k) (:steps config))))
|
||||||
|
|
||||||
|
(defn- prev-step
|
||||||
|
"The step key before `k` in the linear step order (or `k` itself if first)."
|
||||||
|
[config k]
|
||||||
|
(let [keys (mapv :key (:steps config))
|
||||||
|
i (.indexOf keys k)]
|
||||||
|
(if (pos? i) (nth keys (dec i)) k)))
|
||||||
|
|
||||||
|
(defn wizard-form
|
||||||
|
"Wrap a step body in the wizard <form>: the form posts to the submit route, and only the
|
||||||
|
wizard-id + current-step ride along (no accumulated data — that lives in the session)."
|
||||||
|
[config wizard-id current-step body]
|
||||||
|
[:form (merge {:id (:form-id config "wizard-form")
|
||||||
|
:hx-post (:submit-route config)
|
||||||
|
:hx-target "this"
|
||||||
|
:hx-swap "outerHTML"}
|
||||||
|
(:form-attrs config))
|
||||||
|
(com/hidden {:name "wizard-id" :value wizard-id})
|
||||||
|
(com/hidden {:name "current-step" :value (name current-step)})
|
||||||
|
body])
|
||||||
|
|
||||||
|
(defn render-wizard
|
||||||
|
"Render the current step's body inside the wizard form. `step-data`/`errors` let a
|
||||||
|
validation re-render show the just-posted values + messages."
|
||||||
|
[{:keys [config wizard-id session request step-errors step-posted]}]
|
||||||
|
(let [cur (ws/current-step session wizard-id)
|
||||||
|
step (step-by-key config cur)
|
||||||
|
ctx {:wizard-id wizard-id
|
||||||
|
:current-step cur
|
||||||
|
:context (ws/context session wizard-id)
|
||||||
|
:all-data (ws/get-all session wizard-id)
|
||||||
|
:step-data (or step-posted (ws/step-data session wizard-id cur))
|
||||||
|
:errors step-errors
|
||||||
|
:request request
|
||||||
|
:config config}]
|
||||||
|
(wizard-form config wizard-id cur ((:render step) ctx))))
|
||||||
|
|
||||||
|
(defn- render-response
|
||||||
|
"html-response of the rendered wizard, with the (possibly updated) session threaded into
|
||||||
|
the Ring response so the session store persists the new wizard state."
|
||||||
|
[config wizard-id session request & [extra]]
|
||||||
|
(-> (html-response (render-wizard (merge {:config config
|
||||||
|
:wizard-id wizard-id
|
||||||
|
:session session
|
||||||
|
:request request}
|
||||||
|
extra)))
|
||||||
|
(assoc :session session)))
|
||||||
|
|
||||||
|
(defn open-wizard
|
||||||
|
"Create a wizard instance in the session and render its first step. `:init-fn` returns
|
||||||
|
{:context ..., :init-data ...} (both optional)."
|
||||||
|
[config request]
|
||||||
|
(let [{:keys [context init-data]} ((:init-fn config) request)
|
||||||
|
first-step (-> config :steps first :key)
|
||||||
|
[id session'] (ws/create-wizard! (:session request) (:name config)
|
||||||
|
{:first-step first-step
|
||||||
|
:context context
|
||||||
|
:init-data init-data})]
|
||||||
|
(render-response config id session' request)))
|
||||||
|
|
||||||
|
(defn- expired-response
|
||||||
|
"The wizard instance is gone from the session (server restart / session expiry / a stale
|
||||||
|
tab). Re-open a fresh wizard rather than 500-ing."
|
||||||
|
[config request]
|
||||||
|
(open-wizard config request))
|
||||||
|
|
||||||
|
(defn handle-step-submit
|
||||||
|
"Submit handler. Reads wizard-id / current-step / direction from the posted form, then:
|
||||||
|
- \"back\": move to the previous step (no validation).
|
||||||
|
- else: decode + validate the current step; on error re-render it with messages;
|
||||||
|
otherwise store the step's data and either advance to `:next` or, when
|
||||||
|
`:next` is :done, call `done-fn` with all combined data and `forget` the
|
||||||
|
instance."
|
||||||
|
[config request]
|
||||||
|
(let [fp (:form-params request)
|
||||||
|
wizard-id (get fp "wizard-id")
|
||||||
|
current-step (keyword (get fp "current-step"))
|
||||||
|
direction (or (get fp "direction") "next")
|
||||||
|
session (:session request)]
|
||||||
|
(cond
|
||||||
|
(not (ws/exists? session wizard-id))
|
||||||
|
(expired-response config request)
|
||||||
|
|
||||||
|
(= direction "back")
|
||||||
|
(render-response config wizard-id
|
||||||
|
(ws/set-step session wizard-id (prev-step config current-step))
|
||||||
|
request)
|
||||||
|
|
||||||
|
:else
|
||||||
|
(let [step (step-by-key config current-step)
|
||||||
|
posted ((:decode step) request)
|
||||||
|
errors (when-let [v (:validate step)] (v posted request))]
|
||||||
|
(if (seq errors)
|
||||||
|
(render-response config wizard-id session request
|
||||||
|
{:step-errors errors :step-posted posted})
|
||||||
|
(let [session' (ws/put-step session wizard-id current-step posted)
|
||||||
|
nxt ((:next step) posted)]
|
||||||
|
(if (= nxt :done)
|
||||||
|
(-> ((:done-fn config) (ws/get-all session' wizard-id) request)
|
||||||
|
(assoc :session (ws/forget session' wizard-id)))
|
||||||
|
(render-response config wizard-id
|
||||||
|
(ws/set-step session' wizard-id nxt)
|
||||||
|
request))))))))
|
||||||
66
src/clj/auto_ap/ssr/components/wizard_state.clj
Normal file
66
src/clj/auto_ap/ssr/components/wizard_state.clj
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
(ns auto-ap.ssr.components.wizard-state
|
||||||
|
"Session-backed storage for multi-step wizards — the Django formtools `SessionStorage`
|
||||||
|
model. Each wizard instance's per-step *validated* data lives in the Ring session under
|
||||||
|
|
||||||
|
[:wizards <wizard-id> :step-data <step-key>]
|
||||||
|
|
||||||
|
and the steps are combined only at the very end via `get-all`. This replaces the
|
||||||
|
EDN-snapshot-in-a-hidden-field round-trip (and its custom readers + merge logic): no
|
||||||
|
data about other steps ever rides through the page — only an opaque `wizard-id` token.
|
||||||
|
|
||||||
|
State is namespaced by `wizard-id` (a random uuid), so concurrent wizards and browser
|
||||||
|
tabs don't collide, and a completed/abandoned wizard is discarded with `forget`.
|
||||||
|
|
||||||
|
These functions are pure: each takes a session map and returns a new session map (or a
|
||||||
|
read). The engine (`wizard2`) threads the returned session into the Ring response; the
|
||||||
|
session store (cookie / durable) then persists it. Nothing here touches global state."
|
||||||
|
(:require
|
||||||
|
[clojure.string :as str]))
|
||||||
|
|
||||||
|
(defn create-wizard!
|
||||||
|
"Seed a fresh wizard instance. Returns `[wizard-id session']`. `opts`:
|
||||||
|
:first-step the step key the wizard opens on (required)
|
||||||
|
:context read-only data the steps need but don't edit (e.g. an entity id) — kept
|
||||||
|
out of :step-data so it never gets merged into the combined result
|
||||||
|
:init-data optional pre-filled per-step data ({step-key data}), e.g. when editing an
|
||||||
|
existing entity so step 1 opens populated.
|
||||||
|
Despite the bang, this only *computes* the next session — it doesn't mutate anything;
|
||||||
|
the caller threads `session'` into its response."
|
||||||
|
[session config-name {:keys [first-step context init-data]}]
|
||||||
|
(let [id (str (java.util.UUID/randomUUID))]
|
||||||
|
[id (assoc-in session [:wizards id]
|
||||||
|
{:config-name config-name
|
||||||
|
:current-step first-step
|
||||||
|
:context (or context {})
|
||||||
|
:step-data (or init-data {})})]))
|
||||||
|
|
||||||
|
(defn instance [session id] (get-in session [:wizards id]))
|
||||||
|
(defn exists? [session id] (boolean (and id (get-in session [:wizards id]))))
|
||||||
|
(defn current-step [session id] (get-in session [:wizards id :current-step]))
|
||||||
|
(defn context [session id] (get-in session [:wizards id :context]))
|
||||||
|
(defn step-data [session id step-key] (get-in session [:wizards id :step-data step-key]))
|
||||||
|
|
||||||
|
(defn put-step
|
||||||
|
"Store (REPLACE, never merge) a step's validated data. Replacing is the whole point —
|
||||||
|
re-submitting a step overwrites that step only; other steps are untouched."
|
||||||
|
[session id step-key data]
|
||||||
|
(assoc-in session [:wizards id :step-data step-key] data))
|
||||||
|
|
||||||
|
(defn set-step
|
||||||
|
"Move the wizard's current step (navigation)."
|
||||||
|
[session id step-key]
|
||||||
|
(assoc-in session [:wizards id :current-step] step-key))
|
||||||
|
|
||||||
|
(defn get-all
|
||||||
|
"Combine every stored step's data into one map (the formtools `get_all_cleaned_data`).
|
||||||
|
Combined only here, at the end — later steps win on key collisions (steps order)."
|
||||||
|
[session id]
|
||||||
|
(->> (get-in session [:wizards id :step-data])
|
||||||
|
vals
|
||||||
|
(apply merge {})))
|
||||||
|
|
||||||
|
(defn forget
|
||||||
|
"Discard the wizard instance (on completion or abandonment) so the session doesn't grow
|
||||||
|
unbounded. Call from the done-fn's response."
|
||||||
|
[session id]
|
||||||
|
(update session :wizards dissoc id))
|
||||||
Reference in New Issue
Block a user