diff --git a/e2e/transaction-edit-swap.spec.ts b/e2e/transaction-edit-swap.spec.ts index 1fd791ac..8eb776ff 100644 --- a/e2e/transaction-edit-swap.spec.ts +++ b/e2e/transaction-edit-swap.spec.ts @@ -179,9 +179,16 @@ test.describe('Transaction Edit whole-form swap', () => { expect(errors, errors.join('\n')).toEqual([]); }); - test('preserves caret position in the memo text field across a swap', async ({ page }) => { + 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(); @@ -190,22 +197,12 @@ test.describe('Transaction Edit whole-form swap', () => { const memo = page.locator('#edit-memo'); await memo.waitFor(); - // Clear any seeded memo text, then type "hello" via the keyboard (fires the - // field's htmx keyup trigger) and let that first post settle. Memo posts with - // hx-swap=none, so nothing is swapped back into the field. + // Clear any seeded memo text and type "hello". await memo.click(); await memo.press('Control+a'); - const firstSwap = page.waitForResponse( - (r: any) => - r.url().includes('edit-form-changed') && - r.request().method() === 'POST' && - r.status() === 200 - ); await memo.pressSequentially('hello', { delay: 40 }); - await firstSwap; - await page.waitForTimeout(300); - // Drop the caret in the middle (text inputs support selection). + // Drop the caret in the middle and insert a char -> "heXllo", caret -> 3. await memo.evaluate((el: HTMLInputElement) => { el.focus(); el.setSelectionRange(2, 2); @@ -213,17 +210,10 @@ test.describe('Transaction Edit whole-form swap', () => { await page.evaluate(() => { (window as any).__focusedMemo = document.activeElement; }); - - // Insert a char at the caret -> "heXllo", caret moves to 3, fires the post. - const memoSwap = page.waitForResponse( - (r: any) => - r.url().includes('edit-form-changed') && - r.request().method() === 'POST' && - r.status() === 200 - ); await memo.press('X'); - await memoSwap; - await page.waitForTimeout(300); + + // 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; @@ -235,6 +225,8 @@ test.describe('Transaction Edit whole-form swap', () => { }; }); + // 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'); diff --git a/src/clj/auto_ap/ssr/transaction/edit.clj b/src/clj/auto_ap/ssr/transaction/edit.clj index 8f6d7ab5..7b59ff84 100644 --- a/src/clj/auto_ap/ssr/transaction/edit.clj +++ b/src/clj/auto_ap/ssr/transaction/edit.clj @@ -224,24 +224,27 @@ :name (fc/field-name) :x-model "simpleAccountId"})])) (fc/with-field :transaction-account/location - (com/validated-field - {:label "Location" - :errors (fc/field-errors) - :x-hx-val:account-id "simpleAccountId" - :hx-vals (hx/json (cond-> {:name (fc/field-name)} - client-id (assoc :client-id client-id))) - :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-select "#wizard-form" - :hx-swap "outerHTML" - :hx-include "closest form"} - (location-select* - {:name (fc/field-name) - :account-location (:account/location account-id) - :client-locations (pull-attr (dc/db conn) :client/locations client-id) - :value location-val}))) + ;; Selecting the account only affects the valid Location options, so the + ;; change swaps just this cell -- nothing else needs to re-render. + [:div {:id "simple-account-location"} + (com/validated-field + {:label "Location" + :errors (fc/field-errors) + :x-hx-val:account-id "simpleAccountId" + :hx-vals (hx/json (cond-> {:name (fc/field-name)} + client-id (assoc :client-id client-id))) + :x-dispatch:changed "simpleAccountId" + :hx-trigger "changed" + :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) + :hx-target "#simple-account-location" + :hx-select "#simple-account-location" + :hx-swap "outerHTML" + :hx-include "closest form"} + (location-select* + {:name (fc/field-name) + :account-location (:account/location account-id) + :client-locations (pull-attr (dc/db conn) :client/locations client-id) + :value location-val}))]) (fc/with-field :transaction-account/amount (com/hidden {:name (fc/field-name) :value total}))]])) @@ -285,7 +288,9 @@ :x-model "accountId"})))) (fc/with-field :transaction-account/location (com/data-grid-cell - {} + {:id (str "account-location-" index)} + ;; Selecting an account only affects this row's valid Location options, so the + ;; change swaps just this cell -- nothing else needs to re-render. (com/validated-field {:errors (fc/field-errors) :x-hx-val:account-id "accountId" @@ -294,8 +299,8 @@ :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-select "#wizard-form" + :hx-target (str "#account-location-" index) + :hx-select (str "#account-location-" index) :hx-swap "outerHTML" :hx-include "closest form"} (location-select* {:name (fc/field-name) @@ -874,19 +879,14 @@ {:label "Memo" :errors (fc/field-errors)} [:div.w-96 + ;; Memo affects nothing else, so it issues no request at all -- its + ;; value just rides along in the form (posted with the next dependent + ;; change, and merged into the snapshot on save). (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) - ;; 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"})])) + :placeholder "Optional note"})])) [:div {:x-data (hx/json {:activeForm (if (:transaction/payment (:entity request)) "link-payment" (or (fc/with-field :action (fc/field-value))