Replace alpine-morph with targeted hx-select / OOB swaps
Drop the whole-form alpine-morph swap in favour of posting the whole form but swapping back only what changed, never the input the user is editing -- so focus and caret survive a plain swap with no morph extension. - Discrete changes (vendor, account, location, mode, add/remove row) swap the #manual-coding-section fragment via hx-select, plus an OOB refresh of the #wizard-snapshot hidden field so the round-tripped wizard state stays in sync (the snapshot lives at #wizard-form level, outside the swapped fragment, and the new/remove-account handlers read it). - The amount field OOB-swaps only #total/#balance (hx-swap=none); memo posts with hx-swap=none. Neither input is ever replaced. - Give the BALANCE cell a unique id (#balance) so the OOB selector is unambiguous. - Remove the alpine-morph ext + @alpinejs/morph plugin and all the key/x-data re-init tricks they required. Rebuilding the fragment fresh makes vendor->account population and repeat vendor changes work without any keying. - Rename e2e/transaction-edit-morph.spec.ts -> -swap.spec.ts; assertions unchanged (focus/caret preservation, vendor->account, repeat vendor changes all hold). Full e2e suite: 27 passed / 2 failed (both pre-existing and unrelated -- the legacy save-flow test and the date-range filter test). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -233,8 +233,10 @@
|
||||
:x-dispatch:changed "simpleAccountId"
|
||||
:hx-trigger "changed"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||
:hx-target "#wizard-form"
|
||||
:hx-swap "morph"
|
||||
:hx-target "#manual-coding-section"
|
||||
:hx-select "#manual-coding-section"
|
||||
:hx-select-oob "#wizard-snapshot"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"}
|
||||
(location-select*
|
||||
{:name (fc/field-name)
|
||||
@@ -248,8 +250,10 @@
|
||||
[: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 "#wizard-form"
|
||||
:hx-swap "morph"}
|
||||
:hx-target "#manual-coding-section"
|
||||
:hx-select "#manual-coding-section"
|
||||
:hx-select-oob "#wizard-snapshot"
|
||||
:hx-swap "outerHTML"}
|
||||
"Switch to advanced mode"]]]))
|
||||
|
||||
(defn- manual-mode-initial
|
||||
@@ -264,11 +268,6 @@
|
||||
(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"
|
||||
@@ -297,8 +296,10 @@
|
||||
:x-dispatch:changed "accountId"
|
||||
:hx-trigger "changed"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||
:hx-target "#wizard-form"
|
||||
:hx-swap "morph"
|
||||
:hx-target "#manual-coding-section"
|
||||
:hx-select "#manual-coding-section"
|
||||
:hx-select-oob "#wizard-snapshot"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"}
|
||||
(location-select* {:name (fc/field-name)
|
||||
:account-location (:account/location (cond->> (:transaction-account/account @value)
|
||||
@@ -311,16 +312,17 @@
|
||||
{}
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
;; 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)
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||
:hx-target "#wizard-form"
|
||||
:hx-swap "morph"
|
||||
;; Typing an amount posts the whole form but swaps NOTHING into the field
|
||||
;; itself (hx-swap=none). Only the TOTAL and BALANCE cells are pulled out of
|
||||
;; the response and applied out-of-band, so the amount input is never replaced
|
||||
;; and the user's focus + caret survive with no morph involved.
|
||||
:hx-select-oob "#total,#balance"
|
||||
:hx-swap "none"
|
||||
:hx-trigger "keyup changed delay:300ms"
|
||||
:hx-include "closest form"}]
|
||||
(if (= "%" amount-mode)
|
||||
@@ -329,8 +331,10 @@
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
(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-target "#manual-coding-section"
|
||||
:hx-select "#manual-coding-section"
|
||||
:hx-select-oob "#wizard-snapshot"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"
|
||||
:class "account-remove-action"} svg/x))))
|
||||
|
||||
@@ -471,8 +475,10 @@
|
||||
:name "step-params[amount-mode]"
|
||||
:orientation :horizontal
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode)
|
||||
:hx-target "#wizard-form"
|
||||
:hx-swap "morph"
|
||||
:hx-target "#manual-coding-section"
|
||||
:hx-select "#manual-coding-section"
|
||||
:hx-select-oob "#wizard-snapshot"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"}))
|
||||
(com/data-grid-header {:class "w-16"})]}
|
||||
(fc/cursor-map (fn [cursor]
|
||||
@@ -485,8 +491,10 @@
|
||||
(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-target "#manual-coding-section"
|
||||
:hx-select "#manual-coding-section"
|
||||
:hx-select-oob "#wizard-snapshot"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-include "closest form"
|
||||
:color :secondary}
|
||||
"New account")))
|
||||
@@ -501,7 +509,7 @@
|
||||
(com/data-grid-row {:class "account-balance-row"}
|
||||
(com/data-grid-cell {})
|
||||
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"])
|
||||
(com/data-grid-cell {:id "total"
|
||||
(com/data-grid-cell {:id "balance"
|
||||
:class "text-right"}
|
||||
(account-balance* request))
|
||||
(com/data-grid-cell {}))
|
||||
@@ -528,8 +536,10 @@
|
||||
(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 "#wizard-form"
|
||||
:hx-swap "morph"
|
||||
:hx-target "#manual-coding-section"
|
||||
:hx-select "#manual-coding-section"
|
||||
:hx-select-oob "#wizard-snapshot"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-sync "this:replace"
|
||||
:hx-include "closest form"}
|
||||
(fc/with-field :transaction/vendor
|
||||
@@ -537,11 +547,6 @@
|
||||
{: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..."
|
||||
@@ -551,13 +556,7 @@
|
||||
(if (= mode :simple)
|
||||
(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})}
|
||||
[:div {:x-data (hx/json {:simpleAccountId simple-account-id})}
|
||||
(simple-mode-fields* request)])
|
||||
[:div
|
||||
(when (<= row-count 1)
|
||||
@@ -565,8 +564,10 @@
|
||||
[: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 "#wizard-form"
|
||||
:hx-swap "morph"}
|
||||
:hx-target "#manual-coding-section"
|
||||
:hx-select "#manual-coding-section"
|
||||
:hx-select-oob "#wizard-snapshot"
|
||||
:hx-swap "outerHTML"}
|
||||
"Switch to simple mode"]])
|
||||
(fc/with-field :transaction/accounts
|
||||
(com/validated-field
|
||||
@@ -878,17 +879,17 @@
|
||||
{: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"
|
||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||
:hx-target "#wizard-form"
|
||||
:hx-swap "morph"
|
||||
;; Memo has no dependent UI, so it posts only to keep the
|
||||
;; server snapshot in sync and swaps nothing back in. With
|
||||
;; hx-swap=none the input is never touched, so the caret
|
||||
;; stays put with no morph.
|
||||
:hx-swap "none"
|
||||
:hx-trigger "keyup changed delay:300ms"
|
||||
:hx-include "closest form"})]))
|
||||
[:div {:x-data (hx/json {:activeForm (if (:transaction/payment (:entity request))
|
||||
@@ -1372,7 +1373,7 @@
|
||||
(-> mm/default-form-props
|
||||
(assoc :hx-post
|
||||
(str (bidi/path-for ssr-routes/only-routes ::route/edit-submit))
|
||||
:hx-ext "response-targets,alpine-morph"))
|
||||
:hx-ext "response-targets"))
|
||||
:render-timeline? false))
|
||||
(steps [_]
|
||||
[:links])
|
||||
|
||||
Reference in New Issue
Block a user