Files
integreat/docs/superpowers/plans/2025-01-15-wizard-refactor.md
2026-06-01 09:40:49 -07:00

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 storage
  • test/clj/auto_ap/ssr/components/wizard2_test.clj — Tests for new wizard engine

Modified files (ALL wizard instances in codebase):

  1. src/clj/auto_ap/ssr/components/multi_modal.clj — Mark deprecated, add compatibility shim
  2. src/clj/auto_ap/ssr/common_handlers.clj — Update add-new-entity-handler for pure functions
  3. src/clj/auto_ap/ssr/invoice/new_invoice_wizard.clj — NewInvoiceWizard (NewWizard2) — ~850 lines
  4. src/clj/auto_ap/ssr/transaction/edit.clj — TransactionEditWizard (EditWizard) — ~1500 lines, simple/advanced mode toggle
  5. src/clj/auto_ap/ssr/transaction/bulk_code.clj — BulkCodeWizard — ~420 lines
  6. src/clj/auto_ap/ssr/pos/sales_summaries.clj — SalesSummaryEditWizard (EditWizard) — ~780 lines
  7. src/clj/auto_ap/ssr/invoices.clj — PayWizard + BulkEditWizard — ~1800 lines total
  8. src/clj/auto_ap/ssr/admin/vendors.clj — VendorWizard — ~917 lines
  9. src/clj/auto_ap/ssr/admin/clients.clj — ClientWizard — ~1913 lines
  10. src/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-field macro chains
  • Don't work well when HTMX swaps partial content

New Approach:

  1. Pure functions are primary API - All render functions take explicit data maps
  2. Form-cursor becomes optional convenience wrapper - Still available for simple cases
  3. 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"

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-fields and render-advanced-fields pure 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-fields pure 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-fields pure 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-id lookup map passed to wizard

  • Conditional rendering based on payment method

  • handwrite-check mode with check number/date fields

  • Step 1: Extract render-choose-method-step pure function

  • Step 2: Extract render-payment-details-step pure function

  • Step 3: Create pay-wizard-config with both steps

  • Step 4: Move initial-pay-wizard-state logic 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-row pure 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-entity middleware for editing existing vendors

  • x-data Alpine 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-config with all 5 steps

  • Step 3: Handle :new vs :edit via :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-handler calls for bank accounts, location matches, emails, contact methods

  • fc/with-field-default for creating nested maps

  • Complex x-data with many Alpine variables

  • Step 1: Extract 7 step render functions (may need to split into sub-tasks)

  • Step 2: Create client-wizard-config with all 7 steps

  • Step 3: Convert all add-new-entity-handler calls to HTMX oob pattern

  • Step 4: Handle :new vs :edit via :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:

  • :test step shows transaction rule test results table

  • validate-transaction-rule custom validation

  • Step 1: Extract render-edit-step and render-test-step pure functions

  • Step 2: Create transaction-rule-wizard-config with 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-id is string throughout, step-key is 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

  1. Tasks 1-2: Build wizard2 engine and state storage
  2. Task 3: Extract pure render helpers
  3. Task 4: New Invoice Wizard (proven 3-step wizard)
  4. 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?