1284 lines
47 KiB
Markdown
1284 lines
47 KiB
Markdown
# 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?**
|