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.
3.5 KiB
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
(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
;; 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):
;; 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:
(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)
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.