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>
67 lines
3.2 KiB
Clojure
67 lines
3.2 KiB
Clojure
(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))
|