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