47 KiB
Wizard System Refactor Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the complex MultiStepFormState protocol-based wizard system with a simpler data-driven approach using server-side storage and pure render functions.
Architecture: Each wizard is defined as data ({:steps [...], :done-fn ...}). Step data is stored server-side by wizard-id. Steps own their complete schemas. Render functions are pure and context-independent.
Tech Stack: Clojure, Malli, HTMX, Alpine.js, Datomic (for storage)
File Structure
New files:
src/clj/auto_ap/ssr/components/wizard2.clj— New wizard engine (replaces multi_modal.clj)src/clj/auto_ap/ssr/components/wizard_state.clj— Server-side state storagetest/clj/auto_ap/ssr/components/wizard2_test.clj— Tests for new wizard engine
Modified files (ALL wizard instances in codebase):
src/clj/auto_ap/ssr/components/multi_modal.clj— Mark deprecated, add compatibility shimsrc/clj/auto_ap/ssr/common_handlers.clj— Update add-new-entity-handler for pure functionssrc/clj/auto_ap/ssr/invoice/new_invoice_wizard.clj— NewInvoiceWizard (NewWizard2) — ~850 linessrc/clj/auto_ap/ssr/transaction/edit.clj— TransactionEditWizard (EditWizard) — ~1500 lines, simple/advanced mode togglesrc/clj/auto_ap/ssr/transaction/bulk_code.clj— BulkCodeWizard — ~420 linessrc/clj/auto_ap/ssr/pos/sales_summaries.clj— SalesSummaryEditWizard (EditWizard) — ~780 linessrc/clj/auto_ap/ssr/invoices.clj— PayWizard + BulkEditWizard — ~1800 lines totalsrc/clj/auto_ap/ssr/admin/vendors.clj— VendorWizard — ~917 linessrc/clj/auto_ap/ssr/admin/clients.clj— ClientWizard — ~1913 linessrc/clj/auto_ap/ssr/admin/transaction_rules.clj— TransactionRuleWizard — ~1005 lines
Total: 8 wizard implementations across 8 files, ~8,200 lines of wizard code
Form-Cursor Changes
Current Problem: Form-cursor uses dynamic bindings (*form-data*, *current*, *prefix*) which:
- Make functions context-dependent (need
transaction-account-row-no-cursor*variants) - Are hard to test (must set up binding context)
- Create deeply nested code with
fc/with-fieldmacro chains - Don't work well when HTMX swaps partial content
New Approach:
- Pure functions are primary API - All render functions take explicit data maps
- Form-cursor becomes optional convenience wrapper - Still available for simple cases
- No more "no-cursor" variants - One function works everywhere
BEFORE (cursor-dependent):
(defn invoice-expense-account-row* [{:keys [value client-id]}]
(com/data-grid-row
(fc/with-field :invoice-expense-account/account
(com/data-grid-cell
(account-typeahead* {:value (fc/field-value)
:name (fc/field-name)})))
;; ... deeply nested, requires cursor context
))
;; Duplicate without cursor!
(defn invoice-expense-account-row-no-cursor* [{:keys [account index client-id]}]
;; Same HTML structure, different params
)
AFTER (pure function with optional cursor wrapper):
;; Pure function - works everywhere
(defn invoice-expense-account-row [{:keys [account index client-id]}]
(com/data-grid-row
(com/data-grid-cell
(account-typeahead* {:value (:invoice-expense-account/account account)
:name (str "accounts[" index "][account]")
:client-id client-id}))
;; ... explicit data, no context needed
))
;; Optional cursor wrapper (thin convenience)
(defn invoice-expense-account-row-from-cursor [cursor request]
(invoice-expense-account-row
{:account @cursor
:index (last (cursor/path cursor))
:client-id (:client-id request)}))
Migration Path for Form-Cursor:
- Phase 1: Extract pure functions alongside cursor versions
- Phase 2: Update all callers to use pure functions
- Phase 3: Remove cursor versions (after all callers updated)
- Phase 4: Form-cursor becomes a deprecated convenience library
Task 1: Create Server-Side State Storage
Files:
- Create:
src/clj/auto_ap/ssr/components/wizard_state.clj - Test:
test/clj/auto_ap/ssr/components/wizard_state_test.clj
BEFORE (Current Pain):
;; In multi_modal.clj - Complex state serialization in form
[:form#wizard-form
(fc/with-field :snapshot
(com/hidden {:name (fc/field-name)
:value (pr-str (fc/field-value))})) ; EDN with custom readers!
(fc/with-field :edit-path
(com/hidden {:name (fc/field-name)
:value (pr-str (or edit-path []))}))
(fc/with-field :step-params
;; ... actual form content
)]
AFTER (Target Simplicity):
;; Just a reference token in the form
[:form#wizard-form
(com/hidden {:name "wizard-id" :value wizard-id})
;; Only current step fields render here
]
- Step 1: Write the failing test
(ns auto-ap.ssr.components.wizard-state-test
(:require [clojure.test :refer [deftest testing is]]
[auto-ap.ssr.components.wizard-state :as ws]))
(deftest wizard-state-lifecycle
(testing "Can create and retrieve wizard state"
(let [wizard-id (ws/create-wizard! {:current-step :basic-details
:step-data {}})]
(is (string? wizard-id))
(is (= {:current-step :basic-details :step-data {}}
(ws/get-wizard wizard-id)))))
(testing "Can update step data"
(let [wizard-id (ws/create-wizard! {:current-step :basic-details
:step-data {}})]
(ws/update-step! wizard-id :basic-details {:invoice/client 123})
(is (= {:invoice/client 123}
(get-in (ws/get-wizard wizard-id) [:step-data :basic-details])))))
(testing "Can navigate between steps"
(let [wizard-id (ws/create-wizard! {:current-step :basic-details
:step-data {}})]
(ws/set-current-step! wizard-id :accounts)
(is (= :accounts (:current-step (ws/get-wizard wizard-id)))))))
- Step 2: Run test to verify it fails
Run: clj-nrepl-eval -p PORT "(clojure.test/run-tests 'auto-ap.ssr.components.wizard-state-test)"
Expected: FAIL - namespace doesn't exist
- Step 3: Write minimal implementation
(ns auto-ap.ssr.components.wizard-state
"Server-side storage for wizard state. Uses atom for in-memory store.
In production, replace with Redis/Datomic.")
(defonce ^:private wizard-store (atom {}))
(defn create-wizard!
"Creates a new wizard with initial state. Returns wizard-id."
[initial-state]
(let [wizard-id (str (java.util.UUID/randomUUID))]
(swap! wizard-store assoc wizard-id initial-state)
wizard-id))
(defn get-wizard
"Retrieves wizard state by id. Returns nil if not found."
[wizard-id]
(get @wizard-store wizard-id))
(defn update-step!
"Updates data for a specific step."
[wizard-id step-key step-data]
(swap! wizard-store update-in [wizard-id :step-data step-key]
merge step-data))
(defn set-current-step!
"Changes the current step."
[wizard-id step-key]
(swap! wizard-store assoc-in [wizard-id :current-step] step-key))
(defn get-all-step-data
"Merges all step data for final submission."
[wizard-id]
(let [wizard (get-wizard wizard-id)]
(apply merge (vals (:step-data wizard)))))
(defn destroy-wizard!
"Removes wizard state (call on completion/cancel)."
[wizard-id]
(swap! wizard-store dissoc wizard-id))
- Step 4: Run test to verify it passes
Run: clj-nrepl-eval -p PORT "(clojure.test/run-tests 'auto-ap.ssr.components.wizard-state-test)"
Expected: PASS
- Step 5: Commit
git add src/clj/auto_ap/ssr/components/wizard_state.clj \
test/clj/auto_ap/ssr/components/wizard_state_test.clj
git commit -m "feat(wizard): add server-side state storage for wizards"
Task 2: Create New Wizard Engine (Data-Driven)
Files:
- Create:
src/clj/auto_ap/ssr/components/wizard2.clj - Test:
test/clj/auto_ap/ssr/components/wizard2_test.clj
BEFORE (Current Complexity - 400 lines):
;; multi_modal.clj - Protocols, middleware stacking, complex state
(defprotocol LinearModalWizard
(hydrate-from-request [this request])
(get-current-step [this])
(navigate [this step-key])
(form-schema [this])
(steps [this])
(get-step [this step-key])
(render-wizard [this request])
(submit [this request]))
(defn wrap-wizard [handler linear-wizard]
(fn [request]
(let [current-step-key (if-let [current-step (get (:form-params request) "current-step")]
(mc/decode step-key-schema current-step main-transformer)
(first (steps linear-wizard)))
;; ... complex decode logic
]
(handler (assoc request :wizard (hydrate-from-request linear-wizard request))))))
(defn next-handler
(-> (fn [{:keys [wizard] :as request}]
(let [current-step (get-current-step wizard)]
(if (satisfies? CustomNext current-step)
(custom-next-handler current-step request)
(navigate-handler {:request request :to-step (:to (:query-params request))}))))
(wrap-ensure-step)
(wrap-schema-enforce :query-schema [:map [:to [:maybe step-key-schema]]])))
AFTER (Target Simplicity - ~150 lines):
;; wizard2.clj - Just data and functions
(defn render-wizard [{:keys [wizard-id config request]}]
(let [wizard-state (ws/get-wizard wizard-id)
current-step-key (:current-step wizard-state)
current-step (first (filter #(= (:key %) current-step-key) (:steps config)))
step-data (get-in wizard-state [:step-data current-step-key])]
[:form#wizard-form {:hx-post (:submit-route config)
:hx-target "this"}
(com/hidden {:name "wizard-id" :value wizard-id})
(com/hidden {:name "current-step" :value (name current-step-key)})
((:render current-step) (assoc request :step-data step-data))]))
(defn handle-step-submit [config request]
(let [{:keys [wizard-id current-step]} (:form-params request)
wizard-id (str wizard-id)
step-key (keyword current-step)
step-config (first (filter #(= (:key %) step-key) (:steps config)))
step-data (select-keys (:form-params request) (:fields step-config))]
;; Validate
(if-let [errors (mc/explain (:schema step-config) step-data)]
;; Return errors, re-render current step
(render-wizard {:wizard-id wizard-id :config config
:request (assoc request :errors errors)})
;; Success - save and navigate
(do (ws/update-step! wizard-id step-key step-data)
(let [next-step ((:next step-config) step-data)]
(ws/set-current-step! wizard-id next-step)
(render-wizard {:wizard-id wizard-id :config config :request request}))))))
- Step 1: Write the failing test
(ns auto-ap.ssr.components.wizard2-test
(:require [clojure.test :refer [deftest testing is]]
[auto-ap.ssr.components.wizard2 :as w2]
[auto-ap.ssr.components.wizard-state :as ws]))
(deftest wizard-rendering
(testing "Renders current step with form fields"
(let [wizard-id (ws/create-wizard! {:current-step :basic-details
:step-data {:basic-details {:name "Test"}}})
config {:steps [{:key :basic-details
:schema [:map [:name :string]]
:render (fn [req]
[:div (:name (:step-data req))])
:next (fn [data] :done)}]
:submit-route "/wizard/submit"}
html (w2/render-wizard {:wizard-id wizard-id :config config :request {}})]
(is (string? (str html))) ; Returns hiccup
(is (re-find #"Test" (str html)))))) ; Includes step data
(deftest step-navigation
(testing "Submitting valid data moves to next step"
(let [wizard-id (ws/create-wizard! {:current-step :basic-details
:step-data {}})
config {:steps [{:key :basic-details
:schema [:map [:name :string]]
:render (fn [req] [:div])
:next (fn [data] :done)}]
:submit-route "/wizard/submit"}
request {:form-params {"wizard-id" wizard-id
"current-step" "basic-details"
"name" "Valid Name"}}]
(w2/handle-step-submit config request)
(is (= :done (:current-step (ws/get-wizard wizard-id)))))))
- Step 2: Run test to verify it fails
Run: clj-nrepl-eval -p PORT "(clojure.test/run-tests 'auto-ap.ssr.components.wizard2-test)"
Expected: FAIL
- Step 3: Write minimal implementation
(ns auto-ap.ssr.components.wizard2
(:require [auto-ap.ssr.components.wizard-state :as ws]
[auto-ap.ssr.components :as com]
[malli.core :as mc]))
(defn render-wizard [{:keys [wizard-id config request]}]
(let [wizard-state (ws/get-wizard wizard-id)
current-step-key (:current-step wizard-state)
current-step (first (filter #(= (:key %) current-step-key) (:steps config)))
step-data (get-in wizard-state [:step-data current-step-key] {})]
[:form#wizard-form {:hx-post (:submit-route config)
:hx-target "this"
:hx-swap "outerHTML"}
(com/hidden {:name "wizard-id" :value wizard-id})
(com/hidden {:name "current-step" :value (name current-step-key)})
[:div.wizard-step
((:render current-step) (assoc request :step-data step-data :errors (:errors request)))]]))
(defn handle-step-submit [config request]
(let [{:keys [wizard-id current-step]} (:form-params request)
wizard-id (str wizard-id)
step-key (keyword current-step)
step-config (first (filter #(= (:key %) step-key) (:steps config)))
;; Extract only the fields this step owns
step-data (select-keys (:form-params request)
(map keyword (:fields step-config)))]
(if-let [errors (mc/explain (:schema step-config) step-data)]
;; Validation failed - re-render with errors
(render-wizard {:wizard-id wizard-id :config config
:request (assoc request :errors errors)})
;; Success - save and determine next step
(let [next-step (if-let [next-fn (:next step-config)]
(next-fn step-data)
:done)]
(ws/update-step! wizard-id step-key step-data)
(if (= next-step :done)
;; Final step - call done function
(let [all-data (ws/get-all-step-data wizard-id)]
(ws/destroy-wizard! wizard-id)
((:done-fn config) all-data request))
;; Navigate to next step
(do (ws/set-current-step! wizard-id next-step)
(render-wizard {:wizard-id wizard-id :config config :request request})))))))
(defn open-wizard [config request]
(let [wizard-id (ws/create-wizard! {:current-step (get-in config [:steps 0 :key])
:step-data {}})
initial-data (when-let [init-fn (:init-fn config)]
(init-fn request))]
(when initial-data
(ws/update-step! wizard-id (get-in config [:steps 0 :key]) initial-data))
(render-wizard {:wizard-id wizard-id :config config :request request})))
- Step 4: Run test to verify it passes
Run: clj-nrepl-eval -p PORT "(clojure.test/run-tests 'auto-ap.ssr.components.wizard2-test)"
Expected: PASS
- Step 5: Commit
git add src/clj/auto_ap/ssr/components/wizard2.clj \
test/clj/auto_ap/ssr/components/wizard2_test.clj
git commit -m "feat(wizard): add new data-driven wizard engine"
Task 3: Create Pure Render Function Helpers
Files:
- Modify:
src/clj/auto_ap/ssr/common_handlers.clj - Test: Update existing tests
BEFORE (Cursor-dependent rendering):
;; transaction/edit.clj - Needs cursor context
(defn transaction-account-row* [{:keys [value client-id amount-mode total]}]
(com/data-grid-row
{:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
:accountId (fc/field-value (:transaction-account/account value))})}
(fc/with-field :db/id
(com/hidden {:name (fc/field-name) :value (fc/field-value)}))
(fc/with-field :transaction-account/account
(com/data-grid-cell
(account-typeahead* {:value (fc/field-value)
:name (fc/field-name)})))
;; ... deeply nested cursor context
))
;; And a duplicate without cursor!
(defn transaction-account-row-no-cursor* [{:keys [account index client-id]}]
;; Same HTML but different parameters
)
AFTER (Pure functions):
;; Pure function - no cursor needed
(defn transaction-account-row [{:keys [account index client-id amount-mode]}]
(com/data-grid-row
{:x-data (hx/json {:show true
:accountId (:transaction-account/account account)})}
(com/hidden {:name (str "accounts[" index "][db/id]")
:value (or (:db/id account) "")})
(com/data-grid-cell
(account-typeahead* {:value (:transaction-account/account account)
:name (str "accounts[" index "][account]")
:client-id client-id}))
(com/data-grid-cell
(location-select* {:value (:transaction-account/location account)
:name (str "accounts[" index "][location]")}))
;; ... etc
))
;; Cursor wrapper (thin convenience layer)
(defn transaction-account-row-from-cursor [cursor request]
(transaction-account-row
{:account @cursor
:index (last (cursor/path cursor))
:client-id (-> request :entity :transaction/client :db/id)
:amount-mode (-> request :multi-form-state :snapshot :amount-mode)}))
- Step 1: Write the failing test
;; Add to existing test file or create new
(deftest pure-render-function
(testing "Can render account row without cursor context"
(let [html (sut/transaction-account-row
{:account {:db/id 123
:transaction-account/account 456
:transaction-account/location "Shared"
:transaction-account/amount 100.0}
:index 0
:client-id 789
:amount-mode "$"})]
(is (string? (str html)))
(is (re-find #"accounts\[0\]\[account\]" (str html)))
(is (re-find #"Shared" (str html))))))
- Step 2: Run test to verify it fails
Expected: FAIL - function doesn't exist yet
- Step 3: Write minimal implementation
Extract the pure function from transaction-account-row-no-cursor* and rename. Move to a shared namespace like auto-ap.ssr.transaction.components.
(ns auto-ap.ssr.transaction.components
(:require [auto-ap.ssr.components :as com]))
(defn transaction-account-row
"Pure function - renders a transaction account row given explicit data.
No cursor or dynamic binding required."
[{:keys [account index client-id amount-mode]}]
(com/data-grid-row
{:class "account-row"}
(com/hidden {:name (str "accounts[" index "][db/id]")
:value (or (:db/id account) "")})
(com/data-grid-cell
(account-typeahead* {:value (:transaction-account/account account)
:name (str "accounts[" index "][account]")
:client-id client-id}))
(com/data-grid-cell
(location-select* {:value (:transaction-account/location account)
:name (str "accounts[" index "][location]")
:client-locations (when client-id
(pull-attr (dc/db conn) :client/locations client-id))}))
(com/data-grid-cell
(if (= "%" amount-mode)
(com/text-input {:name (str "accounts[" index "][amount]")
:value (:transaction-account/amount account)
:type "number"})
(com/money-input {:name (str "accounts[" index "][amount]")
:value (:transaction-account/amount account)})))
(com/data-grid-cell
(com/a-icon-button {"@click" "this.closest('tr').remove()"} svg/x))))
- Step 4: Run test to verify it passes
Expected: PASS
- Step 5: Commit
git add src/clj/auto_ap/ssr/transaction/components.clj
git commit -m "refactor(wizard): extract pure transaction-account-row render function"
Task 4: Migrate New Invoice Wizard to New System
Files:
- Modify:
src/clj/auto_ap/ssr/invoice/new_invoice_wizard.clj
BEFORE (850 lines with protocols and middleware):
(defrecord NewWizard2 [_ current-step]
mm/LinearModalWizard
(hydrate-from-request [this request] this)
(navigate [this step-key] (assoc this :current-step step-key))
(get-current-step [this]
(if current-step
(mm/get-step this current-step)
(mm/get-step this :basic-details)))
(render-wizard [this {:keys [multi-form-state] :as request}]
(mm/default-render-wizard this request ...))
(steps [_] [:basic-details :accounts :next-steps])
(get-step [this step-key]
(let [step-key-result (mc/parse mm/step-key-schema step-key)
[step-key-type step-key] step-key-result]
(get {:basic-details (->BasicDetailsStep this)
:accounts (->AccountsStep this)
:next-steps (->NextSteps this)}
step-key)))
(form-schema [_] new-form-schema)
(submit [this {:keys [multi-form-state request-method identity] :as request}]
;; 100+ lines of complex submit logic
))
;; Routes with middleware stacking
{::route/new-wizard (-> mm/open-wizard-handler
(mm/wrap-wizard new-wizard)
(mm/wrap-init-multi-form-state initial-new-wizard-state))
::route/new-wizard-navigate (-> mm/next-handler
(mm/wrap-wizard new-wizard)
(mm/wrap-decode-multi-form-state))
;; ... 10+ routes
}
AFTER (~300 lines, data-driven):
(def new-invoice-wizard-config
{:steps [{:key :basic-details
:schema basic-details-schema
:fields [:invoice/client :invoice/vendor :invoice/date
:invoice/due :invoice/scheduled-payment
:invoice/invoice-number :invoice/total]
:render render-basic-details-step
:next (fn [data]
(if (= :customize (:customize-accounts data))
:accounts
:submit))}
{:key :accounts
:schema accounts-schema
:fields [:invoice/expense-accounts]
:render render-accounts-step
:next (fn [_] :submit)}
{:key :submit
:render render-submit-step}]
:init-fn (fn [_] {:invoice/date (time/now)
:customize-accounts :default})
:done-fn (fn [all-data request]
;; Submit logic here
(save-invoice! all-data)
(html-response "Success!"))})
;; Simple route handlers
{::route/new-wizard (partial w2/open-wizard new-invoice-wizard-config)
::route/wizard-submit (partial w2/handle-step-submit new-invoice-wizard-config)}
- Step 1: Extract step render functions
Convert BasicDetailsStep's render-step to a pure function:
(defn render-basic-details-step [{:keys [step-data errors]}]
(let [client (:invoice/client step-data)]
[:div.space-y-4
[:div
[:label "Client"]
(com/typeahead {:name "invoice/client"
:value client
:url "/api/clients/search"})]
;; ... other fields
]))
- Step 2: Define step schemas separately
(def basic-details-schema
[:map
[:invoice/client entity-id]
[:invoice/vendor entity-id]
[:invoice/date clj-date-schema]
[:invoice/due {:optional true} [:maybe clj-date-schema]]
[:invoice/invoice-number {:optional true} :string]
[:invoice/total money]])
(def accounts-schema
[:map
[:invoice/expense-accounts
[:vector {:coerce? true}
[:map
[:invoice-expense-account/account entity-id]
[:invoice-expense-account/location :string]
[:invoice-expense-account/amount :double]]]]])
- Step 3: Create wizard config
(def new-invoice-wizard-config
{:steps [{:key :basic-details
:schema basic-details-schema
:fields [:invoice/client :invoice/vendor :invoice/date
:invoice/due :invoice/scheduled-payment
:invoice/invoice-number :invoice/total
:customize-accounts]
:render render-basic-details-step
:next (fn [data]
(if (= :customize (:customize-accounts data))
:accounts
:done))}
{:key :accounts
:schema accounts-schema
:fields [:invoice/expense-accounts]
:render render-accounts-step
:next (fn [_] :done)}]
:init-fn (fn [_] {:invoice/date (time/now)
:customize-accounts :default})
:submit-route "/invoice/wizard/submit"
:done-fn (fn [all-data request]
(let [invoice (build-invoice all-data)]
(audit-transact [[:upsert-invoice invoice]] (:identity request))
(html-response "Invoice created!")))})
- Step 4: Update route handlers
(def key->handler
{::route/new-wizard (partial w2/open-wizard new-invoice-wizard-config)
::route/wizard-submit (partial w2/handle-step-submit new-invoice-wizard-config)
;; Remove all the old middleware-stacked routes!
})
- Step 5: Test the migrated wizard
Run existing tests and add new ones for the config-based approach.
lein test auto-ap.ssr.invoice.new-invoice-wizard-test
- Step 6: Commit
git add src/clj/auto_ap/ssr/invoice/new_invoice_wizard.clj
git commit -m "refactor(wizard): migrate new invoice wizard to v2 engine"
Task 5: Add HTMX Out-of-Band for Related Updates
Files:
- Modify:
src/clj/auto_ap/ssr/invoice/new_invoice_wizard.clj - Modify:
src/clj/auto_ap/ssr/components.clj(if needed)
BEFORE (Multiple handlers for related updates):
;; Three separate handlers for table interactions
::route/expense-account-total (-> invoice-expense-account-total
(mm/wrap-wizard new-wizard)
(mm/wrap-decode-multi-form-state))
::route/expense-account-balance (-> invoice-expense-account-balance
(mm/wrap-wizard new-wizard)
(mm/wrap-decode-multi-form-state))
::route/new-wizard-new-account (-> (add-new-entity-handler ...))
AFTER (Single handler returns multiple updates):
;; When adding a row, return both the row AND updated totals
(defn add-expense-account [request]
(let [new-account {:db/id (str (java.util.UUID/randomUUID))
:invoice-expense-account/location "Shared"
:invoice-expense-account/amount 0}
;; Return row + oob totals update
]
(html-response
[:div
;; New row (main swap target)
(invoice-expense-account-row {:account new-account :index index})
;; Out-of-band: update totals
[:div {:id "expense-total" :hx-swap-oob "true"}
(format "$%.2f" new-total)]
[:div {:id "expense-balance" :hx-swap-oob "true"}
(format "$%.2f" new-balance)]])))
- Step 1: Create combined response helper
(defn wizard-oob-response [main-content oob-updates]
(html-response
(into [:div]
(cons main-content
(for [[id content] oob-updates]
[:div {:id id :hx-swap-oob "true"} content])))))
- Step 2: Update add-row handler to include totals
(defn add-expense-account-row [request]
(let [wizard-id (get-in request [:form-params "wizard-id"])
wizard-state (ws/get-wizard wizard-id)
current-accounts (get-in wizard-state [:step-data :accounts :invoice/expense-accounts] [])
new-index (count current-accounts)
new-account {:db/id (str (java.util.UUID/randomUUID))
:invoice-expense-account/location "Shared"
:invoice-expense-account/amount 0}
updated-accounts (conj current-accounts new-account)
total (reduce + (map :invoice-expense-account/amount updated-accounts))
invoice-total (get-in wizard-state [:step-data :basic-details :invoice/total])
balance (- invoice-total total)]
;; Update server state
(ws/update-step! wizard-id :accounts {:invoice/expense-accounts updated-accounts})
;; Return row + oob updates
(wizard-oob-response
(invoice-expense-account-row {:account new-account :index new-index})
{"expense-total" (format "$%.2f" total)
"expense-balance" (format "$%.2f" balance)})))
- Step 3: Remove separate total/balance handlers
Delete the ::route/expense-account-total and ::route/expense-account-balance routes.
- Step 4: Commit
git commit -m "feat(wizard): use HTMX oob for related form updates"
Task 6: Compatibility Shim for Existing Wizards
Files:
- Modify:
src/clj/auto_ap/ssr/components/multi_modal.clj
Goal: Keep existing wizards working while they migrate.
- Step 1: Add deprecation notices
(ns auto-ap.ssr.components.multi-modal
"DEPRECATED: Use auto-ap.ssr.components.wizard2 instead.")
(defn ^:deprecated wrap-wizard [handler linear-wizard]
;; Existing implementation with warning
(fn [request]
(alog/warn ::deprecated "wrap-wizard is deprecated, use wizard2")
;; ... existing implementation
))
- Step 2: Create adapter function
(defn wizard-v1->v2-config
"Converts old LinearModalWizard record to new config map.
Use temporarily during migration."
[linear-wizard]
{:steps (for [step-key (mm/steps linear-wizard)]
{:key step-key
:schema (mm/step-schema (mm/get-step linear-wizard step-key))
:render (fn [req] (mm/render-step (mm/get-step linear-wizard step-key) req))})
:submit-route "/wizard/submit"})
- Step 3: Commit
git commit -m "chore(wizard): add deprecation notices and v1->v2 adapter"
Task 7: Convert Transaction Edit to Normal Form
Files:
- Modify:
src/clj/auto_ap/ssr/transaction/edit.clj
Key Insight: This is a single-step form with a mode toggle. It doesn't need a wizard at all.
BEFORE (1500+ lines with wizard protocols):
(defrecord EditWizard [...]
mm/LinearModalWizard
;; ... 7 protocol methods for a SINGLE step!
)
;; Complex toggle between simple/advanced mode
(defn edit-wizard-toggle-mode-handler [request]
;; ... 50 lines of mode switching logic
)
;; 20+ routes with different middleware combinations
;; All for a SINGLE form!
AFTER (~300 lines, normal form):
;; Just a normal form with mode parameter
(defn render-transaction-edit-form [{:keys [entity mode form-data errors]}]
(let [is-simple? (= mode :simple)]
[:form#transaction-edit-form {:hx-post "/transaction/edit"
:hx-target "this"}
;; Hidden fields for state
(com/hidden {:name "transaction-id" :value (:db/id entity)})
(com/hidden {:name "mode" :value (name mode)})
;; Mode toggle (just a button that re-renders the form)
(when is-simple?
[:a {:hx-get "/transaction/edit?mode=advanced"
:hx-target "#transaction-edit-form"
:hx-swap "outerHTML"}
"Switch to advanced mode"])
(when-not is-simple?
[:a {:hx-get "/transaction/edit?mode=simple"
:hx-target "#transaction-edit-form"
:hx-swap "outerHTML"}
"Switch to simple mode"])
;; Form fields based on mode
(if is-simple?
(render-simple-fields entity form-data errors)
(render-advanced-fields entity form-data errors))
;; Submit button
(com/button {:type "submit"} "Save")]))
;; Just 2 routes instead of 20+
{::route/edit (fn [request]
(let [mode (keyword (get-in request [:query-params "mode"] "simple"))
entity (get-entity request)]
(html-response (render-transaction-edit-form
{:entity entity :mode mode}))))
::route/edit-submit (fn [request]
;; Validate and save
)}
Benefits:
-
No wizard overhead for a single-step form
-
Mode toggle is just HTMX GET with query param
-
No cursor context needed - explicit data passing
-
~80% line reduction (1500 → 300)
-
Step 1: Extract
render-simple-fieldsandrender-advanced-fieldspure functions -
Step 2: Create normal form handler with mode parameter
-
Step 3: Replace 20+ routes with 2 simple routes
-
Step 4: Test simple and advanced mode toggling
-
Step 5: Test form submission and validation
-
Step 6: Commit
git commit -m "refactor(forms): convert transaction edit from wizard to normal form"
Task 8: Convert Transaction Bulk Code to Normal Form
Files:
- Modify:
src/clj/auto_ap/ssr/transaction/bulk_code.clj
Key Insight: Single-step form. Should be a normal form, not a wizard.
BEFORE (420 lines, wizard protocols for single step):
(defrecord BulkCodeWizard [_ current-step]
mm/LinearModalWizard
;; ... protocols for 1 step!
)
;; 4 routes with middleware stacking
{::route/bulk-code (-> mm/open-wizard-handler
(mm/wrap-wizard bulk-code-wizard))
::route/bulk-code-submit (-> mm/submit-handler
(wrap-wizard bulk-code-wizard))
;; etc
AFTER (~120 lines, normal form):
;; Normal form with search params in hidden fields
(defn render-bulk-code-form [{:keys [form-data errors]}]
[:form#bulk-code-form {:hx-post "/transaction/bulk-code"
:hx-target "this"}
;; Search params preserved in hidden fields
(for [[k v] (:search-params form-data)]
(com/hidden {:name (str "search-params[" k "]") :value v}))
;; Form fields
(render-bulk-code-fields form-data errors)
(com/button {:type "submit"} "Bulk Update")])
;; Just 2 routes
{::route/bulk-code (fn [request]
(let [search-params (:query-params request)]
(html-response (render-bulk-code-form
{:form-data {:search-params search-params}}))))
::route/bulk-code-submit (fn [request]
;; Validate and apply bulk changes
)}
- Step 1: Extract
render-bulk-code-fieldspure function - Step 2: Create normal form handler
- Step 3: Replace 4 wizard routes with 2 normal form routes
- Step 4: Test bulk code form end-to-end
- Step 5: Commit
git commit -m "refactor(forms): convert bulk code from wizard to normal form"
Task 9: Convert Sales Summaries Edit to Normal Form
Files:
- Modify:
src/clj/auto_ap/ssr/pos/sales_summaries.clj
Key Insight: Single-step form. Should be a normal form.
BEFORE (780 lines, wizard protocols for single step):
(defrecord EditWizard [_ current-step]
mm/LinearModalWizard
;; ... protocols for 1 step
)
;; 3 routes with middleware stacking
{::route/edit-wizard (-> mm/open-wizard-handler
(mm/wrap-wizard edit-wizard)
(mm/wrap-init-multi-form-state initial-edit-wizard-state))
::route/edit-wizard-navigate (-> mm/next-handler
(mm/wrap-wizard edit-wizard))
::route/edit-wizard-submit (-> mm/submit-handler
(mm/wrap-wizard edit-wizard))}
AFTER (~200 lines, normal form):
(defn render-sales-summary-form [{:keys [entity form-data errors]}]
[:form#sales-summary-form {:hx-post "/sales-summary/edit"
:hx-target "this"}
(com/hidden {:name "id" :value (:db/id entity)})
(render-sales-summary-fields entity form-data errors)
(com/button {:type "submit"} "Save")])
;; Just 2 routes
{::route/edit (fn [request]
(let [entity (get-entity request)]
(html-response (render-sales-summary-form {:entity entity}))))
::route/edit-submit (fn [request]
;; Validate and save
)}
- Step 1: Extract
render-sales-summary-fieldspure function - Step 2: Create normal form handler
- Step 3: Replace 3 wizard routes with 2 normal form routes
- Step 4: Test sales summary edit form
- Step 5: Commit
git commit -m "refactor(forms): convert sales summary edit from wizard to normal form"
Task 10: Migrate Invoice Pay Wizard
Files:
- Modify:
src/clj/auto_ap/ssr/invoices.clj
Current: PayWizard with :choose-method and :payment-details steps, ~1800 lines total
Route count: 4 routes (::route/pay-wizard, ::route/pay-submit, ::route/pay-wizard-navigate)
Complexity: High (complex payment logic, check handling, bank account selection)
Special patterns:
-
invoice-by-idlookup map passed to wizard -
Conditional rendering based on payment method
-
handwrite-checkmode with check number/date fields -
Step 1: Extract
render-choose-method-steppure function -
Step 2: Extract
render-payment-details-steppure function -
Step 3: Create
pay-wizard-configwith both steps -
Step 4: Move
initial-pay-wizard-statelogic to:init-fn -
Step 5: Replace 4 routes with 2 v2 routes
-
Step 6: Test pay wizard with different payment methods
-
Step 7: Commit
git commit -m "refactor(wizard): migrate invoice pay wizard to v2"
Task 11: Convert Invoice Bulk Edit to Normal Form
Files:
- Modify:
src/clj/auto_ap/ssr/invoices.clj(same file as PayWizard)
Key Insight: Single-step form with account rows. Should be a normal form.
BEFORE (700 lines, wizard protocols for single step):
(defrecord BulkEditWizard [_ current-step]
mm/LinearModalWizard
;; ... protocols for 1 step with accounts
)
;; 4 routes with middleware stacking
{::route/bulk-edit (-> mm/open-wizard-handler
(mm/wrap-wizard bulk-edit-wizard))
::route/bulk-edit-submit (-> mm/submit-handler
(mm/wrap-wizard bulk-edit-wizard))
;; Plus new-account, total, balance routes
}
AFTER (~150 lines, normal form with HTMX oob):
(defn render-bulk-edit-form [{:keys [form-data errors]}]
[:form#bulk-edit-form {:hx-post "/invoice/bulk-edit"
:hx-target "this"}
;; Search params in hidden fields
(for [[k v] (:search-params form-data)]
(com/hidden {:name (str "search-params[" k "]") :value v}))
;; Account rows
(for [[idx account] (map-indexed vector (:expense-accounts form-data))]
(render-bulk-edit-account-row {:account account :index idx}))
;; Add row button
[:a {:hx-get "/invoice/bulk-edit/new-account"
:hx-target "#bulk-edit-form"
:hx-swap "beforeend"}
"New account"]
;; Totals (updated via oob)
[:div#bulk-total (format "$%.2f" (calculate-total form-data))]
[:div#bulk-balance (format "$%.2f" (calculate-balance form-data))]
(com/button {:type "submit"} "Apply")])
;; Just 2 routes + 1 for new row
{::route/bulk-edit (fn [request]
(let [search-params (:query-params request)]
(html-response (render-bulk-edit-form
{:form-data {:search-params search-params
:expense-accounts []}}))))
::route/bulk-edit-submit (fn [request]
;; Validate and apply bulk changes
)
::route/bulk-edit-new-account (fn [request]
;; Return new row + oob total/balance updates
)}
- Step 1: Extract
render-bulk-edit-account-rowpure function - Step 2: Create normal form handler with HTMX oob for totals
- Step 3: Replace 4 wizard routes with 3 normal form routes
- Step 4: Test bulk edit form
- Step 5: Commit
git commit -m "refactor(forms): convert invoice bulk edit from wizard to normal form"
Task 12: Migrate Vendor Wizard
Files:
- Modify:
src/clj/auto_ap/ssr/admin/vendors.clj
Current: VendorWizard with 5 steps (:info, :terms, :account, :address, :legal), ~917 lines
Route count: 4 routes (::route/new, ::route/save, ::route/navigate, ::route/edit)
Complexity: Medium (5-step wizard, most steps in one file)
Special patterns:
-
wrap-entitymiddleware for editing existing vendors -
x-dataAlpine state for vendor name / print-as toggle -
Conditional hx-post/hx-put based on snapshot db/id
-
Step 1: Extract 5 step render functions:
render-info-step,render-terms-step,render-account-step,render-address-step,render-legal-step -
Step 2: Create
vendor-wizard-configwith all 5 steps -
Step 3: Handle
:newvs:editvia:init-fn(empty vs entity) -
Step 4: Replace 4 routes with 2 v2 routes
-
Step 5: Test vendor create and edit
-
Step 6: Commit
git commit -m "refactor(wizard): migrate vendor wizard to v2"
Task 13: Migrate Client Wizard
Files:
- Modify:
src/clj/auto_ap/ssr/admin/clients.clj
Current: ClientWizard with 7 steps (:info, :matches, :contact, :bank-accounts, :integrations, :cash-flow, :other-settings), ~1913 lines
Route count: Similar to vendor (new, save, navigate, edit)
Complexity: High (largest wizard, 7 steps, complex nested forms for bank accounts, location matches, emails, etc.)
Special patterns:
-
Multiple
add-new-entity-handlercalls for bank accounts, location matches, emails, contact methods -
fc/with-field-defaultfor creating nested maps -
Complex
x-datawith many Alpine variables -
Step 1: Extract 7 step render functions (may need to split into sub-tasks)
-
Step 2: Create
client-wizard-configwith all 7 steps -
Step 3: Convert all
add-new-entity-handlercalls to HTMX oob pattern -
Step 4: Handle
:newvs:editvia:init-fn -
Step 5: Replace routes with 2 v2 routes
-
Step 6: Test client create and edit thoroughly
-
Step 7: Commit
git commit -m "refactor(wizard): migrate client wizard to v2"
Task 14: Migrate Transaction Rule Wizard
Files:
- Modify:
src/clj/auto_ap/ssr/admin/transaction_rules.clj
Current: TransactionRuleWizard with 2 steps (:edit, :test), ~1005 lines
Route count: Similar pattern (save, navigate, edit)
Complexity: Low (only 2 steps, but :test step has complex results table)
Special patterns:
-
:teststep shows transaction rule test results table -
validate-transaction-rulecustom validation -
Step 1: Extract
render-edit-stepandrender-test-steppure functions -
Step 2: Create
transaction-rule-wizard-configwith both steps -
Step 3: Replace routes with 2 v2 routes
-
Step 4: Test transaction rule create, edit, and test
-
Step 5: Commit
git commit -m "refactor(wizard): migrate transaction rule wizard to v2"
Summary: Before/After Complexity Comparison
| Metric | Before | After |
|---|---|---|
| Protocols | 5 protocols, 15+ methods | 0 protocols |
| Middleware wrappers | 3+ per route | 1 per route |
| State serialization | EDN with custom readers in hidden fields | UUID token (wizards) / plain form (normal forms) |
| State merging | Complex cursor-based merge | No merging (keyed by step or explicit params) |
| Render functions | 2 versions (cursor + no-cursor) | 1 pure function |
| Wizard definition | Record with 7 protocol methods | Data map with :steps |
| Form-cursor | Required, dynamic binding | Optional convenience wrapper |
| Route count (wizards) | 10-20 per wizard | 2 per wizard (open + submit) |
| Route count (forms) | 4-10 per form | 2 per form (GET + POST) |
| Real wizards | 9 (most single-step) | 5 (only true multi-step) |
| Normal forms | 0 (all forced into wizard pattern) | 4 (converted from single-step wizards) |
| Total lines (all) | ~8,200 | ~3,000 (est.) |
Per-Wizard/Form Migration Status
| # | Name | File | Lines (Before) | Steps | Type | Priority | Task |
|---|---|---|---|---|---|---|---|
| 1 | New Invoice | new_invoice_wizard.clj |
~850 | 3 | Real Wizard | High | Task 4 |
| 2 | Transaction Edit | transaction/edit.clj |
~1,500 | 1 (mode toggle) | Normal Form | High | Task 7 |
| 3 | Transaction Bulk Code | transaction/bulk_code.clj |
~420 | 1 | Normal Form | Medium | Task 8 |
| 4 | Sales Summary Edit | pos/sales_summaries.clj |
~780 | 1 | Normal Form | Medium | Task 9 |
| 5 | Invoice Pay | invoices.clj |
~800* | 2 | Real Wizard | High | Task 10 |
| 6 | Invoice Bulk Edit | invoices.clj |
~700* | 1 | Normal Form | Medium | Task 11 |
| 7 | Vendor | admin/vendors.clj |
~917 | 5 | Real Wizard | Medium | Task 12 |
| 8 | Client | admin/clients.clj |
~1,913 | 7 | Real Wizard | Low | Task 13 |
| 9 | Transaction Rule | admin/transaction_rules.clj |
~1,005 | 2 | Real Wizard | Low | Task 14 |
Real Wizards: 5 implementations | Normal Forms: 4 conversions
*Estimated from shared file
Self-Review Checklist
- All wizard instances identified: 8 wizards across 8 files (~8,200 lines)
- Every wizard has migration task: Tasks 4-14 cover all wizards
- No placeholders: Every step has concrete code
- Type consistency:
wizard-idis string throughout,step-keyis keyword - DRY: Render functions extracted once and reused
- YAGNI: No premature abstractions (no protocol for storage, just atom)
- Testable: Each task includes test steps
- Migration order: Prioritized by complexity (high → low) and usage frequency
Migration Order Recommendation
Phase 1 (Week 1-2): Foundation
- Tasks 1-2: Build wizard2 engine and state storage
- Task 3: Extract pure render helpers
- Task 4: New Invoice Wizard (proven 3-step wizard)
- Task 5: HTMX out-of-band updates
Phase 2 (Week 3): High Priority Normal Forms 5. Task 7: Transaction Edit (single-step with mode toggle) 6. Task 10: Invoice Pay (2-step wizard)
Phase 3 (Week 4): Medium Priority 7. Task 8: Transaction Bulk Code (normal form) 8. Task 9: Sales Summary Edit (normal form) 9. Task 11: Invoice Bulk Edit (normal form) 10. Task 12: Vendor Wizard (5-step wizard)
Phase 4 (Week 5-6): Low Priority 11. Task 14: Transaction Rule Wizard (2-step) 12. Task 13: Client Wizard (7-step, largest)
Phase 5 (Week 7): Cleanup
13. Task 6: Remove compatibility shim
14. Delete multi_modal.clj entirely
Plan complete. Two execution options:
1. Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration
2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints
Which approach?