refactor(ssr): full Selmer migration of Transaction Edit; remove the wizard
Squashed Phase-2 SSR work: migrate the Transaction Edit modal's render path entirely to Selmer templates (zero Hiccup in the render path), rip out the multi-step wizard abstraction (EditWizard/LinksStep records, MultiStepFormState, step-params[...] field names, mm/* middleware) in favor of a plain form with flat derived state, and promote shared UI components to reusable Selmer partials under resources/templates/components/. Adds the Selmer interop bridge, the auto-ap.ssr.components.selmer (sc) wrapper library, and the ssr-form-migration skill capturing the learnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
389
e2e/transaction-edit-swap.spec.ts
Normal file
389
e2e/transaction-edit-swap.spec.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
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 #edit-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('#editmodal');
|
||||
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-vals*="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-form-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('#edit-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('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();
|
||||
await page.waitForSelector('#editmodal');
|
||||
|
||||
const memo = page.locator('#edit-memo');
|
||||
await memo.waitFor();
|
||||
|
||||
// Clear any seeded memo text and type "hello".
|
||||
await memo.click();
|
||||
await memo.press('Control+a');
|
||||
await memo.pressSequentially('hello', { delay: 40 });
|
||||
|
||||
// Drop the caret in the middle and insert a char -> "heXllo", caret -> 3.
|
||||
await memo.evaluate((el: HTMLInputElement) => {
|
||||
el.focus();
|
||||
el.setSelectionRange(2, 2);
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
(window as any).__focusedMemo = document.activeElement;
|
||||
});
|
||||
await memo.press('X');
|
||||
|
||||
// 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;
|
||||
return {
|
||||
sameNode: active === (window as any).__focusedMemo,
|
||||
id: active ? active.id : null,
|
||||
value: active ? active.value : null,
|
||||
caret: active ? active.selectionStart : null,
|
||||
};
|
||||
});
|
||||
|
||||
// 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');
|
||||
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('#editmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('div[hx-vals*="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-vals*="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-form-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('#editmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('div[hx-vals*="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-vals*="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([]);
|
||||
});
|
||||
});
|
||||
@@ -19,19 +19,18 @@ async function openEditModal(page: any, transactionIndex: number = 0) {
|
||||
|
||||
// Wait for the modal to open
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.waitForSelector('#editmodal');
|
||||
|
||||
// The modal is now single-page (Edit Transaction). Click "Manual" tab to ensure
|
||||
// the manual account coding form is active.
|
||||
await page.click('button:has-text("Manual")');
|
||||
|
||||
// Manual coding renders in "simple" mode (a single account row) when the
|
||||
// transaction has 0-1 accounts, and "advanced" mode (the account grid) when it
|
||||
// has 2+. These tests drive the account grid, so switch into advanced mode when
|
||||
// the toggle is present.
|
||||
const switchToAdvanced = page.locator('text=Switch to advanced mode');
|
||||
if (await switchToAdvanced.count()) {
|
||||
await switchToAdvanced.click();
|
||||
// Transactions with 0-1 accounts open in "simple" mode, which has no account
|
||||
// grid. Switch to "advanced" mode (a whole-form swap) so the grid the rest of
|
||||
// these helpers manipulate is present.
|
||||
const advancedLink = page.locator('a:has-text("Switch to advanced mode")');
|
||||
if (await advancedLink.count()) {
|
||||
await advancedLink.first().click();
|
||||
}
|
||||
|
||||
// Wait for the manual form (account grid) to appear
|
||||
@@ -48,60 +47,33 @@ async function getTestInfo(page: any) {
|
||||
}
|
||||
|
||||
async function selectAccountFromTypeahead(page: any, rowIndex: number, accountName: string) {
|
||||
// The account search uses Solr which isn't available in tests.
|
||||
// Instead, we directly set the hidden input value via JavaScript.
|
||||
|
||||
// Get all rows except the new-row, total, balance, and transaction total rows
|
||||
const allRows = page.locator('#account-grid-body tbody tr');
|
||||
const rowCount = await allRows.count();
|
||||
|
||||
// Find the row that has a hidden input for account (actual account rows)
|
||||
let accountRow = null;
|
||||
let accountRowIndex = 0;
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
const row = allRows.nth(i);
|
||||
const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0;
|
||||
if (hasAccountInput) {
|
||||
if (accountRowIndex === rowIndex) {
|
||||
accountRow = row;
|
||||
break;
|
||||
}
|
||||
accountRowIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!accountRow) {
|
||||
throw new Error(`Could not find account row at index ${rowIndex}`);
|
||||
}
|
||||
|
||||
// Find the hidden input for the account
|
||||
const hiddenInput = accountRow.locator('input[type="hidden"][name*="transaction-account/account"]').first();
|
||||
|
||||
// Get account IDs from test-info endpoint
|
||||
const testInfo = await getTestInfo(page);
|
||||
// Account search is backed by Solr (unavailable in tests). Drive the typeahead the
|
||||
// way a user does, using the Alpine v3 API: open the tippy dropdown, inject a result
|
||||
// into the component's `elements`, then click it. This runs the real click handler,
|
||||
// Alpine reactivity and the HTMX swap exactly as in production -- unlike poking the
|
||||
// long-removed Alpine v2 `__x` internal, which silently no-ops on Alpine v3 and left
|
||||
// the posted account empty.
|
||||
const accountKey = accountName === 'Test' ? 'test-account' : 'second-account';
|
||||
const label = `${accountName} Account`;
|
||||
const testInfo = await getTestInfo(page);
|
||||
const accountId = testInfo.accounts[accountKey];
|
||||
|
||||
if (!accountId) {
|
||||
throw new Error(`Could not find account with name ${accountName}`);
|
||||
}
|
||||
|
||||
// Replace the Alpine-managed hidden input with a plain one. Setting el.value
|
||||
// directly is not enough: the account input is bound via `:value="value.value"`,
|
||||
// and Alpine re-renders it back to its bound object, which serializes to the
|
||||
// literal string "[object Object]" on submit (the server then rejects it as a
|
||||
// non-keyword). Swapping in a plain input detaches it from that binding.
|
||||
await hiddenInput.evaluate((el: HTMLInputElement, value: string) => {
|
||||
const newInput = document.createElement('input');
|
||||
newInput.type = 'hidden';
|
||||
newInput.name = el.name;
|
||||
newInput.value = value;
|
||||
el.parentNode.replaceChild(newInput, el);
|
||||
newInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}, accountId.toString());
|
||||
|
||||
// Wait for any HTMX updates (e.g. location select reload)
|
||||
await page.waitForTimeout(300);
|
||||
const row = page.locator('#account-grid-body tbody tr.account-row').nth(rowIndex);
|
||||
const typeahead = row.locator('div.relative[x-data]').first();
|
||||
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' });
|
||||
await search.fill('te');
|
||||
await typeahead.evaluate((el: any, opt: { id: number; label: string }) => {
|
||||
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
|
||||
}, { id: accountId, label });
|
||||
await page.locator('[data-tippy-root] a', { hasText: label }).first().click();
|
||||
|
||||
// Wait for the change-gated whole-form swap to settle.
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
async function findAccountRow(page: any, rowIndex: number) {
|
||||
@@ -158,14 +130,13 @@ async function getAccountLocation(page: any, rowIndex: number): Promise<string>
|
||||
}
|
||||
|
||||
async function removeAllAccounts(page: any) {
|
||||
const accountRows = page.locator('#account-grid-body tbody tr.account-row');
|
||||
const rowCount = await accountRows.count();
|
||||
|
||||
for (let i = rowCount - 1; i >= 0; i--) {
|
||||
const row = accountRows.nth(i);
|
||||
const removeButton = row.locator('.account-remove-action');
|
||||
await removeButton.click();
|
||||
// Wait for the Alpine.js removal animation (500ms + buffer)
|
||||
// Re-query each iteration: every remove is a whole-form swap that re-renders the rows,
|
||||
// so a row index captured up front goes stale. Click the last remove button until none
|
||||
// remain.
|
||||
for (let guard = 0; guard < 20; guard++) {
|
||||
const removeButtons = page.locator('#account-grid-body .account-remove-action');
|
||||
if (await removeButtons.count() === 0) break;
|
||||
await removeButtons.last().click();
|
||||
await page.waitForTimeout(700);
|
||||
}
|
||||
}
|
||||
@@ -179,23 +150,23 @@ async function saveTransaction(page: any) {
|
||||
}
|
||||
|
||||
async function toggleToPercentMode(page: any) {
|
||||
const percentRadio = page.locator('input[name="step-params[amount-mode]"][value="%"]');
|
||||
const percentRadio = page.locator('input[name="amount-mode"][value="%"]');
|
||||
await percentRadio.click();
|
||||
|
||||
// Wait for HTMX to swap the grid body
|
||||
await page.waitForResponse(response =>
|
||||
response.url().includes('/toggle-amount-mode') && response.status() === 200
|
||||
response.url().includes('/edit-form-changed') && response.status() === 200
|
||||
);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function toggleToDollarMode(page: any) {
|
||||
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
|
||||
const dollarRadio = page.locator('input[name="amount-mode"][value="$"]');
|
||||
await dollarRadio.click();
|
||||
|
||||
// Wait for HTMX to swap the grid body
|
||||
await page.waitForResponse(response =>
|
||||
response.url().includes('/toggle-amount-mode') && response.status() === 200
|
||||
response.url().includes('/edit-form-changed') && response.status() === 200
|
||||
);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
@@ -244,78 +215,39 @@ test.describe('Transaction Edit Shared Location', () => {
|
||||
});
|
||||
|
||||
test.describe('Transaction Edit Full Workflow', () => {
|
||||
test('should code transaction with vendor using percentage, then split 50/50, then switch to dollars', async ({ page }) => {
|
||||
// Step 1: Open edit modal and code with 100% to one account
|
||||
await openEditModal(page);
|
||||
|
||||
// Switch to percentage mode first (this re-renders the grid from server state)
|
||||
test('splits a transaction 50/50 in percentage mode and stores it as dollars', async ({ page }) => {
|
||||
// Transaction 0 is $100. Code it 50% / 50% across two accounts in percentage mode and
|
||||
// verify the save-time %->$ conversion stores/displays $50 + $50 on reopen.
|
||||
//
|
||||
// This intentionally types a percentage and THEN adds another row -- a whole-form
|
||||
// operation. The operation handlers now rebuild from the live posted form, not the
|
||||
// stale snapshot, so the first row's typed 50% survives (it used to revert, yielding a
|
||||
// 66.67/33.33 split).
|
||||
await openEditModal(page, 0);
|
||||
await removeAllAccounts(page);
|
||||
await toggleToPercentMode(page);
|
||||
|
||||
// Check if there's already an account from previous tests
|
||||
const allRows = page.locator('#account-grid-body tbody tr');
|
||||
const hasExistingAccount = await allRows.locator('input[name*="transaction-account/account"]').count() > 0;
|
||||
|
||||
if (!hasExistingAccount) {
|
||||
// Add a new account row if none exist
|
||||
await addNewAccount(page);
|
||||
}
|
||||
|
||||
// Select the account
|
||||
await selectAccountFromTypeahead(page, 0, 'Test');
|
||||
|
||||
// Set amount to 100%
|
||||
await setAccountAmount(page, 0, '100');
|
||||
|
||||
// Save the transaction
|
||||
await saveTransaction(page);
|
||||
|
||||
// Step 2: Re-open and split 50/50 with two accounts
|
||||
await openEditModal(page);
|
||||
|
||||
// Note: amount-mode is UI-only state, so it resets to $ when re-opening
|
||||
// Switch back to percentage mode
|
||||
await toggleToPercentMode(page);
|
||||
|
||||
// The existing account from step 1 should already be there
|
||||
// Change its amount from 100% to 50%
|
||||
await setAccountAmount(page, 0, '50');
|
||||
|
||||
// Add a second account at 50%
|
||||
|
||||
await addNewAccount(page);
|
||||
await selectAccountFromTypeahead(page, 0, 'Test');
|
||||
await setAccountAmount(page, 0, '50');
|
||||
|
||||
await addNewAccount(page);
|
||||
await page.waitForTimeout(1000);
|
||||
await selectAccountFromTypeahead(page, 1, 'Second');
|
||||
await setAccountAmount(page, 1, '50');
|
||||
|
||||
// Save
|
||||
|
||||
await saveTransaction(page);
|
||||
|
||||
// Step 3: Re-open and verify dollar amounts
|
||||
await openEditModal(page);
|
||||
|
||||
// The accounts should be persisted from the previous save
|
||||
// Wait for accounts to load
|
||||
|
||||
// Reopen: dollar mode is the default, and each account is the converted $50.
|
||||
await openEditModal(page, 0);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify we're in dollar mode (default)
|
||||
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
|
||||
|
||||
const dollarRadio = page.locator('input[name="amount-mode"][value="$"]');
|
||||
await expect(dollarRadio).toBeChecked();
|
||||
|
||||
// Verify amounts are in dollars (converted from percentages on save)
|
||||
const row0 = await findAccountRow(page, 0);
|
||||
const row1 = await findAccountRow(page, 1);
|
||||
|
||||
const amount0 = row0.locator('.account-amount-field');
|
||||
const amount1 = row1.locator('.account-amount-field');
|
||||
|
||||
// Each should be $50.00 (or close to it)
|
||||
const val0 = await amount0.inputValue();
|
||||
const val1 = await amount1.inputValue();
|
||||
|
||||
|
||||
const val0 = await (await findAccountRow(page, 0)).locator('.account-amount-field').inputValue();
|
||||
const val1 = await (await findAccountRow(page, 1)).locator('.account-amount-field').inputValue();
|
||||
expect(parseFloat(val0)).toBeCloseTo(50.0, 1);
|
||||
expect(parseFloat(val1)).toBeCloseTo(50.0, 1);
|
||||
|
||||
// Save
|
||||
await saveTransaction(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -346,7 +278,7 @@ test.describe('Transaction Edit Validation', () => {
|
||||
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
||||
|
||||
// The form should still be present
|
||||
const form = page.locator('#wizard-form');
|
||||
const form = page.locator('#edit-form');
|
||||
await expect(form).toBeVisible();
|
||||
|
||||
// Note: the validation-error response re-renders the manual section, and with
|
||||
@@ -374,12 +306,14 @@ async function openEditModalForTransaction(page: any, description: string) {
|
||||
const editButton = row.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first();
|
||||
await editButton.click();
|
||||
|
||||
// Wait for the modal to open
|
||||
// Wait for the modal to open. The modal is single-page now (no multi-step wizard
|
||||
// navigation), so the action tabs -- including "Link to payment" -- are available
|
||||
// immediately; callers click the tab they need.
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.waitForSelector('#editmodal');
|
||||
|
||||
// The modal is now single-page: the link tabs ("Link to payment", "Link to
|
||||
// unpaid invoices", ...) and "Manual" are all present, so there is no separate
|
||||
// The modal is single-page: the link tabs ("Link to payment", "Link to unpaid
|
||||
// invoices", ...) and "Manual" are all present, so there is no separate
|
||||
// "Transaction Actions" step to navigate to. Just wait for the tabs to render.
|
||||
await page.waitForSelector('button:has-text("Link to payment")');
|
||||
}
|
||||
@@ -392,7 +326,7 @@ async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
||||
throw new Error(`Could not find vendor with name ${vendorName}`);
|
||||
}
|
||||
|
||||
const vendorContainer = page.locator('div[hx-post*="edit-vendor-changed"]').first();
|
||||
const vendorContainer = page.locator('div[hx-vals*="vendor-changed"]').first();
|
||||
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
||||
|
||||
await vendorHidden.evaluate((el: HTMLInputElement, value: string) => {
|
||||
@@ -407,7 +341,7 @@ async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
|
||||
await page.waitForResponse(response => response.url().includes('/edit-vendor-changed') && response.status() === 200);
|
||||
await page.waitForResponse(response => response.url().includes('/edit-form-changed') && response.status() === 200);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
@@ -455,12 +389,17 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
|
||||
const testInfo = await getTestInfo(page);
|
||||
expect(accountValue).toBe(testInfo.accounts['test-account'].toString());
|
||||
|
||||
// The default account is pre-populated with the full (absolute) transaction
|
||||
// amount. Transaction index 3 is the "payment link" transaction (-$100), so
|
||||
// the pre-populated amount is $100.
|
||||
// The populated account amount should equal this transaction's amount (the vendor
|
||||
// default fills the single row with the whole amount). Read the actual amount from
|
||||
// the grid's transaction-total row rather than hard-coding it -- table row order is
|
||||
// not pinned across same-date seed transactions.
|
||||
const txTotalText = await page.locator('.account-grand-total-row').innerText();
|
||||
const txTotal = parseFloat(txTotalText.replace(/[^0-9.]/g, ''));
|
||||
expect(txTotal).toBeGreaterThan(0);
|
||||
|
||||
const amountInput = page.locator('.account-amount-field').first();
|
||||
const amountValue = await amountInput.inputValue();
|
||||
expect(parseFloat(amountValue)).toBeCloseTo(100.0, 1);
|
||||
expect(parseFloat(amountValue)).toBeCloseTo(txTotal, 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -470,11 +409,11 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
|
||||
// `elements` instead of being fetched. Everything else -- the dropdown's own
|
||||
// search input firing a native `change` on blur, the `value = element` click
|
||||
// handler, the Alpine reactivity, and the HTMX round-trip to
|
||||
// `edit-vendor-changed` -- runs exactly as in production. This is the flow that
|
||||
// `edit-form-changed` (op=vendor-changed) -- runs exactly as in production. This is the flow that
|
||||
// regressed: a stale native `change` from the search input used to win the race
|
||||
// and revert the vendor to its previous value.
|
||||
async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: string) {
|
||||
const wrapper = page.locator('div[hx-post*="edit-vendor-changed"]').first();
|
||||
const wrapper = page.locator('div[hx-vals*="vendor-changed"]').first();
|
||||
const typeahead = wrapper.locator('div.relative[x-data]').first();
|
||||
|
||||
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
|
||||
@@ -502,7 +441,7 @@ async function selectVendorViaDropdown(page: any, vendorId: number, vendorName:
|
||||
|
||||
await page.waitForResponse(
|
||||
(response: any) =>
|
||||
response.url().includes('/edit-vendor-changed') && response.status() === 200
|
||||
response.url().includes('/edit-form-changed') && response.status() === 200
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
@@ -519,9 +458,9 @@ async function openManualVendorSection(page: any, transactionIndex: number) {
|
||||
await editButton.click();
|
||||
|
||||
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
||||
await page.waitForSelector('#wizardmodal');
|
||||
await page.waitForSelector('#editmodal');
|
||||
await page.click('button:has-text("Manual")');
|
||||
await page.waitForSelector('div[hx-post*="edit-vendor-changed"]');
|
||||
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
|
||||
}
|
||||
|
||||
test.describe('Transaction Edit Vendor Selection', () => {
|
||||
@@ -537,14 +476,14 @@ test.describe('Transaction Edit Vendor Selection', () => {
|
||||
// round-trip. Before the fix this reverted to blank because a stale
|
||||
// `change` event submitted the previous vendor and its response won.
|
||||
const label = page
|
||||
.locator('div[hx-post*="edit-vendor-changed"] span[x-text="value.label"]')
|
||||
.locator('div[hx-vals*="vendor-changed"] span[x-text="value.label"]')
|
||||
.first();
|
||||
await expect(label).toHaveText('Test Vendor');
|
||||
|
||||
// The server-rendered hidden input must carry the newly selected vendor id.
|
||||
const hidden = page
|
||||
.locator(
|
||||
'div[hx-post*="edit-vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]'
|
||||
'div[hx-vals*="vendor-changed"] input[type="hidden"][name="transaction/vendor"]'
|
||||
)
|
||||
.first();
|
||||
await expect(hidden).toHaveValue(vendorId.toString());
|
||||
|
||||
Reference in New Issue
Block a user