fix(ssr): strip UI-only :mode before transaction upsert (500 on advanced manual save)

The :manual save handler builds its tx-data from the wizard snapshot and stripped the
control fields :action and :amount-mode, but not :mode (simple/advanced) added by the
recent manual-coding work. manual-coding-section* emits step-params[mode] on every
render, so EVERY advanced manual save posted :mode "advanced" into :upsert-transaction
and 500'd with ":db.error/not-an-entity :mode". Strip :mode alongside :action so the
upsert only sees real schema attributes.

Also fix the e2e helper that masked this: selectAccountFromTypeahead poked the Alpine v2
internal `el.__x.$data`, which is undefined on Alpine v3 (this app loads alpinejs@3.x),
so it silently no-op'd and the account posted empty. Drive the typeahead via the real
Alpine v3 path (Alpine.$data + tippy dropdown + click), mirroring transaction-edit-swap.

Unmasks the previously-failing "Shared Location spread on save" test (was first in a
serial file, hiding 7 siblings). Verified: that test passes; transaction-edit-swap stays
6/6. Skill gotchas.md records the :mode-strip rule, the Alpine-v3 API requirement, and
the modal-won't-close diagnosis recipe.
This commit is contained in:
2026-06-03 06:05:42 -07:00
parent ed3344438b
commit 69eed1f8a6
3 changed files with 59 additions and 58 deletions

View File

@@ -41,68 +41,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}`);
}
// 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);
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) {