24 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
32056bf396 refactor(ssr): delete dead transaction-account-row-no-cursor* twin (heuristic 1)
transaction-account-row-no-cursor* and its only helper account-field-name were
unreferenced anywhere in src/ or test/ -- the *-no-cursor* duplicate the plan targets
for removal. The live row renderer is the top-rooted cursor form transaction-account-row*
(driven by fc/cursor-map from the accounts cursor). Deleting the twin: no-cursor twins
1 -> 0, ~53 LOC removed. Swap spec stays 6/6.
2026-06-03 06:15:13 -07:00
69eed1f8a6 fix(ssr): strip UI-only :mode before transaction upsert (500 on advanced manual save)
The :manual save handler builds its tx-data from the wizard snapshot and stripped the
control fields :action and :amount-mode, but not :mode (simple/advanced) added by the
recent manual-coding work. manual-coding-section* emits step-params[mode] on every
render, so EVERY advanced manual save posted :mode "advanced" into :upsert-transaction
and 500'd with ":db.error/not-an-entity :mode". Strip :mode alongside :action so the
upsert only sees real schema attributes.

Also fix the e2e helper that masked this: selectAccountFromTypeahead poked the Alpine v2
internal `el.__x.$data`, which is undefined on Alpine v3 (this app loads alpinejs@3.x),
so it silently no-op'd and the account posted empty. Drive the typeahead via the real
Alpine v3 path (Alpine.$data + tippy dropdown + click), mirroring transaction-edit-swap.

Unmasks the previously-failing "Shared Location spread on save" test (was first in a
serial file, hiding 7 siblings). Verified: that test passes; transaction-edit-swap stays
6/6. Skill gotchas.md records the :mode-strip rule, the Alpine-v3 API requirement, and
the modal-won't-close diagnosis recipe.
2026-06-03 06:05:42 -07:00
ed3344438b test(e2e): make Playwright BASE_URL-overridable + record Phase 2 e2e baseline
- playwright.config.ts: honor BASE_URL env (and skip the auto-started webServer when
  set) so a server booted from a specific worktree on a non-default port can be tested
  without fighting over :3333.
- skill test-recipes.md: record the recipe for running e2e from a non-default worktree
  (in-process test server + reseed helper) and the measured baseline on the merged
  hx-select reference: swap-doctrine 6/6 green; transaction-edit.spec.ts has a
  pre-existing Shared-Location save failure that masks 7 via serial mode; full suite
  30 pass / 2 fail / 7 skip. Gate for the refactor = swap spec + REPL pure-fn checks.
2026-06-03 00:18:31 -07:00
bdb286ca71 feat(ssr): add Selmer dependency + Hiccup<->Selmer interop bridge (Phase 2 foundation)
The strangler foundation for migrating interactive SSR components from Hiccup to
Selmer (plain-HTML Alpine/HTMX attributes instead of mixed keyword/string encodings).

- project.clj: add [selmer "1.12.61"].
- auto-ap.ssr.selmer: render / render-str (selmer/render-file + string), hiccup->html
  (Hiccup -> string for {{ frag|safe }}), raw (wrap a rendered fragment for embedding
  in a Hiccup tree without double-escaping), render->hiccup.
- resources/templates/interop-smoke.html: proves render-file from the classpath and
  that plain-HTML alpine attrs (x-model, @keydown, tippy?.show()) pass through verbatim.
- selmer_test: 4 tests / 8 assertions covering both interop directions; all green.

Proven via REPL + tests: a Hiccup component renders inside a Selmer template, and a
Selmer fragment renders inside a Hiccup tree. Both valid during the transition.
2026-06-03 00:09:12 -07:00
3ecd115f76 docs(skill): distil ssr-form-migration skill from transaction-edit reference (Phase 1)
Capture the proven whole-form hx-select swap method as a reusable skill so every
later modal migration is cheaper and consistent. No app code changes.

- SKILL.md: the per-migration playbook (classify → baseline → characterize →
  consolidate render fns → templatize → wire HTMX → collapse routes → verify →
  commit → feed skill) + Growth contract + non-negotiables.
- reference/swap-doctrine.md: the four swap rules, focus invariant, Alpine-survives-
  swap hardening, target-selector strategy — worked from the real edit.clj swaps
  (memo no-request, account→location targeted cell, amount→totals sibling-tbody,
  vendor/mode/row whole-form). 0 OOB.
- reference/render-functions.md: explicit-data or top-rooted cursor; the MapCursor
  fake + transaction-account-row-no-cursor* twin as the smell to remove.
- reference/form-vs-wizard.md: classification + the data-driven session-backed
  (formtools SessionStorage) engine that replaces the snapshot round-trip + protocol.
- reference/selmer-conventions.md: STUB, validated in Phase 2.
- component-cookbook.md / gotchas.md / test-recipes.md / scorecard.md: seeded from
  what transaction-edit proves (7 cookbook entries, caret-survival + typeahead test
  recipes, scorecard baseline LOC 1608 / ~12 routes / 1 no-cursor twin / 2 faked
  roots / 0 OOB).

