1 Commits

Author SHA1 Message Date
5452b8b779 critique of wizard design. 2026-06-01 09:40:49 -07:00
13 changed files with 2171 additions and 19 deletions

View File

@@ -5,7 +5,7 @@
"packages": {
"": {
"dependencies": {
"@opencode-ai/plugin": "1.15.10"
"@opencode-ai/plugin": "1.15.12"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
@@ -87,19 +87,19 @@
]
},
"node_modules/@opencode-ai/plugin": {
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.10.tgz",
"integrity": "sha512-V2p7CvpBtKWB+FID7Dl1y0Ci02zUT40A9b2RD9R9BOiuD8ZcKhHWov+irN0xVJA0Eg6OhEBfA0lPKRn1FNKPlw==",
"version": "1.15.12",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.12.tgz",
"integrity": "sha512-BBteGXEwJt+1ehHqQ+yKXmoWltrW+2xO++B1Fm/dnMGYWT9luEKA5RlUuVYA2qDF6uwlE7kmHZvQZAM79zWHEA==",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.15.10",
"@opencode-ai/sdk": "1.15.12",
"effect": "4.0.0-beta.66",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.2.15",
"@opentui/keymap": ">=0.2.15",
"@opentui/solid": ">=0.2.15"
"@opentui/core": ">=0.2.16",
"@opentui/keymap": ">=0.2.16",
"@opentui/solid": ">=0.2.16"
},
"peerDependenciesMeta": {
"@opentui/core": {
@@ -114,9 +114,9 @@
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.10.tgz",
"integrity": "sha512-CUhpmMGGOqzvPnNNjjWmEIodAfP6Qnuki2ChIUKWYF7UImZ4zUcMZnzO5BtUxu/Ni1P8qzWxDioXs+7aIZQEhA==",
"version": "1.15.12",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.12.tgz",
"integrity": "sha512-lOaBNX93dkakZe6C42ttX1bkSx3K2c6+Yv+w8Qv02v5rPlu1vCXbmdfYDh9/bw+oq+NKPSaBm9d6kPA19hA5Lg==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -118,6 +118,11 @@
"type": "local",
"command": ["clojure", "-Tmcp", "start", ":config-profile", ":cli-assist"],
"enabled": true
} ,
"tavily": {
"type": "remote",
"url": "https://mcp.tavily.com/mcp/?tavilyApiKey=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",
"enabled": true
}
},
"permission": {

View File

@@ -0,0 +1,54 @@
(ns auto-ap.ssr.components.wizard-trial.core
(:require
[auto-ap.ssr.components.wizard-trial.state :as ws]
[auto-ap.ssr.utils :refer [html-response main-transformer modal-response]]
[malli.core :as mc]
[malli.error :as me]))
(defn render-step
"Renders a single step form.
step-config is a map with:
- :key - step keyword
- :render - function taking {:keys [step-data errors request]} returning hiccup
- :submit-route - string URL for form POST"
[{:keys [wizard-id step-config request errors]}]
(let [{:keys [key render submit-route]} step-config
wizard (ws/get-wizard wizard-id)
step-data (get-in wizard [:data key] {})]
[:form {:hx-post submit-route
:hx-target "this"}
[:input {:type "hidden" :name "wizard-id" :value wizard-id}]
[:input {:type "hidden" :name "step-key" :value (name key)}]
(render {:step-data step-data :errors errors :request request})]))
(defn handle-submit
"Handles step submission.
Validates step data against schema.
If valid: saves to session and calls done-fn.
If invalid: re-renders step with errors."
[step-config request]
(let [{:keys [form-params]} request
wizard-id (get form-params "wizard-id")
step-key (keyword (get form-params "step-key"))
fields (:fields step-config)
step-data (reduce (fn [acc field]
(if-let [v (get form-params (name field))]
(assoc acc field v)
acc))
{}
fields)
schema (:schema step-config)
decoded (mc/decode schema step-data main-transformer)
valid? (mc/validate schema decoded)]
(if valid?
(do
(ws/update-step! wizard-id step-key decoded)
(let [all-data (ws/get-all-data wizard-id)]
(ws/destroy! wizard-id)
((:done-fn step-config) all-data request)))
(let [errors (me/humanize (mc/explain schema decoded))]
(modal-response
(render-step {:wizard-id wizard-id
:step-config step-config
:request request
:errors errors}))))))

View File

@@ -0,0 +1,35 @@
(ns auto-ap.ssr.components.wizard-trial.state)
(defonce ^:private store (atom {}))
(defn create!
"Creates new wizard session with initial data. Returns wizard-id string."
[initial-data]
(let [id (str (java.util.UUID/randomUUID))]
(swap! store assoc id {:data initial-data
:created-at (java.util.Date.)})
id))
(defn get-wizard
"Retrieves wizard data by id. Returns nil if not found."
[id]
(get @store id))
(defn update-step!
"Merges step data into wizard session under step-key."
[id step-key step-data]
(swap! store update-in [id :data step-key] merge step-data))
(defn get-all-data
"Returns merged data from all steps for final submission."
[id]
(when-let [wizard (get-wizard id)]
(let [data (:data wizard)]
(apply merge
(into {} (remove (comp map? val) data))
(filter map? (vals data))))))
(defn destroy!
"Removes wizard session."
[id]
(swap! store dissoc id))

View File

@@ -15,6 +15,7 @@
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
[auto-ap.ssr.ledger :refer [wrap-ensure-bank-account-belongs]]
[auto-ap.ssr.transaction.bulk-code :as bulk-code :refer [all-ids-not-locked]]
[auto-ap.ssr.transaction.bulk-code-trial :as bulk-code-trial]
[auto-ap.ssr.transaction.common :refer [bank-account-filter* fetch-ids
grid-page query-schema
wrap-status-from-source]]
@@ -53,7 +54,7 @@
all-selected
(:ids (fetch-ids (dc/db conn) (-> request
(assoc-in [:form-params :start] 0)
(assoc-in [:form-params :per-page] 250))))
(assoc-in [:form-params :per-page] 250))))
:else
selected)
all-ids (all-ids-not-locked ids)
@@ -101,16 +102,18 @@
(def key->handler
(merge edit/key->handler
bulk-code/key->handler
{::route/bulk-code-trial bulk-code-trial/open-trial
::route/bulk-code-trial-submit bulk-code-trial/submit-trial}
(apply-middleware-to-all-handlers
{::route/page page
{::route/page page
::route/approved-page (-> page (wrap-implied-route-param :status :transaction-approval-status/approved))
::route/unapproved-page (-> page (wrap-implied-route-param :status :transaction-approval-status/unapproved))
::route/requires-feedback-page (-> page (wrap-implied-route-param :status :transaction-approval-status/requires-feedback))
::route/table table
::route/csv csv
::route/table table
::route/csv csv
::route/bank-account-filter bank-account-filter
::route/bulk-delete (-> bulk-delete
(wrap-schema-enforce :form-schema query-schema))}
::route/bulk-delete (-> bulk-delete
(wrap-schema-enforce :form-schema query-schema))}
(fn [h]
(-> h
(wrap-copy-qp-pqp)

View File

@@ -0,0 +1,172 @@
(ns auto-ap.ssr.transaction.bulk-code-trial
(:require
[auto-ap.datomic :refer [conn pull-attr pull-many]]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.wizard-trial.core :as wt]
[auto-ap.ssr.components.wizard-trial.state :as ws]
[auto-ap.ssr.transaction.edit :as edit :refer [account-typeahead* location-select*]]
[auto-ap.ssr.utils :refer [html-response modal-response percentage]]
[auto-ap.ssr.svg :as svg]
[bidi.bidi :as bidi]
[clojure.string :as str]
[datomic.api :as dc]
[malli.core :as mc]))
(def bulk-code-schema
(mc/schema
[:map
[:vendor {:optional true} [:maybe int?]]
[:approval-status {:optional true} [:maybe keyword?]]
[:accounts {:optional true}
[:vector {:coerce? true}
[:map
[:account int?]
[:location :string]
[:percentage percentage]]]]]))
(defn- account-row
"Renders a single account row with typeahead, location select, percentage, and delete button."
[index {:keys [account location percentage]} errors request]
(let [account-name (str "accounts[" index "][account]")
location-name (str "accounts[" index "][location]")
percentage-name (str "accounts[" index "][percentage]")
row-errors (get errors index)
client-id (-> request :clients first :db/id)
account-location (try
(when (nat-int? account)
(:account/location (dc/pull (dc/db conn) '[:account/location] account)))
(catch Exception e nil))
client-locations (try
(pull-attr (dc/db conn) :client/locations client-id)
(catch Exception e nil))]
[:tr
[:td (com/validated-field
{:errors (get row-errors :account)}
(account-typeahead* {:value account
:client-id client-id
:name account-name}))]
[:td (com/validated-field
{:errors (get row-errors :location)}
(location-select* {:name location-name
:account-location account-location
:client-locations client-locations
:value location}))]
[:td (com/validated-field
{:errors (get row-errors :percentage)}
(com/money-input {:name percentage-name
:value (some-> percentage (* 100) long)
:class "w-16"}))]
[:td (com/a-icon-button {"@click.prevent.stop" "this.closest('tr').remove()"}
svg/x)]]))
(defn render-bulk-code-form
"Renders the bulk code form inside a modal card structure.
Takes {:keys [step-data errors request]}"
[{:keys [step-data errors request]}]
(let [vendor (get step-data :vendor)
approval-status (get step-data :approval-status)
accounts (get step-data :accounts
[{:account nil :location "Shared" :percentage 0.5}
{:account nil :location "Shared" :percentage 0.5}
{:account nil :location "" :percentage nil}])
selected-ids [] ; Would come from request in real implementation
all-ids []]
(com/modal-card-advanced
{:class "md:w-[750px] md:h-[600px] w-full h-full"}
(com/modal-header {}
[:div.p-2 "Bulk editing " (count all-ids) " transactions"])
(com/modal-body {}
[:div.space-y-4.p-4
[:div.grid.grid-cols-2.gap-4
;; Vendor field
[:div
(com/validated-field
{:label "Vendor"
:errors (get errors :vendor)}
(com/typeahead {:name "vendor"
:placeholder "Search for vendor..."
:url (bidi/path-for auto-ap.ssr-routes/only-routes :vendor-search)
:value vendor
:content-fn (fn [c]
(try
(pull-attr (dc/db conn) :vendor/name c)
(catch Exception e
"Vendor")))}))]
;; Approval status field
[:div
(com/validated-field
{:label "Status"
:errors (get errors :approval-status)}
(com/select {:name "approval-status"
:value (some-> approval-status name)
:allow-blank? true
:options [["" "No Change"]
["approved" "Approved"]
["unapproved" "Unapproved"]
["suppressed" "Suppressed"]
["requires_feedback" "Requires Feedback"]]}))]]
;; Accounts section
[:div.col-span-2.pt-4
[:h3.text-lg.font-medium.mb-3 "Expense Accounts"]
(com/validated-field
{:errors (get errors :accounts)}
[:table.w-full.text-sm.text-left
[:thead
[:tr
[:th "Account"]
[:th {:class "w-32"} "Location"]
[:th {:class "w-16"} "%"]
[:th {:class "w-16"}]]]
[:tbody
(map-indexed
(fn [idx account]
(account-row idx account (get errors :accounts) request))
accounts)]])]
;; Add new account button
[:div
(com/button {:color :secondary
:type "button"
:class "mt-2"
"@click" (str "
const tbody = this.closest('form').querySelector('tbody');
const newRow = document.createElement('tr');
newRow.innerHTML = `
<td><input type='text' name='accounts[" (count accounts) "][account]' placeholder='Account ID' class='w-full'></td>
<td><input type='text' name='accounts[" (count accounts) "][location]' value='Shared' class='w-full'></td>
<td><input type='number' name='accounts[" (count accounts) "][percentage]' class='w-16'></td>
<td><button type='button' onclick='this.closest(\"tr\").remove()'>×</button></td>
`;
tbody.appendChild(newRow);
")}
"New account")]])
(com/modal-footer {}
[:div.flex.justify-end
[:div.flex.items-baseline.gap-x-4
(com/form-errors {:errors (seq errors)})
(com/button {:color :primary :type "submit" :class "w-32"} "Save")]]))))
(def trial-step-config
{:key :bulk-code
:schema bulk-code-schema
:fields [:vendor :approval-status :accounts]
:render render-bulk-code-form
:submit-route "/transaction/bulk-code-trial"
:done-fn (fn [data request]
(modal-response
(com/success-modal {:title "Transactions Coded (Trial)"}
[:p "This was a trial run. No transactions were actually modified."])
:headers {"hx-trigger" "refreshTable"}))})
(defn open-trial [request]
(let [wizard-id (ws/create! {})]
(modal-response
(wt/render-step {:wizard-id wizard-id
:step-config trial-step-config
:request request}))))
(defn submit-trial [request]
(wt/handle-submit trial-step-config request))

View File

@@ -440,6 +440,12 @@
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
"hx-include" "#transaction-filters"}
"Code")
(com/button {:color :secondary
:hx-get (bidi/path-for ssr-routes/only-routes ::route/bulk-code-trial)
:hx-target "#modal-holder"
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
"hx-include" "#transaction-filters"}
"Code (Trial)")
(com/button {:color :primary
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
:hx-target "#modal-holder"

View File

@@ -10,7 +10,9 @@
"/bulk-code" {:get ::bulk-code
:put ::bulk-code-submit
"/new-account" ::bulk-code-new-account
"/vendor-changed" ::bulk-code-vendor-changed}}
"/vendor-changed" ::bulk-code-vendor-changed}
"/bulk-code-trial" {:get ::bulk-code-trial
:post ::bulk-code-trial-submit}}
"/new" {:get ::new
:post ::new-submit
"/location-select" ::location-select

View File

@@ -0,0 +1,114 @@
(ns auto-ap.ssr.components.wizard-trial.core-test
(:require
[auto-ap.ssr.components.wizard-trial.core :as sut]
[auto-ap.ssr.components.wizard-trial.state :as ws]
[clojure.test :refer [deftest is testing]]
[hiccup.core :as hiccup]))
(deftest render-step-test
(testing "render-step produces a form with hidden wizard-id and step-key inputs"
(let [wizard-id (ws/create! {})
step-config {:key :test-step
:render (fn [{:keys [step-data errors request]}]
[:div (str "data: " step-data) (when errors [:span.error (str errors)])])
:submit-route "/test-submit"}
result (sut/render-step {:wizard-id wizard-id
:step-config step-config
:request {}})]
(is (= :form (first result)))
(let [attrs (second result)
children (drop 2 result)]
(is (= "/test-submit" (:hx-post attrs)))
(is (= "this" (:hx-target attrs)))
(let [html (hiccup/html result)]
(is (re-find #"name=\"wizard-id\"" html))
(is (re-find #"value=\"" html))
(is (re-find #"name=\"step-key\"" html))
(is (re-find #"value=\"test-step\"" html))))))
(testing "render-step passes step-data, errors, and request to the render function"
(let [wizard-id (ws/create! {:test-step {:field "value"}})
captured (atom nil)
step-config {:key :test-step
:render (fn [args] (reset! captured args) [:div "rendered"])
:submit-route "/test-submit"}
_ (sut/render-step {:wizard-id wizard-id
:step-config step-config
:request {:client-id 123}
:errors {:field ["is invalid"]}})
{:keys [step-data errors request]} @captured]
(is (= {:field "value"} step-data))
(is (= {:field ["is invalid"]} errors))
(is (= {:client-id 123} request)))))
(deftest handle-submit-test
(testing "handle-submit with valid data saves step and calls done-fn"
(let [done-result (atom nil)
wizard-id (ws/create! {})
step-config {:key :test-step
:schema [:map [:name :string]]
:fields [:name]
:render (fn [_] [:div "rendered"])
:submit-route "/test-submit"
:done-fn (fn [data request]
(reset! done-result {:data data :request request})
{:status 200 :body "done"})}
request {:form-params {"wizard-id" wizard-id
"step-key" "test-step"
"name" "Alice"}}
response (sut/handle-submit step-config request)]
(is (= 200 (:status response)))
(is (= "done" (:body response)))
(is (= "Alice" (get-in @done-result [:data :name])))
(is (nil? (ws/get-wizard wizard-id)) "Wizard session should be destroyed after successful submit")))
(testing "handle-submit with invalid data re-renders step with errors"
(let [wizard-id (ws/create! {})
step-config {:key :test-step
:schema [:map [:name :string]]
:fields [:name]
:render (fn [{:keys [errors]}]
[:div (when errors [:span.error (str errors)])])
:submit-route "/test-submit"
:done-fn (fn [_ _] {:status 200 :body "done"})}
request {:form-params {"wizard-id" wizard-id
"step-key" "test-step"
"name" ""}}
response (sut/handle-submit step-config request)]
(is (= 200 (:status response)))
(is (string? (:body response)))
(is (re-find #"error" (:body response)) "Response body should contain error markup")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should still exist after failed validation")))
(testing "handle-submit with missing required field shows validation error"
(let [wizard-id (ws/create! {})
step-config {:key :test-step
:schema [:map [:name :string]]
:fields [:name]
:render (fn [{:keys [errors]}]
[:div (when errors [:span.error (str errors)])])
:submit-route "/test-submit"
:done-fn (fn [_ _] {:status 200 :body "done"})}
request {:form-params {"wizard-id" wizard-id
"step-key" "test-step"}}
response (sut/handle-submit step-config request)]
(is (= 200 (:status response)))
(is (re-find #"error" (:body response)) "Response body should contain error markup for missing field")))
(testing "handle-submit decodes step data using main-transformer"
(let [done-result (atom nil)
wizard-id (ws/create! {})
step-config {:key :test-step
:schema [:map [:count int?]]
:fields [:count]
:render (fn [_] [:div "rendered"])
:submit-route "/test-submit"
:done-fn (fn [data _]
(reset! done-result data)
{:status 200 :body "done"})}
request {:form-params {"wizard-id" wizard-id
"step-key" "test-step"
"count" "42"}}
response (sut/handle-submit step-config request)]
(is (= 200 (:status response)))
(is (= 42 (:count @done-result)) "String count should be decoded to integer"))))

View File

@@ -0,0 +1,76 @@
(ns auto-ap.ssr.components.wizard-trial.state-test
(:require
[auto-ap.ssr.components.wizard-trial.state :as sut]
[clojure.test :refer [deftest is testing]]))
(deftest create-and-get-wizard-test
(testing "Session creation returns a non-nil wizard-id"
(let [wizard-id (sut/create! {:foo "bar"})]
(is (string? wizard-id))
(is (seq wizard-id))))
(testing "Session retrieval returns the stored data"
(let [wizard-id (sut/create! {:foo "bar"})
wizard (sut/get-wizard wizard-id)]
(is (map? wizard))
(is (= {:foo "bar"} (:data wizard)))
(is (inst? (:created-at wizard)))))
(testing "Session retrieval returns nil for unknown id"
(is (nil? (sut/get-wizard "non-existent-id")))))
(deftest update-step-test
(testing "update-step! merges data into the specified step key"
(let [wizard-id (sut/create! {})
_ (sut/update-step! wizard-id :step1 {:field-a "a"})
wizard (sut/get-wizard wizard-id)]
(is (= {:field-a "a"} (get-in wizard [:data :step1])))))
(testing "update-step! merges without overwriting other step keys"
(let [wizard-id (sut/create! {})
_ (sut/update-step! wizard-id :step1 {:field-a "a"})
_ (sut/update-step! wizard-id :step2 {:field-b "b"})
wizard (sut/get-wizard wizard-id)]
(is (= {:field-a "a"} (get-in wizard [:data :step1])))
(is (= {:field-b "b"} (get-in wizard [:data :step2])))))
(testing "update-step! merges within the same step key"
(let [wizard-id (sut/create! {})
_ (sut/update-step! wizard-id :step1 {:field-a "a"})
_ (sut/update-step! wizard-id :step1 {:field-b "b"})
wizard (sut/get-wizard wizard-id)]
(is (= {:field-a "a" :field-b "b"} (get-in wizard [:data :step1]))))))
(deftest destroy-test
(testing "destroy! removes the wizard session"
(let [wizard-id (sut/create! {:foo "bar"})
_ (sut/destroy! wizard-id)]
(is (nil? (sut/get-wizard wizard-id)))))
(testing "destroy! is a no-op for unknown id"
(sut/destroy! "non-existent-id")
(is (nil? (sut/get-wizard "non-existent-id")))))
(deftest get-all-data-test
(testing "get-all-data merges non-map values and map values from all steps"
(let [wizard-id (sut/create! {:client-id 123})
_ (sut/update-step! wizard-id :step1 {:vendor 456})
_ (sut/update-step! wizard-id :step2 {:accounts [{:account 1}]})
all-data (sut/get-all-data wizard-id)]
(is (= {:client-id 123 :vendor 456 :accounts [{:account 1}]} all-data))))
(testing "get-all-data returns nil for unknown id"
(is (nil? (sut/get-all-data "non-existent-id")))))
(deftest session-exists-test
(testing "Session exists after creation"
(let [wizard-id (sut/create! {})]
(is (some? (sut/get-wizard wizard-id)))))
(testing "Session does not exist after destruction"
(let [wizard-id (sut/create! {})
_ (sut/destroy! wizard-id)]
(is (nil? (sut/get-wizard wizard-id)))))
(testing "Session does not exist for random id"
(is (nil? (sut/get-wizard (str (java.util.UUID/randomUUID)))))))

View File

@@ -0,0 +1,104 @@
(ns auto-ap.ssr.transaction.bulk-code-trial-test
(:require
[auto-ap.ssr.components.wizard-trial.state :as ws]
[auto-ap.ssr.transaction.bulk-code-trial :as sut]
[clojure.test :refer [deftest is testing use-fixtures]]
[mount.core :as mount]))
(use-fixtures :each
(fn [test-fn]
(mount/start #'auto-ap.datomic/conn)
(test-fn)
(mount/stop #'auto-ap.datomic/conn)))
(deftest open-trial-test
(testing "open-trial returns modal-response with a form containing expected fields"
(let [response (sut/open-trial {})]
(is (= 200 (:status response)))
(is (= "text/html" (get-in response [:headers "Content-Type"])))
(let [body (:body response)]
(is (string? body))
(is (re-find #"modal-card" body) "Should contain modal card structure")
(is (re-find #"Bulk editing" body) "Should show header with transaction count")
(is (re-find #"Vendor" body) "Form should contain Vendor label")
(is (re-find #"Status" body) "Form should contain Status label")
(is (re-find #"Expense Accounts" body) "Form should contain Expense Accounts heading")
(is (re-find #"Account" body) "Form should contain Account column header")
(is (re-find #"Location" body) "Form should contain Location column header")
(is (re-find #"%" body) "Form should contain percentage column header")
(is (re-find #"Save" body) "Form should contain Save button")
(is (re-find #"New account" body) "Form should contain New account button")
(is (re-find #"name=\"wizard-id\"" body) "Form should contain hidden wizard-id input")
(is (re-find #"name=\"step-key\"" body) "Form should contain hidden step-key input")))))
(deftest submit-trial-valid-test
(testing "submit-trial with valid data returns success response and destroys session"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"
"vendor" "123"
"approval-status" "approved"
"accounts[0][account]" "1"
"accounts[0][location]" "DT"
"accounts[0][percentage]" "50"}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (re-find #"Transactions Coded" (:body response)) "Response should indicate success")
(is (nil? (ws/get-wizard wizard-id)) "Wizard session should be destroyed after successful submit"))))
(deftest submit-trial-invalid-test
(testing "submit-trial with invalid vendor id shows validation error"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"
"vendor" "not-a-number"
"approval-status" "approved"}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (re-find #"error" (:body response)) "Response should contain error markup for invalid vendor")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation")))
(testing "submit-trial with invalid account data shows validation error"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"
"accounts[0][account]" "not-a-number"
"accounts[0][location]" "DT"
"accounts[0][percentage]" "50"}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (re-find #"error" (:body response)) "Response should contain error markup for invalid account")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation")))
(testing "submit-trial with percentage over 100% shows validation error"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"
"accounts[0][account]" "1"
"accounts[0][location]" "DT"
"accounts[0][percentage]" "150"}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (re-find #"error" (:body response)) "Response should contain error markup for percentage > 100%")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation"))))
(deftest submit-trial-empty-test
(testing "submit-trial with empty form data shows validation errors"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (not (re-find #"Bulk code applied" (:body response))) "Empty form should not succeed")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation")))
(testing "submit-trial with no account rows selected shows validation errors"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"
"vendor" ""
"approval-status" ""}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (not (re-find #"Bulk code applied" (:body response))) "Form with empty values should not succeed")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation"))))