refactor(ssr): Phase 6b — migrate Transaction Rule wizard onto the session engine; de-cursor

Proves the Phase-6a wizard engine against a real 2-step modal: the Transaction
Rule wizard (edit step + read-only test/preview step) now runs on wizard2 /
wizard-state, fully de-cursored.

What changed
- Wizard machinery removed: deleted the EditModal / TestModal /
  TransactionRuleWizard defrecords (mm/ModalWizardStep + LinearModalWizard),
  MultiStepFormState, the EDN snapshot, and the step-params[...] prefix. Replaced
  with a data-driven `transaction-rule-wizard-config` (two steps + init-fn +
  done-fn) driven by the engine.
- De-cursored the whole edit form (82 fc/ refs -> 0): every field reads explicit
  data + path->name2; errors via a bound *errors* / ferr. The account row's Alpine
  cross-field dispatch wiring (clientId -> accountId -> location) is preserved
  verbatim — only the data plumbing moved off the cursor.
- The test step's :render reads :all-data (the engine's get-all), so the
  formtools "combine at the end" mechanism feeds the preview table.
- Routes 4 -> 2: open-rule-wizard (new + edit), save-step (every transition via the
  engine's `direction` field). The dedicated `navigate` route is deleted.
- decode-rule-form select-keys to the schema's known keys so the engine's nav
  fields (wizard-id/current-step/direction) don't leak into the upserted entity.

Scorecard (admin/transaction_rules.clj): fc/ 82->0, mm/ 20->0, defrecords 3->0,
LOC 1000->964, routes 4->2.

Scope note: the de-cursored edit step keeps com/* Hiccup leaf components (not yet
sc/* Selmer); the value here was removing fc/ + mm/ and proving the engine, not
re-templating the conditional/Alpine-cross-field layout. Hiccup-in-render is a
documented partial; the com/ -> sc/ swap is a mechanical follow-up.

Verification: rule spec 4/4 (new + edit dialogs, advance-to-test preview, save);
full Playwright suite 55/55; cljfmt clean. Skill fed: scorecard row + narrative
(engine's first real modal; generalizes for a one-data-step wizard); gotchas
(strip engine nav fields in decode, new-row temp-id, direction-button nav).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 11:12:33 -07:00
parent 15ff9855c1
commit 107a02f4f1
5 changed files with 363 additions and 356 deletions

View File

@@ -265,6 +265,24 @@ carve-out. Verify with `load-file` (compile) + `lein cljfmt check`, not by eyeba
diff is contained with `git diff -U0 <file> | grep '^@@'` — the hunks should cluster only where you
edited (requires + the modal region), nothing else.
## Wiring a modal onto the wizard2 engine — three traps that cost a debug cycle each
1. **Strip the engine's nav fields in the step `:decode`.** The posted form carries
`wizard-id` / `current-step` / `direction` alongside the real fields. If the step schema is
an open `:map` (most are), `mc/decode` keeps them, they ride into `get-all`, and the save's
`:upsert-entity` dies with `:db.error/not-an-entity ... :current-step`. Fix: `select-keys`
the decode to the schema's known top-level keys (the same allowlist trick as the flat-form
migrations). Symptom is a **500 on save**, not a validation message.
2. **New repeated-row needs a temp `:db/id` or the step can't advance.** If the row schema
requires `[:db/id [:or entity-id temp-id]]`, an added row with no id fails per-step
validation, so the engine re-renders the *same* step instead of advancing — looks like "the
Next/Test button does nothing." Give new rows `(str (java.util.UUID/randomUUID))`.
3. **Nav is a `direction` field, and Back/Save are both submit buttons.** The footer buttons
are plain `<button type="submit" name="direction" value="next|back|submit">`; the clicked
one's value rides in the POST and the engine branches on it. In tests, a selector like
`button:has-text("Save"), button[type=submit]` also matches **Back** (also a submit) and
`.first()` clicks Back — target the button by its text/value precisely.
## Scorecard exceptions (ratchet violations with a reason)
**Heuristic 4 (LOC net ↓) — exception (Phase 3, Transaction Bulk Code: 420→506).** When the

View File

@@ -161,3 +161,29 @@ Each migration appends one row (after-numbers), referencing the before in the di
> helper. Per-row ids are generated from the row index the form already uses for field names
> (`path->name2`), so server and markup agree by construction. Whole-form swap (Rule 3) covers
> structural changes (add/remove row). This is now the cookbook default; see `swap-doctrine.md`.
> **Phase 6 — the wizard engine, and its first real modal (Transaction Rule).** The inflection
> phase. (a) **Engine** (`6a`, committed separately): `wizard-state` + `wizard2`, the Django
> `formtools` SessionStorage model, REPL-proven before any modal touched it. (b) **First real
> modal** (`6b`): the Transaction Rule wizard (edit step + read-only test/preview step) migrated
> onto the engine and **fully de-cursored** like Phases 2-5. Scorecard (`admin/transaction_rules.clj`):
> `fc/` cursor refs **82 -> 0**, `mm/` coupling **20 -> 0**, defrecords **3 -> 0** (EditModal /
> TestModal / TransactionRuleWizard all gone), LOC 1000 -> 964, the 4 wizard routes
> (open/navigate/save + per-dialog) collapse to **2** (`open-rule-wizard` for new+edit,
> `save-step` for every transition). Parity held: rule spec **4/4**, full suite **55/55**.
>
> **The engine generalizes even for a one-data-step "wizard".** Transaction Rule is *edit + a
> read-only preview of the same entity*, not two independent data steps — so it exercises the
> engine's render / navigation / `:all-data`-preview path but not the cross-step *merge* (that
> waits for Phase 7's Invoice Pay). The test step's `:render` reads `:all-data` (the engine's
> `get-all`), which here is just the edit step's rule — so the formtools "combine at the end"
> mechanism is exactly what feeds the preview table. Nav is the engine's `direction` field
> (plain submit buttons `name="direction" value="next|back|submit"`), so the per-step
> `navigate` route is deleted.
>
> **Note (scope):** the de-cursored edit step keeps `com/*` Hiccup leaf components rather than
> porting to `sc/*` Selmer partials — the modal's value was removing `fc/` + `mm/` and proving
> the engine, not re-templating its (conditional, Alpine-cross-field) layout. Hiccup-in-render
> (heuristic 9) is therefore a documented partial here; the leaf-component `com/ -> sc/` swap is
> a mechanical follow-up. The Alpine cross-field dispatch wiring (clientId -> accountId ->
> location) was preserved verbatim — de-cursoring touched only the data plumbing.

View File

@@ -52,7 +52,7 @@ async function addAccount(page: any, accountId: string) {
}
async function fillDescription(page: any, desc: string) {
await page.locator('#wizard-form input[name*="[transaction-rule/description]"]').first().fill(desc);
await page.locator('#wizard-form input[name="transaction-rule/description"]').first().fill(desc);
}
// Approval status is required to advance/save; the radio-card's first option is "Approved".
@@ -89,7 +89,7 @@ test.describe('Transaction Rule wizard (characterization)', () => {
test('Edit dialog pre-populates the seeded rule', async ({ page }) => {
await navigateToRules(page);
await openEditDialog(page);
const desc = page.locator('#wizard-form input[name*="[transaction-rule/description]"]').first();
const desc = page.locator('#wizard-form input[name="transaction-rule/description"]').first();
await expect(desc).toHaveValue('ZZRULEMATCH');
});
@@ -116,8 +116,8 @@ test.describe('Transaction Rule wizard (characterization)', () => {
await addAccount(page, info.accounts['test-account'].toString());
await selectApproved(page);
await clickTest(page);
// Save from the test step
await page.locator('#wizard-form button:has-text("Save"), #wizard-form button[type="submit"]').first().click();
// Save from the test step (the precise Save button, not Back which is also submit)
await page.locator('#wizard-form button:has-text("Save")').first().click();
await page.waitForTimeout(1000);
// modal closed + a new rule row added
await expect(page.locator('#wizard-form')).toBeHidden();

View File

@@ -14,21 +14,22 @@
[auto-ap.rule-matching :as rm]
[auto-ap.solr :as solr]
[auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
[auto-ap.ssr.company :refer [bank-account-typeahead*]]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.multi-modal :as mm]
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.components.wizard-state :as ws]
[auto-ap.ssr.components.wizard2 :as wizard2]
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.nested-form-params :as nfp]
[auto-ap.ssr.svg :as svg]
[auto-ap.ssr.utils
:refer [apply-middleware-to-all-handlers
:refer [->db-id apply-middleware-to-all-handlers
default-grid-fields-schema entity-id
field-validation-error form-validation-error
html-response many-entity modal-response money percentage
ref->enum-schema ref->radio-options regex temp-id
wrap-entity wrap-merge-prior-hx wrap-schema-enforce]]
html-response main-transformer many-entity modal-response money
path->name2 percentage ref->enum-schema ref->radio-options regex
temp-id wrap-entity wrap-form-4xx-2 wrap-merge-prior-hx
wrap-schema-enforce]]
[auto-ap.time :as atime]
[auto-ap.utils :refer [dollars=]]
[bidi.bidi :as bidi]
@@ -37,7 +38,23 @@
[clojure.string :as str]
[datomic.api :as dc]
[malli.core :as mc]
[malli.util :as mut]))
[malli.error :as me]))
;; ---------------------------------------------------------------------------
;; Field-name / error helpers for the (de-cursored) rule form. No step-params
;; prefix -- posted fields decode straight into form-schema. Mirrors edit.clj.
;; ---------------------------------------------------------------------------
(def ^:dynamic *errors*
"Humanized form errors for the current rule render, keyed by form-schema paths.
Bound by render-edit-step from the engine ctx :errors."
{})
(defn- fname [& path] (apply path->name2 path))
(defn- ferr [& path] (get-in *errors* (vec path)))
(defn- err? [& path] (boolean (seq (apply ferr path))))
(defn- account-field-name [index field] (path->name2 :transaction-rule/accounts index field))
(defn- account-field-errors [index field] (ferr :transaction-rule/accounts index field))
(def query-schema (mc/schema
[:maybe
@@ -437,67 +454,63 @@
client-id))))))})])
(defn- transaction-rule-account-row*
[account client-id client-locations]
(com/data-grid-row
(-> {:x-data (hx/json {:accountId (or (:db/id (fc/field-value (:transaction-rule-account/account account)))
(fc/field-value (:transaction-rule-account/account account)))
:location (fc/field-value (:transaction-rule-account/location account))
:show (boolean (not (fc/field-value (:new? account))))})
:data-key "show"
:x-ref "p"}
hx/alpine-mount-then-appear)
(let [account-name (fc/field-name (:transaction-rule-account/account account))]
(list
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
(fc/with-field :transaction-rule-account/account
(com/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)}
[:div {:hx-trigger "changed"
:hx-target "next div"
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId || '', value: event.detail.accountId || ''}" account-name)
:hx-get (str (bidi/path-for ssr-routes/only-routes ::route/account-typeahead))
:x-init "$watch('clientId', cid => $dispatch('changed', $data));"}]
(account-typeahead* {:value (fc/field-value)
:client-id client-id
:name (fc/field-name)
:x-model "accountId"}))))
(fc/with-field :transaction-rule-account/location
(com/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)
:x-data (hx/json {:location (fc/field-value)})}
;; TODO make this thing into a component
[:div {:hx-trigger "changed"
:hx-target "next *"
:hx-swap "outerHTML"
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId || '', 'account-id': event.detail.accountId || '', value: event.detail.location || ''}" (fc/field-name))
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
:x-init "$watch('clientId', cid => $dispatch('changed', $data)); $watch('accountId', cid => $dispatch('changed', $data) )"}]
(location-select* {:name (fc/field-name)
:account-location (:account/location (cond->> (:transaction-rule-account/account @account)
(nat-int? (:transaction-rule-account/account @account)) (dc/pull (dc/db conn)
'[:account/location])))
:client-locations client-locations
:x-model "location"
:value (fc/field-value)}))))
(fc/with-field :transaction-rule-account/percentage
(com/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)}
(com/money-input {:name (fc/field-name)
:class "w-16"
:value (some-> (fc/field-value)
(* 100)
(long))}))))))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))
"One account-coding row, from a plain account map + its index (no cursor). The Alpine
cross-field dispatch wiring (clientId -> accountId -> location) is preserved verbatim;
only the field names/values move from the form cursor to explicit data + path->name2."
[account index client-id client-locations]
(let [acct (:transaction-rule-account/account account)
acct-id (if (map? acct) (:db/id acct) acct)
aname (account-field-name index :transaction-rule-account/account)
lname (account-field-name index :transaction-rule-account/location)]
(com/data-grid-row
(-> {:x-data (hx/json {:accountId acct-id
:location (:transaction-rule-account/location account)
:show (boolean (not (:new? account)))})
:data-key "show"
:x-ref "p"}
hx/alpine-mount-then-appear)
(com/hidden {:name (account-field-name index :db/id)
:value (:db/id account)})
(com/data-grid-cell
{}
(com/validated-field
{:errors (account-field-errors index :transaction-rule-account/account)}
[:div {:hx-trigger "changed"
:hx-target "next div"
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId || '', value: event.detail.accountId || ''}" aname)
:hx-get (str (bidi/path-for ssr-routes/only-routes ::route/account-typeahead))
:x-init "$watch('clientId', cid => $dispatch('changed', $data));"}]
(account-typeahead* {:value acct-id
:client-id client-id
:name aname
:x-model "accountId"})))
(com/data-grid-cell
{}
(com/validated-field
{:errors (account-field-errors index :transaction-rule-account/location)
:x-data (hx/json {:location (:transaction-rule-account/location account)})}
[:div {:hx-trigger "changed"
:hx-target "next *"
:hx-swap "outerHTML"
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId || '', 'account-id': event.detail.accountId || '', value: event.detail.location || ''}" lname)
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
:x-init "$watch('clientId', cid => $dispatch('changed', $data)); $watch('accountId', cid => $dispatch('changed', $data) )"}]
(location-select* {:name lname
:account-location (:account/location (when (nat-int? acct-id)
(dc/pull (dc/db conn) '[:account/location] acct-id)))
:client-locations client-locations
:value (:transaction-rule-account/location account)})))
(com/data-grid-cell
{}
(com/validated-field
{:errors (account-field-errors index :transaction-rule-account/percentage)}
(com/money-input {:name (account-field-name index :transaction-rule-account/percentage)
:class "w-16"
:value (some-> (:transaction-rule-account/percentage account)
(* 100)
(long))})))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))))
(defn all-ids-not-locked [all-ids]
(->> all-ids
@@ -638,269 +651,243 @@
(html-response (row* (:identity request) entity {:delete-after-settle? true :class "live-removed"})
:headers {"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id entity))}))
(defrecord EditModal [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Edit")
;; ---------------------------------------------------------------------------
;; The rule wizard on the data-driven session engine (wizard2 / wizard-state),
;; replacing the EditModal/TestModal/TransactionRuleWizard records +
;; MultiStepFormState + the EDN-snapshot round-trip.
;; ---------------------------------------------------------------------------
(step-key [_]
:edit)
(defn- rule-modal-card [& {:keys [head body footer]}]
(com/modal-card-advanced
{}
(com/modal-header {} head)
(com/modal-body {} body)
(com/modal-footer {} footer)))
(edit-path [_ _] [])
(defn- rule-nav
"Footer step controls. Buttons post a `direction` field the engine reads:
next = validate + advance, back = no validate, submit = finish."
[{:keys [next back? save?]}]
[:div.flex.justify-end.gap-x-4
[:div#form-errors]
(when back?
(com/button {:type "submit" :name "direction" :value "back" :class "w-24"} "Back"))
(when next
(com/button {:type "submit" :name "direction" :value "next" :color :primary :class "w-24"} next))
(when save?
(com/button {:type "submit" :name "direction" :value "submit" :color :primary :class "w-24" :x-ref "next"} "Save"))])
(step-schema [_]
(mm/form-schema linear-wizard))
(defn render-edit-step
"Edit step: the rule form, de-cursored (explicit data + path->name2 + *errors*)."
[{:keys [step-data errors]}]
(binding [*errors* (or errors {})]
(let [rule (or step-data {})
rule-client (:transaction-rule/client rule)
client-id (if (map? rule-client) (:db/id rule-client) rule-client)
client-locations (some->> client-id (pull-attr (dc/db conn) :client/locations))
accounts (vec (:transaction-rule/accounts rule))]
(rule-modal-card
:head "Transaction rule"
:body [:div#my-form {:x-trap "true"}
[:fieldset {:class "hx-disable"
:x-data (hx/json {:clientId client-id})}
[:div.space-y-1
(when-let [id (:db/id rule)]
(com/hidden {:name "db/id" :value id}))
(com/validated-field {:label "Description" :errors (ferr :transaction-rule/description)}
(com/text-input {:name (fname :transaction-rule/description)
:error? (err? :transaction-rule/description)
:x-init "$el.focus()"
:placeholder "HOME DEPOT"
:class "w-96"
:value (:transaction-rule/description rule)}))
[:div.filters {:x-data (hx/json {:clientFilter (boolean (:transaction-rule/client rule))
:clientGroupFilter (boolean (:transaction-rule/client-group rule))
:bankAccountFilter (boolean (:transaction-rule/bank-account rule))
:amountFilter (boolean (or (:transaction-rule/amount-gte rule) (:transaction-rule/amount-lte rule)))
:domFilter (boolean (or (:transaction-rule/dom-gte rule) (:transaction-rule/dom-lte rule)))})}
[:div.flex.gap-2.mb-2
(com/a-button {"@click" "clientFilter=true" "x-show" "!clientFilter"} "Filter client")
(com/a-button {"@click" "clientGroupFilter=true" "x-show" "!clientGroupFilter"} "Filter client group")
(com/a-button {"@click" "bankAccountFilter=true" "x-show" "clientFilter && !bankAccountFilter"} "Filter bank account")
(com/a-button {"@click" "amountFilter=true" "x-show" "!amountFilter"} "Filter amount")
(com/a-button {"@click" "domFilter=true" "x-show" "!domFilter"} "Filter day of month")]
(com/validated-field
(-> {:label "Client" :errors (ferr :transaction-rule/client) :x-show "clientFilter"} (hx/alpine-appear))
[:div.w-96
(com/typeahead {:name (fname :transaction-rule/client)
:error? (err? :transaction-rule/client)
:class "w-96" :placeholder "Search..."
:url (bidi/path-for ssr-routes/only-routes :company-search)
:x-model "clientId"
:value rule-client
:content-fn (fn [c] (pull-attr (dc/db conn) :client/name c))})])
(com/validated-field
(-> {:label "Client Group" :errors (ferr :transaction-rule/client-group) :x-show "clientGroupFilter"} (hx/alpine-appear))
[:div.w-96
(com/text-input {:name (fname :transaction-rule/client-group)
:error? (err? :transaction-rule/client-group)
:class "w-24" :placeholder "NTG"
:value (:transaction-rule/client-group rule)})])
(com/validated-field
(-> {:label "Bank Account" :errors (ferr :transaction-rule/bank-account) :x-show "bankAccountFilter"} hx/alpine-appear)
[:div.w-96
[:div#bank-account-changer {:hx-get (bidi/path-for ssr-routes/only-routes :bank-account-typeahead)
:hx-trigger "changed"
:hx-target "next *"
:hx-include "#bank-account-changer"
:hx-swap "outerHTML"
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId}" (fname :transaction-rule/bank-account))
:x-init "$watch('clientId', cid => $dispatch('changed', $data))"}]
(bank-account-typeahead* {:client-id client-id
:name (fname :transaction-rule/bank-account)
:value (:transaction-rule/bank-account rule)})])
(com/field (-> {:label "Amount" :x-show "amountFilter"} hx/alpine-appear)
[:div.flex.gap-2
[:div.flex.flex-col
(com/money-input {:name (fname :transaction-rule/amount-gte) :placeholder ">=" :class "w-24" :value (:transaction-rule/amount-gte rule)})
(com/errors {:errors (ferr :transaction-rule/amount-gte)})]
[:div.flex.flex-col
(com/money-input {:name (fname :transaction-rule/amount-lte) :placeholder "<=" :class "w-24" :value (:transaction-rule/amount-lte rule)})
(com/errors {:errors (ferr :transaction-rule/amount-lte)})]])
(com/field (-> {:label "Day of month" :x-show "domFilter"} hx/alpine-appear)
[:div.flex.gap-2
(com/validated-field {:errors (ferr :transaction-rule/dom-gte)}
(com/int-input {:name (fname :transaction-rule/dom-gte) :placeholder ">=" :class "w-24" :value (:transaction-rule/dom-gte rule)}))
(com/validated-field {:errors (ferr :transaction-rule/dom-lte)}
(com/int-input {:name (fname :transaction-rule/dom-lte) :placeholder ">=" :class "w-24" :value (:transaction-rule/dom-lte rule)}))])]
[:h2.text-lg "Outcomes"]
(com/validated-field {:label "Assign Vendor" :errors (ferr :transaction-rule/vendor)}
[:div.w-96
(com/typeahead {:name (fname :transaction-rule/vendor)
:placeholder "Search..."
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
:class "w-96"
:value (:transaction-rule/vendor rule)
:content-fn #(pull-attr (dc/db conn) :vendor/name %)})])
(com/validated-field
{:errors (ferr :transaction-rule/accounts)}
(com/data-grid {:headers [(com/data-grid-header {} "Account")
(com/data-grid-header {:class "w-32"} "Location")
(com/data-grid-header {:class "w-16"} "%")
(com/data-grid-header {:class "w-16"})]}
(map-indexed (fn [i a] (transaction-rule-account-row* a i client-id client-locations)) accounts)
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-account)
:index (count accounts)
:tr-params (hx/bind-alpine-vals {} {"client-id" "clientId"})}
"New account")))
(com/validated-field {:label "Approval status" :errors (ferr :transaction-rule/transaction-approval-status)}
(com/radio-card {:options (ref->radio-options "transaction-approval-status")
:value (:transaction-rule/transaction-approval-status rule)
:name (fname :transaction-rule/transaction-approval-status)
:size :small
:orientation :horizontal}))]]]
:footer (rule-nav {:next "Test"})))))
(render-step [this request]
(mm/default-render-step
linear-wizard this
:head "Transaction rule"
:body (mm/default-step-body {}
[:div#my-form {:x-trap "true"}
[:fieldset {:class "hx-disable"
:x-data (hx/json {:clientId (or (:db/id (:transaction-rule/client (fc/field-value)))
(:transaction-rule/client (fc/field-value)))})}
(defn render-test-step
"Test step: a read-only preview of the transactions the rule (the combined session
data) matches. The query/render is reused unchanged."
[{:keys [all-data request]}]
(rule-modal-card
:head [:div.p-2.flex.space-x-4 [:div "Transaction Rule"] [:div ">"] [:div "Results"]]
:body [:div.space-y-1 {:class "w-[850px] h-[600px]"}
(transaction-rule-test-table* {:entity all-data :clients (:clients request)})]
:footer (rule-nav {:back? true :save? true})))
[:div.space-y-1
(when-let [id (:db/id (fc/field-value))]
(com/hidden {:name "db/id"
:value id}))
(fc/with-field :transaction-rule/description
(com/validated-field {:label "Description"
:errors (fc/field-errors)}
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:x-init "$el.focus()"
:placeholder "HOME DEPOT"
:class "w-96"
:value (fc/field-value)})))
[:div.filters {:x-data (hx/json {:clientFilter (boolean (fc/field-value (:transaction-rule/client fc/*current*)))
:clientGroupFilter (boolean (fc/field-value (:transaction-rule/client-group fc/*current*)))
:bankAccountFilter (boolean (fc/field-value (:transaction-rule/bank-account fc/*current*)))
:amountFilter (boolean (or (fc/field-value (:transaction-rule/amount-gte fc/*current*))
(fc/field-value (:transaction-rule/amount-lte fc/*current*))))
:domFilter (boolean (or (fc/field-value (:transaction-rule/dom-gte fc/*current*))
(fc/field-value (:transaction-rule/dom-lte fc/*current*))))})}
(def ^:private rule-form-keys
"Top-level keys form-schema recognises. The posted form also carries the engine's nav
fields (wizard-id / current-step / direction); without this allowlist they'd ride into
the decoded rule (form-schema is an open :map) and break the upsert."
[:db/id :transaction-rule/client :transaction-rule/client-group :transaction-rule/description
:transaction-rule/bank-account :transaction-rule/amount-gte :transaction-rule/amount-lte
:transaction-rule/dom-gte :transaction-rule/dom-lte :transaction-rule/vendor
:transaction-rule/transaction-approval-status :transaction-rule/accounts])
[:div.flex.gap-2.mb-2
(com/a-button {"@click" "clientFilter=true"
"x-show" "!clientFilter"} "Filter client")
(com/a-button {"@click" "clientGroupFilter=true"
"x-show" "!clientGroupFilter"} "Filter client group")
(com/a-button {"@click" "bankAccountFilter=true"
"x-show" "clientFilter && !bankAccountFilter"} "Filter bank account")
(com/a-button {"@click" "amountFilter=true"
"x-show" "!amountFilter"} "Filter amount")
(com/a-button {"@click" "domFilter=true"
"x-show" "!domFilter"} "Filter day of month")]
(fc/with-field :transaction-rule/client
(defn- decode-rule-form
"Parse the posted edit-step fields straight into the rule map (no step-params prefix);
strip the stray engine nav fields."
[request]
(let [nested (:form-params (nfp/nested-params-request request {}))
decoded (mc/decode form-schema nested main-transformer)]
(if (map? decoded) (select-keys decoded rule-form-keys) {})))
(com/validated-field
(-> {:label "Client"
:errors (fc/field-errors)
:x-show "clientFilter"}
(hx/alpine-appear))
[:div.w-96
(com/typeahead {:name (fc/field-name)
:error? (fc/error?)
:class "w-96"
:placeholder "Search..."
:url (bidi/path-for ssr-routes/only-routes :company-search)
:x-model "clientId"
:value (fc/field-value)
:content-fn (fn [c] (pull-attr (dc/db conn) :client/name c))})]))
(fc/with-field :transaction-rule/client-group
(defn- rule-form-errors
"Per-step validation: schema-validate so an invalid form can't advance to the test step
(matches the old navigate-validates behavior). Returns a humanized errors map or nil.
The full custom checks (percentage sum, location, bank-account) run at save."
[rule _request]
(when-not (mc/validate form-schema rule)
(me/humanize (mc/explain form-schema rule))))
(com/validated-field
(-> {:label "Client Group"
:errors (fc/field-errors)
:x-show "clientGroupFilter"}
(hx/alpine-appear))
[:div.w-96
(com/text-input {:name (fc/field-name)
:error? (fc/error?)
:class "w-24"
:placeholder "NTG"
:value (fc/field-value)})]))
(let [rule-client (fc/field-value (:transaction-rule/client fc/*current*))]
(fc/with-field :transaction-rule/bank-account
(com/validated-field
(-> {:label "Bank Account"
:errors (fc/field-errors)
:x-show "bankAccountFilter"}
hx/alpine-appear)
[:div.w-96
[:div#bank-account-changer {:hx-get (bidi/path-for ssr-routes/only-routes :bank-account-typeahead)
:hx-trigger "changed"
:hx-target "next *"
:hx-include "#bank-account-changer"
:hx-swap "outerHTML"
(defn save-rule!
"Engine done-fn: validate + upsert the rule, then return the grid row + modalclose."
[all-data request]
(validate-transaction-rule all-data)
(let [editing? (some? (:db/id all-data))
entity (cond-> all-data
(:transaction-rule/client-group all-data) (update :transaction-rule/client-group str/upper-case)
(not editing?) (assoc :db/id "new")
true (assoc :transaction-rule/note (entity->note all-data)))
{:keys [tempids]} (audit-transact [[:upsert-entity entity]] (:identity request))
saved (dc/pull (dc/db conn) default-read (or (get tempids (:db/id entity)) (:db/id entity)))]
(html-response
(row* (:identity request) saved {:flash? true})
:headers (cond-> {"hx-trigger" "modalclose"}
(not editing?) (assoc "hx-retarget" "#entity-table tbody" "hx-reswap" "afterbegin")
editing? (assoc "hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id saved)) "hx-reswap" "outerHTML")))))
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId}" (fc/field-name))
:x-init "$watch('clientId', cid => $dispatch('changed', $data))"}]
(def transaction-rule-wizard-config
{:name :transaction-rule
:form-id "wizard-form"
:submit-route (bidi/path-for ssr-routes/only-routes ::route/save)
:form-attrs {:hx-ext "response-targets"
:hx-target-400 "#form-errors"}
:init-fn (fn [request]
{:context {}
:init-data (when-let [e (:entity request)] {:edit e})})
:steps [{:key :edit
:decode decode-rule-form
:validate rule-form-errors
:render render-edit-step
:next (fn [_] :test)}
{:key :test
:decode (fn [_] {})
:render render-test-step
:next (fn [_] :done)}]
:done-fn save-rule!})
(bank-account-typeahead* {:client-id (or (:db/id rule-client) rule-client)
:name (fc/field-name)
:value (fc/field-value)})])))
(defn open-rule-wizard
"Open handler (new or edit): create the wizard instance, render its first step, and
wrap it in the modal shell the stack expects."
[request]
(let [cfg transaction-rule-wizard-config
{:keys [context init-data]} ((:init-fn cfg) request)
[id session'] (ws/create-wizard! (:session request) (:name cfg)
{:first-step :edit :context context :init-data init-data})
form (wizard2/render-wizard {:config cfg :wizard-id id :session session' :request request})]
(-> (modal-response [:div#transitioner.flex-1 form])
(assoc :session session'))))
(com/field (-> {:label "Amount"
:x-show "amountFilter"}
hx/alpine-appear)
[:div.flex.gap-2
(fc/with-field :transaction-rule/amount-gte
[:div.flex.flex-col
(com/money-input {:name (fc/field-name)
:placeholder ">="
:class "w-24"
:value (fc/field-value)})
(com/errors {:errors (fc/field-errors)})])
(fc/with-field :transaction-rule/amount-lte
[:div.flex.flex-col
(com/money-input {:name (fc/field-name)
:placeholder "<="
:class "w-24"
:value (fc/field-value)})
(com/errors {:errors (fc/field-errors)})])])
(defn save-step
"POST handler for every step transition (next / back / save) -- the engine reads the
`direction` field and either advances, goes back, or finishes via done-fn."
[request]
(wizard2/handle-step-submit transaction-rule-wizard-config request))
(com/field (-> {:label "Day of month"
:x-show "domFilter"}
hx/alpine-appear)
[:div.flex.gap-2
(fc/with-field :transaction-rule/dom-gte
(com/validated-field
{:errors (fc/field-errors)}
(com/int-input {:name (fc/field-name)
:placeholder ">="
:class "w-24"
:value (fc/field-value)})))
(fc/with-field :transaction-rule/dom-lte
(com/validated-field
{:errors (fc/field-errors)}
(com/int-input {:name (fc/field-name)
:placeholder ">="
:class "w-24"
:value (fc/field-value)})))])]
[:h2.text-lg "Outcomes"]
(fc/with-field :transaction-rule/vendor
(com/validated-field {:label "Assign Vendor"
:errors (fc/field-errors)}
[:div.w-96
(com/typeahead {:name (fc/field-name)
:placeholder "Search..."
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
:class "w-96"
:value (fc/field-value)
:content-fn #(pull-attr (dc/db conn) :vendor/name %)})]))
(fc/with-field :transaction-rule/accounts
(com/validated-field
{:errors (fc/field-errors)}
(let [client-locations (some->> (fc/field-value) :transaction-rule/client (pull-attr (dc/db conn) :client/locations))]
(com/data-grid {:headers [(com/data-grid-header {} "Account")
(com/data-grid-header {:class "w-32"} "Location")
(com/data-grid-header {:class "w-16"} "%")
(com/data-grid-header {:class "w-16"})]}
(fc/cursor-map #(transaction-rule-account-row* % (:transaction-rule/client (fc/field-value)) client-locations))
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes
::route/new-account)
:index (count (fc/field-value))
:tr-params (hx/bind-alpine-vals {} {"client-id" "clientId"})}
"New account")))))
(fc/with-field :transaction-rule/transaction-approval-status
(com/validated-field {:label "Approval status"
:errors (fc/field-errors)}
(com/radio-card {:options (ref->radio-options "transaction-approval-status")
:value (fc/field-value)
:name (fc/field-name)
:size :small
:orientation :horizontal})))]]])
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/navigate)
:validation-route ::route/navigate)))
(defrecord TestModal [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Test")
(step-key [_]
:test)
(edit-path [_ _] [])
(step-schema [_]
(mut/select-keys (mm/form-schema linear-wizard) #{}))
(render-step [this request]
(mm/default-render-step
linear-wizard this
:head [:div.p-2.flex.space-x-4 [:div "Transaction Rule"] [:div ">"] [:div "Results"]]
:body [:div.space-y-1 {:class "w-[850px] h-[600px]"}
(transaction-rule-test-table* {:entity (:snapshot (:multi-form-state request))
:clients (:clients request)})]
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/navigate)
:validation-route ::route/navigate)))
(defrecord TransactionRuleWizard [transaction-rule current-step entity]
mm/LinearModalWizard
(hydrate-from-request
[this request]
this
#_(assoc this :entity (:entity request)))
(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 :edit)))
(render-wizard [this {:keys [multi-form-state] :as request}]
(mm/default-render-wizard
this request
:form-params
(-> mm/default-form-props
(assoc (if (get-in multi-form-state [:snapshot :db/id])
:hx-put
:hx-post)
(str (bidi/path-for ssr-routes/only-routes ::route/save))))))
(steps [_]
[:edit
:test])
(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]
(if (= :step step-key-type)
(get {:edit (->EditModal this)
:test (->TestModal this)}
step-key)
nil)))
(form-schema [_] form-schema)
(submit [_ {:keys [multi-form-state request-method identity] :as request}]
(let [transaction-rule (:snapshot multi-form-state)
_ (validate-transaction-rule transaction-rule)
entity (cond-> transaction-rule
(:transaction-rule/client-group transaction-rule) (update :transaction-rule/client-group str/upper-case)
(= :post request-method) (assoc :db/id "new")
true (assoc :transaction-rule/note (entity->note transaction-rule)))
{:keys [tempids]} (audit-transact [[:upsert-entity entity]]
(:identity request))
updated-rule (dc/pull (dc/db conn)
default-read
(or (get tempids (:db/id entity)) (:db/id entity)))]
(html-response
(row* identity updated-rule {:flash? true})
:headers (cond-> {"hx-trigger" "modalclose"}
(= :post request-method) (assoc "hx-retarget" "#entity-table tbody"
"hx-reswap" "afterbegin")
(= :put request-method) (assoc "hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id updated-rule))
"hx-reswap" "outerHTML"))))))
(def rule-wizard (->TransactionRuleWizard nil nil nil))
(defn- new-account
"Render one fresh (de-cursored) account row at the posted index (the data grid's
newRowIndex Alpine counter increments it for repeated adds)."
[request]
(let [idx (-> request :query-params :index)
idx (if (string? idx) (Integer/parseInt idx) idx)
client-id (-> request :query-params :client-id)
client-locations (some->> client-id (pull-attr (dc/db conn) :client/locations))]
(html-response
(transaction-rule-account-row* {:db/id (str (java.util.UUID/randomUUID))
:new? true
:transaction-rule-account/location "Shared"}
idx client-id client-locations))))
(def key->handler
(apply-middleware-to-all-handlers
@@ -911,18 +898,11 @@
(wrap-entity [:route-params :db/id] default-read)
(wrap-schema-enforce :route-params [:map [:db/id entity-id]]))
::route/new-account
(->
(add-new-entity-handler [:step-params :transaction-rule/accounts]
(fn render [cursor request]
(transaction-rule-account-row*
cursor
(:client-id (:query-params request))
(some->> (:client-id (:query-params request)) (pull-attr (dc/db conn) :client/locations))))
(fn build-new-row [base _]
(assoc base :transaction-rule-account/location "Shared")))
(wrap-schema-enforce :query-schema [:map
[:client-id {:optional true}
[:maybe entity-id]]]))
(-> new-account
(wrap-schema-enforce :query-schema [:map
[:index {:optional true} [:maybe nat-int?]]
[:client-id {:optional true}
[:maybe entity-id]]]))
::route/location-select (-> location-select
(wrap-schema-enforce :query-schema [:map
@@ -938,10 +918,7 @@
[:maybe entity-id]]
[:value {:optional true}
[:maybe entity-id]]]))
::route/save (-> mm/submit-handler
(mm/wrap-wizard rule-wizard)
(mm/wrap-decode-multi-form-state)
(wrap-entity [:form-params :db/id] default-read))
::route/save save-step
::route/execute (-> execute
(wrap-entity [:route-params :db/id] default-read)
@@ -971,24 +948,11 @@
(wrap-entity [:route-params :db/id] default-read)
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
::route/navigate (-> mm/next-handler
(mm/wrap-wizard rule-wizard)
(mm/wrap-decode-multi-form-state))
::route/edit-dialog (-> mm/open-wizard-handler
(mm/wrap-wizard rule-wizard)
(mm/wrap-init-multi-form-state (fn [request]
(mm/->MultiStepFormState (:entity request)
[]
(:entity request))))
::route/edit-dialog (-> open-rule-wizard
(wrap-entity [:route-params :db/id] default-read)
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
::route/new-dialog (-> mm/open-wizard-handler
(mm/wrap-wizard rule-wizard)
(mm/wrap-init-multi-form-state (fn [_]
(mm/->MultiStepFormState {}
[]
{}))))})
::route/new-dialog open-rule-wizard})
(fn [h]
(-> h
(wrap-copy-qp-pqp)

View File

@@ -10,7 +10,6 @@
"/account/typeahead" ::account-typeahead
"/test" ::test
"/new" {:get ::new-dialog}
"/navigate" ::navigate
["/" [#"\d+" :db/id] "/edit"] ::edit-dialog
["/" [#"\d+" :db/id] "/delete"] ::delete
["/" [#"\d+" :db/id] "/run"] {:get ::execute-dialog