diff --git a/e2e/transaction-edit-swap.spec.ts b/e2e/transaction-edit-swap.spec.ts index 27d79e0a..1fd791ac 100644 --- a/e2e/transaction-edit-swap.spec.ts +++ b/e2e/transaction-edit-swap.spec.ts @@ -1,17 +1,17 @@ import { test, expect } from '@playwright/test'; -// These tests cover the "post the whole form, swap back only what changed" -// behaviour on the transaction edit page. Each edit hits its own route and the -// server re-renders the entire form, but the client swaps back a targeted slice: +// 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 -// the #manual-coding-section fragment via hx-select (+ an OOB refresh of the -// #wizard-snapshot hidden field so the round-tripped wizard state stays in -// sync); -// - typed fields never swap the input the user is in -- the amount field -// OOB-swaps only the #total/#balance cells (hx-swap=none), and the memo +// all of #wizard-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 with no morph extension involved. +// 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. @@ -36,7 +36,7 @@ async function openManualAdvanced(page: any, transactionIndex = 0) { await page.click('button:has-text("Manual")'); // First transaction has no accounts so it opens in "simple" mode. Switch to - // advanced mode (a section swap) so the account grid is present. + // 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(); @@ -45,7 +45,7 @@ async function openManualAdvanced(page: any, transactionIndex = 0) { } // Drives the vendor typeahead like a user: open the dropdown, inject a result -// (Solr is unavailable in tests), click it, and wait for the section swap. +// (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-post*="edit-vendor-changed"]') @@ -71,7 +71,7 @@ async function selectVendor(page: any, vendorId: number, label: string) { await page.waitForTimeout(400); } -// Removes every existing account row (each remove is its own section swap), so a +// 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) { @@ -90,12 +90,12 @@ async function clearAccounts(page: any) { test.describe.configure({ mode: 'serial' }); test.describe('Transaction Edit whole-form swap', () => { - test('section swaps (toggle mode, add account) do not throw', async ({ page }) => { + 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 section swap. + // 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")') @@ -133,10 +133,9 @@ test.describe('Transaction Edit whole-form swap', () => { 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 total/balance - // cells out-of-band (hx-swap=none on the field itself). The amount field is - // type=number (no text caret), so we assert focus + node identity + value -- - // the input is never replaced, which is what makes that hold. + // (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'); @@ -174,7 +173,7 @@ test.describe('Transaction Edit whole-form swap', () => { expect(state.value).toBe('150'); // The TOTAL must have recomputed server-side from the posted amount and been - // applied via the out-of-band swap. + // applied via the #account-totals swap. await expect(page.locator('.account-total-row #total')).toContainText('150'); expect(errors, errors.join('\n')).toEqual([]); @@ -250,7 +249,7 @@ test.describe('Transaction Edit whole-form swap', () => { await openManualAdvanced(page, 0); // Start from a clean, empty account row so selecting the account actually - // changes accountId (and fires the change-gated section swap). + // changes accountId (and fires the change-gated whole-form swap). await clearAccounts(page); await page .locator('#account-grid-body') @@ -281,7 +280,7 @@ test.describe('Transaction Edit whole-form swap', () => { }, accountId); // Clicking the result runs `value = element; tippy.hide(); ...` and dispatches - // the change that fires the section swap. + // the change that fires the whole-form swap. const swap = page.waitForResponse( (r: any) => r.url().includes('edit-form-changed') && @@ -292,7 +291,7 @@ test.describe('Transaction Edit whole-form swap', () => { await swap; await page.waitForTimeout(300); - // The chosen account must survive the section swap. + // The chosen account must survive the whole-form swap. const hidden = page .locator('#account-grid-body tbody tr.account-row') .first() 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/multi_modal.clj b/src/clj/auto_ap/ssr/components/multi_modal.clj index f7f76ec6..898343bb 100644 --- a/src/clj/auto_ap/ssr/components/multi_modal.clj +++ b/src/clj/auto_ap/ssr/components/multi_modal.clj @@ -305,11 +305,6 @@ (list (fc/with-field :snapshot (com/hidden {:name (fc/field-name) - ;; Stable id so a partial swap (e.g. the transaction - ;; edit form swapping only #manual-coding-section) can - ;; refresh the encoded snapshot out-of-band and keep the - ;; round-tripped wizard state in sync. - :id "wizard-snapshot" :value (pr-str (fc/field-value))})) (fc/with-field :edit-path (com/hidden {:name (fc/field-name) diff --git a/src/clj/auto_ap/ssr/transaction/edit.clj b/src/clj/auto_ap/ssr/transaction/edit.clj index 59fec8b2..8f6d7ab5 100644 --- a/src/clj/auto_ap/ssr/transaction/edit.clj +++ b/src/clj/auto_ap/ssr/transaction/edit.clj @@ -233,9 +233,8 @@ :x-dispatch:changed "simpleAccountId" :hx-trigger "changed" :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) - :hx-target "#manual-coding-section" - :hx-select "#manual-coding-section" - :hx-select-oob "#wizard-snapshot" + :hx-target "#wizard-form" + :hx-select "#wizard-form" :hx-swap "outerHTML" :hx-include "closest form"} (location-select* @@ -250,9 +249,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-select "#manual-coding-section" - :hx-select-oob "#wizard-snapshot" + :hx-target "#wizard-form" + :hx-select "#wizard-form" :hx-swap "outerHTML"} "Switch to advanced mode"]]])) @@ -296,9 +294,8 @@ :x-dispatch:changed "accountId" :hx-trigger "changed" :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) - :hx-target "#manual-coding-section" - :hx-select "#manual-coding-section" - :hx-select-oob "#wizard-snapshot" + :hx-target "#wizard-form" + :hx-select "#wizard-form" :hx-swap "outerHTML" :hx-include "closest form"} (location-select* {:name (fc/field-name) @@ -317,12 +314,12 @@ :class "w-16 account-amount-field" :value (fc/field-value) :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) - ;; 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" + ;; Typing an amount posts the whole form but swaps back only the + ;; #account-totals tbody -- a sibling of the input-bearing rows, so + ;; the amount input is never replaced and the caret survives. + :hx-target "#account-totals" + :hx-select "#account-totals" + :hx-swap "outerHTML" :hx-trigger "keyup changed delay:300ms" :hx-include "closest form"}] (if (= "%" amount-mode) @@ -331,9 +328,8 @@ (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 "#manual-coding-section" - :hx-select "#manual-coding-section" - :hx-select-oob "#wizard-snapshot" + :hx-target "#wizard-form" + :hx-select "#wizard-form" :hx-swap "outerHTML" :hx-include "closest form" :class "account-remove-action"} svg/x)))) @@ -475,12 +471,36 @@ :name "step-params[amount-mode]" :orientation :horizontal :hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode) - :hx-target "#manual-coding-section" - :hx-select "#manual-coding-section" - :hx-select-oob "#wizard-snapshot" + :hx-target "#wizard-form" + :hx-select "#wizard-form" :hx-swap "outerHTML" :hx-include "closest form"})) - (com/data-grid-header {:class "w-16"})]} + (com/data-grid-header {:class "w-16"})] + ;; Totals live in their own so the amount + ;; field refreshes them with a plain targeted swap, never swapping the + ;; input-bearing rows above (which would drop the caret). + :footer-tbody + [:tbody {:id "account-totals"} + (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"} + (account-total* request)) + (com/data-grid-cell {})) + (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 "balance" + :class "text-right"} + (account-balance* request)) + (com/data-grid-cell {})) + (com/data-grid-row {:class "account-grand-total-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 {}))]} (fc/cursor-map (fn [cursor] (transaction-account-row* {:value cursor :client-id (-> request :entity :transaction/client :db/id) @@ -491,35 +511,12 @@ (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 "#manual-coding-section" - :hx-select "#manual-coding-section" - :hx-select-oob "#wizard-snapshot" + :hx-target "#wizard-form" + :hx-select "#wizard-form" :hx-swap "outerHTML" :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"} - (account-total* request)) - (com/data-grid-cell {})) - - (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 "balance" - :class "text-right"} - (account-balance* request)) - (com/data-grid-cell {})) - - (com/data-grid-row {:class "account-grand-total-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 {}))))) + "New account")))))) (defn manual-coding-section* "Renders the vendor field + account/location section for the manual tab. @@ -536,9 +533,8 @@ (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-select "#manual-coding-section" - :hx-select-oob "#wizard-snapshot" + :hx-target "#wizard-form" + :hx-select "#wizard-form" :hx-swap "outerHTML" :hx-sync "this:replace" :hx-include "closest form"} @@ -564,9 +560,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-select "#manual-coding-section" - :hx-select-oob "#wizard-snapshot" + :hx-target "#wizard-form" + :hx-select "#wizard-form" :hx-swap "outerHTML"} "Switch to simple mode"]]) (fc/with-field :transaction/accounts