Whole-form hx-select swaps with zero out-of-band swaps

Replace the section-swap + OOB approach with uniform whole-form swaps,
eliminating both out-of-band swaps:

- Discrete edits (vendor, account, location, mode, add/remove row) now swap all
  of #wizard-form via hx-select. The active action/tab already round-trips
  (:action is in edit-form-schema and the tab x-data inits from it), so a
  whole-form swap re-creates the tab state from the server value and the active
  tab is preserved -- no #wizard-snapshot OOB needed, since the snapshot hidden
  field rides along inside the form.
- Move the totals into their own <tbody id="account-totals"> (new optional
  :footer-tbody param on data-grid-) so the amount field updates them with a
  plain targeted swap instead of an OOB swap of #total,#balance. The totals tbody
  is a sibling of the input rows, so the amount input is never replaced.
- Memo unchanged (hx-swap=none).

Net: 0 hx-select-oob, 0 morph. The focus invariant is unchanged -- the typed
field is never inside a region it swaps. Tab clicks stay Alpine (instant); only
the action value round-trips. Revert the now-unneeded #wizard-snapshot id.

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 11:01:37 -07:00
parent a2684bf5c1
commit 5f1bb6db82
4 changed files with 77 additions and 84 deletions

View File

@@ -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()