Migrate every part of the Transaction Edit modal's HTML to Selmer templates
(zero Hiccup in the render path) and delete the mm multi-modal "wizard"
abstraction entirely -- there was only ever one step.
- New auto-ap.ssr.components.selmer (sc) + ~22 shared component partials under
resources/templates/components/ (typeahead, button-group, radio-card,
data-grid, validated-field, modal, buttons, inputs, SVGs). Each wrapper renders
its own partial; dynamic HTMX/Alpine attrs bridge via attrs->str -> {{attrs|safe}}.
- 15 modal templates under resources/templates/transaction-edit/.
- Delete EditWizard/LinksStep records + all mm/* usage. Plain handlers: flat
wrap-decode-edit (fields renamed off step-params[...], stray keys stripped),
flat wrap-derive-state, *errors*-based field errors, generic wrap-form-4xx-2.
- Drop the edit-wizard-navigate route (routes ~12 -> 5).
- Fix: stray `method` (tab button-group hidden) leaked into the upsert -> 500;
strip decoded map to schema keys.
- e2e selectors updated (#wizard-form->#edit-form, #wizardmodal->#editmodal,
step-params[...] field names). Parity: swap 6/6, edit 8/8, suite 38/1
(1 pre-existing unrelated nav test).
- ssr-form-migration skill updated with the learnings (composition mechanics,
sc/* library, drop-the-wizard recipe, scorecard row, 3 new gotchas).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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).
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.
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.
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).
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.
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.
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.
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.
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>
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>
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>
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>
When a transaction is pre-coded, the snapshot stores :transaction-account/account
as a Datomic ref map {:db/id N} rather than a bare integer. simple-mode-fields*
and the simpleAccountId Alpine initializer both need the integer id, not the map,
to correctly populate the account typeahead value and the x-hx-val binding.
- Add vendor-changed HTMX handlers for both bulk code and individual edit
- Pre-populate default account at 100% when vendor is selected and no accounts exist
- Fix render-accounts-section to render from step-params correctly
- Change bulk code vendor-changed from hx-get to hx-post to include form data
- Add routes for vendor-changed endpoints
- Update e2e tests to cover vendor pre-population
- Run lein cljfmt fix across codebase
The format specifier $%,.2f requires floating-point values but
(reduce + 0 ...) can return a Long when all amounts sum to an integer.
Added explicit (double ...) casts and changed initial value to 0.0
to ensure the format call always receives a double.
The previous attempts to set up form cursor context in toggle-amount-mode
were failing because the cursor library's dynamic binding model is complex
and requires specific initialization through fc/start-form.
Instead of trying to recreate the cursor context, this fix:
1. Creates transaction-account-row-no-cursor* that renders rows with explicit
field names and values (no cursor functions)
2. Rewrites toggle-amount-mode to directly construct the data-grid HTML
using map-indexed over the accounts vector
3. Removes the broken manual cursor binding attempts
4. Removes unused auto-ap.cursor import
This ensures the toggle handler works independently of the wizard's cursor
context while still producing identical HTML output.
The toggle-amount-mode handler was failing because account-grid-body*
uses fc/cursor-map which requires the form cursor context to be set up.
Added manual cursor binding in toggle-amount-mode to create a cursor
pointing to the transaction/accounts vector and bind it to fc/*current*
before rendering the grid.
The assoc-in call had too many arguments, so the request state wasn't being
updated with the new mode or converted accounts. Using -> threading with
separate assoc-in calls ensures both the accounts and mode are properly set
before re-rendering the grid.
- Fix Math/abs nil error when adding new accounts by using (or value 0.0)
- Fix Math/abs nil in account-grid-body* and save-handler for safety
- Make $/% radio toggle display side-by-side using :orientation :horizontal
- Apply fixes to edit-wizard-new-account render, account-grid-body*, and save-handler
When editing a transaction manually, users can now toggle between viewing
account amounts as dollar values or percentages. The toggle appears in the
table header as a radio button group ($ / %).
Key features:
- Global toggle switches all accounts simultaneously
- $→%: amounts are converted to percentages of the transaction total
- %→$: uses percentages->dollars with spread-cents for accurate cent distribution
- Form state preserves vendor, memo, approval status when toggling
- Save handler converts % back to $ before persisting to Datomic
- Uses HTMX to re-render only the account grid body on toggle
New route: /toggle-amount-mode
New functions: ->percentage, percentages->dollars, convert-accounts-mode,
account-grid-body*, toggle-amount-mode
When a transaction is saved via the edit modal, return the updated row
HTML with {:flash? true} instead of an empty div. This makes the row
flash with the live-added animation, matching the behavior of other
pages like invoices and accounts.
Uses hx-retarget to swap the specific row in the table while also
triggering modalclose to close the modal.
When a transaction account had 'Shared' location, spread-account was
creating multiple accounts with the same :db/id but different locations,
causing a datoms-conflict error in Datomic.
Now generates unique tempids for all spread accounts beyond the first,
preventing entity ID collisions while preserving the original account's
tempid for the first location.
Fixes: Two datoms in the same transaction conflict on :transaction-account/location
- Add require-approval schema validation for :manual action
- Fix keyword comparison to use :transaction-approval-status/approved
- Move require-approval function before schema definition
- Also fix save handler validation to use correct keyword