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.
86 lines
3.5 KiB
Markdown
86 lines
3.5 KiB
Markdown
# 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.
|