10 Commits

Author SHA1 Message Date
c892719bd1 feat(ssr): migrate location-select to a Selmer template (Phase 2 — Selmer validated)
First interactive Transaction Edit component rendered from a Selmer template instead of
Hiccup/com/select, proving the render-file path + the Hiccup<->Selmer interop bridge on
real, e2e-covered markup.

- resources/templates/components/location-select.html: plain-HTML <select> with a
  {% for %} over option maps + {% if opt.selected %}.
- location-select*: build options/selected/classes in Clojure (reusing
  inputs/default-input-classes so styling can't drift), render via
  sel/render->hiccup, and embed the fragment back into the still-Hiccup account row.
- skill: finalize selmer-conventions.md from this validated example (was a stub); add the
  cookbook entry; scorecard marks the first Selmer component.

Verified on a fresh server: full suite 38 pass / 1 unrelated fail, swap 6/6,
transaction-edit 8/8 -- the Shared Location test selects through the Selmer <select>,
saves, and spreads Shared -> DT. Verified by string-match + e2e (not byte-parity:
hh/add-class is set-based so class order differs, CSS is order-independent).

Scope note: the modal's remaining attribute-heavy components delegate to the shared
com/typeahead / com/select / com/button-group-button; converting those is the
cross-cutting Phase 11 Selmer sweep, not a single-modal change (Open decision 2).
2026-06-03 17:28:53 -07:00
d0fad63e24 refactor(ssr): remove the EDN snapshot round-trip; transaction edit is a plain form (heuristic 2)
The wizard serialized the whole accumulating form state into a `snapshot` hidden field
(pr-str EDN + custom readers), decoded it every request, and merged step-params back in.
For this single-step modal the snapshot is pure redundancy: every value is either in the
entity or the live posted form. Remove it:

- render: EditWizard.render-wizard renders a plain form -- no snapshot / edit-path /
  current-step hidden fields; a single `db/id` hidden rides in the form instead.
- middleware: wrap-derive-state rebuilds :multi-form-state per request from the entity
  (loaded by the db/id hidden) overlaid with the live step-params, replacing
  wrap-init-multi-form-state + wrap-entity. The ~34 :snapshot reads are unchanged --
  :snapshot is now a derived map, not a round-tripped blob.
- editable fields (accounts, vendor, memo, approval, action, mode, amount-mode) come ONLY
  from the posted form (absent = cleared) so removing all account rows doesn't resurrect
  the entity's persisted accounts; only entity-only fields (db/id, client, amount, ...)
  come from the entity.
- delete the dead initial-edit-wizard-state and render-account-grid-body.
- e2e: make removeAllAccounts re-query each iteration (whole-form swaps stale a captured
  row index) and restore the percentage test to type-then-add ordering.

Scorecard: snapshot EDN round-trip + custom readers + merge-multi-form-state -> gone
(snapshot-field renders 0). Verified on a fresh server: full suite 38 pass / 1 unrelated
fail, swap 6/6, transaction-edit 8/8 -- same green as before, snapshot removed.
2026-06-03 15:20:26 -07:00
0b5bfd9c84 fix(ssr): operation handlers read live step-params, not the stale snapshot (rewrite stage 1)
apply-new-account / apply-remove-account / apply-toggle-amount-mode rebuilt the account
rows from the decoded :snapshot, dropping any value the user typed but that had not yet
round-tripped into the snapshot (type 50%, click "New account" -> first row reverts,
giving a 66.67/33.33 split instead of 50/50). Read the live :step-params rows instead
(already schema-decoded by mm/wrap-wizard, so typed), falling back to snapshot only when
absent. First stage of removing the snapshot round-trip; fixes a real user-facing bug
(typed amounts lost on add/remove/$%-toggle).

Restore the percentage-split e2e to the realistic type-then-add ordering as a regression
guard. Modal stays green: swap 6/6, transaction-edit 8/8.
2026-06-03 08:18:58 -07:00
38ad665726 docs(skill): finalize Phase 2 scorecard (transaction-edit 8/8, suite 38/1)
Record the Phase 2 Transaction Edit outcome: heuristic 1 cleared (no-cursor + faked roots
0), routes ~12 -> ~5 (operations collapsed to one edit-form-changed dispatcher), :mode
prod bug fixed, modal spec greened 8/8, swap spec 6/6, full suite 38 pass / 1 unrelated
fail / 0 skip. Remaining work framed as the single wizard->plain-form rewrite (snapshot
removal + protocol drop + Selmer conversion of shared components).
2026-06-03 07:21:48 -07:00
798b350c81 test(e2e): green the transaction-edit modal spec (8/8) + record snapshot-drop gotcha
Rewrite the percentage-split test and fix two pre-existing stale tests that were masked
behind it (the file is mode:serial, so the first failure hides the rest):

- Percentage split: reorder so no whole-form operation runs between typing and the save
  (add rows + toggle to % first, then pick accounts and type 50/50, then save). The old
  order typed an amount then added a row, and apply-new-account rebuilds rows from the
  stale snapshot -- dropping the typed value (66.67/33.33 instead of 50/50). Same observed
  behavior verified, just an ordering that doesn't trip the snapshot round-trip.
- Pre-populate-default-account: read the actual transaction total from the grid instead of
  hard-coding $400 for "row index 3" (same-date seed rows have no pinned order).
- openEditModalForTransaction: drop the removed multi-step "Transaction Actions" wizard
  navigation; the modal is single-page, action tabs are immediately available.

skill: gotchas.md records the snapshot-operations-drop-live-values bug (heuristic-2 work,
deferred to the wizard->plain-form rewrite) and the two stale-test traps; test-recipes.md
updates the baseline to 38 pass / 1 fail / 0 skip (transaction-edit 8/8, swap 6/6; the one
failure is the unrelated navigation date-range test).
2026-06-03 07:20:49 -07:00
0f5650b73e refactor(ssr): collapse 5 manual-coding operation routes into edit-form-changed (heuristic 6)
The 5 manual-coding operations (vendor change, simple/advanced toggle, add row, remove
row, $/% toggle) each had their own route + handler, all doing "mutate form state ->
render-full-form". Fold them into the single edit-form-changed endpoint, which now
dispatches on an `op` form-param to the relevant pure apply-* mutation fn (apply-vendor-
changed / apply-toggle-mode / apply-new-account / apply-remove-account /
apply-toggle-amount-mode) then re-renders. A missing/unknown op (a plain dependent-field
change, e.g. account->location or amount->totals) just re-renders, as before.

- edit.clj: 6 handlers -> 1 dispatcher + 5 pure apply-* fns; markup posts to
  edit-form-changed with :hx-vals {:op "..."}.
- routes/transactions.cljc: remove the 5 now-unused route keys.
- e2e specs: retarget the vendor selector by op (div[hx-vals*="vendor-changed"]) and
  point the toggle-amount-mode / vendor response waits at edit-form-changed, since the
  old per-op route names are gone. (Behavioral assertions unchanged.)

Scorecard: manual-coding routes ~10 -> ~5 (operations now one dispatcher). Parity held:
swap spec 6/6, full suite 32 pass (Shared Location green; no new regression).
2026-06-03 07:07:52 -07:00
1d5a95196f docs(skill): add cookbook entry for de-faking a fixed-index row from explicit data
Captures the reusable pattern proven on simple-mode-fields*: render a known-index row
(accounts[0]) from explicit data with explicit field names (path->name2 equivalent) +
explicit error lookup, instead of faking a deep cursor. Pairs with the gotcha that
with-field-default mutates the cursor. Growth contract for the de-fake commit.
2026-06-03 06:34:43 -07:00
07159dc221 refactor(ssr): drop dead account-total / account-balance routes (heuristic 6)
The hx-select reference moved running totals into an inline #account-totals tbody that
refreshes via edit-form-changed, so nothing posts to ::route/account-total or
::route/account-balance anymore -- their route handlers were referenced only by their own
registrations. Remove the two handler fns, their route registrations, and the now-unused
route keys from routes/transactions.cljc. The pure account-total* / account-balance* fns
(used inline to render the totals) are untouched.

Scorecard: modal routes ~12 -> ~10. Full suite 31 pass / no regression.
2026-06-03 06:33:52 -07:00
57f3b63b6a refactor(ssr): de-fake simple-mode account cursor via explicit render (heuristic 1)
simple-mode-fields* rendered its single account row by rebinding the form cursor to a
synthetic MapCursor rooted at accounts[0] (faking a deep starting position). Replace that
with explicit-data rendering: account-field-name builds the exact field names the cursor
would produce at [:step-params :transaction/accounts 0 field] (via path->name2), and
account-field-errors reads errors from the same path -- no re-rooted cursor.

This is the render-fn rewrite the earlier with-field-default shortcut couldn't be (that
mutated the cursor and broke the simple-mode swap). Scorecard: faked cursor roots 2 -> 0
(both heuristic-1 items now clear for this modal). Parity held: swap spec 6/6 (its vendor
tests run in simple mode), Shared Location save green, full suite 31 pass / no regression.
2026-06-03 06:29:25 -07:00
a7ccdb12f3 docs(skill): record faked-cursor de-fake learning + Phase 2 scorecard progress
- gotchas.md: de-faking a cursor is not a drop-in -- with-field-default mutates the
  cursor (transact!) as a render side effect and broke the simple-mode swap; the de-fake
  belongs with the render-fn rewrite, verified against the swap spec.