Scorecard (Transaction Edit baseline, before Phase 2): LOC 1608, routes ~12,
no-cursor twins 1, faked-cursor roots 2, snapshot merges ~75, OOB 0, mixed hx- 8.
2026-06-03 00:05:11 -07:00
246df6996e Merge branch 'integreat-render-hx-select' into integreat-execute-refactor 2026-06-02 23:58:36 -07:00
85aaf7b759 mcp repl fixes 2026-06-02 23:40:05 -07:00
3641846f70 Merge pull request 'docs: SSR rendering modernization rollout plan' (#12) from docs/ssr-rendering-modernization-plan into staging
Reviewed-on: #12
2026-06-02 23:26:45 -07:00
8215e6376d Merge pull request 'fix(ssr): require Apply for all date-range filters' (#13) from integreat-fix-other-dates into staging
Reviewed-on: #13
2026-06-02 22:42:49 -07:00
3759258ebe fix(ssr): require Apply for all date-range filters
Most grid pages auto-submitted their date-range filter on every change
event, which fired mid-typing and re-rendered the date inputs, breaking
manual date entry. Invoices and ledgers already gated date submission
behind an explicit Apply button; this brings the other ten pages in line.

- date-range component: stop `change` from the date inputs bubbling to
  the form (@change.stop) and always render the Apply button, so typed or
  picked dates submit only via the Apply button's `datesApplied` event.
  The All/Week/Month/Year presets and all other filters are unaffected.
- payments, invoice import, transactions, import batches, sales
  summaries, expected deposits, cash drawer shifts, refunds, tenders,
  sales orders: add `datesApplied` to the form hx-trigger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:42:17 -07:00
482b4802ff Make swaps precise: drop no-op requests, swap only the affected field
Refine per-trigger granularity now that the swap target is explicit:

- Memo issues no request at all -- it affects nothing else, so its value just
  rides along in the form and is merged into the snapshot on save. (Changing the
  Location *value* likewise issues no request -- it never did; that cell's request
  is the account->location dependency.)
- Account select swaps only that row's Location cell (#account-location-<index> /
  #simple-account-location) instead of the whole form. Selecting an account only
  affects the valid Location options (computed from the posted account-id), so a
  precise cell swap is safe -- no snapshot dependency.

Account-structural changes (vendor, add/remove row, mode toggle, $/% radio) keep
swapping the whole form: their accounts+amount-mode state is interdependent and
round-trips through the single form-level snapshot hidden field, so a whole-form
swap is what keeps it consistent with zero OOB.

Update the memo test to assert it fires no request and keeps its value/caret.
Full e2e suite: 27 passed / 2 failed (same pre-existing, unrelated failures).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:12:22 -07:00
5f1bb6db82 Whole-form hx-select swaps with zero out-of-band swaps
Replace the section-swap + OOB approach with uniform whole-form swaps,
eliminating both out-of-band swaps:

- Discrete edits (vendor, account, location, mode, add/remove row) now swap all
  of #wizard-form via hx-select. The active action/tab already round-trips
  (:action is in edit-form-schema and the tab x-data inits from it), so a
  whole-form swap re-creates the tab state from the server value and the active
  tab is preserved -- no #wizard-snapshot OOB needed, since the snapshot hidden
  field rides along inside the form.
- Move the totals into their own <tbody id="account-totals"> (new optional
  :footer-tbody param on data-grid-) so the amount field updates them with a
  plain targeted swap instead of an OOB swap of #total,#balance. The totals tbody
  is a sibling of the input rows, so the amount input is never replaced.
- Memo unchanged (hx-swap=none).

Net: 0 hx-select-oob, 0 morph. The focus invariant is unchanged -- the typed
field is never inside a region it swaps. Tab clicks stay Alpine (instant); only
the action value round-trips. Revert the now-unneeded #wizard-snapshot id.

Full e2e suite: 27 passed / 2 failed (same pre-existing, unrelated failures).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:01:37 -07:00
a2684bf5c1 Replace alpine-morph with targeted hx-select / OOB swaps
Drop the whole-form alpine-morph swap in favour of posting the whole form
but swapping back only what changed, never the input the user is editing --
so focus and caret survive a plain swap with no morph extension.

- Discrete changes (vendor, account, location, mode, add/remove row) swap the
  #manual-coding-section fragment via hx-select, plus an OOB refresh of the
  #wizard-snapshot hidden field so the round-tripped wizard state stays in sync
  (the snapshot lives at #wizard-form level, outside the swapped fragment, and
  the new/remove-account handlers read it).
- The amount field OOB-swaps only #total/#balance (hx-swap=none); memo posts
  with hx-swap=none. Neither input is ever replaced.
- Give the BALANCE cell a unique id (#balance) so the OOB selector is unambiguous.
- Remove the alpine-morph ext + @alpinejs/morph plugin and all the key/x-data
  re-init tricks they required. Rebuilding the fragment fresh makes vendor->account
  population and repeat vendor changes work without any keying.
- Rename e2e/transaction-edit-morph.spec.ts -> -swap.spec.ts; assertions unchanged
  (focus/caret preservation, vendor->account, repeat vendor changes all hold).

Full e2e suite: 27 passed / 2 failed (both pre-existing and unrelated -- the
legacy save-flow test and the date-range filter test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 08:10:05 -07:00
cdb6bb6fe3 Whole-form htmx + Alpine morph for transaction edit
Re-render the entire #wizard-form on each field edit and swap with
hx-swap="morph" so the focused input keeps focus/caret/value while typing.

- Field-level routes return the full form and target #wizard-form
- Key state-owning wrappers (account rows, simple-mode wrapper, vendor
  typeahead) so server-driven value changes re-init across the morph
- Guard tippy/$refs access in typeahead against stale post-morph state
- Round-trip simple/advanced mode via step-params[mode]
- Add e2e/transaction-edit-morph.spec.ts covering focus/caret preservation,
  vendor->account population, and repeated vendor changes
- Seed a second vendor/account for test isolation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 07:40:30 -07:00
37 changed files with 2165 additions and 563 deletions

View File

@@ -0,0 +1,122 @@
---
name: ssr-form-migration
description: Migrate an SSR form or wizard modal to the whole-form HTMX swap doctrine, top-rooted render functions, the data-driven session-backed wizard engine, and (where it helps) Selmer templates. Use when simplifying a server-rendered form/wizard modal in this codebase, removing EDN-snapshot round-trips, deleting *-no-cursor* duplicate render fns, collapsing per-interaction routes, or replacing the multi-step wizard protocol machinery.
---
# SSR Form & Wizard Migration
A repeatable method for making a server-rendered form/wizard modal **simpler** without
changing user-facing behavior. Distilled from the first proven migration — the
`transaction/edit.clj` modal, which already runs on the whole-form `hx-select` swap
approach with **zero out-of-band swaps**. Every migration *reads this skill first* and
*extends it last* (the Growth contract below). If migration N+1 is not easier than N,
the skill-update step was skipped — treat that as a bug.
The four patterns every migration moves code toward live in `reference/`:
- `reference/swap-doctrine.md` — the four-rule HTMX swap priority order + the focus
invariant + Alpine-survives-swap hardening + target-selector strategy.
- `reference/render-functions.md` — one render fn per component, taking explicit data
**or a top-rooted cursor**; no faked cursor positions, no `*-no-cursor*` twins.
- `reference/form-vs-wizard.md` — single-step → plain form; multi-step → data-driven
engine with **per-step state in the Ring session** (the Django `formtools` model).
- `reference/selmer-conventions.md` — plain-HTML attributes via Selmer, the
Hiccup↔Selmer interop bridge, include/block patterns.
Growing cookbooks (append every migration):
`component-cookbook.md`, `gotchas.md`, `test-recipes.md`, `scorecard.md`.
---
## The per-migration playbook
Run this loop for each modal. The phase notes in the migration plan list only what is
*specific* to a modal; this loop is the constant.
1. **Read the skill.** Skim `reference/` and note which `component-cookbook.md`
entries and `gotchas.md` you can reuse. Start from the cookbook, not a blank file.
2. **Classify** (`reference/form-vs-wizard.md`).
- Single logical step (even with a `?mode=` toggle or add/remove rows) → **plain
form**: no server-side wizard state, no snapshot, no protocol.
- Genuinely multiple steps the user advances through → **wizard**: the data-driven
engine + per-step session storage.
- When in doubt, it's a form.
3. **Baseline the scorecard** (`scorecard.md`, heuristics in §6 of the plan). Record
before-numbers with cheap tools:
```bash
F=src/clj/auto_ap/ssr/<modal>.clj
wc -l $F # LOC (heuristic 4)
grep -c 'defn.*-no-cursor' $F # *-no-cursor* twins (heuristic 1)
grep -cE 'with-cursor|MapCursor\.' $F # faked cursor re-roots (heuristic 1)
grep -c 'hx-swap-oob' $F # OOB swaps (heuristic 7)
grep -cE '"hx-[a-z]' $F # mixed string hx- attrs (heuristic 8)
# route count: count this modal's entries in src/cljc/auto_ap/routes/*.cljc
```
4. **Characterize behavior (test-first).** Write or confirm a Playwright spec that
captures *current* behavior before you touch anything — focus/caret survival across
swaps, each field round-trip, validation errors, and the real save. This spec is the
parity contract; it must stay green through every commit. See `test-recipes.md`.
5. **Consolidate render functions** (`reference/render-functions.md`). Make each render
fn take explicit data or a **top-rooted cursor**. Delete `*-no-cursor*` duplicates
and any `with-cursor`/`MapCursor` rebind that fakes a deep starting position
(heuristics 1, 2). Using a cursor is fine; faking where it *starts* is not.
6. **Templatize in Selmer** (`reference/selmer-conventions.md`) where the component is
interactive/attribute-heavy. Reuse cookbook bits; add new ones back (heuristics 5, 8).
Static markup may stay Hiccup — Selmer scope is hybrid (Open decision 2).
7. **Wire HTMX per the swap doctrine** (`reference/swap-doctrine.md`). Keep the focus
invariant intact. No OOB except a genuinely disjoint region, documented (heuristic 7).
8. **Collapse routes** to 2 (`GET` open, `POST` submit) — `+1` for an add-row endpoint,
`+1` for the single `*-form-changed` whole-form re-render endpoint (heuristic 6).
9. **Verify.** Modal e2e green + full e2e suite at-or-above baseline; assert DB
mutations by querying Datomic, not markup; REPL-check the pure render/data fns.
Re-measure the scorecard — **no metric may regress for the touched modal** without a
written exception in `gotchas.md`.
10. **Commit** one reversible feature commit. The message includes the scorecard delta
and the reused/new cookbook entries.
11. **Feed the skill** (the Growth contract). *Not optional.*
---
## Growth contract — the last task of every migration
- Converted a component? → add its before/after to `component-cookbook.md`.
- Hit a surprise? → one entry in `gotchas.md`.
- Found a test pattern? → `test-recipes.md`.
- Playbook step missing or wrong? → fix this `SKILL.md`.
- Measured the scorecard? → append the row to `scorecard.md`.
**Success signal:** each migration reuses more cookbook entries and starts from a better
scorecard baseline than the previous one.
---
## Non-negotiables
- **Focus invariant:** the input the user is typing in is *never* inside the region its
own request swaps. Violating this drops the caret. (Proven by the
`transaction-edit-swap.spec.ts` caret tests.)
- **No new OOB swaps.** If tempted to OOB something inside the same feature, restructure
the DOM so the dependent element shares an ancestor with the trigger and use an
ordinary swap (e.g. totals in a sibling `<tbody>`).
- **Behavior parity is proven by tests, not by reading.** The full e2e suite stays green
after every migration.
- **Don't game the heuristics.** They're directional evidence paired with the e2e parity
gate; review the trend, not single numbers.
## Project conventions that bite (see `gotchas.md`)
- Edit Clojure with the clojure-mcp tools (`clojure_edit`, `clojure_edit_replace_sexp`),
not the raw file editor. `clj-paren-repair` then `lein cljfmt fix` when a file won't
compile.
- Run tests via the `clojure-eval` skill / `clj-nrepl-eval -p PORT`, not `lein test`.
- Temp files go in `./tmp/`.

View File

@@ -0,0 +1,151 @@
# Component cookbook
GROWS every migration. Each entry: what it is, the swap rule it uses, and the canonical
snippet. Reuse these before writing anything new; the success signal is *more reuse each
migration*.
Seeded from `transaction/edit.clj` (Hiccup form — Selmer versions land in Phase 2).
---
## typeahead (account / vendor) — Alpine + tippy, survives swaps
Used for account and vendor selection. Click-to-select (not a live text caret), so a
whole-form swap on change is safe. Null-guard `tippy?`/`$refs.input?`.
```clojure
(defn account-typeahead* [{:keys [name value client-id x-model]}]
[:div.flex.flex-col
(com/typeahead {:name name
:placeholder "Search..."
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
(cond-> {:purpose "transaction"} client-id (assoc :client-id client-id)))
:id name
:x-model x-model ; binds selected value into the row's Alpine scope
:value value
:content-fn (fn [v] (:account/name (d-accounts/clientize ... v client-id)))})])
```
Reuse note: `:x-model` lets the *parent* row read the selected id (e.g. `accountId`) to
gate a targeted location swap. See account-row.
## account-row — cursor render fn + per-row targeted location swap + whole-form remove
The canonical "row in a repeated grid" pattern. One render fn, top-rooted cursor.
- account typeahead binds `accountId` into row Alpine scope;
- **location cell** swaps *only itself* (`#account-location-<index>`) on `changed`
(swap-doctrine Rule 2);
- **amount cell** swaps *only* `#account-totals` (Rule 4, sibling tbody);
- **remove** swaps the whole form (Rule 3).
```clojure
(defn transaction-account-row* [{:keys [value client-id amount-mode index]}]
(com/data-grid-row
(-> {:class "account-row" :id (str "account-row-" index)
:x-data (hx/json {:show ... :accountId (fc/field-value (:transaction-account/account value))})
:data-key "show" :x-ref "p"}
hx/alpine-mount-then-appear)
(fc/with-field :db/id (com/hidden {:name (fc/field-name) :value (fc/field-value)}))
(fc/with-field :transaction-account/account
(com/data-grid-cell {} (com/validated-field {:errors (fc/field-errors)}
(account-typeahead* {:value (fc/field-value) :client-id client-id
:name (fc/field-name) :x-model "accountId"}))))
(fc/with-field :transaction-account/location
(com/data-grid-cell {:id (str "account-location-" index)} ...Rule 2 targeted swap...))
(fc/with-field :transaction-account/amount
(com/data-grid-cell {} ...Rule 4 totals swap...))
(com/data-grid-cell {:class "align-top"} ...Rule 3 whole-form remove...)))
```
TODO Phase 2: drop the `transaction-account-row-no-cursor*` twin; this is the only kept form.
## totals in a sibling `<tbody>` — Rule 4 instead of OOB
Running totals live in their own `<tbody id="account-totals">`, a sibling of the
input-bearing rows, so an amount edit refreshes them with a plain targeted swap and never
replaces the amount input (caret survives).
```clojure
(com/data-grid
{:footer-tbody
[:tbody {:id "account-totals"}
(com/data-grid-row {:class "account-total-row"} ... (account-total* request) ...)
(com/data-grid-row {:class "account-balance-row"} ... (account-balance* request) ...)]}
...input rows...)
```
## money-input / text-input amount field — Rule 4 targeted totals swap
```clojure
(com/money-input
{:name (fc/field-name) :id (str "account-amount-" index) :class "w-16 account-amount-field"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target "#account-totals" :hx-select "#account-totals" :hx-swap "outerHTML"
:hx-trigger "keyup changed delay:300ms" :hx-include "closest form"})
```
`%` mode swaps to `com/text-input {:type "number" :step "0.01"}` with the same swap attrs.
## memo field — Rule 1, no request
```clojure
(com/text-input {:value (fc/field-value) :name (fc/field-name) :id "edit-memo"
: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
(com/radio-card {:options [{:value "$" :content "$"} {:value "%" :content "%"}]
:value amount-mode :name "step-params[amount-mode]"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode)
:hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"
:hx-include "closest form"})
```
TODO Phase 2: the simple/advanced toggle becomes a `?mode=` re-render (plain form), not a
dedicated route.

View File

@@ -0,0 +1,115 @@
# Forms vs. wizards (and the data-driven wizard engine)
## Classify first
| Signal | Classification |
|--------|----------------|
| One logical step — even with a `?mode=` toggle, $/% radio, or add/remove rows | **plain form** |
| The user genuinely advances through ordered steps, each validated before the next | **wizard** |
| In doubt | **form** |
Most "wizards" in this codebase are single-step forms wearing wizard costumes: they
implement the multi-step protocol (`mm/ModalWizardStep` + friends), serialize an EDN
snapshot into hidden fields, and register 1020 stacked-middleware routes — all for one
step. That is pure overhead to delete.
## The machinery being replaced
`transaction/edit.clj` today still carries the old shape, useful as the "before":
```clojure
(defrecord LinksStep [linear-wizard]
mm/ModalWizardStep
(step-name [_] "Transaction Actions")
(step-key [_] :links)
(edit-path [_ _] [])
(step-schema [_] (mm/form-schema linear-wizard))
(render-step [this {{:keys [snapshot step-params]} :multi-form-state :as request}] ...))
```
…plus the snapshot round-trip: the whole accumulating form state is serialized to hidden
fields (custom EDN readers), then rebuilt every request by merging the posted pieces back
into the snapshot (`:multi-form-state :snapshot` is read ~75× in `edit.clj`). The
serialization needs custom readers, the merge is error-prone, and the payload grows each
step.
---
## Single-step → plain form
Two routes: `GET` (render) and `POST` (validate + save). State is plain form fields + an
entity id. No snapshot, no server state, no protocol.
```clojure
{::route/edit (fn [req] (html-response (render-edit-form {:entity (get-entity req)})))
::route/edit-submit (fn [req] (validate-and-save req))}
```
A `?mode=` toggle is just the `GET` re-rendering with a different query param — still a
plain form. An add-row interaction is one extra `POST` that appends a fresh row and
re-renders (the `+1` route).
---
## Genuinely multi-step → data-driven engine with session-stored step state
> **Inspiration — Django `formtools` `WizardView`.** Django's wizard does *not* round-trip
> a serialized blob of the whole form through the page. Each step's validated data is
> written to a **storage backend (the user session by default)** under that step's key,
> and the steps are combined only at the very end via `get_all_cleaned_data()`. We adopt
> the same model: **replace the EDN snapshot + piecewise merging with per-step form state
> stored in the Ring session.** A step writes its own data under its own key; nothing is
> merged into a snapshot and nothing about other steps rides through the form.
> Refs: `formtools.wizard.views.WizardView`, `SessionStorage`, `get_all_cleaned_data()`
> (https://django-formtools.readthedocs.io/en/latest/wizard.html).
A wizard is **data**:
```clojure
(def vendor-wizard-config
{:steps [{:key :info :schema info-schema :fields [...] :render render-info-step
:next (fn [data] :terms)}
{:key :terms :schema terms-schema :fields [...] :render render-terms-step
:next (fn [data] :done)}]
:init-fn (fn [req] {...})
:submit-route "/admin/vendor/wizard/submit"
:done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))})
```
with a tiny engine (no protocols) whose state lives **in the session**, keyed by a wizard
instance id, each step's data under its own step key — the formtools `SessionStorage`
model. No snapshot, no custom EDN readers, no merge-into-snapshot:
```clojure
;; Storage backed by the Ring session. Path: [:wizards <wizard-id> :step-data <step-key>]
(defn create-wizard! [session config]
(let [id (str (java.util.UUID/randomUUID))]
[id (assoc-in session [:wizards id]
{:current-step (-> config :steps first :key) :step-data {}})]))
(defn put-step [session id k data] (assoc-in session [:wizards id :step-data k] data)) ; replace, not merge
(defn set-step [session id k] (assoc-in session [:wizards id :current-step] k))
(defn get-all [session id] (->> (get-in session [:wizards id :step-data]) vals (apply merge)))
(defn forget [session id] (update session :wizards dissoc id))
```
The render emits only a **reference token** (`wizard-id`, `current-step`) in the form —
never the form's state. The submit handler validates the posted step, `put-step`s it,
computes `:next`, and either advances (`set-step`) or finishes (`get-all` + `:done-fn` +
`forget`). Every fn returns the updated session for the handler to thread into the Ring
response (`(assoc resp :session session')`).
**Two routes per wizard:** open (`partial open-wizard config`) and submit
(`partial handle-step-submit config`). State is namespaced by `wizard-id` inside the
session, so multiple in-flight wizards (and browser tabs) don't collide, and it is
discarded on completion (`forget`).
### Storage lifetime (Open decision 1)
State lives in the Ring session, scoped to true multi-step wizards (plain forms hold
none). Lifetime follows the session; `forget` on completion prevents session bloat. For
long-lived wizards, confirm the session backend (in-memory vs. durable) is acceptable or
pick a durable store. **This engine is built in Phase 6** (Transaction Rule) — until then
this file describes the target; validate `components/wizard_state.clj` +
`components/wizard2.clj` against it when they land, and update this doc from the real
implementation.

View File

@@ -0,0 +1,153 @@
# Gotchas
GROWS every migration. One entry per surprise. Also the home for any **written exception**
to the scorecard ratchet (a metric that regressed for a documented reason).
---
## Stale `$refs` / `tippy` after a swap
A whole-form swap can run an Alpine event handler *before* the component re-initialises,
so a handler that dereferences `$refs.input.__x_tippy` or calls `tippy.show()` throws.
**Always null-guard:** `$refs.input?.__x_tippy?.hide()`, `tippy?.show()`. The
`transaction-edit-swap.spec.ts` `trackErrors()` helper fails the test on any `pageerror`
or `console.error`, which is exactly how a stale-ref throw surfaces.
## Let the server value win — don't preserve Alpine state across a server-driven change
When a server change should update a component (e.g. choosing a vendor sets its default
account), rebuild that section fresh on the swap so the server-provided value lands
without keying tricks. The bug this prevents: "changing the vendor a *second* time doesn't
update the account" because preserved Alpine state shadowed the new server value. If you
*must* preserve a component, key it by value so a change forces re-init:
`(assoc attrs :key (str id "--" current-value))`.
## Focus dies if the typed input is inside its own swapped region
The single most important invariant. Amount field → swap a sibling tbody, not the row.
Memo → swap nothing. If a caret test (`sameNode`) fails, the input is in its own swap
region — re-target to a sibling/ancestor that excludes it.
## Faked cursors breed duplicate render fns
A `with-cursor`/`MapCursor` re-root to fake a deep start forces a `*-no-cursor*` twin.
Removing the fake lets you delete the twin. Don't "fix" a faked cursor in place — top-root
it and collapse to one render fn. (See `render-functions.md`.)
## Edit Clojure with clojure-mcp tools, not the file editor
`clojure_edit` / `clojure_edit_replace_sexp`. If a file won't compile: `clj-paren-repair`
the file, then retry; if still broken, `lein cljfmt check`. Run tests via `clojure-eval` /
`clj-nrepl-eval -p PORT`, never `lein test` (slow, last resort).
## Solr/typeahead in tests
Account/vendor search is backed by Solr, unavailable in tests. To drive a typeahead in
e2e: type under the 3-char threshold, then inject a result into Alpine state
(`Alpine.$data(el).elements = [{value, label}]`) and click it — the real click handler,
`tippy.hide()`, Alpine reactivity, and the HTMX swap all run as in production. Entity ids
come from `GET /test-info`.
---
## UI-only control fields must be stripped before a Datomic upsert
The wizard snapshot/step-params carry UI control fields that are **not** schema
attributes — `:action`, `:amount-mode`, and (added by the simple/advanced work) `:mode`.
The `:manual` save handler stripped `:action`/`:amount-mode` but not `:mode`, so every
*advanced* manual save passed `:mode "advanced"` into `:upsert-transaction` and 500'd with
`:db.error/not-an-entity :mode`. Lesson: when a save derives its tx-data from the form
snapshot, **strip every non-schema control key** before transacting. The session-backed
wizard engine (Phase 6) avoids this class of bug by storing per-step *validated* data
only — UI control fields never enter the combined data. This was a real production bug
surfaced by the e2e gate, not a test artifact.
## E2E helpers must use the Alpine **v3** API, not the v2 `__x` internal
The app loads Alpine v3 (`cdn.jsdelivr.net/npm/alpinejs@3.x.x`). The v2 internal
`el.__x.$data` is **gone**`el.__x` is `undefined`, so any helper that pokes it silently
no-ops. A stale `selectAccountFromTypeahead` did this and left the posted account empty
(account-controlled by `x-model`, so the raw DOM `.value` you set is overwritten from
Alpine's empty state). Drive components the real way instead: `window.Alpine.$data(el)`,
open the tippy dropdown, inject `elements`, click the result — exactly as
`transaction-edit-swap.spec.ts` does. Probe with
`{ hasLegacy__x: !!el.__x, hasAlpineData: !!window.Alpine.$data(el) }`.
## Diagnosing a "modal won't close after save"
The edit modal closes on an `hx-trigger: modalclose` from a *successful* save; a
validation failure re-renders the `#wizard-form` (200), and a server exception returns 500
(caught by `wrap-error`). To find which: capture POST responses in Playwright
(`page.on('response', …)`), read the `edit-submit` body — a `<form id="wizard-form">` means
validation re-render; a `#error {…}` stack means a 500. Then serialize the form right
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.

View File

@@ -0,0 +1,85 @@
# Render functions: explicit data, or a top-rooted cursor
**One function, data in, markup out.** The data can arrive as a plain map *or* via a
cursor — as long as the cursor was rooted at the top of the form and walked down to here,
never faked to start at this depth. The rule is about *where the cursor starts*, not
whether you use one.
## GOOD — explicit data, pure, testable without setup
```clojure
(defn account-row [{:keys [account index client-id amount-mode]}]
(com/data-grid-row
(com/hidden {:name (str "accounts[" index "][db/id]")
:value (or (:db/id account) "")})
(com/data-grid-cell
(account-typeahead* {:value (:transaction-account/account account)
:name (str "accounts[" index "][account]")
:client-id client-id}))
...))
```
## ALSO FINE — a cursor that started at the form root and was advanced naturally
```clojure
;; The top-level render walks the cursor; the row fn receives the dereferenced row
;; (or the advanced cursor). No rebinding of *current*/*prefix* to fake depth.
(defn account-rows [accounts-cursor]
(for [row-cursor (fc/each accounts-cursor)] ; advanced from the root, not faked
(account-row {:account @row-cursor :index (fc/index row-cursor) ...})))
```
`transaction/edit.clj`'s `transaction-account-row*` is the cursor form done right: the
caller (`account-grid-body*`) holds a top-rooted cursor via `fc/cursor-map` and hands each
row cursor to one render fn.
---
## The SMELL this migration removes
### 1. Faking the cursor's starting position
A "form cursor" is fine. The pain is **rebinding the dynamic root deeper in the tree** so
a deeply nested render fn can run against a fragment. Real example from
`transaction/edit.clj`'s `simple-mode-fields*` (the thing to delete):
```clojure
;; SMELL: re-roots the cursor to a synthetic MapCursor pointed at accounts[0] so a
;; fragment can render "deep". Fragile, and the source of the *-no-cursor* twin below.
(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))))
...))
```
Target: the cursor begins at the top level of what the form consumes and walks down
naturally. Because the **whole form is re-rendered each time** (swap doctrine), there is
no longer any reason to fake a deep starting position.
### 2. The `*-no-cursor*` twin
Faking the deep cursor forces a *second copy of the same markup* — one that reads the
faked cursor and one that takes plain params for the cases where the fake can't be set up.
`transaction/edit.clj` has exactly this pair:
```clojure
(defn transaction-account-row* [{:keys [value index client-id ...]}] ...) ; cursor form
(defn transaction-account-row-no-cursor* [{:keys [account index client-id ...]}] ...) ; duplicate markup
```
**Fix:** keep one render fn. If a caller already holds a top-rooted cursor, advance it and
hand the row data (or the advanced cursor) to that one fn. Delete the `*-no-cursor*` copy.
Heuristic 1 targets `grep -c 'defn.*-no-cursor'` → 0 and faked-cursor re-roots → 0.
## Scorecard hooks (heuristics 1, 2)
```bash
grep -c 'defn.*-no-cursor' $F # → 0
grep -cE 'with-cursor|MapCursor\.' $F # faked re-roots → 0 (top-rooted cursors are fine)
```
Top-rooted cursors do **not** count against heuristic 1 — only *re-roots that fake depth*
and the `*-no-cursor*` twins do.

View File

@@ -0,0 +1,61 @@
# Quality scorecard (the ratchet)
Cheap to measure (`grep -c`, `wc -l`, `clj-kondo`), recorded **before/after each
migration** in the commit message and in the results table below. **No metric may regress
for the touched modal** without a written exception in `gotchas.md`. These are directional
evidence, not targets to game — always paired with the e2e parity gate.
## Heuristics
| # | Heuristic | Measure | Target |
|---|-----------|---------|--------|
| 1 | Faked cursor positions (not cursors themselves) | `grep -cE 'with-cursor\|MapCursor\.'` re-roots + `grep -c 'defn.*-no-cursor'` | → 0 (top-rooted cursors are fine) |
| 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit `put-step` only (wizards) |
| 3 | Branching complexity | `clj-kondo`, or count `cond`/`condp`/`case`/nested `if` + max depth | net ↓ |
| 4 | Lines of code | `wc -l` on the modal's file(s) | net ↓ |
| 5 | Reuse / cross-form similarity | cookbook components reused; duplicated-block count | reuse ↑, dup ↓ |
| 6 | Route count | count routes for the modal | → 2 (+1 for add-row) |
| 7 | OOB swaps | `grep -c hx-swap-oob` | → 0 unless a justified disjoint-region case is documented |
| 8 | Attribute consistency | mixed `:x-`/`"x-"` encodings in migrated template | → 0 |
## How to measure (copy/paste)
```bash
F=src/clj/auto_ap/ssr/<modal>.clj
echo "LOC $(wc -l < $F)"
echo "no-cursor twins $(grep -c 'defn.*-no-cursor' $F)"
echo "faked-cursor roots $(grep -cE 'with-cursor|MapCursor\.' $F)"
echo "snapshot merges $(grep -c ':multi-form-state :snapshot' $F)"
echo "branch forms $(grep -cE '\(cond |\(condp |\(case |\(when-not ' $F)"
echo "hx-swap-oob $(grep -c 'hx-swap-oob' $F)"
echo "mixed string hx- $(grep -cE '\"hx-[a-z]' $F)"
# route count: count this modal's entries in src/cljc/auto_ap/routes/*.cljc
```
## Results
Each migration appends one row (after-numbers), referencing the before in the diff.
| 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 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).

View File

@@ -0,0 +1,99 @@
# Selmer template conventions
> **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 real
`com/typeahead-` mixes them in one map:
```clojure
: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:
```html
{# templates/components/typeahead.html #}
<div class="relative" x-data="{{ x_data|safe }}" x-model="{{ x_model }}">
<a class="{{ classes }}" x-ref="input" tabindex="0"
@keydown.down.prevent.stop="tippy?.show()"
@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.
## The render helper + interop bridge (`auto-ap.ssr.selmer`)
```clojure
(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 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
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, 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)
```bash
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. (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

@@ -0,0 +1,149 @@
# Whole-form HTMX swap doctrine
Every interactive control picks a swap strategy in this **priority order** (prefer the
earliest rule that works). Worked examples are the real `transaction/edit.clj` swaps.
## Rule 1 — No request when the field affects nothing else
Its value rides along in the form and is read on submit. No `hx-*` at all.
```clojure
;; transaction/edit.clj — the memo field. Editing it issues NO request; the value
;; just rides along until save. The e2e proves zero POSTs fire while typing.
(com/text-input {:value (fc/field-value)
:name (fc/field-name)
:id "edit-memo"
:placeholder "Optional note"})
```
## Rule 2 — Targeted swap of a single isolated cell when the effect is purely local
Give the cell a stable id, keep it **out of the typed input's subtree**, and post the
whole form but `hx-select` back only that cell.
```clojure
;; transaction/edit.clj — selecting an account only changes that row's valid Location
;; options, so the change swaps just this cell. Nothing else re-renders.
[:div {:id (str "account-location-" index)} ; stable, per-row id
(com/validated-field
{:x-hx-val:account-id "accountId"
:x-dispatch:changed "accountId" ; Alpine fires `changed` when account changes
:hx-trigger "changed"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target (str "#account-location-" index)
:hx-select (str "#account-location-" index)
:hx-swap "outerHTML"
:hx-include "closest form"} ; whole form posts; only this cell swaps back
(location-select* {...}))]
```
## Rule 3 — Whole-form swap when the change touches interdependent state
Vendor change, add/remove row, mode toggle, $/% radio. The form's hidden state rides
along, so one swap keeps everything consistent — **no out-of-band swaps**.
```clojure
;; transaction/edit.clj — vendor change rebuilds the whole manual-coding section
;; (vendor default account, terms, etc. are interdependent).
[:div {:hx-trigger "change"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
:hx-target "#wizard-form"
:hx-select "#wizard-form"
:hx-swap "outerHTML"
:hx-sync "this:replace"
:hx-include "closest form"}
...]
```
The active tab/action round-trips through the form (it's a hidden field bound to Alpine
`activeForm`), so it **survives** the whole-form swap — that's why a whole-form swap is
safe here even though the user is "on" a tab.
## Rule 4 — OOB only for genuinely disjoint DOM regions
A global flash/toast, a nav badge, a modal at the document root. **If tempted to OOB
something inside the same feature, restructure instead**: give the dependent element a
common ancestor with the trigger and use an ordinary swap.
Worked example — running **totals live in their own sibling `<tbody>`** so an amount edit
swaps the totals without ever replacing the amount input:
```clojure
;; The totals tbody is a sibling of the input-bearing rows.
(com/data-grid
{:footer-tbody [:tbody {:id "account-totals"} ...totals rows...]}
...account rows with inputs...)
;; The amount input posts the whole form but hx-selects ONLY #account-totals.
(com/money-input
{:name (fc/field-name)
:id (str "account-amount-" index)
:class "w-16 account-amount-field"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target "#account-totals" ; a SIBLING of this input's row...
:hx-select "#account-totals" ; ...so the input is never in the swapped region
:hx-swap "outerHTML"
:hx-trigger "keyup changed delay:300ms"
:hx-include "closest form"})
```
`grep -c hx-swap-oob` on a migrated modal must be `0` unless a justified disjoint-region
case is documented here and in `gotchas.md`.
---
## The focus invariant (must always hold)
> The input the user is typing in is never inside the region its own request swaps.
This is *the* reason the doctrine works. The amount field swaps a sibling tbody; the memo
field swaps nothing; the account typeahead's change swaps the whole form but the typeahead
isn't an active text caret at that moment (it's a click-to-select). The
`transaction-edit-swap.spec.ts` `sameNode` assertions exist to catch any violation.
## Alpine components must survive swaps
When a whole-form swap replaces a region containing Alpine/tippy components, they get
re-initialised from the server-provided values. Two hardening moves:
1. **Null-guard every reference** that depends on Alpine/tippy being initialised:
```clojure
"@keydown.down.prevent.stop" "tippy?.show()"
"@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); ..."
```
(`$refs.input?` / `tippy?` — the `?` matters; a swap can run a handler before re-init.)
2. **Let the server value win.** Because the section is rebuilt fresh on each swap, the
server-driven value (e.g. a vendor's default account) lands without keying tricks —
no preserved stale Alpine state to fight. The "changing the vendor a *second* time
still updates it" e2e is the regression guard for this.
If you *do* preserve a component across a morph/replace, key it by its server value so
a server-driven change forces re-init: `(assoc attrs :key (str id "--" current-value))`.
Use `hx/alpine-mount-then-appear` for rows that should mount-then-transition-in (it sets
`x-data {data-key false}`, `x-init $nextTick(() => key=true)`, `x-show key`).
---
## Selector strategy for targeted swaps (a consideration, not a mandate)
Rules 2 and 4 need a stable `hx-target`/`hx-select`. Per-element unique ids
(`#account-location-0`) work and are what transaction-edit uses today. They get noisy in
deeply repeated/nested structures. When you hit that (Phase 5 / the wizards), consider:
- **Semantic markup + data-attributes** — mark rows/cells with their identity and target
by attribute, no per-element ids:
```html
<tr data-row="account" data-index="0">
<td data-cell="location"> … </td>
</tr>
<!-- hx-target="[data-row='account'][data-index='0'] [data-cell='location']" -->
```
- **A `form-path -> selector` function**, derived the same way a cursor path is, so the
server and the markup agree on the target by construction. A render fn at form-path
`[:accounts 0 :location]` computes its own stable selector from that path.
**Decision status:** still per-element ids. The first modal to hit nested repeated swaps
(Invoice Bulk Edit, Phase 5) settles the convention and records it here + in
`component-cookbook.md` for the wizards to reuse.

View File

@@ -0,0 +1,137 @@
# Test recipes
GROWS every migration. How to characterize and verify a modal. Consistent with the
project `testing-conventions` skill: test user-observable behavior, assert DB state
directly, don't test the means.
## The three test layers
1. **Characterization e2e first (Playwright).** Before changing a modal, write/confirm a
spec capturing *current* behavior — focus/caret survival across swaps, each field
round-trip, validation errors, the real save. This is the parity contract; keep it
green through every commit.
2. **Pure-function checks via REPL.** Once render/data-prep fns are pure, exercise them
with `clojure-eval` / `clj-nrepl-eval -p <port>`. Assert on returned data; for markup
use string matches (`(re-find #"accounts\[0\]\[account\]" (str html))`) — this style
survives the Selmer switch. Avoid brittle structural assertions.
3. **DB-state assertions for mutations.** If a submit writes Datomic, verify by querying
the DB, not by asserting on markup.
## Running e2e
```bash
npx playwright test # full suite
npx playwright test e2e/transaction-edit-swap.spec.ts # one spec
```
- Config: `playwright.config.ts`, `baseURL http://localhost:3333`, `webServer:
lein run -m auto-ap.test-server`, `reuseExistingServer: !CI`.
- **The server must be from the worktree you're testing.** `reuseExistingServer` will
silently reuse *any* server on `:3333` — including another worktree's. Confirm with
`ls -la /proc/$(lsof -ti :3333)/cwd` (or restart on a clean port) before trusting a run.
- The test-server port is hardcoded (`test_server.clj` `run-jetty {:port 3333}`); to run a
second server from another worktree, change that or parameterise it.
## Driving a typeahead in e2e (Solr unavailable in tests)
```js
await typeahead.locator('a[x-ref="input"]').click(); // open tippy dropdown
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
await search.fill('te'); // under 3-char Solr threshold
await typeahead.evaluate((el, id) => { // inject a clickable result
window.Alpine.$data(el).elements = [{ value: id, label: 'Test Account' }];
}, accountId);
await page.locator('[data-tippy-root] a', { hasText: 'Test Account' }).first().click();
```
Entity ids come from `GET /test-info` (`{accounts:{test-account, vendor, vendor2, ...}}`).
## Proving the focus invariant (caret survival) — the key swap test
```js
// before the debounced swap lands, capture the live focused node...
await page.evaluate(() => { window.__focused = document.activeElement; });
await swap; // waitForResponse on the *-form-changed POST
const ok = await page.evaluate(() => {
const a = document.activeElement;
return { sameNode: a === window.__focused, value: a?.value, caret: a?.selectionStart };
});
// ...assert the SAME node survived with value + caret intact.
```
`trackErrors(page)` (collect `pageerror` + `console.error`, assert `[]`) catches a swap
that throws on a stale `$refs`/`tippy` — pair it with every swap test.
## Asserting "no request" (Rule 1 fields)
```js
let posts = 0;
page.on('request', r => { if (r.url().includes('edit-form-changed') && r.method()==='POST') posts++; });
// ...type in the memo...
expect(posts).toBe(0); // memo affects nothing → issues no request
```
---
## E2E baseline (the regression gate — never drop below this)
The full suite must stay green after every migration. Specs touching the migrated modals:
| Spec | Tests | Role |
|------|-------|------|
| `e2e/transaction-edit-swap.spec.ts` | 8 | **Phase 2 parity contract** — whole-form `hx-select` swaps, caret survival, no-request memo, vendor re-select |
| `e2e/transaction-edit.spec.ts` | 15 | transaction edit behavior |
| `e2e/bulk-code-transactions.spec.ts` | 18 | Phase 3 (bulk code) |
| `e2e/transaction-import.spec.ts` | 4 | import |
| `e2e/transaction-navigation.spec.ts` | 13 | navigation |
### Running e2e from a non-default worktree (recipe)
`:3333` is often taken by another worktree's server. To run this worktree's code:
1. Boot the test server in-process on this worktree's REPL at an alternate port — no
second JVM, and it live-reloads as you edit:
```clojure
(require '[auto-ap.test-server :as ts] '[ring.adapter.jetty :refer [run-jetty]]
'[datomic.api :as dc])
;; reseed helper — call before each full run so state doesn't leak between runs
(defn reseed! []
(try (.stop (:server test-srv)) (catch Throwable _))
(try (dc/delete-database "datomic:mem://playwright-test") (catch Throwable _))
(def test-srv (let [c (ts/create-test-db) id (ts/seed-test-data c)]
(reset! ts/test-transaction-id id)
{:server (run-jetty (ts/test-app) {:port 3334 :join? false}) :tx-id id})))
(reseed!)
```
2. `playwright.config.ts` honors `BASE_URL`; setting it also disables the auto-started
webServer (so worktrees don't fight over :3333):
```bash
BASE_URL=http://localhost:3334 npx playwright test --workers=1 --reporter=line
```
3. **Reseed (`reseed!`) before each full run.** One long-lived in-process server persists
its in-mem DB across separate `npx playwright` invocations; the swap spec's
`clearAccounts`/save mutate the shared transaction and leak into later specs. The
normal harness avoids this by booting a fresh server per `npx playwright test`.
### Pass/fail baseline — measured on the merged hx-select reference (Phase 2 start)
Server: in-process from `integreat-execute-refactor` on `:3334`, `--workers=1`, fresh seed.
| Spec | Result |
|------|--------|
| `transaction-edit-swap.spec.ts` | **6 / 6 pass** — the whole-form swap parity contract |
| `transaction-edit.spec.ts` | **1 fail (masks 7 via `mode: 'serial'`)** — `Shared Location … spread on save and reopen` fails: the save POST returns a validation error (amount/balance test-data assumption: "$200 = full amount of the 2nd transaction" doesn't hold), so the modal stays open. **Pre-existing on the merged reference, not introduced by this work.** |
| 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.
### 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.

View File

@@ -0,0 +1,389 @@
import { test, expect } from '@playwright/test';
// These tests cover the "post the whole form, hx-select what to swap" behaviour
// on the transaction edit page. Each edit hits its own route, the server
// re-renders the entire form, and the client selects what to swap back -- with
// no out-of-band swaps and no morph extension:
// - discrete changes (vendor, account, location, mode, add/remove row) swap
// all of #wizard-form (the active action/tab round-trips through the form,
// so it survives the swap);
// - typed fields never swap the input the user is in -- the amount field swaps
// only the #account-totals tbody (a sibling of the input rows), and the memo
// posts with hx-swap=none.
// Because the active input is never part of a swapped region, focus and caret
// survive a plain swap.
// Collect any uncaught page errors or console errors so a swap that throws
// (e.g. a tooltip callback dereferencing a stale $refs) fails the test loudly.
function trackErrors(page: any): string[] {
const errors: string[] = [];
page.on('pageerror', (e: any) => errors.push('pageerror: ' + e.message));
page.on('console', (m: any) => {
if (m.type() === 'error') errors.push('console: ' + m.text());
});
return errors;
}
async function openManualAdvanced(page: any, transactionIndex = 0) {
await page.goto('/transaction2');
await page.waitForSelector('table tbody tr');
await page
.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]')
.nth(transactionIndex)
.click();
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
await page.waitForSelector('#wizardmodal');
await page.click('button:has-text("Manual")');
// First transaction has no accounts so it opens in "simple" mode. Switch to
// advanced mode (a whole-form swap) so the account grid is present.
const advancedLink = page.locator('a:has-text("Switch to advanced mode")');
if (await advancedLink.count()) {
await advancedLink.first().click();
await page.waitForSelector('#account-grid-body');
}
}
// Drives the vendor typeahead like a user: open the dropdown, inject a result
// (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-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' });
await search.fill('xx');
await vendor.evaluate((el: HTMLElement, opt: { id: number; label: string }) => {
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
}, { id: vendorId, label });
const swap = page.waitForResponse(
(r: any) =>
r.url().includes('edit-form-changed') &&
r.request().method() === 'POST' &&
r.status() === 200
);
await page.locator('[data-tippy-root] a', { hasText: label }).first().click();
await swap;
await page.waitForTimeout(400);
}
// Removes every existing account row (each remove is its own whole-form swap), so a
// test starts from a known-empty state regardless of what earlier tests saved
// onto the shared transaction.
async function clearAccounts(page: any) {
// eslint-disable-next-line no-constant-condition
while (true) {
const removeButtons = page.locator('#account-grid-body .account-remove-action');
const count = await removeButtons.count();
if (count === 0) break;
await removeButtons.first().click();
await expect
.poll(async () => page.locator('#account-grid-body .account-remove-action').count())
.toBeLessThan(count);
}
}
test.describe.configure({ mode: 'serial' });
test.describe('Transaction Edit whole-form swap', () => {
test('whole-form swaps (toggle mode, add account) do not throw', async ({ page }) => {
const errors = trackErrors(page);
await openManualAdvanced(page, 0);
// Add an account row -- another whole-form swap.
await page
.locator('#account-grid-body')
.locator('button:has-text("New account"), a:has-text("New account")')
.first()
.click();
await expect
.poll(async () => page.locator('#account-grid-body tbody tr.account-row').count())
.toBeGreaterThan(0);
// The form must survive the swap intact.
await expect(page.locator('#wizard-form')).toHaveCount(1);
expect(errors, errors.join('\n')).toEqual([]);
});
test('keeps focus and typed value in the amount field across a swap', async ({ page }) => {
const errors = trackErrors(page);
await openManualAdvanced(page, 0);
// Ensure exactly one account row exists.
const rows = await page.locator('#account-grid-body tbody tr.account-row').count();
if (rows === 0) {
await page
.locator('#account-grid-body')
.locator('button:has-text("New account"), a:has-text("New account")')
.first()
.click();
await expect
.poll(async () => page.locator('#account-grid-body tbody tr.account-row').count())
.toBeGreaterThan(0);
}
const amount = page.locator('.account-amount-field').first();
await amount.waitFor();
// Type a clean value via the keyboard. Typing fires the field's htmx trigger
// (keyup), which posts the whole form but swaps back only the #account-totals
// tbody -- a sibling of this input's row, so the input is never replaced. It's
// type=number (no text caret), so we assert focus + node identity + value.
await amount.click();
await amount.press('Control+a');
const amountSwap = page.waitForResponse(
(r: any) =>
r.url().includes('edit-form-changed') &&
r.request().method() === 'POST' &&
r.status() === 200
);
await amount.pressSequentially('150', { delay: 40 });
// Identify the live focused node (before the debounced swap lands) so we can
// prove the *same* node survives.
await page.evaluate(() => {
(window as any).__focusedAmount = document.activeElement;
});
await amountSwap;
await page.waitForTimeout(300);
const state = await page.evaluate(() => {
const active = document.activeElement as HTMLInputElement;
return {
sameNode: active === (window as any).__focusedAmount,
isAmountField: !!active && active.classList.contains('account-amount-field'),
value: active ? active.value : null,
};
});
// Focus must stay on the amount field after the swap...
expect(state.isAmountField).toBe(true);
// ...on the very same DOM node (the input is never part of the swapped region)...
expect(state.sameNode).toBe(true);
// ...with the value the user typed left intact.
expect(state.value).toBe('150');
// The TOTAL must have recomputed server-side from the posted amount and been
// applied via the #account-totals swap.
await expect(page.locator('.account-total-row #total')).toContainText('150');
expect(errors, errors.join('\n')).toEqual([]);
});
test('memo edits issue no request and keep their value/caret', async ({ page }) => {
const errors = trackErrors(page);
// Memo affects nothing else in the form, so editing it must NOT issue a
// request at all -- its value just rides along in the form until save.
let memoRequests = 0;
page.on('request', (r: any) => {
if (r.url().includes('edit-form-changed') && r.method() === 'POST') memoRequests++;
});
await page.goto('/transaction2');
await page.waitForSelector('table tbody tr');
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
await page.waitForSelector('#wizardmodal');
const memo = page.locator('#edit-memo');
await memo.waitFor();
// Clear any seeded memo text and type "hello".
await memo.click();
await memo.press('Control+a');
await memo.pressSequentially('hello', { delay: 40 });
// Drop the caret in the middle and insert a char -> "heXllo", caret -> 3.
await memo.evaluate((el: HTMLInputElement) => {
el.focus();
el.setSelectionRange(2, 2);
});
await page.evaluate(() => {
(window as any).__focusedMemo = document.activeElement;
});
await memo.press('X');
// Give the old debounce window a chance to (not) fire.
await page.waitForTimeout(500);
const state = await page.evaluate(() => {
const active = document.activeElement as HTMLInputElement;
return {
sameNode: active === (window as any).__focusedMemo,
id: active ? active.id : null,
value: active ? active.value : null,
caret: active ? active.selectionStart : null,
};
});
// No request fired, and the value/caret are simply intact (nothing swapped).
expect(memoRequests).toBe(0);
expect(state.id).toBe('edit-memo');
expect(state.sameNode).toBe(true);
expect(state.value).toBe('heXllo');
expect(state.caret).toBe(3);
expect(errors, errors.join('\n')).toEqual([]);
});
test('choosing an account from the typeahead does not throw and persists', async ({ page }) => {
const errors = trackErrors(page);
await openManualAdvanced(page, 0);
// Start from a clean, empty account row so selecting the account actually
// changes accountId (and fires the change-gated whole-form swap).
await clearAccounts(page);
await page
.locator('#account-grid-body')
.locator('button:has-text("New account"), a:has-text("New account")')
.first()
.click();
await expect
.poll(async () => page.locator('#account-grid-body tbody tr.account-row').count())
.toBeGreaterThan(0);
const row = page.locator('#account-grid-body tbody tr.account-row').first();
const typeahead = row.locator('div.relative[x-data]').first();
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
await typeahead.locator('a[x-ref="input"]').click();
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
await search.waitFor({ state: 'visible' });
// Account search is backed by Solr (unavailable in tests), so type under the
// 3-char threshold and inject a clickable result into the typeahead state --
// the click handler, tippy.hide(), Alpine reactivity and the HTMX swap all run
// exactly as in production.
await search.fill('te');
const testInfo = await (await page.request.get('/test-info')).json();
const accountId: number = testInfo.accounts['test-account'];
await typeahead.evaluate((el: HTMLElement, id: number) => {
(window as any).Alpine.$data(el).elements = [{ value: id, label: 'Test Account' }];
}, accountId);
// Clicking the result runs `value = element; tippy.hide(); ...` and dispatches
// the change that fires the whole-form swap.
const swap = page.waitForResponse(
(r: any) =>
r.url().includes('edit-form-changed') &&
r.request().method() === 'POST' &&
r.status() === 200
);
await page.locator('[data-tippy-root] a', { hasText: 'Test Account' }).first().click();
await swap;
await page.waitForTimeout(300);
// The chosen account must survive the whole-form swap.
const hidden = page
.locator('#account-grid-body tbody tr.account-row')
.first()
.locator('input[type="hidden"][name*="transaction-account/account"]')
.first();
await expect(hidden).toHaveValue(accountId.toString());
expect(errors, errors.join('\n')).toEqual([]);
});
test('selecting a vendor populates its default account across the swap', async ({ page }) => {
const errors = trackErrors(page);
// Open the modal in simple mode (transaction 0 has no accounts).
await page.goto('/transaction2');
await page.waitForSelector('table tbody tr');
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-vals*="vendor-changed"]');
const testInfo = await (await page.request.get('/test-info')).json();
const vendorId: number = testInfo.accounts.vendor;
const defaultAccountId: number = testInfo.accounts['test-account'];
// 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-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' });
await search.fill('te');
await vendor.evaluate((el: HTMLElement, id: number) => {
(window as any).Alpine.$data(el).elements = [{ value: id, label: 'Test Vendor' }];
}, vendorId);
const swap = page.waitForResponse(
(r: any) =>
r.url().includes('edit-form-changed') &&
r.request().method() === 'POST' &&
r.status() === 200
);
await page.locator('[data-tippy-root] a', { hasText: 'Test Vendor' }).first().click();
await swap;
await page.waitForTimeout(400);
// The vendor's default account must now be reflected in the account field.
// Because the section is rebuilt fresh from the server (no preserved Alpine
// state), the server-driven account value lands without any keying tricks.
const accountHidden = page
.locator('input[type="hidden"][name*="transaction-account/account"]')
.first();
await expect(accountHidden).toHaveValue(defaultAccountId.toString());
// The displayed account label should resolve too.
await expect(page.locator('span[x-text="value.label"]', { hasText: 'Test Account' })).toBeVisible();
expect(errors, errors.join('\n')).toEqual([]);
});
test('changing the vendor a second time still updates it', async ({ page }) => {
const errors = trackErrors(page);
await page.goto('/transaction2');
await page.waitForSelector('table tbody tr');
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-vals*="vendor-changed"]');
const testInfo = await (await page.request.get('/test-info')).json();
const vendor1: number = testInfo.accounts.vendor;
const vendor2: number = testInfo.accounts.vendor2;
const account1: number = testInfo.accounts['test-account'];
const account2: number = testInfo.accounts['second-account'];
const vendorLabel = page
.locator('div[hx-vals*="vendor-changed"] span[x-text="value.label"]')
.first();
const accountHidden = page
.locator('input[type="hidden"][name*="transaction-account/account"]')
.first();
// First vendor.
await selectVendor(page, vendor1, 'Test Vendor');
await expect(vendorLabel).toHaveText('Test Vendor');
await expect(accountHidden).toHaveValue(account1.toString());
// Second vendor -- the regression guard: the section (and its vendor
// typeahead) is rebuilt fresh on every swap, so a second change still fires
// its request and updates the default account.
await selectVendor(page, vendor2, 'Second Vendor');
await expect(vendorLabel).toHaveText('Second Vendor');
await expect(accountHidden).toHaveValue(account2.toString());
// And back again, to be sure it keeps working.
await selectVendor(page, vendor1, 'Test Vendor');
await expect(vendorLabel).toHaveText('Test Vendor');
await expect(accountHidden).toHaveValue(account1.toString());
expect(errors, errors.join('\n')).toEqual([]);
});
});

View File

@@ -18,7 +18,15 @@ async function openEditModal(page: any, transactionIndex: number = 0) {
// The modal is now single-page (Edit Transaction). Click "Manual" tab to ensure
// the manual account coding form is active.
await page.click('button:has-text("Manual")');
// Transactions with 0-1 accounts open in "simple" mode, which has no account
// grid. Switch to "advanced" mode (a whole-form morph swap) so the grid the
// rest of these helpers manipulate is present.
const advancedLink = page.locator('a:has-text("Switch to advanced mode")');
if (await advancedLink.count()) {
await advancedLink.first().click();
}
// Wait for the manual form to appear
await page.waitForSelector('#account-grid-body');
}
@@ -33,68 +41,33 @@ async function getTestInfo(page: any) {
}
async function selectAccountFromTypeahead(page: any, rowIndex: number, accountName: string) {
// The account search uses Solr which isn't available in tests.
// Instead, we directly set the hidden input value via JavaScript.
// Get all rows except the new-row, total, balance, and transaction total rows
const allRows = page.locator('#account-grid-body tbody tr');
const rowCount = await allRows.count();
// Find the row that has a hidden input for account (actual account rows)
let accountRow = null;
let accountRowIndex = 0;
for (let i = 0; i < rowCount; i++) {
const row = allRows.nth(i);
const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0;
if (hasAccountInput) {
if (accountRowIndex === rowIndex) {
accountRow = row;
break;
}
accountRowIndex++;
}
}
if (!accountRow) {
throw new Error(`Could not find account row at index ${rowIndex}`);
}
// Find the hidden input for the account
const hiddenInput = accountRow.locator('input[type="hidden"][name*="transaction-account/account"]').first();
// Get account IDs from test-info endpoint
const testInfo = await getTestInfo(page);
// Account search is backed by Solr (unavailable in tests). Drive the typeahead the
// way a user does, using the Alpine v3 API: open the tippy dropdown, inject a result
// into the component's `elements`, then click it. This runs the real click handler,
// Alpine reactivity and the HTMX swap exactly as in production -- unlike poking the
// long-removed Alpine v2 `__x` internal, which silently no-ops on Alpine v3 and left
// the posted account empty.
const accountKey = accountName === 'Test' ? 'test-account' : 'second-account';
const label = `${accountName} Account`;
const testInfo = await getTestInfo(page);
const accountId = testInfo.accounts[accountKey];
if (!accountId) {
throw new Error(`Could not find account with name ${accountName}`);
}
// Set the hidden input value and trigger change
// Also update Alpine.js data to prevent it from overwriting our value
await hiddenInput.evaluate((el: HTMLInputElement, value: string) => {
// Set the DOM value
el.value = value;
// Update Alpine.js component data
const alpineEl = el.closest('[x-data]');
if (alpineEl && (alpineEl as any).__x) {
(alpineEl as any).__x.$data.value.value = parseInt(value);
(alpineEl as any).__x.$data.value.label = 'Selected Account';
}
// Also update any parent Alpine model (accountId)
const rowEl = el.closest('tr[x-data]');
if (rowEl && (rowEl as any).__x) {
(rowEl as any).__x.$data.accountId = parseInt(value);
}
el.dispatchEvent(new Event('change', { bubbles: true }));
}, accountId.toString());
// Wait for any HTMX updates
await page.waitForTimeout(300);
const row = page.locator('#account-grid-body tbody tr.account-row').nth(rowIndex);
const typeahead = row.locator('div.relative[x-data]').first();
await typeahead.locator('a[x-ref="input"]').click();
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
await search.waitFor({ state: 'visible' });
await search.fill('te');
await typeahead.evaluate((el: any, opt: { id: number; label: string }) => {
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
}, { id: accountId, label });
await page.locator('[data-tippy-root] a', { hasText: label }).first().click();
// Wait for the change-gated whole-form swap to settle.
await page.waitForTimeout(400);
}
async function findAccountRow(page: any, rowIndex: number) {
@@ -151,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);
}
}
@@ -177,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);
}
@@ -188,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);
}
@@ -237,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);
});
});
@@ -367,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) {
@@ -386,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) => {
@@ -401,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);
}
@@ -449,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);
});
});
@@ -461,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]).
@@ -493,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);
}
@@ -512,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', () => {
@@ -528,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());

