diff --git a/.claude/skills/ssr-form-migration/SKILL.md b/.claude/skills/ssr-form-migration/SKILL.md new file mode 100644 index 00000000..2588b9fe --- /dev/null +++ b/.claude/skills/ssr-form-migration/SKILL.md @@ -0,0 +1,122 @@ +--- +name: ssr-form-migration +description: Migrate an SSR form or wizard modal to the whole-form HTMX swap doctrine, top-rooted render functions, the data-driven session-backed wizard engine, and (where it helps) Selmer templates. Use when simplifying a server-rendered form/wizard modal in this codebase, removing EDN-snapshot round-trips, deleting *-no-cursor* duplicate render fns, collapsing per-interaction routes, or replacing the multi-step wizard protocol machinery. +--- + +# SSR Form & Wizard Migration + +A repeatable method for making a server-rendered form/wizard modal **simpler** without +changing user-facing behavior. Distilled from the first proven migration — the +`transaction/edit.clj` modal, which already runs on the whole-form `hx-select` swap +approach with **zero out-of-band swaps**. Every migration *reads this skill first* and +*extends it last* (the Growth contract below). If migration N+1 is not easier than N, +the skill-update step was skipped — treat that as a bug. + +The four patterns every migration moves code toward live in `reference/`: + +- `reference/swap-doctrine.md` — the four-rule HTMX swap priority order + the focus + invariant + Alpine-survives-swap hardening + target-selector strategy. +- `reference/render-functions.md` — one render fn per component, taking explicit data + **or a top-rooted cursor**; no faked cursor positions, no `*-no-cursor*` twins. +- `reference/form-vs-wizard.md` — single-step → plain form; multi-step → data-driven + engine with **per-step state in the Ring session** (the Django `formtools` model). +- `reference/selmer-conventions.md` — plain-HTML attributes via Selmer, the + Hiccup↔Selmer interop bridge, include/block patterns. + +Growing cookbooks (append every migration): +`component-cookbook.md`, `gotchas.md`, `test-recipes.md`, `scorecard.md`. + +--- + +## The per-migration playbook + +Run this loop for each modal. The phase notes in the migration plan list only what is +*specific* to a modal; this loop is the constant. + +1. **Read the skill.** Skim `reference/` and note which `component-cookbook.md` + entries and `gotchas.md` you can reuse. Start from the cookbook, not a blank file. + +2. **Classify** (`reference/form-vs-wizard.md`). + - Single logical step (even with a `?mode=` toggle or add/remove rows) → **plain + form**: no server-side wizard state, no snapshot, no protocol. + - Genuinely multiple steps the user advances through → **wizard**: the data-driven + engine + per-step session storage. + - When in doubt, it's a form. + +3. **Baseline the scorecard** (`scorecard.md`, heuristics in §6 of the plan). Record + before-numbers with cheap tools: + ```bash + F=src/clj/auto_ap/ssr/.clj + wc -l $F # LOC (heuristic 4) + grep -c 'defn.*-no-cursor' $F # *-no-cursor* twins (heuristic 1) + grep -cE 'with-cursor|MapCursor\.' $F # faked cursor re-roots (heuristic 1) + grep -c 'hx-swap-oob' $F # OOB swaps (heuristic 7) + grep -cE '"hx-[a-z]' $F # mixed string hx- attrs (heuristic 8) + # route count: count this modal's entries in src/cljc/auto_ap/routes/*.cljc + ``` + +4. **Characterize behavior (test-first).** Write or confirm a Playwright spec that + captures *current* behavior before you touch anything — focus/caret survival across + swaps, each field round-trip, validation errors, and the real save. This spec is the + parity contract; it must stay green through every commit. See `test-recipes.md`. + +5. **Consolidate render functions** (`reference/render-functions.md`). Make each render + fn take explicit data or a **top-rooted cursor**. Delete `*-no-cursor*` duplicates + and any `with-cursor`/`MapCursor` rebind that fakes a deep starting position + (heuristics 1, 2). Using a cursor is fine; faking where it *starts* is not. + +6. **Templatize in Selmer** (`reference/selmer-conventions.md`) where the component is + interactive/attribute-heavy. Reuse cookbook bits; add new ones back (heuristics 5, 8). + Static markup may stay Hiccup — Selmer scope is hybrid (Open decision 2). + +7. **Wire HTMX per the swap doctrine** (`reference/swap-doctrine.md`). Keep the focus + invariant intact. No OOB except a genuinely disjoint region, documented (heuristic 7). + +8. **Collapse routes** to 2 (`GET` open, `POST` submit) — `+1` for an add-row endpoint, + `+1` for the single `*-form-changed` whole-form re-render endpoint (heuristic 6). + +9. **Verify.** Modal e2e green + full e2e suite at-or-above baseline; assert DB + mutations by querying Datomic, not markup; REPL-check the pure render/data fns. + Re-measure the scorecard — **no metric may regress for the touched modal** without a + written exception in `gotchas.md`. + +10. **Commit** one reversible feature commit. The message includes the scorecard delta + and the reused/new cookbook entries. + +11. **Feed the skill** (the Growth contract). *Not optional.* + +--- + +## Growth contract — the last task of every migration + +- Converted a component? → add its before/after to `component-cookbook.md`. +- Hit a surprise? → one entry in `gotchas.md`. +- Found a test pattern? → `test-recipes.md`. +- Playbook step missing or wrong? → fix this `SKILL.md`. +- Measured the scorecard? → append the row to `scorecard.md`. + +**Success signal:** each migration reuses more cookbook entries and starts from a better +scorecard baseline than the previous one. + +--- + +## Non-negotiables + +- **Focus invariant:** the input the user is typing in is *never* inside the region its + own request swaps. Violating this drops the caret. (Proven by the + `transaction-edit-swap.spec.ts` caret tests.) +- **No new OOB swaps.** If tempted to OOB something inside the same feature, restructure + the DOM so the dependent element shares an ancestor with the trigger and use an + ordinary swap (e.g. totals in a sibling ``). +- **Behavior parity is proven by tests, not by reading.** The full e2e suite stays green + after every migration. +- **Don't game the heuristics.** They're directional evidence paired with the e2e parity + gate; review the trend, not single numbers. + +## Project conventions that bite (see `gotchas.md`) + +- Edit Clojure with the clojure-mcp tools (`clojure_edit`, `clojure_edit_replace_sexp`), + not the raw file editor. `clj-paren-repair` then `lein cljfmt fix` when a file won't + compile. +- Run tests via the `clojure-eval` skill / `clj-nrepl-eval -p PORT`, not `lein test`. +- Temp files go in `./tmp/`. diff --git a/.claude/skills/ssr-form-migration/reference/component-cookbook.md b/.claude/skills/ssr-form-migration/reference/component-cookbook.md new file mode 100644 index 00000000..af778820 --- /dev/null +++ b/.claude/skills/ssr-form-migration/reference/component-cookbook.md @@ -0,0 +1,182 @@ +# 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-`) 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 `` — Rule 4 instead of OOB + +Running totals live in their own ``, 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 `` 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). diff --git a/.claude/skills/ssr-form-migration/reference/form-vs-wizard.md b/.claude/skills/ssr-form-migration/reference/form-vs-wizard.md new file mode 100644 index 00000000..213d5942 --- /dev/null +++ b/.claude/skills/ssr-form-migration/reference/form-vs-wizard.md @@ -0,0 +1,146 @@ +# Forms vs. wizards (and the data-driven wizard engine) + +## Classify first + +| Signal | Classification | +|--------|----------------| +| One logical step — even with a `?mode=` toggle, $/% radio, or add/remove rows | **plain form** | +| The user genuinely advances through ordered steps, each validated before the next | **wizard** | +| In doubt | **form** | + +Most "wizards" in this codebase are single-step forms wearing wizard costumes: they +implement the multi-step protocol (`mm/ModalWizardStep` + friends), serialize an EDN +snapshot into hidden fields, and register 10–20 stacked-middleware routes — all for one +step. That is pure overhead to delete. + +> **Done — Transaction Edit is now a plain form.** `LinksStep`/`EditWizard` and all `mm/*` +> usage were deleted from `transaction/edit.clj`; the worked example below is realized, not +> aspirational. See "Single-step → plain form (realized)". + +## The machinery being replaced + +The old shape (kept here as the "before"): + +```clojure +(defrecord LinksStep [linear-wizard] + mm/ModalWizardStep + (step-name [_] "Transaction Actions") + (step-key [_] :links) + (edit-path [_ _] []) + (step-schema [_] (mm/form-schema linear-wizard)) + (render-step [this {{:keys [snapshot step-params]} :multi-form-state :as request}] ...)) +``` + +…plus the snapshot round-trip: the whole accumulating form state is serialized to hidden +fields (custom EDN readers), then rebuilt every request by merging the posted pieces back +into the snapshot (`:multi-form-state :snapshot` is read ~75× in `edit.clj`). The +serialization needs custom readers, the merge is error-prone, and the payload grows each +step. + +--- + +## Single-step → plain form + +Two routes: `GET` (render) and `POST` (validate + save). State is plain form fields + an +entity id. No snapshot, no server state, no protocol. + +```clojure +{::route/edit (fn [req] (html-response (render-edit-form {:entity (get-entity req)}))) + ::route/edit-submit (fn [req] (validate-and-save req))} +``` + +A `?mode=` toggle is just the `GET` re-rendering with a different query param — still a +plain form. An add-row interaction is one extra `POST` that appends a fresh row and +re-renders (the `+1` route). + +### Single-step → plain form (realized: Transaction Edit) + +What replacing the wizard actually looked like, end to end: + +1. **Delete the records + middleware.** `EditWizard`/`LinksStep`, `mm/open-wizard-handler`, + `mm/next-handler`, `mm/submit-handler`, `mm/wrap-wizard`, `mm/wrap-decode-multi-form-state`, + and the `edit-wizard-navigate` route all go. `render-step` becomes a plain `render-form`. +2. **Rename the fields off `step-params[...]`.** Field names are now the schema path + directly (`(path->name2 :transaction/accounts 0 :transaction-account/account)` → + `transaction/accounts[0][transaction-account/account]`). They decode straight into the + form schema via the unchanged `wrap-nested-form-params` + `mc/decode` — no two-key + snapshot/step-params decode. **Strip stray keys after decode** (`select-keys` to the + schema's keys) or a non-schema input like the tab group's `method` hidden 500s the save + (see `gotchas.md`). +3. **Flat state.** `wrap-derive-state` builds a plain `{:snapshot :edit-path :step-params}` + map (not the `MultiStepFormState` record): `entity-only` fields from the entity, editable + fields from the live posted form (absent = cleared). The ~34 `:snapshot` reads keep + working. +4. **Validation/error flow without `wrap-ensure-step`.** Reuse the generic + `wrap-form-4xx-2` directly: `(-> submit-edit (wrap-form-4xx-2 render-form-response) …)`. + `submit-edit` runs `assert-schema` then dispatches the save; on a throw, `wrap-form-4xx-2` + re-renders the whole form with `:form-errors` keyed by schema paths. A `*errors*` dynamic + var (bound by `render-form`) replaces the form-cursor's `*form-errors*` for field lookups. +5. **Routes shrink to** `edit-wizard` (GET open), `edit-submit` (POST), `edit-form-changed` + (POST whole-form re-render for dependent changes), `location-select` (GET), + `unlink-payment` (POST). + +--- + +## Genuinely multi-step → data-driven engine with session-stored step state + +> **Inspiration — Django `formtools` `WizardView`.** Django's wizard does *not* round-trip +> a serialized blob of the whole form through the page. Each step's validated data is +> written to a **storage backend (the user session by default)** under that step's key, +> and the steps are combined only at the very end via `get_all_cleaned_data()`. We adopt +> the same model: **replace the EDN snapshot + piecewise merging with per-step form state +> stored in the Ring session.** A step writes its own data under its own key; nothing is +> merged into a snapshot and nothing about other steps rides through the form. +> Refs: `formtools.wizard.views.WizardView`, `SessionStorage`, `get_all_cleaned_data()` +> (https://django-formtools.readthedocs.io/en/latest/wizard.html). + +A wizard is **data**: + +```clojure +(def vendor-wizard-config + {:steps [{:key :info :schema info-schema :fields [...] :render render-info-step + :next (fn [data] :terms)} + {:key :terms :schema terms-schema :fields [...] :render render-terms-step + :next (fn [data] :done)}] + :init-fn (fn [req] {...}) + :submit-route "/admin/vendor/wizard/submit" + :done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))}) +``` + +with a tiny engine (no protocols) whose state lives **in the session**, keyed by a wizard +instance id, each step's data under its own step key — the formtools `SessionStorage` +model. No snapshot, no custom EDN readers, no merge-into-snapshot: + +```clojure +;; Storage backed by the Ring session. Path: [:wizards :step-data ] +(defn create-wizard! [session config] + (let [id (str (java.util.UUID/randomUUID))] + [id (assoc-in session [:wizards id] + {:current-step (-> config :steps first :key) :step-data {}})])) + +(defn put-step [session id k data] (assoc-in session [:wizards id :step-data k] data)) ; replace, not merge +(defn set-step [session id k] (assoc-in session [:wizards id :current-step] k)) +(defn get-all [session id] (->> (get-in session [:wizards id :step-data]) vals (apply merge))) +(defn forget [session id] (update session :wizards dissoc id)) +``` + +The render emits only a **reference token** (`wizard-id`, `current-step`) in the form — +never the form's state. The submit handler validates the posted step, `put-step`s it, +computes `:next`, and either advances (`set-step`) or finishes (`get-all` + `:done-fn` + +`forget`). Every fn returns the updated session for the handler to thread into the Ring +response (`(assoc resp :session session')`). + +**Two routes per wizard:** open (`partial open-wizard config`) and submit +(`partial handle-step-submit config`). State is namespaced by `wizard-id` inside the +session, so multiple in-flight wizards (and browser tabs) don't collide, and it is +discarded on completion (`forget`). + +### Storage lifetime (Open decision 1) + +State lives in the Ring session, scoped to true multi-step wizards (plain forms hold +none). Lifetime follows the session; `forget` on completion prevents session bloat. For +long-lived wizards, confirm the session backend (in-memory vs. durable) is acceptable or +pick a durable store. **This engine is built in Phase 6** (Transaction Rule) — until then +this file describes the target; validate `components/wizard_state.clj` + +`components/wizard2.clj` against it when they land, and update this doc from the real +implementation. diff --git a/.claude/skills/ssr-form-migration/reference/gotchas.md b/.claude/skills/ssr-form-migration/reference/gotchas.md new file mode 100644 index 00000000..e9f9fc26 --- /dev/null +++ b/.claude/skills/ssr-form-migration/reference/gotchas.md @@ -0,0 +1,199 @@ +# Gotchas + +GROWS every migration. One entry per surprise. Also the home for any **written exception** +to the scorecard ratchet (a metric that regressed for a documented reason). + +--- + +## Stale `$refs` / `tippy` after a swap + +A whole-form swap can run an Alpine event handler *before* the component re-initialises, +so a handler that dereferences `$refs.input.__x_tippy` or calls `tippy.show()` throws. +**Always null-guard:** `$refs.input?.__x_tippy?.hide()`, `tippy?.show()`. The +`transaction-edit-swap.spec.ts` `trackErrors()` helper fails the test on any `pageerror` +or `console.error`, which is exactly how a stale-ref throw surfaces. + +## Let the server value win — don't preserve Alpine state across a server-driven change + +When a server change should update a component (e.g. choosing a vendor sets its default +account), rebuild that section fresh on the swap so the server-provided value lands +without keying tricks. The bug this prevents: "changing the vendor a *second* time doesn't +update the account" because preserved Alpine state shadowed the new server value. If you +*must* preserve a component, key it by value so a change forces re-init: +`(assoc attrs :key (str id "--" current-value))`. + +## Focus dies if the typed input is inside its own swapped region + +The single most important invariant. Amount field → swap a sibling tbody, not the row. +Memo → swap nothing. If a caret test (`sameNode`) fails, the input is in its own swap +region — re-target to a sibling/ancestor that excludes it. + +## Faked cursors breed duplicate render fns + +A `with-cursor`/`MapCursor` re-root to fake a deep start forces a `*-no-cursor*` twin. +Removing the fake lets you delete the twin. Don't "fix" a faked cursor in place — top-root +it and collapse to one render fn. (See `render-functions.md`.) + +## Edit Clojure with clojure-mcp tools, not the file editor + +`clojure_edit` / `clojure_edit_replace_sexp`. If a file won't compile: `clj-paren-repair` +the file, then retry; if still broken, `lein cljfmt check`. Run tests via `clojure-eval` / +`clj-nrepl-eval -p PORT`, never `lein test` (slow, last resort). + +## Solr/typeahead in tests + +Account/vendor search is backed by Solr, unavailable in tests. To drive a typeahead in +e2e: type under the 3-char threshold, then inject a result into Alpine state +(`Alpine.$data(el).elements = [{value, label}]`) and click it — the real click handler, +`tippy.hide()`, Alpine reactivity, and the HTMX swap all run as in production. Entity ids +come from `GET /test-info`. + +--- + +## UI-only control fields must be stripped before a Datomic upsert + +The wizard snapshot/step-params carry UI control fields that are **not** schema +attributes — `:action`, `:amount-mode`, and (added by the simple/advanced work) `:mode`. +The `:manual` save handler stripped `:action`/`:amount-mode` but not `:mode`, so every +*advanced* manual save passed `:mode "advanced"` into `:upsert-transaction` and 500'd with +`:db.error/not-an-entity :mode`. Lesson: when a save derives its tx-data from the form +snapshot, **strip every non-schema control key** before transacting. The session-backed +wizard engine (Phase 6) avoids this class of bug by storing per-step *validated* data +only — UI control fields never enter the combined data. This was a real production bug +surfaced by the e2e gate, not a test artifact. + +## E2E helpers must use the Alpine **v3** API, not the v2 `__x` internal + +The app loads Alpine v3 (`cdn.jsdelivr.net/npm/alpinejs@3.x.x`). The v2 internal +`el.__x.$data` is **gone** — `el.__x` is `undefined`, so any helper that pokes it silently +no-ops. A stale `selectAccountFromTypeahead` did this and left the posted account empty +(account-controlled by `x-model`, so the raw DOM `.value` you set is overwritten from +Alpine's empty state). Drive components the real way instead: `window.Alpine.$data(el)`, +open the tippy dropdown, inject `elements`, click the result — exactly as +`transaction-edit-swap.spec.ts` does. Probe with +`{ hasLegacy__x: !!el.__x, hasAlpineData: !!window.Alpine.$data(el) }`. + +## Diagnosing a "modal won't close after save" + +The edit modal closes on an `hx-trigger: modalclose` from a *successful* save; a +validation failure re-renders the `#wizard-form` (200), and a server exception returns 500 +(caught by `wrap-error`). To find which: capture POST responses in Playwright +(`page.on('response', …)`), read the `edit-submit` body — a `
` means +validation re-render; a `#error {…}` stack means a 500. Then serialize the form right +before save (`new FormData(document.querySelector('#wizard-form'))`) to see exactly what +posts. This is how the `:mode` 500 and the empty-account bugs above were isolated. + +## De-faking a cursor is not a drop-in — `with-field-default` mutates + +Tempting fix for a faked deep cursor (`with-cursor` + synthetic `MapCursor` at index 0): +replace it with `(fc/with-field-default 0 {})` to advance naturally. **It broke the +simple-mode swap** (`transaction-edit-swap` test 1 threw). `with-field-default` calls +`cursor/transact!` — it *mutates the form cursor* (assoc-ing the default row) as a render +side effect, which changes simple-mode behavior. The read-only synthetic `MapCursor` did +not. Lesson: removing a faked cursor on these modals is **not** a one-liner — it's part of +the larger render-fn extraction (render the row from explicit data, construct field names +directly, look up errors explicitly), done when the simple/advanced rows are reworked into +pure render fns / Selmer. Don't swap one cursor primitive for another and assume parity; +verify against the swap spec, and expect the de-fake to come with the render-fn rewrite. + +## Snapshot operations read stale state and drop live form values (heuristic 2) + +The whole-form operation handlers (`apply-new-account`, `apply-remove-account`, +`apply-toggle-amount-mode`) rebuild the account rows from the **decoded `:snapshot`** (the +hidden EDN field), not from the live posted `:step-params`. So any value the user has typed +but that hasn't been re-serialised into the snapshot yet — e.g. an amount typed right +before clicking "New account" — is **silently lost** when the operation re-renders. This is +the snapshot round-trip fragility the migration removes (heuristic 2: → 0 merges; state +should ride in the form, not a parallel snapshot). It bit the percentage-split e2e: typing +50% then adding a row reverted the first row to its snapshot value, yielding a 66.67/33.33 +split. Two ways it shows up and how to handle until the snapshot is gone: + +**Fixed (Stage 1):** the operation handlers read the live `:step-params` rows (already +schema-decoded by `mm/wrap-wizard`) so typed values survive add/remove/toggle. + +**Done (Stage 2 — the snapshot round-trip is gone).** The EDN `snapshot` hidden field + +custom readers + `merge-multi-form-state` are removed. A `db/id` hidden rides in the form; +`wrap-derive-state` rebuilds `:multi-form-state` per request from `entity ∪ step-params`, +and `EditWizard.render-wizard` renders a plain form (no snapshot/edit-path/current-step +hiddens). The ~34 `:snapshot` reads still work — `:snapshot` is now a derived map, not a +round-tripped blob. + +**Trap that cost hours — derive `entity ∪ step-params` correctly.** First cut was +`(merge base step-params)`. Bug: `base` always carries the entity's *persisted* accounts, +so after the user removes every row (step-params has no accounts key) the merge falls back +to base → the persisted accounts **resurrect** on the next operation. Fix: editable fields +(accounts, vendor, memo, approval, action, mode, amount-mode) come **only** from the live +form (absent = cleared); only entity-only fields (`db/id`, client, amount, description, +status, type) come from the entity. Lesson: with a posted form, "field absent" means +*cleared*, not "use the persisted value" — never merge the entity's editable fields back in. + +**Verify the snapshot removal on a FRESH server, and don't trust a long-lived in-process +test server.** Protocol/defrecord (`EditWizard.render-wizard`) and middleware reloads do +**not** fully take in a running REPL — the server kept rendering the old snapshot field +after `:reload`, and an in-process server that isn't reseeded between `npx playwright` +invocations accumulates state that makes order-dependent tests flake. Both produced hours +of phantom failures. Restart the REPL clean (or reseed) before trusting an e2e result; CI +boots a fresh server per run, so the fresh-server number (38 pass / 1 unrelated) is the real one. + +## Characterization tests rot against table order and removed wizard chrome + +Two stale-test traps surfaced once the masking failure was fixed (a `mode: 'serial'` file +hides every test after the first failure, so fixing one unmasks the next): + +- **Hard-coded amounts per table row index** (`openEditModal(page, 3)` then + `expect(amount).toBeCloseTo(400)`) break because same-date seed transactions have no + pinned row order. Read the actual value (e.g. the grid's `.account-grand-total-row`) + instead of hard-coding. +- **Helpers that navigate the old multi-step wizard** (`click('button:has-text("Transaction + Actions")')`) hang once the modal is single-page. Drop the navigation; the action tabs + are present immediately. + +## Flat decode leaks stray form fields into the saved entity (the `method` 500) + +Dropping the wizard's `step-params[...]` field-name prefix and decoding posted params +**straight into the form schema** means the decode now captures **every** posted field, not +just the namespaced ones. A single stray field breaks the save: + +- The tab switcher is `(com/button-group {:name "method"} …)`, which emits + ``. Under the wizard, `method` lived *outside* + `step-params[...]` so it never entered the decoded map. After the rename it decodes to + `:method ""` (malli `:map` is open and passes unknown keys), rides into `snapshot` → + `tx-data`, and `:upsert-transaction` rejects it → **HTTP 500 on save**. +- Symptom: the save POST fires (confirm with a `println` in the submit handler) but the + modal never closes, because the 500 trips `htmx:response-error`. The server error may go + to mulog, not stdout — an empty stdout log does **not** mean "no error." Reproduce the + exact POST with `curl` (add/remove one field) to isolate the offender fast. + +**Fix:** strip the decoded map to the schema's known top-level keys before threading on +(`select-keys decoded edit-form-keys`); keep that allowlist next to the schema. Nested +account sub-maps decode fine — only the top level needs the guard. + +## REPL reload does not refresh a running jetty's routes — restart the JVM + +`handler/match->handler-lookup` is a top-level `def` capturing `(merge ssr/key->handler …)` +at load, through a chain of module-level `def`s (`edit` → `ssr.transaction` → `ssr.core` → +`handler`). Reloading the leaf `edit.clj` updates it but **not** the captured merges, and a +jetty started `(run-jetty app …)` holds a static `app` that doesn't re-deref the lookup per +request. Net: after a handler/route/record change, an already-running dev server keeps +serving the **old** code — `curl` shows the pre-change response (e.g. the old wizard +transitioner) while your REPL renders the new one. **Restart in a fresh JVM** for +route/record/middleware changes. For e2e, the Playwright test server +(`lein run -m auto-ap.test-server`) is a fresh JVM compiling from disk — but kill any stale +`:3333` first (`reuseExistingServer` reuses it), and kill **by port** +(`ss -tlnp | grep :3333`), never `pkill -f test-server` (it matches its own command line). + +## Full-suite e2e flakes are shared-seed interference + +The test server seeds once at boot; edit tests **save** (mutate) those seed transactions. +Run in parallel, workers race the same rows and earlier saves pollute later reads → phantom +failures that pass in isolation. Clean signal: restart (re-seed) + **`--workers=1`**. +Baseline is **38 pass / 1 fail**, the 1 being the pre-existing +`transaction-navigation.spec.ts:92` date-range test (unrelated to the edit modal). + +## Scorecard exceptions (ratchet violations with a reason) + +**Heuristic 9 (Hiccup in render path) — partial exception (Phase 2-final).** The post-save +`com/success-modal` confirmation dialogs in `save-handler` keep ~6 `[:p …]` Hiccup lines. +They are terminal responses (shown after the form closes), reuse a shared dialog component, +and sit outside the form's interactive render path. Migrating them means porting the shared +`success-modal` to Selmer — a Phase 11 cross-cutting task, not a single-modal one. diff --git a/.claude/skills/ssr-form-migration/reference/render-functions.md b/.claude/skills/ssr-form-migration/reference/render-functions.md new file mode 100644 index 00000000..ad261809 --- /dev/null +++ b/.claude/skills/ssr-form-migration/reference/render-functions.md @@ -0,0 +1,85 @@ +# 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. diff --git a/.claude/skills/ssr-form-migration/reference/scorecard.md b/.claude/skills/ssr-form-migration/reference/scorecard.md new file mode 100644 index 00000000..f74491cf --- /dev/null +++ b/.claude/skills/ssr-form-migration/reference/scorecard.md @@ -0,0 +1,84 @@ +# Quality scorecard (the ratchet) + +Cheap to measure (`grep -c`, `wc -l`, `clj-kondo`), recorded **before/after each +migration** in the commit message and in the results table below. **No metric may regress +for the touched modal** without a written exception in `gotchas.md`. These are directional +evidence, not targets to game — always paired with the e2e parity gate. + +## Heuristics + +| # | Heuristic | Measure | Target | +|---|-----------|---------|--------| +| 1 | Faked cursor positions (not cursors themselves) | `grep -cE 'with-cursor\|MapCursor\.'` re-roots + `grep -c 'defn.*-no-cursor'` | → 0 (top-rooted cursors are fine) | +| 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit `put-step` only (wizards) | +| 3 | Branching complexity | `clj-kondo`, or count `cond`/`condp`/`case`/nested `if` + max depth | net ↓ | +| 4 | Lines of code | `wc -l` on the modal's file(s) | net ↓ | +| 5 | Reuse / cross-form similarity | cookbook components reused; duplicated-block count | reuse ↑, dup ↓ | +| 6 | Route count | count routes for the modal | → 2 (+1 for add-row) | +| 7 | OOB swaps | `grep -c hx-swap-oob` | → 0 unless a justified disjoint-region case is documented | +| 8 | Attribute consistency | mixed `:x-`/`"x-"` encodings in migrated template | → 0 | + +## How to measure (copy/paste) + +```bash +F=src/clj/auto_ap/ssr/.clj +echo "LOC $(wc -l < $F)" +echo "no-cursor twins $(grep -c 'defn.*-no-cursor' $F)" +echo "faked-cursor roots $(grep -cE 'with-cursor|MapCursor\.' $F)" +echo "snapshot merges $(grep -c ':multi-form-state :snapshot' $F)" +echo "branch forms $(grep -cE '\(cond |\(condp |\(case |\(when-not ' $F)" +echo "hx-swap-oob $(grep -c 'hx-swap-oob' $F)" +echo "mixed string hx- $(grep -cE '\"hx-[a-z]' $F)" +# route count: count this modal's entries in src/cljc/auto_ap/routes/*.cljc +``` + +## Results + +Each migration appends one row (after-numbers), referencing the before in the diff. + +| Phase | Modal | LOC | Routes | no-cursor twins | faked roots | snapshot merges | OOB | mixed hx- | cookbook reused / added | +|-------|-------|-----|--------|-----------------|-------------|-----------------|-----|-----------|-------------------------| +| 1 (baseline) | Transaction Edit `transaction/edit.clj` | 1608 | ~12 | 1 | 2 | ~75 | 0 | 8 | — / seeded 7 entries | +| 2 | Transaction Edit `transaction/edit.clj` | 1593 | **~5** | **0** | **0** | **0 round-trip** | 0 | 8 (shared) | location-select / **1 Selmer** | +| 2-final | Transaction Edit (full Selmer + wizard removed) | 1548 | **5** | 0 | 0 | 0 | 0 | **0** | full `sc/*` lib / **~30 partials** | + +### New heuristics introduced at 2-final (full Selmer) + +| # | Heuristic | Measure | Target | +|---|-----------|---------|--------| +| 9 | Hiccup HTML tags in the render path | `grep -cE '\[:(div\|span\|p\|a\|button\|input\|h[1-6]\|ul\|li\|label\|select\|option\|t(able\|head\|body\|r\|d\|h)\|form\|svg\|template)'` over the modal's render fns | → 0 (success-modal confirmation dialogs may keep the shared Hiccup component) | +| 10 | mm wizard coupling | `grep -c 'mm/' the modal file` + `grep -c 'defrecord.*Wizard\|ModalWizardStep'` | → 0 for a single-step modal | + +> **Phase 2 progress.** Achieved with parity held (swap spec **6/6**, transaction-edit +> spec **8/8**, full suite **38 pass / 1 unrelated fail / 0 skip**, up from 30/2/7): +> - deleted the dead `*-no-cursor*` twin (no-cursor 1→0); +> - **de-faked the simple-mode cursor** (faked roots 2→0) via explicit data + explicit +> field names (`account-field-name`) + explicit error lookup — the render-fn rewrite the +> `with-field-default` shortcut couldn't do; +> - **collapsed the 5 manual-coding operation routes into one `edit-form-changed` +> dispatcher** (routes ~12→~5; the operations are now pure `apply-*` fns); +> - fixed a real production bug (`:mode` → 500 on every advanced manual save); +> - greened `transaction-edit.spec.ts` (8/8) and matured the skill. +> +> **Phase 2 complete.** The wizard→plain-form rewrite removed the snapshot round-trip +> (heuristic 2 → 0) and the first interactive component (`location-select`) is migrated to +> a Selmer template (`selmer-conventions.md` validated). Remaining for *later phases*: drop +> the now-thin `mm/ModalWizardStep` protocol wrappers, and the cross-cutting Phase 11 +> Selmer sweep of the shared `com/typeahead`/`com/select`/`com/button-group-button` (those +> shared call sites hold the last 8 mixed `@`/`:`-attr offenders; they clear when the +> shared components move to Selmer — not a single-modal task, per Open decision 2). + +> **Phase 2-final — full Selmer + wizard removed.** Every component the modal renders +> through was ported to a Selmer partial under `resources/templates/components/` with a +> thin Clojure wrapper in `auto-ap.ssr.components.selmer` (`sc/*`); the modal's own +> structure lives under `resources/templates/transaction-edit/`. The `mm` wizard +> abstraction (`EditWizard`/`LinksStep` records, `MultiStepFormState`, `step-params[...]` +> field names, `wrap-wizard`/`wrap-decode-multi-form-state` middleware) was deleted — there +> was only ever one step, so it was pure overhead. Result: heuristic 8 (mixed hx-) and 9 +> (Hiccup in render) and 10 (mm coupling) all → **0**; the `edit-wizard-navigate` route is +> gone (routes 5). Parity held: swap spec **6/6**, transaction-edit spec **8/8**, full +> suite **38 pass / 1 pre-existing unrelated fail** (serial, fresh seed). The only Hiccup +> left in the file is the post-save `com/success-modal` confirmation dialogs (terminal, +> shared component — out of the form's render path). See `form-vs-wizard.md` (drop-the- +> wizard test), `selmer-conventions.md` (composition mechanics), and `gotchas.md` +> (stray-field decode leak; jetty reload staleness). diff --git a/.claude/skills/ssr-form-migration/reference/selmer-conventions.md b/.claude/skills/ssr-form-migration/reference/selmer-conventions.md new file mode 100644 index 00000000..85487bb8 --- /dev/null +++ b/.claude/skills/ssr-form-migration/reference/selmer-conventions.md @@ -0,0 +1,148 @@ +# Selmer template conventions + +> **Validated** in the Transaction Edit migration: `location-select*` now renders from +> `resources/templates/components/location-select.html` via the interop bridge, embedded +> back into the Hiccup account row. Verified: swap spec 6/6, transaction-edit 8/8 (the +> Shared Location test selects through the Selmer ``. Rules mirror hiccup2 — nil/false +dropped, `true` → bare boolean attr, else `name="escaped"` (via `hu/escape-html`, so JSON +`x-data` and `x-init` quotes become `"`/`'` and the browser decodes them back). +Keyword keys keep their full name incl. namespaced/colon forms (`:x-hx-val:account-id` → +`x-hx-val:account-id`). This keeps templates free of per-attribute `{% if %}` ladders while +still emitting 100% Selmer markup — `attrs->str` serialises data, it does not build Hiccup. + +### Reuse the real class helpers + +Wrappers reuse `inputs/default-input-classes`, `inputs/use-size`, `hh/add-class`, +`btn/bg-colors` so output matches the Hiccup component **modulo Tailwind class order**. +Verify by class-**set** equality + e2e, never byte-parity (see the cookbook entries). + +### Trivial wrapper divs + +A bare `
` around a fragment is composed with a `wrap-div` string +helper (or put the class in the parent template), not a Hiccup vector — string composition +of a structural wrapper is not Hiccup and avoids a micro-template per div. + +Keep `|safe` to values the server fully controls (rendered fragments, JSON for `x-data`), +never raw user input. + +## Scope (Open decision 2) + +Hybrid, and the boundary is real: **the modal's attribute-heavy components delegate to the +shared `com/typeahead` / `com/select` / `com/button-group-button`.** Converting those is a +*cross-cutting* change (every modal uses them), so it belongs to the Phase 11 Selmer sweep, +not a single modal. `location-select*` is the first, self-contained proof; the shared +components follow when the sweep promotes them to Selmer partials. + +## Attribute-consistency scorecard (heuristic 8) + +```bash +grep -cE '"x-[a-z]|"hx-[a-z]|"@' # → 0 mixed encodings in Selmer +``` +A migrated Selmer template has no mixed `:x-`/`"x-"` encodings because everything is plain +HTML. (The Hiccup `"@click"`/`":class"` offenders that remain in `edit.clj` live in the +shared-component call sites — they clear when those components move to Selmer.) diff --git a/.claude/skills/ssr-form-migration/reference/swap-doctrine.md b/.claude/skills/ssr-form-migration/reference/swap-doctrine.md new file mode 100644 index 00000000..c250dd93 --- /dev/null +++ b/.claude/skills/ssr-form-migration/reference/swap-doctrine.md @@ -0,0 +1,149 @@ +# Whole-form HTMX swap doctrine + +Every interactive control picks a swap strategy in this **priority order** (prefer the +earliest rule that works). Worked examples are the real `transaction/edit.clj` swaps. + +## Rule 1 — No request when the field affects nothing else + +Its value rides along in the form and is read on submit. No `hx-*` at all. + +```clojure +;; transaction/edit.clj — the memo field. Editing it issues NO request; the value +;; just rides along until save. The e2e proves zero POSTs fire while typing. +(com/text-input {:value (fc/field-value) + :name (fc/field-name) + :id "edit-memo" + :placeholder "Optional note"}) +``` + +## Rule 2 — Targeted swap of a single isolated cell when the effect is purely local + +Give the cell a stable id, keep it **out of the typed input's subtree**, and post the +whole form but `hx-select` back only that cell. + +```clojure +;; transaction/edit.clj — selecting an account only changes that row's valid Location +;; options, so the change swaps just this cell. Nothing else re-renders. +[:div {:id (str "account-location-" index)} ; stable, per-row id + (com/validated-field + {:x-hx-val:account-id "accountId" + :x-dispatch:changed "accountId" ; Alpine fires `changed` when account changes + :hx-trigger "changed" + :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) + :hx-target (str "#account-location-" index) + :hx-select (str "#account-location-" index) + :hx-swap "outerHTML" + :hx-include "closest form"} ; whole form posts; only this cell swaps back + (location-select* {...}))] +``` + +## Rule 3 — Whole-form swap when the change touches interdependent state + +Vendor change, add/remove row, mode toggle, $/% radio. The form's hidden state rides +along, so one swap keeps everything consistent — **no out-of-band swaps**. + +```clojure +;; transaction/edit.clj — vendor change rebuilds the whole manual-coding section +;; (vendor default account, terms, etc. are interdependent). +[:div {:hx-trigger "change" + :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed) + :hx-target "#wizard-form" + :hx-select "#wizard-form" + :hx-swap "outerHTML" + :hx-sync "this:replace" + :hx-include "closest form"} + ...] +``` + +The active tab/action round-trips through the form (it's a hidden field bound to Alpine +`activeForm`), so it **survives** the whole-form swap — that's why a whole-form swap is +safe here even though the user is "on" a tab. + +## Rule 4 — OOB only for genuinely disjoint DOM regions + +A global flash/toast, a nav badge, a modal at the document root. **If tempted to OOB +something inside the same feature, restructure instead**: give the dependent element a +common ancestor with the trigger and use an ordinary swap. + +Worked example — running **totals live in their own sibling ``** so an amount edit +swaps the totals without ever replacing the amount input: + +```clojure +;; The totals tbody is a sibling of the input-bearing rows. +(com/data-grid + {:footer-tbody [:tbody {:id "account-totals"} ...totals rows...]} + ...account rows with inputs...) + +;; The amount input posts the whole form but hx-selects ONLY #account-totals. +(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" ; a SIBLING of this input's row... + :hx-select "#account-totals" ; ...so the input is never in the swapped region + :hx-swap "outerHTML" + :hx-trigger "keyup changed delay:300ms" + :hx-include "closest form"}) +``` + +`grep -c hx-swap-oob` on a migrated modal must be `0` unless a justified disjoint-region +case is documented here and in `gotchas.md`. + +--- + +## The focus invariant (must always hold) + +> The input the user is typing in is never inside the region its own request swaps. + +This is *the* reason the doctrine works. The amount field swaps a sibling tbody; the memo +field swaps nothing; the account typeahead's change swaps the whole form but the typeahead +isn't an active text caret at that moment (it's a click-to-select). The +`transaction-edit-swap.spec.ts` `sameNode` assertions exist to catch any violation. + +## Alpine components must survive swaps + +When a whole-form swap replaces a region containing Alpine/tippy components, they get +re-initialised from the server-provided values. Two hardening moves: + +1. **Null-guard every reference** that depends on Alpine/tippy being initialised: + ```clojure + "@keydown.down.prevent.stop" "tippy?.show()" + "@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); ..." + ``` + (`$refs.input?` / `tippy?` — the `?` matters; a swap can run a handler before re-init.) + +2. **Let the server value win.** Because the section is rebuilt fresh on each swap, the + server-driven value (e.g. a vendor's default account) lands without keying tricks — + no preserved stale Alpine state to fight. The "changing the vendor a *second* time + still updates it" e2e is the regression guard for this. + + If you *do* preserve a component across a morph/replace, key it by its server value so + a server-driven change forces re-init: `(assoc attrs :key (str id "--" current-value))`. + +Use `hx/alpine-mount-then-appear` for rows that should mount-then-transition-in (it sets +`x-data {data-key false}`, `x-init $nextTick(() => key=true)`, `x-show key`). + +--- + +## Selector strategy for targeted swaps (a consideration, not a mandate) + +Rules 2 and 4 need a stable `hx-target`/`hx-select`. Per-element unique ids +(`#account-location-0`) work and are what transaction-edit uses today. They get noisy in +deeply repeated/nested structures. When you hit that (Phase 5 / the wizards), consider: + +- **Semantic markup + data-attributes** — mark rows/cells with their identity and target + by attribute, no per-element ids: + ```html + + … + + + ``` +- **A `form-path -> selector` function**, derived the same way a cursor path is, so the + server and the markup agree on the target by construction. A render fn at form-path + `[:accounts 0 :location]` computes its own stable selector from that path. + +**Decision status:** still per-element ids. The first modal to hit nested repeated swaps +(Invoice Bulk Edit, Phase 5) settles the convention and records it here + in +`component-cookbook.md` for the wizards to reuse. diff --git a/.claude/skills/ssr-form-migration/reference/test-recipes.md b/.claude/skills/ssr-form-migration/reference/test-recipes.md new file mode 100644 index 00000000..bb58f4ef --- /dev/null +++ b/.claude/skills/ssr-form-migration/reference/test-recipes.md @@ -0,0 +1,137 @@ +# Test recipes + +GROWS every migration. How to characterize and verify a modal. Consistent with the +project `testing-conventions` skill: test user-observable behavior, assert DB state +directly, don't test the means. + +## The three test layers + +1. **Characterization e2e first (Playwright).** Before changing a modal, write/confirm a + spec capturing *current* behavior — focus/caret survival across swaps, each field + round-trip, validation errors, the real save. This is the parity contract; keep it + green through every commit. +2. **Pure-function checks via REPL.** Once render/data-prep fns are pure, exercise them + with `clojure-eval` / `clj-nrepl-eval -p `. Assert on returned data; for markup + use string matches (`(re-find #"accounts\[0\]\[account\]" (str html))`) — this style + survives the Selmer switch. Avoid brittle structural assertions. +3. **DB-state assertions for mutations.** If a submit writes Datomic, verify by querying + the DB, not by asserting on markup. + +## Running e2e + +```bash +npx playwright test # full suite +npx playwright test e2e/transaction-edit-swap.spec.ts # one spec +``` +- Config: `playwright.config.ts`, `baseURL http://localhost:3333`, `webServer: + lein run -m auto-ap.test-server`, `reuseExistingServer: !CI`. +- **The server must be from the worktree you're testing.** `reuseExistingServer` will + silently reuse *any* server on `:3333` — including another worktree's. Confirm with + `ls -la /proc/$(lsof -ti :3333)/cwd` (or restart on a clean port) before trusting a run. +- The test-server port is hardcoded (`test_server.clj` `run-jetty {:port 3333}`); to run a + second server from another worktree, change that or parameterise it. + +## Driving a typeahead in e2e (Solr unavailable in tests) + +```js +await typeahead.locator('a[x-ref="input"]').click(); // open tippy dropdown +const search = page.locator('[data-tippy-root] input[x-model="search"]').first(); +await search.fill('te'); // under 3-char Solr threshold +await typeahead.evaluate((el, id) => { // inject a clickable result + window.Alpine.$data(el).elements = [{ value: id, label: 'Test Account' }]; +}, accountId); +await page.locator('[data-tippy-root] a', { hasText: 'Test Account' }).first().click(); +``` +Entity ids come from `GET /test-info` (`{accounts:{test-account, vendor, vendor2, ...}}`). + +## Proving the focus invariant (caret survival) — the key swap test + +```js +// before the debounced swap lands, capture the live focused node... +await page.evaluate(() => { window.__focused = document.activeElement; }); +await swap; // waitForResponse on the *-form-changed POST +const ok = await page.evaluate(() => { + const a = document.activeElement; + return { sameNode: a === window.__focused, value: a?.value, caret: a?.selectionStart }; +}); +// ...assert the SAME node survived with value + caret intact. +``` +`trackErrors(page)` (collect `pageerror` + `console.error`, assert `[]`) catches a swap +that throws on a stale `$refs`/`tippy` — pair it with every swap test. + +## Asserting "no request" (Rule 1 fields) + +```js +let posts = 0; +page.on('request', r => { if (r.url().includes('edit-form-changed') && r.method()==='POST') posts++; }); +// ...type in the memo... +expect(posts).toBe(0); // memo affects nothing → issues no request +``` + +--- + +## E2E baseline (the regression gate — never drop below this) + +The full suite must stay green after every migration. Specs touching the migrated modals: + +| Spec | Tests | Role | +|------|-------|------| +| `e2e/transaction-edit-swap.spec.ts` | 8 | **Phase 2 parity contract** — whole-form `hx-select` swaps, caret survival, no-request memo, vendor re-select | +| `e2e/transaction-edit.spec.ts` | 15 | transaction edit behavior | +| `e2e/bulk-code-transactions.spec.ts` | 18 | Phase 3 (bulk code) | +| `e2e/transaction-import.spec.ts` | 4 | import | +| `e2e/transaction-navigation.spec.ts` | 13 | navigation | + +### Running e2e from a non-default worktree (recipe) + +`:3333` is often taken by another worktree's server. To run this worktree's code: + +1. Boot the test server in-process on this worktree's REPL at an alternate port — no + second JVM, and it live-reloads as you edit: + ```clojure + (require '[auto-ap.test-server :as ts] '[ring.adapter.jetty :refer [run-jetty]] + '[datomic.api :as dc]) + ;; reseed helper — call before each full run so state doesn't leak between runs + (defn reseed! [] + (try (.stop (:server test-srv)) (catch Throwable _)) + (try (dc/delete-database "datomic:mem://playwright-test") (catch Throwable _)) + (def test-srv (let [c (ts/create-test-db) id (ts/seed-test-data c)] + (reset! ts/test-transaction-id id) + {:server (run-jetty (ts/test-app) {:port 3334 :join? false}) :tx-id id}))) + (reseed!) + ``` +2. `playwright.config.ts` honors `BASE_URL`; setting it also disables the auto-started + webServer (so worktrees don't fight over :3333): + ```bash + BASE_URL=http://localhost:3334 npx playwright test --workers=1 --reporter=line + ``` +3. **Reseed (`reseed!`) before each full run.** One long-lived in-process server persists + its in-mem DB across separate `npx playwright` invocations; the swap spec's + `clearAccounts`/save mutate the shared transaction and leak into later specs. The + normal harness avoids this by booting a fresh server per `npx playwright test`. + +### Pass/fail baseline — measured on the merged hx-select reference (Phase 2 start) + +Server: in-process from `integreat-execute-refactor` on `:3334`, `--workers=1`, fresh seed. + +| Spec | Result | +|------|--------| +| `transaction-edit-swap.spec.ts` | **6 / 6 pass** — the whole-form swap parity contract | +| `transaction-edit.spec.ts` | **1 fail (masks 7 via `mode: 'serial'`)** — `Shared Location … spread on save and reopen` fails: the save POST returns a validation error (amount/balance test-data assumption: "$200 = full amount of the 2nd transaction" doesn't hold), so the modal stays open. **Pre-existing on the merged reference, not introduced by this work.** | +| Full suite (39) | **30 pass / 2 fail / 7 skipped.** 2nd failure: `transaction-navigation.spec.ts` date-range persistence (`date-range=all` expected, got `month`) — drift from the base branch's "require Apply for date-range filters" change, unrelated to forms. | + +**Gate for the Transaction Edit refactor:** the 6/6 swap-doctrine spec + REPL pure-fn +checks. + +### Current state — after the Phase 2 modal work (never drop below this) + +Full suite (workers=1, fresh seed): **38 passed / 1 failed / 0 skipped.** + +- `transaction-edit-swap.spec.ts` — **6/6** (parity contract held through every change). +- `transaction-edit.spec.ts` — **8/8** (was 1 pass + 7 masked). Greened by: the `:mode` + 500 fix, the Alpine-v3 typeahead helper, rewriting the percentage-split test to avoid + the snapshot-drops-live-values ordering trap, reading the real transaction total instead + of a hard-coded `400`, and dropping the removed `"Transaction Actions"` wizard-nav step. +- Remaining 1 failure: `transaction-navigation.spec.ts:92` date-range-preset persistence — + **unrelated to forms** (drift from the base branch's "require Apply for date-range + filters" change). Pre-existing; out of scope for this migration. diff --git a/e2e/transaction-edit-swap.spec.ts b/e2e/transaction-edit-swap.spec.ts new file mode 100644 index 00000000..824594d8 --- /dev/null +++ b/e2e/transaction-edit-swap.spec.ts @@ -0,0 +1,389 @@ +import { test, expect } from '@playwright/test'; + +// These tests cover the "post the whole form, hx-select what to swap" behaviour +// on the transaction edit page. Each edit hits its own route, the server +// re-renders the entire form, and the client selects what to swap back -- with +// no out-of-band swaps and no morph extension: +// - discrete changes (vendor, account, location, mode, add/remove row) swap +// all of #edit-form (the active action/tab round-trips through the form, +// so it survives the swap); +// - typed fields never swap the input the user is in -- the amount field swaps +// only the #account-totals tbody (a sibling of the input rows), and the memo +// posts with hx-swap=none. +// Because the active input is never part of a swapped region, focus and caret +// survive a plain swap. + +// Collect any uncaught page errors or console errors so a swap that throws +// (e.g. a tooltip callback dereferencing a stale $refs) fails the test loudly. +function trackErrors(page: any): string[] { + const errors: string[] = []; + page.on('pageerror', (e: any) => errors.push('pageerror: ' + e.message)); + page.on('console', (m: any) => { + if (m.type() === 'error') errors.push('console: ' + m.text()); + }); + return errors; +} + +async function openManualAdvanced(page: any, transactionIndex = 0) { + await page.goto('/transaction2'); + await page.waitForSelector('table tbody tr'); + await page + .locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]') + .nth(transactionIndex) + .click(); + await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); + await page.waitForSelector('#editmodal'); + await page.click('button:has-text("Manual")'); + + // First transaction has no accounts so it opens in "simple" mode. Switch to + // advanced mode (a whole-form swap) so the account grid is present. + const advancedLink = page.locator('a:has-text("Switch to advanced mode")'); + if (await advancedLink.count()) { + await advancedLink.first().click(); + await page.waitForSelector('#account-grid-body'); + } +} + +// Drives the vendor typeahead like a user: open the dropdown, inject a result +// (Solr is unavailable in tests), click it, and wait for the whole-form swap. +async function selectVendor(page: any, vendorId: number, label: string) { + const vendor = page + .locator('div[hx-vals*="vendor-changed"]') + .first() + .locator('div.relative[x-data]') + .first(); + await vendor.locator('a[x-ref="input"]').click(); + const search = page.locator('[data-tippy-root] input[x-model="search"]').first(); + await search.waitFor({ state: 'visible' }); + await search.fill('xx'); + await vendor.evaluate((el: HTMLElement, opt: { id: number; label: string }) => { + (window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }]; + }, { id: vendorId, label }); + + const swap = page.waitForResponse( + (r: any) => + r.url().includes('edit-form-changed') && + r.request().method() === 'POST' && + r.status() === 200 + ); + await page.locator('[data-tippy-root] a', { hasText: label }).first().click(); + await swap; + await page.waitForTimeout(400); +} + +// Removes every existing account row (each remove is its own whole-form swap), so a +// test starts from a known-empty state regardless of what earlier tests saved +// onto the shared transaction. +async function clearAccounts(page: any) { + // eslint-disable-next-line no-constant-condition + while (true) { + const removeButtons = page.locator('#account-grid-body .account-remove-action'); + const count = await removeButtons.count(); + if (count === 0) break; + await removeButtons.first().click(); + await expect + .poll(async () => page.locator('#account-grid-body .account-remove-action').count()) + .toBeLessThan(count); + } +} + +test.describe.configure({ mode: 'serial' }); + +test.describe('Transaction Edit whole-form swap', () => { + test('whole-form swaps (toggle mode, add account) do not throw', async ({ page }) => { + const errors = trackErrors(page); + + await openManualAdvanced(page, 0); + + // Add an account row -- another whole-form swap. + await page + .locator('#account-grid-body') + .locator('button:has-text("New account"), a:has-text("New account")') + .first() + .click(); + + await expect + .poll(async () => page.locator('#account-grid-body tbody tr.account-row').count()) + .toBeGreaterThan(0); + + // The form must survive the swap intact. + await expect(page.locator('#edit-form')).toHaveCount(1); + expect(errors, errors.join('\n')).toEqual([]); + }); + + test('keeps focus and typed value in the amount field across a swap', async ({ page }) => { + const errors = trackErrors(page); + + await openManualAdvanced(page, 0); + + // Ensure exactly one account row exists. + const rows = await page.locator('#account-grid-body tbody tr.account-row').count(); + if (rows === 0) { + await page + .locator('#account-grid-body') + .locator('button:has-text("New account"), a:has-text("New account")') + .first() + .click(); + await expect + .poll(async () => page.locator('#account-grid-body tbody tr.account-row').count()) + .toBeGreaterThan(0); + } + + const amount = page.locator('.account-amount-field').first(); + await amount.waitFor(); + + // Type a clean value via the keyboard. Typing fires the field's htmx trigger + // (keyup), which posts the whole form but swaps back only the #account-totals + // tbody -- a sibling of this input's row, so the input is never replaced. It's + // type=number (no text caret), so we assert focus + node identity + value. + await amount.click(); + await amount.press('Control+a'); + + const amountSwap = page.waitForResponse( + (r: any) => + r.url().includes('edit-form-changed') && + r.request().method() === 'POST' && + r.status() === 200 + ); + await amount.pressSequentially('150', { delay: 40 }); + + // Identify the live focused node (before the debounced swap lands) so we can + // prove the *same* node survives. + await page.evaluate(() => { + (window as any).__focusedAmount = document.activeElement; + }); + + await amountSwap; + await page.waitForTimeout(300); + + const state = await page.evaluate(() => { + const active = document.activeElement as HTMLInputElement; + return { + sameNode: active === (window as any).__focusedAmount, + isAmountField: !!active && active.classList.contains('account-amount-field'), + value: active ? active.value : null, + }; + }); + + // Focus must stay on the amount field after the swap... + expect(state.isAmountField).toBe(true); + // ...on the very same DOM node (the input is never part of the swapped region)... + expect(state.sameNode).toBe(true); + // ...with the value the user typed left intact. + expect(state.value).toBe('150'); + + // The TOTAL must have recomputed server-side from the posted amount and been + // applied via the #account-totals swap. + await expect(page.locator('.account-total-row #total')).toContainText('150'); + + expect(errors, errors.join('\n')).toEqual([]); + }); + + test('memo edits issue no request and keep their value/caret', async ({ page }) => { + const errors = trackErrors(page); + + // Memo affects nothing else in the form, so editing it must NOT issue a + // request at all -- its value just rides along in the form until save. + let memoRequests = 0; + page.on('request', (r: any) => { + if (r.url().includes('edit-form-changed') && r.method() === 'POST') memoRequests++; + }); + + await page.goto('/transaction2'); + await page.waitForSelector('table tbody tr'); + await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click(); + await page.waitForSelector('#editmodal'); + + const memo = page.locator('#edit-memo'); + await memo.waitFor(); + + // Clear any seeded memo text and type "hello". + await memo.click(); + await memo.press('Control+a'); + await memo.pressSequentially('hello', { delay: 40 }); + + // Drop the caret in the middle and insert a char -> "heXllo", caret -> 3. + await memo.evaluate((el: HTMLInputElement) => { + el.focus(); + el.setSelectionRange(2, 2); + }); + await page.evaluate(() => { + (window as any).__focusedMemo = document.activeElement; + }); + await memo.press('X'); + + // Give the old debounce window a chance to (not) fire. + await page.waitForTimeout(500); + + const state = await page.evaluate(() => { + const active = document.activeElement as HTMLInputElement; + return { + sameNode: active === (window as any).__focusedMemo, + id: active ? active.id : null, + value: active ? active.value : null, + caret: active ? active.selectionStart : null, + }; + }); + + // No request fired, and the value/caret are simply intact (nothing swapped). + expect(memoRequests).toBe(0); + expect(state.id).toBe('edit-memo'); + expect(state.sameNode).toBe(true); + expect(state.value).toBe('heXllo'); + expect(state.caret).toBe(3); + + expect(errors, errors.join('\n')).toEqual([]); + }); + + test('choosing an account from the typeahead does not throw and persists', async ({ page }) => { + const errors = trackErrors(page); + + await openManualAdvanced(page, 0); + + // Start from a clean, empty account row so selecting the account actually + // changes accountId (and fires the change-gated whole-form swap). + await clearAccounts(page); + await page + .locator('#account-grid-body') + .locator('button:has-text("New account"), a:has-text("New account")') + .first() + .click(); + await expect + .poll(async () => page.locator('#account-grid-body tbody tr.account-row').count()) + .toBeGreaterThan(0); + + const row = page.locator('#account-grid-body tbody tr.account-row').first(); + const typeahead = row.locator('div.relative[x-data]').first(); + + // Open the dropdown (tippy renders the popper into [data-tippy-root]). + await typeahead.locator('a[x-ref="input"]').click(); + const search = page.locator('[data-tippy-root] input[x-model="search"]').first(); + await search.waitFor({ state: 'visible' }); + + // Account search is backed by Solr (unavailable in tests), so type under the + // 3-char threshold and inject a clickable result into the typeahead state -- + // the click handler, tippy.hide(), Alpine reactivity and the HTMX swap all run + // exactly as in production. + await search.fill('te'); + const testInfo = await (await page.request.get('/test-info')).json(); + const accountId: number = testInfo.accounts['test-account']; + await typeahead.evaluate((el: HTMLElement, id: number) => { + (window as any).Alpine.$data(el).elements = [{ value: id, label: 'Test Account' }]; + }, accountId); + + // Clicking the result runs `value = element; tippy.hide(); ...` and dispatches + // the change that fires the whole-form swap. + const swap = page.waitForResponse( + (r: any) => + r.url().includes('edit-form-changed') && + r.request().method() === 'POST' && + r.status() === 200 + ); + await page.locator('[data-tippy-root] a', { hasText: 'Test Account' }).first().click(); + await swap; + await page.waitForTimeout(300); + + // The chosen account must survive the whole-form swap. + const hidden = page + .locator('#account-grid-body tbody tr.account-row') + .first() + .locator('input[type="hidden"][name*="transaction-account/account"]') + .first(); + await expect(hidden).toHaveValue(accountId.toString()); + + expect(errors, errors.join('\n')).toEqual([]); + }); + + test('selecting a vendor populates its default account across the swap', async ({ page }) => { + const errors = trackErrors(page); + + // Open the modal in simple mode (transaction 0 has no accounts). + await page.goto('/transaction2'); + await page.waitForSelector('table tbody tr'); + await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click(); + await page.waitForSelector('#editmodal'); + await page.click('button:has-text("Manual")'); + await page.waitForSelector('div[hx-vals*="vendor-changed"]'); + + const testInfo = await (await page.request.get('/test-info')).json(); + const vendorId: number = testInfo.accounts.vendor; + const defaultAccountId: number = testInfo.accounts['test-account']; + + // Drive the vendor typeahead like a user: open dropdown, inject a result + // (Solr is unavailable in tests), click it. + const vendor = page.locator('div[hx-vals*="vendor-changed"]').first().locator('div.relative[x-data]').first(); + await vendor.locator('a[x-ref="input"]').click(); + const search = page.locator('[data-tippy-root] input[x-model="search"]').first(); + await search.waitFor({ state: 'visible' }); + await search.fill('te'); + await vendor.evaluate((el: HTMLElement, id: number) => { + (window as any).Alpine.$data(el).elements = [{ value: id, label: 'Test Vendor' }]; + }, vendorId); + + const swap = page.waitForResponse( + (r: any) => + r.url().includes('edit-form-changed') && + r.request().method() === 'POST' && + r.status() === 200 + ); + await page.locator('[data-tippy-root] a', { hasText: 'Test Vendor' }).first().click(); + await swap; + await page.waitForTimeout(400); + + // The vendor's default account must now be reflected in the account field. + // Because the section is rebuilt fresh from the server (no preserved Alpine + // state), the server-driven account value lands without any keying tricks. + const accountHidden = page + .locator('input[type="hidden"][name*="transaction-account/account"]') + .first(); + await expect(accountHidden).toHaveValue(defaultAccountId.toString()); + + // The displayed account label should resolve too. + await expect(page.locator('span[x-text="value.label"]', { hasText: 'Test Account' })).toBeVisible(); + + expect(errors, errors.join('\n')).toEqual([]); + }); + + test('changing the vendor a second time still updates it', async ({ page }) => { + const errors = trackErrors(page); + + await page.goto('/transaction2'); + await page.waitForSelector('table tbody tr'); + await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click(); + await page.waitForSelector('#editmodal'); + await page.click('button:has-text("Manual")'); + await page.waitForSelector('div[hx-vals*="vendor-changed"]'); + + const testInfo = await (await page.request.get('/test-info')).json(); + const vendor1: number = testInfo.accounts.vendor; + const vendor2: number = testInfo.accounts.vendor2; + const account1: number = testInfo.accounts['test-account']; + const account2: number = testInfo.accounts['second-account']; + + const vendorLabel = page + .locator('div[hx-vals*="vendor-changed"] span[x-text="value.label"]') + .first(); + const accountHidden = page + .locator('input[type="hidden"][name*="transaction-account/account"]') + .first(); + + // First vendor. + await selectVendor(page, vendor1, 'Test Vendor'); + await expect(vendorLabel).toHaveText('Test Vendor'); + await expect(accountHidden).toHaveValue(account1.toString()); + + // Second vendor -- the regression guard: the section (and its vendor + // typeahead) is rebuilt fresh on every swap, so a second change still fires + // its request and updates the default account. + await selectVendor(page, vendor2, 'Second Vendor'); + await expect(vendorLabel).toHaveText('Second Vendor'); + await expect(accountHidden).toHaveValue(account2.toString()); + + // And back again, to be sure it keeps working. + await selectVendor(page, vendor1, 'Test Vendor'); + await expect(vendorLabel).toHaveText('Test Vendor'); + await expect(accountHidden).toHaveValue(account1.toString()); + + expect(errors, errors.join('\n')).toEqual([]); + }); +}); diff --git a/e2e/transaction-edit.spec.ts b/e2e/transaction-edit.spec.ts index 297af94d..1d63aa8d 100644 --- a/e2e/transaction-edit.spec.ts +++ b/e2e/transaction-edit.spec.ts @@ -13,12 +13,20 @@ async function openEditModal(page: any, transactionIndex: number = 0) { // Wait for the modal to open await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); - await page.waitForSelector('#wizardmodal'); + await page.waitForSelector('#editmodal'); // The modal is now single-page (Edit Transaction). Click "Manual" tab to ensure // the manual account coding form is active. await page.click('button:has-text("Manual")'); - + + // Transactions with 0-1 accounts open in "simple" mode, which has no account + // grid. Switch to "advanced" mode (a whole-form morph swap) so the grid the + // rest of these helpers manipulate is present. + const advancedLink = page.locator('a:has-text("Switch to advanced mode")'); + if (await advancedLink.count()) { + await advancedLink.first().click(); + } + // Wait for the manual form to appear await page.waitForSelector('#account-grid-body'); } @@ -33,68 +41,33 @@ async function getTestInfo(page: any) { } async function selectAccountFromTypeahead(page: any, rowIndex: number, accountName: string) { - // The account search uses Solr which isn't available in tests. - // Instead, we directly set the hidden input value via JavaScript. - - // Get all rows except the new-row, total, balance, and transaction total rows - const allRows = page.locator('#account-grid-body tbody tr'); - const rowCount = await allRows.count(); - - // Find the row that has a hidden input for account (actual account rows) - let accountRow = null; - let accountRowIndex = 0; - for (let i = 0; i < rowCount; i++) { - const row = allRows.nth(i); - const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0; - if (hasAccountInput) { - if (accountRowIndex === rowIndex) { - accountRow = row; - break; - } - accountRowIndex++; - } - } - - if (!accountRow) { - throw new Error(`Could not find account row at index ${rowIndex}`); - } - - // Find the hidden input for the account - const hiddenInput = accountRow.locator('input[type="hidden"][name*="transaction-account/account"]').first(); - - // Get account IDs from test-info endpoint - const testInfo = await getTestInfo(page); + // Account search is backed by Solr (unavailable in tests). Drive the typeahead the + // way a user does, using the Alpine v3 API: open the tippy dropdown, inject a result + // into the component's `elements`, then click it. This runs the real click handler, + // Alpine reactivity and the HTMX swap exactly as in production -- unlike poking the + // long-removed Alpine v2 `__x` internal, which silently no-ops on Alpine v3 and left + // the posted account empty. const accountKey = accountName === 'Test' ? 'test-account' : 'second-account'; + const label = `${accountName} Account`; + const testInfo = await getTestInfo(page); const accountId = testInfo.accounts[accountKey]; - if (!accountId) { throw new Error(`Could not find account with name ${accountName}`); } - - // Set the hidden input value and trigger change - // Also update Alpine.js data to prevent it from overwriting our value - await hiddenInput.evaluate((el: HTMLInputElement, value: string) => { - // Set the DOM value - el.value = value; - - // Update Alpine.js component data - const alpineEl = el.closest('[x-data]'); - if (alpineEl && (alpineEl as any).__x) { - (alpineEl as any).__x.$data.value.value = parseInt(value); - (alpineEl as any).__x.$data.value.label = 'Selected Account'; - } - - // Also update any parent Alpine model (accountId) - const rowEl = el.closest('tr[x-data]'); - if (rowEl && (rowEl as any).__x) { - (rowEl as any).__x.$data.accountId = parseInt(value); - } - - el.dispatchEvent(new Event('change', { bubbles: true })); - }, accountId.toString()); - - // Wait for any HTMX updates - await page.waitForTimeout(300); + + const row = page.locator('#account-grid-body tbody tr.account-row').nth(rowIndex); + const typeahead = row.locator('div.relative[x-data]').first(); + await typeahead.locator('a[x-ref="input"]').click(); + const search = page.locator('[data-tippy-root] input[x-model="search"]').first(); + await search.waitFor({ state: 'visible' }); + await search.fill('te'); + await typeahead.evaluate((el: any, opt: { id: number; label: string }) => { + (window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }]; + }, { id: accountId, label }); + await page.locator('[data-tippy-root] a', { hasText: label }).first().click(); + + // Wait for the change-gated whole-form swap to settle. + await page.waitForTimeout(400); } async function findAccountRow(page: any, rowIndex: number) { @@ -151,14 +124,13 @@ async function getAccountLocation(page: any, rowIndex: number): Promise } async function removeAllAccounts(page: any) { - const accountRows = page.locator('#account-grid-body tbody tr.account-row'); - const rowCount = await accountRows.count(); - - for (let i = rowCount - 1; i >= 0; i--) { - const row = accountRows.nth(i); - const removeButton = row.locator('.account-remove-action'); - await removeButton.click(); - // Wait for the Alpine.js removal animation (500ms + buffer) + // Re-query each iteration: every remove is a whole-form swap that re-renders the rows, + // so a row index captured up front goes stale. Click the last remove button until none + // remain. + for (let guard = 0; guard < 20; guard++) { + const removeButtons = page.locator('#account-grid-body .account-remove-action'); + if (await removeButtons.count() === 0) break; + await removeButtons.last().click(); await page.waitForTimeout(700); } } @@ -172,23 +144,23 @@ async function saveTransaction(page: any) { } async function toggleToPercentMode(page: any) { - const percentRadio = page.locator('input[name="step-params[amount-mode]"][value="%"]'); + const percentRadio = page.locator('input[name="amount-mode"][value="%"]'); await percentRadio.click(); // Wait for HTMX to swap the grid body await page.waitForResponse(response => - response.url().includes('/toggle-amount-mode') && response.status() === 200 + response.url().includes('/edit-form-changed') && response.status() === 200 ); await page.waitForTimeout(200); } async function toggleToDollarMode(page: any) { - const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]'); + const dollarRadio = page.locator('input[name="amount-mode"][value="$"]'); await dollarRadio.click(); // Wait for HTMX to swap the grid body await page.waitForResponse(response => - response.url().includes('/toggle-amount-mode') && response.status() === 200 + response.url().includes('/edit-form-changed') && response.status() === 200 ); await page.waitForTimeout(200); } @@ -237,78 +209,39 @@ test.describe('Transaction Edit Shared Location', () => { }); test.describe('Transaction Edit Full Workflow', () => { - test('should code transaction with vendor using percentage, then split 50/50, then switch to dollars', async ({ page }) => { - // Step 1: Open edit modal and code with 100% to one account - await openEditModal(page); - - // Switch to percentage mode first (this re-renders the grid from server state) + test('splits a transaction 50/50 in percentage mode and stores it as dollars', async ({ page }) => { + // Transaction 0 is $100. Code it 50% / 50% across two accounts in percentage mode and + // verify the save-time %->$ conversion stores/displays $50 + $50 on reopen. + // + // This intentionally types a percentage and THEN adds another row -- a whole-form + // operation. The operation handlers now rebuild from the live posted form, not the + // stale snapshot, so the first row's typed 50% survives (it used to revert, yielding a + // 66.67/33.33 split). + await openEditModal(page, 0); + await removeAllAccounts(page); await toggleToPercentMode(page); - - // Check if there's already an account from previous tests - const allRows = page.locator('#account-grid-body tbody tr'); - const hasExistingAccount = await allRows.locator('input[name*="transaction-account/account"]').count() > 0; - - if (!hasExistingAccount) { - // Add a new account row if none exist - await addNewAccount(page); - } - - // Select the account - await selectAccountFromTypeahead(page, 0, 'Test'); - - // Set amount to 100% - await setAccountAmount(page, 0, '100'); - - // Save the transaction - await saveTransaction(page); - - // Step 2: Re-open and split 50/50 with two accounts - await openEditModal(page); - - // Note: amount-mode is UI-only state, so it resets to $ when re-opening - // Switch back to percentage mode - await toggleToPercentMode(page); - - // The existing account from step 1 should already be there - // Change its amount from 100% to 50% - await setAccountAmount(page, 0, '50'); - - // Add a second account at 50% + + await addNewAccount(page); + await selectAccountFromTypeahead(page, 0, 'Test'); + await setAccountAmount(page, 0, '50'); + await addNewAccount(page); - await page.waitForTimeout(1000); await selectAccountFromTypeahead(page, 1, 'Second'); await setAccountAmount(page, 1, '50'); - - // Save + await saveTransaction(page); - - // Step 3: Re-open and verify dollar amounts - await openEditModal(page); - - // The accounts should be persisted from the previous save - // Wait for accounts to load + + // Reopen: dollar mode is the default, and each account is the converted $50. + await openEditModal(page, 0); await page.waitForTimeout(500); - - // Verify we're in dollar mode (default) - const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]'); + + const dollarRadio = page.locator('input[name="amount-mode"][value="$"]'); await expect(dollarRadio).toBeChecked(); - - // Verify amounts are in dollars (converted from percentages on save) - const row0 = await findAccountRow(page, 0); - const row1 = await findAccountRow(page, 1); - - const amount0 = row0.locator('.account-amount-field'); - const amount1 = row1.locator('.account-amount-field'); - - // Each should be $50.00 (or close to it) - const val0 = await amount0.inputValue(); - const val1 = await amount1.inputValue(); - + + const val0 = await (await findAccountRow(page, 0)).locator('.account-amount-field').inputValue(); + const val1 = await (await findAccountRow(page, 1)).locator('.account-amount-field').inputValue(); expect(parseFloat(val0)).toBeCloseTo(50.0, 1); expect(parseFloat(val1)).toBeCloseTo(50.0, 1); - - // Save - await saveTransaction(page); }); }); @@ -339,7 +272,7 @@ test.describe('Transaction Edit Validation', () => { await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible(); // The form should still be present - const form = page.locator('#wizard-form'); + const form = page.locator('#edit-form'); await expect(form).toBeVisible(); // Verify the account row is still there with our $50 value @@ -367,15 +300,11 @@ async function openEditModalForTransaction(page: any, description: string) { const editButton = row.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first(); await editButton.click(); - // Wait for the modal to open + // Wait for the modal to open. The modal is single-page now (no multi-step wizard + // navigation), so the action tabs -- including "Link to payment" -- are available + // immediately; callers click the tab they need. await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); - await page.waitForSelector('#wizardmodal'); - - // Click Next to go to the links step (button says "Transaction Actions") - await page.click('button:has-text("Transaction Actions")'); - - // Wait for the links step to load - await page.waitForSelector('text=Transaction Actions', { state: 'visible' }); + await page.waitForSelector('#editmodal'); } async function selectVendorFromTypeahead(page: any, vendorName: string) { @@ -386,7 +315,7 @@ async function selectVendorFromTypeahead(page: any, vendorName: string) { throw new Error(`Could not find vendor with name ${vendorName}`); } - const vendorContainer = page.locator('div[hx-post*="edit-vendor-changed"]').first(); + const vendorContainer = page.locator('div[hx-vals*="vendor-changed"]').first(); const vendorHidden = vendorContainer.locator('input[type="hidden"]').first(); await vendorHidden.evaluate((el: HTMLInputElement, value: string) => { @@ -401,7 +330,7 @@ async function selectVendorFromTypeahead(page: any, vendorName: string) { el.dispatchEvent(new Event('change', { bubbles: true })); }); - await page.waitForResponse(response => response.url().includes('/edit-vendor-changed') && response.status() === 200); + await page.waitForResponse(response => response.url().includes('/edit-form-changed') && response.status() === 200); await page.waitForTimeout(500); } @@ -449,9 +378,17 @@ test.describe('Transaction Edit Vendor Pre-population', () => { const testInfo = await getTestInfo(page); expect(accountValue).toBe(testInfo.accounts['test-account'].toString()); + // The populated account amount should equal this transaction's amount (the vendor + // default fills the single row with the whole amount). Read the actual amount from + // the grid's transaction-total row rather than hard-coding it -- table row order is + // not pinned across same-date seed transactions. + const txTotalText = await page.locator('.account-grand-total-row').innerText(); + const txTotal = parseFloat(txTotalText.replace(/[^0-9.]/g, '')); + expect(txTotal).toBeGreaterThan(0); + const amountInput = page.locator('.account-amount-field').first(); const amountValue = await amountInput.inputValue(); - expect(parseFloat(amountValue)).toBeCloseTo(400.0, 1); + expect(parseFloat(amountValue)).toBeCloseTo(txTotal, 1); }); }); @@ -461,11 +398,11 @@ test.describe('Transaction Edit Vendor Pre-population', () => { // `elements` instead of being fetched. Everything else -- the dropdown's own // search input firing a native `change` on blur, the `value = element` click // handler, the Alpine reactivity, and the HTMX round-trip to -// `edit-vendor-changed` -- runs exactly as in production. This is the flow that +// `edit-form-changed` (op=vendor-changed) -- runs exactly as in production. This is the flow that // regressed: a stale native `change` from the search input used to win the race // and revert the vendor to its previous value. async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: string) { - const wrapper = page.locator('div[hx-post*="edit-vendor-changed"]').first(); + const wrapper = page.locator('div[hx-vals*="vendor-changed"]').first(); const typeahead = wrapper.locator('div.relative[x-data]').first(); // Open the dropdown (tippy renders the popper into [data-tippy-root]). @@ -493,7 +430,7 @@ async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: await page.waitForResponse( (response: any) => - response.url().includes('/edit-vendor-changed') && response.status() === 200 + response.url().includes('/edit-form-changed') && response.status() === 200 ); await page.waitForTimeout(500); } @@ -510,9 +447,9 @@ async function openManualVendorSection(page: any, transactionIndex: number) { await editButton.click(); await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); - await page.waitForSelector('#wizardmodal'); + await page.waitForSelector('#editmodal'); await page.click('button:has-text("Manual")'); - await page.waitForSelector('div[hx-post*="edit-vendor-changed"]'); + await page.waitForSelector('div[hx-vals*="vendor-changed"]'); } test.describe('Transaction Edit Vendor Selection', () => { @@ -528,14 +465,14 @@ test.describe('Transaction Edit Vendor Selection', () => { // round-trip. Before the fix this reverted to blank because a stale // `change` event submitted the previous vendor and its response won. const label = page - .locator('div[hx-post*="edit-vendor-changed"] span[x-text="value.label"]') + .locator('div[hx-vals*="vendor-changed"] span[x-text="value.label"]') .first(); await expect(label).toHaveText('Test Vendor'); // The server-rendered hidden input must carry the newly selected vendor id. const hidden = page .locator( - 'div[hx-post*="edit-vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]' + 'div[hx-vals*="vendor-changed"] input[type="hidden"][name="transaction/vendor"]' ) .first(); await expect(hidden).toHaveValue(vendorId.toString()); diff --git a/playwright.config.ts b/playwright.config.ts index 499ba2ec..a6c57fc2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,11 @@ import { defineConfig, devices } from '@playwright/test'; +// Allow pointing the suite at an already-running test server (e.g. one booted from a +// specific worktree on a non-default port) via BASE_URL. When BASE_URL is set we skip +// the auto-started webServer entirely, so parallel worktrees don't fight over :3333. +const baseURL = process.env.BASE_URL ?? 'http://localhost:3333'; +const useExternalServer = !!process.env.BASE_URL; + export default defineConfig({ testDir: './e2e', fullyParallel: true, @@ -8,15 +14,17 @@ export default defineConfig({ workers: process.env.CI ? 1 : undefined, reporter: 'html', use: { - baseURL: 'http://localhost:3333', + baseURL, trace: 'on-first-retry', }, - webServer: { - command: 'lein run -m auto-ap.test-server', - url: 'http://localhost:3333/test-info', - reuseExistingServer: !process.env.CI, - timeout: 120000, - }, + webServer: useExternalServer + ? undefined + : { + command: 'lein run -m auto-ap.test-server', + url: 'http://localhost:3333/test-info', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, projects: [ { name: 'chromium', diff --git a/project.clj b/project.clj index e81c06ff..51a918f0 100644 --- a/project.clj +++ b/project.clj @@ -96,6 +96,7 @@ [org.clojure/core.async]] [hiccup "2.0.0-alpha2"] + [selmer "1.12.61"] ;; needed for java 11 [javax.xml.bind/jaxb-api "2.4.0-b180830.0359"] diff --git a/resources/public/js/htmx-disable.js b/resources/public/js/htmx-disable.js index 38083f9d..b8094d52 100644 --- a/resources/public/js/htmx-disable.js +++ b/resources/public/js/htmx-disable.js @@ -416,4 +416,64 @@ htmx.onLoad(function(content) { console.error('Failed to copy text to clipboard:', err); } } +/* +(function() { + var lastFocusedSelector = null; + var lastCursorPosition = null; + + document.addEventListener('htmx:beforeSwap', function(evt) { + var active = document.activeElement; + if (active && active !== document.body) { + // Build a selector to find this element after swap + if (active.id) { + lastFocusedSelector = '#' + active.id; + } else if (active.name) { + lastFocusedSelector = '[name="' + active.name + '"]'; + } else { + lastFocusedSelector = null; + } + + // Save cursor position for text inputs. selectionStart is null on + // inputs that don't support selection (number, date, select, etc.), + // and calling setSelectionRange on those throws, so only capture it + // when it's an actual numeric caret position. + if (typeof active.selectionStart === 'number') { + lastCursorPosition = { + start: active.selectionStart, + end: active.selectionEnd, + direction: active.selectionDirection + }; + } else { + lastCursorPosition = null; + } + } + }); + + document.addEventListener('htmx:afterSwap', function(evt) { + if (lastFocusedSelector) { + setTimeout(function() { + var el = document.querySelector(lastFocusedSelector); + // If morph already kept focus on the right element there's nothing + // to do; only restore when focus was actually lost by the swap. + if (el && el.focus && document.activeElement !== el) { + el.focus(); + if (lastCursorPosition && el.setSelectionRange) { + try { + el.setSelectionRange( + lastCursorPosition.start, + lastCursorPosition.end, + lastCursorPosition.direction + ); + } catch (e) { } + } + } + lastFocusedSelector = null; + lastCursorPosition = null; + }, 10); + } + }); +})(); + +*/ + diff --git a/resources/templates/components/a-button.html b/resources/templates/components/a-button.html new file mode 100644 index 00000000..c746750c --- /dev/null +++ b/resources/templates/components/a-button.html @@ -0,0 +1 @@ +{% if indicator %}
{% include "templates/components/spinner.html" %}
Loading...
{% endif %}
{{ body|safe }}
diff --git a/resources/templates/components/a-icon-button.html b/resources/templates/components/a-icon-button.html new file mode 100644 index 00000000..9265f2a5 --- /dev/null +++ b/resources/templates/components/a-icon-button.html @@ -0,0 +1 @@ +
{{ body|safe }}
diff --git a/resources/templates/components/badge.html b/resources/templates/components/badge.html new file mode 100644 index 00000000..cc8a6630 --- /dev/null +++ b/resources/templates/components/badge.html @@ -0,0 +1 @@ +
{{ body|safe }}
diff --git a/resources/templates/components/button-group-button.html b/resources/templates/components/button-group-button.html new file mode 100644 index 00000000..fc8c23ac --- /dev/null +++ b/resources/templates/components/button-group-button.html @@ -0,0 +1 @@ + diff --git a/resources/templates/components/button-group.html b/resources/templates/components/button-group.html new file mode 100644 index 00000000..8cd98844 --- /dev/null +++ b/resources/templates/components/button-group.html @@ -0,0 +1 @@ +
{{ body|safe }}
diff --git a/resources/templates/components/button.html b/resources/templates/components/button.html new file mode 100644 index 00000000..875664db --- /dev/null +++ b/resources/templates/components/button.html @@ -0,0 +1 @@ + diff --git a/resources/templates/components/data-grid-cell.html b/resources/templates/components/data-grid-cell.html new file mode 100644 index 00000000..96006923 --- /dev/null +++ b/resources/templates/components/data-grid-cell.html @@ -0,0 +1 @@ +{{ body|safe }} diff --git a/resources/templates/components/data-grid-header.html b/resources/templates/components/data-grid-header.html new file mode 100644 index 00000000..7b2a904a --- /dev/null +++ b/resources/templates/components/data-grid-header.html @@ -0,0 +1 @@ +{% if sort_key %}{{ body|safe }}{% else %}{{ body|safe }}{% endif %} diff --git a/resources/templates/components/data-grid-row.html b/resources/templates/components/data-grid-row.html new file mode 100644 index 00000000..8162179f --- /dev/null +++ b/resources/templates/components/data-grid-row.html @@ -0,0 +1 @@ +{{ body|safe }} diff --git a/resources/templates/components/data-grid.html b/resources/templates/components/data-grid.html new file mode 100644 index 00000000..6231a37f --- /dev/null +++ b/resources/templates/components/data-grid.html @@ -0,0 +1 @@ +
{{ headers|safe }}{{ rows|safe }}{{ footer_tbody|safe }}
diff --git a/resources/templates/components/hidden.html b/resources/templates/components/hidden.html new file mode 100644 index 00000000..1c8971d6 --- /dev/null +++ b/resources/templates/components/hidden.html @@ -0,0 +1,3 @@ +{# Hidden input. The Clojure wrapper (sc/hidden) serializes the full attribute map + (name, value, optional id/form/class/Alpine :value bind) into `attrs`. #} + diff --git a/resources/templates/components/link.html b/resources/templates/components/link.html new file mode 100644 index 00000000..168a975d --- /dev/null +++ b/resources/templates/components/link.html @@ -0,0 +1 @@ +{{ body|safe }} diff --git a/resources/templates/components/location-select.html b/resources/templates/components/location-select.html new file mode 100644 index 00000000..c96755d7 --- /dev/null +++ b/resources/templates/components/location-select.html @@ -0,0 +1,8 @@ +{# Location + {% for opt in options %} + + {% endfor %} + diff --git a/resources/templates/components/modal.html b/resources/templates/components/modal.html new file mode 100644 index 00000000..497175e0 --- /dev/null +++ b/resources/templates/components/modal.html @@ -0,0 +1 @@ +
{{ body|safe }}
diff --git a/resources/templates/components/money-input.html b/resources/templates/components/money-input.html new file mode 100644 index 00000000..99103792 --- /dev/null +++ b/resources/templates/components/money-input.html @@ -0,0 +1,2 @@ +{# Money input (number, step=0.01, right-aligned). sc/money-input builds `attrs`. #} + diff --git a/resources/templates/components/radio-card.html b/resources/templates/components/radio-card.html new file mode 100644 index 00000000..e7b9e56e --- /dev/null +++ b/resources/templates/components/radio-card.html @@ -0,0 +1 @@ +
    {% for opt in options %}
  • {% endfor %}
diff --git a/resources/templates/components/spinner.html b/resources/templates/components/spinner.html new file mode 100644 index 00000000..2b408d87 --- /dev/null +++ b/resources/templates/components/spinner.html @@ -0,0 +1 @@ + diff --git a/resources/templates/components/svg-drop-down.html b/resources/templates/components/svg-drop-down.html new file mode 100644 index 00000000..7c7f13f5 --- /dev/null +++ b/resources/templates/components/svg-drop-down.html @@ -0,0 +1 @@ + diff --git a/resources/templates/components/svg-external-link.html b/resources/templates/components/svg-external-link.html new file mode 100644 index 00000000..6280d30b --- /dev/null +++ b/resources/templates/components/svg-external-link.html @@ -0,0 +1 @@ +navigation-next diff --git a/resources/templates/components/svg-x.html b/resources/templates/components/svg-x.html new file mode 100644 index 00000000..717e9253 --- /dev/null +++ b/resources/templates/components/svg-x.html @@ -0,0 +1 @@ +delete-2 diff --git a/resources/templates/components/text-input.html b/resources/templates/components/text-input.html new file mode 100644 index 00000000..79f9dd58 --- /dev/null +++ b/resources/templates/components/text-input.html @@ -0,0 +1,3 @@ +{# Text input. sc/text-input builds the full attr map (type/autocomplete/class+size + already merged, reusing inputs/default-input-classes + hh/add-class) into `attrs`. #} + diff --git a/resources/templates/components/typeahead.html b/resources/templates/components/typeahead.html new file mode 100644 index 00000000..7df07c9e --- /dev/null +++ b/resources/templates/components/typeahead.html @@ -0,0 +1,4 @@ +{# Click-to-select typeahead (Alpine + tippy). Survives whole-form swaps; null-guarded + tippy?. / $refs.input? throughout. The Clojure wrapper (sc/typeahead) resolves the + initial {value,label} server-side and builds x_data + the hidden-input attrs. #} +
{% if disabled %}{% else %}
{% include "templates/components/svg-drop-down.html" %}
!
{% endif %}
diff --git a/resources/templates/components/validated-field.html b/resources/templates/components/validated-field.html new file mode 100644 index 00000000..96806adf --- /dev/null +++ b/resources/templates/components/validated-field.html @@ -0,0 +1,6 @@ +{# Field wrapper with label + always-present error

(the errors- variant of field-). + `classes` already folds group / has-error / caller class via hh/add-class; `attrs` + carries any pass-through div attributes (the per-row location cell hangs its hx-* / + x-dispatch swap wiring here); `body` is the pre-rendered inner control HTML; + `errors_str` is the comma-joined string errors (empty when none). #} +

{% if label %}{% endif %}{{ body|safe }}

{{ errors_str }}

diff --git a/resources/templates/interop-smoke.html b/resources/templates/interop-smoke.html new file mode 100644 index 00000000..deac9901 --- /dev/null +++ b/resources/templates/interop-smoke.html @@ -0,0 +1,7 @@ +
+

{{ title }}

+ {# a Hiccup-rendered component, passed in pre-rendered and emitted verbatim #} + {{ hiccup_frag|safe }} + +
diff --git a/resources/templates/transaction-edit/account-totals.html b/resources/templates/transaction-edit/account-totals.html new file mode 100644 index 00000000..cdc8f1b8 --- /dev/null +++ b/resources/templates/transaction-edit/account-totals.html @@ -0,0 +1,3 @@ +{# Totals live in their own swappable so an amount edit refreshes them with a + targeted swap, never replacing the input-bearing rows above (caret survives). #} +{{ rows|safe }} diff --git a/resources/templates/transaction-edit/approval-status.html b/resources/templates/transaction-edit/approval-status.html new file mode 100644 index 00000000..58a7dc00 --- /dev/null +++ b/resources/templates/transaction-edit/approval-status.html @@ -0,0 +1 @@ +
{{ status_hidden|safe }}
{{ buttons|safe }}
diff --git a/resources/templates/transaction-edit/details-panel.html b/resources/templates/transaction-edit/details-panel.html new file mode 100644 index 00000000..6a584217 --- /dev/null +++ b/resources/templates/transaction-edit/details-panel.html @@ -0,0 +1,2 @@ +{# Read-only transaction summary shown in the modal's left side panel. #} +

Details

Amount
{{ amount }}
Date
{{ date }}
Bank Account
{{ bank_account }}
Post Date
{{ post_date }}
Description
{{ description_simple }}
Check Number
{{ check_number }}
Status
{{ status }}
Transaction Type
{{ type }}
diff --git a/resources/templates/transaction-edit/edit-form.html b/resources/templates/transaction-edit/edit-form.html new file mode 100644 index 00000000..64e7a2b6 --- /dev/null +++ b/resources/templates/transaction-edit/edit-form.html @@ -0,0 +1,4 @@ +{# Top-level plain form. The entity id rides in a hidden field; all other state is the + live form, re-derived against the entity each request (no serialized snapshot, no + wizard step-params). #} +{{ modal|safe }} diff --git a/resources/templates/transaction-edit/edit-modal.html b/resources/templates/transaction-edit/edit-modal.html new file mode 100644 index 00000000..734ee8d1 --- /dev/null +++ b/resources/templates/transaction-edit/edit-modal.html @@ -0,0 +1,4 @@ +{# Modal card chrome (header / optional side panel / body / footer). Single-step, so + no timeline, no back/next nav -- just the Done button in the footer. Enter triggers + the save button via $refs.next. #} + diff --git a/resources/templates/transaction-edit/invoice-option.html b/resources/templates/transaction-edit/invoice-option.html new file mode 100644 index 00000000..ac85e955 --- /dev/null +++ b/resources/templates/transaction-edit/invoice-option.html @@ -0,0 +1 @@ +
{{ number }}{{ vendor }}{{ date }}{{ amount }}
diff --git a/resources/templates/transaction-edit/linked-payment.html b/resources/templates/transaction-edit/linked-payment.html new file mode 100644 index 00000000..7c695c62 --- /dev/null +++ b/resources/templates/transaction-edit/linked-payment.html @@ -0,0 +1 @@ +

Linked Payment{{ external_link|safe }}

Payment #
{{ number }}
Vendor
{{ vendor }}
Amount
{{ amount }}
Status
{{ status }}
Date
{{ date }}
{{ payment_id_hidden|safe }}
{{ unlink_button|safe }}
diff --git a/resources/templates/transaction-edit/links-body.html b/resources/templates/transaction-edit/links-body.html new file mode 100644 index 00000000..5332da45 --- /dev/null +++ b/resources/templates/transaction-edit/links-body.html @@ -0,0 +1,3 @@ +{# The single step's body: memo + the activeForm tab switcher (link payment / unpaid / + autopay / rule / manual) + the five x-show panels. Fragments are pre-rendered. #} +
{{ memo_field|safe }}
{{ action_hidden|safe }}{{ tabs|safe }}
{{ panel_payment|safe }}
{{ panel_unpaid|safe }}
{{ panel_autopay|safe }}
{{ panel_rule|safe }}
{{ panel_manual|safe }}
diff --git a/resources/templates/transaction-edit/manual-coding.html b/resources/templates/transaction-edit/manual-coding.html new file mode 100644 index 00000000..c9315560 --- /dev/null +++ b/resources/templates/transaction-edit/manual-coding.html @@ -0,0 +1,3 @@ +{# Vendor field (a change repopulates the default account via a whole-form swap) + either + the simple single-row coding or the advanced account grid. #} +
{{ mode_hidden|safe }}{{ vendor_field|safe }}
{% if is_simple %}
{{ simple_mode|safe }}
{% else %}
{{ toggle_link|safe }}{{ accounts_field|safe }}
{% endif %} diff --git a/resources/templates/transaction-edit/panel-empty.html b/resources/templates/transaction-edit/panel-empty.html new file mode 100644 index 00000000..78e81f16 --- /dev/null +++ b/resources/templates/transaction-edit/panel-empty.html @@ -0,0 +1 @@ +
{{ message }}
diff --git a/resources/templates/transaction-edit/panel-list.html b/resources/templates/transaction-edit/panel-list.html new file mode 100644 index 00000000..0bfe7219 --- /dev/null +++ b/resources/templates/transaction-edit/panel-list.html @@ -0,0 +1,3 @@ +{# Shared shell for the autopay/unpaid/rule match panels: heading + action hidden + + prompt label + a radio-card of options. #} +

{{ heading }}

{{ action_hidden|safe }}
{{ radio|safe }}
diff --git a/resources/templates/transaction-edit/payment-matches.html b/resources/templates/transaction-edit/payment-matches.html new file mode 100644 index 00000000..6d1ff5d5 --- /dev/null +++ b/resources/templates/transaction-edit/payment-matches.html @@ -0,0 +1,2 @@ +{# Outer wrapper for the payment tab; the unlink-payment route swaps #payment-matches. #} +
{{ inner|safe }}
diff --git a/resources/templates/transaction-edit/rule-option.html b/resources/templates/transaction-edit/rule-option.html new file mode 100644 index 00000000..1ecaaa23 --- /dev/null +++ b/resources/templates/transaction-edit/rule-option.html @@ -0,0 +1 @@ +
{{ note }}{{ description }}
diff --git a/resources/templates/transaction-edit/simple-mode.html b/resources/templates/transaction-edit/simple-mode.html new file mode 100644 index 00000000..aa72b073 --- /dev/null +++ b/resources/templates/transaction-edit/simple-mode.html @@ -0,0 +1,4 @@ +{# Simple mode: a single account row (account typeahead + location select) rendered at a + fixed index 0, plus the link to switch to the advanced grid. Selecting the account + swaps just the location cell (#simple-account-location). #} +
{{ row_id_hidden|safe }}
{{ account_field|safe }}
{{ location_field|safe }}
{{ amount_hidden|safe }}
diff --git a/resources/templates/transaction-edit/transitioner.html b/resources/templates/transaction-edit/transitioner.html new file mode 100644 index 00000000..f96ae53d --- /dev/null +++ b/resources/templates/transaction-edit/transitioner.html @@ -0,0 +1,3 @@ +{# Wrapper the modal stack expects around the opened form (the wizard transition hooks + are gone -- there is only one step). #} +
{{ body|safe }}
diff --git a/src/clj/auto_ap/ssr/components/data_grid.clj b/src/clj/auto_ap/ssr/components/data_grid.clj index e9bd9f3b..148ee01d 100644 --- a/src/clj/auto_ap/ssr/components/data_grid.clj +++ b/src/clj/auto_ap/ssr/components/data_grid.clj @@ -45,10 +45,10 @@ [:label {:for "checkbox-all", :class "sr-only"} "checkbox"]]]) (defn data-grid- - [{:keys [headers thead-params id] :as params} & rest] + [{:keys [headers thead-params id footer-tbody] :as params} & rest] [:div.shrink.overflow-y-scroll [:table (merge {:class "w-full text-sm text-left text-gray-500 dark:text-gray-400 shrink"} - (dissoc params :headers :thead-params)) + (dissoc params :headers :thead-params :footer-tbody)) [:thead (update thead-params :class #(-> "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 group-[.raw]:sticky group-[.raw]:z-10 group-[.raw]:top-0" (hh/add-class (or % "")))) (into @@ -56,7 +56,11 @@ headers)] (into [:tbody {}] - rest)]]) + rest) + ;; Optional second (valid HTML) so callers can keep a stable, + ;; separately-swappable region in the same table -- e.g. totals rows that + ;; update without touching the input-bearing rows above them. + footer-tbody]]) ;; needed for tailwind ;; lg:table-cell md:table-cell diff --git a/src/clj/auto_ap/ssr/components/inputs.clj b/src/clj/auto_ap/ssr/components/inputs.clj index ae865616..1d3bbf81 100644 --- a/src/clj/auto_ap/ssr/components/inputs.clj +++ b/src/clj/auto_ap/ssr/components/inputs.clj @@ -51,23 +51,28 @@ {:x-init "$el.indeterminate = true"}))])) (defn typeahead- [params] - [:div.relative {:x-data (hx/json {:baseUrl (str (:url params)) - :value {:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))} - :tippy nil - :search "" - :active -1 - :elements (if ((:value-fn params identity) (:value params)) - [{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}] - [])}) - :x-modelable "value.value" - :x-model (:x-model params)} + [:div.relative (cond-> {:x-data (hx/json {:baseUrl (str (:url params)) + :value {:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))} + :tippy nil + :search "" + :active -1 + :elements (if ((:value-fn params identity) (:value params)) + [{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}] + [])}) + :x-modelable "value.value" + :x-model (:x-model params)} + ;; Key the component by its current value so alpine-morph re-initialises + ;; it (rather than preserving stale Alpine x-data) whenever the *server* + ;; changes the value -- e.g. the default account a vendor selection + ;; populates. alpine-morph keys off the `key` attribute, not `id`. + (:id params) (assoc :key (str (:id params) "--" ((:value-fn params identity) (:value params))))) (if (:disabled params) [:span {:x-text "value.label"}] [:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes) (hh/add-class "cursor-pointer")) - "x-tooltip.on.click" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" - "@keydown.down.prevent.stop" "tippy.show();" - "@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }" + "x-tooltip.on.click" "{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" + "@keydown.down.prevent.stop" "tippy?.show();" + "@keydown.backspace" "tippy?.hide(); value = {value: '', label: '' }" :tabindex 0 :x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) :x-ref "input"} @@ -94,7 +99,7 @@ [:template {:x-ref "dropdown"} [:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1" - "@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; " + "@keydown.escape" "$refs.input?.__x_tippy?.hide(); value = {value: '', label: '' }; " :x-destroy "if ($refs.input) {$refs.input.focus();}"} [:input {:type "text" :autofocus true @@ -107,8 +112,8 @@ "@change.stop" "" "@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active" "@keydown.up.prevent" "active --; active = active < 0 ? 0 : active" - "@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input.focus()" - "x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}] + "@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input?.focus()" + "x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; $refs.input?.__x_tippy?.popperInstance?.update()}) }})"}] [:div.dropdown-options {:class "rounded-b-lg overflow-hidden"} [:template {:x-for "(element, index) in elements"} [:li [:a {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100" @@ -117,7 +122,7 @@ "@mouseover" "active = index" "@mouseout" "active = -1" - "@click.prevent" "value = element; tippy.hide(); setTimeout(() => $refs.input.focus(), 10)" + "@click.prevent" "value = element; $refs.input?.__x_tippy?.hide(); setTimeout(() => $refs.input?.focus(), 10)" "x-html" "element.label"}]]] [:template {:x-if "elements.length == 0"} [:li {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs "} @@ -126,7 +131,7 @@ (defn multi-typeahead-dropdown- [params] [:template {:x-ref "dropdown"} [:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 ring-1 p-4" - "@keydown.escape.prevent" "tippy.hide();" + "@keydown.escape.prevent" "$refs.input?.__x_tippy?.hide();" :x-destroy "if ($refs.input) {$refs.input.focus();}"} [:div {:class (-> "relative" #_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))} @@ -240,9 +245,9 @@ [:span {:x-text "value.label"}] [:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes) (hh/add-class "cursor-pointer")) - "x-tooltip.on.click.prevent" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" - "@keydown.down.prevent.stop" "tippy.show();" - "@keydown.backspace" "tippy.hide(); value=new Set( []);" + "x-tooltip.on.click.prevent" "{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" + "@keydown.down.prevent.stop" "$refs.input?.__x_tippy?.show();" + "@keydown.backspace" "$refs.input?.__x_tippy?.hide(); value=new Set( []);" :tabindex 0 :x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) :x-ref "input"} @@ -325,7 +330,7 @@ (-> params (update :class (fnil hh/add-class "") default-input-classes) (assoc :x-model "value") - (assoc "x-tooltip.on.focus" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}") + (assoc "x-tooltip.on.focus" "{content: ()=>($refs.tooltip?.innerHTML ?? ''), theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}") (assoc :x-init "$nextTick(() => tippy = $el.__x_tippy); ") (assoc :type "text") @@ -333,7 +338,7 @@ (assoc "autocomplete" "off") (assoc "@change" "value = $event.target.value;") - (assoc "@keydown.escape" "tippy.hide(); ") + (assoc "@keydown.escape" "$el?.__x_tippy?.hide(); ") #_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") ")) (update :class #(str % (use-size size) " w-full")) (dissoc :size))] diff --git a/src/clj/auto_ap/ssr/components/selmer.clj b/src/clj/auto_ap/ssr/components/selmer.clj new file mode 100644 index 00000000..1226145a --- /dev/null +++ b/src/clj/auto_ap/ssr/components/selmer.clj @@ -0,0 +1,292 @@ +(ns auto-ap.ssr.components.selmer + "Selmer-rendered versions of the shared SSR components used by the Transaction Edit + modal (see .claude/skills/ssr-form-migration). Each wrapper assembles a plain-data + context and renders its own template under resources/templates/components/ via the + interop bridge -- the element structure lives entirely in the .html templates; the + only Clojure is data assembly. Dynamic HTMX/Alpine attributes (which vary per call + site) are serialized to an attribute string by `attrs->str` and injected with + {{ attrs|safe }}, so the templates stay free of per-attribute {% if %} ladders. + + Reuses class logic from auto-ap.ssr.components.inputs so output matches the Hiccup + components byte-for-byte modulo Tailwind class ordering (verify by string-match + + e2e, never byte-parity -- see selmer-conventions.md)." + (:require + [auto-ap.ssr.components.buttons :as btn] + [auto-ap.ssr.components.inputs :as inputs] + [auto-ap.ssr.hiccup-helper :as hh] + [auto-ap.ssr.hx :as hx] + [auto-ap.ssr.selmer :as sel] + [clojure.string :as str] + [hiccup.util :as hu])) + +(defn- attr-name [k] + (if (keyword? k) (subs (str k) 1) (str k))) + +(defn attrs->str + "Serialize an attribute map to an HTML attribute string with a leading space, so it + concatenates after fixed template attributes: . + nil/false values are dropped, true renders a bare boolean attribute, everything else + renders name=\"escaped-value\". Mirrors how hiccup2 emits attributes." + [m] + (->> m + (keep (fn [[k v]] + (cond + (nil? v) nil + (false? v) nil + (true? v) (str " " (attr-name k)) + :else (str " " (attr-name k) "=\"" + (hu/escape-html (if (keyword? v) (name v) (str v))) + "\"")))) + (apply str))) + +(defn render + "Render a component partial and trim outer whitespace (so {# comments #} and the + file's trailing newline don't leak into the embedding tree). Returns a raw-wrapped + string ready to drop into Hiccup or another Selmer context value." + [template ctx] + (sel/raw (str/trim (sel/render template ctx)))) + +(defn- body->html + "Render child content (Hiccup vectors and/or raw Selmer fragments) to an HTML string." + [body] + (->> (if (sequential? body) body [body]) + (remove nil?) + (map sel/hiccup->html) + (apply str))) + +;; --- leaf inputs ----------------------------------------------------------------- + +(defn hidden [{:keys [name value] :as params}] + (render "templates/components/hidden.html" + {:attrs (attrs->str (merge {:name name} + (when (some? value) {:value value}) + (dissoc params :name :value)))})) + +(defn text-input [{:keys [size] :as params}] + (let [attrs (-> params + (dissoc :error? :size) + (assoc :type "text" :autocomplete "off") + (update :class #(-> "" + (hh/add-class inputs/default-input-classes) + (hh/add-class %))) + (update :class #(str % (inputs/use-size size))))] + (render "templates/components/text-input.html" {:attrs (attrs->str attrs)}))) + +(defn money-input [{:keys [size] :as params}] + (let [attrs (-> params + (dissoc :size) + (update :class (fnil hh/add-class "") inputs/default-input-classes) + (update :class hh/add-class "appearance-none text-right") + (update :class #(str % (inputs/use-size size))) + (assoc :type "number" :step "0.01"))] + (render "templates/components/money-input.html" {:attrs (attrs->str attrs)}))) + +;; --- field wrapper --------------------------------------------------------------- + +(defn validated-field + "Selmer port of com/validated-field (the errors- variant of field-): label + body + + an always-present error

. Pass-through attrs land on the wrapping div (the account + row's location cell hangs its swap wiring here)." + [{:keys [label errors] :as params} & body] + (let [classes (cond-> (or (:class params) "") + (sequential? errors) (hh/add-class "has-error") + :always (hh/add-class "group")) + attrs (dissoc params :label :errors :error-source :error-key :class) + errors-str (when (sequential? errors) + (str/join ", " (filter string? errors)))] + (render "templates/components/validated-field.html" + {:label label + :classes classes + :attrs (attrs->str attrs) + :body (body->html body) + :errors_str (or errors-str "")}))) + +;; --- buttons / badges / links ---------------------------------------------------- + +(defn badge [{:keys [color] :as params} & children] + (let [classes (-> (hh/add-class + "absolute inline-flex items-center z-10 justify-center w-6 h-6 text-xs font-black text-white \n border-3 border-white rounded-full -top-2 -right-2 dark:border-gray-900" + (:class params)) + (hh/add-class (or (some-> color (#(str "bg-" % "-300"))) "bg-red-300")))] + (render "templates/components/badge.html" + {:classes classes + :attrs (attrs->str (dissoc params :class)) + :body (body->html children)}))) + +(defn link [{:keys [class] :as params} & children] + (render "templates/components/link.html" + {:classes (str class " font-medium text-blue-600 dark:text-blue-500 hover:underline cursor-pointer") + :attrs (attrs->str (dissoc params :class)) + :body (body->html children)})) + +(defn button [{:keys [color disabled minimal-loading?] :as params} & children] + (let [classes (cond-> (:class params) + true (str " focus:ring-4 font-bold rounded-lg text-xs p-3 text-center mr-2 inline-flex items-center relative justify-center disabled:opacity-50" + (btn/bg-colors color disabled)) + (not disabled) (str " hover:scale-105 transition duration-100") + disabled (str " cursor-not-allowed") + (some? color) (str " text-white ") + (nil? color) (str " bg-white dark:bg-gray-600 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 font-medium border border-gray-300 dark:border-gray-700"))] + (render "templates/components/button.html" + {:classes classes + :attrs (attrs->str (dissoc params :class)) + :loading_label (not minimal-loading?) + :body (body->html children)}))) + +(defn a-button [{:keys [color disabled] :as params} & children] + (let [indicator? (:indicator? params true) + classes (cond-> (:class params) + true (str " focus:ring-4 font-bold rounded-lg text-xs p-3 text-center mr-2 inline-flex items-center hover:scale-105 transition duration-100 justify-center") + (= :secondary color) (str " text-white bg-blue-500 hover:bg-blue-600 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700") + (= :primary color) (str " text-white bg-green-500 hover:bg-green-600 focus:ring-green-300 dark:bg-green-600 dark:hover:bg-green-700 ") + (= :secondary-light color) (str " text-blue-800 bg-white-200 border-gray-100 border hover:bg-blue-100 focus:ring-blue-100 dark:bg-blue-400 dark:hover:bg-blue-800 ") + (some? color) (str " text-white " (btn/bg-colors color disabled)) + (nil? color) (str " bg-white dark:bg-gray-600 border-gray-300 dark:border-gray-700 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 font-medium border border-gray-300 dark:border-gray-700"))] + (render "templates/components/a-button.html" + {:classes classes + :attrs (attrs->str (-> (dissoc params :class) + (assoc :tabindex 0 :href (:href params "#")))) + :indicator indicator? + :body (body->html children)}))) + +(defn a-icon-button [{:keys [class] :as params} & children] + (let [class-str (or class "") + has-padding? (re-find #"\bp[xy]?-\d+(\.\d+)?\b" class-str) + classes (str class-str (if has-padding? "" " p-3") + " inline-flex items-center justify-center bg-white dark:bg-gray-600 items-center text-sm font-medium border border-gray-300 dark:border-gray-700 text-center text-gray-500 hover:text-gray-800 rounded-lg dark:text-gray-400 dark:hover:text-gray-100")] + (render "templates/components/a-icon-button.html" + {:classes classes + :attrs (attrs->str (-> (dissoc params :class) + (assoc :href (or (:href params) "")))) + :body (body->html children)}))) + +(defn button-group-button [{:keys [size] :or {size :normal} :as params} & children] + (let [classes (cond-> (:class params) + true (str " font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-primary-700 focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-green-700 dark:bg-gray-700 dark:border-gray-600 dark:text-white dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-green-500 dark:focus:text-white disabled:opacity-50") + (= :small size) (str " text-xs px-3 py-2") + (= :normal size) (str " text-sm px-4 py-2"))] + (render "templates/components/button-group-button.html" + {:classes classes + :attrs (attrs->str (-> (dissoc params :class :size) + (assoc :type (or (:type params) "button")))) + :body (body->html children)}))) + +(defn button-group [{:keys [name]} & children] + (render "templates/components/button-group.html" + {:name name + :body (body->html children)})) + +;; --- radio-card ------------------------------------------------------------------ + +(defn radio-card + "Selmer port of com/radio-card. NB: the Hiccup radio-card- has a dangling [:h3 title] + the let discards, so only the

    renders -- reproduced here. Only the documented + htmx keys ride onto each (the same select-keys filter; :hx-vals / :hx-select + are intentionally dropped, matching existing behavior)." + [{:keys [options name title size orientation width] :or {size :medium width "w-48"} + selected-value :value :as params}] + (let [htmx-attrs (select-keys params [:hx-post :hx-target :hx-swap :hx-include :hx-trigger]) + sel (cond-> selected-value (keyword? selected-value) clojure.core/name) + ul-class (cond-> " text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white" + (= orientation :horizontal) (-> (hh/add-class "flex gap-2 flex-wrap") + (hh/remove-wildcard ["w-" "rounded-lg" "border" "bg-"])) + :always (str " " width " ")) + li-class (cond-> "w-full border-b border-gray-200 rounded-t-lg dark:border-gray-600" + (= orientation :horizontal) (-> (hh/remove-wildcard ["w-full" "rounded-"]) + (hh/add-class "w-auto shrink-0 block rounded-lg border border-gray-200 dark:border-gray-600 px-3"))) + div-class (cond-> "flex items-center" + (not= orientation :horizontal) (hh/add-class "pl-3")) + input-class (cond-> "w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-700 dark:focus:ring-offset-gray-700 focus:ring-2 dark:bg-gray-600 dark:border-gray-500" + (= size :small) (str " text-xs") + (= size :medium) (str " text-sm")) + label-class (cond-> "w-full ml-2 font-medium text-gray-900 dark:text-gray-300" + (= size :small) (str " text-xs py-2") + (= size :medium) (str " text-sm py-3") + (= orientation :horizontal) (hh/remove-class "w-full"))] + (render "templates/components/radio-card.html" + {:ul_class ul-class :li_class li-class :div_class div-class + :input_class input-class :label_class label-class + :name name + :input_attrs (attrs->str htmx-attrs) + :options (for [{:keys [value content]} options] + {:id (str "list-" name "-" value) + :value value + :checked (= sel value) + :content (body->html content)})}))) + +;; --- data grid ------------------------------------------------------------------- + +(defn data-grid-header [params & body] + (render "templates/components/data-grid-header.html" + {:klass (:class params) + :click (format "$dispatch('sorted', {key: '%s'})" (:sort-key params)) + :sort_key (:sort-key params) + :attrs (attrs->str (cond-> {} (:style params) (assoc :style (:style params)))) + :body (body->html body)})) + +(defn data-grid-row [params & body] + (render "templates/components/data-grid-row.html" + {:classes (str (:class params) " border-b dark:border-gray-600 group hover:bg-gray-100 dark:hover:bg-gray-700") + :attrs (attrs->str (dissoc params :class)) + :body (body->html body)})) + +(defn data-grid-cell [params & body] + (render "templates/components/data-grid-cell.html" + {:klass (:class params) + :attrs (attrs->str (dissoc params :class)) + :body (body->html body)})) + +(defn data-grid + "Table shell: outer scroll div > table > thead(headers) > tbody(rows) + optional + footer-tbody. `headers`, `rows`, and `footer-tbody` are pre-rendered fragments." + [{:keys [headers footer-tbody] :as params} & rows] + (render "templates/components/data-grid.html" + {:table_class "w-full text-sm text-left text-gray-500 dark:text-gray-400 shrink" + :table_attrs (attrs->str (dissoc params :headers :thead-params :footer-tbody)) + :thead_class "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 group-[.raw]:sticky group-[.raw]:z-10 group-[.raw]:top-0" + :headers (body->html headers) + :rows (body->html rows) + :footer_tbody (when footer-tbody (body->html footer-tbody))})) + +;; --- modal + typeahead ----------------------------------------------------------- + +(defn modal [{:as params} & children] + (render "templates/components/modal.html" + {:classes (hh/add-class "" (:class params "")) + :attrs (attrs->str (dissoc params :handle-unexpected-error? :class)) + :body (body->html children)})) + +(defn typeahead + "Selmer port of com/typeahead. Resolves the initial {value,label} server-side via + value-fn/content-fn (DB lookups), builds the Alpine x-data, and serializes the + hidden posting-input attributes. Preserves every tippy?. null-guard." + [{:keys [value value-fn content-fn x-model x-init id class placeholder disabled url] + :as params}] + (let [vf (or value-fn identity) + cf (or content-fn identity) + vval (vf value) + vlabel (cf value) + x-data (hx/json {:baseUrl (str url) + :value {:value vval :label vlabel} + :tippy nil :search "" :active -1 + :elements (if vval [{:value vval :label vlabel}] [])}) + a-class (-> (hh/add-class (or class "") inputs/default-input-classes) + (hh/add-class "cursor-pointer")) + a-xinit (str "$nextTick(() => tippy = $el.__x_tippy); " x-init) + search-class (-> (or class "") + (hh/add-class inputs/default-input-classes) + (hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full")) + hidden-attrs (-> params + (dissoc :class :value-fn :content-fn :placeholder :x-model) + (assoc "x-ref" "hidden" :type "hidden" ":value" "value.value" + :x-init "$watch('value', v => { $el.value = (v && v.value != null) ? v.value : ''; $nextTick(() => $dispatch('change')); }); "))] + (render "templates/components/typeahead.html" + {:x_data x-data + :x_model x-model + :key (when id (str id "--" vval)) + :disabled disabled + :a_class a-class + :a_xinit a-xinit + :search_class search-class + :placeholder placeholder + :hidden_attrs (attrs->str hidden-attrs)}))) diff --git a/src/clj/auto_ap/ssr/selmer.clj b/src/clj/auto_ap/ssr/selmer.clj new file mode 100644 index 00000000..b96315e8 --- /dev/null +++ b/src/clj/auto_ap/ssr/selmer.clj @@ -0,0 +1,43 @@ +(ns auto-ap.ssr.selmer + "Selmer rendering + the Hiccup<->Selmer interop bridge for the SSR form/wizard + migration (see .claude/skills/ssr-form-migration). Interactive, attribute-heavy + components render from Selmer templates with plain-HTML Alpine/HTMX attributes; + the bridge lets a Selmer template embed Hiccup output and lets a Selmer fragment + sit inside a Hiccup tree during the strangler transition. + + Templates live under resources/templates/ and are referenced by classpath-relative + path, e.g. (render \"templates/components/typeahead.html\" ctx)." + (:require + [hiccup.util :as hu] + [hiccup2.core :as h2] + [selmer.parser :as selmer])) + +(defn hiccup->html + "Render a Hiccup form to an HTML string so it can be embedded in a Selmer + context value and emitted with the |safe filter: {{ frag|safe }}." + [hiccup] + (str (h2/html {} hiccup))) + +(defn raw + "Wrap an already-rendered HTML string (e.g. from `render`) so hiccup2 emits it + verbatim instead of escaping it. Use to drop a Selmer fragment into a Hiccup tree: + [:div (sel/raw (sel/render \"...\" ctx))]." + [^String html] + (hu/raw-string html)) + +(defn render + "Render a Selmer template file (classpath-relative path) with `ctx`, returning an + HTML string. Hiccup values in `ctx` should be pre-rendered via `hiccup->html` and + referenced with |safe in the template." + [template ctx] + (selmer/render-file template ctx)) + +(defn render-str + "Render a Selmer template given as a string (handy for tests/REPL)." + [template ctx] + (selmer/render template ctx)) + +(defn render->hiccup + "Render a Selmer template file and wrap the result for safe embedding in Hiccup." + [template ctx] + (raw (render template ctx))) diff --git a/src/clj/auto_ap/ssr/transaction/edit.clj b/src/clj/auto_ap/ssr/transaction/edit.clj index e66abe69..40a50610 100644 --- a/src/clj/auto_ap/ssr/transaction/edit.clj +++ b/src/clj/auto_ap/ssr/transaction/edit.clj @@ -1,6 +1,5 @@ (ns auto-ap.ssr.transaction.edit (:require - [auto-ap.cursor :as cursor] [auto-ap.datomic :refer [audit-transact conn pull-attr pull-ref]] [auto-ap.datomic.accounts :as d-accounts] @@ -19,29 +18,33 @@ [auto-ap.rule-matching :as rm] [auto-ap.solr :as solr] [auto-ap.ssr-routes :as ssr-routes] - [auto-ap.ssr.common-handlers :refer [add-new-entity-handler]] [auto-ap.ssr.components :as com] + [auto-ap.ssr.components.inputs :as inputs] + [auto-ap.ssr.components.selmer :as sc] [auto-ap.ssr.grid-page-helper :as helper] [auto-ap.ssr.transaction.common :refer [grid-page]] - [auto-ap.ssr.components.multi-modal :as mm] - [auto-ap.ssr.form-cursor :as fc] [auto-ap.ssr.hx :as hx] - [auto-ap.ssr.svg :as svg] + [auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]] + [auto-ap.ssr.selmer :as sel] [auto-ap.ssr.utils - :refer [->db-id apply-middleware-to-all-handlers check-allowance - check-location-belongs entity-id form-validation-error - html-response modal-response ref->enum-schema strip temp-id - wrap-entity wrap-schema-enforce]] + :refer [->db-id apply-middleware-to-all-handlers assert-schema + check-allowance check-location-belongs entity-id + form-validation-error html-response main-transformer + modal-response path->name2 ref->enum-schema strip temp-id + wrap-form-4xx-2 wrap-schema-enforce]] [auto-ap.time :as atime] [bidi.bidi :as bidi] [clj-time.coerce :as coerce] [clojure.edn :as edn] + [clojure.string :as str] [datomic.api :as dc] [hiccup.util :as hu] [iol-ion.query :refer [dollars=]] [iol-ion.tx :refer [random-tempid]] [malli.core :as mc])) +(declare render-full-form wrap-div) + (def transaction-approval-status {:transaction-approval-status/unapproved "Unapproved" :transaction-approval-status/approved "Approved" @@ -82,6 +85,7 @@ [:transaction/vendor {:optional true} [:maybe entity-id]] [:transaction/approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]] [:amount-mode {:optional true} [:maybe [:enum "$" "%"]]] + [:mode {:optional true} [:maybe [:enum "simple" "advanced"]]] [:transaction/accounts {:optional true} [:maybe [:vector {:coerce? true} @@ -150,40 +154,87 @@ (:vendor/default-account clientized)))) (defn location-select* + "The location