Make swaps precise: drop no-op requests, swap only the affected field
Refine per-trigger granularity now that the swap target is explicit: - Memo issues no request at all -- it affects nothing else, so its value just rides along in the form and is merged into the snapshot on save. (Changing the Location *value* likewise issues no request -- it never did; that cell's request is the account->location dependency.) - Account select swaps only that row's Location cell (#account-location-<index> / #simple-account-location) instead of the whole form. Selecting an account only affects the valid Location options (computed from the posted account-id), so a precise cell swap is safe -- no snapshot dependency. Account-structural changes (vendor, add/remove row, mode toggle, $/% radio) keep swapping the whole form: their accounts+amount-mode state is interdependent and round-trips through the single form-level snapshot hidden field, so a whole-form swap is what keeps it consistent with zero OOB. Update the memo test to assert it fires no request and keeps its value/caret. Full e2e suite: 27 passed / 2 failed (same pre-existing, unrelated failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -179,9 +179,16 @@ test.describe('Transaction Edit whole-form swap', () => {
|
|||||||
expect(errors, errors.join('\n')).toEqual([]);
|
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);
|
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.goto('/transaction2');
|
||||||
await page.waitForSelector('table tbody tr');
|
await page.waitForSelector('table tbody tr');
|
||||||
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
|
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');
|
const memo = page.locator('#edit-memo');
|
||||||
await memo.waitFor();
|
await memo.waitFor();
|
||||||
|
|
||||||
// Clear any seeded memo text, then type "hello" via the keyboard (fires the
|
// Clear any seeded memo text and type "hello".
|
||||||
// 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.
|
|
||||||
await memo.click();
|
await memo.click();
|
||||||
await memo.press('Control+a');
|
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 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) => {
|
await memo.evaluate((el: HTMLInputElement) => {
|
||||||
el.focus();
|
el.focus();
|
||||||
el.setSelectionRange(2, 2);
|
el.setSelectionRange(2, 2);
|
||||||
@@ -213,17 +210,10 @@ test.describe('Transaction Edit whole-form swap', () => {
|
|||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
(window as any).__focusedMemo = document.activeElement;
|
(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 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 state = await page.evaluate(() => {
|
||||||
const active = document.activeElement as HTMLInputElement;
|
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.id).toBe('edit-memo');
|
||||||
expect(state.sameNode).toBe(true);
|
expect(state.sameNode).toBe(true);
|
||||||
expect(state.value).toBe('heXllo');
|
expect(state.value).toBe('heXllo');
|
||||||
|
|||||||
@@ -224,24 +224,27 @@
|
|||||||
:name (fc/field-name)
|
:name (fc/field-name)
|
||||||
:x-model "simpleAccountId"})]))
|
:x-model "simpleAccountId"})]))
|
||||||
(fc/with-field :transaction-account/location
|
(fc/with-field :transaction-account/location
|
||||||
(com/validated-field
|
;; Selecting the account only affects the valid Location options, so the
|
||||||
{:label "Location"
|
;; change swaps just this cell -- nothing else needs to re-render.
|
||||||
:errors (fc/field-errors)
|
[:div {:id "simple-account-location"}
|
||||||
:x-hx-val:account-id "simpleAccountId"
|
(com/validated-field
|
||||||
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
|
{:label "Location"
|
||||||
client-id (assoc :client-id client-id)))
|
:errors (fc/field-errors)
|
||||||
:x-dispatch:changed "simpleAccountId"
|
:x-hx-val:account-id "simpleAccountId"
|
||||||
:hx-trigger "changed"
|
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
|
||||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
client-id (assoc :client-id client-id)))
|
||||||
:hx-target "#wizard-form"
|
:x-dispatch:changed "simpleAccountId"
|
||||||
:hx-select "#wizard-form"
|
:hx-trigger "changed"
|
||||||
:hx-swap "outerHTML"
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||||
:hx-include "closest form"}
|
:hx-target "#simple-account-location"
|
||||||
(location-select*
|
:hx-select "#simple-account-location"
|
||||||
{:name (fc/field-name)
|
:hx-swap "outerHTML"
|
||||||
:account-location (:account/location account-id)
|
:hx-include "closest form"}
|
||||||
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
|
(location-select*
|
||||||
:value location-val})))
|
{: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
|
(fc/with-field :transaction-account/amount
|
||||||
(com/hidden {:name (fc/field-name)
|
(com/hidden {:name (fc/field-name)
|
||||||
:value total}))]]))
|
:value total}))]]))
|
||||||
@@ -285,7 +288,9 @@
|
|||||||
:x-model "accountId"}))))
|
:x-model "accountId"}))))
|
||||||
(fc/with-field :transaction-account/location
|
(fc/with-field :transaction-account/location
|
||||||
(com/data-grid-cell
|
(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
|
(com/validated-field
|
||||||
{:errors (fc/field-errors)
|
{:errors (fc/field-errors)
|
||||||
:x-hx-val:account-id "accountId"
|
:x-hx-val:account-id "accountId"
|
||||||
@@ -294,8 +299,8 @@
|
|||||||
:x-dispatch:changed "accountId"
|
:x-dispatch:changed "accountId"
|
||||||
:hx-trigger "changed"
|
:hx-trigger "changed"
|
||||||
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||||
:hx-target "#wizard-form"
|
:hx-target (str "#account-location-" index)
|
||||||
:hx-select "#wizard-form"
|
:hx-select (str "#account-location-" index)
|
||||||
:hx-swap "outerHTML"
|
:hx-swap "outerHTML"
|
||||||
:hx-include "closest form"}
|
:hx-include "closest form"}
|
||||||
(location-select* {:name (fc/field-name)
|
(location-select* {:name (fc/field-name)
|
||||||
@@ -874,19 +879,14 @@
|
|||||||
{:label "Memo"
|
{:label "Memo"
|
||||||
:errors (fc/field-errors)}
|
:errors (fc/field-errors)}
|
||||||
[:div.w-96
|
[: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))
|
(com/text-input {:value (-> (fc/field-value))
|
||||||
:name (fc/field-name)
|
:name (fc/field-name)
|
||||||
:id "edit-memo"
|
:id "edit-memo"
|
||||||
:error? (fc/field-errors)
|
:error? (fc/field-errors)
|
||||||
:placeholder "Optional note"
|
: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"})]))
|
|
||||||
[:div {:x-data (hx/json {:activeForm (if (:transaction/payment (:entity request))
|
[:div {:x-data (hx/json {:activeForm (if (:transaction/payment (:entity request))
|
||||||
"link-payment"
|
"link-payment"
|
||||||
(or (fc/with-field :action (fc/field-value))
|
(or (fc/with-field :action (fc/field-value))
|
||||||
|
|||||||
Reference in New Issue
Block a user