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:
2026-06-01 20:12:22 -07:00
parent 5f1bb6db82
commit 482b4802ff
2 changed files with 45 additions and 53 deletions

View File

@@ -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');