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
|
||||
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 "<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.
|
||||
|
||||
Reference in New Issue
Block a user