Files
integreat/.claude/skills/ssr-form-migration/reference/component-cookbook.md
Bryce 599b849e6f refactor(ssr): Phase 4 — full Selmer migration of POS Sales Summary; remove the wizard; fix add-item + totals
Migrates the POS Sales Summary edit modal off the wizard to a plain Selmer form,
building on the parity gate committed earlier. Largest migration so far and the
first with no prior test coverage.

What changed
- Wizard removed: deleted MainStep/EditWizard records, MultiStepFormState, the
  step-params[...] prefix, the EDN snapshot round-trip, and all mm/* middleware.
  Replaced with a plain handler + flat wrap-decode/wrap-derive-state. The 51 fc/
  cursor refs are de-cursored into explicit data + Selmer templates.
- db/id-keyed item merge: wrap-derive-state overlays posted items onto the
  persisted items by :db/id, so read-only fields the form doesn't post
  (ledger-side, amount) survive a re-render and the debit/credit split + totals
  stay correct. New manual rows (temp db/id) ride through as-is.
- Inline click-to-edit account cell preserved as three small targeted
  .account-cell-swap routes (edit/save/cancel-item-account), ported to Selmer
  with the new field-name scheme.
- 100% Selmer modal render path (the remaining Hiccup / hx-swap-oob / "hx-"
  strings are all grid-page code — grid render lambdas, the filters form, and the
  submit response-header map — not the modal).
- Routes: dropped edit-wizard-navigate + new-summary-item; added form-changed.

Fixes (two pre-existing bugs, per request)
- "New Summary Item" add button (was throwing `newRowIndex is not defined` and
  targeting a non-existent `.new-row`) is now a whole-form-swap op=new-item that
  adds an editable manual row (category + account typeahead + debit/credit money
  inputs + remove).
- The dead totals/balance display (malformed Hiccup that discarded its labels) is
  replaced by a proper #summary-totals block showing running Total +
  Balanced/Unbalanced, refreshed via a Rule-4 targeted swap on manual amount edits.

Scorecard delta (pos/sales_summaries.clj): LOC 790->732, mm coupling 20->0,
wizard records 4->0, fc/ cursor 51->0, step-params 27->0 (2 comments), modal
routes 8->6. (hx-swap-oob 1 and mixed-hx live in the grid page, not the modal.)

Verification: sales-summary spec 7/7 (incl. the two fixes); full Playwright suite
46/46; cljfmt clean. Skill fed: scorecard row + narrative; gotchas (parity-gate-
first, characterize-then-fix, keyup-trigger tests); cookbook (inline click-to-edit
cell, db/id-keyed item merge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:13:19 -07:00

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

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

inline click-to-edit cell (Phase 4) — targeted .account-cell swap, not a whole-form op

A "display value + pencil → edit-in-place → check/cancel" cell. Three tiny stateless routes, each swapping just the cell (hx-target="closest .account-cell", outerHTML): a display cell (value + pencil hx-get edit), an edit cell (typeahead + check hx-put save / cancel hx-get cancel). State rides in the request (item index + current value via hx-vals), so no server-side "which cell is editing" flag is needed. Keep it as its own routes — it is a distinct feature, not folded into the whole-form form-changed dispatcher (that would lose the targeted swap and re-render the whole modal on every pencil click). The cells are assembled with sc/* + sel/raw strings (like edit.clj's footer*); SVGs ride in as svg/* Hiccup via the sc/a-icon-button body (no [:svg] literal lands in the modal file).

db/id-keyed item merge (Phase 4) — for rows the form posts only partially

When a row renders some fields read-only (so they aren't posted) but the entity holds them (sales-summary auto items post only db/id/category/account — not ledger-side/amount), the flat wrap-derive-state must overlay posted items onto the persisted items by :db/id so the unposted fields survive a re-render: (merge (by-id (:db/id posted)) posted). New rows (temp :db/id not in the entity) ride through as-is. This is the row-level analog of edit's "entity-only fields always from the entity"; without it, a re-render drops ledger-side/amount and the debit/credit split + totals break. | 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).