View File

@@ -1,5 +1,11 @@
import { defineConfig, devices } from '@playwright/test';
// Allow pointing the suite at an already-running test server (e.g. one booted from a
// specific worktree on a non-default port) via BASE_URL. When BASE_URL is set we skip
// the auto-started webServer entirely, so parallel worktrees don't fight over :3333.
const baseURL = process.env.BASE_URL ?? 'http://localhost:3333';
const useExternalServer = !!process.env.BASE_URL;
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
@@ -8,15 +14,17 @@ export default defineConfig({
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3333',
baseURL,
trace: 'on-first-retry',
},
webServer: {
command: 'lein run -m auto-ap.test-server',
url: 'http://localhost:3333/test-info',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
webServer: useExternalServer
? undefined
: {
command: 'lein run -m auto-ap.test-server',
url: 'http://localhost:3333/test-info',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
projects: [
{
name: 'chromium',

View File

@@ -96,6 +96,7 @@
[org.clojure/core.async]]
[hiccup "2.0.0-alpha2"]
[selmer "1.12.61"]
;; needed for java 11
[javax.xml.bind/jaxb-api "2.4.0-b180830.0359"]

View File

@@ -416,4 +416,64 @@ htmx.onLoad(function(content) {
console.error('Failed to copy text to clipboard:', err);
}
}
/*
(function() {
var lastFocusedSelector = null;
var lastCursorPosition = null;
document.addEventListener('htmx:beforeSwap', function(evt) {
var active = document.activeElement;
if (active && active !== document.body) {
// Build a selector to find this element after swap
if (active.id) {
lastFocusedSelector = '#' + active.id;
} else if (active.name) {
lastFocusedSelector = '[name="' + active.name + '"]';
} else {
lastFocusedSelector = null;
}
// Save cursor position for text inputs. selectionStart is null on
// inputs that don't support selection (number, date, select, etc.),
// and calling setSelectionRange on those throws, so only capture it
// when it's an actual numeric caret position.
if (typeof active.selectionStart === 'number') {
lastCursorPosition = {
start: active.selectionStart,
end: active.selectionEnd,
direction: active.selectionDirection
};
} else {
lastCursorPosition = null;
}
}
});
document.addEventListener('htmx:afterSwap', function(evt) {
if (lastFocusedSelector) {
setTimeout(function() {
var el = document.querySelector(lastFocusedSelector);
// If morph already kept focus on the right element there's nothing
// to do; only restore when focus was actually lost by the swap.
if (el && el.focus && document.activeElement !== el) {
el.focus();
if (lastCursorPosition && el.setSelectionRange) {
try {
el.setSelectionRange(
lastCursorPosition.start,
lastCursorPosition.end,
lastCursorPosition.direction
);
} catch (e) { }
}
}
lastFocusedSelector = null;
lastCursorPosition = null;
}, 10);
}
});
})();
*/

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

@@ -0,0 +1,7 @@
<div id="interop-smoke" class="p-2">
<h3>{{ title }}</h3>
{# a Hiccup-rendered component, passed in pre-rendered and emitted verbatim #}
{{ hiccup_frag|safe }}
<input x-ref="input" x-model="value.value"
@keydown.down.prevent.stop="tippy?.show()" />
</div>

View File

@@ -62,7 +62,15 @@
(.setHandler server stats-handler))
(.setStopAtShutdown server true))
(def ^:dynamic *http-port-override* nil)
(def ^:dynamic *http-port-override*
;; In dev, `lein mcp-repl` records the chosen HTTP port in `.http-port` so it
;; stays stable across reloads. `refresh` re-evaluates this def, so reading the
;; file here (rather than relying solely on an alter-var-root override that gets
;; reset) keeps the port from falling back to (env :port). Absent in prod.
(let [f (java.io.File. ".http-port")]
(when (.exists f)
(let [p (.trim ^String (slurp f))]
(when (seq p) p)))))
(mount/defstate port :start (Integer/parseInt (str (or *http-port-override* (env :port) "3000"))))

View File

@@ -35,7 +35,7 @@
default-grid-fields-schema)]))
(defn filters [request]
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
::route/table)
"hx-target" "#entity-table"

