refactor(ssr): Phase 10 — migrate Client wizard onto the engine (7 steps + bank-account sub-editor)

The largest SSR modal, moved off the mm/* multi-step wizard protocol machinery
(ClientWizard/*Modal records, MultiStepFormState, fc/* form-cursors, EDN-snapshot
round-trip) onto the session-backed engine (wizard2 + wizard-state): flat de-cursored
field names, whole-form HTMX swaps, per-step session state combined by the done-fn.

Seven linear steps (info → matches → contact → bank-accounts → integrations → cash-flow
→ other-settings), each a data-driven {:decode :validate :render :next}. The grid, form
schemas, and the sales power-query export are preserved unchanged.

The parameterized [:bank-account which] mm sub-step (which the linear engine can't model)
becomes a sub-editor of the bank-accounts step: the list view and per-account editor are
whole-form swaps of #wizard-form, driven by dedicated routes (new/edit/accept/discard/
sort) that mutate the :bank-accounts step-data in the session directly and re-render via
the engine's render-wizard. The bank-accounts step's :decode is a pass-through that
re-affirms the session-managed list (read via a `wiz` hidden the engine doesn't strip),
so Next never wipes it.

Notable fixes carried over from prior phases:
- New vs edit is keyed off :db/id presence (the engine always POSTs, so the old PUT/POST
  split no longer distinguishes them).
- Client + bank-account dates are coerced to #inst for EDN-safe session storage
  (clj-time DateTime has no cookie-session reader).
- An empty Contact-step address posts blank fields → decodes to an all-nil, db/id-less
  map; blank-address? drops it before upsert (else datomic: "tempid used only as value").

Routes: drop ::navigate/::discard; add the four bank-account sub-editor routes.
Full e2e suite green (71/71); client-wizard acceptance spec rewritten for the engine
(flat field names, data-primary nav, bank-account open/accept/discard sub-flows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 00:49:56 -07:00
parent b0fe7cc70d
commit 7b0e8bfd65
3 changed files with 833 additions and 1211 deletions

View File

@@ -1,14 +1,14 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
// Characterization spec for the Client wizard — the largest SSR modal in the app: // Acceptance spec for the New/Edit Client wizard — the largest SSR modal in the app:
// seven linear steps (info → matches → contact → bank-accounts → integrations → // seven linear steps (info → matches → contact → bank-accounts → integrations → cash-flow →
// cash-flow → other-settings) PLUS a parameterized bank-account sub-editor reached // other-settings) PLUS a parameterized bank-account sub-editor reached from the
// from the bank-accounts step. This pins the CURRENT (pre-migration) behavior so the // bank-accounts step. Migrated onto the session-backed engine (wizard2): flat de-cursored
// Phase 10 migration onto the session-backed engine preserves it. // field names (client/name, not step-params[client/name]), whole-form HTMX swaps, and the
// bank-account add/edit/sort modeled as whole-form swaps of #wizard-form.
// //
// The seed (test_server.clj) exposes client "Test Client" (code TEST, location DT) which // The seed (test_server.clj) exposes client "Test Client" (code TEST, location DT) which
// owns one "Test Checking" (TEST-CHK) bank account. The admin grid's base query requires // owns one "Test Checking" (TEST-CHK, a checking account) bank account.
// :client/name, so the seed gives TEST a name purely so the row is selectable here.
test.beforeEach(async ({ request }) => { await request.post('/test-reset'); }); test.beforeEach(async ({ request }) => { await request.post('/test-reset'); });
// The grid lazy-loads its rows into #entity-table after the page renders. // The grid lazy-loads its rows into #entity-table after the page renders.
@@ -32,23 +32,28 @@ async function openEditTestClient(page: any) {
await page.waitForTimeout(400); await page.waitForTimeout(400);
} }
// Jump to a step via its timeline button (validates the current step first). Steps are // Advance one step: click the data-primary Next button and wait until the whole-form swap
// keyed in the navigate URL as `to=:<step>` (the colon is %3A url-encoded in the attr). // has actually changed the current-step hidden (the timeline lists every step name, so a
async function gotoStep(page: any, step: string) { // text check can't confirm progress — the hidden value can).
await page.locator(`#wizard-form [hx-put*="to=%3A${step}"]`).first().click(); async function advance(page: any) {
await page.waitForTimeout(500); const before = await page.locator('#wizard-form input[name="current-step"]').first().inputValue();
await page.locator('#wizard-form button[data-primary]').first().click();
await page.waitForFunction(
(prev: string) => {
const el = document.querySelector('#wizard-form input[name="current-step"]') as HTMLInputElement | null;
return !!el && el.value !== prev;
}, before, { timeout: 6000 });
} }
test.describe.configure({ mode: 'serial' }); test.describe.configure({ mode: 'serial' });
test.describe('Client wizard (characterization)', () => { test.describe('Client wizard (acceptance)', () => {
test('new dialog renders the info step with the 7-step timeline', async ({ page }) => { test('new dialog renders the info step with the 7-step timeline', async ({ page }) => {
await openNewClient(page); await openNewClient(page);
const form = page.locator('#wizard-form'); const form = page.locator('#wizard-form');
await expect(form.locator('input[name="step-params[client/name]"]')).toBeVisible(); await expect(form.locator('input[name="client/name"]')).toBeVisible();
await expect(form.locator('input[name="step-params[client/code]"]').first()).toBeVisible(); await expect(form.locator('input[name="client/code"]').first()).toBeVisible();
await expect(form).toContainText('Locations'); await expect(form).toContainText('Locations');
// the timeline lists all seven steps
for (const label of ['Info', 'Matches', 'Contact', 'Bank Accounts', for (const label of ['Info', 'Matches', 'Contact', 'Bank Accounts',
'Integrations', 'Cash Flow', 'Other Settings']) { 'Integrations', 'Cash Flow', 'Other Settings']) {
await expect(form).toContainText(label); await expect(form).toContainText(label);
@@ -58,29 +63,63 @@ test.describe('Client wizard (characterization)', () => {
test('edit opens prefilled with the name and a disabled code', async ({ page }) => { test('edit opens prefilled with the name and a disabled code', async ({ page }) => {
await openEditTestClient(page); await openEditTestClient(page);
const form = page.locator('#wizard-form'); const form = page.locator('#wizard-form');
await expect(form.locator('input[name="step-params[client/name]"]')).toHaveValue('Test Client'); await expect(form.locator('input[name="client/name"]')).toHaveValue('Test Client');
// the visible code input is disabled on edit (a hidden twin carries the value on submit) // the visible code input is disabled on edit (a hidden twin carries the value on submit)
const code = form.locator('input[name="step-params[client/code]"]').first(); const code = form.locator('input[name="client/code"]').first();
await expect(code).toHaveValue('TEST'); await expect(code).toHaveValue('TEST');
await expect(code).toBeDisabled(); await expect(code).toBeDisabled();
}); });
test('bank-accounts step shows the seeded account card and a new-account affordance', async ({ page }) => { test('bank-accounts step shows the seeded account card and a new-account affordance', async ({ page }) => {
await openEditTestClient(page); await openEditTestClient(page);
await gotoStep(page, 'bank-accounts'); await advance(page); // info -> matches
await advance(page); // matches -> contact
await advance(page); // contact -> bank-accounts
const form = page.locator('#wizard-form'); const form = page.locator('#wizard-form');
// the seeded bank account renders as a card showing its name await expect(form).toContainText('Bank Accounts');
await expect(form).toContainText('Test Checking'); await expect(form).toContainText('Test Checking');
// the add-a-bank-account affordance is present await expect(form).toContainText('Add a new');
await expect(form).toContainText('Add a new cash account'); });
test('opening the bank-account editor swaps in the per-account form', async ({ page }) => {
await openEditTestClient(page);
await advance(page); await advance(page); await advance(page); // -> bank-accounts
// click the pencil on the seeded account card to open its editor
await page.locator('#wizard-form [hx-get*="/bank-account/edit"]').first().click();
await page.waitForTimeout(450);
const form = page.locator('#wizard-form');
await expect(form.locator('input[name="bank-account/name"]')).toHaveValue('Test Checking');
await expect(form).toContainText('Accept');
// discard returns to the list
await page.locator('#wizard-form [hx-get*="/bank-account/discard"]').first().click();
await page.waitForTimeout(450);
await expect(form).toContainText('Test Checking');
});
test('accepting a bank-account edit merges the change back into the card', async ({ page }) => {
await openEditTestClient(page);
await advance(page); await advance(page); await advance(page); // -> bank-accounts
await page.locator('#wizard-form [hx-get*="/bank-account/edit"]').first().click();
await page.waitForTimeout(450);
const form = page.locator('#wizard-form');
await form.locator('input[name="bank-account/name"]').fill('Renamed Checking');
await form.locator('button[data-primary]:has-text("Accept")').first().click();
await page.waitForTimeout(450);
// back on the list, the card now shows the new nickname
await expect(form).toContainText('Renamed Checking');
}); });
test('editing through to the last step and saving keeps the client in the grid', async ({ page }) => { test('editing through to the last step and saving keeps the client in the grid', async ({ page }) => {
await openEditTestClient(page); await openEditTestClient(page);
// rename, then jump to the final step and save const nameInput = page.locator('#wizard-form input[name="client/name"]');
await page.locator('#wizard-form input[name="step-params[client/name]"]').fill('Test Client RENAMED'); await nameInput.fill('Test Client RENAMED');
await gotoStep(page, 'other-settings'); await expect(nameInput).toHaveValue('Test Client RENAMED');
await page.locator('#wizard-form button[type="submit"]:has-text("Save")').first().click(); // info -> matches -> contact -> bank-accounts -> integrations -> cash-flow -> other-settings
for (let i = 0; i < 6; i++) await advance(page);
// the last step is the only one with a Feature Flags grid — confirm we really got here
await expect(page.locator('#wizard-form')).toContainText('Feature Flags');
// Save persists the edit; reload the grid and the rename is there
await page.locator('#wizard-form button[data-primary]').first().click();
await page.waitForTimeout(1200); await page.waitForTimeout(1200);
await openClientList(page); await openClientList(page);
await expect(page.locator('#entity-table')).toContainText('Test Client RENAMED'); await expect(page.locator('#entity-table')).toContainText('Test Client RENAMED');

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,6 @@
:post ::save} :post ::save}
"/table" ::table "/table" ::table
"/navigate" ::navigate
"/bank-accounts/sort" ::sort-bank-accounts
"/discard" ::discard
"/square-locations" ::refresh-square-locations "/square-locations" ::refresh-square-locations
"/location/new" ::new-location "/location/new" ::new-location
@@ -15,6 +12,13 @@
"/email-contact/new" ::new-email-contact "/email-contact/new" ::new-email-contact
"/group/new" ::new-group "/group/new" ::new-group
"/feature-flag/new" ::new-feature-flag "/feature-flag/new" ::new-feature-flag
"/bank-account/new" ::new-bank-account
"/bank-account/edit" ::edit-bank-account
"/bank-account/accept" {:post ::accept-bank-account}
"/bank-account/discard" ::discard-bank-account
"/bank-accounts/sort" ::sort-bank-accounts
"/new" {:get ::new-dialog} "/new" {:get ::new-dialog}
["/" [#"\d+" :db/id] "/sales-powerquery"] ::biweekly-sales-powerquery ["/" [#"\d+" :db/id] "/sales-powerquery"] ::biweekly-sales-powerquery
["/" [#"\d+" :db/id] "/edit"] ::edit-dialog}) ["/" [#"\d+" :db/id] "/edit"] ::edit-dialog})