Compare commits
10 Commits
32056bf396
...
c892719bd1
| Author | SHA1 | Date | |
|---|---|---|---|
| c892719bd1 | |||
| d0fad63e24 | |||
| 0b5bfd9c84 | |||
| 38ad665726 | |||
| 798b350c81 | |||
| 0f5650b73e | |||
| 1d5a95196f | |||
| 07159dc221 | |||
| 57f3b63b6a | |||
| a7ccdb12f3 |
@@ -90,6 +90,54 @@ replaces the amount input (caret survives).
|
||||
:placeholder "Optional note"}) ; no hx-* — rides along to save
|
||||
```
|
||||
|
||||
## location-select — first Selmer-migrated component (validated)
|
||||
|
||||
The account row's location `<select>`, rendered from a Selmer template instead of
|
||||
`com/select`. The first interactive modal component off Hiccup; proves the render-file
|
||||
path + interop bridge on real, e2e-covered markup (swap 6/6, transaction-edit 8/8).
|
||||
|
||||
```clojure
|
||||
;; templates/components/location-select.html — plain HTML, {% for %} + {% if selected %}
|
||||
(defn location-select* [{:keys [name client-locations value ...]}]
|
||||
(let [options (cond ...) ; [[value label] ...]
|
||||
selected (or value (ffirst options))
|
||||
classes (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
|
||||
(sel/render->hiccup "templates/components/location-select.html"
|
||||
{:name name :classes classes
|
||||
:options (for [[v l] options] {:value v :label l :selected (= v selected)})})))
|
||||
```
|
||||
Reuse: pass `inputs/default-input-classes` in (don't hard-code); embed via
|
||||
`render->hiccup` so it drops into the still-Hiccup row. See `selmer-conventions.md`.
|
||||
|
||||
## fixed-index row from explicit data — de-faking a deep cursor
|
||||
|
||||
When a row always lives at a known index (e.g. simple mode renders exactly `accounts[0]`),
|
||||
render it from **explicit data with explicit field names** instead of faking a cursor
|
||||
rooted there. Build the name the same way the cursor would (`path->name2`) and read errors
|
||||
from the same path — no `with-cursor`/`MapCursor` rebind, no `with-field-default` (which
|
||||
*mutates* the cursor and breaks swap behavior, see `gotchas.md`).
|
||||
|
||||
```clojure
|
||||
(defn- account-field-name [index field] ; == path->name2 for this path
|
||||
(str "step-params[transaction/accounts][" index "]["
|
||||
(if (keyword? field)
|
||||
(str (when (namespace field) (str (namespace field) "/")) (name field))
|
||||
field) "]"))
|
||||
|
||||
(defn- account-field-errors [index field]
|
||||
(when (bound? #'fc/*form-errors*)
|
||||
(get-in fc/*form-errors* [:step-params :transaction/accounts index field])))
|
||||
|
||||
;; render the row directly -- no fc/with-field / fc/with-cursor wrappers
|
||||
[:span
|
||||
(com/hidden {:name (account-field-name 0 :db/id) :value row-id})
|
||||
(com/validated-field {:errors (account-field-errors 0 :transaction-account/account)}
|
||||
(account-typeahead* {:name (account-field-name 0 :transaction-account/account) ...}))
|
||||
...]
|
||||
```
|
||||
Verify byte-parity against the cursor version (the swap spec's simple-mode tests catch
|
||||
divergence). Scorecard heuristic 1: faked roots → 0.
|
||||
|
||||
## mode toggle ($/% radio, simple/advanced link) — Rule 3, whole-form swap
|
||||
|
||||
```clojure
|
||||
|
||||
@@ -83,6 +83,71 @@ validation re-render; a `#error {…}` stack means a 500. Then serialize the for
|
||||
before save (`new FormData(document.querySelector('#wizard-form'))`) to see exactly what
|
||||
posts. This is how the `:mode` 500 and the empty-account bugs above were isolated.
|
||||
|
||||
## De-faking a cursor is not a drop-in — `with-field-default` mutates
|
||||
|
||||
Tempting fix for a faked deep cursor (`with-cursor` + synthetic `MapCursor` at index 0):
|
||||
replace it with `(fc/with-field-default 0 {})` to advance naturally. **It broke the
|
||||
simple-mode swap** (`transaction-edit-swap` test 1 threw). `with-field-default` calls
|
||||
`cursor/transact!` — it *mutates the form cursor* (assoc-ing the default row) as a render
|
||||
side effect, which changes simple-mode behavior. The read-only synthetic `MapCursor` did
|
||||
not. Lesson: removing a faked cursor on these modals is **not** a one-liner — it's part of
|
||||
the larger render-fn extraction (render the row from explicit data, construct field names
|
||||
directly, look up errors explicitly), done when the simple/advanced rows are reworked into
|
||||
pure render fns / Selmer. Don't swap one cursor primitive for another and assume parity;
|
||||
verify against the swap spec, and expect the de-fake to come with the render-fn rewrite.
|
||||
|
||||
## Snapshot operations read stale state and drop live form values (heuristic 2)
|
||||
|
||||
The whole-form operation handlers (`apply-new-account`, `apply-remove-account`,
|
||||
`apply-toggle-amount-mode`) rebuild the account rows from the **decoded `:snapshot`** (the
|
||||
hidden EDN field), not from the live posted `:step-params`. So any value the user has typed
|
||||
but that hasn't been re-serialised into the snapshot yet — e.g. an amount typed right
|
||||
before clicking "New account" — is **silently lost** when the operation re-renders. This is
|
||||
the snapshot round-trip fragility the migration removes (heuristic 2: → 0 merges; state
|
||||
should ride in the form, not a parallel snapshot). It bit the percentage-split e2e: typing
|
||||
50% then adding a row reverted the first row to its snapshot value, yielding a 66.67/33.33
|
||||
split. Two ways it shows up and how to handle until the snapshot is gone:
|
||||
|
||||
**Fixed (Stage 1):** the operation handlers read the live `:step-params` rows (already
|
||||
schema-decoded by `mm/wrap-wizard`) so typed values survive add/remove/toggle.
|
||||
|
||||
**Done (Stage 2 — the snapshot round-trip is gone).** The EDN `snapshot` hidden field +
|
||||
custom readers + `merge-multi-form-state` are removed. A `db/id` hidden rides in the form;
|
||||
`wrap-derive-state` rebuilds `:multi-form-state` per request from `entity ∪ step-params`,
|
||||
and `EditWizard.render-wizard` renders a plain form (no snapshot/edit-path/current-step
|
||||
hiddens). The ~34 `:snapshot` reads still work — `:snapshot` is now a derived map, not a
|
||||
round-tripped blob.
|
||||
|
||||
**Trap that cost hours — derive `entity ∪ step-params` correctly.** First cut was
|
||||
`(merge base step-params)`. Bug: `base` always carries the entity's *persisted* accounts,
|
||||
so after the user removes every row (step-params has no accounts key) the merge falls back
|
||||
to base → the persisted accounts **resurrect** on the next operation. Fix: editable fields
|
||||
(accounts, vendor, memo, approval, action, mode, amount-mode) come **only** from the live
|
||||
form (absent = cleared); only entity-only fields (`db/id`, client, amount, description,
|
||||
status, type) come from the entity. Lesson: with a posted form, "field absent" means
|
||||
*cleared*, not "use the persisted value" — never merge the entity's editable fields back in.
|
||||
|
||||
**Verify the snapshot removal on a FRESH server, and don't trust a long-lived in-process
|
||||
test server.** Protocol/defrecord (`EditWizard.render-wizard`) and middleware reloads do
|
||||
**not** fully take in a running REPL — the server kept rendering the old snapshot field
|
||||
after `:reload`, and an in-process server that isn't reseeded between `npx playwright`
|
||||
invocations accumulates state that makes order-dependent tests flake. Both produced hours
|
||||
of phantom failures. Restart the REPL clean (or reseed) before trusting an e2e result; CI
|
||||
boots a fresh server per run, so the fresh-server number (38 pass / 1 unrelated) is the real one.
|
||||
|
||||
## Characterization tests rot against table order and removed wizard chrome
|
||||
|
||||
Two stale-test traps surfaced once the masking failure was fixed (a `mode: 'serial'` file
|
||||
hides every test after the first failure, so fixing one unmasks the next):
|
||||
|
||||
- **Hard-coded amounts per table row index** (`openEditModal(page, 3)` then
|
||||
`expect(amount).toBeCloseTo(400)`) break because same-date seed transactions have no
|
||||
pinned row order. Read the actual value (e.g. the grid's `.account-grand-total-row`)
|
||||
instead of hard-coding.
|
||||
- **Helpers that navigate the old multi-step wizard** (`click('button:has-text("Transaction
|
||||
Actions")')`) hang once the modal is single-page. Drop the navigation; the action tabs
|
||||
are present immediately.
|
||||
|
||||
## Scorecard exceptions (ratchet violations with a reason)
|
||||
|
||||
_None yet._ Append here if a migration must let a metric regress for a documented reason.
|
||||
|
||||
@@ -39,8 +39,23 @@ Each migration appends one row (after-numbers), referencing the before in the di
|
||||
| Phase | Modal | LOC | Routes | no-cursor twins | faked roots | snapshot merges | OOB | mixed hx- | cookbook reused / added |
|
||||
|-------|-------|-----|--------|-----------------|-------------|-----------------|-----|-----------|-------------------------|
|
||||
| 1 (baseline) | Transaction Edit `transaction/edit.clj` | 1608 | ~12 | 1 | 2 | ~75 | 0 | 8 | — / seeded 7 entries |
|
||||
| 2 | Transaction Edit `transaction/edit.clj` | 1593 | **~5** | **0** | **0** | **0 round-trip** | 0 | 8 (shared) | location-select / **1 Selmer** |
|
||||
|
||||
> Phase 1 is distillation only — no app code changed. The Transaction Edit row is the
|
||||
> **before** baseline that Phase 2 must beat (target: routes → ~3, no-cursor → 0, faked
|
||||
> roots → 0, snapshot merges → 0, LOC ↓, mixed hx- → 0). The `0` OOB is already achieved
|
||||
> by the merged reference and must not regress.
|
||||
> **Phase 2 progress.** Achieved with parity held (swap spec **6/6**, transaction-edit
|
||||
> spec **8/8**, full suite **38 pass / 1 unrelated fail / 0 skip**, up from 30/2/7):
|
||||
> - deleted the dead `*-no-cursor*` twin (no-cursor 1→0);
|
||||
> - **de-faked the simple-mode cursor** (faked roots 2→0) via explicit data + explicit
|
||||
> field names (`account-field-name`) + explicit error lookup — the render-fn rewrite the
|
||||
> `with-field-default` shortcut couldn't do;
|
||||
> - **collapsed the 5 manual-coding operation routes into one `edit-form-changed`
|
||||
> dispatcher** (routes ~12→~5; the operations are now pure `apply-*` fns);
|
||||
> - fixed a real production bug (`:mode` → 500 on every advanced manual save);
|
||||
> - greened `transaction-edit.spec.ts` (8/8) and matured the skill.
|
||||
>
|
||||
> **Phase 2 complete.** The wizard→plain-form rewrite removed the snapshot round-trip
|
||||
> (heuristic 2 → 0) and the first interactive component (`location-select`) is migrated to
|
||||
> a Selmer template (`selmer-conventions.md` validated). Remaining for *later phases*: drop
|
||||
> the now-thin `mm/ModalWizardStep` protocol wrappers, and the cross-cutting Phase 11
|
||||
> Selmer sweep of the shared `com/typeahead`/`com/select`/`com/button-group-button` (those
|
||||
> shared call sites hold the last 8 mixed `@`/`:`-attr offenders; they clear when the
|
||||
> shared components move to Selmer — not a single-modal task, per Open decision 2).
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
# Selmer template conventions
|
||||
|
||||
> **Status: STUB — validated in Phase 2.** This file describes the target. The Selmer
|
||||
> dependency, render helper, and interop bridge are added in Phase 2 (Transaction Edit);
|
||||
> rewrite this file from the *real, verified* example once that lands, and record each
|
||||
> converted component in `component-cookbook.md`.
|
||||
> **Validated** in the Transaction Edit migration: `location-select*` now renders from
|
||||
> `resources/templates/components/location-select.html` via the interop bridge, embedded
|
||||
> back into the Hiccup account row. Verified: swap spec 6/6, transaction-edit 8/8 (the
|
||||
> Shared Location test selects through the Selmer `<select>`, saves, and spreads to DT).
|
||||
|
||||
## Why Selmer for interactive components
|
||||
|
||||
In Hiccup the same Alpine/HTMX attribute is sometimes a keyword and sometimes a string in
|
||||
the same file — there's no rule a reader (or an LLM) can rely on:
|
||||
the same file — there's no rule a reader (or an LLM) can rely on. The real
|
||||
`com/typeahead-` mixes them in one map:
|
||||
|
||||
```clojure
|
||||
;; All of these appear in one component today:
|
||||
:x-ref "input" "x-ref" "hidden"
|
||||
:x-model "value.value" "x-model" "search"
|
||||
"@keydown.down.prevent.stop" "tippy.show();" ; handlers MUST be strings
|
||||
:x-init "..." ; structural attrs are keywords
|
||||
:x-modelable "value.value" ; keyword key
|
||||
"x-ref" "hidden" ; string key
|
||||
"@keydown.down.prevent.stop" "tippy?.show();" ; handlers MUST be strings
|
||||
:x-init "..." ; structural attrs are keywords
|
||||
```
|
||||
|
||||
In a Selmer template the same markup is unambiguous plain HTML:
|
||||
@@ -28,36 +28,66 @@ In a Selmer template the same markup is unambiguous plain HTML:
|
||||
@keydown.backspace="tippy?.hide(); value = {value:'', label:''}">
|
||||
<span x-text="value.label"></span>
|
||||
</a>
|
||||
...
|
||||
</div>
|
||||
```
|
||||
|
||||
Note the `tippy?.` null-guard carried over from the swap doctrine — Selmer doesn't change
|
||||
the Alpine-survives-swap requirement.
|
||||
|
||||
## Render helper + interop bridge (the Phase 2 foundation)
|
||||
## The render helper + interop bridge (`auto-ap.ssr.selmer`)
|
||||
|
||||
```clojure
|
||||
(defn render [tpl ctx] (selmer/render-file tpl ctx))
|
||||
(defn hiccup->html [h] (hiccup/html h)) ; embed hiccup inside selmer via {{ frag|safe }}
|
||||
;; selmer fragment inside hiccup: [:div (hiccup/raw (render "..." ctx))]
|
||||
(sel/render template ctx) ; selmer/render-file -> HTML string (classpath-relative path)
|
||||
(sel/render-str template ctx) ; render from a string (tests/REPL)
|
||||
(sel/hiccup->html h) ; Hiccup -> string, for {{ frag|safe }} inside a template
|
||||
(sel/raw html-string) ; wrap a rendered string so hiccup2 emits it verbatim
|
||||
(sel/render->hiccup template ctx); render + raw, ready to drop into a Hiccup tree
|
||||
```
|
||||
|
||||
The bridge must work **both ways** during the strangler transition: a Hiccup component
|
||||
renders inside a Selmer template (pass `(hiccup->html h)` into the context, render with
|
||||
`|safe`), and a Selmer fragment renders inside a Hiccup tree (`(hiccup/raw (render ...))`).
|
||||
Prove both in Phase 2 before broad use.
|
||||
The bridge works **both ways** (proven in `selmer_test`): a Hiccup component renders inside
|
||||
a Selmer template (`hiccup->html` + `|safe`), and a Selmer fragment renders inside a Hiccup
|
||||
tree (`render->hiccup`, which `raw`-wraps so hiccup2 doesn't double-escape).
|
||||
|
||||
## The worked example — `location-select*`
|
||||
|
||||
Template (`resources/templates/components/location-select.html`): plain HTML, an
|
||||
`{% for %}` over option maps, `{% if opt.selected %}`.
|
||||
|
||||
```clojure
|
||||
;; Clojure side: build the data, compute classes (reuse inputs/default-input-classes so
|
||||
;; styling can't drift), render, and return a Hiccup-embeddable fragment.
|
||||
(defn location-select* [{:keys [name client-locations value ...]}]
|
||||
(let [options (cond ...) ; [[value label] ...]
|
||||
selected (or value (ffirst options))
|
||||
classes (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
|
||||
(sel/render->hiccup "templates/components/location-select.html"
|
||||
{:name name :classes classes
|
||||
:options (for [[v l] options] {:value v :label l :selected (= v selected)})})))
|
||||
```
|
||||
|
||||
Lessons:
|
||||
- **Pass computed values in, don't hard-code.** Reuse the Clojure source of truth
|
||||
(`inputs/default-input-classes`) as a context value rather than copying class strings
|
||||
into the template — otherwise styling drifts from the shared components.
|
||||
- **Verify by string-match + e2e, not byte-parity.** `hh/add-class` is set-based, so class
|
||||
*order* differs from the old `com/select` output; CSS is order-independent and the e2e
|
||||
proves behavior. (`testing-conventions`: don't assert on exact markup.)
|
||||
- **Embed via the bridge.** `render->hiccup` lets the Selmer fragment sit inside the
|
||||
still-Hiccup row (`com/validated-field` wraps it) — strangler, one component at a time.
|
||||
|
||||
## Composition
|
||||
|
||||
Selmer composes via `{% include %}` and `{% block %}`. Prefer small per-component
|
||||
templates that the cookbook references by path. Keep `|safe` to values the server fully
|
||||
controls (rendered Hiccup, JSON for `x-data`), never raw user input.
|
||||
Selmer composes via `{% include %}` and `{% block %}`. Prefer small per-component templates
|
||||
referenced by classpath path. Keep `|safe` to values the server fully controls (rendered
|
||||
Hiccup, JSON for `x-data`), never raw user input.
|
||||
|
||||
## Scope (Open decision 2)
|
||||
|
||||
Hybrid: convert interactive/attribute-heavy components first; static markup may stay
|
||||
Hiccup. Revisit a fuller sweep in Phase 11.
|
||||
Hybrid, and the boundary is real: **the modal's attribute-heavy components delegate to the
|
||||
shared `com/typeahead` / `com/select` / `com/button-group-button`.** Converting those is a
|
||||
*cross-cutting* change (every modal uses them), so it belongs to the Phase 11 Selmer sweep,
|
||||
not a single modal. `location-select*` is the first, self-contained proof; the shared
|
||||
components follow when the sweep promotes them to Selmer partials.
|
||||
|
||||
## Attribute-consistency scorecard (heuristic 8)
|
||||
|
||||
@@ -65,4 +95,5 @@ Hiccup. Revisit a fuller sweep in Phase 11.
|
||||
grep -cE '"x-[a-z]|"hx-[a-z]|"@' <migrated-template> # → 0 mixed encodings in Selmer
|
||||
```
|
||||
A migrated Selmer template has no mixed `:x-`/`"x-"` encodings because everything is plain
|
||||
HTML.
|
||||
HTML. (The Hiccup `"@click"`/`":class"` offenders that remain in `edit.clj` live in the
|
||||
shared-component call sites — they clear when those components move to Selmer.)
|
||||
|
||||
@@ -121,7 +121,17 @@ Server: in-process from `integreat-execute-refactor` on `:3334`, `--workers=1`,
|
||||
| Full suite (39) | **30 pass / 2 fail / 7 skipped.** 2nd failure: `transaction-navigation.spec.ts` date-range persistence (`date-range=all` expected, got `month`) — drift from the base branch's "require Apply for date-range filters" change, unrelated to forms. |
|
||||
|
||||
**Gate for the Transaction Edit refactor:** the 6/6 swap-doctrine spec + REPL pure-fn
|
||||
checks. The `transaction-edit.spec.ts` `Shared Location` failure must be understood/fixed
|
||||
to unmask the other 7 before that file can serve as a full parity gate — it is **not**
|
||||
a regression to introduce, but it does cap the available characterization coverage today.
|
||||
Never drop below 30 passing on the full suite.
|
||||
checks.
|
||||
|
||||
### Current state — after the Phase 2 modal work (never drop below this)
|
||||
|
||||
Full suite (workers=1, fresh seed): **38 passed / 1 failed / 0 skipped.**
|
||||
|
||||
- `transaction-edit-swap.spec.ts` — **6/6** (parity contract held through every change).
|
||||
- `transaction-edit.spec.ts` — **8/8** (was 1 pass + 7 masked). Greened by: the `:mode`
|
||||
500 fix, the Alpine-v3 typeahead helper, rewriting the percentage-split test to avoid
|
||||
the snapshot-drops-live-values ordering trap, reading the real transaction total instead
|
||||
of a hard-coded `400`, and dropping the removed `"Transaction Actions"` wizard-nav step.
|
||||
- Remaining 1 failure: `transaction-navigation.spec.ts:92` date-range-preset persistence —
|
||||
**unrelated to forms** (drift from the base branch's "require Apply for date-range
|
||||
filters" change). Pre-existing; out of scope for this migration.
|
||||
|
||||
@@ -48,7 +48,7 @@ async function openManualAdvanced(page: any, transactionIndex = 0) {
|
||||
// (Solr is unavailable in tests), click it, and wait for the whole-form swap.
|
||||
async function selectVendor(page: any, vendorId: number, label: string) {
|
||||
const vendor = page
|
||||
.locator('div[hx-post*="edit-vendor-changed"]')
|
||||
.locator('div[hx-vals*="vendor-changed"]')
|
||||
.first()
|
||||
.locator('div.relative[x-data]')
|
||||
.first();
|
||||
@@ -62,7 +62,7 @@ async function selectVendor(page: any, vendorId: number, label: string) {
|
||||
|
||||
const swap = page.waitForResponse(
|
||||
(r: any) =>
|
||||
r.url().includes('edit-vendor-changed') &&
|
||||
r.url().includes('edit-form-changed') &&
|
||||
r.request().method() === 'POST' &&
|
||||
r.status() === 200
|
||||
);
|
||||
@@ -303,7 +303,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
||||
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('div[hx-post*="edit-vendor-changed"]');
|
||||
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
||||
|
||||
const testInfo = await (await page.request.get('/test-info')).json();
|
||||
const vendorId: number = testInfo.accounts.vendor;
|
||||
@@ -311,7 +311,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
||||
|
||||
// Drive the vendor typeahead like a user: open dropdown, inject a result
|
||||
// (Solr is unavailable in tests), click it.
|
||||
const vendor = page.locator('div[hx-post*="edit-vendor-changed"]').first().locator('div.relative[x-data]').first();
|
||||
const vendor = page.locator('div[hx-vals*="vendor-changed"]').first().locator('div.relative[x-data]').first();
|
||||
await vendor.locator('a[x-ref="input"]').click();
|
||||
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
|
||||
await search.waitFor({ state: 'visible' });
|
||||
@@ -322,7 +322,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
||||
|
||||
const swap = page.waitForResponse(
|
||||
(r: any) =>
|
||||
r.url().includes('edit-vendor-changed') &&
|
||||
r.url().includes('edit-form-changed') &&
|
||||
r.request().method() === 'POST' &&
|
||||
r.status() === 200
|
||||
);
|
||||
@@ -352,7 +352,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
||||
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('div[hx-post*="edit-vendor-changed"]');
|
||||
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
||||
|
||||
const testInfo = await (await page.request.get('/test-info')).json();
|
||||
const vendor1: number = testInfo.accounts.vendor;
|
||||
@@ -361,7 +361,7 @@ test.describe('Transaction Edit whole-form swap', () => {
|
||||
const account2: number = testInfo.accounts['second-account'];
|
||||
|
||||
const vendorLabel = page
|
||||
.locator('div[hx-post*="edit-vendor-changed"] span[x-text="value.label"]')
|
||||
.locator('div[hx-vals*="vendor-changed"] span[x-text="value.label"]')
|
||||
.first();
|
||||
const accountHidden = page
|
||||
.locator('input[type="hidden"][name*="transaction-account/account"]')
|
||||
|
||||
@@ -124,14 +124,13 @@ async function getAccountLocation(page: any, rowIndex: number): Promise<string>
|
||||
}
|
||||
|
||||
async function removeAllAccounts(page: any) {
|
||||
const accountRows = page.locator('#account-grid-body tbody tr.account-row');
|
||||
const rowCount = await accountRows.count();
|
||||
|
||||
for (let i = rowCount - 1; i >= 0; i--) {
|
||||
const row = accountRows.nth(i);
|
||||
const removeButton = row.locator('.account-remove-action');
|
||||
await removeButton.click();
|
||||
// Wait for the Alpine.js removal animation (500ms + buffer)
|
||||
// Re-query each iteration: every remove is a whole-form swap that re-renders the rows,
|
||||
// so a row index captured up front goes stale. Click the last remove button until none
|
||||
// remain.
|
||||
for (let guard = 0; guard < 20; guard++) {
|
||||
const removeButtons = page.locator('#account-grid-body .account-remove-action');
|
||||
if (await removeButtons.count() === 0) break;
|
||||
await removeButtons.last().click();
|
||||
await page.waitForTimeout(700);
|
||||
}
|
||||
}
|
||||
@@ -150,7 +149,7 @@ async function toggleToPercentMode(page: any) {
|
||||
|
||||
// Wait for HTMX to swap the grid body
|
||||
await page.waitForResponse(response =>
|
||||
response.url().includes('/toggle-amount-mode') && response.status() === 200
|
||||
response.url().includes('/edit-form-changed') && response.status() === 200
|
||||
);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
@@ -161,7 +160,7 @@ async function toggleToDollarMode(page: any) {
|
||||
|
||||
// Wait for HTMX to swap the grid body
|
||||
await page.waitForResponse(response =>
|
||||
response.url().includes('/toggle-amount-mode') && response.status() === 200
|
||||
response.url().includes('/edit-form-changed') && response.status() === 200
|
||||
);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
@@ -210,78 +209,39 @@ test.describe('Transaction Edit Shared Location', () => {
|
||||
});
|
||||
|
||||
test.describe('Transaction Edit Full Workflow', () => {
|
||||
test('should code transaction with vendor using percentage, then split 50/50, then switch to dollars', async ({ page }) => {
|
||||
// Step 1: Open edit modal and code with 100% to one account
|
||||
await openEditModal(page);
|
||||
|
||||
// Switch to percentage mode first (this re-renders the grid from server state)
|
||||
test('splits a transaction 50/50 in percentage mode and stores it as dollars', async ({ page }) => {
|
||||
// Transaction 0 is $100. Code it 50% / 50% across two accounts in percentage mode and
|
||||
// verify the save-time %->$ conversion stores/displays $50 + $50 on reopen.
|
||||
//
|
||||
// This intentionally types a percentage and THEN adds another row -- a whole-form
|
||||
// operation. The operation handlers now rebuild from the live posted form, not the
|
||||
// stale snapshot, so the first row's typed 50% survives (it used to revert, yielding a
|
||||
// 66.67/33.33 split).
|
||||
await openEditModal(page, 0);
|
||||
await removeAllAccounts(page);
|
||||
await toggleToPercentMode(page);
|
||||
|
||||
// Check if there's already an account from previous tests
|
||||
const allRows = page.locator('#account-grid-body tbody tr');
|
||||
const hasExistingAccount = await allRows.locator('input[name*="transaction-account/account"]').count() > 0;
|
||||
|
||||
if (!hasExistingAccount) {
|
||||
// Add a new account row if none exist
|
||||
await addNewAccount(page);
|
||||
}
|
||||
|
||||
// Select the account
|
||||
await selectAccountFromTypeahead(page, 0, 'Test');
|
||||
|
||||
// Set amount to 100%
|
||||
await setAccountAmount(page, 0, '100');
|
||||
|
||||
// Save the transaction
|
||||
await saveTransaction(page);
|
||||
|
||||
// Step 2: Re-open and split 50/50 with two accounts
|
||||
await openEditModal(page);
|
||||
|
||||
// Note: amount-mode is UI-only state, so it resets to $ when re-opening
|
||||
// Switch back to percentage mode
|
||||
await toggleToPercentMode(page);
|
||||
|
||||
// The existing account from step 1 should already be there
|
||||
// Change its amount from 100% to 50%
|
||||
await setAccountAmount(page, 0, '50');
|
||||
|
||||
// Add a second account at 50%
|
||||
|
||||
await addNewAccount(page);
|
||||
await selectAccountFromTypeahead(page, 0, 'Test');
|
||||
await setAccountAmount(page, 0, '50');
|
||||
|
||||
await addNewAccount(page);
|
||||
await page.waitForTimeout(1000);
|
||||
await selectAccountFromTypeahead(page, 1, 'Second');
|
||||
await setAccountAmount(page, 1, '50');
|
||||
|
||||
// Save
|
||||
|
||||
await saveTransaction(page);
|
||||
|
||||
// Step 3: Re-open and verify dollar amounts
|
||||
await openEditModal(page);
|
||||
|
||||
// The accounts should be persisted from the previous save
|
||||
// Wait for accounts to load
|
||||
|
||||
// Reopen: dollar mode is the default, and each account is the converted $50.
|
||||
await openEditModal(page, 0);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify we're in dollar mode (default)
|
||||
|
||||
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
|
||||
await expect(dollarRadio).toBeChecked();
|
||||
|
||||
// Verify amounts are in dollars (converted from percentages on save)
|
||||
const row0 = await findAccountRow(page, 0);
|
||||
const row1 = await findAccountRow(page, 1);
|
||||
|
||||
const amount0 = row0.locator('.account-amount-field');
|
||||
const amount1 = row1.locator('.account-amount-field');
|
||||
|
||||
// Each should be $50.00 (or close to it)
|
||||
const val0 = await amount0.inputValue();
|
||||
const val1 = await amount1.inputValue();
|
||||
|
||||
|
||||
const val0 = await (await findAccountRow(page, 0)).locator('.account-amount-field').inputValue();
|
||||
const val1 = await (await findAccountRow(page, 1)).locator('.account-amount-field').inputValue();
|
||||
expect(parseFloat(val0)).toBeCloseTo(50.0, 1);
|
||||
expect(parseFloat(val1)).toBeCloseTo(50.0, 1);
|
||||
|
||||
// Save
|
||||
await saveTransaction(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -340,15 +300,11 @@ async function openEditModalForTransaction(page: any, description: string) {
|
||||
const editButton = row.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first();
|
||||
await editButton.click();
|
||||
|
||||
// Wait for the modal to open
|
||||
// Wait for the modal to open. The modal is single-page now (no multi-step wizard
|
||||
// navigation), so the action tabs -- including "Link to payment" -- are available
|
||||
// immediately; callers click the tab they need.
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
|
||||
// Click Next to go to the links step (button says "Transaction Actions")
|
||||
await page.click('button:has-text("Transaction Actions")');
|
||||
|
||||
// Wait for the links step to load
|
||||
await page.waitForSelector('text=Transaction Actions', { state: 'visible' });
|
||||
}
|
||||
|
||||
async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
||||
@@ -359,7 +315,7 @@ async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
||||
throw new Error(`Could not find vendor with name ${vendorName}`);
|
||||
}
|
||||
|
||||
const vendorContainer = page.locator('div[hx-post*="edit-vendor-changed"]').first();
|
||||
const vendorContainer = page.locator('div[hx-vals*="vendor-changed"]').first();
|
||||
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
||||
|
||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||
@@ -374,7 +330,7 @@ async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
await page.waitForResponse(response => response.url().includes('/edit-vendor-changed') && response.status() === 200);
|
||||
await page.waitForResponse(response => response.url().includes('/edit-form-changed') && response.status() === 200);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
@@ -422,9 +378,17 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
|
||||
const testInfo = await getTestInfo(page);
|
||||
expect(accountValue).toBe(testInfo.accounts['test-account'].toString());
|
||||
|
||||
// The populated account amount should equal this transaction's amount (the vendor
|
||||
// default fills the single row with the whole amount). Read the actual amount from
|
||||
// the grid's transaction-total row rather than hard-coding it -- table row order is
|
||||
// not pinned across same-date seed transactions.
|
||||
const txTotalText = await page.locator('.account-grand-total-row').innerText();
|
||||
const txTotal = parseFloat(txTotalText.replace(/[^0-9.]/g, ''));
|
||||
expect(txTotal).toBeGreaterThan(0);
|
||||
|
||||
const amountInput = page.locator('.account-amount-field').first();
|
||||
const amountValue = await amountInput.inputValue();
|
||||
expect(parseFloat(amountValue)).toBeCloseTo(400.0, 1);
|
||||
expect(parseFloat(amountValue)).toBeCloseTo(txTotal, 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -434,11 +398,11 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
|
||||
// `elements` instead of being fetched. Everything else -- the dropdown's own
|
||||
// search input firing a native `change` on blur, the `value = element` click
|
||||
// handler, the Alpine reactivity, and the HTMX round-trip to
|
||||
// `edit-vendor-changed` -- runs exactly as in production. This is the flow that
|
||||
// `edit-form-changed` (op=vendor-changed) -- runs exactly as in production. This is the flow that
|
||||
// regressed: a stale native `change` from the search input used to win the race
|
||||
// and revert the vendor to its previous value.
|
||||
async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: string) {
|
||||
const wrapper = page.locator('div[hx-post*="edit-vendor-changed"]').first();
|
||||
const wrapper = page.locator('div[hx-vals*="vendor-changed"]').first();
|
||||
const typeahead = wrapper.locator('div.relative[x-data]').first();
|
||||
|
||||
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
|
||||
@@ -466,7 +430,7 @@ async function selectVendorViaDropdown(page: any, vendorId: number, vendorName:
|
||||
|
||||
await page.waitForResponse(
|
||||
(response: any) =>
|
||||
response.url().includes('/edit-vendor-changed') && response.status() === 200
|
||||
response.url().includes('/edit-form-changed') && response.status() === 200
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
@@ -485,7 +449,7 @@ async function openManualVendorSection(page: any, transactionIndex: number) {
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('div[hx-post*="edit-vendor-changed"]');
|
||||
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
||||
}
|
||||
|
||||
test.describe('Transaction Edit Vendor Selection', () => {
|
||||
@@ -501,14 +465,14 @@ test.describe('Transaction Edit Vendor Selection', () => {
|
||||
// round-trip. Before the fix this reverted to blank because a stale
|
||||
// `change` event submitted the previous vendor and its response won.
|
||||
const label = page
|
||||
.locator('div[hx-post*="edit-vendor-changed"] span[x-text="value.label"]')
|
||||
.locator('div[hx-vals*="vendor-changed"] span[x-text="value.label"]')
|
||||
.first();
|
||||
await expect(label).toHaveText('Test Vendor');
|
||||
|
||||
// The server-rendered hidden input must carry the newly selected vendor id.
|
||||
const hidden = page
|
||||
.locator(
|
||||
'div[hx-post*="edit-vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]'
|
||||
'div[hx-vals*="vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]'
|
||||
)
|
||||
.first();
|
||||
await expect(hidden).toHaveValue(vendorId.toString());
|
||||
|
||||
8
resources/templates/components/location-select.html
Normal file
8
resources/templates/components/location-select.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{# Location <select> for a transaction account row. Plain-HTML attributes -- the Selmer
|
||||
migration target (no Hiccup keyword/string attribute ambiguity). Rendered into the
|
||||
surrounding Hiccup row via the auto-ap.ssr.selmer interop bridge. #}
|
||||
<select name="{{ name }}" class="{{ classes }}">
|
||||
{% for opt in options %}
|
||||
<option value="{{ opt.value }}"{% if opt.selected %} selected{% endif %}>{{ opt.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
@@ -21,11 +21,13 @@
|
||||
[auto-ap.ssr-routes :as ssr-routes]
|
||||
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
|
||||
[auto-ap.ssr.components :as com]
|
||||
[auto-ap.ssr.components.inputs :as inputs]
|
||||
[auto-ap.ssr.grid-page-helper :as helper]
|
||||
[auto-ap.ssr.transaction.common :refer [grid-page]]
|
||||
[auto-ap.ssr.components.multi-modal :as mm]
|
||||
[auto-ap.ssr.form-cursor :as fc]
|
||||
[auto-ap.ssr.hx :as hx]
|
||||
[auto-ap.ssr.selmer :as sel]
|
||||
[auto-ap.ssr.svg :as svg]
|
||||
[auto-ap.ssr.utils
|
||||
:refer [->db-id apply-middleware-to-all-handlers check-allowance
|
||||
@@ -36,6 +38,7 @@
|
||||
[bidi.bidi :as bidi]
|
||||
[clj-time.coerce :as coerce]
|
||||
[clojure.edn :as edn]
|
||||
[clojure.string :as str]
|
||||
[datomic.api :as dc]
|
||||
[hiccup.util :as hu]
|
||||
[iol-ion.query :refer [dollars=]]
|
||||
@@ -153,20 +156,29 @@
|
||||
(:vendor/default-account clientized))))
|
||||
|
||||
(defn location-select*
|
||||
"The location <select> for an account row, rendered from a Selmer template
|
||||
(templates/components/location-select.html) -- the first interactive modal component
|
||||
migrated off Hiccup. Same options/selection/styling as the old com/select, emitted as
|
||||
plain HTML and embedded back into the Hiccup row via the interop bridge."
|
||||
[{:keys [name account-location client-locations value]}]
|
||||
(let [options (into (cond account-location
|
||||
[[account-location account-location]]
|
||||
(let [options (cond account-location
|
||||
[[account-location account-location]]
|
||||
|
||||
(seq client-locations)
|
||||
(into [["Shared" "Shared"]]
|
||||
(for [cl client-locations]
|
||||
[cl cl]))
|
||||
:else
|
||||
[["Shared" "Shared"]]))]
|
||||
(com/select {:options options
|
||||
:name name
|
||||
:value (or value (ffirst options))
|
||||
:class "w-full"})))
|
||||
(seq client-locations)
|
||||
(into [["Shared" "Shared"]]
|
||||
(for [cl client-locations]
|
||||
[cl cl]))
|
||||
|
||||
:else
|
||||
[["Shared" "Shared"]])
|
||||
selected (or value (ffirst options))
|
||||
classes (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
|
||||
(sel/render->hiccup
|
||||
"templates/components/location-select.html"
|
||||
{:name name
|
||||
:classes classes
|
||||
:options (for [[v label] options]
|
||||
{:value v :label label :selected (= v selected)})})))
|
||||
|
||||
(defn account-typeahead*
|
||||
[{:keys [name value client-id x-model]}]
|
||||
@@ -183,10 +195,32 @@
|
||||
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
|
||||
client-id)))})])
|
||||
|
||||
(defn- account-field-name
|
||||
"Explicit form-field name for account row `index`, field `field` -- the same string
|
||||
the form cursor produces at path [:step-params :transaction/accounts index field]
|
||||
(via path->name2), without faking a deep cursor to get there."
|
||||
[index field]
|
||||
(str "step-params[transaction/accounts][" index "]["
|
||||
(if (keyword? field)
|
||||
(str (when (namespace field) (str (namespace field) "/")) (name field))
|
||||
field)
|
||||
"]"))
|
||||
|
||||
(defn- account-field-errors
|
||||
"Errors for account row `index`, field `field`, read straight from the form errors
|
||||
at the same path the cursor would walk -- avoids re-rooting a cursor to look them up."
|
||||
[index field]
|
||||
(when (bound? #'fc/*form-errors*)
|
||||
(get-in fc/*form-errors* [:step-params :transaction/accounts index field])))
|
||||
|
||||
(defn simple-mode-fields*
|
||||
"Renders the simple-mode account + location row and the toggle-to-advanced link.
|
||||
Must be called within a fc/start-form + fc/with-field :step-params context.
|
||||
Caller must establish Alpine x-data with simpleAccountId in scope."
|
||||
Caller must establish Alpine x-data with simpleAccountId in scope.
|
||||
|
||||
The single account row is rendered from explicit data with explicit field names
|
||||
(account-field-name 0 ...) rather than faking a synthetic MapCursor rooted at
|
||||
accounts[0] -- the row always lives at index 0 in simple mode."
|
||||
[request]
|
||||
(let [snapshot (-> request :multi-form-state :snapshot)
|
||||
step-params (-> request :multi-form-state :step-params)
|
||||
@@ -204,53 +238,45 @@
|
||||
(:transaction/amount snapshot)
|
||||
0.0))]
|
||||
[:div
|
||||
(fc/with-field :transaction/accounts
|
||||
(fc/with-cursor (let [cur fc/*current*]
|
||||
(if (sequential? @cur)
|
||||
(nth cur 0 nil)
|
||||
(auto_ap.cursor.MapCursor. {} (cursor/state cur) (conj (cursor/path cur) 0))))
|
||||
[:span
|
||||
(fc/with-field :db/id
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value row-id}))
|
||||
[:div.flex.gap-2.mt-2
|
||||
(fc/with-field :transaction-account/account
|
||||
(com/validated-field
|
||||
{:label "Account"
|
||||
:errors (fc/field-errors)}
|
||||
[:div.w-72
|
||||
(account-typeahead* {:value account-val
|
||||
:client-id client-id
|
||||
:name (fc/field-name)
|
||||
:x-model "simpleAccountId"})]))
|
||||
(fc/with-field :transaction-account/location
|
||||
;; Selecting the account only affects the valid Location options, so the
|
||||
;; change swaps just this cell -- nothing else needs to re-render.
|
||||
[:div {:id "simple-account-location"}
|
||||
(com/validated-field
|
||||
{:label "Location"
|
||||
:errors (fc/field-errors)
|
||||
:x-hx-val:account-id "simpleAccountId"
|
||||
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
|
||||
client-id (assoc :client-id client-id)))
|
||||
:x-dispatch:changed "simpleAccountId"
|
||||
:hx-trigger "changed"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||
:hx-target "#simple-account-location"
|
||||
:hx-select "#simple-account-location"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"}
|
||||
(location-select*
|
||||
{:name (fc/field-name)
|
||||
:account-location (:account/location account-id)
|
||||
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
|
||||
:value location-val}))])
|
||||
(fc/with-field :transaction-account/amount
|
||||
(com/hidden {:name (fc/field-name)
|
||||
:value total}))]]))
|
||||
[:span
|
||||
(com/hidden {:name (account-field-name 0 :db/id)
|
||||
:value row-id})
|
||||
[:div.flex.gap-2.mt-2
|
||||
(com/validated-field
|
||||
{:label "Account"
|
||||
:errors (account-field-errors 0 :transaction-account/account)}
|
||||
[:div.w-72
|
||||
(account-typeahead* {:value account-val
|
||||
:client-id client-id
|
||||
:name (account-field-name 0 :transaction-account/account)
|
||||
:x-model "simpleAccountId"})])
|
||||
;; Selecting the account only affects the valid Location options, so the
|
||||
;; change swaps just this cell -- nothing else needs to re-render.
|
||||
[:div {:id "simple-account-location"}
|
||||
(com/validated-field
|
||||
{:label "Location"
|
||||
:errors (account-field-errors 0 :transaction-account/location)
|
||||
:x-hx-val:account-id "simpleAccountId"
|
||||
:hx-vals (hx/json (cond-> {:name (account-field-name 0 :transaction-account/location)}
|
||||
client-id (assoc :client-id client-id)))
|
||||
:x-dispatch:changed "simpleAccountId"
|
||||
:hx-trigger "changed"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||
:hx-target "#simple-account-location"
|
||||
:hx-select "#simple-account-location"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"}
|
||||
(location-select*
|
||||
{:name (account-field-name 0 :transaction-account/location)
|
||||
:account-location (:account/location account-id)
|
||||
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
|
||||
:value location-val}))]
|
||||
(com/hidden {:name (account-field-name 0 :transaction-account/amount)
|
||||
:value total})]]
|
||||
[:div.mt-1
|
||||
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
|
||||
{:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
|
||||
{:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||
:hx-vals (hx/json {:op "toggle-mode"})
|
||||
:hx-include "closest form"
|
||||
:hx-target "#wizard-form"
|
||||
:hx-select "#wizard-form"
|
||||
@@ -331,8 +357,8 @@
|
||||
(com/text-input (assoc amount-attrs :type "number" :step "0.01"))
|
||||
(com/money-input amount-attrs))))))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(com/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-remove-account)
|
||||
:hx-vals (hx/json {:row-index (or index 0)})
|
||||
(com/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||
:hx-vals (hx/json {:op "remove-account" :row-index (or index 0)})
|
||||
:hx-target "#wizard-form"
|
||||
:hx-select "#wizard-form"
|
||||
:hx-swap "outerHTML"
|
||||
@@ -374,12 +400,6 @@
|
||||
"text-red-300")}
|
||||
(format "$%,.2f" balance)]))
|
||||
|
||||
(defn account-total [request]
|
||||
(html-response (account-total* request)))
|
||||
|
||||
(defn account-balance [request]
|
||||
(html-response (account-balance* request)))
|
||||
|
||||
(defn ->percentage [amount total]
|
||||
(when (and amount total (not= total 0))
|
||||
(* 100.0 (/ amount total))))
|
||||
@@ -421,7 +441,8 @@
|
||||
:value amount-mode
|
||||
:name "step-params[amount-mode]"
|
||||
:orientation :horizontal
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode)
|
||||
:hx-vals (hx/json {:op "toggle-amount-mode"})
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||
:hx-target "#wizard-form"
|
||||
:hx-select "#wizard-form"
|
||||
:hx-swap "outerHTML"
|
||||
@@ -461,7 +482,8 @@
|
||||
|
||||
(com/data-grid-row {:class "new-row"}
|
||||
(com/data-grid-cell {:colspan 4}
|
||||
(com/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-new-account)
|
||||
(com/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||
:hx-vals (hx/json {:op "new-account"})
|
||||
:hx-target "#wizard-form"
|
||||
:hx-select "#wizard-form"
|
||||
:hx-swap "outerHTML"
|
||||
@@ -483,7 +505,8 @@
|
||||
[:div#manual-coding-section
|
||||
(com/hidden {:name "step-params[mode]" :value (name mode)})
|
||||
[:div {:hx-trigger "change"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||
:hx-vals (hx/json {:op "vendor-changed"})
|
||||
:hx-target "#wizard-form"
|
||||
:hx-select "#wizard-form"
|
||||
:hx-swap "outerHTML"
|
||||
@@ -509,7 +532,8 @@
|
||||
(when (<= row-count 1)
|
||||
[:div.mb-2
|
||||
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
|
||||
{:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
|
||||
{:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||
:hx-vals (hx/json {:op "toggle-mode"})
|
||||
:hx-include "closest form"
|
||||
:hx-target "#wizard-form"
|
||||
:hx-select "#wizard-form"
|
||||
@@ -521,17 +545,22 @@
|
||||
[:div#account-grid-body
|
||||
(account-grid-body* request)]))])]))
|
||||
|
||||
(defn toggle-amount-mode [request]
|
||||
(defn apply-toggle-amount-mode
|
||||
"edit-form-changed op: convert account amounts between $ and % and record the new mode."
|
||||
[request]
|
||||
(let [snapshot (-> request :multi-form-state :snapshot)
|
||||
step-params (-> request :multi-form-state :step-params)
|
||||
old-mode (or (:amount-mode snapshot) "$")
|
||||
new-mode (or (get-in request [:multi-form-state :step-params :amount-mode]) "$")
|
||||
new-mode (or (:amount-mode step-params) "$")
|
||||
total (Math/abs (or (:transaction/amount snapshot) 0.0))
|
||||
accounts (convert-accounts-mode (:transaction/accounts snapshot) old-mode new-mode total)
|
||||
updated-request (-> request
|
||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts] accounts)
|
||||
(assoc-in [:multi-form-state :snapshot :amount-mode] new-mode))]
|
||||
(html-response
|
||||
(render-full-form updated-request))))
|
||||
;; Convert the LIVE rows (step-params), not the stale snapshot, so amounts the
|
||||
;; user typed before toggling survive. step-params is already schema-decoded.
|
||||
accounts (convert-accounts-mode (or (seq (:transaction/accounts step-params))
|
||||
(:transaction/accounts snapshot))
|
||||
old-mode new-mode total)]
|
||||
(-> request
|
||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts] accounts)
|
||||
(assoc-in [:multi-form-state :snapshot :amount-mode] new-mode))))
|
||||
|
||||
(defn transaction-details-panel [tx]
|
||||
[:div.p-4.space-y-4
|
||||
@@ -756,7 +785,9 @@
|
||||
[:div.mt-4 {:hx-post (bidi/path-for ssr-routes/only-routes ::route/unlink-payment)
|
||||
:hx-trigger "unlinkPayment"
|
||||
:hx-target "#payment-matches"
|
||||
:hx-include "this"
|
||||
;; include the whole form so the db/id hidden rides along (the plain
|
||||
;; form derives state from db/id instead of a serialized snapshot)
|
||||
:hx-include "closest form"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-confirm "Are you sure you want to unlink this payment?"}
|
||||
|
||||
@@ -1310,15 +1341,20 @@
|
||||
(if current-step
|
||||
(mm/get-step this current-step)
|
||||
(mm/get-step this :links)))
|
||||
(render-wizard [this {:keys [multi-form-state] :as request}]
|
||||
(mm/default-render-wizard
|
||||
this request
|
||||
:form-params
|
||||
(-> mm/default-form-props
|
||||
(assoc :hx-post
|
||||
(str (bidi/path-for ssr-routes/only-routes ::route/edit-submit))
|
||||
:hx-ext "response-targets"))
|
||||
:render-timeline? false))
|
||||
(render-wizard [this {:keys [multi-form-state form-errors] :as request}]
|
||||
;; Plain-form render: no snapshot / edit-path / current-step hidden fields. The entity
|
||||
;; id rides in the form; all other state is the live form (step-params) re-derived
|
||||
;; against the entity on each request (see wrap-derive-state).
|
||||
(let [step (mm/get-current-step this)]
|
||||
[:form#wizard-form (-> mm/default-form-props
|
||||
(assoc :hx-post (str (bidi/path-for ssr-routes/only-routes ::route/edit-submit))
|
||||
:hx-ext "response-targets"))
|
||||
(fc/start-form multi-form-state (when form-errors {:step-params form-errors})
|
||||
(list
|
||||
(com/hidden {:name "db/id" :value (-> multi-form-state :snapshot :db/id)})
|
||||
(fc/with-field :step-params
|
||||
(com/modal {:id "wizardmodal"}
|
||||
(mm/render-step step request)))))]))
|
||||
(steps [_]
|
||||
[:links])
|
||||
(get-step [this step-key]
|
||||
@@ -1333,46 +1369,60 @@
|
||||
|
||||
(def edit-wizard (->EditWizard nil nil))
|
||||
|
||||
(defn initial-edit-wizard-state [request]
|
||||
(let [tx-id (-> request :route-params :db/id)
|
||||
entity (dc/pull (dc/db conn)
|
||||
'[:db/id
|
||||
:transaction/vendor
|
||||
:transaction/client
|
||||
:transaction/description-original
|
||||
:transaction/status
|
||||
:transaction/type
|
||||
:transaction/memo
|
||||
{[:transaction/approval-status :xform iol-ion.query/ident] [:db/ident]}
|
||||
:transaction/amount
|
||||
:transaction/accounts]
|
||||
tx-id)
|
||||
entity (-> entity
|
||||
(update :transaction/vendor :db/id)
|
||||
(update :transaction/client :db/id))]
|
||||
(mm/->MultiStepFormState entity
|
||||
[]
|
||||
entity)))
|
||||
(defn entity->base
|
||||
"The persisted transaction, shaped like the form's base state (what the old snapshot was
|
||||
seeded with). The plain form derives its state fresh from this + the live posted form,
|
||||
instead of round-tripping an EDN snapshot hidden field."
|
||||
[tx-id]
|
||||
(-> (dc/pull (dc/db conn)
|
||||
'[:db/id
|
||||
:transaction/vendor
|
||||
:transaction/client
|
||||
:transaction/description-original
|
||||
:transaction/status
|
||||
:transaction/type
|
||||
:transaction/memo
|
||||
{[:transaction/approval-status :xform iol-ion.query/ident] [:db/ident]}
|
||||
:transaction/amount
|
||||
:transaction/accounts]
|
||||
tx-id)
|
||||
(update :transaction/vendor :db/id)
|
||||
(update :transaction/client :db/id)))
|
||||
|
||||
(defn- render-account-grid-body [request]
|
||||
(fc/start-form (:multi-form-state request) nil
|
||||
(fc/with-field :step-params
|
||||
(fc/with-field :transaction/accounts
|
||||
(account-grid-body* request)))))
|
||||
(defn wrap-derive-state
|
||||
"Plain-form replacement for the EDN-snapshot round-trip. Builds :multi-form-state from
|
||||
the entity (loaded by the db/id hidden field, or the route on initial open) overlaid
|
||||
with the live posted step-params -- no serialized snapshot. Runs after wrap-decode /
|
||||
wrap-wizard, which provide nested + schema-typed step-params. The 30-odd `:snapshot`
|
||||
reads keep working: snapshot is now `entity ∪ step-params`, derived per request."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [tx-id (->db-id (or (some-> request :form-params (get "db/id"))
|
||||
(-> request :route-params :db/id)))
|
||||
base (entity->base tx-id)
|
||||
posted (-> request :multi-form-state :step-params)
|
||||
;; Fields the form does NOT edit always come from the entity. Everything else is
|
||||
;; the live posted form, which is authoritative even when ABSENT -- an absent
|
||||
;; field means the user cleared it (e.g. removed all account rows), not "fall
|
||||
;; back to the entity's persisted value". Merging base's editable fields back in
|
||||
;; would resurrect persisted accounts after a remove-all.
|
||||
entity-only (select-keys base [:db/id :transaction/client :transaction/amount
|
||||
:transaction/description-original
|
||||
:transaction/status :transaction/type])
|
||||
;; On initial open there is no posted form -> render the entity. On every post
|
||||
;; the form is authoritative for the editable fields.
|
||||
step-params (if (seq posted) posted base)
|
||||
snapshot (if (seq posted) (merge entity-only posted) base)]
|
||||
(handler (-> request
|
||||
(assoc :entity (d-transactions/get-by-id tx-id))
|
||||
(assoc :multi-form-state (mm/->MultiStepFormState snapshot [] step-params)))))))
|
||||
|
||||
(defn render-full-form
|
||||
"Helper to render the complete transaction edit form for whole-form re-rendering."
|
||||
[request]
|
||||
(mm/render-wizard edit-wizard request))
|
||||
|
||||
(defn edit-form-changed-handler
|
||||
"Generic handler that re-renders the whole form. Used when any field changes
|
||||
and we need the server to re-compute dependent fields."
|
||||
[request]
|
||||
(html-response
|
||||
(render-full-form request)))
|
||||
|
||||
(defn edit-vendor-changed-handler [request]
|
||||
(defn apply-vendor-changed [request]
|
||||
(let [multi-form-state (:multi-form-state request)
|
||||
snapshot (:snapshot multi-form-state)
|
||||
step-params (:step-params multi-form-state)
|
||||
@@ -1414,10 +1464,9 @@
|
||||
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
|
||||
request)
|
||||
(assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))]
|
||||
(html-response
|
||||
(render-full-form render-request))))
|
||||
render-request))
|
||||
|
||||
(defn edit-wizard-toggle-mode-handler [request]
|
||||
(defn apply-toggle-mode [request]
|
||||
(let [step-params (-> request :multi-form-state :step-params)
|
||||
snapshot (-> request :multi-form-state :snapshot)
|
||||
current-mode (keyword (or (:mode step-params) "simple"))
|
||||
@@ -1446,34 +1495,39 @@
|
||||
(if first-row [first-row] []))
|
||||
(assoc-in [:multi-form-state :step-params :mode]
|
||||
(name target-mode)))))]
|
||||
(html-response
|
||||
(render-full-form render-request))))
|
||||
render-request))
|
||||
|
||||
(defn edit-wizard-new-account-handler
|
||||
"Adds a new account row and re-renders the whole form."
|
||||
(defn apply-new-account
|
||||
"edit-form-changed op: append a fresh account row."
|
||||
[request]
|
||||
(let [snapshot (-> request :multi-form-state :snapshot)
|
||||
amount-mode (or (:amount-mode snapshot) "$")
|
||||
step-params (-> request :multi-form-state :step-params)
|
||||
amount-mode (or (:amount-mode step-params) (:amount-mode snapshot) "$")
|
||||
total (Math/abs (or (:transaction/amount snapshot) 0.0))
|
||||
new-account {:db/id (str (java.util.UUID/randomUUID))
|
||||
:new? true
|
||||
:transaction-account/location "Shared"
|
||||
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
|
||||
accounts (vec (or (:transaction/accounts snapshot) []))
|
||||
;; Append to the LIVE rows (step-params) so values typed before clicking
|
||||
;; "New account" are not reverted to the stale snapshot.
|
||||
accounts (vec (or (seq (:transaction/accounts step-params))
|
||||
(:transaction/accounts snapshot) []))
|
||||
updated-accounts (conj accounts new-account)
|
||||
updated-request (-> request
|
||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts)
|
||||
(assoc-in [:multi-form-state :step-params :transaction/accounts] updated-accounts))]
|
||||
(html-response
|
||||
(render-full-form updated-request))))
|
||||
updated-request))
|
||||
|
||||
(defn edit-wizard-remove-account-handler
|
||||
"Removes an account row and re-renders the whole form.
|
||||
Expects a row-index in the form params."
|
||||
(defn apply-remove-account
|
||||
"edit-form-changed op: remove the account row at form-param row-index."
|
||||
[request]
|
||||
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
|
||||
snapshot (-> request :multi-form-state :snapshot)
|
||||
accounts (vec (or (:transaction/accounts snapshot) []))
|
||||
step-params (-> request :multi-form-state :step-params)
|
||||
;; Remove from the LIVE rows (step-params) so the surviving rows keep the values
|
||||
;; the user typed, rather than reverting to the stale snapshot.
|
||||
accounts (vec (or (seq (:transaction/accounts step-params))
|
||||
(:transaction/accounts snapshot) []))
|
||||
updated-accounts (if (and row-index (< row-index (count accounts)))
|
||||
(vec (concat (subvec accounts 0 row-index)
|
||||
(subvec accounts (inc row-index))))
|
||||
@@ -1481,31 +1535,43 @@
|
||||
updated-request (-> request
|
||||
(assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts)
|
||||
(assoc-in [:multi-form-state :step-params :transaction/accounts] updated-accounts))]
|
||||
updated-request))
|
||||
|
||||
(defn edit-form-changed-handler
|
||||
"Single whole-form re-render endpoint. Dispatches on the `op` form-param to apply the
|
||||
relevant state mutation (vendor change, mode toggle, add/remove row, $/% toggle), then
|
||||
re-renders the whole form. A missing/unknown op (a plain dependent-field change) just
|
||||
re-renders. Replaces the per-operation edit-wizard-* / toggle-amount-mode routes."
|
||||
[request]
|
||||
(let [op (get-in request [:form-params "op"])
|
||||
request' (case op
|
||||
"vendor-changed" (apply-vendor-changed request)
|
||||
"toggle-mode" (apply-toggle-mode request)
|
||||
"new-account" (apply-new-account request)
|
||||
"remove-account" (apply-remove-account request)
|
||||
"toggle-amount-mode" (apply-toggle-amount-mode request)
|
||||
request)]
|
||||
(html-response
|
||||
(render-full-form updated-request))))
|
||||
(render-full-form request'))))
|
||||
|
||||
(def key->handler
|
||||
(apply-middleware-to-all-handlers
|
||||
{::route/edit-wizard (-> mm/open-wizard-handler
|
||||
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
|
||||
(wrap-derive-state)
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-init-multi-form-state initial-edit-wizard-state)
|
||||
(wrap-entity [:route-params :db/id] d-transactions/default-read)
|
||||
(mm/wrap-decode-multi-form-state)
|
||||
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
||||
::route/edit-wizard-navigate (-> mm/next-handler
|
||||
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(wrap-derive-state)
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/edit-submit (-> mm/submit-handler
|
||||
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(wrap-derive-state)
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/edit-vendor-changed (-> edit-vendor-changed-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/location-select (-> location-select
|
||||
(wrap-schema-enforce :query-schema [:map
|
||||
[:name :string]
|
||||
@@ -1513,42 +1579,15 @@
|
||||
[:maybe entity-id]]
|
||||
[:account-id {:optional true}
|
||||
[:maybe entity-id]]]))
|
||||
::route/account-total (-> account-total
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/account-balance (-> account-balance
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/edit-form-changed (-> edit-form-changed-handler
|
||||
(wrap-derive-state)
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/toggle-amount-mode (-> toggle-amount-mode
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/edit-wizard-toggle-mode (-> edit-wizard-toggle-mode-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/edit-wizard-new-account (-> edit-wizard-new-account-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
::route/edit-wizard-remove-account (-> edit-wizard-remove-account-handler
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(mm/wrap-decode-multi-form-state))
|
||||
|
||||
::route/unlink-payment (-> unlink-payment
|
||||
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
|
||||
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
|
||||
(wrap-derive-state)
|
||||
(mm/wrap-wizard edit-wizard)
|
||||
(mm/wrap-decode-multi-form-state)
|
||||
#_(wrap-schema-enforce :form-schema
|
||||
save-schema))}
|
||||
(mm/wrap-decode-multi-form-state))}
|
||||
(fn [h]
|
||||
(-> h
|
||||
(wrap-client-redirect-unauthenticated)))))
|
||||
|
||||
@@ -28,15 +28,8 @@
|
||||
|
||||
["/" [#"\d+" :db/id]] {"/edit" {:get ::edit-wizard}}
|
||||
"/edit-submit" ::edit-submit
|
||||
"/edit-vendor-changed" ::edit-vendor-changed
|
||||
"/location-select" ::location-select
|
||||
"/account-total" ::account-total
|
||||
"/account-balance" ::account-balance
|
||||
"/toggle-amount-mode" ::toggle-amount-mode
|
||||
"/edit-form-changed" ::edit-form-changed
|
||||
"/edit-wizard-new-account" ::edit-wizard-new-account
|
||||
"/edit-wizard-remove-account" ::edit-wizard-remove-account
|
||||
"/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode
|
||||
"/match-payment" ::link-payment
|
||||
"/match-autopay-invoices" ::link-autopay-invoices
|
||||
"/match-unpaid-invoices" ::link-unpaid-invoices
|
||||
|
||||
Reference in New Issue
Block a user