refactor(ssr): full Selmer migration of Transaction Edit; remove the wizard

Migrate every part of the Transaction Edit modal's HTML to Selmer templates
(zero Hiccup in the render path) and delete the mm multi-modal "wizard"
abstraction entirely -- there was only ever one step.

- New auto-ap.ssr.components.selmer (sc) + ~22 shared component partials under
  resources/templates/components/ (typeahead, button-group, radio-card,
  data-grid, validated-field, modal, buttons, inputs, SVGs). Each wrapper renders
  its own partial; dynamic HTMX/Alpine attrs bridge via attrs->str -> {{attrs|safe}}.
- 15 modal templates under resources/templates/transaction-edit/.
- Delete EditWizard/LinksStep records + all mm/* usage. Plain handlers: flat
  wrap-decode-edit (fields renamed off step-params[...], stray keys stripped),
  flat wrap-derive-state, *errors*-based field errors, generic wrap-form-4xx-2.
- Drop the edit-wizard-navigate route (routes ~12 -> 5).
- Fix: stray `method` (tab button-group hidden) leaked into the upsert -> 500;
  strip decoded map to schema keys.
- e2e selectors updated (#wizard-form->#edit-form, #wizardmodal->#editmodal,
  step-params[...] field names). Parity: swap 6/6, edit 8/8, suite 38/1
  (1 pre-existing unrelated nav test).
- ssr-form-migration skill updated with the learnings (composition mechanics,
  sc/* library, drop-the-wizard recipe, scorecard row, 3 new gotchas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 07:47:47 -07:00
parent c892719bd1
commit a01dfc197e
47 changed files with 1161 additions and 659 deletions

View File

@@ -5,7 +5,7 @@ import { test, expect } from '@playwright/test';
// 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,
// 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
@@ -32,7 +32,7 @@ async function openManualAdvanced(page: any, transactionIndex = 0) {
.nth(transactionIndex)
.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")');
// First transaction has no accounts so it opens in "simple" mode. Switch to
@@ -107,7 +107,7 @@ test.describe('Transaction Edit whole-form swap', () => {
.toBeGreaterThan(0);
// The form must survive the swap intact.
await expect(page.locator('#wizard-form')).toHaveCount(1);
await expect(page.locator('#edit-form')).toHaveCount(1);
expect(errors, errors.join('\n')).toEqual([]);
});
@@ -192,7 +192,7 @@ test.describe('Transaction Edit whole-form swap', () => {
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.waitForSelector('#editmodal');
const memo = page.locator('#edit-memo');
await memo.waitFor();
@@ -301,7 +301,7 @@ test.describe('Transaction Edit whole-form swap', () => {
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.waitForSelector('#editmodal');
await page.click('button:has-text("Manual")');
await page.waitForSelector('div[hx-vals*="vendor-changed"]');
@@ -350,7 +350,7 @@ test.describe('Transaction Edit whole-form swap', () => {
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.waitForSelector('#editmodal');
await page.click('button:has-text("Manual")');
await page.waitForSelector('div[hx-vals*="vendor-changed"]');

View File

@@ -13,7 +13,7 @@ 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.
@@ -144,7 +144,7 @@ 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
@@ -155,7 +155,7 @@ async function toggleToPercentMode(page: any) {
}
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
@@ -235,7 +235,7 @@ test.describe('Transaction Edit Full Workflow', () => {
await openEditModal(page, 0);
await page.waitForTimeout(500);
const dollarRadio = page.locator('input[name="step-params[amount-mode]"][value="$"]');
const dollarRadio = page.locator('input[name="amount-mode"][value="$"]');
await expect(dollarRadio).toBeChecked();
const val0 = await (await findAccountRow(page, 0)).locator('.account-amount-field').inputValue();
@@ -272,7 +272,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();
// Verify the account row is still there with our $50 value
@@ -304,7 +304,7 @@ async function openEditModalForTransaction(page: any, description: string) {
// 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');
}
async function selectVendorFromTypeahead(page: any, vendorName: string) {
@@ -447,7 +447,7 @@ 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-vals*="vendor-changed"]');
}
@@ -472,7 +472,7 @@ test.describe('Transaction Edit Vendor Selection', () => {
// The server-rendered hidden input must carry the newly selected vendor id.
const hidden = page
.locator(
'div[hx-vals*="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());