Compare commits
3 Commits
2bf87056d7
...
107a02f4f1
| Author | SHA1 | Date | |
|---|---|---|---|
| 107a02f4f1 | |||
| 15ff9855c1 | |||
| d56056d66c |
@@ -140,7 +140,52 @@ discarded on completion (`forget`).
|
||||
State lives in the Ring session, scoped to true multi-step wizards (plain forms hold
|
||||
none). Lifetime follows the session; `forget` on completion prevents session bloat. For
|
||||
long-lived wizards, confirm the session backend (in-memory vs. durable) is acceptable or
|
||||
pick a durable store. **This engine is built in Phase 6** (Transaction Rule) — until then
|
||||
this file describes the target; validate `components/wizard_state.clj` +
|
||||
`components/wizard2.clj` against it when they land, and update this doc from the real
|
||||
implementation.
|
||||
pick a durable store.
|
||||
|
||||
## The engine — REALIZED (Phase 6)
|
||||
|
||||
Built and REPL-proven in Phase 6 as two namespaces (no protocols, no defrecords):
|
||||
|
||||
- **`auto-ap.ssr.components.wizard-state`** — the pure session-storage layer (the skeleton
|
||||
above, fleshed out): `create-wizard!` / `instance` / `exists?` / `current-step` /
|
||||
`context` / `step-data` / `put-step` (replace) / `set-step` / `get-all` / `forget`. Each
|
||||
is `session -> session'` (or a read); nothing mutates global state. `:context` holds
|
||||
read-only data the steps need (e.g. an entity id) **outside** `:step-data`, so it never
|
||||
gets merged into the combined result.
|
||||
- **`auto-ap.ssr.components.wizard2`** — the engine: `open-wizard`, `render-wizard`,
|
||||
`handle-step-submit`, and the `wizard-form` shell. A wizard is a **config map**:
|
||||
|
||||
```clojure
|
||||
{:name :vendor :form-id "wizard-form" :submit-route "<resolved url>"
|
||||
:init-fn (fn [request] {:context {...} :init-data {step-key data}})
|
||||
:done-fn (fn [all-data request] ring-response)
|
||||
:steps [{:key :info
|
||||
:decode (fn [request] -> data-map) ; parse this step's posted fields
|
||||
:validate (fn [data request] -> errors|nil) ; optional
|
||||
:render (fn [ctx] -> hiccup) ; step body; engine wraps the <form>
|
||||
:next (fn [data] -> next-step-key | :done)}
|
||||
...]}
|
||||
```
|
||||
|
||||
The step's `:render` gets `{:wizard-id :current-step :context :all-data :step-data
|
||||
:errors :request :config}`. `:all-data` (every step combined so far) is exactly what a
|
||||
**read-only summary/preview step** consumes. Nav buttons post a `direction` field:
|
||||
`"next"` (validate+advance via `:next`), `"back"` (no validate), `"submit"` (== next, for
|
||||
the last step). Only `wizard-id` + `current-step` ride in the form — **no snapshot**.
|
||||
|
||||
**Two routes per wizard:** `(partial open-wizard config)` (GET) and
|
||||
`(partial handle-step-submit config)` (POST). No `wrap-wizard` / `wrap-decode-multi-form-state`
|
||||
stack — the engine threads the session itself and `(assoc resp :session session')`.
|
||||
|
||||
**Proven via REPL** (lifecycle, before any modal used it): open seeds session state and
|
||||
renders step 1 with no accumulated data in the form; next stores `{step-key data}` and
|
||||
advances; an invalid step re-renders itself with errors (no advance); the final step's
|
||||
`:done` calls `done-fn` with the combined `get-all` data and `forget`s the instance; back
|
||||
navigates without validating; an unknown/expired `wizard-id` re-opens fresh instead of
|
||||
500-ing. See the lifecycle eval in the Phase 6 commit message.
|
||||
|
||||
**Note (Phase 6 fit).** Transaction Rule itself is *edit + read-only preview of one
|
||||
entity*, not a true multi-data-step flow — so it exercises the engine's render/navigation/
|
||||
preview path (`:all-data` feeds the test table) but not the cross-step *merge*. The merge
|
||||
(`get-all` combining independent steps) gets its real workout in Phase 7+ (Invoice Pay,
|
||||
New Invoice, Vendor, Client), where steps collect genuinely different fields.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
126
e2e/transaction-rule.spec.ts
Normal file
126
e2e/transaction-rule.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Characterization spec for the Transaction Rule wizard (edit step + test/preview step).
|
||||
// Captures CURRENT (pre-migration) behavior so the migration onto the session-backed
|
||||
// wizard engine can be proven behavior-preserving. Reset the dataset before each test.
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await request.post('/test-reset');
|
||||
});
|
||||
|
||||
async function getTestInfo(page: any) {
|
||||
return (await page.request.get('/test-info')).json();
|
||||
}
|
||||
|
||||
async function navigateToRules(page: any) {
|
||||
// The rule fixtures live under client TEST2 (to stay out of the single-client TEST
|
||||
// transaction grid the other specs use), so view as an admin who sees all clients.
|
||||
await page.request.get('/test-set-client-mode?mode=multi-client');
|
||||
await page.setExtraHTTPHeaders({ 'x-clients': '"all"' });
|
||||
await page.goto('/admin/transaction-rule');
|
||||
await page.waitForSelector('#entity-table');
|
||||
}
|
||||
|
||||
async function openNewDialog(page: any) {
|
||||
await page.locator('button:has-text("New Transaction Rule")').first().click();
|
||||
await page.waitForSelector('#wizard-form');
|
||||
}
|
||||
|
||||
async function openEditDialog(page: any) {
|
||||
// the edit pencil on the seeded rule's row (hx-get .../<id>/edit)
|
||||
await page.locator('#entity-table tbody tr').first()
|
||||
.locator('[hx-get*="/edit"]').first().click();
|
||||
await page.waitForSelector('#wizard-form');
|
||||
}
|
||||
|
||||
// Add a valid account-coding row (Solr typeahead unavailable in tests, so inject the
|
||||
// account id into the row's hidden input), location Shared, percentage 100.
|
||||
async function addAccount(page: any, accountId: string) {
|
||||
await page.locator('#wizard-form a:has-text("New account")').first().click();
|
||||
await page.waitForTimeout(400);
|
||||
const hidden = page.locator('#wizard-form input[type="hidden"][name*="[transaction-rule-account/account]"]').first();
|
||||
await hidden.evaluate((el: HTMLInputElement, v: string) => {
|
||||
const n = document.createElement('input'); n.type = 'hidden'; n.name = el.name; n.value = v;
|
||||
el.parentNode!.replaceChild(n, el);
|
||||
}, accountId);
|
||||
await page.waitForTimeout(200);
|
||||
const loc = page.locator('#wizard-form select[name*="[transaction-rule-account/location]"]').first();
|
||||
if (await loc.count() > 0) await loc.selectOption('Shared').catch(() => {});
|
||||
const pct = page.locator('#wizard-form input[name*="[transaction-rule-account/percentage]"]').first();
|
||||
await pct.fill('100');
|
||||
await pct.dispatchEvent('change');
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function fillDescription(page: any, desc: string) {
|
||||
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".
|
||||
async function selectApproved(page: any) {
|
||||
const radio = page.locator('#wizard-form input[type="radio"][name*="transaction-approval-status"]').first();
|
||||
await radio.check({ force: true }).catch(async () => {
|
||||
await page.locator('#wizard-form label:has-text("Approved")').first().click();
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
async function clickTest(page: any) {
|
||||
// the footer "Test" button navigates edit -> test
|
||||
await page.locator('#wizard-form button:has-text("Test"), #wizard-form a:has-text("Test")').first().click();
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Transaction Rule wizard (characterization)', () => {
|
||||
test('New dialog opens the edit step with rule form + account grid', async ({ page }) => {
|
||||
await navigateToRules(page);
|
||||
await openNewDialog(page);
|
||||
|
||||
const modal = page.locator('#wizard-form');
|
||||
await expect(modal).toContainText('Description');
|
||||
await expect(modal).toContainText('Outcomes');
|
||||
await expect(modal).toContainText('New account');
|
||||
await expect(modal).toContainText('Approval status');
|
||||
// the step indicator + the Test (advance) control
|
||||
await expect(modal.locator('button:has-text("Test"), a:has-text("Test")').first()).toBeVisible();
|
||||
});
|
||||
|
||||
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();
|
||||
await expect(desc).toHaveValue('ZZRULEMATCH');
|
||||
});
|
||||
|
||||
test('advancing to the test step renders the matching-transactions preview', async ({ page }) => {
|
||||
const info = await getTestInfo(page);
|
||||
await navigateToRules(page);
|
||||
await openNewDialog(page);
|
||||
await fillDescription(page, 'ZZRULEMATCH');
|
||||
await addAccount(page, info.accounts['test-account'].toString());
|
||||
await selectApproved(page);
|
||||
await clickTest(page);
|
||||
// the wizard advances to the test/preview step (the test-table query + render is
|
||||
// reused unchanged by the migration; the seed has no recent match, so the count is 0)
|
||||
const modal = page.locator('#wizard-form');
|
||||
await expect(modal).toContainText('Matching transactions');
|
||||
});
|
||||
|
||||
test('Saving from the test step creates the rule and closes the modal', async ({ page }) => {
|
||||
const info = await getTestInfo(page);
|
||||
await navigateToRules(page);
|
||||
const before = await page.locator('#entity-table tbody tr').count();
|
||||
await openNewDialog(page);
|
||||
await fillDescription(page, 'ZZRULEMATCH');
|
||||
await addAccount(page, info.accounts['test-account'].toString());
|
||||
await selectApproved(page);
|
||||
await clickTest(page);
|
||||
// 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();
|
||||
expect(await page.locator('#entity-table tbody tr').count()).toBe(before + 1);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
142
src/clj/auto_ap/ssr/components/wizard2.clj
Normal file
142
src/clj/auto_ap/ssr/components/wizard2.clj
Normal file
@@ -0,0 +1,142 @@
|
||||
(ns auto-ap.ssr.components.wizard2
|
||||
"Data-driven multi-step wizard engine — no protocols, no defrecords, no middleware
|
||||
stacking. A wizard is a plain *config map*; per-step validated state lives in the Ring
|
||||
session (see `wizard-state`), combined only at the end. Two routes per wizard: open
|
||||
(GET) and submit (POST). Only an opaque `wizard-id` + the `current-step` ride in the
|
||||
form — never the accumulated data, so there is no EDN snapshot to serialize or merge.
|
||||
|
||||
## Config shape
|
||||
|
||||
{:name :vendor ; instance label (for debugging)
|
||||
:form-id \"wizard-form\" ; the <form> id (swap target)
|
||||
:submit-route \"/admin/vendor/wizard\" ; resolved URL the form posts to
|
||||
:form-attrs {...} ; extra <form> attrs (hx-ext, etc.)
|
||||
:init-fn (fn [request] {:context {...} :init-data {step-key data}})
|
||||
:done-fn (fn [all-data request] ring-response) ; called when a step's :next = :done
|
||||
:steps [{:key :info
|
||||
:decode (fn [request] -> data-map) ; parse this step's posted fields
|
||||
:validate (fn [data request] -> errors|nil) ; optional
|
||||
:render (fn [ctx] -> hiccup) ; renders the step body
|
||||
:next (fn [data] -> next-step-key | :done)}
|
||||
...]}
|
||||
|
||||
The engine wraps each step's body in the wizard <form> (adding the wizard-id /
|
||||
current-step hiddens + hx-post). A step's `:render` receives a ctx map:
|
||||
|
||||
{:wizard-id :current-step :context :all-data :step-data :errors :request :config}
|
||||
|
||||
`:step-data` is the previously-stored data for this step (so editing repopulates), or
|
||||
the just-posted data on a validation re-render. `:all-data` is every step combined so
|
||||
far (handy for a read-only preview/summary step). Navigation buttons post a `direction`
|
||||
field: \"next\" (validate+advance), \"back\" (no validate), \"submit\" (== next, for the
|
||||
last step). See `reference/form-vs-wizard.md`."
|
||||
(:require
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.wizard-state :as ws]
|
||||
[auto-ap.ssr.utils :refer [html-response]]))
|
||||
|
||||
(defn- step-by-key [config k]
|
||||
(first (filter #(= (:key %) k) (:steps config))))
|
||||
|
||||
(defn- prev-step
|
||||
"The step key before `k` in the linear step order (or `k` itself if first)."
|
||||
[config k]
|
||||
(let [keys (mapv :key (:steps config))
|
||||
i (.indexOf keys k)]
|
||||
(if (pos? i) (nth keys (dec i)) k)))
|
||||
|
||||
(defn wizard-form
|
||||
"Wrap a step body in the wizard <form>: the form posts to the submit route, and only the
|
||||
wizard-id + current-step ride along (no accumulated data — that lives in the session)."
|
||||
[config wizard-id current-step body]
|
||||
[:form (merge {:id (:form-id config "wizard-form")
|
||||
:hx-post (:submit-route config)
|
||||
:hx-target "this"
|
||||
:hx-swap "outerHTML"}
|
||||
(:form-attrs config))
|
||||
(com/hidden {:name "wizard-id" :value wizard-id})
|
||||
(com/hidden {:name "current-step" :value (name current-step)})
|
||||
body])
|
||||
|
||||
(defn render-wizard
|
||||
"Render the current step's body inside the wizard form. `step-data`/`errors` let a
|
||||
validation re-render show the just-posted values + messages."
|
||||
[{:keys [config wizard-id session request step-errors step-posted]}]
|
||||
(let [cur (ws/current-step session wizard-id)
|
||||
step (step-by-key config cur)
|
||||
ctx {:wizard-id wizard-id
|
||||
:current-step cur
|
||||
:context (ws/context session wizard-id)
|
||||
:all-data (ws/get-all session wizard-id)
|
||||
:step-data (or step-posted (ws/step-data session wizard-id cur))
|
||||
:errors step-errors
|
||||
:request request
|
||||
:config config}]
|
||||
(wizard-form config wizard-id cur ((:render step) ctx))))
|
||||
|
||||
(defn- render-response
|
||||
"html-response of the rendered wizard, with the (possibly updated) session threaded into
|
||||
the Ring response so the session store persists the new wizard state."
|
||||
[config wizard-id session request & [extra]]
|
||||
(-> (html-response (render-wizard (merge {:config config
|
||||
:wizard-id wizard-id
|
||||
:session session
|
||||
:request request}
|
||||
extra)))
|
||||
(assoc :session session)))
|
||||
|
||||
(defn open-wizard
|
||||
"Create a wizard instance in the session and render its first step. `:init-fn` returns
|
||||
{:context ..., :init-data ...} (both optional)."
|
||||
[config request]
|
||||
(let [{:keys [context init-data]} ((:init-fn config) request)
|
||||
first-step (-> config :steps first :key)
|
||||
[id session'] (ws/create-wizard! (:session request) (:name config)
|
||||
{:first-step first-step
|
||||
:context context
|
||||
:init-data init-data})]
|
||||
(render-response config id session' request)))
|
||||
|
||||
(defn- expired-response
|
||||
"The wizard instance is gone from the session (server restart / session expiry / a stale
|
||||
tab). Re-open a fresh wizard rather than 500-ing."
|
||||
[config request]
|
||||
(open-wizard config request))
|
||||
|
||||
(defn handle-step-submit
|
||||
"Submit handler. Reads wizard-id / current-step / direction from the posted form, then:
|
||||
- \"back\": move to the previous step (no validation).
|
||||
- else: decode + validate the current step; on error re-render it with messages;
|
||||
otherwise store the step's data and either advance to `:next` or, when
|
||||
`:next` is :done, call `done-fn` with all combined data and `forget` the
|
||||
instance."
|
||||
[config request]
|
||||
(let [fp (:form-params request)
|
||||
wizard-id (get fp "wizard-id")
|
||||
current-step (keyword (get fp "current-step"))
|
||||
direction (or (get fp "direction") "next")
|
||||
session (:session request)]
|
||||
(cond
|
||||
(not (ws/exists? session wizard-id))
|
||||
(expired-response config request)
|
||||
|
||||
(= direction "back")
|
||||
(render-response config wizard-id
|
||||
(ws/set-step session wizard-id (prev-step config current-step))
|
||||
request)
|
||||
|
||||
:else
|
||||
(let [step (step-by-key config current-step)
|
||||
posted ((:decode step) request)
|
||||
errors (when-let [v (:validate step)] (v posted request))]
|
||||
(if (seq errors)
|
||||
(render-response config wizard-id session request
|
||||
{:step-errors errors :step-posted posted})
|
||||
(let [session' (ws/put-step session wizard-id current-step posted)
|
||||
nxt ((:next step) posted)]
|
||||
(if (= nxt :done)
|
||||
(-> ((:done-fn config) (ws/get-all session' wizard-id) request)
|
||||
(assoc :session (ws/forget session' wizard-id)))
|
||||
(render-response config wizard-id
|
||||
(ws/set-step session' wizard-id nxt)
|
||||
request))))))))
|
||||
66
src/clj/auto_ap/ssr/components/wizard_state.clj
Normal file
66
src/clj/auto_ap/ssr/components/wizard_state.clj
Normal file
@@ -0,0 +1,66 @@
|
||||
(ns auto-ap.ssr.components.wizard-state
|
||||
"Session-backed storage for multi-step wizards — the Django formtools `SessionStorage`
|
||||
model. Each wizard instance's per-step *validated* data lives in the Ring session under
|
||||
|
||||
[:wizards <wizard-id> :step-data <step-key>]
|
||||
|
||||
and the steps are combined only at the very end via `get-all`. This replaces the
|
||||
EDN-snapshot-in-a-hidden-field round-trip (and its custom readers + merge logic): no
|
||||
data about other steps ever rides through the page — only an opaque `wizard-id` token.
|
||||
|
||||
State is namespaced by `wizard-id` (a random uuid), so concurrent wizards and browser
|
||||
tabs don't collide, and a completed/abandoned wizard is discarded with `forget`.
|
||||
|
||||
These functions are pure: each takes a session map and returns a new session map (or a
|
||||
read). The engine (`wizard2`) threads the returned session into the Ring response; the
|
||||
session store (cookie / durable) then persists it. Nothing here touches global state."
|
||||
(:require
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn create-wizard!
|
||||
"Seed a fresh wizard instance. Returns `[wizard-id session']`. `opts`:
|
||||
:first-step the step key the wizard opens on (required)
|
||||
:context read-only data the steps need but don't edit (e.g. an entity id) — kept
|
||||
out of :step-data so it never gets merged into the combined result
|
||||
:init-data optional pre-filled per-step data ({step-key data}), e.g. when editing an
|
||||
existing entity so step 1 opens populated.
|
||||
Despite the bang, this only *computes* the next session — it doesn't mutate anything;
|
||||
the caller threads `session'` into its response."
|
||||
[session config-name {:keys [first-step context init-data]}]
|
||||
(let [id (str (java.util.UUID/randomUUID))]
|
||||
[id (assoc-in session [:wizards id]
|
||||
{:config-name config-name
|
||||
:current-step first-step
|
||||
:context (or context {})
|
||||
:step-data (or init-data {})})]))
|
||||
|
||||
(defn instance [session id] (get-in session [:wizards id]))
|
||||
(defn exists? [session id] (boolean (and id (get-in session [:wizards id]))))
|
||||
(defn current-step [session id] (get-in session [:wizards id :current-step]))
|
||||
(defn context [session id] (get-in session [:wizards id :context]))
|
||||
(defn step-data [session id step-key] (get-in session [:wizards id :step-data step-key]))
|
||||
|
||||
(defn put-step
|
||||
"Store (REPLACE, never merge) a step's validated data. Replacing is the whole point —
|
||||
re-submitting a step overwrites that step only; other steps are untouched."
|
||||
[session id step-key data]
|
||||
(assoc-in session [:wizards id :step-data step-key] data))
|
||||
|
||||
(defn set-step
|
||||
"Move the wizard's current step (navigation)."
|
||||
[session id step-key]
|
||||
(assoc-in session [:wizards id :current-step] step-key))
|
||||
|
||||
(defn get-all
|
||||
"Combine every stored step's data into one map (the formtools `get_all_cleaned_data`).
|
||||
Combined only here, at the end — later steps win on key collisions (steps order)."
|
||||
[session id]
|
||||
(->> (get-in session [:wizards id :step-data])
|
||||
vals
|
||||
(apply merge {})))
|
||||
|
||||
(defn forget
|
||||
"Discard the wizard instance (on completion or abandonment) so the session doesn't grow
|
||||
unbounded. Call from the done-fn's response."
|
||||
[session id]
|
||||
(update session :wizards dissoc id))
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
(def test-account-ids (atom {}))
|
||||
(def test-client-ids (atom {}))
|
||||
(def test-sales-summary-id (atom nil))
|
||||
(def test-rule-id (atom nil))
|
||||
|
||||
(defn admin-identity []
|
||||
(case @test-identity-mode
|
||||
@@ -182,6 +183,25 @@
|
||||
:ledger-mapped/amount 500.0
|
||||
:ledger-mapped/account "account-id-2"}]}])
|
||||
tempids (:tempids tx-result)
|
||||
;; A pre-existing transaction rule (for the wizard edit flow), in a SEPARATE
|
||||
;; transaction so the first one's tempid->entity-id allocation (and thus the TEST
|
||||
;; transaction grid order other specs depend on) is byte-identical to before.
|
||||
;; Under client TEST2 so it stays out of the single-client TEST views. We do NOT
|
||||
;; seed a recent matching transaction: a date-NOW txn perturbs an unrelated
|
||||
;; transaction-edit save spec, and the rule test step's query/render is reused
|
||||
;; unchanged by the migration, so characterizing that the preview table renders is
|
||||
;; sufficient parity (the specific match count is not what the migration risks).
|
||||
rule-tx (:tempids
|
||||
@(dc/transact conn
|
||||
[{:db/id "rule-id"
|
||||
:transaction-rule/client (get tempids "client-id-2")
|
||||
:transaction-rule/description "ZZRULEMATCH"
|
||||
:transaction-rule/note "ZZRULEMATCH"
|
||||
:transaction-rule/transaction-approval-status :transaction-approval-status/approved
|
||||
:transaction-rule/accounts [{:db/id "rule-acct"
|
||||
:transaction-rule-account/account (get tempids "account-id")
|
||||
:transaction-rule-account/location "Shared"
|
||||
:transaction-rule-account/percentage 1.0}]}]))
|
||||
tx-entity-id (get tempids "transaction-id")]
|
||||
(println "Test transaction entity ID:" tx-entity-id)
|
||||
(reset! test-account-ids
|
||||
@@ -195,6 +215,7 @@
|
||||
{:test (get tempids "client-id")
|
||||
:test2 (get tempids "client-id-2")})
|
||||
(reset! test-sales-summary-id (get tempids "sales-summary-id"))
|
||||
(reset! test-rule-id (get rule-tx "rule-id"))
|
||||
tx-entity-id))
|
||||
|
||||
(defn test-info-handler [request]
|
||||
@@ -205,6 +226,7 @@
|
||||
:accounts @test-account-ids
|
||||
:clientMode @test-identity-mode
|
||||
:salesSummaryId @test-sales-summary-id
|
||||
:ruleId @test-rule-id
|
||||
:clients (mapv :client/code (:clients request))})})
|
||||
|
||||
(defn test-set-client-mode-handler [request]
|
||||
|
||||
Reference in New Issue
Block a user