diff --git a/.claude/skills/ssr-form-migration/reference/scorecard.md b/.claude/skills/ssr-form-migration/reference/scorecard.md index 5d8a49f4..655bab92 100644 --- a/.claude/skills/ssr-form-migration/reference/scorecard.md +++ b/.claude/skills/ssr-form-migration/reference/scorecard.md @@ -39,7 +39,7 @@ Each migration appends one row (after-numbers), referencing the before in the di | Phase | Modal | LOC | Routes | no-cursor twins | faked roots | snapshot merges | OOB | mixed hx- | cookbook reused / added | |-------|-------|-----|--------|-----------------|-------------|-----------------|-----|-----------|-------------------------| | 1 (baseline) | Transaction Edit `transaction/edit.clj` | 1608 | ~12 | 1 | 2 | ~75 | 0 | 8 | — / seeded 7 entries | -| 2 (in progress) | Transaction Edit `transaction/edit.clj` | ~1558 | **~10** | **0** | **0** | ~75 | 0 | 8 | — / 0 | +| 2 (in progress) | Transaction Edit `transaction/edit.clj` | ~1530 | **~5** | **0** | **0** | ~75 | 0 | 8 | — / 0 | > **Phase 2 progress (partial).** Achieved with parity held (swap spec 6/6 + Shared > Location green, full suite 31 pass / no regression): deleted the dead `*-no-cursor*` diff --git a/e2e/transaction-edit-swap.spec.ts b/e2e/transaction-edit-swap.spec.ts index 8eb776ff..8d1a251e 100644 --- a/e2e/transaction-edit-swap.spec.ts +++ b/e2e/transaction-edit-swap.spec.ts @@ -48,7 +48,7 @@ async function openManualAdvanced(page: any, transactionIndex = 0) { // (Solr is unavailable in tests), click it, and wait for the whole-form swap. async function selectVendor(page: any, vendorId: number, label: string) { const vendor = page - .locator('div[hx-post*="edit-vendor-changed"]') + .locator('div[hx-vals*="vendor-changed"]') .first() .locator('div.relative[x-data]') .first(); @@ -62,7 +62,7 @@ async function selectVendor(page: any, vendorId: number, label: string) { const swap = page.waitForResponse( (r: any) => - r.url().includes('edit-vendor-changed') && + r.url().includes('edit-form-changed') && r.request().method() === 'POST' && r.status() === 200 ); @@ -303,7 +303,7 @@ test.describe('Transaction Edit whole-form swap', () => { await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click(); await page.waitForSelector('#wizardmodal'); await page.click('button:has-text("Manual")'); - await page.waitForSelector('div[hx-post*="edit-vendor-changed"]'); + await page.waitForSelector('div[hx-vals*="vendor-changed"]'); const testInfo = await (await page.request.get('/test-info')).json(); const vendorId: number = testInfo.accounts.vendor; @@ -311,7 +311,7 @@ test.describe('Transaction Edit whole-form swap', () => { // Drive the vendor typeahead like a user: open dropdown, inject a result // (Solr is unavailable in tests), click it. - const vendor = page.locator('div[hx-post*="edit-vendor-changed"]').first().locator('div.relative[x-data]').first(); + const vendor = page.locator('div[hx-vals*="vendor-changed"]').first().locator('div.relative[x-data]').first(); await vendor.locator('a[x-ref="input"]').click(); const search = page.locator('[data-tippy-root] input[x-model="search"]').first(); await search.waitFor({ state: 'visible' }); @@ -322,7 +322,7 @@ test.describe('Transaction Edit whole-form swap', () => { const swap = page.waitForResponse( (r: any) => - r.url().includes('edit-vendor-changed') && + r.url().includes('edit-form-changed') && r.request().method() === 'POST' && r.status() === 200 ); @@ -352,7 +352,7 @@ test.describe('Transaction Edit whole-form swap', () => { await page.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]').nth(0).click(); await page.waitForSelector('#wizardmodal'); await page.click('button:has-text("Manual")'); - await page.waitForSelector('div[hx-post*="edit-vendor-changed"]'); + await page.waitForSelector('div[hx-vals*="vendor-changed"]'); const testInfo = await (await page.request.get('/test-info')).json(); const vendor1: number = testInfo.accounts.vendor; @@ -361,7 +361,7 @@ test.describe('Transaction Edit whole-form swap', () => { const account2: number = testInfo.accounts['second-account']; const vendorLabel = 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(); const accountHidden = page .locator('input[type="hidden"][name*="transaction-account/account"]') diff --git a/e2e/transaction-edit.spec.ts b/e2e/transaction-edit.spec.ts index 3f7d948e..ff350f25 100644 --- a/e2e/transaction-edit.spec.ts +++ b/e2e/transaction-edit.spec.ts @@ -150,7 +150,7 @@ async function toggleToPercentMode(page: any) { // 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); } @@ -161,7 +161,7 @@ async function toggleToDollarMode(page: any) { // 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); } @@ -359,7 +359,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) => { @@ -374,7 +374,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); } @@ -434,11 +434,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]). @@ -466,7 +466,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); } @@ -485,7 +485,7 @@ async function openManualVendorSection(page: any, transactionIndex: number) { 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"]'); + await page.waitForSelector('div[hx-vals*="vendor-changed"]'); } test.describe('Transaction Edit Vendor Selection', () => { @@ -501,14 +501,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="step-params[transaction/vendor]"]' ) .first(); await expect(hidden).toHaveValue(vendorId.toString()); diff --git a/src/clj/auto_ap/ssr/transaction/edit.clj b/src/clj/auto_ap/ssr/transaction/edit.clj index 4979e0be..5e5ad1f8 100644 --- a/src/clj/auto_ap/ssr/transaction/edit.clj +++ b/src/clj/auto_ap/ssr/transaction/edit.clj @@ -263,7 +263,8 @@ :value total})]] [:div.mt-1 [:a.text-sm.text-blue-600.hover:underline.cursor-pointer - {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode) + {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) + :hx-vals (hx/json {:op "toggle-mode"}) :hx-include "closest form" :hx-target "#wizard-form" :hx-select "#wizard-form" @@ -344,8 +345,8 @@ (com/text-input (assoc amount-attrs :type "number" :step "0.01")) (com/money-input amount-attrs)))))) (com/data-grid-cell {:class "align-top"} - (com/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-remove-account) - :hx-vals (hx/json {:row-index (or index 0)}) + (com/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) + :hx-vals (hx/json {:op "remove-account" :row-index (or index 0)}) :hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML" @@ -428,7 +429,8 @@ :value amount-mode :name "step-params[amount-mode]" :orientation :horizontal - :hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode) + :hx-vals (hx/json {:op "toggle-amount-mode"}) + :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) :hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML" @@ -468,7 +470,8 @@ (com/data-grid-row {:class "new-row"} (com/data-grid-cell {:colspan 4} - (com/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-new-account) + (com/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) + :hx-vals (hx/json {:op "new-account"}) :hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML" @@ -490,7 +493,8 @@ [:div#manual-coding-section (com/hidden {:name "step-params[mode]" :value (name mode)}) [:div {:hx-trigger "change" - :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed) + :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) + :hx-vals (hx/json {:op "vendor-changed"}) :hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML" @@ -516,7 +520,8 @@ (when (<= row-count 1) [:div.mb-2 [:a.text-sm.text-blue-600.hover:underline.cursor-pointer - {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode) + {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) + :hx-vals (hx/json {:op "toggle-mode"}) :hx-include "closest form" :hx-target "#wizard-form" :hx-select "#wizard-form" @@ -528,17 +533,17 @@ [:div#account-grid-body (account-grid-body* request)]))])])) -(defn toggle-amount-mode [request] +(defn apply-toggle-amount-mode + "edit-form-changed op: convert account amounts between $ and % and record the new mode." + [request] (let [snapshot (-> request :multi-form-state :snapshot) old-mode (or (:amount-mode snapshot) "$") new-mode (or (get-in request [:multi-form-state :step-params :amount-mode]) "$") total (Math/abs (or (:transaction/amount snapshot) 0.0)) - accounts (convert-accounts-mode (:transaction/accounts snapshot) old-mode new-mode total) - updated-request (-> request - (assoc-in [:multi-form-state :snapshot :transaction/accounts] accounts) - (assoc-in [:multi-form-state :snapshot :amount-mode] new-mode))] - (html-response - (render-full-form updated-request)))) + accounts (convert-accounts-mode (:transaction/accounts snapshot) old-mode new-mode total)] + (-> request + (assoc-in [:multi-form-state :snapshot :transaction/accounts] accounts) + (assoc-in [:multi-form-state :snapshot :amount-mode] new-mode)))) (defn transaction-details-panel [tx] [:div.p-4.space-y-4 @@ -1372,14 +1377,7 @@ [request] (mm/render-wizard edit-wizard request)) -(defn edit-form-changed-handler - "Generic handler that re-renders the whole form. Used when any field changes - and we need the server to re-compute dependent fields." - [request] - (html-response - (render-full-form request))) - -(defn edit-vendor-changed-handler [request] +(defn apply-vendor-changed [request] (let [multi-form-state (:multi-form-state request) snapshot (:snapshot multi-form-state) step-params (:step-params multi-form-state) @@ -1421,10 +1419,9 @@ (assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account]))) request) (assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))] - (html-response - (render-full-form render-request)))) + render-request)) -(defn edit-wizard-toggle-mode-handler [request] +(defn apply-toggle-mode [request] (let [step-params (-> request :multi-form-state :step-params) snapshot (-> request :multi-form-state :snapshot) current-mode (keyword (or (:mode step-params) "simple")) @@ -1453,11 +1450,10 @@ (if first-row [first-row] [])) (assoc-in [:multi-form-state :step-params :mode] (name target-mode)))))] - (html-response - (render-full-form render-request)))) + render-request)) -(defn edit-wizard-new-account-handler - "Adds a new account row and re-renders the whole form." +(defn apply-new-account + "edit-form-changed op: append a fresh account row." [request] (let [snapshot (-> request :multi-form-state :snapshot) amount-mode (or (:amount-mode snapshot) "$") @@ -1471,12 +1467,10 @@ updated-request (-> request (assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts) (assoc-in [:multi-form-state :step-params :transaction/accounts] updated-accounts))] - (html-response - (render-full-form updated-request)))) + updated-request)) -(defn edit-wizard-remove-account-handler - "Removes an account row and re-renders the whole form. - Expects a row-index in the form params." +(defn apply-remove-account + "edit-form-changed op: remove the account row at form-param row-index." [request] (let [row-index (some-> request :form-params (get "row-index") Integer/parseInt) snapshot (-> request :multi-form-state :snapshot) @@ -1488,8 +1482,24 @@ updated-request (-> request (assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts) (assoc-in [:multi-form-state :step-params :transaction/accounts] updated-accounts))] + updated-request)) + +(defn edit-form-changed-handler + "Single whole-form re-render endpoint. Dispatches on the `op` form-param to apply the + relevant state mutation (vendor change, mode toggle, add/remove row, $/% toggle), then + re-renders the whole form. A missing/unknown op (a plain dependent-field change) just + re-renders. Replaces the per-operation edit-wizard-* / toggle-amount-mode routes." + [request] + (let [op (get-in request [:form-params "op"]) + request' (case op + "vendor-changed" (apply-vendor-changed request) + "toggle-mode" (apply-toggle-mode request) + "new-account" (apply-new-account request) + "remove-account" (apply-remove-account request) + "toggle-amount-mode" (apply-toggle-amount-mode request) + request)] (html-response - (render-full-form updated-request)))) + (render-full-form request')))) (def key->handler (apply-middleware-to-all-handlers @@ -1509,10 +1519,6 @@ (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) (mm/wrap-wizard edit-wizard) (mm/wrap-decode-multi-form-state)) - ::route/edit-vendor-changed (-> edit-vendor-changed-handler - (mm/wrap-wizard edit-wizard) - (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) - (mm/wrap-decode-multi-form-state)) ::route/location-select (-> location-select (wrap-schema-enforce :query-schema [:map [:name :string] @@ -1524,23 +1530,6 @@ (mm/wrap-wizard edit-wizard) (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) (mm/wrap-decode-multi-form-state)) - ::route/toggle-amount-mode (-> toggle-amount-mode - (mm/wrap-wizard edit-wizard) - (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) - (mm/wrap-decode-multi-form-state)) - ::route/edit-wizard-toggle-mode (-> edit-wizard-toggle-mode-handler - (mm/wrap-wizard edit-wizard) - (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) - (mm/wrap-decode-multi-form-state)) - ::route/edit-wizard-new-account (-> edit-wizard-new-account-handler - (mm/wrap-wizard edit-wizard) - (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) - (mm/wrap-decode-multi-form-state)) - ::route/edit-wizard-remove-account (-> edit-wizard-remove-account-handler - (mm/wrap-wizard edit-wizard) - (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) - (mm/wrap-decode-multi-form-state)) - ::route/unlink-payment (-> unlink-payment (wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client))) (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) diff --git a/src/cljc/auto_ap/routes/transactions.cljc b/src/cljc/auto_ap/routes/transactions.cljc index e5c6c48e..246a207d 100644 --- a/src/cljc/auto_ap/routes/transactions.cljc +++ b/src/cljc/auto_ap/routes/transactions.cljc @@ -28,13 +28,8 @@ ["/" [#"\d+" :db/id]] {"/edit" {:get ::edit-wizard}} "/edit-submit" ::edit-submit - "/edit-vendor-changed" ::edit-vendor-changed "/location-select" ::location-select - "/toggle-amount-mode" ::toggle-amount-mode "/edit-form-changed" ::edit-form-changed - "/edit-wizard-new-account" ::edit-wizard-new-account - "/edit-wizard-remove-account" ::edit-wizard-remove-account - "/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode "/match-payment" ::link-payment "/match-autopay-invoices" ::link-autopay-invoices "/match-unpaid-invoices" ::link-unpaid-invoices