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:
2026-06-24 08:23:34 -07:00
parent e2ccfc8d2c
commit 70c178de83
62 changed files with 3091 additions and 1006 deletions

View File

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