Replace alpine-morph with targeted hx-select / OOB swaps

Drop the whole-form alpine-morph swap in favour of posting the whole form
but swapping back only what changed, never the input the user is editing --
so focus and caret survive a plain swap with no morph extension.

- Discrete changes (vendor, account, location, mode, add/remove row) swap the
  #manual-coding-section fragment via hx-select, plus an OOB refresh of the
  #wizard-snapshot hidden field so the round-tripped wizard state stays in sync
  (the snapshot lives at #wizard-form level, outside the swapped fragment, and
  the new/remove-account handlers read it).
- The amount field OOB-swaps only #total/#balance (hx-swap=none); memo posts
  with hx-swap=none. Neither input is ever replaced.
- Give the BALANCE cell a unique id (#balance) so the OOB selector is unambiguous.
- Remove the alpine-morph ext + @alpinejs/morph plugin and all the key/x-data
  re-init tricks they required. Rebuilding the fragment fresh makes vendor->account
  population and repeat vendor changes work without any keying.
- Rename e2e/transaction-edit-morph.spec.ts -> -swap.spec.ts; assertions unchanged
  (focus/caret preservation, vendor->account, repeat vendor changes all hold).

Full e2e suite: 27 passed / 2 failed (both pre-existing and unrelated -- the
legacy save-flow test and the date-range filter test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 08:10:05 -07:00
parent cdb6bb6fe3
commit a2684bf5c1
4 changed files with 99 additions and 84 deletions

View File

@@ -1,11 +1,19 @@
import { test, expect } from '@playwright/test';
// These tests cover the "render the whole form via an htmx + alpine morph swap"
// behaviour on the transaction edit page. The whole-form approach exists so that
// any edit can hit its own route yet re-render the entire form, while keeping the
// user's focus and caret position intact (which a plain innerHTML swap destroys).
// 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:
// - 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
// 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.
// Collect any uncaught page errors or console errors so a morph that throws
// 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.
function trackErrors(page: any): string[] {
const errors: string[] = [];
@@ -28,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 whole-form morph swap) so the account grid is present.
// advanced mode (a section 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();
@@ -37,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 whole-form morph.
// (Solr is unavailable in tests), click it, and wait for the section swap.
async function selectVendor(page: any, vendorId: number, label: string) {
const vendor = page
.locator('div[hx-post*="edit-vendor-changed"]')
@@ -63,9 +71,9 @@ async function selectVendor(page: any, vendorId: number, label: string) {
await page.waitForTimeout(400);
}
// Removes every existing account row (each remove is its own whole-form morph),
// so a test starts from a known-empty state regardless of what earlier tests
// saved onto the shared transaction.
// Removes every existing account row (each remove is its own section 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) {
// eslint-disable-next-line no-constant-condition
while (true) {
@@ -81,13 +89,13 @@ async function clearAccounts(page: any) {
test.describe.configure({ mode: 'serial' });
test.describe('Transaction Edit whole-form morph', () => {
test('morph swaps (toggle mode, add account) do not throw', async ({ page }) => {
test.describe('Transaction Edit whole-form swap', () => {
test('section swaps (toggle mode, add account) do not throw', async ({ page }) => {
const errors = trackErrors(page);
await openManualAdvanced(page, 0);
// Add an account row -- another whole-form morph swap.
// Add an account row -- another section swap.
await page
.locator('#account-grid-body')
.locator('button:has-text("New account"), a:has-text("New account")')
@@ -98,12 +106,12 @@ test.describe('Transaction Edit whole-form morph', () => {
.poll(async () => page.locator('#account-grid-body tbody tr.account-row').count())
.toBeGreaterThan(0);
// The form must survive the morph intact.
// The form must survive the swap intact.
await expect(page.locator('#wizard-form')).toHaveCount(1);
expect(errors, errors.join('\n')).toEqual([]);
});
test('keeps focus and typed value in the amount field across a morph', async ({ page }) => {
test('keeps focus and typed value in the amount field across a swap', async ({ page }) => {
const errors = trackErrors(page);
await openManualAdvanced(page, 0);
@@ -125,9 +133,10 @@ test.describe('Transaction Edit whole-form morph', () => {
await amount.waitFor();
// Type a clean value via the keyboard. Typing fires the field's htmx trigger
// (keyup), which posts to a per-field route and morphs the whole form back
// in. The amount field is type=number (no text caret), so we assert focus +
// node identity + value -- the guarantees morph gives that innerHTML can't.
// (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.
await amount.click();
await amount.press('Control+a');
@@ -139,7 +148,7 @@ test.describe('Transaction Edit whole-form morph', () => {
);
await amount.pressSequentially('150', { delay: 40 });
// Identify the live focused node (before the debounced morph lands) so we can
// Identify the live focused node (before the debounced swap lands) so we can
// prove the *same* node survives.
await page.evaluate(() => {
(window as any).__focusedAmount = document.activeElement;
@@ -157,20 +166,21 @@ test.describe('Transaction Edit whole-form morph', () => {
};
});
// Focus must stay on the amount field after the morph...
// Focus must stay on the amount field after the swap...
expect(state.isAmountField).toBe(true);
// ...on the very same DOM node (this is what morph buys us over innerHTML)...
// ...on the very same DOM node (the input is never part of the swapped region)...
expect(state.sameNode).toBe(true);
// ...with the value the user typed left intact.
expect(state.value).toBe('150');
// The TOTAL must have recomputed server-side from the posted amount.
// The TOTAL must have recomputed server-side from the posted amount and been
// applied via the out-of-band swap.
await expect(page.locator('.account-total-row #total')).toContainText('150');
expect(errors, errors.join('\n')).toEqual([]);
});
test('preserves caret position in the memo text field across a morph', async ({ page }) => {
test('preserves caret position in the memo text field across a swap', async ({ page }) => {
const errors = trackErrors(page);
await page.goto('/transaction2');
@@ -182,7 +192,8 @@ test.describe('Transaction Edit whole-form morph', () => {
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 whole-form morph settle.
// 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.press('Control+a');
const firstSwap = page.waitForResponse(
@@ -204,7 +215,7 @@ test.describe('Transaction Edit whole-form morph', () => {
(window as any).__focusedMemo = document.activeElement;
});
// Insert a char at the caret -> "heXllo", caret moves to 3, fires the swap.
// 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') &&
@@ -239,7 +250,7 @@ test.describe('Transaction Edit whole-form morph', () => {
await openManualAdvanced(page, 0);
// Start from a clean, empty account row so selecting the account actually
// changes accountId (and fires the change-gated whole-form morph).
// changes accountId (and fires the change-gated section swap).
await clearAccounts(page);
await page
.locator('#account-grid-body')
@@ -260,7 +271,7 @@ test.describe('Transaction Edit whole-form morph', () => {
// Account search is backed by Solr (unavailable in tests), so type under the
// 3-char threshold and inject a clickable result into the typeahead state --
// the click handler, tippy.hide(), Alpine reactivity and HTMX morph all run
// the click handler, tippy.hide(), Alpine reactivity and the HTMX swap all run
// exactly as in production.
await search.fill('te');
const testInfo = await (await page.request.get('/test-info')).json();
@@ -269,9 +280,8 @@ test.describe('Transaction Edit whole-form morph', () => {
(window as any).Alpine.$data(el).elements = [{ value: id, label: 'Test Account' }];
}, accountId);
// Clicking the result runs `value = element; tippy.hide(); ...`. Before the
// fix this threw "tippy is null" because the cached tippy var was stale after
// an earlier morph.
// Clicking the result runs `value = element; tippy.hide(); ...` and dispatches
// the change that fires the section swap.
const swap = page.waitForResponse(
(r: any) =>
r.url().includes('edit-form-changed') &&
@@ -282,7 +292,7 @@ test.describe('Transaction Edit whole-form morph', () => {
await swap;
await page.waitForTimeout(300);
// The chosen account must survive the whole-form morph.
// The chosen account must survive the section swap.
const hidden = page
.locator('#account-grid-body tbody tr.account-row')
.first()
@@ -293,7 +303,7 @@ test.describe('Transaction Edit whole-form morph', () => {
expect(errors, errors.join('\n')).toEqual([]);
});
test('selecting a vendor populates its default account across the morph', async ({ page }) => {
test('selecting a vendor populates its default account across the swap', async ({ page }) => {
const errors = trackErrors(page);
// Open the modal in simple mode (transaction 0 has no accounts).
@@ -329,9 +339,9 @@ test.describe('Transaction Edit whole-form morph', () => {
await swap;
await page.waitForTimeout(400);
// The vendor's default account must now be reflected in the account field --
// this is the bug the `key` re-init fixes: a server-driven value change into an
// Alpine-stateful typeahead that morph would otherwise preserve as empty.
// The vendor's default account must now be reflected in the account field.
// Because the section is rebuilt fresh from the server (no preserved Alpine
// state), the server-driven account value lands without any keying tricks.
const accountHidden = page
.locator('input[type="hidden"][name*="transaction-account/account"]')
.first();
@@ -371,8 +381,9 @@ test.describe('Transaction Edit whole-form morph', () => {
await expect(vendorLabel).toHaveText('Test Vendor');
await expect(accountHidden).toHaveValue(account1.toString());
// Second vendor -- this is the regression: a morph-preserved typeahead lost its
// value watcher, so the second change fired no request at all.
// Second vendor -- the regression guard: the section (and its vendor
// typeahead) is rebuilt fresh on every swap, so a second change still fires
// its request and updates the default account.
await selectVendor(page, vendor2, 'Second Vendor');
await expect(vendorLabel).toHaveText('Second Vendor');
await expect(accountHidden).toHaveValue(account2.toString());