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 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 07:40:30 -07:00
parent b6649a3d1d
commit cdb6bb6fe3
8 changed files with 681 additions and 185 deletions

View File

@@ -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([]);
});
});

View File

@@ -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 modal is now single-page (Edit Transaction). Click "Manual" tab to ensure
// the manual account coding form is active. // the manual account coding form is active.
await page.click('button:has-text("Manual")'); 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 // Wait for the manual form to appear
await page.waitForSelector('#account-grid-body'); await page.waitForSelector('#account-grid-body');
} }

View File

@@ -416,4 +416,64 @@ htmx.onLoad(function(content) {
console.error('Failed to copy text to clipboard:', err); 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);
}
});
})();
*/

View File

@@ -51,23 +51,28 @@
{:x-init "$el.indeterminate = true"}))])) {:x-init "$el.indeterminate = true"}))]))
(defn typeahead- [params] (defn typeahead- [params]
[:div.relative {:x-data (hx/json {:baseUrl (str (:url 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))} :value {:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}
:tippy nil :tippy nil
:search "" :search ""
:active -1 :active -1
:elements (if ((:value-fn params identity) (:value params)) :elements (if ((:value-fn params identity) (:value params))
[{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}] [{:value ((:value-fn params identity) (:value params)) :label ((:content-fn params identity) (:value params))}]
[])}) [])})
:x-modelable "value.value" :x-modelable "value.value"
:x-model (:x-model params)} :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) (if (:disabled params)
[:span {:x-text "value.label"}] [:span {:x-text "value.label"}]
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes) [:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
(hh/add-class "cursor-pointer")) (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}" "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.down.prevent.stop" "tippy?.show();"
"@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }" "@keydown.backspace" "tippy?.hide(); value = {value: '', label: '' }"
:tabindex 0 :tabindex 0
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) :x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
:x-ref "input"} :x-ref "input"}
@@ -94,7 +99,7 @@
[:template {:x-ref "dropdown"} [:template {:x-ref "dropdown"}
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1" [: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();}"} :x-destroy "if ($refs.input) {$refs.input.focus();}"}
[:input {:type "text" [:input {:type "text"
:autofocus true :autofocus true
@@ -107,8 +112,8 @@
"@change.stop" "" "@change.stop" ""
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active" "@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : 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()" "@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; tippy.popperInstance.update()}) }})"}] "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"} [:div.dropdown-options {:class "rounded-b-lg overflow-hidden"}
[:template {:x-for "(element, index) in elements"} [: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" [: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" "@mouseover" "active = index"
"@mouseout" "active = -1" "@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"}]]] "x-html" "element.label"}]]]
[:template {:x-if "elements.length == 0"} [: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 "} [: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] (defn multi-typeahead-dropdown- [params]
[:template {:x-ref "dropdown"} [: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" [: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();}"} :x-destroy "if ($refs.input) {$refs.input.focus();}"}
[:div {:class (-> "relative" [:div {:class (-> "relative"
#_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))} #_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))}
@@ -240,9 +245,9 @@
[:span {:x-text "value.label"}] [:span {:x-text "value.label"}]
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes) [:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
(hh/add-class "cursor-pointer")) (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}" "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.down.prevent.stop" "$refs.input?.__x_tippy?.show();"
"@keydown.backspace" "tippy.hide(); value=new Set( []);" "@keydown.backspace" "$refs.input?.__x_tippy?.hide(); value=new Set( []);"
:tabindex 0 :tabindex 0
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params)) :x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
:x-ref "input"} :x-ref "input"}
@@ -325,7 +330,7 @@
(-> params (-> params
(update :class (fnil hh/add-class "") default-input-classes) (update :class (fnil hh/add-class "") default-input-classes)
(assoc :x-model "value") (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 :x-init "$nextTick(() => tippy = $el.__x_tippy); ")
(assoc :type "text") (assoc :type "text")
@@ -333,7 +338,7 @@
(assoc "autocomplete" "off") (assoc "autocomplete" "off")
(assoc "@change" "value = $event.target.value;") (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\") ")) #_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") "))
(update :class #(str % (use-size size) " w-full")) (update :class #(str % (use-size size) " w-full"))
(dissoc :size))] (dissoc :size))]

View File

@@ -42,6 +42,8 @@
[iol-ion.tx :refer [random-tempid]] [iol-ion.tx :refer [random-tempid]]
[malli.core :as mc])) [malli.core :as mc]))
(declare render-full-form)
(def transaction-approval-status (def transaction-approval-status
{:transaction-approval-status/unapproved "Unapproved" {:transaction-approval-status/unapproved "Unapproved"
:transaction-approval-status/approved "Approved" :transaction-approval-status/approved "Approved"
@@ -82,6 +84,7 @@
[:transaction/vendor {:optional true} [:maybe entity-id]] [:transaction/vendor {:optional true} [:maybe entity-id]]
[:transaction/approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]] [:transaction/approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
[:amount-mode {:optional true} [:maybe [:enum "$" "%"]]] [:amount-mode {:optional true} [:maybe [:enum "$" "%"]]]
[:mode {:optional true} [:maybe [:enum "simple" "advanced"]]]
[:transaction/accounts {:optional true} [:transaction/accounts {:optional true}
[:maybe [:maybe
[:vector {:coerce? true} [:vector {:coerce? true}
@@ -229,9 +232,10 @@
client-id (assoc :client-id client-id))) client-id (assoc :client-id client-id)))
:x-dispatch:changed "simpleAccountId" :x-dispatch:changed "simpleAccountId"
:hx-trigger "changed" :hx-trigger "changed"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select) :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target "find *" :hx-target "#wizard-form"
:hx-swap "outerHTML"} :hx-swap "morph"
:hx-include "closest form"}
(location-select* (location-select*
{:name (fc/field-name) {:name (fc/field-name)
:account-location (:account/location account-id) :account-location (:account/location account-id)
@@ -244,8 +248,8 @@
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer [: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-wizard-toggle-mode)
:hx-include "closest form" :hx-include "closest form"
:hx-target "#manual-coding-section" :hx-target "#wizard-form"
:hx-swap "outerHTML"} :hx-swap "morph"}
"Switch to advanced mode"]]])) "Switch to advanced mode"]]]))
(defn- manual-mode-initial (defn- manual-mode-initial
@@ -256,9 +260,15 @@
:advanced :advanced
:simple))) :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 (com/data-grid-row
(-> {:class "account-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)))) :x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
:accountId (fc/field-value (:transaction-account/account value))}) :accountId (fc/field-value (:transaction-account/account value))})
:data-key "show" :data-key "show"
@@ -286,9 +296,10 @@
client-id (assoc :client-id client-id))) client-id (assoc :client-id client-id)))
:x-dispatch:changed "accountId" :x-dispatch:changed "accountId"
:hx-trigger "changed" :hx-trigger "changed"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select) :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target "find *" :hx-target "#wizard-form"
:hx-swap "outerHTML"} :hx-swap "morph"
:hx-include "closest form"}
(location-select* {:name (fc/field-name) (location-select* {:name (fc/field-name)
:account-location (:account/location (cond->> (:transaction-account/account @value) :account-location (:account/location (cond->> (:transaction-account/account @value)
(nat-int? (:transaction-account/account @value)) (dc/pull (dc/db conn) (nat-int? (:transaction-account/account @value)) (dc/pull (dc/db conn)
@@ -300,17 +311,27 @@
{} {}
(com/validated-field (com/validated-field
{:errors (fc/field-errors)} {:errors (fc/field-errors)}
(if (= "%" amount-mode) ;; Editing an amount re-renders the whole form (so TOTAL/BALANCE recompute).
(com/text-input {:name (fc/field-name) ;; The stable id lets alpine-morph match this exact input across the swap,
:class "w-16 account-amount-field" ;; keeping the user's focus and caret while they type.
:value (fc/field-value) (let [amount-attrs {:name (fc/field-name)
:type "number" :id (str "account-amount-" index)
:step "0.01"})
(com/money-input {:name (fc/field-name)
:class "w-16 account-amount-field" :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/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)))) :class "account-remove-action"} svg/x))))
(defn- account-field-name [index field] (defn- account-field-name [index field]
@@ -450,31 +471,30 @@
:name "step-params[amount-mode]" :name "step-params[amount-mode]"
:orientation :horizontal :orientation :horizontal
:hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode) :hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode)
:hx-target "#account-grid-body" :hx-target "#wizard-form"
:hx-swap "outerHTML" :hx-swap "morph"
:hx-include "closest form"})) :hx-include "closest form"}))
(com/data-grid-header {:class "w-16"})]} (com/data-grid-header {:class "w-16"})]}
(fc/cursor-map #(transaction-account-row* {:value % (fc/cursor-map (fn [cursor]
:client-id (-> request :entity :transaction/client :db/id) (transaction-account-row* {:value cursor
:amount-mode amount-mode :client-id (-> request :entity :transaction/client :db/id)
:total total})) :amount-mode amount-mode
:total total
:index (last (cursor/path cursor))})))
(com/data-grid-new-row {:colspan 4 (com/data-grid-row {:class "new-row"}
:hx-get (bidi/path-for ssr-routes/only-routes (com/data-grid-cell {:colspan 4}
::route/edit-wizard-new-account) (com/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-new-account)
:row-offset 0 :hx-target "#wizard-form"
:index (count (:transaction/accounts snapshot)) :hx-swap "morph"
:tr-params {:hx-vals (hx/json {:client-id (:transaction/client snapshot)})}} :hx-include "closest form"
"New account") :color :secondary}
"New account")))
(com/data-grid-row {:class "account-total-row"} (com/data-grid-row {:class "account-total-row"}
(com/data-grid-cell {}) (com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"]) (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
(com/data-grid-cell {:id "total" (com/data-grid-cell {:id "total"
:class "text-right" :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"}
(account-total* request)) (account-total* request))
(com/data-grid-cell {})) (com/data-grid-cell {}))
@@ -482,11 +502,7 @@
(com/data-grid-cell {}) (com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"]) (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"])
(com/data-grid-cell {:id "total" (com/data-grid-cell {:id "total"
:class "text-right" :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"}
(account-balance* request)) (account-balance* request))
(com/data-grid-cell {})) (com/data-grid-cell {}))
@@ -509,11 +525,11 @@
(seq (:transaction/accounts snapshot))) (seq (:transaction/accounts snapshot)))
row-count (count all-accounts)] row-count (count all-accounts)]
[:div#manual-coding-section [:div#manual-coding-section
(com/hidden {:name "mode" :value (name mode)}) (com/hidden {:name "step-params[mode]" :value (name mode)})
[:div {:hx-trigger "change" [: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-vendor-changed)
:hx-target "#manual-coding-section" :hx-target "#wizard-form"
:hx-swap "outerHTML" :hx-swap "morph"
:hx-sync "this:replace" :hx-sync "this:replace"
:hx-include "closest form"} :hx-include "closest form"}
(fc/with-field :transaction/vendor (fc/with-field :transaction/vendor
@@ -521,6 +537,11 @@
{:label "Vendor" :errors (fc/field-errors)} {:label "Vendor" :errors (fc/field-errors)}
[:div.w-96 [:div.w-96
(com/typeahead {:name (fc/field-name) (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?) :error? (fc/error?)
:class "w-96" :class "w-96"
:placeholder "Search..." :placeholder "Search..."
@@ -528,18 +549,24 @@
:value (fc/field-value) :value (fc/field-value)
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})]))] :content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})]))]
(if (= mode :simple) (if (= mode :simple)
[:div {:x-data (hx/json {:simpleAccountId (let [simple-account-id (let [av (-> (first all-accounts) :transaction-account/account)]
(let [av (-> (first all-accounts) :transaction-account/account)] (if (map? av) (:db/id av) av))]
(if (map? av) (:db/id av) av))})} ;; Key this wrapper by the account id so alpine-morph re-inits its x-data
(simple-mode-fields* request)] ;; 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 [:div
(when (<= row-count 1) (when (<= row-count 1)
[:div.mb-2 [:div.mb-2
[:a.text-sm.text-blue-600.hover:underline.cursor-pointer [: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-wizard-toggle-mode)
:hx-include "closest form" :hx-include "closest form"
:hx-target "#manual-coding-section" :hx-target "#wizard-form"
:hx-swap "outerHTML"} :hx-swap "morph"}
"Switch to simple mode"]]) "Switch to simple mode"]])
(fc/with-field :transaction/accounts (fc/with-field :transaction/accounts
(com/validated-field (com/validated-field
@@ -557,59 +584,7 @@
(assoc-in [:multi-form-state :snapshot :transaction/accounts] accounts) (assoc-in [:multi-form-state :snapshot :transaction/accounts] accounts)
(assoc-in [:multi-form-state :snapshot :amount-mode] new-mode))] (assoc-in [:multi-form-state :snapshot :amount-mode] new-mode))]
(html-response (html-response
[:div#account-grid-body (render-full-form updated-request))))
(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 {})))])))
(defn transaction-details-panel [tx] (defn transaction-details-panel [tx]
[:div.p-4.space-y-4 [:div.p-4.space-y-4
@@ -617,7 +592,7 @@
[:div.space-y-3 [:div.space-y-3
[:div [:div
[:div.text-xs.font-medium.text-gray-500 "Amount"] [: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
[:div.text-xs.font-medium.text-gray-500 "Date"] [: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))]] [: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" {:label "Memo"
:errors (fc/field-errors)} :errors (fc/field-errors)}
[:div.w-96 [: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)) (com/text-input {:value (-> (fc/field-value))
:name (fc/field-name) :name (fc/field-name)
:id "edit-memo"
:error? (fc/field-errors) :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)) [:div {:x-data (hx/json {:activeForm (if (:transaction/payment (:entity request))
"link-payment" "link-payment"
(or (fc/with-field :action (fc/field-value)) (or (fc/with-field :action (fc/field-value))
@@ -1387,7 +1371,8 @@
:form-params :form-params
(-> mm/default-form-props (-> mm/default-form-props
(assoc :hx-post (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)) :render-timeline? false))
(steps [_] (steps [_]
[:links]) [:links])
@@ -1430,6 +1415,18 @@
(fc/with-field :transaction/accounts (fc/with-field :transaction/accounts
(account-grid-body* request))))) (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] (defn edit-vendor-changed-handler [request]
(let [multi-form-state (:multi-form-state request) (let [multi-form-state (:multi-form-state request)
snapshot (:snapshot multi-form-state) snapshot (:snapshot multi-form-state)
@@ -1439,7 +1436,7 @@
"simple")) "simple"))
client-id (or (:transaction/client snapshot) client-id (or (:transaction/client snapshot)
(-> request :entity :transaction/client :db/id)) (-> 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")) (->db-id (get step-params "transaction/vendor"))
(:transaction/vendor snapshot)) (:transaction/vendor snapshot))
total (Math/abs (or (-> request :entity :transaction/amount) total (Math/abs (or (-> request :entity :transaction/amount)
@@ -1466,16 +1463,14 @@
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID)) (let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
:transaction-account/location (or (:account/location default-account) "Shared") :transaction-account/location (or (:account/location default-account) "Shared")
:transaction-account/amount (if (= amount-mode "%") 100.0 total)} :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 (-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account]) (assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account]))) (assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
request) request)
(assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))] (assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))]
(html-response (html-response
(fc/start-form (:multi-form-state render-request) nil (render-full-form render-request))))
(fc/with-field :step-params
(manual-coding-section* mode render-request))))))
(defn edit-wizard-toggle-mode-handler [request] (defn edit-wizard-toggle-mode-handler [request]
(let [step-params (-> request :multi-form-state :step-params) (let [step-params (-> request :multi-form-state :step-params)
@@ -1493,7 +1488,9 @@
(assoc-in [:multi-form-state :snapshot :transaction/accounts] (assoc-in [:multi-form-state :snapshot :transaction/accounts]
(vec accounts)) (vec accounts))
(assoc-in [:multi-form-state :step-params :transaction/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 ;; advanced→simple: take first row only
(let [first-row (first (or (seq (:transaction/accounts step-params)) (let [first-row (first (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot))))] (seq (:transaction/accounts snapshot))))]
@@ -1501,11 +1498,46 @@
(assoc-in [:multi-form-state :snapshot :transaction/accounts] (assoc-in [:multi-form-state :snapshot :transaction/accounts]
(if first-row [first-row] [])) (if first-row [first-row] []))
(assoc-in [:multi-form-state :step-params :transaction/accounts] (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 (html-response
(fc/start-form (:multi-form-state render-request) nil (render-full-form render-request))))
(fc/with-field :step-params
(manual-coding-section* target-mode 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 (def key->handler
(apply-middleware-to-all-handlers (apply-middleware-to-all-handlers
@@ -1544,6 +1576,10 @@
(mm/wrap-wizard edit-wizard) (mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-decode-multi-form-state)) (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 ::route/toggle-amount-mode (-> toggle-amount-mode
(mm/wrap-wizard edit-wizard) (mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
@@ -1552,22 +1588,14 @@
(mm/wrap-wizard edit-wizard) (mm/wrap-wizard edit-wizard)
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-decode-multi-form-state)) (mm/wrap-decode-multi-form-state))
::route/edit-wizard-new-account (-> ::route/edit-wizard-new-account (-> edit-wizard-new-account-handler
(add-new-entity-handler [:step-params :transaction/accounts] (mm/wrap-wizard edit-wizard)
(fn render [cursor request] (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(let [snapshot (-> request :multi-form-state :snapshot) (mm/wrap-decode-multi-form-state))
amount-mode (or (:amount-mode snapshot) "$") ::route/edit-wizard-remove-account (-> edit-wizard-remove-account-handler
total (Math/abs (or (:transaction/amount snapshot) 0.0))] (mm/wrap-wizard edit-wizard)
(transaction-account-row* (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
{:value cursor (mm/wrap-decode-multi-form-state))
: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/unlink-payment (-> unlink-payment ::route/unlink-payment (-> unlink-payment
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client))) (wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))

View File

@@ -5,13 +5,13 @@
[hiccup2.core :as hiccup] [hiccup2.core :as hiccup]
[auto-ap.ssr.components :as com])) [auto-ap.ssr.components :as com]))
(defn html-page [hiccup] (defn html-page [hiccup]
{:status 200 {:status 200
:headers {"Content-Type" "text/html"} :headers {"Content-Type" "text/html"}
:body (str :body (str
"<!DOCTYPE html>" "<!DOCTYPE html>"
(hiccup/html (hiccup/html
{} {}
hiccup))}) hiccup))})
(defn base-page [request contents page-name] (defn base-page [request contents page-name]
(html-page (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}] [: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/tippy.css"}]
[:link {:rel "stylesheet" :href "/css/tippy/light.css"}] [:link {:rel "stylesheet" :href "/css/tippy/light.css"}]
[:script {:src "/js/htmx.js" [:script {:src "/js/htmx.js"
:crossorigin= "anonymous"}] :crossorigin= "anonymous"}]
[:script {:src "https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"}] [: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"}] [: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 {: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/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://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://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}] [: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 "/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/@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/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 {: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/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"}] [: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"} "x-transition:leave-end" "!bg-opacity-0"}
[:div {:class "flex h-full w-full justify-stretch md:justify-center items-stretch md:items-center " [: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.noscroll" "open"
"x-trap.inert" "open" "x-trap.inert" "open"
"x-show" "open" "x-show" "open"
"x-transition:enter" "ease-out duration-300" "x-transition:enter" "ease-out duration-300"
"x-transition:enter-start" "!bg-opacity-0 !translate-y-32" "x-transition:enter-start" "!bg-opacity-0 !translate-y-32"
"x-transition:enter-end" "!bg-opacity-100 !translate-y-0" "x-transition:enter-end" "!bg-opacity-100 !translate-y-0"
"x-transition:leave" "duration-300" "x-transition:leave" "duration-300"
"x-transition:leave-start" "!opacity-100 !translate-y-0" "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"}]]]]]])) [:div#modal-content.flex.items-center.justify-center {:class "md:p-12"}]]]]]]))

View File

@@ -33,7 +33,9 @@
"/account-total" ::account-total "/account-total" ::account-total
"/account-balance" ::account-balance "/account-balance" ::account-balance
"/toggle-amount-mode" ::toggle-amount-mode "/toggle-amount-mode" ::toggle-amount-mode
"/edit-form-changed" ::edit-form-changed
"/edit-wizard-new-account" ::edit-wizard-new-account "/edit-wizard-new-account" ::edit-wizard-new-account
"/edit-wizard-remove-account" ::edit-wizard-remove-account
"/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode "/edit-wizard-toggle-mode" ::edit-wizard-toggle-mode
"/match-payment" ::link-payment "/match-payment" ::link-payment
"/match-autopay-invoices" ::link-autopay-invoices "/match-autopay-invoices" ::link-autopay-invoices

View File

@@ -100,6 +100,9 @@
{:db/id "vendor-id" {:db/id "vendor-id"
:vendor/name "Test Vendor" :vendor/name "Test Vendor"
:vendor/default-account "account-id"} :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" (test-transaction :db/id "transaction-id"
:transaction/client "client-id" :transaction/client "client-id"
:transaction/bank-account "bank-account-id" :transaction/bank-account "bank-account-id"
@@ -135,19 +138,19 @@
:payment/status :payment-status/pending :payment/status :payment-status/pending
:payment/date #inst "2023-06-15") :payment/date #inst "2023-06-15")
;; Transaction and unpaid invoice for link testing ;; Transaction and unpaid invoice for link testing
(test-transaction :db/id "transaction-id-unpaid" (test-transaction :db/id "transaction-id-unpaid"
:transaction/client "client-id" :transaction/client "client-id"
:transaction/bank-account "bank-account-id" :transaction/bank-account "bank-account-id"
:transaction/amount -150.0 :transaction/amount -150.0
:transaction/description-original "Transaction for unpaid invoice link" :transaction/description-original "Transaction for unpaid invoice link"
:transaction/approval-status :transaction-approval-status/unapproved) :transaction/approval-status :transaction-approval-status/unapproved)
(test-transaction :db/id "transaction-id-feedback" (test-transaction :db/id "transaction-id-feedback"
:transaction/client "client-id" :transaction/client "client-id"
:transaction/bank-account "bank-account-id" :transaction/bank-account "bank-account-id"
:transaction/amount 400.0 :transaction/amount 400.0
:transaction/description-original "Transaction for feedback review" :transaction/description-original "Transaction for feedback review"
:transaction/approval-status :transaction-approval-status/requires-feedback) :transaction/approval-status :transaction-approval-status/requires-feedback)
(test-invoice :db/id "invoice-unpaid-id" (test-invoice :db/id "invoice-unpaid-id"
:invoice/client "client-id" :invoice/client "client-id"
:invoice/vendor "vendor-id" :invoice/vendor "vendor-id"
:invoice/total 150.0 :invoice/total 150.0
@@ -166,7 +169,8 @@
:second-account (get tempids "account-id-2") :second-account (get tempids "account-id-2")
:fixed-location-account (get tempids "account-id-fixed-loc") :fixed-location-account (get tempids "account-id-fixed-loc")
:ap-account (get tempids "ap-account-id") :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 (reset! test-client-ids
{:test (get tempids "client-id") {:test (get tempids "client-id")
:test2 (get tempids "client-id-2")}) :test2 (get tempids "client-id-2")})