View File

@@ -45,10 +45,10 @@
[:label {:for "checkbox-all", :class "sr-only"} "checkbox"]]])
(defn data-grid-
[{:keys [headers thead-params id] :as params} & rest]
[{:keys [headers thead-params id footer-tbody] :as params} & rest]
[:div.shrink.overflow-y-scroll
[:table (merge {:class "w-full text-sm text-left text-gray-500 dark:text-gray-400 shrink"}
(dissoc params :headers :thead-params))
(dissoc params :headers :thead-params :footer-tbody))
[:thead (update thead-params :class #(-> "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 group-[.raw]:sticky group-[.raw]:z-10 group-[.raw]:top-0"
(hh/add-class (or % ""))))
(into
@@ -56,7 +56,11 @@
headers)]
(into
[:tbody {}]
rest)]])
rest)
;; Optional second <tbody> (valid HTML) so callers can keep a stable,
;; separately-swappable region in the same table -- e.g. totals rows that
;; update without touching the input-bearing rows above them.
footer-tbody]])
;; needed for tailwind
;; lg:table-cell md:table-cell

View File

@@ -7,7 +7,7 @@
[clj-time.core :as t]
[clj-time.periodic :as per]))
(defn date-range-field [{:keys [value id apply-button?]}]
(defn date-range-field [{:keys [value id]}]
[:div {:id id}
(com/field {:label "Date Range"}
[:div.space-y-4
@@ -17,7 +17,7 @@
(com/button-group-button {:size :small :value "week" :hx-trigger "click"} "Week")
(com/button-group-button {:size :small :value "month" :hx-trigger "click"} "Month")
(com/button-group-button {:size :small :value "year" :hx-trigger "click"} "Year"))]
[:div.flex.space-x-1.items-baseline.w-full.justify-start
[:div.flex.space-x-1.items-baseline.w-full.justify-start {"@change.stop" ""}
(com/date-input {:name "start-date"
:value (some-> (:start value)
(atime/unparse-local atime/normal-date))
@@ -31,9 +31,8 @@
:placeholder "Date"
:size :small
:class "shrink date-filter-input"})
(when apply-button?
(but/button- {:color :secondary
:size :small
:type "button"
"x-on:click" "$dispatch('datesApplied')"}
"Apply"))]])])
(but/button- {:color :secondary
:size :small
:type "button"
"x-on:click" "$dispatch('datesApplied')"}
"Apply")]])])

