Migrates the Transaction Bulk Code modal (a single-step form wearing a full wizard costume) to a plain Selmer form, cold-applying the ssr-form-migration skill. Almost entirely reuse of the Phase-2 work: the whole `sc/*` Selmer component library, `account-typeahead*` / `location-select*`, and the `edit-modal` / `transitioner` chrome are imported wholesale. What changed - Wizard removed: deleted `BulkCodeWizard` / `AccountsStep` records, `MultiStepFormState`, the `step-params[...]` prefix, and all `mm/*` middleware. Replaced with a plain handler + flat `wrap-bulk-state` (decode straight into `bulk-code-schema`, no snapshot round-trip). - Selection round-trip: the non-editable transaction selection is resolved to a concrete not-locked id vector at open and ridden back in hidden `ids[]` fields (the bulk analog of edit's single `db/id`) — no EDN snapshot, no filter re-query, and more correct (codes exactly the rows the user saw). - 100% Selmer render path (only the shared terminal `com/success-modal` keeps Hiccup — heuristic-9 exception). New shared component `sc/select` (`location-select.html` generalized) for the status dropdown. - Routes 4 -> 3: GET `bulk-code` (open), POST `bulk-code-submit`, POST `bulk-code-form-changed` (one whole-form op dispatcher folding the old `new-account` + `vendor-changed` routes). Location swap moved off `find *` onto explicit `#account-location-<index>` + `hx-select`. - Fixed a latent correctness bug surfaced by the migration: the vendor typeahead needs `:id` (value-keyed `:key`) or its value-bound hidden goes stale across a whole-form swap and posts blank. Scorecard delta (transaction/bulk_code.clj): mm coupling 19->0, snapshot merges 4->0, wizard records 3->0, step-params 10->0, routes 4->3, OOB 0, Hiccup-in-render ->0 (bar success-modal). LOC 420->506 (documented exception: the wizard was a thin shell over mm/* defaults, so explicitness moves shared plumbing into the file). Cookbook: reused the entire Phase-2 sc/* lib + chrome, added sc/select. Verification: bulk-code-transactions.spec.ts 13/13; full Playwright suite 39/39; cljfmt clean. Skill fed: scorecard row + narrative + LOC exception; gotchas (value-bound typeahead keying, selection-as-ids round-trip); cookbook (sc/select). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
10 KiB
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?.
(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
accountIdinto row Alpine scope; - location cell swaps only itself (
#account-location-<index>) onchanged(swap-doctrine Rule 2); - amount cell swaps only
#account-totals(Rule 4, sibling tbody); - remove swaps the whole form (Rule 3).
(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).
(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
(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
(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).
;; 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).
(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
(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.
The Selmer component library (auto-ap.ssr.components.selmer / sc) — Phase 2-final
Every shared component the modal renders through is now a thin Clojure wrapper over a
partial under resources/templates/components/. Reuse these before reaching for the
Hiccup com/* versions in a migrated modal. Each wrapper builds a context (reusing the
real class helpers so output matches modulo Tailwind order) and renders its own partial via
the interop bridge; dynamic HTMX/Alpine attrs go through sc/attrs->str →
{{ attrs|safe }}. See selmer-conventions.md for the mechanics.
| Wrapper | Partial | Notes |
|---|---|---|
sc/hidden / sc/text-input / sc/money-input |
hidden/text-input/money-input.html |
leaf inputs; class via inputs/default-input-classes + use-size |
sc/select (Phase 3) |
select.html |
generic <select>; options [[value label] …], :value (string/keyword) marks selected, extra hx-/x- attrs ride through. location-select.html generalized — reach for this before com/select. Added for the bulk-code status field. |
sc/validated-field |
validated-field.html |
label + body + always-present error <p>; pass-through attrs land on the wrapping div (the per-row location cell hangs its swap wiring here) |
sc/button / sc/a-button / sc/a-icon-button |
button/a-button/a-icon-button.html |
spinner via {% include "spinner.html" %}; class via btn/bg-colors |
sc/badge / sc/link |
badge/link.html |
|
sc/button-group / sc/button-group-button |
button-group(+button).html |
the group does not mutate children's classes (the Hiccup group- added rounded-l/r) — add rounding in the caller/template (tabs do) |
sc/radio-card |
radio-card.html |
reproduces the select-keys [:hx-post :hx-target :hx-swap :hx-include :hx-trigger] filter (drops :hx-vals/:hx-select) and the dangling-[:h3] quirk: only the <ul> renders |
sc/data-grid (+ -header/-row/-cell) |
data-grid*.html |
table shell + optional footer-tbody (the swappable totals tbody) |
sc/typeahead |
typeahead.html |
full Alpine + tippy; resolves {value,label} server-side via content-fn; every tippy?. null-guard preserved; hidden posting <input> with :value="value.value" + the x-init watcher |
sc/modal |
modal.html |
the @click.outside="open=false" wrapper |
| SVGs | spinner/svg-x/svg-external-link/svg-drop-down.html |
static, {% include %}d so the markup isn't duplicated |
Modal-specific structure lives under resources/templates/transaction-edit/
(edit-form, edit-modal, links-body, manual-coding, simple-mode, account-totals,
details-panel, the four match panels, transitioner). The render fns in edit.clj
gather data, call sc/*, and interpolate the fragments into these layout templates as
{{ frag|safe }}. Verify each wrapper by class-set equality + e2e, never byte-parity
(hh/add-class is set-based, so class order differs from the Hiccup output).