3 Commits

Author SHA1 Message Date
107a02f4f1 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>
2026-06-25 11:12:33 -07:00
15ff9855c1 test(ssr): Phase 6b parity gate — characterization spec for Transaction Rule wizard
Behavior-parity safety net before migrating the Transaction Rule modal onto the
session-backed wizard engine. The modal had no e2e coverage; the test server seeded
no rules.

- test_server.clj: seed a transaction rule (under client TEST2, in a SEPARATE
  transaction so the first transaction's tempid->entity-id allocation — and thus the
  TEST transaction grid order the other specs depend on — is byte-identical); surface
  its id via /test-info (ruleId).
- e2e/transaction-rule.spec.ts (4 tests): the new-rule edit step renders (description,
  account grid, approval radios, Test control), the edit dialog pre-populates the
  seeded rule, advancing to the test step renders the matching-transactions preview,
  and saving from the test step creates the rule + closes the modal. Covers both entry
  points (new/edit), both steps (edit + test), and save.

Note: deliberately NOT seeding a recent matching transaction — a date-NOW txn perturbs
an unrelated transaction-edit save spec (pre-existing fragility), and the test-table
query/render is reused unchanged by the migration, so characterizing that the preview
renders is sufficient parity.

Full Playwright suite 55/55 (51 prior + 4 new).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 07:07:18 -07:00
d56056d66c feat(ssr): Phase 6a — session-backed wizard engine (the formtools model)
Builds the reusable multi-step wizard engine the plan front-loads in Phase 6, as
two protocol-free namespaces. This replaces the EDN-snapshot-in-a-hidden-field
round-trip for genuine multi-step flows: per-step validated data lives in the Ring
session and is combined only at the end — only an opaque wizard-id rides in the form.

- components/wizard_state.clj — pure session storage (Django formtools SessionStorage
  model): create-wizard!, instance, exists?, current-step, context, step-data,
  put-step (REPLACE not merge), set-step, get-all (combine at end), forget. State
  namespaced by wizard-id at [:wizards <id> ...]; :context holds read-only step inputs
  outside :step-data so it never merges into the result. Each fn is session -> session'.
- components/wizard2.clj — the engine: open-wizard, render-wizard, handle-step-submit,
  wizard-form. A wizard is a config map (steps with :decode/:validate/:render/:next,
  plus :init-fn/:done-fn/:submit-route). Steps' :render get {wizard-id, current-step,
  context, all-data, step-data, errors, request}; nav posts a `direction` field
  (next/back/submit). Two routes per wizard (open + submit); the engine threads the
  session into the response itself — no wrap-wizard / wrap-decode-multi-form-state stack.

REPL-proven lifecycle (before wiring any modal):
  1. OPEN     -> seeds session state, renders step 1, form leaks NO accumulated data
  2. NEXT     -> stores {:info {:name "Acme"}}, advances to :terms
  3. INVALID  -> re-renders the same step with errors, no advance
  4. DONE     -> done-fn gets combined {:name "Acme" :days 30} (get-all), instance forgotten
  5. BACK     -> :terms -> :info, no validation
  6. EXPIRED  -> unknown wizard-id re-opens fresh instead of 500-ing

Inert infrastructure — nothing imports it yet (Transaction Rule migrates onto it next),
so the e2e suite is unaffected. cljfmt clean. Skill: form-vs-wizard.md updated from
aspirational to the realized engine API + the Phase-6 fit note (Transaction Rule
exercises render/nav/preview; the cross-step merge gets its workout in Phase 7+).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 00:29:32 -07:00
9 changed files with 764 additions and 356 deletions

View File

@@ -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.

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

@@ -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);
});
});

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

@@ -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))))))))

View 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))

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

View File

@@ -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]