View File

@@ -51,23 +51,28 @@
{:x-init "$el.indeterminate = true"}))]))
(defn typeahead- [params]
[:div.relative {:x-data (hx/json {:baseUrl (str (:url params))
:value {:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}
:tippy nil
:search ""
:active -1
:elements (if ((:value-fn params identity) (:value params))
[{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}]
[])})
:x-modelable "value.value"
:x-model (:x-model params)}
[:div.relative (cond-> {:x-data (hx/json {:baseUrl (str (:url params))
:value {:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}
:tippy nil
:search ""
:active -1
:elements (if ((:value-fn params identity) (:value params))
[{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}]
[])})
:x-modelable "value.value"
:x-model (:x-model params)}
;; Key the component by its current value so alpine-morph re-initialises
;; it (rather than preserving stale Alpine x-data) whenever the *server*
;; changes the value -- e.g. the default account a vendor selection
;; populates. alpine-morph keys off the `key` attribute, not `id`.
(:id params) (assoc :key (str (:id params) "--" ((:value-fn params identity) (:value params)))))
(if (:disabled params)
[:span {:x-text "value.label"}]
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
(hh/add-class "cursor-pointer"))
"x-tooltip.on.click" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
"@keydown.down.prevent.stop" "tippy.show();"
"@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }"
"x-tooltip.on.click" "{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
"@keydown.down.prevent.stop" "tippy?.show();"
"@keydown.backspace" "tippy?.hide(); value = {value: '', label: '' }"
:tabindex 0
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
:x-ref "input"}
@@ -94,7 +99,7 @@
[:template {:x-ref "dropdown"}
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1"
"@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; "
"@keydown.escape" "$refs.input?.__x_tippy?.hide(); value = {value: '', label: '' }; "
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
[:input {:type "text"
:autofocus true
@@ -107,8 +112,8 @@
"@change.stop" ""
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
"@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input.focus()"
"x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}]
"@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input?.focus()"
"x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; $refs.input?.__x_tippy?.popperInstance?.update()}) }})"}]
[:div.dropdown-options {:class "rounded-b-lg overflow-hidden"}
[:template {:x-for "(element, index) in elements"}
[:li [:a {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100"
@@ -117,7 +122,7 @@
"@mouseover" "active = index"
"@mouseout" "active = -1"
"@click.prevent" "value = element; tippy.hide(); setTimeout(() => $refs.input.focus(), 10)"
"@click.prevent" "value = element; $refs.input?.__x_tippy?.hide(); setTimeout(() => $refs.input?.focus(), 10)"
"x-html" "element.label"}]]]
[:template {:x-if "elements.length == 0"}
[:li {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs "}
@@ -126,7 +131,7 @@
(defn multi-typeahead-dropdown- [params]
[:template {:x-ref "dropdown"}
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 ring-1 p-4"
"@keydown.escape.prevent" "tippy.hide();"
"@keydown.escape.prevent" "$refs.input?.__x_tippy?.hide();"
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
[:div {:class (-> "relative"
#_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))}
@@ -240,9 +245,9 @@
[:span {:x-text "value.label"}]
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
(hh/add-class "cursor-pointer"))
"x-tooltip.on.click.prevent" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
"@keydown.down.prevent.stop" "tippy.show();"
"@keydown.backspace" "tippy.hide(); value=new Set( []);"
"x-tooltip.on.click.prevent" "{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
"@keydown.down.prevent.stop" "$refs.input?.__x_tippy?.show();"
"@keydown.backspace" "$refs.input?.__x_tippy?.hide(); value=new Set( []);"
:tabindex 0
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
:x-ref "input"}
@@ -325,7 +330,7 @@
(-> params
(update :class (fnil hh/add-class "") default-input-classes)
(assoc :x-model "value")
(assoc "x-tooltip.on.focus" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}")
(assoc "x-tooltip.on.focus" "{content: ()=>($refs.tooltip?.innerHTML ?? ''), theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}")
(assoc :x-init "$nextTick(() => tippy = $el.__x_tippy); ")
(assoc :type "text")
@@ -333,7 +338,7 @@
(assoc "autocomplete" "off")
(assoc "@change" "value = $event.target.value;")
(assoc "@keydown.escape" "tippy.hide(); ")
(assoc "@keydown.escape" "$el?.__x_tippy?.hide(); ")
#_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") "))
(update :class #(str % (use-size size) " w-full"))
(dissoc :size))]

View File

@@ -56,7 +56,7 @@
[:div {:id "exact-match-id-tag"}]))
(defn filters [request]
[:form#invoice-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form#invoice-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
::route/import-table)
"hx-target" "#entity-table"

View File

@@ -53,7 +53,7 @@
[:div {:id "exact-match-id-tag"}]))
(defn filters [request]
[:form#payment-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form#payment-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
::route/table)
"hx-target" "#entity-table"

View File

@@ -29,7 +29,7 @@
default-grid-fields-schema)]))
(defn filters [params]
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
:pos-cash-drawer-shift-table)
"hx-target" "#cash-drawer-shift-table"

View File

@@ -34,7 +34,7 @@
default-grid-fields-schema)]))
(defn filters [request]
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
:pos-expected-deposit-table)
"hx-target" "#expected-deposit-table"

