Compare commits
1 Commits
c892719bd1
...
integreat-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5452b8b779 |
22
.opencode/package-lock.json
generated
22
.opencode/package-lock.json
generated
@@ -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"
|
||||
|
||||
298
docs/superpowers/plans/2025-01-15-wizard-phase-i-trial.md
Normal file
298
docs/superpowers/plans/2025-01-15-wizard-phase-i-trial.md
Normal 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?**
|
||||
1283
docs/superpowers/plans/2025-01-15-wizard-refactor.md
Normal file
1283
docs/superpowers/plans/2025-01-15-wizard-refactor.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -118,7 +118,12 @@
|
||||
"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": {
|
||||
"read": "allow",
|
||||
|
||||
54
src/clj/auto_ap/ssr/components/wizard_trial/core.clj
Normal file
54
src/clj/auto_ap/ssr/components/wizard_trial/core.clj
Normal 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}))))))
|
||||
35
src/clj/auto_ap/ssr/components/wizard_trial/state.clj
Normal file
35
src/clj/auto_ap/ssr/components/wizard_trial/state.clj
Normal 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))
|
||||
@@ -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)
|
||||
|
||||
172
src/clj/auto_ap/ssr/transaction/bulk_code_trial.clj
Normal file
172
src/clj/auto_ap/ssr/transaction/bulk_code_trial.clj
Normal 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))
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
114
test/clj/auto_ap/ssr/components/wizard_trial/core_test.clj
Normal file
114
test/clj/auto_ap/ssr/components/wizard_trial/core_test.clj
Normal 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"))))
|
||||
76
test/clj/auto_ap/ssr/components/wizard_trial/state_test.clj
Normal file
76
test/clj/auto_ap/ssr/components/wizard_trial/state_test.clj
Normal 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)))))))
|
||||
104
test/clj/auto_ap/ssr/transaction/bulk_code_trial_test.clj
Normal file
104
test/clj/auto_ap/ssr/transaction/bulk_code_trial_test.clj
Normal 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"))))
|
||||
Reference in New Issue
Block a user