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>
398 lines
16 KiB
TypeScript
398 lines
16 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
|
|
// 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
|
|
// 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.
|
|
|
|
// 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[] = [];
|
|
page.on('pageerror', (e: any) => errors.push('pageerror: ' + e.message));
|
|
page.on('console', (m: any) => {
|
|
if (m.type() === 'error') errors.push('console: ' + m.text());
|
|
});
|
|
return errors;
|
|
}
|
|
|
|
async function openManualAdvanced(page: any, transactionIndex = 0) {
|
|
await page.goto('/transaction2');
|
|
await page.waitForSelector('table tbody tr');
|
|
await page
|
|
.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]')
|
|
.nth(transactionIndex)
|
|
.click();
|
|
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
|
await page.waitForSelector('#wizardmodal');
|
|
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 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();
|
|
await page.waitForSelector('#account-grid-body');
|
|
}
|
|
}
|
|
|
|
// 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 swap.
|
|
async function selectVendor(page: any, vendorId: number, label: string) {
|
|
const vendor = page
|
|
.locator('div[hx-post*="edit-vendor-changed"]')
|
|
.first()
|
|
.locator('div.relative[x-data]')
|
|
.first();
|
|
await vendor.locator('a[x-ref="input"]').click();
|
|
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
|
|
await search.waitFor({ state: 'visible' });
|
|
await search.fill('xx');
|
|
await vendor.evaluate((el: HTMLElement, opt: { id: number; label: string }) => {
|
|
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
|
|
}, { id: vendorId, label });
|
|
|
|
const swap = page.waitForResponse(
|
|
(r: any) =>
|
|
r.url().includes('edit-vendor-changed') &&
|
|
r.request().method() === 'POST' &&
|
|
r.status() === 200
|
|
);
|
|
await page.locator('[data-tippy-root] a', { hasText: label }).first().click();
|
|
await swap;
|
|
await page.waitForTimeout(400);
|
|
}
|
|
|
|
// 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) {
|
|
// eslint-disable-next-line no-constant-condition
|
|
while (true) {
|
|
const removeButtons = page.locator('#account-grid-body .account-remove-action');
|
|
const count = await removeButtons.count();
|
|
if (count === 0) break;
|
|
await removeButtons.first().click();
|
|
await expect
|
|
.poll(async () => page.locator('#account-grid-body .account-remove-action').count())
|
|
.toBeLessThan(count);
|
|
}
|
|
}
|
|
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
test.describe('Transaction Edit whole-form swap', () => {
|
|
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 whole-form swap.
|
|
await page
|
|
.locator('#account-grid-body')
|
|
.locator('button:has-text("New account"), a:has-text("New account")')
|
|
.first()
|
|
.click();
|
|
|
|
await expect
|
|
.poll(async () => page.locator('#account-grid-body tbody tr.account-row').count())
|
|
.toBeGreaterThan(0);
|
|
|
|
// 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 swap', async ({ page }) => {
|
|
const errors = trackErrors(page);
|
|
|
|
await openManualAdvanced(page, 0);
|
|
|
|
// Ensure exactly one account row exists.
|
|
const rows = await page.locator('#account-grid-body tbody tr.account-row').count();
|
|
if (rows === 0) {
|
|
await page
|
|
.locator('#account-grid-body')
|
|
.locator('button:has-text("New account"), a:has-text("New account")')
|
|
.first()
|
|
.click();
|
|
await expect
|
|
.poll(async () => page.locator('#account-grid-body tbody tr.account-row').count())
|
|
.toBeGreaterThan(0);
|
|
}
|
|
|
|
const amount = page.locator('.account-amount-field').first();
|
|
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 #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');
|
|
|
|
const amountSwap = page.waitForResponse(
|
|
(r: any) =>
|
|
r.url().includes('edit-form-changed') &&
|
|
r.request().method() === 'POST' &&
|
|
r.status() === 200
|
|
);
|
|
await amount.pressSequentially('150', { delay: 40 });
|
|
|
|
// 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;
|
|
});
|
|
|
|
await amountSwap;
|
|
await page.waitForTimeout(300);
|
|
|
|
const state = await page.evaluate(() => {
|
|
const active = document.activeElement as HTMLInputElement;
|
|
return {
|
|
sameNode: active === (window as any).__focusedAmount,
|
|
isAmountField: !!active && active.classList.contains('account-amount-field'),
|
|
value: active ? active.value : null,
|
|
};
|
|
});
|
|
|
|
// Focus must stay on the amount field after the swap...
|
|
expect(state.isAmountField).toBe(true);
|
|
// ...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 and been
|
|
// applied via the #account-totals 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 swap', async ({ page }) => {
|
|
const errors = trackErrors(page);
|
|
|
|
await page.goto('/transaction2');
|
|
await page.waitForSelector('table tbody tr');
|
|
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
|
|
await page.waitForSelector('#wizardmodal');
|
|
|
|
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.
|
|
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).
|
|
await memo.evaluate((el: HTMLInputElement) => {
|
|
el.focus();
|
|
el.setSelectionRange(2, 2);
|
|
});
|
|
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);
|
|
|
|
const state = await page.evaluate(() => {
|
|
const active = document.activeElement as HTMLInputElement;
|
|
return {
|
|
sameNode: active === (window as any).__focusedMemo,
|
|
id: active ? active.id : null,
|
|
value: active ? active.value : null,
|
|
caret: active ? active.selectionStart : null,
|
|
};
|
|
});
|
|
|
|
expect(state.id).toBe('edit-memo');
|
|
expect(state.sameNode).toBe(true);
|
|
expect(state.value).toBe('heXllo');
|
|
expect(state.caret).toBe(3);
|
|
|
|
expect(errors, errors.join('\n')).toEqual([]);
|
|
});
|
|
|
|
test('choosing an account from the typeahead does not throw and persists', async ({ page }) => {
|
|
const errors = trackErrors(page);
|
|
|
|
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 swap).
|
|
await clearAccounts(page);
|
|
await page
|
|
.locator('#account-grid-body')
|
|
.locator('button:has-text("New account"), a:has-text("New account")')
|
|
.first()
|
|
.click();
|
|
await expect
|
|
.poll(async () => page.locator('#account-grid-body tbody tr.account-row').count())
|
|
.toBeGreaterThan(0);
|
|
|
|
const row = page.locator('#account-grid-body tbody tr.account-row').first();
|
|
const typeahead = row.locator('div.relative[x-data]').first();
|
|
|
|
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
|
|
await typeahead.locator('a[x-ref="input"]').click();
|
|
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
|
|
await search.waitFor({ state: 'visible' });
|
|
|
|
// 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 the HTMX swap all run
|
|
// exactly as in production.
|
|
await search.fill('te');
|
|
const testInfo = await (await page.request.get('/test-info')).json();
|
|
const accountId: number = testInfo.accounts['test-account'];
|
|
await typeahead.evaluate((el: HTMLElement, id: number) => {
|
|
(window as any).Alpine.$data(el).elements = [{ value: id, label: 'Test Account' }];
|
|
}, accountId);
|
|
|
|
// Clicking the result runs `value = element; tippy.hide(); ...` and dispatches
|
|
// the change that fires the whole-form swap.
|
|
const swap = page.waitForResponse(
|
|
(r: any) =>
|
|
r.url().includes('edit-form-changed') &&
|
|
r.request().method() === 'POST' &&
|
|
r.status() === 200
|
|
);
|
|
await page.locator('[data-tippy-root] a', { hasText: 'Test Account' }).first().click();
|
|
await swap;
|
|
await page.waitForTimeout(300);
|
|
|
|
// The chosen account must survive the whole-form swap.
|
|
const hidden = page
|
|
.locator('#account-grid-body tbody tr.account-row')
|
|
.first()
|
|
.locator('input[type="hidden"][name*="transaction-account/account"]')
|
|
.first();
|
|
await expect(hidden).toHaveValue(accountId.toString());
|
|
|
|
expect(errors, errors.join('\n')).toEqual([]);
|
|
});
|
|
|
|
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).
|
|
await page.goto('/transaction2');
|
|
await page.waitForSelector('table tbody tr');
|
|
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
|
|
await page.waitForSelector('#wizardmodal');
|
|
await page.click('button:has-text("Manual")');
|
|
await page.waitForSelector('div[hx-post*="edit-vendor-changed"]');
|
|
|
|
const testInfo = await (await page.request.get('/test-info')).json();
|
|
const vendorId: number = testInfo.accounts.vendor;
|
|
const defaultAccountId: number = testInfo.accounts['test-account'];
|
|
|
|
// Drive the vendor typeahead like a user: open dropdown, inject a result
|
|
// (Solr is unavailable in tests), click it.
|
|
const vendor = page.locator('div[hx-post*="edit-vendor-changed"]').first().locator('div.relative[x-data]').first();
|
|
await vendor.locator('a[x-ref="input"]').click();
|
|
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
|
|
await search.waitFor({ state: 'visible' });
|
|
await search.fill('te');
|
|
await vendor.evaluate((el: HTMLElement, id: number) => {
|
|
(window as any).Alpine.$data(el).elements = [{ value: id, label: 'Test Vendor' }];
|
|
}, vendorId);
|
|
|
|
const swap = page.waitForResponse(
|
|
(r: any) =>
|
|
r.url().includes('edit-vendor-changed') &&
|
|
r.request().method() === 'POST' &&
|
|
r.status() === 200
|
|
);
|
|
await page.locator('[data-tippy-root] a', { hasText: 'Test Vendor' }).first().click();
|
|
await swap;
|
|
await page.waitForTimeout(400);
|
|
|
|
// 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();
|
|
await expect(accountHidden).toHaveValue(defaultAccountId.toString());
|
|
|
|
// The displayed account label should resolve too.
|
|
await expect(page.locator('span[x-text="value.label"]', { hasText: 'Test Account' })).toBeVisible();
|
|
|
|
expect(errors, errors.join('\n')).toEqual([]);
|
|
});
|
|
|
|
test('changing the vendor a second time still updates it', async ({ page }) => {
|
|
const errors = trackErrors(page);
|
|
|
|
await page.goto('/transaction2');
|
|
await page.waitForSelector('table tbody tr');
|
|
await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click();
|
|
await page.waitForSelector('#wizardmodal');
|
|
await page.click('button:has-text("Manual")');
|
|
await page.waitForSelector('div[hx-post*="edit-vendor-changed"]');
|
|
|
|
const testInfo = await (await page.request.get('/test-info')).json();
|
|
const vendor1: number = testInfo.accounts.vendor;
|
|
const vendor2: number = testInfo.accounts.vendor2;
|
|
const account1: number = testInfo.accounts['test-account'];
|
|
const account2: number = testInfo.accounts['second-account'];
|
|
|
|
const vendorLabel = page
|
|
.locator('div[hx-post*="edit-vendor-changed"] span[x-text="value.label"]')
|
|
.first();
|
|
const accountHidden = page
|
|
.locator('input[type="hidden"][name*="transaction-account/account"]')
|
|
.first();
|
|
|
|
// First vendor.
|
|
await selectVendor(page, vendor1, 'Test Vendor');
|
|
await expect(vendorLabel).toHaveText('Test Vendor');
|
|
await expect(accountHidden).toHaveValue(account1.toString());
|
|
|
|
// 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());
|
|
|
|
// And back again, to be sure it keeps working.
|
|
await selectVendor(page, vendor1, 'Test Vendor');
|
|
await expect(vendorLabel).toHaveText('Test Vendor');
|
|
await expect(accountHidden).toHaveValue(account1.toString());
|
|
|
|
expect(errors, errors.join('\n')).toEqual([]);
|
|
});
|
|
});
|