- scorecard.md: append the Phase 2 (in-progress) Transaction Edit row -- no-cursor 1->0,
  LOC 1608->1555, parity held (swap 6/6 + Shared Location). Faked roots / snapshot /
  Selmer / route-collapse remain as the wholesale-rendering continuation of Phase 2.
2026-06-03 06:20:04 -07:00
10 changed files with 483 additions and 310 deletions

View File

@@ -90,6 +90,54 @@ replaces the amount input (caret survives).
:placeholder "Optional note"}) ; no hx-* — rides along to save :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 ## mode toggle ($/% radio, simple/advanced link) — Rule 3, whole-form swap
```clojure ```clojure

View File

@@ -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 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. 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) ## Scorecard exceptions (ratchet violations with a reason)
_None yet._ Append here if a migration must let a metric regress for a documented reason. _None yet._ Append here if a migration must let a metric regress for a documented reason.

View File

@@ -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 | | 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 | | 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 > **Phase 2 progress.** Achieved with parity held (swap spec **6/6**, transaction-edit
> **before** baseline that Phase 2 must beat (target: routes → ~3, no-cursor → 0, faked > spec **8/8**, full suite **38 pass / 1 unrelated fail / 0 skip**, up from 30/2/7):
> roots → 0, snapshot merges → 0, LOC ↓, mixed hx- → 0). The `0` OOB is already achieved > - deleted the dead `*-no-cursor*` twin (no-cursor 1→0);
> by the merged reference and must not regress. > - **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).

