Re-render the entire #wizard-form on each field edit and swap with hx-swap="morph" so the focused input keeps focus/caret/value while typing. - Field-level routes return the full form and target #wizard-form - Key state-owning wrappers (account rows, simple-mode wrapper, vendor typeahead) so server-driven value changes re-init across the morph - Guard tippy/$refs access in typeahead against stale post-morph state - Round-trip simple/advanced mode via step-params[mode] - Add e2e/transaction-edit-morph.spec.ts covering focus/caret preservation, vendor->account population, and repeated vendor changes - Seed a second vendor/account for test isolation Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
579 lines
22 KiB
TypeScript
579 lines
22 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
|
|
async function openEditModal(page: any, transactionIndex: number = 0) {
|
|
// Navigate to transactions page
|
|
await page.goto('/transaction2');
|
|
|
|
// Wait for the table to load
|
|
await page.waitForSelector('table tbody tr');
|
|
|
|
// Find and click the edit button for the specified transaction
|
|
const editButton = page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(transactionIndex);
|
|
await editButton.click();
|
|
|
|
// Wait for the modal to open
|
|
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
|
await page.waitForSelector('#wizardmodal');
|
|
|
|
// 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")');
|
|
|
|
// Transactions with 0-1 accounts open in "simple" mode, which has no account
|
|
// grid. Switch to "advanced" mode (a whole-form morph 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 to appear
|
|
await page.waitForSelector('#account-grid-body');
|
|
}
|
|
|
|
let testInfoCache: any = null;
|
|
|
|
async function getTestInfo(page: any) {
|
|
// Always fetch fresh to handle server restarts
|
|
const response = await page.request.get('/test-info');
|
|
testInfoCache = await response.json();
|
|
return testInfoCache;
|
|
}
|
|
|
|
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);
|
|
const accountKey = accountName === 'Test' ? 'test-account' : 'second-account';
|
|
const accountId = testInfo.accounts[accountKey];
|
|
|
|
if (!accountId) {
|
|
throw new Error(`Could not find account with name ${accountName}`);
|
|
}
|
|
|
|
// Set the hidden input value and trigger change
|
|
// Also update Alpine.js data to prevent it from overwriting our value
|
|
await hiddenInput.evaluate((el: HTMLInputElement, value: string) => {
|
|
// Set the DOM value
|
|
el.value = value;
|
|
|
|
// Update Alpine.js component data
|
|
const alpineEl = el.closest('[x-data]');
|
|
if (alpineEl && (alpineEl as any).__x) {
|
|
(alpineEl as any).__x.$data.value.value = parseInt(value);
|
|
(alpineEl as any).__x.$data.value.label = 'Selected Account';
|
|
}
|
|
|
|
// Also update any parent Alpine model (accountId)
|
|
const rowEl = el.closest('tr[x-data]');
|
|
if (rowEl && (rowEl as any).__x) {
|
|
(rowEl as any).__x.$data.accountId = parseInt(value);
|
|
}
|
|
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}, accountId.toString());
|
|
|
|
// Wait for any HTMX updates
|
|
await page.waitForTimeout(300);
|
|
}
|
|
|
|
async function findAccountRow(page: any, rowIndex: number) {
|
|
const accountRows = page.locator('#account-grid-body tbody tr.account-row');
|
|
const rowCount = await accountRows.count();
|
|
|
|
if (rowIndex >= rowCount) {
|
|
throw new Error(`Could not find account row at index ${rowIndex}. Only ${rowCount} account rows found.`);
|
|
}
|
|
|
|
return accountRows.nth(rowIndex);
|
|
}
|
|
|
|
async function setAccountAmount(page: any, rowIndex: number, amount: string) {
|
|
const row = await findAccountRow(page, rowIndex);
|
|
const amountInput = row.locator('.account-amount-field');
|
|
await amountInput.fill(amount);
|
|
await amountInput.dispatchEvent('change');
|
|
await page.waitForTimeout(300);
|
|
}
|
|
|
|
async function addNewAccount(page: any) {
|
|
// Click the "New account" button
|
|
await page.click('text=New account');
|
|
|
|
// Wait for the new row to be added
|
|
await page.waitForTimeout(500);
|
|
}
|
|
|
|
async function setAccountLocation(page: any, rowIndex: number, location: string) {
|
|
const row = await findAccountRow(page, rowIndex);
|
|
const locationSelect = row.locator('select[name*="location"]').first();
|
|
|
|
// If the option doesn't exist, add it (for testing invalid locations)
|
|
const optionExists = await locationSelect.locator(`option[value="${location}"]`).count() > 0;
|
|
if (!optionExists) {
|
|
await locationSelect.evaluate((el: HTMLSelectElement, value: string) => {
|
|
const option = document.createElement('option');
|
|
option.value = value;
|
|
option.textContent = value;
|
|
el.appendChild(option);
|
|
}, location);
|
|
}
|
|
|
|
await locationSelect.selectOption(location);
|
|
await locationSelect.dispatchEvent('change');
|
|
await page.waitForTimeout(300);
|
|
}
|
|
|
|
async function getAccountLocation(page: any, rowIndex: number): Promise<string> {
|
|
const row = await findAccountRow(page, rowIndex);
|
|
const locationSelect = row.locator('select[name*="location"]').first();
|
|
return await locationSelect.inputValue();
|
|
}
|
|
|
|
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)
|
|
await page.waitForTimeout(700);
|
|
}
|
|
}
|
|
|
|
async function saveTransaction(page: any) {
|
|
// Click the save button to submit the form via HTMX
|
|
await page.locator('.wizard-save-action').click();
|
|
|
|
// Wait for the modal to close (longer timeout for parallel test load)
|
|
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'hidden', timeout: 20000 });
|
|
}
|
|
|
|
async function toggleToPercentMode(page: any) {
|
|
const percentRadio = page.locator('input[name="step-params[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
|
|
);
|
|
await page.waitForTimeout(200);
|
|
}
|
|
|
|
async function toggleToDollarMode(page: any) {
|
|
const dollarRadio = page.locator('input[name="step-params[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
|
|
);
|
|
await page.waitForTimeout(200);
|
|
}
|
|
|
|
test.describe.configure({ mode: 'serial' });
|
|
|
|
test.describe('Transaction Edit Shared Location', () => {
|
|
test('should spread Shared location to client locations on save and display correctly on reopen', async ({ page }) => {
|
|
// Use the second transaction to avoid interfering with other tests
|
|
const transactionIndex = 1;
|
|
|
|
// Step 1: Open edit modal and add an account with Shared location
|
|
await openEditModal(page, transactionIndex);
|
|
|
|
// Remove any existing accounts from previous tests
|
|
await removeAllAccounts(page);
|
|
|
|
// Add a new account row
|
|
await addNewAccount(page);
|
|
|
|
// Select the account
|
|
await selectAccountFromTypeahead(page, 0, 'Test');
|
|
|
|
// Set location to Shared
|
|
await setAccountLocation(page, 0, 'Shared');
|
|
|
|
// Set amount to $200 (the full transaction amount for the second transaction)
|
|
await setAccountAmount(page, 0, '200');
|
|
|
|
// Save the transaction
|
|
await saveTransaction(page);
|
|
|
|
// Step 2: Re-open and verify location is not "Shared" but the actual client location
|
|
await openEditModal(page, transactionIndex);
|
|
|
|
// Wait for accounts to load
|
|
await page.waitForTimeout(500);
|
|
|
|
// Get the location of the first account
|
|
const location = await getAccountLocation(page, 0);
|
|
|
|
// The location should be the actual client location ("DT" in test data), not "Shared"
|
|
expect(location).not.toBe('Shared');
|
|
expect(location).toBe('DT');
|
|
});
|
|
});
|
|
|
|
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)
|
|
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 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
|
|
await page.waitForTimeout(500);
|
|
|
|
// Verify we're in dollar mode (default)
|
|
const dollarRadio = page.locator('input[name="step-params[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();
|
|
|
|
expect(parseFloat(val0)).toBeCloseTo(50.0, 1);
|
|
expect(parseFloat(val1)).toBeCloseTo(50.0, 1);
|
|
|
|
// Save
|
|
await saveTransaction(page);
|
|
});
|
|
});
|
|
|
|
test.describe('Transaction Edit Validation', () => {
|
|
test('should show validation error when account totals do not match transaction amount', async ({ page }) => {
|
|
// Use the third transaction to avoid interference from other tests
|
|
await openEditModal(page, 2);
|
|
|
|
// Stay in dollar mode (default)
|
|
// Remove any existing accounts from previous tests
|
|
await removeAllAccounts(page);
|
|
await page.waitForTimeout(2000);
|
|
// Add an account
|
|
await addNewAccount(page);
|
|
await selectAccountFromTypeahead(page, 0, 'Test');
|
|
|
|
// Set amount to $50 (which doesn't match the $300 transaction)
|
|
await setAccountAmount(page, 0, '50');
|
|
|
|
// Try to save - this should fail because $50 != $300
|
|
// Click the save button to submit the form via HTMX
|
|
await page.locator('.wizard-save-action').click();
|
|
|
|
// Wait for the response (longer timeout for parallel test load)
|
|
await page.waitForTimeout(3000);
|
|
|
|
// Modal should still be open (save failed)
|
|
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
|
|
|
|
// The form should still be present
|
|
const form = page.locator('#wizard-form');
|
|
await expect(form).toBeVisible();
|
|
|
|
// Verify the account row is still there with our $50 value
|
|
const amountInput = page.locator('.account-amount-field').first();
|
|
const value = await amountInput.inputValue();
|
|
expect(parseFloat(value)).toBeCloseTo(50.0, 1);
|
|
|
|
// Verify the user-friendly error message is displayed
|
|
const errorElement = page.locator('#form-errors .error-content');
|
|
await expect(errorElement).toBeVisible();
|
|
const errorText = await errorElement.textContent();
|
|
expect(errorText).toContain('The total of your expense accounts ($50.00) must equal the transaction amount ($300.00)');
|
|
});
|
|
});
|
|
|
|
async function openEditModalForTransaction(page: any, description: string) {
|
|
// Navigate to transactions page
|
|
await page.goto('/transaction2');
|
|
|
|
// Wait for the table to load
|
|
await page.waitForSelector('table tbody tr');
|
|
|
|
// Find the row with the specific description and click its edit button
|
|
const row = page.locator('table tbody tr', { hasText: description }).first();
|
|
const editButton = row.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').first();
|
|
await editButton.click();
|
|
|
|
// Wait for the modal to open
|
|
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
|
await page.waitForSelector('#wizardmodal');
|
|
|
|
// Click Next to go to the links step (button says "Transaction Actions")
|
|
await page.click('button:has-text("Transaction Actions")');
|
|
|
|
// Wait for the links step to load
|
|
await page.waitForSelector('text=Transaction Actions', { state: 'visible' });
|
|
}
|
|
|
|
async function selectVendorFromTypeahead(page: any, vendorName: string) {
|
|
const testInfo = await getTestInfo(page);
|
|
const vendorId = testInfo.accounts.vendor;
|
|
|
|
if (!vendorId) {
|
|
throw new Error(`Could not find vendor with name ${vendorName}`);
|
|
}
|
|
|
|
const vendorContainer = page.locator('div[hx-post*="edit-vendor-changed"]').first();
|
|
const vendorHidden = vendorContainer.locator('input[type="hidden"]').first();
|
|
|
|
await vendorHidden.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);
|
|
}, vendorId.toString());
|
|
|
|
await vendorContainer.evaluate((el: HTMLElement) => {
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
});
|
|
|
|
await page.waitForResponse(response => response.url().includes('/edit-vendor-changed') && response.status() === 200);
|
|
await page.waitForTimeout(500);
|
|
}
|
|
|
|
test.describe('Transaction Edit Vendor Pre-population', () => {
|
|
test('should start with no account rows when transaction has no accounts', async ({ page }) => {
|
|
await openEditModal(page, 3);
|
|
|
|
await page.click('button:has-text("Manual")');
|
|
await page.waitForSelector('#account-grid-body');
|
|
|
|
// Remove any existing accounts from previous tests
|
|
await removeAllAccounts(page);
|
|
await page.waitForTimeout(1000);
|
|
|
|
const accountRows = page.locator('#account-grid-body tbody tr.account-row');
|
|
const rowCount = await accountRows.count();
|
|
|
|
expect(rowCount).toBe(0);
|
|
});
|
|
|
|
test('should pre-populate default account when vendor is selected', async ({ page }) => {
|
|
await openEditModal(page, 3);
|
|
|
|
await page.click('button:has-text("Manual")');
|
|
await page.waitForSelector('#account-grid-body');
|
|
|
|
// Remove any existing accounts from previous tests
|
|
await removeAllAccounts(page);
|
|
await page.waitForTimeout(1000);
|
|
|
|
const accountRows = page.locator('#account-grid-body tbody tr.account-row');
|
|
const initialRowCount = await accountRows.count();
|
|
expect(initialRowCount).toBe(0);
|
|
|
|
await selectVendorFromTypeahead(page, 'Test Vendor');
|
|
|
|
const rowsAfterVendor = page.locator('#account-grid-body tbody tr.account-row');
|
|
const rowCountAfter = await rowsAfterVendor.count();
|
|
|
|
expect(rowCountAfter).toBe(1);
|
|
|
|
const accountHidden = page.locator('input[type="hidden"][name*="transaction-account/account"]').first();
|
|
const accountValue = await accountHidden.inputValue();
|
|
|
|
const testInfo = await getTestInfo(page);
|
|
expect(accountValue).toBe(testInfo.accounts['test-account'].toString());
|
|
|
|
const amountInput = page.locator('.account-amount-field').first();
|
|
const amountValue = await amountInput.inputValue();
|
|
expect(parseFloat(amountValue)).toBeCloseTo(400.0, 1);
|
|
});
|
|
});
|
|
|
|
// Drives the *real* vendor typeahead the way a user does: open the dropdown,
|
|
// click a rendered result. The vendor search is backed by Solr (unavailable in
|
|
// tests), so the result option is injected into the typeahead's Alpine
|
|
// `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
|
|
// 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 typeahead = wrapper.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' });
|
|
|
|
// Type under the 3-char search threshold so no Solr request fires and clears
|
|
// our injected option, while still dirtying the input so it fires a native
|
|
// `change` on blur -- the event that used to clobber the selection.
|
|
await search.fill('te');
|
|
|
|
// Inject a clickable result into the typeahead's Alpine state.
|
|
await typeahead.evaluate(
|
|
(el: HTMLElement, opt: { id: number; label: string }) => {
|
|
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
|
|
},
|
|
{ id: vendorId, label: vendorName }
|
|
);
|
|
|
|
// Click the rendered option: fires the search input's native change (stale
|
|
// value) AND the synthetic change carrying the new value, then HTMX swaps.
|
|
await page.locator('[data-tippy-root] a', { hasText: vendorName }).first().click();
|
|
|
|
await page.waitForResponse(
|
|
(response: any) =>
|
|
response.url().includes('/edit-vendor-changed') && response.status() === 200
|
|
);
|
|
await page.waitForTimeout(500);
|
|
}
|
|
|
|
// Opens the edit modal and activates the Manual tab, waiting on the vendor
|
|
// typeahead rather than the account grid (which only exists in advanced mode).
|
|
async function openManualVendorSection(page: any, transactionIndex: number) {
|
|
await page.goto('/transaction2');
|
|
await page.waitForSelector('table tbody tr');
|
|
|
|
const editButton = page
|
|
.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]')
|
|
.nth(transactionIndex);
|
|
await editButton.click();
|
|
|
|
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
|
|
await page.waitForSelector('#wizardmodal');
|
|
await page.click('button:has-text("Manual")');
|
|
await page.waitForSelector('div[hx-post*="edit-vendor-changed"]');
|
|
}
|
|
|
|
test.describe('Transaction Edit Vendor Selection', () => {
|
|
test('selecting a vendor from the dropdown updates the displayed vendor', async ({ page }) => {
|
|
await openManualVendorSection(page, 3);
|
|
|
|
const testInfo = await getTestInfo(page);
|
|
const vendorId: number = testInfo.accounts.vendor;
|
|
|
|
await selectVendorViaDropdown(page, vendorId, 'Test Vendor');
|
|
|
|
// The displayed vendor label must reflect the selection after the HTMX
|
|
// 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"]')
|
|
.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]"]'
|
|
)
|
|
.first();
|
|
await expect(hidden).toHaveValue(vendorId.toString());
|
|
});
|
|
});
|
|
|
|
test.describe('Transaction Link Date Display', () => {
|
|
test('should show payment date when linking to payment', async ({ page }) => {
|
|
await openEditModalForTransaction(page, 'Transaction for payment link');
|
|
|
|
// Click on "Link to payment" tab
|
|
await page.click('button:has-text("Link to payment")');
|
|
await page.waitForTimeout(500);
|
|
|
|
// Verify the payment option shows the date
|
|
await expect(page.locator('#payment-matches')).toContainText('Available Payments');
|
|
await expect(page.locator('#payment-matches')).toContainText('06/14/2023');
|
|
});
|
|
|
|
test('should show invoice date when linking to unpaid invoice', async ({ page }) => {
|
|
await openEditModalForTransaction(page, 'Transaction for unpaid invoice link');
|
|
|
|
// Click on "Link to unpaid invoices" tab
|
|
await page.click('button:has-text("Link to unpaid invoices")');
|
|
await page.waitForTimeout(500);
|
|
|
|
// Verify the invoice option shows the date
|
|
await expect(page.locator('text=Available Unpaid Invoices')).toBeVisible();
|
|
await expect(page.locator('text=UNPAID-001')).toBeVisible();
|
|
await expect(page.locator('text=07/19/2023')).toBeVisible();
|
|
});
|
|
});
|