# 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):** ```clojure (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):** ```clojure ;; 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):** ```clojure ;; 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):** ```clojure ;; 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** ```clojure (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** ```clojure (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** ```bash 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):** ```clojure ;; 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):** ```clojure ;; 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** ```clojure (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** ```clojure (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** ```bash 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):** ```clojure ;; 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):** ```clojure ;; 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** ```clojure ;; 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`. ```clojure (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** ```bash 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):** ```clojure (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):** ```clojure (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: ```clojure (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** ```clojure (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** ```clojure (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** ```clojure (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. ```bash lein test auto-ap.ssr.invoice.new-invoice-wizard-test ``` - [ ] **Step 6: Commit** ```bash 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):** ```clojure ;; 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):** ```clojure ;; 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** ```clojure (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** ```clojure (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** ```bash 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** ```clojure (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** ```clojure (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** ```bash 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):** ```clojure (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):** ```clojure ;; 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** ```bash 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):** ```clojure (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):** ```clojure ;; 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** ```bash 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):** ```clojure (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):** ```clojure (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** ```bash 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** ```bash 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):** ```clojure (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):** ```clojure (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** ```bash 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** ```bash 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** ```bash 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** ```bash 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 - [x] **All wizard instances identified:** 8 wizards across 8 files (~8,200 lines) - [x] **Every wizard has migration task:** Tasks 4-14 cover all wizards - [x] **No placeholders:** Every step has concrete code - [x] **Type consistency:** `wizard-id` is string throughout, `step-key` is keyword - [x] **DRY:** Render functions extracted once and reused - [x] **YAGNI:** No premature abstractions (no protocol for storage, just atom) - [x] **Testable:** Each task includes test steps - [x] **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?**