From cdb6bb6fe31497c7267f44e47247b85dc337cc5f Mon Sep 17 00:00:00 2001 From: Bryce Date: Mon, 1 Jun 2026 07:40:30 -0700 Subject: [PATCH] Whole-form htmx + Alpine morph for transaction edit Re-render the entire #wizard-form on each field edit and swap with hx-swap="morph" so the focused input keeps focus/caret/value while typing. - Field-level routes return the full form and target #wizard-form - Key state-owning wrappers (account rows, simple-mode wrapper, vendor typeahead) so server-driven value changes re-init across the morph - Guard tippy/$refs access in typeahead against stale post-morph state - Round-trip simple/advanced mode via step-params[mode] - Add e2e/transaction-edit-morph.spec.ts covering focus/caret preservation, vendor->account population, and repeated vendor changes - Seed a second vendor/account for test isolation Co-Authored-By: Claude Opus 4.8 --- e2e/transaction-edit-morph.spec.ts | 387 ++++++++++++++++++++++ e2e/transaction-edit.spec.ts | 10 +- resources/public/js/htmx-disable.js | 60 ++++ src/clj/auto_ap/ssr/components/inputs.clj | 51 +-- src/clj/auto_ap/ssr/transaction/edit.clj | 292 ++++++++-------- src/clj/auto_ap/ssr/ui.clj | 32 +- src/cljc/auto_ap/routes/transactions.cljc | 2 + test/clj/auto_ap/test_server.clj | 32 +- 8 files changed, 681 insertions(+), 185 deletions(-) create mode 100644 e2e/transaction-edit-morph.spec.ts diff --git a/e2e/transaction-edit-morph.spec.ts b/e2e/transaction-edit-morph.spec.ts new file mode 100644 index 00000000..6ee9ca62 --- /dev/null +++ b/e2e/transaction-edit-morph.spec.ts @@ -0,0 +1,387 @@ +import { test, expect } from '@playwright/test'; + +// These tests cover the "render the whole form via an htmx + alpine morph swap" +// behaviour on the transaction edit page. The whole-form approach exists so that +// any edit can hit its own route yet re-render the entire form, while keeping the +// user's focus and caret position intact (which a plain innerHTML swap destroys). + +// Collect any uncaught page errors or console errors so a morph that throws +// (e.g. a tooltip callback dereferencing a stale $refs) fails the test loudly. +function trackErrors(page: any): string[] { + const errors: string[] = []; + page.on('pageerror', (e: any) => errors.push('pageerror: ' + e.message)); + page.on('console', (m: any) => { + if (m.type() === 'error') errors.push('console: ' + m.text()); + }); + return errors; +} + +async function openManualAdvanced(page: any, transactionIndex = 0) { + await page.goto('/transaction2'); + await page.waitForSelector('table tbody tr'); + await page + .locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]') + .nth(transactionIndex) + .click(); + await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' }); + await page.waitForSelector('#wizardmodal'); + await page.click('button:has-text("Manual")'); + + // First transaction has no accounts so it opens in "simple" mode. Switch to + // advanced mode (a whole-form morph swap) so the account grid is present. + const advancedLink = page.locator('a:has-text("Switch to advanced mode")'); + if (await advancedLink.count()) { + await advancedLink.first().click(); + await page.waitForSelector('#account-grid-body'); + } +} + +// Drives the vendor typeahead like a user: open the dropdown, inject a result +// (Solr is unavailable in tests), click it, and wait for the whole-form morph. +async function selectVendor(page: any, vendorId: number, label: string) { + const vendor = page + .locator('div[hx-post*="edit-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' }); + await search.fill('xx'); + await vendor.evaluate((el: HTMLElement, opt: { id: number; label: string }) => { + (window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }]; + }, { id: vendorId, label }); + + const swap = page.waitForResponse( + (r: any) => + r.url().includes('edit-vendor-changed') && + r.request().method() === 'POST' && + r.status() === 200 + ); + await page.locator('[data-tippy-root] a', { hasText: label }).first().click(); + await swap; + await page.waitForTimeout(400); +} + +// Removes every existing account row (each remove is its own whole-form morph), +// so a test starts from a known-empty state regardless of what earlier tests +// saved onto the shared transaction. +async function clearAccounts(page: any) { + // eslint-disable-next-line no-constant-condition + while (true) { + const removeButtons = page.locator('#account-grid-body .account-remove-action'); + const count = await removeButtons.count(); + if (count === 0) break; + await removeButtons.first().click(); + await expect + .poll(async () => page.locator('#account-grid-body .account-remove-action').count()) + .toBeLessThan(count); + } +} + +test.describe.configure({ mode: 'serial' }); + +test.describe('Transaction Edit whole-form morph', () => { + test('morph swaps (toggle mode, add account) do not throw', async ({ page }) => { + const errors = trackErrors(page); + + await openManualAdvanced(page, 0); + + // Add an account row -- another whole-form morph swap. + await page + .locator('#account-grid-body') + .locator('button:has-text("New account"), a:has-text("New account")') + .first() + .click(); + + await expect + .poll(async () => page.locator('#account-grid-body tbody tr.account-row').count()) + .toBeGreaterThan(0); + + // The form must survive the morph intact. + await expect(page.locator('#wizard-form')).toHaveCount(1); + expect(errors, errors.join('\n')).toEqual([]); + }); + + test('keeps focus and typed value in the amount field across a morph', async ({ page }) => { + const errors = trackErrors(page); + + await openManualAdvanced(page, 0); + + // Ensure exactly one account row exists. + const rows = await page.locator('#account-grid-body tbody tr.account-row').count(); + if (rows === 0) { + await page + .locator('#account-grid-body') + .locator('button:has-text("New account"), a:has-text("New account")') + .first() + .click(); + await expect + .poll(async () => page.locator('#account-grid-body tbody tr.account-row').count()) + .toBeGreaterThan(0); + } + + const amount = page.locator('.account-amount-field').first(); + await amount.waitFor(); + + // Type a clean value via the keyboard. Typing fires the field's htmx trigger + // (keyup), which posts to a per-field route and morphs the whole form back + // in. The amount field is type=number (no text caret), so we assert focus + + // node identity + value -- the guarantees morph gives that innerHTML can't. + await amount.click(); + await amount.press('Control+a'); + + const amountSwap = page.waitForResponse( + (r: any) => + r.url().includes('edit-form-changed') && + r.request().method() === 'POST' && + r.status() === 200 + ); + await amount.pressSequentially('150', { delay: 40 }); + + // Identify the live focused node (before the debounced morph lands) so we can + // prove the *same* node survives. + await page.evaluate(() => { + (window as any).__focusedAmount = document.activeElement; + }); + + await amountSwap; + await page.waitForTimeout(300); + + const state = await page.evaluate(() => { + const active = document.activeElement as HTMLInputElement; + return { + sameNode: active === (window as any).__focusedAmount, + isAmountField: !!active && active.classList.contains('account-amount-field'), + value: active ? active.value : null, + }; + }); + + // Focus must stay on the amount field after the morph... + expect(state.isAmountField).toBe(true); + // ...on the very same DOM node (this is what morph buys us over innerHTML)... + expect(state.sameNode).toBe(true); + // ...with the value the user typed left intact. + expect(state.value).toBe('150'); + + // The TOTAL must have recomputed server-side from the posted amount. + await expect(page.locator('.account-total-row #total')).toContainText('150'); + + expect(errors, errors.join('\n')).toEqual([]); + }); + + test('preserves caret position in the memo text field across a morph', async ({ page }) => { + const errors = trackErrors(page); + + 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'); + + const memo = page.locator('#edit-memo'); + await memo.waitFor(); + + // Clear any seeded memo text, then type "hello" via the keyboard (fires the + // field's htmx keyup trigger) and let that first whole-form morph settle. + await memo.click(); + await memo.press('Control+a'); + const firstSwap = page.waitForResponse( + (r: any) => + r.url().includes('edit-form-changed') && + r.request().method() === 'POST' && + r.status() === 200 + ); + await memo.pressSequentially('hello', { delay: 40 }); + await firstSwap; + await page.waitForTimeout(300); + + // Drop the caret in the middle (text inputs support selection). + await memo.evaluate((el: HTMLInputElement) => { + el.focus(); + el.setSelectionRange(2, 2); + }); + await page.evaluate(() => { + (window as any).__focusedMemo = document.activeElement; + }); + + // Insert a char at the caret -> "heXllo", caret moves to 3, fires the swap. + const memoSwap = page.waitForResponse( + (r: any) => + r.url().includes('edit-form-changed') && + r.request().method() === 'POST' && + r.status() === 200 + ); + await memo.press('X'); + await memoSwap; + await page.waitForTimeout(300); + + const state = await page.evaluate(() => { + const active = document.activeElement as HTMLInputElement; + return { + sameNode: active === (window as any).__focusedMemo, + id: active ? active.id : null, + value: active ? active.value : null, + caret: active ? active.selectionStart : null, + }; + }); + + expect(state.id).toBe('edit-memo'); + expect(state.sameNode).toBe(true); + expect(state.value).toBe('heXllo'); + expect(state.caret).toBe(3); + + expect(errors, errors.join('\n')).toEqual([]); + }); + + test('choosing an account from the typeahead does not throw and persists', async ({ page }) => { + const errors = trackErrors(page); + + await openManualAdvanced(page, 0); + + // Start from a clean, empty account row so selecting the account actually + // changes accountId (and fires the change-gated whole-form morph). + await clearAccounts(page); + await page + .locator('#account-grid-body') + .locator('button:has-text("New account"), a:has-text("New account")') + .first() + .click(); + await expect + .poll(async () => page.locator('#account-grid-body tbody tr.account-row').count()) + .toBeGreaterThan(0); + + const row = page.locator('#account-grid-body tbody tr.account-row').first(); + const typeahead = row.locator('div.relative[x-data]').first(); + + // Open the dropdown (tippy renders the popper into [data-tippy-root]). + 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' }); + + // Account search is backed by Solr (unavailable in tests), so type under the + // 3-char threshold and inject a clickable result into the typeahead state -- + // the click handler, tippy.hide(), Alpine reactivity and HTMX morph all run + // exactly as in production. + await search.fill('te'); + const testInfo = await (await page.request.get('/test-info')).json(); + const accountId: number = testInfo.accounts['test-account']; + await typeahead.evaluate((el: HTMLElement, id: number) => { + (window as any).Alpine.$data(el).elements = [{ value: id, label: 'Test Account' }]; + }, accountId); + + // Clicking the result runs `value = element; tippy.hide(); ...`. Before the + // fix this threw "tippy is null" because the cached tippy var was stale after + // an earlier morph. + const swap = page.waitForResponse( + (r: any) => + r.url().includes('edit-form-changed') && + r.request().method() === 'POST' && + r.status() === 200 + ); + await page.locator('[data-tippy-root] a', { hasText: 'Test Account' }).first().click(); + await swap; + await page.waitForTimeout(300); + + // The chosen account must survive the whole-form morph. + const hidden = page + .locator('#account-grid-body tbody tr.account-row') + .first() + .locator('input[type="hidden"][name*="transaction-account/account"]') + .first(); + await expect(hidden).toHaveValue(accountId.toString()); + + expect(errors, errors.join('\n')).toEqual([]); + }); + + test('selecting a vendor populates its default account across the morph', async ({ page }) => { + const errors = trackErrors(page); + + // Open the modal in simple mode (transaction 0 has no accounts). + 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.click('button:has-text("Manual")'); + await page.waitForSelector('div[hx-post*="edit-vendor-changed"]'); + + const testInfo = await (await page.request.get('/test-info')).json(); + const vendorId: number = testInfo.accounts.vendor; + const defaultAccountId: number = testInfo.accounts['test-account']; + + // 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(); + 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' }); + await search.fill('te'); + await vendor.evaluate((el: HTMLElement, id: number) => { + (window as any).Alpine.$data(el).elements = [{ value: id, label: 'Test Vendor' }]; + }, vendorId); + + const swap = page.waitForResponse( + (r: any) => + r.url().includes('edit-vendor-changed') && + r.request().method() === 'POST' && + r.status() === 200 + ); + await page.locator('[data-tippy-root] a', { hasText: 'Test Vendor' }).first().click(); + await swap; + await page.waitForTimeout(400); + + // The vendor's default account must now be reflected in the account field -- + // this is the bug the `key` re-init fixes: a server-driven value change into an + // Alpine-stateful typeahead that morph would otherwise preserve as empty. + const accountHidden = page + .locator('input[type="hidden"][name*="transaction-account/account"]') + .first(); + await expect(accountHidden).toHaveValue(defaultAccountId.toString()); + + // The displayed account label should resolve too. + await expect(page.locator('span[x-text="value.label"]', { hasText: 'Test Account' })).toBeVisible(); + + expect(errors, errors.join('\n')).toEqual([]); + }); + + test('changing the vendor a second time still updates it', async ({ page }) => { + const errors = trackErrors(page); + + 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.click('button:has-text("Manual")'); + await page.waitForSelector('div[hx-post*="edit-vendor-changed"]'); + + const testInfo = await (await page.request.get('/test-info')).json(); + const vendor1: number = testInfo.accounts.vendor; + const vendor2: number = testInfo.accounts.vendor2; + const account1: number = testInfo.accounts['test-account']; + const account2: number = testInfo.accounts['second-account']; + + const vendorLabel = page + .locator('div[hx-post*="edit-vendor-changed"] span[x-text="value.label"]') + .first(); + const accountHidden = page + .locator('input[type="hidden"][name*="transaction-account/account"]') + .first(); + + // First vendor. + await selectVendor(page, vendor1, 'Test Vendor'); + await expect(vendorLabel).toHaveText('Test Vendor'); + await expect(accountHidden).toHaveValue(account1.toString()); + + // Second vendor -- this is the regression: a morph-preserved typeahead lost its + // value watcher, so the second change fired no request at all. + await selectVendor(page, vendor2, 'Second Vendor'); + await expect(vendorLabel).toHaveText('Second Vendor'); + await expect(accountHidden).toHaveValue(account2.toString()); + + // And back again, to be sure it keeps working. + await selectVendor(page, vendor1, 'Test Vendor'); + await expect(vendorLabel).toHaveText('Test Vendor'); + await expect(accountHidden).toHaveValue(account1.toString()); + + expect(errors, errors.join('\n')).toEqual([]); + }); +}); diff --git a/e2e/transaction-edit.spec.ts b/e2e/transaction-edit.spec.ts index 297af94d..fc941d96 100644 --- a/e2e/transaction-edit.spec.ts +++ b/e2e/transaction-edit.spec.ts @@ -18,7 +18,15 @@ async function openEditModal(page: any, transactionIndex: number = 0) { // 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")'); - + + // Transactions with 0-1 accounts open in "simple" mode, which has no account + // grid. Switch to "advanced" mode (a whole-form morph 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 to appear await page.waitForSelector('#account-grid-body'); } diff --git a/resources/public/js/htmx-disable.js b/resources/public/js/htmx-disable.js index 38083f9d..b8094d52 100644 --- a/resources/public/js/htmx-disable.js +++ b/resources/public/js/htmx-disable.js @@ -416,4 +416,64 @@ htmx.onLoad(function(content) { console.error('Failed to copy text to clipboard:', err); } } +/* +(function() { + var lastFocusedSelector = null; + var lastCursorPosition = null; + + document.addEventListener('htmx:beforeSwap', function(evt) { + var active = document.activeElement; + if (active && active !== document.body) { + // Build a selector to find this element after swap + if (active.id) { + lastFocusedSelector = '#' + active.id; + } else if (active.name) { + lastFocusedSelector = '[name="' + active.name + '"]'; + } else { + lastFocusedSelector = null; + } + + // Save cursor position for text inputs. selectionStart is null on + // inputs that don't support selection (number, date, select, etc.), + // and calling setSelectionRange on those throws, so only capture it + // when it's an actual numeric caret position. + if (typeof active.selectionStart === 'number') { + lastCursorPosition = { + start: active.selectionStart, + end: active.selectionEnd, + direction: active.selectionDirection + }; + } else { + lastCursorPosition = null; + } + } + }); + + document.addEventListener('htmx:afterSwap', function(evt) { + if (lastFocusedSelector) { + setTimeout(function() { + var el = document.querySelector(lastFocusedSelector); + // If morph already kept focus on the right element there's nothing + // to do; only restore when focus was actually lost by the swap. + if (el && el.focus && document.activeElement !== el) { + el.focus(); + if (lastCursorPosition && el.setSelectionRange) { + try { + el.setSelectionRange( + lastCursorPosition.start, + lastCursorPosition.end, + lastCursorPosition.direction + ); + } catch (e) { } + } + } + lastFocusedSelector = null; + lastCursorPosition = null; + }, 10); + } + }); +})(); + +*/ + diff --git a/src/clj/auto_ap/ssr/components/inputs.clj b/src/clj/auto_ap/ssr/components/inputs.clj index ae865616..1d3bbf81 100644 --- a/src/clj/auto_ap/ssr/components/inputs.clj +++ b/src/clj/auto_ap/ssr/components/inputs.clj @@ -51,23 +51,28 @@ {:x-init "$el.indeterminate = true"}))])) (defn typeahead- [params] - [:div.relative {:x-data (hx/json {:baseUrl (str (:url params)) - :value {:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))} - :tippy nil - :search "" - :active -1 - :elements (if ((:value-fn params identity) (:value params)) - [{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}] - [])}) - :x-modelable "value.value" - :x-model (:x-model params)} + [:div.relative (cond-> {:x-data (hx/json {:baseUrl (str (:url params)) + :value {:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))} + :tippy nil + :search "" + :active -1 + :elements (if ((:value-fn params identity) (:value params)) + [{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}] + [])}) + :x-modelable "value.value" + :x-model (:x-model params)} + ;; Key the component by its current value so alpine-morph re-initialises + ;; it (rather than preserving stale Alpine x-data) whenever the *server* + ;; changes the value -- e.g. the default account a vendor selection + ;; populates. alpine-morph keys off the `key` attribute, not `id`. + (:id params) (assoc :key (str (:id params) "--" ((:value-fn params identity) (:value params))))) (if (:disabled params) [:span {:x-text "value.label"}] [:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes) (hh/add-class "cursor-pointer")) - "x-tooltip.on.click" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" - "@keydown.down.prevent.stop" "tippy.show();" - "@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }" + "x-tooltip.on.click" "{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" + "@keydown.down.prevent.stop" "tippy?.show();" + "@keydown.backspace" "tippy?.hide(); value = {value: '', label: '' }" :tabindex 0 :x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) :x-ref "input"} @@ -94,7 +99,7 @@ [:template {:x-ref "dropdown"} [:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1" - "@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; " + "@keydown.escape" "$refs.input?.__x_tippy?.hide(); value = {value: '', label: '' }; " :x-destroy "if ($refs.input) {$refs.input.focus();}"} [:input {:type "text" :autofocus true @@ -107,8 +112,8 @@ "@change.stop" "" "@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active" "@keydown.up.prevent" "active --; active = active < 0 ? 0 : active" - "@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input.focus()" - "x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}] + "@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input?.focus()" + "x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; $refs.input?.__x_tippy?.popperInstance?.update()}) }})"}] [:div.dropdown-options {:class "rounded-b-lg overflow-hidden"} [:template {:x-for "(element, index) in elements"} [:li [:a {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100" @@ -117,7 +122,7 @@ "@mouseover" "active = index" "@mouseout" "active = -1" - "@click.prevent" "value = element; tippy.hide(); setTimeout(() => $refs.input.focus(), 10)" + "@click.prevent" "value = element; $refs.input?.__x_tippy?.hide(); setTimeout(() => $refs.input?.focus(), 10)" "x-html" "element.label"}]]] [:template {:x-if "elements.length == 0"} [:li {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs "} @@ -126,7 +131,7 @@ (defn multi-typeahead-dropdown- [params] [:template {:x-ref "dropdown"} [:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 ring-1 p-4" - "@keydown.escape.prevent" "tippy.hide();" + "@keydown.escape.prevent" "$refs.input?.__x_tippy?.hide();" :x-destroy "if ($refs.input) {$refs.input.focus();}"} [:div {:class (-> "relative" #_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))} @@ -240,9 +245,9 @@ [:span {:x-text "value.label"}] [:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes) (hh/add-class "cursor-pointer")) - "x-tooltip.on.click.prevent" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" - "@keydown.down.prevent.stop" "tippy.show();" - "@keydown.backspace" "tippy.hide(); value=new Set( []);" + "x-tooltip.on.click.prevent" "{content: ()=>($refs.dropdown?.innerHTML ?? ''), placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}" + "@keydown.down.prevent.stop" "$refs.input?.__x_tippy?.show();" + "@keydown.backspace" "$refs.input?.__x_tippy?.hide(); value=new Set( []);" :tabindex 0 :x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) :x-ref "input"} @@ -325,7 +330,7 @@ (-> params (update :class (fnil hh/add-class "") default-input-classes) (assoc :x-model "value") - (assoc "x-tooltip.on.focus" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}") + (assoc "x-tooltip.on.focus" "{content: ()=>($refs.tooltip?.innerHTML ?? ''), theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}") (assoc :x-init "$nextTick(() => tippy = $el.__x_tippy); ") (assoc :type "text") @@ -333,7 +338,7 @@ (assoc "autocomplete" "off") (assoc "@change" "value = $event.target.value;") - (assoc "@keydown.escape" "tippy.hide(); ") + (assoc "@keydown.escape" "$el?.__x_tippy?.hide(); ") #_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") ")) (update :class #(str % (use-size size) " w-full")) (dissoc :size))] diff --git a/src/clj/auto_ap/ssr/transaction/edit.clj b/src/clj/auto_ap/ssr/transaction/edit.clj index e66abe69..924670a9 100644 --- a/src/clj/auto_ap/ssr/transaction/edit.clj +++ b/src/clj/auto_ap/ssr/transaction/edit.clj @@ -42,6 +42,8 @@ [iol-ion.tx :refer [random-tempid]] [malli.core :as mc])) +(declare render-full-form) + (def transaction-approval-status {:transaction-approval-status/unapproved "Unapproved" :transaction-approval-status/approved "Approved" @@ -82,6 +84,7 @@ [:transaction/vendor {:optional true} [:maybe entity-id]] [:transaction/approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]] [:amount-mode {:optional true} [:maybe [:enum "$" "%"]]] + [:mode {:optional true} [:maybe [:enum "simple" "advanced"]]] [:transaction/accounts {:optional true} [:maybe [:vector {:coerce? true} @@ -229,9 +232,10 @@ client-id (assoc :client-id client-id))) :x-dispatch:changed "simpleAccountId" :hx-trigger "changed" - :hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select) - :hx-target "find *" - :hx-swap "outerHTML"} + :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) + :hx-target "#wizard-form" + :hx-swap "morph" + :hx-include "closest form"} (location-select* {:name (fc/field-name) :account-location (:account/location account-id) @@ -244,8 +248,8 @@ [: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-include "closest form" - :hx-target "#manual-coding-section" - :hx-swap "outerHTML"} + :hx-target "#wizard-form" + :hx-swap "morph"} "Switch to advanced mode"]]])) (defn- manual-mode-initial @@ -256,9 +260,15 @@ :advanced :simple))) -(defn transaction-account-row* [{:keys [value client-id amount-mode total]}] +(defn transaction-account-row* [{:keys [value client-id amount-mode total index]}] (com/data-grid-row (-> {:class "account-row" + :id (str "account-row-" index) + ;; Key the row by its account id (alpine-morph keys off `key`, not `id`) so + ;; a server-driven account change re-inits the row's x-data. Otherwise morph + ;; preserves the stale accountId and the account typeahead (x-model="accountId") + ;; snaps back to the old value. + :key (str "account-row-" index "--" (fc/field-value (:transaction-account/account value))) :x-data (hx/json {:show (boolean (not (fc/field-value (:new? value)))) :accountId (fc/field-value (:transaction-account/account value))}) :data-key "show" @@ -286,9 +296,10 @@ client-id (assoc :client-id client-id))) :x-dispatch:changed "accountId" :hx-trigger "changed" - :hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select) - :hx-target "find *" - :hx-swap "outerHTML"} + :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) + :hx-target "#wizard-form" + :hx-swap "morph" + :hx-include "closest form"} (location-select* {:name (fc/field-name) :account-location (:account/location (cond->> (:transaction-account/account @value) (nat-int? (:transaction-account/account @value)) (dc/pull (dc/db conn) @@ -300,17 +311,27 @@ {} (com/validated-field {:errors (fc/field-errors)} - (if (= "%" amount-mode) - (com/text-input {:name (fc/field-name) - :class "w-16 account-amount-field" - :value (fc/field-value) - :type "number" - :step "0.01"}) - (com/money-input {:name (fc/field-name) + ;; Editing an amount re-renders the whole form (so TOTAL/BALANCE recompute). + ;; The stable id lets alpine-morph match this exact input across the swap, + ;; keeping the user's focus and caret while they type. + (let [amount-attrs {:name (fc/field-name) + :id (str "account-amount-" index) :class "w-16 account-amount-field" - :value (fc/field-value)}))))) + :value (fc/field-value) + :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) + :hx-target "#wizard-form" + :hx-swap "morph" + :hx-trigger "keyup changed delay:300ms" + :hx-include "closest form"}] + (if (= "%" amount-mode) + (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 {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)" + (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)}) + :hx-target "#wizard-form" + :hx-swap "morph" + :hx-include "closest form" :class "account-remove-action"} svg/x)))) (defn- account-field-name [index field] @@ -450,31 +471,30 @@ :name "step-params[amount-mode]" :orientation :horizontal :hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode) - :hx-target "#account-grid-body" - :hx-swap "outerHTML" + :hx-target "#wizard-form" + :hx-swap "morph" :hx-include "closest form"})) (com/data-grid-header {:class "w-16"})]} - (fc/cursor-map #(transaction-account-row* {:value % - :client-id (-> request :entity :transaction/client :db/id) - :amount-mode amount-mode - :total total})) + (fc/cursor-map (fn [cursor] + (transaction-account-row* {:value cursor + :client-id (-> request :entity :transaction/client :db/id) + :amount-mode amount-mode + :total total + :index (last (cursor/path cursor))}))) - (com/data-grid-new-row {:colspan 4 - :hx-get (bidi/path-for ssr-routes/only-routes - ::route/edit-wizard-new-account) - :row-offset 0 - :index (count (:transaction/accounts snapshot)) - :tr-params {:hx-vals (hx/json {:client-id (:transaction/client snapshot)})}} - "New account") + (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) + :hx-target "#wizard-form" + :hx-swap "morph" + :hx-include "closest form" + :color :secondary} + "New account"))) (com/data-grid-row {:class "account-total-row"} (com/data-grid-cell {}) (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"]) (com/data-grid-cell {:id "total" - :class "text-right" - :hx-trigger "change from:closest form target:.amount-field" - :hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/account-total) - :hx-target "this" - :hx-swap "innerHTML"} + :class "text-right"} (account-total* request)) (com/data-grid-cell {})) @@ -482,11 +502,7 @@ (com/data-grid-cell {}) (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"]) (com/data-grid-cell {:id "total" - :class "text-right" - :hx-trigger "change from:closest form target:.amount-field" - :hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/account-balance) - :hx-target "this" - :hx-swap "innerHTML"} + :class "text-right"} (account-balance* request)) (com/data-grid-cell {})) @@ -509,11 +525,11 @@ (seq (:transaction/accounts snapshot))) row-count (count all-accounts)] [:div#manual-coding-section - (com/hidden {:name "mode" :value (name mode)}) + (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-target "#manual-coding-section" - :hx-swap "outerHTML" + :hx-target "#wizard-form" + :hx-swap "morph" :hx-sync "this:replace" :hx-include "closest form"} (fc/with-field :transaction/vendor @@ -521,6 +537,11 @@ {:label "Vendor" :errors (fc/field-errors)} [:div.w-96 (com/typeahead {:name (fc/field-name) + ;; Key the vendor typeahead by its value so alpine-morph re-creates + ;; it on each vendor change. A morph-preserved typeahead loses its + ;; value $watch -> change dispatch, so a *second* vendor change would + ;; otherwise never fire its htmx request. + :id (fc/field-name) :error? (fc/error?) :class "w-96" :placeholder "Search..." @@ -528,18 +549,24 @@ :value (fc/field-value) :content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})]))] (if (= mode :simple) - [:div {:x-data (hx/json {:simpleAccountId - (let [av (-> (first all-accounts) :transaction-account/account)] - (if (map? av) (:db/id av) av))})} - (simple-mode-fields* request)] + (let [simple-account-id (let [av (-> (first all-accounts) :transaction-account/account)] + (if (map? av) (:db/id av) av))] + ;; Key this wrapper by the account id so alpine-morph re-inits its x-data + ;; when the server changes the account (e.g. a vendor selection populating + ;; its default account). Without a changing key, morph keeps the stale + ;; simpleAccountId and the nested typeahead's x-model="simpleAccountId" + ;; binds back to the empty value. alpine-morph keys off `key`, not `id`. + [:div {:key (str "simple-account-wrapper--" simple-account-id) + :x-data (hx/json {:simpleAccountId simple-account-id})} + (simple-mode-fields* request)]) [:div (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-include "closest form" - :hx-target "#manual-coding-section" - :hx-swap "outerHTML"} + :hx-target "#wizard-form" + :hx-swap "morph"} "Switch to simple mode"]]) (fc/with-field :transaction/accounts (com/validated-field @@ -557,59 +584,7 @@ (assoc-in [:multi-form-state :snapshot :transaction/accounts] accounts) (assoc-in [:multi-form-state :snapshot :amount-mode] new-mode))] (html-response - [:div#account-grid-body - (com/data-grid {:headers [(com/data-grid-header {} "Account") - (com/data-grid-header {:class "w-32"} "Location") - (com/data-grid-header {:class "w-16"} - (com/radio-card {:options [{:value "$" :content "$"} - {:value "%" :content "%"}] - :value new-mode - :name "step-params[amount-mode]" - :orientation :horizontal - :hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode) - :hx-target "#account-grid-body" - :hx-swap "outerHTML" - :hx-include "closest form"})) - (com/data-grid-header {:class "w-16"})]} - (map-indexed (fn [idx account] - (transaction-account-row-no-cursor* - {:account account - :index idx - :client-id (-> updated-request :entity :transaction/client :db/id) - :amount-mode new-mode - :total total})) - accounts) - (com/data-grid-new-row {:colspan 4 - :hx-get (bidi/path-for ssr-routes/only-routes - ::route/edit-wizard-new-account) - :row-offset 0 - :index (count accounts) - :tr-params {:hx-vals (hx/json {:client-id (:transaction/client snapshot)})}} - "New account") - (com/data-grid-row {} - (com/data-grid-cell {}) - (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"]) - (com/data-grid-cell {:id "total" - :class "text-right"} - (format "$%,.2f" (double (reduce + 0.0 (map :transaction-account/amount accounts))))) - (com/data-grid-cell {})) - (com/data-grid-row {} - (com/data-grid-cell {}) - (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"]) - (com/data-grid-cell {:id "total" - :class "text-right"} - (let [account-total (double (reduce + 0.0 (map :transaction-account/amount accounts))) - balance (- total account-total)] - [:span {:class (when-not (dollars= 0.0 balance) - "text-red-300")} - (format "$%,.2f" (double balance))])) - (com/data-grid-cell {})) - (com/data-grid-row {} - (com/data-grid-cell {}) - (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TRANSACTION TOTAL"]) - (com/data-grid-cell {:class "text-right"} - (format "$%,.2f" total)) - (com/data-grid-cell {})))]))) + (render-full-form updated-request)))) (defn transaction-details-panel [tx] [:div.p-4.space-y-4 @@ -617,7 +592,7 @@ [:div.space-y-3 [:div [:div.text-xs.font-medium.text-gray-500 "Amount"] - [:div.text-sm.font-medium.text-gray-900 (format "$%,.2f" (Math/abs (:transaction/amount tx)))]] + [:div.text-sm.font-medium.text-gray-900 (format "$%,.2f" (Math/abs (or (:transaction/amount tx) 0.0)))]] [:div [:div.text-xs.font-medium.text-gray-500 "Date"] [:div.text-sm.text-gray-900 (some-> tx :transaction/date coerce/to-date-time (atime/unparse-local atime/normal-date))]] @@ -903,10 +878,19 @@ {:label "Memo" :errors (fc/field-errors)} [:div.w-96 + ;; Memo edits re-render the whole form via morph. The stable id + ;; lets alpine-morph match this input across the swap so the + ;; caret stays put while the user types. (com/text-input {:value (-> (fc/field-value)) :name (fc/field-name) + :id "edit-memo" :error? (fc/field-errors) - :placeholder "Optional note"})])) + :placeholder "Optional note" + :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) + :hx-target "#wizard-form" + :hx-swap "morph" + :hx-trigger "keyup changed delay:300ms" + :hx-include "closest form"})])) [:div {:x-data (hx/json {:activeForm (if (:transaction/payment (:entity request)) "link-payment" (or (fc/with-field :action (fc/field-value)) @@ -1387,7 +1371,8 @@ :form-params (-> mm/default-form-props (assoc :hx-post - (str (bidi/path-for ssr-routes/only-routes ::route/edit-submit)))) + (str (bidi/path-for ssr-routes/only-routes ::route/edit-submit)) + :hx-ext "response-targets,alpine-morph")) :render-timeline? false)) (steps [_] [:links]) @@ -1430,6 +1415,18 @@ (fc/with-field :transaction/accounts (account-grid-body* request))))) +(defn render-full-form + "Helper to render the complete transaction edit form for whole-form re-rendering." + [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] (let [multi-form-state (:multi-form-state request) snapshot (:snapshot multi-form-state) @@ -1439,7 +1436,7 @@ "simple")) client-id (or (:transaction/client snapshot) (-> request :entity :transaction/client :db/id)) - vendor-id (or (:transaction/vendor step-params) + vendor-id (or (->db-id (:transaction/vendor step-params)) (->db-id (get step-params "transaction/vendor")) (:transaction/vendor snapshot)) total (Math/abs (or (-> request :entity :transaction/amount) @@ -1466,16 +1463,14 @@ (let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID)) :transaction-account/location (or (:account/location default-account) "Shared") :transaction-account/amount (if (= amount-mode "%") 100.0 total)} - default-account (assoc :transaction-account/account (:db/id default-account)))] + default-account (assoc :transaction-account/account (:db/id default-account)))] (-> request (assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account]) (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 - (fc/start-form (:multi-form-state render-request) nil - (fc/with-field :step-params - (manual-coding-section* mode render-request)))))) + (render-full-form render-request)))) (defn edit-wizard-toggle-mode-handler [request] (let [step-params (-> request :multi-form-state :step-params) @@ -1493,7 +1488,9 @@ (assoc-in [:multi-form-state :snapshot :transaction/accounts] (vec accounts)) (assoc-in [:multi-form-state :step-params :transaction/accounts] - (vec accounts)))) + (vec accounts)) + (assoc-in [:multi-form-state :step-params :mode] + (name target-mode)))) ;; advanced→simple: take first row only (let [first-row (first (or (seq (:transaction/accounts step-params)) (seq (:transaction/accounts snapshot))))] @@ -1501,11 +1498,46 @@ (assoc-in [:multi-form-state :snapshot :transaction/accounts] (if first-row [first-row] [])) (assoc-in [:multi-form-state :step-params :transaction/accounts] - (if first-row [first-row] [])))))] + (if first-row [first-row] [])) + (assoc-in [:multi-form-state :step-params :mode] + (name target-mode)))))] (html-response - (fc/start-form (:multi-form-state render-request) nil - (fc/with-field :step-params - (manual-coding-section* target-mode render-request)))))) + (render-full-form render-request)))) + +(defn edit-wizard-new-account-handler + "Adds a new account row and re-renders the whole form." + [request] + (let [snapshot (-> request :multi-form-state :snapshot) + amount-mode (or (:amount-mode snapshot) "$") + total (Math/abs (or (:transaction/amount snapshot) 0.0)) + new-account {:db/id (str (java.util.UUID/randomUUID)) + :new? true + :transaction-account/location "Shared" + :transaction-account/amount (if (= amount-mode "%") 100.0 total)} + accounts (vec (or (:transaction/accounts snapshot) [])) + updated-accounts (conj accounts new-account) + 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)))) + +(defn edit-wizard-remove-account-handler + "Removes an account row and re-renders the whole form. + Expects a row-index in the form params." + [request] + (let [row-index (some-> request :form-params (get "row-index") Integer/parseInt) + snapshot (-> request :multi-form-state :snapshot) + accounts (vec (or (:transaction/accounts snapshot) [])) + updated-accounts (if (and row-index (< row-index (count accounts))) + (vec (concat (subvec accounts 0 row-index) + (subvec accounts (inc row-index)))) + accounts) + 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)))) (def key->handler (apply-middleware-to-all-handlers @@ -1544,6 +1576,10 @@ (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-form-changed (-> edit-form-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/toggle-amount-mode (-> toggle-amount-mode (mm/wrap-wizard edit-wizard) (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) @@ -1552,22 +1588,14 @@ (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 (-> - (add-new-entity-handler [:step-params :transaction/accounts] - (fn render [cursor request] - (let [snapshot (-> request :multi-form-state :snapshot) - amount-mode (or (:amount-mode snapshot) "$") - total (Math/abs (or (:transaction/amount snapshot) 0.0))] - (transaction-account-row* - {:value cursor - :client-id (:client-id (:query-params request)) - :amount-mode amount-mode - :total total}))) - (fn build-new-row [base _] - (assoc base :transaction-account/location "Shared"))) - (wrap-schema-enforce :query-schema [:map - [:client-id {:optional true} - [:maybe entity-id]]])) + ::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))) diff --git a/src/clj/auto_ap/ssr/ui.clj b/src/clj/auto_ap/ssr/ui.clj index f9525ccc..ca099d10 100644 --- a/src/clj/auto_ap/ssr/ui.clj +++ b/src/clj/auto_ap/ssr/ui.clj @@ -5,13 +5,13 @@ [hiccup2.core :as hiccup] [auto-ap.ssr.components :as com])) (defn html-page [hiccup] - {:status 200 + {:status 200 :headers {"Content-Type" "text/html"} - :body (str - "" - (hiccup/html - {} - hiccup))}) + :body (str + "" + (hiccup/html + {} + hiccup))}) (defn base-page [request contents page-name] (html-page @@ -30,7 +30,7 @@ [:script {:src "https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-tooltip@1.x.x/dist/cdn.min.js" :defer true}] [:link {:rel "stylesheet" :href "/css/tippy/tippy.css"}] [:link {:rel "stylesheet" :href "/css/tippy/light.css"}] - [:script {:src "/js/htmx.js" + [:script {:src "/js/htmx.js" :crossorigin= "anonymous"}] [:script {:src "https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"}] @@ -39,14 +39,16 @@ [:link {:rel "stylesheet" :href "https://cdn.jsdelivr.net/npm/vanillajs-datepicker@1.3.4/dist/css/datepicker.min.css"}] [:script {:type "text/javascript" :src "https://cdn.jsdelivr.net/npm/vanillajs-datepicker@1.3.4/dist/js/datepicker-full.min.js"}] [:script {:src "https://unpkg.com/htmx.org/dist/ext/response-targets.js" :defer true}] + [:script {:src "https://unpkg.com/htmx.org@2.0.10/dist/ext/alpine-morph.js"}] [:script {:src "https://cdn.jsdelivr.net/npm/date-fns@3.6.0/cdn.min.js" :defer true}] [:script {:src "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js" :integrity "sha512-CQBWl4fJHWbryGE+Pc7UAxWMUMNMWzWxF4SQo9CgkJIN1kx6djDQZjh3Y8SZ1d+6I+1zze6Z7kHXO7q3UyZAWw==" :crossorigin "anonymous" :referrerpolicy "no-referrer"}] [:script {:src "https://unpkg.com/dropzone@5.9.3/dist/min/dropzone.min.js" :defer true}] - [:link {:rel "stylesheet" :href "https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" :type "text/css" :defer true}] + [:link {:rel "stylesheet" :href "https://unpkg.com/dropzone@5/dist/min/dropzone.min.css" :type "text/css" :defer true}] [:script {:defer true :src "/js/alpine-vals.js"}] [:script {:defer true :src "https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-clipboard@2.x.x/dist/alpine-clipboard.js"}] [:script {:defer true :src "https://cdn.jsdelivr.net/npm/@alpinejs/focus@3.x.x/dist/cdn.min.js"}] + [:script {:defer true :src "https://cdn.jsdelivr.net/npm/@alpinejs/morph@3.x.x/dist/cdn.min.js"}] [:script {:defer true :src "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"}] [:script {:src "https://cdn.jsdelivr.net/npm/signature_pad@4.1.7/dist/signature_pad.umd.min.js"}] [:script {:src "https://cdn.jsdelivr.net/npm/jdenticon@3.3.0/dist/jdenticon.min.js" :async true :defer true :integrity "sha384-LfouGM03m83ArVtne1JPk926e3SGD0Tz8XHtW2OKGsgeBU/UfR0Fa8eX+UlwSSAZ" :crossorigin "anonymous"}] @@ -94,14 +96,14 @@ input[type=number] { "x-transition:leave-end" "!bg-opacity-0"} [:div {:class "flex h-full w-full justify-stretch md:justify-center items-stretch md:items-center " - "x-trap.inert.noscroll" "open" - "x-trap.inert" "open" - "x-show" "open" - "x-transition:enter" "ease-out duration-300" + "x-trap.inert.noscroll" "open" + "x-trap.inert" "open" + "x-show" "open" + "x-transition:enter" "ease-out duration-300" "x-transition:enter-start" "!bg-opacity-0 !translate-y-32" - "x-transition:enter-end" "!bg-opacity-100 !translate-y-0" - "x-transition:leave" "duration-300" + "x-transition:enter-end" "!bg-opacity-100 !translate-y-0" + "x-transition:leave" "duration-300" "x-transition:leave-start" "!opacity-100 !translate-y-0" - "x-transition:leave-end" "!opacity-0 !translate-y-32"} + "x-transition:leave-end" "!opacity-0 !translate-y-32"} [:div#modal-content.flex.items-center.justify-center {:class "md:p-12"}]]]]]])) diff --git a/src/cljc/auto_ap/routes/transactions.cljc b/src/cljc/auto_ap/routes/transactions.cljc index e75f28a0..30f77dcc 100644 --- a/src/cljc/auto_ap/routes/transactions.cljc +++ b/src/cljc/auto_ap/routes/transactions.cljc @@ -33,7 +33,9 @@ "/account-total" ::account-total "/account-balance" ::account-balance "/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 diff --git a/test/clj/auto_ap/test_server.clj b/test/clj/auto_ap/test_server.clj index b944de17..3815c82c 100644 --- a/test/clj/auto_ap/test_server.clj +++ b/test/clj/auto_ap/test_server.clj @@ -100,6 +100,9 @@ {:db/id "vendor-id" :vendor/name "Test Vendor" :vendor/default-account "account-id"} + {:db/id "vendor-id-2" + :vendor/name "Second Vendor" + :vendor/default-account "account-id-2"} (test-transaction :db/id "transaction-id" :transaction/client "client-id" :transaction/bank-account "bank-account-id" @@ -135,19 +138,19 @@ :payment/status :payment-status/pending :payment/date #inst "2023-06-15") ;; Transaction and unpaid invoice for link testing - (test-transaction :db/id "transaction-id-unpaid" - :transaction/client "client-id" - :transaction/bank-account "bank-account-id" - :transaction/amount -150.0 - :transaction/description-original "Transaction for unpaid invoice link" - :transaction/approval-status :transaction-approval-status/unapproved) - (test-transaction :db/id "transaction-id-feedback" - :transaction/client "client-id" - :transaction/bank-account "bank-account-id" - :transaction/amount 400.0 - :transaction/description-original "Transaction for feedback review" - :transaction/approval-status :transaction-approval-status/requires-feedback) - (test-invoice :db/id "invoice-unpaid-id" + (test-transaction :db/id "transaction-id-unpaid" + :transaction/client "client-id" + :transaction/bank-account "bank-account-id" + :transaction/amount -150.0 + :transaction/description-original "Transaction for unpaid invoice link" + :transaction/approval-status :transaction-approval-status/unapproved) + (test-transaction :db/id "transaction-id-feedback" + :transaction/client "client-id" + :transaction/bank-account "bank-account-id" + :transaction/amount 400.0 + :transaction/description-original "Transaction for feedback review" + :transaction/approval-status :transaction-approval-status/requires-feedback) + (test-invoice :db/id "invoice-unpaid-id" :invoice/client "client-id" :invoice/vendor "vendor-id" :invoice/total 150.0 @@ -166,7 +169,8 @@ :second-account (get tempids "account-id-2") :fixed-location-account (get tempids "account-id-fixed-loc") :ap-account (get tempids "ap-account-id") - :vendor (get tempids "vendor-id")}) + :vendor (get tempids "vendor-id") + :vendor2 (get tempids "vendor-id-2")}) (reset! test-client-ids {:test (get tempids "client-id") :test2 (get tempids "client-id-2")})