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>
206 lines
12 KiB
Markdown
206 lines
12 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.
|
|
|
|
---
|
|
|
|
## 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).
|