Whole-form htmx + Alpine morph for transaction edit

Re-render the entire #wizard-form on each field edit and swap with
hx-swap="morph" so the focused input keeps focus/caret/value while typing.

- Field-level routes return the full form and target #wizard-form
- Key state-owning wrappers (account rows, simple-mode wrapper, vendor
  typeahead) so server-driven value changes re-init across the morph
- Guard tippy/$refs access in typeahead against stale post-morph state
- Round-trip simple/advanced mode via step-params[mode]
- Add e2e/transaction-edit-morph.spec.ts covering focus/caret preservation,
  vendor->account population, and repeated vendor changes
- Seed a second vendor/account for test isolation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 07:40:30 -07:00
parent b6649a3d1d
commit cdb6bb6fe3
8 changed files with 681 additions and 185 deletions

View File

@@ -42,6 +42,8 @@
[iol-ion.tx :refer [random-tempid]]
[malli.core :as mc]))
(declare render-full-form)
(def transaction-approval-status
{:transaction-approval-status/unapproved "Unapproved"
:transaction-approval-status/approved "Approved"
@@ -82,6 +84,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}
@@ -229,9 +232,10 @@
client-id (assoc :client-id client-id)))
:x-dispatch:changed "simpleAccountId"
:hx-trigger "changed"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
:hx-target "find *"
:hx-swap "outerHTML"}
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target "#wizard-form"
:hx-swap "morph"
:hx-include "closest form"}
(location-select*
{:name (fc/field-name)
:account-location (:account/location account-id)
@@ -244,8 +248,8 @@
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
{:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
:hx-include "closest form"
:hx-target "#manual-coding-section"
:hx-swap "outerHTML"}
:hx-target "#wizard-form"
:hx-swap "morph"}
"Switch to advanced mode"]]]))
(defn- manual-mode-initial
@@ -256,9 +260,15 @@
:advanced
:simple)))
(defn transaction-account-row* [{:keys [value client-id amount-mode total]}]
(defn transaction-account-row* [{:keys [value client-id amount-mode total index]}]
(com/data-grid-row
(-> {:class "account-row"
:id (str "account-row-" index)
;; Key the row by its account id (alpine-morph keys off `key`, not `id`) so
;; a server-driven account change re-inits the row's x-data. Otherwise morph
;; preserves the stale accountId and the account typeahead (x-model="accountId")
;; snaps back to the old value.
:key (str "account-row-" index "--" (fc/field-value (:transaction-account/account value)))
:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
:accountId (fc/field-value (:transaction-account/account value))})
:data-key "show"
@@ -286,9 +296,10 @@
client-id (assoc :client-id client-id)))
:x-dispatch:changed "accountId"
:hx-trigger "changed"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
:hx-target "find *"
:hx-swap "outerHTML"}
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target "#wizard-form"
:hx-swap "morph"
:hx-include "closest form"}
(location-select* {:name (fc/field-name)
:account-location (:account/location (cond->> (:transaction-account/account @value)
(nat-int? (:transaction-account/account @value)) (dc/pull (dc/db conn)
@@ -300,17 +311,27 @@
{}
(com/validated-field
{:errors (fc/field-errors)}
(if (= "%" amount-mode)
(com/text-input {:name (fc/field-name)
:class "w-16 account-amount-field"
:value (fc/field-value)
:type "number"
:step "0.01"})
(com/money-input {:name (fc/field-name)
;; Editing an amount re-renders the whole form (so TOTAL/BALANCE recompute).
;; The stable id lets alpine-morph match this exact input across the swap,
;; keeping the user's focus and caret while they type.
(let [amount-attrs {:name (fc/field-name)
:id (str "account-amount-" index)
:class "w-16 account-amount-field"
:value (fc/field-value)})))))
:value (fc/field-value)
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target "#wizard-form"
:hx-swap "morph"
:hx-trigger "keyup changed delay:300ms"
:hx-include "closest form"}]
(if (= "%" amount-mode)
(com/text-input (assoc amount-attrs :type "number" :step "0.01"))
(com/money-input amount-attrs))))))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"
(com/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-remove-account)
:hx-vals (hx/json {:row-index (or index 0)})
:hx-target "#wizard-form"
:hx-swap "morph"
:hx-include "closest form"
:class "account-remove-action"} svg/x))))
(defn- account-field-name [index field]
@@ -450,31 +471,30 @@
:name "step-params[amount-mode]"
:orientation :horizontal
:hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode)
:hx-target "#account-grid-body"
:hx-swap "outerHTML"
:hx-target "#wizard-form"
:hx-swap "morph"
:hx-include "closest form"}))
(com/data-grid-header {:class "w-16"})]}
(fc/cursor-map #(transaction-account-row* {:value %
:client-id (-> request :entity :transaction/client :db/id)
:amount-mode amount-mode
:total total}))
(fc/cursor-map (fn [cursor]
(transaction-account-row* {:value cursor
:client-id (-> request :entity :transaction/client :db/id)
:amount-mode amount-mode
:total total
:index (last (cursor/path cursor))})))
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes
::route/edit-wizard-new-account)
:row-offset 0
:index (count (:transaction/accounts snapshot))
:tr-params {:hx-vals (hx/json {:client-id (:transaction/client snapshot)})}}
"New account")
(com/data-grid-row {:class "new-row"}
(com/data-grid-cell {:colspan 4}
(com/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-new-account)
:hx-target "#wizard-form"
:hx-swap "morph"
:hx-include "closest form"
:color :secondary}
"New account")))
(com/data-grid-row {:class "account-total-row"}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
(com/data-grid-cell {:id "total"
:class "text-right"
:hx-trigger "change from:closest form target:.amount-field"
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/account-total)
:hx-target "this"
:hx-swap "innerHTML"}
:class "text-right"}
(account-total* request))
(com/data-grid-cell {}))
@@ -482,11 +502,7 @@
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"])
(com/data-grid-cell {:id "total"
:class "text-right"
:hx-trigger "change from:closest form target:.amount-field"
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/account-balance)
:hx-target "this"
:hx-swap "innerHTML"}
:class "text-right"}
(account-balance* request))
(com/data-grid-cell {}))
@@ -509,11 +525,11 @@
(seq (:transaction/accounts snapshot)))
row-count (count all-accounts)]
[:div#manual-coding-section
(com/hidden {:name "mode" :value (name mode)})
(com/hidden {:name "step-params[mode]" :value (name mode)})
[:div {:hx-trigger "change"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
:hx-target "#manual-coding-section"
:hx-swap "outerHTML"
:hx-target "#wizard-form"
:hx-swap "morph"
:hx-sync "this:replace"
:hx-include "closest form"}
(fc/with-field :transaction/vendor
@@ -521,6 +537,11 @@
{:label "Vendor" :errors (fc/field-errors)}
[:div.w-96
(com/typeahead {:name (fc/field-name)
;; Key the vendor typeahead by its value so alpine-morph re-creates
;; it on each vendor change. A morph-preserved typeahead loses its
;; value $watch -> change dispatch, so a *second* vendor change would
;; otherwise never fire its htmx request.
:id (fc/field-name)
:error? (fc/error?)
:class "w-96"
:placeholder "Search..."
@@ -528,18 +549,24 @@
:value (fc/field-value)
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})]))]
(if (= mode :simple)
[:div {:x-data (hx/json {:simpleAccountId
(let [av (-> (first all-accounts) :transaction-account/account)]
(if (map? av) (:db/id av) av))})}
(simple-mode-fields* request)]
(let [simple-account-id (let [av (-> (first all-accounts) :transaction-account/account)]
(if (map? av) (:db/id av) av))]
;; Key this wrapper by the account id so alpine-morph re-inits its x-data
;; when the server changes the account (e.g. a vendor selection populating
;; its default account). Without a changing key, morph keeps the stale
;; simpleAccountId and the nested typeahead's x-model="simpleAccountId"
;; binds back to the empty value. alpine-morph keys off `key`, not `id`.
[:div {:key (str "simple-account-wrapper--" simple-account-id)
:x-data (hx/json {:simpleAccountId simple-account-id})}
(simple-mode-fields* request)])
[:div
(when (<= row-count 1)
[:div.mb-2
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer
{:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode)
:hx-include "closest form"
:hx-target "#manual-coding-section"
:hx-swap "outerHTML"}
:hx-target "#wizard-form"
:hx-swap "morph"}
"Switch to simple mode"]])
(fc/with-field :transaction/accounts
(com/validated-field
@@ -557,59 +584,7 @@
(assoc-in [:multi-form-state :snapshot :transaction/accounts] accounts)
(assoc-in [:multi-form-state :snapshot :amount-mode] new-mode))]
(html-response
[:div#account-grid-body
(com/data-grid {:headers [(com/data-grid-header {} "Account")
(com/data-grid-header {:class "w-32"} "Location")
(com/data-grid-header {:class "w-16"}
(com/radio-card {:options [{:value "$" :content "$"}
{:value "%" :content "%"}]
:value new-mode
:name "step-params[amount-mode]"
:orientation :horizontal
:hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode)
:hx-target "#account-grid-body"
:hx-swap "outerHTML"
:hx-include "closest form"}))
(com/data-grid-header {:class "w-16"})]}
(map-indexed (fn [idx account]
(transaction-account-row-no-cursor*
{:account account
:index idx
:client-id (-> updated-request :entity :transaction/client :db/id)
:amount-mode new-mode
:total total}))
accounts)
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes
::route/edit-wizard-new-account)
:row-offset 0
:index (count accounts)
:tr-params {:hx-vals (hx/json {:client-id (:transaction/client snapshot)})}}
"New account")
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
(com/data-grid-cell {:id "total"
:class "text-right"}
(format "$%,.2f" (double (reduce + 0.0 (map :transaction-account/amount accounts)))))
(com/data-grid-cell {}))
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"])
(com/data-grid-cell {:id "total"
:class "text-right"}
(let [account-total (double (reduce + 0.0 (map :transaction-account/amount accounts)))
balance (- total account-total)]
[:span {:class (when-not (dollars= 0.0 balance)
"text-red-300")}
(format "$%,.2f" (double balance))]))
(com/data-grid-cell {}))
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TRANSACTION TOTAL"])
(com/data-grid-cell {:class "text-right"}
(format "$%,.2f" total))
(com/data-grid-cell {})))])))
(render-full-form updated-request))))
(defn transaction-details-panel [tx]
[:div.p-4.space-y-4
@@ -617,7 +592,7 @@
[:div.space-y-3
[:div
[:div.text-xs.font-medium.text-gray-500 "Amount"]
[:div.text-sm.font-medium.text-gray-900 (format "$%,.2f" (Math/abs (:transaction/amount tx)))]]
[:div.text-sm.font-medium.text-gray-900 (format "$%,.2f" (Math/abs (or (:transaction/amount tx) 0.0)))]]
[:div
[:div.text-xs.font-medium.text-gray-500 "Date"]
[:div.text-sm.text-gray-900 (some-> tx :transaction/date coerce/to-date-time (atime/unparse-local atime/normal-date))]]
@@ -903,10 +878,19 @@
{:label "Memo"
:errors (fc/field-errors)}
[:div.w-96
;; Memo edits re-render the whole form via morph. The stable id
;; lets alpine-morph match this input across the swap so the
;; caret stays put while the user types.
(com/text-input {:value (-> (fc/field-value))
:name (fc/field-name)
:id "edit-memo"
:error? (fc/field-errors)
:placeholder "Optional note"})]))
:placeholder "Optional note"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target "#wizard-form"
:hx-swap "morph"
:hx-trigger "keyup changed delay:300ms"
:hx-include "closest form"})]))
[:div {:x-data (hx/json {:activeForm (if (:transaction/payment (:entity request))
"link-payment"
(or (fc/with-field :action (fc/field-value))
@@ -1387,7 +1371,8 @@
:form-params
(-> mm/default-form-props
(assoc :hx-post
(str (bidi/path-for ssr-routes/only-routes ::route/edit-submit))))
(str (bidi/path-for ssr-routes/only-routes ::route/edit-submit))
:hx-ext "response-targets,alpine-morph"))
:render-timeline? false))
(steps [_]
[:links])
@@ -1430,6 +1415,18 @@
(fc/with-field :transaction/accounts
(account-grid-body* request)))))
(defn render-full-form
"Helper to render the complete transaction edit form for whole-form re-rendering."
[request]
(mm/render-wizard edit-wizard request))
(defn edit-form-changed-handler
"Generic handler that re-renders the whole form. Used when any field changes
and we need the server to re-compute dependent fields."
[request]
(html-response
(render-full-form request)))
(defn edit-vendor-changed-handler [request]
(let [multi-form-state (:multi-form-state request)
snapshot (:snapshot multi-form-state)
@@ -1439,7 +1436,7 @@
"simple"))
client-id (or (:transaction/client snapshot)
(-> request :entity :transaction/client :db/id))
vendor-id (or (:transaction/vendor step-params)
vendor-id (or (->db-id (:transaction/vendor step-params))
(->db-id (get step-params "transaction/vendor"))
(:transaction/vendor snapshot))
total (Math/abs (or (-> request :entity :transaction/amount)
@@ -1466,16 +1463,14 @@
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
:transaction-account/location (or (:account/location default-account) "Shared")
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
default-account (assoc :transaction-account/account (:db/id default-account)))]
default-account (assoc :transaction-account/account (:db/id default-account)))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
request)
(assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))]
(html-response
(fc/start-form (:multi-form-state render-request) nil
(fc/with-field :step-params
(manual-coding-section* mode render-request))))))
(render-full-form render-request))))
(defn edit-wizard-toggle-mode-handler [request]
(let [step-params (-> request :multi-form-state :step-params)
@@ -1493,7 +1488,9 @@
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
(vec accounts))
(assoc-in [:multi-form-state :step-params :transaction/accounts]
(vec accounts))))
(vec accounts))
(assoc-in [:multi-form-state :step-params :mode]
(name target-mode))))
;; advanced→simple: take first row only
(let [first-row (first (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot))))]
@@ -1501,11 +1498,46 @@
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
(if first-row [first-row] []))
(assoc-in [:multi-form-state :step-params :transaction/accounts]
(if first-row [first-row] [])))))]
(if first-row [first-row] []))
(assoc-in [:multi-form-state :step-params :mode]
(name target-mode)))))]
(html-response
(fc/start-form (:multi-form-state render-request) nil
(fc/with-field :step-params
(manual-coding-section* target-mode render-request))))))
(render-full-form render-request))))
(defn edit-wizard-new-account-handler
"Adds a new account row and re-renders the whole form."
[request]
(let [snapshot (-> request :multi-form-state :snapshot)
amount-mode (or (:amount-mode snapshot) "$")
total (Math/abs (or (:transaction/amount snapshot) 0.0))
new-account {:db/id (str (java.util.UUID/randomUUID))
:new? true
:transaction-account/location "Shared"
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
accounts (vec (or (:transaction/accounts snapshot) []))
updated-accounts (conj accounts new-account)
updated-request (-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts)
(assoc-in [:multi-form-state :step-params :transaction/accounts] updated-accounts))]
(html-response
(render-full-form updated-request))))
(defn edit-wizard-remove-account-handler
"Removes an account row and re-renders the whole form.
Expects a row-index in the form params."
[request]
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
snapshot (-> request :multi-form-state :snapshot)
accounts (vec (or (:transaction/accounts snapshot) []))
updated-accounts (if (and row-index (< row-index (count accounts)))
(vec (concat (subvec accounts 0 row-index)
(subvec accounts (inc row-index))))
accounts)
updated-request (-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts)
(assoc-in [:multi-form-state :step-params :transaction/accounts] updated-accounts))]
(html-response
(render-full-form updated-request))))
(def key->handler
(apply-middleware-to-all-handlers
@@ -1544,6 +1576,10 @@
(mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-decode-multi-form-state))
::route/edit-form-changed (-> edit-form-changed-handler
(mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-decode-multi-form-state))
::route/toggle-amount-mode (-> toggle-amount-mode
(mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
@@ -1552,22 +1588,14 @@
(mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-decode-multi-form-state))
::route/edit-wizard-new-account (->
(add-new-entity-handler [:step-params :transaction/accounts]
(fn render [cursor request]
(let [snapshot (-> request :multi-form-state :snapshot)
amount-mode (or (:amount-mode snapshot) "$")
total (Math/abs (or (:transaction/amount snapshot) 0.0))]
(transaction-account-row*
{:value cursor
:client-id (:client-id (:query-params request))
:amount-mode amount-mode
:total total})))
(fn build-new-row [base _]
(assoc base :transaction-account/location "Shared")))
(wrap-schema-enforce :query-schema [:map
[:client-id {:optional true}
[:maybe entity-id]]]))
::route/edit-wizard-new-account (-> edit-wizard-new-account-handler
(mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-decode-multi-form-state))
::route/edit-wizard-remove-account (-> edit-wizard-remove-account-handler
(mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-decode-multi-form-state))
::route/unlink-payment (-> unlink-payment
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))