8.8 KiB
Phase I Trial: Django-Formtools Style Wizard (Server-Side Storage)
Goal
Create a copy of one existing wizard using Approach B (server-side session storage) without modifying any existing multi_modal.clj code. This is an isolated trial to validate the pattern.
Trial Subject: Transaction Bulk Code Wizard
Why this one:
- Single-step form (simplest case)
- Currently uses wizard protocols unnecessarily
- ~420 lines in
transaction/bulk_code.clj - Well-contained with clear inputs/outputs
Architecture
Browser Server
| |
|-- GET /bulk-code-trial -->|
| |-- Create session entry
| |-- Return form with wizard-id
|<-- HTML + wizard-id -----|
| |
|-- POST (wizard-id + -->|
| current-step data) |-- Validate step
| |-- Store in session
| |-- Return next step or done
|<-- HTML -----------------|
Key difference from current approach:
- Current: EDN snapshot serialized in hidden form fields
- Trial: Only
wizard-idand current step fields in form. State lives server-side in atom.
Files (All New - No Existing Files Modified)
src/clj/auto_ap/ssr/components/wizard_trial/
state.clj - Session storage backend
core.clj - Trial wizard engine (minimal)
src/clj/auto_ap/ssr/transaction/
bulk_code_trial.clj - Trial implementation of bulk code
test/clj/auto_ap/ssr/transaction/
bulk_code_trial_test.clj - Tests for trial
Phase I: Trial Implementation (This Week)
Step 1: Create Session Storage
File: src/clj/auto_ap/ssr/components/wizard_trial/state.clj
(ns auto-ap.ssr.components.wizard-trial.state)
(defonce ^:private store (atom {}))
(defn create!
"Creates new wizard session. Returns wizard-id."
[initial-data]
(let [id (str (java.util.UUID/randomUUID))]
(swap! store assoc id {:data initial-data
:created-at (java.util.Date.)})
id))
(defn get-wizard
"Retrieves wizard data by id."
[id]
(get @store id))
(defn update-step!
"Merges step data into wizard session."
[id step-key step-data]
(swap! store assoc-in [id :data step-key] step-data))
(defn get-all-data
"Returns merged data from all steps."
[id]
(-> (get-wizard id)
:data
vals
(apply merge)))
(defn destroy!
"Removes wizard session."
[id]
(swap! store dissoc id))
Step 2: Create Minimal Wizard Engine
File: src/clj/auto_ap/ssr/components/wizard_trial/core.clj
(ns auto-ap.ssr.components.wizard-trial.core
(:require [auto-ap.ssr.components.wizard-trial.state :as ws]
[malli.core :as mc]))
(defn render-step
"Renders a single step form."
[{:keys [wizard-id step-config request]}]
(let [step-data (get-in (ws/get-wizard wizard-id) [:data (:key step-config)])]
[:form {:hx-post (:submit-route step-config)
:hx-target "this"
:hx-swap "outerHTML"}
[:input {:type "hidden" :name "wizard-id" :value wizard-id}]
[:input {:type "hidden" :name "step-key" :value (name (:key step-config))}]
((:render step-config) (assoc request :step-data step-data))]))
(defn handle-submit
"Handles step submission."
[step-config request]
(let [{:keys [wizard-id step-key]} (:form-params request)
wizard-id (str wizard-id)
step-key (keyword step-key)
step-data (select-keys (:form-params request) (:fields step-config))]
(if-let [errors (mc/explain (:schema step-config) step-data)]
;; Validation failed - re-render with errors
(render-step {:wizard-id wizard-id
:step-config step-config
:request (assoc request :errors errors)})
;; Success - save and done (single step for trial)
(let [all-data (ws/get-all-data wizard-id)]
(ws/destroy! wizard-id)
((:done-fn step-config) all-data request)))))
Step 3: Create Trial Bulk Code Form
File: src/clj/auto_ap/ssr/transaction/bulk_code_trial.clj
(ns auto-ap.ssr.transaction.bulk-code-trial
(:require [auto-ap.ssr.components.wizard-trial.core :as wt]
[auto-ap.ssr.components.wizard-trial.state :as ws]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.utils :refer [html-response]]
[auto-ap.datomic :refer [conn pull-attr]]
[datomic.api :as dc]))
(def bulk-code-schema
[:map
[:vendor {:optional true} [:maybe int?]]
[:approval-status {:optional true} [:maybe keyword?]]
[:accounts {:optional true}
[:vector {:coerce? true}
[:map
[:account int?]
[:location :string]
[:percentage :double]]]]]])
(defn render-bulk-code-form
"Pure function - renders the form given data."
[{:keys [step-data errors]}]
[:div.bulk-code-trial
[:h2 "Bulk Code (Trial - Phase I)"]
[:div.space-y-4
;; Vendor field
[:div
[:label "Vendor"]
(com/typeahead {:name "vendor"
:value (:vendor step-data)
:url "/api/vendors/search"})]
;; Accounts table
[:div
[:h3 "Accounts"]
[:table
[:thead
[:tr
[:th "Account"]
[:th "Location"]
[:th "%"]]]
[:tbody
(for [[idx account] (map-indexed vector (:accounts step-data []))]
[:tr {:key idx}
[:td (com/typeahead {:name (str "accounts[" idx "][account]")
:value (:account account)})]
[:td (com/text-input {:name (str "accounts[" idx "][location]")
:value (:location account)})]
[:td (com/money-input {:name (str "accounts[" idx "][percentage]")
:value (:percentage account)})]])]]]
;; Submit
[:button {:type "submit"} "Apply Bulk Code"]]])
(def trial-step-config
{:key :bulk-code
:schema bulk-code-schema
:fields [:vendor :approval-status :accounts]
:render render-bulk-code-form
:submit-route "/transaction/bulk-code-trial"
:done-fn (fn [data request]
;; Apply bulk coding logic here
(html-response [:div.success "Bulk code applied! (Trial)"]))})
;; Route handlers
(defn open-trial [request]
(let [wizard-id (ws/create! {:accounts []})]
(wt/render-step {:wizard-id wizard-id
:step-config trial-step-config
:request request})))
(defn submit-trial [request]
(wt/handle-submit trial-step-config request))
Step 4: Add Routes
In your routes file (new entries, don't modify existing):
{::route/bulk-code-trial open-trial
::route/bulk-code-trial-submit submit-trial}
Phase II: Expand Trial (If Phase I Works)
Goal: Test with a multi-step wizard
Subject: New Invoice Wizard (2-3 steps)
- Step 1: Basic details
- Step 2: Accounts (conditional)
- Step 3: Submit
New additions to trial engine:
- Step navigation (next/prev)
- Conditional steps (skip accounts if not customizing)
- Step validation per-step
- Progress indicator
Files to create:
src/clj/auto_ap/ssr/invoice/
new_invoice_trial.clj
Phase III: Full Migration Decision (If Phase II Works)
Goal: Decide whether to migrate all wizards or keep both systems
Evaluation criteria:
- ✅ Line count reduction (target: 50%+)
- ✅ Testability (pure functions easier to test)
- ✅ Performance (server-side storage vs EDN serialization)
- ✅ Complexity (fewer protocols/middleware)
- ⚠️ Session handling (what happens on server restart?)
- ⚠️ Multiple tabs (can user have two wizards open?)
Decision matrix:
| Criteria | Current | Trial | Winner |
|---|---|---|---|
| Lines of code | ~8,200 | ~3,000 (est.) | Trial |
| Server restarts | Survives (state in form) | Loses state | Current |
| Multiple tabs | Works (independent forms) | Needs separate IDs | Tie |
| Testability | Hard (cursor context) | Easy (pure functions) | Trial |
| Complex merges | Painful | Simple (keyed steps) | Trial |
If trial wins: Migrate all wizards using Phase II pattern If mixed: Use trial for simple forms, keep current for complex multi-step
How to Run the Trial
- Start server:
lein run - Navigate to:
/transaction/bulk-code-trial - Test: Fill form, submit, verify state handling
- Compare: Open existing
/transaction/bulk-codein another tab - Evaluate: Which feels simpler? Which is easier to debug?
Success Criteria for Phase I
- Trial renders without errors
- Form submission validates correctly
- Server-side state persists across requests
- No modifications to existing multi_modal.clj
- Code is < 200 lines (vs 420 original)
- Developer can understand flow in 5 minutes
Ready to implement Phase I?