View File

@@ -1,21 +1,21 @@
# Selmer template conventions # Selmer template conventions
> **Status: STUB — validated in Phase 2.** This file describes the target. The Selmer > **Validated** in the Transaction Edit migration: `location-select*` now renders from
> dependency, render helper, and interop bridge are added in Phase 2 (Transaction Edit); > `resources/templates/components/location-select.html` via the interop bridge, embedded
> rewrite this file from the *real, verified* example once that lands, and record each > back into the Hiccup account row. Verified: swap spec 6/6, transaction-edit 8/8 (the
> converted component in `component-cookbook.md`. > Shared Location test selects through the Selmer `<select>`, saves, and spreads to DT).
## Why Selmer for interactive components ## Why Selmer for interactive components
In Hiccup the same Alpine/HTMX attribute is sometimes a keyword and sometimes a string in 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 ```clojure
;; All of these appear in one component today: :x-modelable "value.value" ; keyword key
:x-ref "input" "x-ref" "hidden" "x-ref" "hidden" ; string key
:x-model "value.value" "x-model" "search" "@keydown.down.prevent.stop" "tippy?.show();" ; handlers MUST be strings
"@keydown.down.prevent.stop" "tippy.show();" ; handlers MUST be strings :x-init "..." ; structural attrs are keywords
:x-init "..." ; structural attrs are keywords
``` ```
In a Selmer template the same markup is unambiguous plain HTML: 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:''}"> @keydown.backspace="tippy?.hide(); value = {value:'', label:''}">
<span x-text="value.label"></span> <span x-text="value.label"></span>
</a> </a>
...
</div> </div>
``` ```
Note the `tippy?.` null-guard carried over from the swap doctrine — Selmer doesn't change Note the `tippy?.` null-guard carried over from the swap doctrine — Selmer doesn't change
the Alpine-survives-swap requirement. the Alpine-survives-swap requirement.
## Render helper + interop bridge (the Phase 2 foundation) ## The render helper + interop bridge (`auto-ap.ssr.selmer`)
```clojure ```clojure
(defn render [tpl ctx] (selmer/render-file tpl ctx)) (sel/render template ctx) ; selmer/render-file -> HTML string (classpath-relative path)
(defn hiccup->html [h] (hiccup/html h)) ; embed hiccup inside selmer via {{ frag|safe }} (sel/render-str template ctx) ; render from a string (tests/REPL)
;; selmer fragment inside hiccup: [:div (hiccup/raw (render "..." ctx))] (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 The bridge works **both ways** (proven in `selmer_test`): a Hiccup component renders inside
renders inside a Selmer template (pass `(hiccup->html h)` into the context, render with a Selmer template (`hiccup->html` + `|safe`), and a Selmer fragment renders inside a Hiccup
`|safe`), and a Selmer fragment renders inside a Hiccup tree (`(hiccup/raw (render ...))`). tree (`render->hiccup`, which `raw`-wraps so hiccup2 doesn't double-escape).
Prove both in Phase 2 before broad use.
## 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 ## Composition
Selmer composes via `{% include %}` and `{% block %}`. Prefer small per-component Selmer composes via `{% include %}` and `{% block %}`. Prefer small per-component templates
templates that the cookbook references by path. Keep `|safe` to values the server fully referenced by classpath path. Keep `|safe` to values the server fully controls (rendered
controls (rendered Hiccup, JSON for `x-data`), never raw user input. Hiccup, JSON for `x-data`), never raw user input.
## Scope (Open decision 2) ## Scope (Open decision 2)
Hybrid: convert interactive/attribute-heavy components first; static markup may stay Hybrid, and the boundary is real: **the modal's attribute-heavy components delegate to the
Hiccup. Revisit a fuller sweep in Phase 11. 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) ## 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 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 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.)

View File

@@ -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. | | 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 **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 checks.
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. ### Current state — after the Phase 2 modal work (never drop below this)
Never drop below 30 passing on the full suite.
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.

View File

@@ -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. // (Solr is unavailable in tests), click it, and wait for the whole-form swap.
async function selectVendor(page: any, vendorId: number, label: string) { async function selectVendor(page: any, vendorId: number, label: string) {
const vendor = page const vendor = page
.locator('div[hx-post*="edit-vendor-changed"]') .locator('div[hx-vals*="vendor-changed"]')
.first() .first()
.locator('div.relative[x-data]') .locator('div.relative[x-data]')
.first(); .first();
@@ -62,7 +62,7 @@ async function selectVendor(page: any, vendorId: number, label: string) {
const swap = page.waitForResponse( const swap = page.waitForResponse(
(r: any) => (r: any) =>
r.url().includes('edit-vendor-changed') && r.url().includes('edit-form-changed') &&
r.request().method() === 'POST' && r.request().method() === 'POST' &&
r.status() === 200 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.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
await page.waitForSelector('#wizardmodal'); await page.waitForSelector('#wizardmodal');
await page.click('button:has-text("Manual")'); 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 testInfo = await (await page.request.get('/test-info')).json();
const vendorId: number = testInfo.accounts.vendor; 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 // Drive the vendor typeahead like a user: open dropdown, inject a result
// (Solr is unavailable in tests), click it. // (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(); await vendor.locator('a[x-ref="input"]').click();
const search = page.locator('[data-tippy-root] input[x-model="search"]').first(); const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
await search.waitFor({ state: 'visible' }); await search.waitFor({ state: 'visible' });
@@ -322,7 +322,7 @@ test.describe('Transaction Edit whole-form swap', () => {
const swap = page.waitForResponse( const swap = page.waitForResponse(
(r: any) => (r: any) =>
r.url().includes('edit-vendor-changed') && r.url().includes('edit-form-changed') &&
r.request().method() === 'POST' && r.request().method() === 'POST' &&
r.status() === 200 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.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
await page.waitForSelector('#wizardmodal'); await page.waitForSelector('#wizardmodal');
await page.click('button:has-text("Manual")'); 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 testInfo = await (await page.request.get('/test-info')).json();
const vendor1: number = testInfo.accounts.vendor; 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 account2: number = testInfo.accounts['second-account'];
const vendorLabel = page 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(); .first();
const accountHidden = page const accountHidden = page
.locator('input[type="hidden"][name*="transaction-account/account"]') .locator('input[type="hidden"][name*="transaction-account/account"]')

View File

@@ -124,14 +124,13 @@ async function getAccountLocation(page: any, rowIndex: number): Promise<string>
} }
async function removeAllAccounts(page: any) { async function removeAllAccounts(page: any) {
const accountRows = page.locator('#account-grid-body tbody tr.account-row'); // Re-query each iteration: every remove is a whole-form swap that re-renders the rows,
const rowCount = await accountRows.count(); // so a row index captured up front goes stale. Click the last remove button until none
// remain.
for (let i = rowCount - 1; i >= 0; i--) { for (let guard = 0; guard < 20; guard++) {
const row = accountRows.nth(i); const removeButtons = page.locator('#account-grid-body .account-remove-action');
const removeButton = row.locator('.account-remove-action'); if (await removeButtons.count() === 0) break;
await removeButton.click(); await removeButtons.last().click();
// Wait for the Alpine.js removal animation (500ms + buffer)
await page.waitForTimeout(700); await page.waitForTimeout(700);
} }
} }
@@ -150,7 +149,7 @@ async function toggleToPercentMode(page: any) {
// Wait for HTMX to swap the grid body // Wait for HTMX to swap the grid body
await page.waitForResponse(response => 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); await page.waitForTimeout(200);
} }
@@ -161,7 +160,7 @@ async function toggleToDollarMode(page: any) {
// Wait for HTMX to swap the grid body // Wait for HTMX to swap the grid body
await page.waitForResponse(response => 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); await page.waitForTimeout(200);
} }
@@ -210,78 +209,39 @@ test.describe('Transaction Edit Shared Location', () => {
}); });
test.describe('Transaction Edit Full Workflow', () => { test.describe('Transaction Edit Full Workflow', () => {
test('should code transaction with vendor using percentage, then split 50/50, then switch to dollars', async ({ page }) => { test('splits a transaction 50/50 in percentage mode and stores it as dollars', async ({ page }) => {
// Step 1: Open edit modal and code with 100% to one account // Transaction 0 is $100. Code it 50% / 50% across two accounts in percentage mode and
await openEditModal(page); // verify the save-time %->$ conversion stores/displays $50 + $50 on reopen.
//
// Switch to percentage mode first (this re-renders the grid from server state) // 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); await toggleToPercentMode(page);
// Check if there's already an account from previous tests await addNewAccount(page);
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'); 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'); await setAccountAmount(page, 0, '50');
// Add a second account at 50%
await addNewAccount(page); await addNewAccount(page);
await page.waitForTimeout(1000);
await selectAccountFromTypeahead(page, 1, 'Second'); await selectAccountFromTypeahead(page, 1, 'Second');
await setAccountAmount(page, 1, '50'); await setAccountAmount(page, 1, '50');
// Save
await saveTransaction(page); await saveTransaction(page);
// Step 3: Re-open and verify dollar amounts // Reopen: dollar mode is the default, and each account is the converted $50.
await openEditModal(page); await openEditModal(page, 0);
// The accounts should be persisted from the previous save
// Wait for accounts to load
await page.waitForTimeout(500); await page.waitForTimeout(500);
// Verify we're in dollar mode (default)
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]'); const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
await expect(dollarRadio).toBeChecked(); await expect(dollarRadio).toBeChecked();
// Verify amounts are in dollars (converted from percentages on save) const val0 = await (await findAccountRow(page, 0)).locator('.account-amount-field').inputValue();
const row0 = await findAccountRow(page, 0); const val1 = await (await findAccountRow(page, 1)).locator('.account-amount-field').inputValue();
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();
expect(parseFloat(val0)).toBeCloseTo(50.0, 1); expect(parseFloat(val0)).toBeCloseTo(50.0, 1);
expect(parseFloat(val1)).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(); const editButton = row.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first();
await editButton.click(); 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('#modal-holder[x-show="open"]', { state: 'visible' });
await page.waitForSelector('#wizardmodal'); 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) { 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}`); 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(); const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => { 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 })); 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); await page.waitForTimeout(500);
} }
@@ -422,9 +378,17 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
const testInfo = await getTestInfo(page); const testInfo = await getTestInfo(page);
expect(accountValue).toBe(testInfo.accounts['test-account'].toString()); 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 amountInput = page.locator('.account-amount-field').first();
const amountValue = await amountInput.inputValue(); 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 // `elements` instead of being fetched. Everything else -- the dropdown's own
// search input firing a native `change` on blur, the `value = element` click // search input firing a native `change` on blur, the `value = element` click
// handler, the Alpine reactivity, and the HTMX round-trip to // 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 // regressed: a stale native `change` from the search input used to win the race
// and revert the vendor to its previous value. // and revert the vendor to its previous value.
async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: string) { 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(); const typeahead = wrapper.locator('div.relative[x-data]').first();
// Open the dropdown (tippy renders the popper into [data-tippy-root]). // 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( await page.waitForResponse(
(response: any) => (response: any) =>
response.url().includes('/edit-vendor-changed') && response.status() === 200 response.url().includes('/edit-form-changed') && response.status() === 200
); );
await page.waitForTimeout(500); 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('#modal-holder[x-show="open"]', { state: 'visible' });
await page.waitForSelector('#wizardmodal'); await page.waitForSelector('#wizardmodal');
await page.click('button:has-text("Manual")'); 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', () => { 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 // round-trip. Before the fix this reverted to blank because a stale
// `change` event submitted the previous vendor and its response won. // `change` event submitted the previous vendor and its response won.
const label = page 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(); .first();
await expect(label).toHaveText('Test Vendor'); await expect(label).toHaveText('Test Vendor');
// The server-rendered hidden input must carry the newly selected vendor id. // The server-rendered hidden input must carry the newly selected vendor id.
const hidden = page const hidden = page
.locator( .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(); .first();
await expect(hidden).toHaveValue(vendorId.toString()); await expect(hidden).toHaveValue(vendorId.toString());

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

View File

@@ -21,11 +21,13 @@
[auto-ap.ssr-routes :as ssr-routes] [auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]] [auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
[auto-ap.ssr.components :as com] [auto-ap.ssr.components :as com]
[auto-ap.ssr.components.inputs :as inputs]
[auto-ap.ssr.grid-page-helper :as helper] [auto-ap.ssr.grid-page-helper :as helper]
[auto-ap.ssr.transaction.common :refer [grid-page]] [auto-ap.ssr.transaction.common :refer [grid-page]]
[auto-ap.ssr.components.multi-modal :as mm] [auto-ap.ssr.components.multi-modal :as mm]
[auto-ap.ssr.form-cursor :as fc] [auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.hx :as hx] [auto-ap.ssr.hx :as hx]
[auto-ap.ssr.selmer :as sel]
[auto-ap.ssr.svg :as svg] [auto-ap.ssr.svg :as svg]
[auto-ap.ssr.utils [auto-ap.ssr.utils
:refer [->db-id apply-middleware-to-all-handlers check-allowance :refer [->db-id apply-middleware-to-all-handlers check-allowance
@@ -36,6 +38,7 @@
[bidi.bidi :as bidi] [bidi.bidi :as bidi]
[clj-time.coerce :as coerce] [clj-time.coerce :as coerce]
[clojure.edn :as edn] [clojure.edn :as edn]
[clojure.string :as str]
[datomic.api :as dc] [datomic.api :as dc]
[hiccup.util :as hu] [hiccup.util :as hu]
[iol-ion.query :refer [dollars=]] [iol-ion.query :refer [dollars=]]
@@ -153,20 +156,29 @@
(:vendor/default-account clientized)))) (:vendor/default-account clientized))))
(defn location-select* (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]}] [{:keys [name account-location client-locations value]}]
(let [options (into (cond account-location (let [options (cond account-location
[[account-location account-location]] [[account-location account-location]]
(seq client-locations) (seq client-locations)
(into [["Shared" "Shared"]] (into [["Shared" "Shared"]]
(for [cl client-locations] (for [cl client-locations]
[cl cl])) [cl cl]))
:else
[["Shared" "Shared"]]))] :else
(com/select {:options options [["Shared" "Shared"]])
:name name selected (or value (ffirst options))
:value (or value (ffirst options)) classes (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
:class "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* (defn account-typeahead*
[{:keys [name value client-id x-model]}] [{: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) (:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
client-id)))})]) 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* (defn simple-mode-fields*
"Renders the simple-mode account + location row and the toggle-to-advanced link. "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. 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] [request]
(let [snapshot (-> request :multi-form-state :snapshot) (let [snapshot (-> request :multi-form-state :snapshot)
step-params (-> request :multi-form-state :step-params) step-params (-> request :multi-form-state :step-params)
@@ -204,53 +238,45 @@
(:transaction/amount snapshot) (:transaction/amount snapshot)
0.0))] 0.0))]
[:div [:div
(fc/with-field :transaction/accounts [:span
(fc/with-cursor (let [cur fc/*current*] (com/hidden {:name (account-field-name 0 :db/id)
(if (sequential? @cur) :value row-id})
(nth cur 0 nil) [:div.flex.gap-2.mt-2
(auto_ap.cursor.MapCursor. {} (cursor/state cur) (conj (cursor/path cur) 0)))) (com/validated-field
[:span {:label "Account"
(fc/with-field :db/id :errors (account-field-errors 0 :transaction-account/account)}
(com/hidden {:name (fc/field-name) [:div.w-72
:value row-id})) (account-typeahead* {:value account-val
[:div.flex.gap-2.mt-2 :client-id client-id
(fc/with-field :transaction-account/account :name (account-field-name 0 :transaction-account/account)
(com/validated-field :x-model "simpleAccountId"})])
{:label "Account" ;; Selecting the account only affects the valid Location options, so the
:errors (fc/field-errors)} ;; change swaps just this cell -- nothing else needs to re-render.
[:div.w-72 [:div {:id "simple-account-location"}
(account-typeahead* {:value account-val (com/validated-field
:client-id client-id {:label "Location"
:name (fc/field-name) :errors (account-field-errors 0 :transaction-account/location)
:x-model "simpleAccountId"})])) :x-hx-val:account-id "simpleAccountId"
(fc/with-field :transaction-account/location :hx-vals (hx/json (cond-> {:name (account-field-name 0 :transaction-account/location)}
;; Selecting the account only affects the valid Location options, so the client-id (assoc :client-id client-id)))
;; change swaps just this cell -- nothing else needs to re-render. :x-dispatch:changed "simpleAccountId"
[:div {:id "simple-account-location"} :hx-trigger "changed"
(com/validated-field :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
{:label "Location" :hx-target "#simple-account-location"
:errors (fc/field-errors) :hx-select "#simple-account-location"
:x-hx-val:account-id "simpleAccountId" :hx-swap "outerHTML"
:hx-vals (hx/json (cond-> {:name (fc/field-name)} :hx-include "closest form"}
client-id (assoc :client-id client-id))) (location-select*
:x-dispatch:changed "simpleAccountId" {:name (account-field-name 0 :transaction-account/location)
:hx-trigger "changed" :account-location (:account/location account-id)
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) :client-locations (pull-attr (dc/db conn) :client/locations client-id)
:hx-target "#simple-account-location" :value location-val}))]
:hx-select "#simple-account-location" (com/hidden {:name (account-field-name 0 :transaction-account/amount)
:hx-swap "outerHTML" :value total})]]
: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}))]]))
[:div.mt-1 [:div.mt-1
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer [: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-include "closest form"
:hx-target "#wizard-form" :hx-target "#wizard-form"
:hx-select "#wizard-form" :hx-select "#wizard-form"
@@ -331,8 +357,8 @@
(com/text-input (assoc amount-attrs :type "number" :step "0.01")) (com/text-input (assoc amount-attrs :type "number" :step "0.01"))
(com/money-input amount-attrs)))))) (com/money-input amount-attrs))))))
(com/data-grid-cell {:class "align-top"} (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) (com/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-vals (hx/json {:row-index (or index 0)}) :hx-vals (hx/json {:op "remove-account" :row-index (or index 0)})
:hx-target "#wizard-form" :hx-target "#wizard-form"
:hx-select "#wizard-form" :hx-select "#wizard-form"
:hx-swap "outerHTML" :hx-swap "outerHTML"
@@ -374,12 +400,6 @@
"text-red-300")} "text-red-300")}
(format "$%,.2f" balance)])) (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] (defn ->percentage [amount total]
(when (and amount total (not= total 0)) (when (and amount total (not= total 0))
(* 100.0 (/ amount total)))) (* 100.0 (/ amount total))))
@@ -421,7 +441,8 @@
:value amount-mode :value amount-mode
:name "step-params[amount-mode]" :name "step-params[amount-mode]"
:orientation :horizontal :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-target "#wizard-form"
:hx-select "#wizard-form" :hx-select "#wizard-form"
:hx-swap "outerHTML" :hx-swap "outerHTML"
@@ -461,7 +482,8 @@
(com/data-grid-row {:class "new-row"} (com/data-grid-row {:class "new-row"}
(com/data-grid-cell {:colspan 4} (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-target "#wizard-form"
:hx-select "#wizard-form" :hx-select "#wizard-form"
:hx-swap "outerHTML" :hx-swap "outerHTML"
@@ -483,7 +505,8 @@
[:div#manual-coding-section [:div#manual-coding-section
(com/hidden {:name "step-params[mode]" :value (name mode)}) (com/hidden {:name "step-params[mode]" :value (name mode)})
[:div {:hx-trigger "change" [: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-target "#wizard-form"
:hx-select "#wizard-form" :hx-select "#wizard-form"
:hx-swap "outerHTML" :hx-swap "outerHTML"
@@ -509,7 +532,8 @@
(when (<= row-count 1) (when (<= row-count 1)
[:div.mb-2 [:div.mb-2
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer [: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-include "closest form"
:hx-target "#wizard-form" :hx-target "#wizard-form"
:hx-select "#wizard-form" :hx-select "#wizard-form"
@@ -521,17 +545,22 @@
[:div#account-grid-body [:div#account-grid-body
(account-grid-body* request)]))])])) (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) (let [snapshot (-> request :multi-form-state :snapshot)
step-params (-> request :multi-form-state :step-params)
old-mode (or (:amount-mode snapshot) "$") 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)) total (Math/abs (or (:transaction/amount snapshot) 0.0))
accounts (convert-accounts-mode (:transaction/accounts snapshot) old-mode new-mode total) ;; Convert the LIVE rows (step-params), not the stale snapshot, so amounts the
updated-request (-> request ;; user typed before toggling survive. step-params is already schema-decoded.
(assoc-in [:multi-form-state :snapshot :transaction/accounts] accounts) accounts (convert-accounts-mode (or (seq (:transaction/accounts step-params))
(assoc-in [:multi-form-state :snapshot :amount-mode] new-mode))] (:transaction/accounts snapshot))
(html-response old-mode new-mode total)]
(render-full-form updated-request)))) (-> 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] (defn transaction-details-panel [tx]
[:div.p-4.space-y-4 [: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) [:div.mt-4 {:hx-post (bidi/path-for ssr-routes/only-routes ::route/unlink-payment)
:hx-trigger "unlinkPayment" :hx-trigger "unlinkPayment"
:hx-target "#payment-matches" :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-swap "outerHTML"
:hx-confirm "Are you sure you want to unlink this payment?"} :hx-confirm "Are you sure you want to unlink this payment?"}
@@ -1310,15 +1341,20 @@
(if current-step (if current-step
(mm/get-step this current-step) (mm/get-step this current-step)
(mm/get-step this :links))) (mm/get-step this :links)))
(render-wizard [this {:keys [multi-form-state] :as request}] (render-wizard [this {:keys [multi-form-state form-errors] :as request}]
(mm/default-render-wizard ;; Plain-form render: no snapshot / edit-path / current-step hidden fields. The entity
this request ;; id rides in the form; all other state is the live form (step-params) re-derived
:form-params ;; against the entity on each request (see wrap-derive-state).
(-> mm/default-form-props (let [step (mm/get-current-step this)]
(assoc :hx-post [:form#wizard-form (-> mm/default-form-props
(str (bidi/path-for ssr-routes/only-routes ::route/edit-submit)) (assoc :hx-post (str (bidi/path-for ssr-routes/only-routes ::route/edit-submit))
:hx-ext "response-targets")) :hx-ext "response-targets"))
:render-timeline? false)) (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 [_] (steps [_]
[:links]) [:links])
(get-step [this step-key] (get-step [this step-key]
@@ -1333,46 +1369,60 @@
(def edit-wizard (->EditWizard nil nil)) (def edit-wizard (->EditWizard nil nil))
(defn initial-edit-wizard-state [request] (defn entity->base
(let [tx-id (-> request :route-params :db/id) "The persisted transaction, shaped like the form's base state (what the old snapshot was
entity (dc/pull (dc/db conn) seeded with). The plain form derives its state fresh from this + the live posted form,
'[:db/id instead of round-tripping an EDN snapshot hidden field."
:transaction/vendor [tx-id]
:transaction/client (-> (dc/pull (dc/db conn)
:transaction/description-original '[:db/id
:transaction/status :transaction/vendor
:transaction/type :transaction/client
:transaction/memo :transaction/description-original
{[:transaction/approval-status :xform iol-ion.query/ident] [:db/ident]} :transaction/status
:transaction/amount :transaction/type
:transaction/accounts] :transaction/memo
tx-id) {[:transaction/approval-status :xform iol-ion.query/ident] [:db/ident]}
entity (-> entity :transaction/amount
(update :transaction/vendor :db/id) :transaction/accounts]
(update :transaction/client :db/id))] tx-id)
(mm/->MultiStepFormState entity (update :transaction/vendor :db/id)
[] (update :transaction/client :db/id)))
entity)))
(defn- render-account-grid-body [request] (defn wrap-derive-state
(fc/start-form (:multi-form-state request) nil "Plain-form replacement for the EDN-snapshot round-trip. Builds :multi-form-state from
(fc/with-field :step-params the entity (loaded by the db/id hidden field, or the route on initial open) overlaid
(fc/with-field :transaction/accounts with the live posted step-params -- no serialized snapshot. Runs after wrap-decode /
(account-grid-body* request))))) 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 (defn render-full-form
"Helper to render the complete transaction edit form for whole-form re-rendering." "Helper to render the complete transaction edit form for whole-form re-rendering."
[request] [request]
(mm/render-wizard edit-wizard request)) (mm/render-wizard edit-wizard request))
(defn edit-form-changed-handler (defn apply-vendor-changed [request]
"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]
(let [multi-form-state (:multi-form-state request) (let [multi-form-state (:multi-form-state request)
snapshot (:snapshot multi-form-state) snapshot (:snapshot multi-form-state)
step-params (:step-params 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]))) (assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
request) request)
(assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))] (assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))]
(html-response render-request))
(render-full-form render-request))))
(defn edit-wizard-toggle-mode-handler [request] (defn apply-toggle-mode [request]
(let [step-params (-> request :multi-form-state :step-params) (let [step-params (-> request :multi-form-state :step-params)
snapshot (-> request :multi-form-state :snapshot) snapshot (-> request :multi-form-state :snapshot)
current-mode (keyword (or (:mode step-params) "simple")) current-mode (keyword (or (:mode step-params) "simple"))
@@ -1446,34 +1495,39 @@
(if first-row [first-row] [])) (if first-row [first-row] []))
(assoc-in [:multi-form-state :step-params :mode] (assoc-in [:multi-form-state :step-params :mode]
(name target-mode)))))] (name target-mode)))))]
(html-response render-request))
(render-full-form render-request))))
(defn edit-wizard-new-account-handler (defn apply-new-account
"Adds a new account row and re-renders the whole form." "edit-form-changed op: append a fresh account row."
[request] [request]
(let [snapshot (-> request :multi-form-state :snapshot) (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)) total (Math/abs (or (:transaction/amount snapshot) 0.0))
new-account {:db/id (str (java.util.UUID/randomUUID)) new-account {:db/id (str (java.util.UUID/randomUUID))
:new? true :new? true
:transaction-account/location "Shared" :transaction-account/location "Shared"
:transaction-account/amount (if (= amount-mode "%") 100.0 total)} :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-accounts (conj accounts new-account)
updated-request (-> request updated-request (-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts) (assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts)
(assoc-in [:multi-form-state :step-params :transaction/accounts] updated-accounts))] (assoc-in [:multi-form-state :step-params :transaction/accounts] updated-accounts))]
(html-response updated-request))
(render-full-form updated-request))))
(defn edit-wizard-remove-account-handler (defn apply-remove-account
"Removes an account row and re-renders the whole form. "edit-form-changed op: remove the account row at form-param row-index."
Expects a row-index in the form params."
[request] [request]
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt) (let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
snapshot (-> request :multi-form-state :snapshot) 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))) updated-accounts (if (and row-index (< row-index (count accounts)))
(vec (concat (subvec accounts 0 row-index) (vec (concat (subvec accounts 0 row-index)
(subvec accounts (inc row-index)))) (subvec accounts (inc row-index))))
@@ -1481,31 +1535,43 @@
updated-request (-> request updated-request (-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts) (assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts)
(assoc-in [:multi-form-state :step-params :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 (html-response
(render-full-form updated-request)))) (render-full-form request'))))
(def key->handler (def key->handler
(apply-middleware-to-all-handlers (apply-middleware-to-all-handlers
{::route/edit-wizard (-> mm/open-wizard-handler {::route/edit-wizard (-> mm/open-wizard-handler
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client))) (wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
(wrap-derive-state)
(mm/wrap-wizard edit-wizard) (mm/wrap-wizard edit-wizard)
(mm/wrap-init-multi-form-state initial-edit-wizard-state) (mm/wrap-decode-multi-form-state)
(wrap-entity [:route-params :db/id] d-transactions/default-read)
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]])) (wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
::route/edit-wizard-navigate (-> mm/next-handler ::route/edit-wizard-navigate (-> mm/next-handler
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client))) (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-wizard edit-wizard)
(mm/wrap-decode-multi-form-state)) (mm/wrap-decode-multi-form-state))
::route/edit-submit (-> mm/submit-handler ::route/edit-submit (-> mm/submit-handler
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client))) (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-wizard edit-wizard)
(mm/wrap-decode-multi-form-state)) (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 ::route/location-select (-> location-select
(wrap-schema-enforce :query-schema [:map (wrap-schema-enforce :query-schema [:map
[:name :string] [:name :string]
@@ -1513,42 +1579,15 @@
[:maybe entity-id]] [:maybe entity-id]]
[:account-id {:optional true} [:account-id {:optional true}
[:maybe entity-id]]])) [: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 ::route/edit-form-changed (-> edit-form-changed-handler
(wrap-derive-state)
(mm/wrap-wizard edit-wizard) (mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-decode-multi-form-state)) (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 ::route/unlink-payment (-> unlink-payment
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client))) (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-wizard edit-wizard)
(mm/wrap-decode-multi-form-state) (mm/wrap-decode-multi-form-state))}
#_(wrap-schema-enforce :form-schema
save-schema))}
(fn [h] (fn [h]
(-> h (-> h
(wrap-client-redirect-unauthenticated))))) (wrap-client-redirect-unauthenticated)))))

View File

@@ -28,15 +28,8 @@
["/" [#"\d+" :db/id]] {"/edit" {:get ::edit-wizard}} ["/" [#"\d+" :db/id]] {"/edit" {:get ::edit-wizard}}
"/edit-submit" ::edit-submit "/edit-submit" ::edit-submit
"/edit-vendor-changed" ::edit-vendor-changed
"/location-select" ::location-select "/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-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-payment" ::link-payment
"/match-autopay-invoices" ::link-autopay-invoices "/match-autopay-invoices" ::link-autopay-invoices
"/match-unpaid-invoices" ::link-unpaid-invoices "/match-unpaid-invoices" ::link-unpaid-invoices