From d56056d66cbbd2f3edc48f3d4ce3161c782fdb01 Mon Sep 17 00:00:00 2001 From: Bryce Date: Thu, 25 Jun 2026 00:29:32 -0700 Subject: [PATCH] =?UTF-8?q?feat(ssr):=20Phase=206a=20=E2=80=94=20session-b?= =?UTF-8?q?acked=20wizard=20engine=20(the=20formtools=20model)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ...]; :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 --- .../reference/form-vs-wizard.md | 53 ++++++- src/clj/auto_ap/ssr/components/wizard2.clj | 142 ++++++++++++++++++ .../auto_ap/ssr/components/wizard_state.clj | 66 ++++++++ 3 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 src/clj/auto_ap/ssr/components/wizard2.clj create mode 100644 src/clj/auto_ap/ssr/components/wizard_state.clj diff --git a/.claude/skills/ssr-form-migration/reference/form-vs-wizard.md b/.claude/skills/ssr-form-migration/reference/form-vs-wizard.md index 213d5942..1d34d4af 100644 --- a/.claude/skills/ssr-form-migration/reference/form-vs-wizard.md +++ b/.claude/skills/ssr-form-migration/reference/form-vs-wizard.md @@ -140,7 +140,52 @@ discarded on completion (`forget`). 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. +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. diff --git a/src/clj/auto_ap/ssr/components/wizard2.clj b/src/clj/auto_ap/ssr/components/wizard2.clj new file mode 100644 index 00000000..7a83f734 --- /dev/null +++ b/src/clj/auto_ap/ssr/components/wizard2.clj @@ -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 id (swap target) + :submit-route \"/admin/vendor/wizard\" ; resolved URL the form posts to + :form-attrs {...} ; extra 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 (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 : 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)))))))) diff --git a/src/clj/auto_ap/ssr/components/wizard_state.clj b/src/clj/auto_ap/ssr/components/wizard_state.clj new file mode 100644 index 00000000..b77bec63 --- /dev/null +++ b/src/clj/auto_ap/ssr/components/wizard_state.clj @@ -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 :step-data ] + + 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))