View File

@@ -29,7 +29,7 @@
[:client {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :client/name]}]]]]
default-grid-fields-schema)]))
(defn filters [request]
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
:pos-refund-table)
"hx-target" "#refund-table"

View File

@@ -34,7 +34,7 @@
default-grid-fields-schema)]))
(defn filters [request]
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
:pos-sales-table)
"hx-target" "#sales-table"

View File

@@ -44,7 +44,7 @@
default-grid-fields-schema)]))
(defn filters [request]
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
::route/table)
"hx-target" "#entity-table"

View File

@@ -22,7 +22,7 @@
;; always should be fast
(defn filters [request]
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
:pos-tender-table)
"hx-target" "#tender-table"

View File

@@ -0,0 +1,43 @@
(ns auto-ap.ssr.selmer
"Selmer rendering + the Hiccup<->Selmer interop bridge for the SSR form/wizard
migration (see .claude/skills/ssr-form-migration). Interactive, attribute-heavy
components render from Selmer templates with plain-HTML Alpine/HTMX attributes;
the bridge lets a Selmer template embed Hiccup output and lets a Selmer fragment
sit inside a Hiccup tree during the strangler transition.
Templates live under resources/templates/ and are referenced by classpath-relative
path, e.g. (render \"templates/components/typeahead.html\" ctx)."
(:require
[hiccup.util :as hu]
[hiccup2.core :as h2]
[selmer.parser :as selmer]))
(defn hiccup->html
"Render a Hiccup form to an HTML string so it can be embedded in a Selmer
context value and emitted with the |safe filter: {{ frag|safe }}."
[hiccup]
(str (h2/html {} hiccup)))
(defn raw
"Wrap an already-rendered HTML string (e.g. from `render`) so hiccup2 emits it
verbatim instead of escaping it. Use to drop a Selmer fragment into a Hiccup tree:
[:div (sel/raw (sel/render \"...\" ctx))]."
[^String html]
(hu/raw-string html))
(defn render
"Render a Selmer template file (classpath-relative path) with `ctx`, returning an
HTML string. Hiccup values in `ctx` should be pre-rendered via `hiccup->html` and
referenced with |safe in the template."
[template ctx]
(selmer/render-file template ctx))
(defn render-str
"Render a Selmer template given as a string (handy for tests/REPL)."
[template ctx]
(selmer/render template ctx))
(defn render->hiccup
"Render a Selmer template file and wrap the result for safe embedding in Hiccup."
[template ctx]
(raw (render template ctx)))

View File

@@ -316,7 +316,7 @@
:content (:bank-account/name ba)}))}))))])
(defn filters [request]
[:form#transaction-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form#transaction-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
::route/table)
"hx-target" "#entity-table"

View File

@@ -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,12 +38,15 @@
[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=]]
[iol-ion.tx :refer [random-tempid]]
[malli.core :as mc]))
(declare render-full-form)
(def transaction-approval-status
{:transaction-approval-status/unapproved "Unapproved"
:transaction-approval-status/approved "Approved"
@@ -82,6 +87,7 @@
[:transaction/vendor {:optional true} [:maybe entity-id]]
[:transaction/approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
[:amount-mode {:optional true} [:maybe [:enum "$" "%"]]]
[:mode {:optional true} [:maybe [:enum "simple" "advanced"]]]
[:transaction/accounts {:optional true}
[:maybe
[:vector {:coerce? true}
@@ -150,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]}]
@@ -180,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)
@@ -201,50 +238,48 @@
(: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
(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-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
:hx-target "find *"
:hx-swap "outerHTML"}
(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 "#manual-coding-section"
:hx-target "#wizard-form"
:hx-select "#wizard-form"
:hx-swap "outerHTML"}
"Switch to advanced mode"]]]))
@@ -256,9 +291,10 @@
:advanced
:simple)))
(defn transaction-account-row* [{:keys [value client-id amount-mode total]}]
(defn transaction-account-row* [{:keys [value client-id amount-mode total index]}]
(com/data-grid-row
(-> {:class "account-row"
:id (str "account-row-" index)
:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
:accountId (fc/field-value (:transaction-account/account value))})
:data-key "show"
@@ -278,7 +314,9 @@
:x-model "accountId"}))))
(fc/with-field :transaction-account/location
(com/data-grid-cell
{}
{:id (str "account-location-" index)}
;; Selecting an account only affects this row's valid Location options, so the
;; change swaps just this cell -- nothing else needs to re-render.
(com/validated-field
{:errors (fc/field-errors)
:x-hx-val:account-id "accountId"
@@ -286,9 +324,11 @@
client-id (assoc :client-id client-id)))
:x-dispatch:changed "accountId"
:hx-trigger "changed"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
:hx-target "find *"
:hx-swap "outerHTML"}
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target (str "#account-location-" index)
:hx-select (str "#account-location-" index)
:hx-swap "outerHTML"
:hx-include "closest form"}
(location-select* {:name (fc/field-name)
:account-location (:account/location (cond->> (:transaction-account/account @value)
(nat-int? (:transaction-account/account @value)) (dc/pull (dc/db conn)
@@ -300,71 +340,29 @@
{}
(com/validated-field
{:errors (fc/field-errors)}
(if (= "%" amount-mode)
(com/text-input {:name (fc/field-name)
:class "w-16 account-amount-field"
:value (fc/field-value)
:type "number"
:step "0.01"})
(com/money-input {:name (fc/field-name)
(let [amount-attrs {:name (fc/field-name)
:id (str "account-amount-" index)
:class "w-16 account-amount-field"
:value (fc/field-value)})))))
:value (fc/field-value)
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
;; Typing an amount posts the whole form but swaps back only the
;; #account-totals tbody -- a sibling of the input-bearing rows, so
;; the amount input is never replaced and the caret survives.
:hx-target "#account-totals"
:hx-select "#account-totals"
:hx-swap "outerHTML"
:hx-trigger "keyup changed delay:300ms"
:hx-include "closest form"}]
(if (= "%" amount-mode)
(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 {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"
:class "account-remove-action"} svg/x))))
(defn- account-field-name [index field]
(str "step-params[transaction/accounts][" index "]["
(if (keyword? field)
(str (when (namespace field)
(str (namespace field) "/"))
(name field))
field)
"]"))
(defn transaction-account-row-no-cursor* [{:keys [account index client-id amount-mode total]}]
(com/data-grid-row
(-> {:class "account-row"
:x-data (hx/json {:show true
:accountId (:transaction-account/account account)})
:data-key "show"
:x-ref "p"}
hx/alpine-mount-then-appear)
(com/hidden {:name (account-field-name index :db/id)
:value (or (:db/id account) "")})
(com/data-grid-cell
{}
(com/validated-field
{}
(account-typeahead* {:value (:transaction-account/account account)
:client-id client-id
:name (account-field-name index :transaction-account/account)
:x-model "accountId"})))
(com/data-grid-cell
{}
(com/validated-field
{}
(location-select* {:name (account-field-name index :transaction-account/location)
:account-location (:account/location (cond->> (:transaction-account/account account)
(nat-int? (:transaction-account/account account)) (dc/pull (dc/db conn)
'[:account/location])))
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value (:transaction-account/location account)})))
(com/data-grid-cell
{}
(com/validated-field
{}
(if (= "%" amount-mode)
(com/text-input {:name (account-field-name index :transaction-account/amount)
:class "w-16 account-amount-field"
:value (:transaction-account/amount account)
:type "number"
:step "0.01"})
(com/money-input {:name (account-field-name index :transaction-account/amount)
:class "w-16 account-amount-field"
:value (:transaction-account/amount account)}))))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"
(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"
:hx-include "closest form"
:class "account-remove-action"} svg/x))))
(defn location-select [{{:keys [name account-id client-id value] :as qp} :query-params}]
@@ -402,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))))
@@ -449,53 +441,55 @@
:value amount-mode
:name "step-params[amount-mode]"
:orientation :horizontal
:hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode)
:hx-target "#account-grid-body"
: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"
:hx-include "closest form"}))
(com/data-grid-header {:class "w-16"})]}
(fc/cursor-map #(transaction-account-row* {:value %
:client-id (-> request :entity :transaction/client :db/id)
:amount-mode amount-mode
:total total}))
(com/data-grid-header {:class "w-16"})]
;; Totals live in their own <tbody id="account-totals"> so the amount
;; field refreshes them with a plain targeted swap, never swapping the
;; input-bearing rows above (which would drop the caret).
:footer-tbody
[:tbody {:id "account-totals"}
(com/data-grid-row {:class "account-total-row"}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
(com/data-grid-cell {:id "total"
:class "text-right"}
(account-total* request))
(com/data-grid-cell {}))
(com/data-grid-row {:class "account-balance-row"}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"])
(com/data-grid-cell {:id "balance"
:class "text-right"}
(account-balance* request))
(com/data-grid-cell {}))
(com/data-grid-row {:class "account-grand-total-row"}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TRANSACTION TOTAL"])
(com/data-grid-cell {:class "text-right"}
(format "$%,.2f" total))
(com/data-grid-cell {}))]}
(fc/cursor-map (fn [cursor]
(transaction-account-row* {:value cursor
:client-id (-> request :entity :transaction/client :db/id)
:amount-mode amount-mode
:total total
:index (last (cursor/path cursor))})))
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes
::route/edit-wizard-new-account)
:row-offset 0
:index (count (:transaction/accounts snapshot))
:tr-params {:hx-vals (hx/json {:client-id (:transaction/client snapshot)})}}
"New account")
(com/data-grid-row {:class "account-total-row"}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
(com/data-grid-cell {:id "total"
:class "text-right"
:hx-trigger "change from:closest form target:.amount-field"
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/account-total)
:hx-target "this"
:hx-swap "innerHTML"}
(account-total* request))
(com/data-grid-cell {}))
(com/data-grid-row {:class "account-balance-row"}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"])
(com/data-grid-cell {:id "total"
:class "text-right"
:hx-trigger "change from:closest form target:.amount-field"
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/account-balance)
:hx-target "this"
:hx-swap "innerHTML"}
(account-balance* request))
(com/data-grid-cell {}))
(com/data-grid-row {:class "account-grand-total-row"}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TRANSACTION TOTAL"])
(com/data-grid-cell {:class "text-right"}
(format "$%,.2f" total))
(com/data-grid-cell {})))))
(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-form-changed)
:hx-vals (hx/json {:op "new-account"})
:hx-target "#wizard-form"
:hx-select "#wizard-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:color :secondary}
"New account"))))))
(defn manual-coding-section*
"Renders the vendor field + account/location section for the manual tab.
@@ -509,10 +503,12 @@
(seq (:transaction/accounts snapshot)))
row-count (count all-accounts)]
[:div#manual-coding-section
(com/hidden {:name "mode" :value (name mode)})
(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-target "#manual-coding-section"
: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"
:hx-sync "this:replace"
:hx-include "closest form"}
@@ -528,17 +524,19 @@
:value (fc/field-value)
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})]))]
(if (= mode :simple)
[:div {:x-data (hx/json {:simpleAccountId
(let [av (-> (first all-accounts) :transaction-account/account)]
(if (map? av) (:db/id av) av))})}
(simple-mode-fields* request)]
(let [simple-account-id (let [av (-> (first all-accounts) :transaction-account/account)]
(if (map? av) (:db/id av) av))]
[:div {:x-data (hx/json {:simpleAccountId simple-account-id})}
(simple-mode-fields* request)])
[:div
(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 "#manual-coding-section"
:hx-target "#wizard-form"
:hx-select "#wizard-form"
:hx-swap "outerHTML"}
"Switch to simple mode"]])
(fc/with-field :transaction/accounts
@@ -547,69 +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
[:div#account-grid-body
(com/data-grid {:headers [(com/data-grid-header {} "Account")
(com/data-grid-header {:class "w-32"} "Location")
(com/data-grid-header {:class "w-16"}
(com/radio-card {:options [{:value "$" :content "$"}
{:value "%" :content "%"}]
:value new-mode
:name "step-params[amount-mode]"
:orientation :horizontal
:hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode)
:hx-target "#account-grid-body"
:hx-swap "outerHTML"
:hx-include "closest form"}))
(com/data-grid-header {:class "w-16"})]}
(map-indexed (fn [idx account]
(transaction-account-row-no-cursor*
{:account account
:index idx
:client-id (-> updated-request :entity :transaction/client :db/id)
:amount-mode new-mode
:total total}))
accounts)
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes
::route/edit-wizard-new-account)
:row-offset 0
:index (count accounts)
:tr-params {:hx-vals (hx/json {:client-id (:transaction/client snapshot)})}}
"New account")
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
(com/data-grid-cell {:id "total"
:class "text-right"}
(format "$%,.2f" (double (reduce + 0.0 (map :transaction-account/amount accounts)))))
(com/data-grid-cell {}))
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"])
(com/data-grid-cell {:id "total"
:class "text-right"}
(let [account-total (double (reduce + 0.0 (map :transaction-account/amount accounts)))
balance (- total account-total)]
[:span {:class (when-not (dollars= 0.0 balance)
"text-red-300")}
(format "$%,.2f" (double balance))]))
(com/data-grid-cell {}))
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TRANSACTION TOTAL"])
(com/data-grid-cell {:class "text-right"}
(format "$%,.2f" total))
(com/data-grid-cell {})))])))
;; 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
@@ -617,7 +568,7 @@
[:div.space-y-3
[:div
[:div.text-xs.font-medium.text-gray-500 "Amount"]
[:div.text-sm.font-medium.text-gray-900 (format "$%,.2f" (Math/abs (:transaction/amount tx)))]]
[:div.text-sm.font-medium.text-gray-900 (format "$%,.2f" (Math/abs (or (:transaction/amount tx) 0.0)))]]
[:div
[:div.text-xs.font-medium.text-gray-500 "Date"]
[:div.text-sm.text-gray-900 (some-> tx :transaction/date coerce/to-date-time (atime/unparse-local atime/normal-date))]]
@@ -834,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?"}
@@ -903,8 +856,12 @@
{:label "Memo"
:errors (fc/field-errors)}
[:div.w-96
;; Memo affects nothing else, so it issues no request at all -- its
;; value just rides along in the form (posted with the next dependent
;; change, and merged into the snapshot on save).
(com/text-input {:value (-> (fc/field-value))
:name (fc/field-name)
:id "edit-memo"
:error? (fc/field-errors)
:placeholder "Optional note"})]))
[:div {:x-data (hx/json {:activeForm (if (:transaction/payment (:entity request))
@@ -1255,7 +1212,10 @@
[{:as request
transaction :entity
:keys [multi-form-state]}]
(let [tx-data (-> multi-form-state :snapshot (dissoc :action))
(let [;; :mode is a UI-only field (simple/advanced); :action/:amount-mode are control
;; fields. None are Datomic attributes, so strip them before building the upsert
;; (otherwise :upsert-transaction fails with :db.error/not-an-entity :mode).
tx-data (-> multi-form-state :snapshot (dissoc :action :mode))
tx-id (:db/id tx-data)
client-id (->db-id (:transaction/client tx-data))
existing-tx (d-transactions/get-by-id tx-id)
@@ -1381,14 +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))))
: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]
@@ -1403,34 +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 edit-vendor-changed-handler [request]
(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 apply-vendor-changed [request]
(let [multi-form-state (:multi-form-state request)
snapshot (:snapshot multi-form-state)
step-params (:step-params multi-form-state)
@@ -1439,7 +1431,7 @@
"simple"))
client-id (or (:transaction/client snapshot)
(-> request :entity :transaction/client :db/id))
vendor-id (or (:transaction/vendor step-params)
vendor-id (or (->db-id (:transaction/vendor step-params))
(->db-id (get step-params "transaction/vendor"))
(:transaction/vendor snapshot))
total (Math/abs (or (-> request :entity :transaction/amount)
@@ -1466,18 +1458,15 @@
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
:transaction-account/location (or (:account/location default-account) "Shared")
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
default-account (assoc :transaction-account/account (:db/id default-account)))]
default-account (assoc :transaction-account/account (:db/id default-account)))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
(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
(fc/start-form (:multi-form-state render-request) nil
(fc/with-field :step-params
(manual-coding-section* mode 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"))
@@ -1493,7 +1482,9 @@
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
(vec accounts))
(assoc-in [:multi-form-state :step-params :transaction/accounts]
(vec accounts))))
(vec accounts))
(assoc-in [:multi-form-state :step-params :mode]
(name target-mode))))
;; advanced→simple: take first row only
(let [first-row (first (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot))))]
@@ -1501,34 +1492,86 @@
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
(if first-row [first-row] []))
(assoc-in [:multi-form-state :step-params :transaction/accounts]
(if first-row [first-row] [])))))]
(if first-row [first-row] []))
(assoc-in [:multi-form-state :step-params :mode]
(name target-mode)))))]
render-request))
(defn apply-new-account
"edit-form-changed op: append a fresh account row."
[request]
(let [snapshot (-> request :multi-form-state :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)}
;; 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))]
updated-request))
(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)
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))))
accounts)
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
(fc/start-form (:multi-form-state render-request) nil
(fc/with-field :step-params
(manual-coding-section* target-mode render-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]
@@ -1536,46 +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/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 (->
(add-new-entity-handler [:step-params :transaction/accounts]
(fn render [cursor request]
(let [snapshot (-> request :multi-form-state :snapshot)
amount-mode (or (:amount-mode snapshot) "$")
total (Math/abs (or (:transaction/amount snapshot) 0.0))]
(transaction-account-row*
{:value cursor
:client-id (:client-id (:query-params request))
:amount-mode amount-mode
:total total})))
(fn build-new-row [base _]
(assoc base :transaction-account/location "Shared")))
(wrap-schema-enforce :query-schema [:map
[:client-id {:optional true}
[:maybe entity-id]]]))
::route/edit-form-changed (-> edit-form-changed-handler
(wrap-derive-state)
(mm/wrap-wizard edit-wizard)
(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)))))

