Files
integreat/.claude/skills/ssr-form-migration/reference/component-cookbook.md
Bryce c892719bd1 feat(ssr): migrate location-select to a Selmer template (Phase 2 — Selmer validated)
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).
2026-06-03 17:28:53 -07:00

152 lines
7.1 KiB
Markdown

# 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?`.
```clojure
(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 `accountId` into row Alpine scope;
- **location cell** swaps *only itself* (`#account-location-<index>`) on `changed`
(swap-doctrine Rule 2);
- **amount cell** swaps *only* `#account-totals` (Rule 4, sibling tbody);
- **remove** swaps the whole form (Rule 3).
```clojure
(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).
```clojure
(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
```clojure
(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
```clojure
(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).
```clojure
;; 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`).
```clojure
(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
```clojure
(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.