View File

@@ -5,13 +5,13 @@
[hiccup2.core :as hiccup]
[auto-ap.ssr.components :as com]))
(defn html-page [hiccup]
{:status 200
{:status 200
:headers {"Content-Type" "text/html"}
:body (str
"<!DOCTYPE html>"
(hiccup/html
{}
hiccup))})
:body (str
"<!DOCTYPE html>"
(hiccup/html
{}
hiccup))})
(defn base-page [request contents page-name]
(html-page
@@ -28,7 +28,7 @@
[:script {:src "https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-tooltip@1.x.x/dist/cdn.min.js" :defer true}]
[:link {:rel "stylesheet" :href "/css/tippy/tippy.css"}]
[:link {:rel "stylesheet" :href "/css/tippy/light.css"}]
[:script {:src "/js/htmx.js"
[:script {:src "/js/htmx.js"
:crossorigin= "anonymous"}]
[:script {:src "https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"}]
@@ -41,7 +41,7 @@
[:script {:src "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js" :integrity "sha512-CQBWl4fJHWbryGE+Pc7UAxWMUMNMWzWxF4SQo9CgkJIN1kx6djDQZjh3Y8SZ1d+6I+1zze6Z7kHXO7q3UyZAWw==" :crossorigin "anonymous" :referrerpolicy "no-referrer"}]
[:script {:src "https://unpkg.com/dropzone@5.9.3/dist/min/dropzone.min.js" :defer true}]
[:link {:rel "stylesheet" :href "https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" :type "text/css" :defer true}]
[:link {:rel "stylesheet" :href "https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" :type "text/css" :defer true}]
[:script {:defer true :src "/js/alpine-vals.js"}]
[:script {:defer true :src "https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-clipboard@2.x.x/dist/alpine-clipboard.js"}]
[:script {:defer true :src "https://cdn.jsdelivr.net/npm/@alpinejs/focus@3.x.x/dist/cdn.min.js"}]
@@ -92,14 +92,14 @@ input[type=number] {
"x-transition:leave-end" "!bg-opacity-0"}
[:div {:class "flex h-full w-full justify-stretch md:justify-center items-stretch md:items-center "
"x-trap.inert.noscroll" "open"
"x-trap.inert" "open"
"x-show" "open"
"x-transition:enter" "ease-out duration-300"
"x-trap.inert.noscroll" "open"
"x-trap.inert" "open"
"x-show" "open"
"x-transition:enter" "ease-out duration-300"
"x-transition:enter-start" "!bg-opacity-0 !translate-y-32"
"x-transition:enter-end" "!bg-opacity-100 !translate-y-0"
"x-transition:leave" "duration-300"
"x-transition:enter-end" "!bg-opacity-100 !translate-y-0"
"x-transition:leave" "duration-300"
"x-transition:leave-start" "!opacity-100 !translate-y-0"
"x-transition:leave-end" "!opacity-0 !translate-y-32"}
"x-transition:leave-end" "!opacity-0 !translate-y-32"}
[:div#modal-content.flex.items-center.justify-center {:class "md:p-12"}]]]]]]))

View File

@@ -9,15 +9,29 @@
(with-open [s (ServerSocket. 0)]
(.getLocalPort s)))
(defn- mcp-repl-task [& _args]
"Start nREPL server and HTTP server on random ports.
(defn- read-port [path]
"Read a previously-recorded port from `path`, or nil if missing/unparseable."
(let [f (io/file path)]
(when (.exists f)
(try (Integer/parseInt (.trim ^String (slurp f)))
(catch Exception _ nil)))))
Writes ports to nrepl-port and .http-port files.
Connect with: clj-nrepl-eval -p $(cat nrepl-port)"
(let [nrepl-port (available-port)
http-port (available-port)]
(spit "nrepl-port" (str nrepl-port))
(spit ".http-port" (str http-port))
(defn- stable-port [path]
"Reuse the port recorded in `path` if present, otherwise pick a random
available one. Always (re)writes the file so the port stays stable for this
worktree across REPL restarts and reloads."
(let [port (or (read-port path) (available-port))]
(spit path (str port))
port))
(defn- mcp-repl-task [& _args]
"Start nREPL server and HTTP server.
Reuses the ports recorded in nrepl-port and .http-port if present (keeping
them stable per worktree), otherwise picks random available ports and records
them. Connect with: clj-nrepl-eval -p $(cat nrepl-port)"
(let [nrepl-port (stable-port "nrepl-port")
http-port (stable-port ".http-port")]
(println (format "nREPL port: %d (nrepl-port)" nrepl-port))
(println (format "HTTP port: %d (.http-port)" http-port))
(nrepl/start-server :port nrepl-port)

View File

@@ -28,13 +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-wizard-new-account" ::edit-wizard-new-account
"/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode
"/edit-form-changed" ::edit-form-changed
"/match-payment" ::link-payment
"/match-autopay-invoices" ::link-autopay-invoices
"/match-unpaid-invoices" ::link-unpaid-invoices

View File

@@ -0,0 +1,36 @@
(ns auto-ap.ssr.selmer-test
(:require
[auto-ap.ssr.selmer :as sut]
[clojure.string :as str]
[clojure.test :refer [deftest is testing]]
[hiccup2.core :as h2]))
(deftest hiccup->html
(testing "renders a Hiccup form to an HTML string"
(is (= "<span class=\"label\">A &amp; B</span>"
(sut/hiccup->html [:span.label "A & B"])))))
(deftest selmer-embeds-hiccup
(testing "a Hiccup component renders inside a Selmer template via |safe"
(let [frag (sut/hiccup->html [:span.badge "from hiccup"])
out (sut/render-str "<div>{{frag|safe}}</div>" {:frag frag})]
(is (str/includes? out "<span class=\"badge\">from hiccup</span>"))
;; without |safe the markup would be escaped; |safe keeps it verbatim
(is (not (str/includes? out "&lt;span"))))))
(deftest selmer-fragment-inside-hiccup
(testing "a Selmer fragment renders inside a Hiccup tree without double-escaping"
(let [sel (sut/render-str "<a href=\"{{url}}\">{{label}}</a>" {:url "/x" :label "Go"})
out (str (h2/html {} [:div (sut/raw sel)]))]
(is (= "<div><a href=\"/x\">Go</a></div>" out)))))
(deftest render-file-from-classpath
(testing "render-file resolves a template under resources/templates and keeps plain-HTML Alpine/HTMX attrs"
(let [out (sut/render "templates/interop-smoke.html"
{:title "Interop OK"
:hiccup_frag (sut/hiccup->html [:span.badge "from hiccup"])})]
(is (str/includes? out "Interop OK"))
(is (str/includes? out "from hiccup"))
;; plain-HTML attributes (the whole point of Selmer) survive unambiguously
(is (str/includes? out "x-model=\"value.value\""))
(is (str/includes? out "tippy?.show()")))))

View File

@@ -100,6 +100,9 @@
{:db/id "vendor-id"
:vendor/name "Test Vendor"
:vendor/default-account "account-id"}
{:db/id "vendor-id-2"
:vendor/name "Second Vendor"
:vendor/default-account "account-id-2"}
(test-transaction :db/id "transaction-id"
:transaction/client "client-id"
:transaction/bank-account "bank-account-id"
@@ -166,7 +169,8 @@
:second-account (get tempids "account-id-2")
:fixed-location-account (get tempids "account-id-fixed-loc")
:ap-account (get tempids "ap-account-id")
:vendor (get tempids "vendor-id")})
:vendor (get tempids "vendor-id")
:vendor2 (get tempids "vendor-id-2")})
(reset! test-client-ids
{:test (get tempids "client-id")
:test2 (get tempids "client-id-2")})