Merge branch 'integreat-render-hx-select' into integreat-execute-refactor
This commit is contained in:
389
e2e/transaction-edit-swap.spec.ts
Normal file
389
e2e/transaction-edit-swap.spec.ts
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// These tests cover the "post the whole form, hx-select what to swap" behaviour
|
||||||
|
// on the transaction edit page. Each edit hits its own route, the server
|
||||||
|
// re-renders the entire form, and the client selects what to swap back -- with
|
||||||
|
// no out-of-band swaps and no morph extension:
|
||||||
|
// - discrete changes (vendor, account, location, mode, add/remove row) swap
|
||||||
|
// all of #wizard-form (the active action/tab round-trips through the form,
|
||||||
|
// so it survives the swap);
|
||||||
|
// - typed fields never swap the input the user is in -- the amount field swaps
|
||||||
|
// only the #account-totals tbody (a sibling of the input rows), and the memo
|
||||||
|
// posts with hx-swap=none.
|
||||||
|
// Because the active input is never part of a swapped region, focus and caret
|
||||||
|
// survive a plain swap.
|
||||||
|
|
||||||
|
// Collect any uncaught page errors or console errors so a swap 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 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 swap.
|
||||||
|
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 swap), 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 swap', () => {
|
||||||
|
test('whole-form 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 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 swap 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 swap', 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 the whole form but swaps back only the #account-totals
|
||||||
|
// tbody -- a sibling of this input's row, so the input is never replaced. It's
|
||||||
|
// type=number (no text caret), so we assert focus + node identity + value.
|
||||||
|
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 swap 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 swap...
|
||||||
|
expect(state.isAmountField).toBe(true);
|
||||||
|
// ...on the very same DOM node (the input is never part of the swapped region)...
|
||||||
|
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 and been
|
||||||
|
// applied via the #account-totals swap.
|
||||||
|
await expect(page.locator('.account-total-row #total')).toContainText('150');
|
||||||
|
|
||||||
|
expect(errors, errors.join('\n')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('memo edits issue no request and keep their value/caret', async ({ page }) => {
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
|
||||||
|
// Memo affects nothing else in the form, so editing it must NOT issue a
|
||||||
|
// request at all -- its value just rides along in the form until save.
|
||||||
|
let memoRequests = 0;
|
||||||
|
page.on('request', (r: any) => {
|
||||||
|
if (r.url().includes('edit-form-changed') && r.method() === 'POST') memoRequests++;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 and type "hello".
|
||||||
|
await memo.click();
|
||||||
|
await memo.press('Control+a');
|
||||||
|
await memo.pressSequentially('hello', { delay: 40 });
|
||||||
|
|
||||||
|
// Drop the caret in the middle and insert a char -> "heXllo", caret -> 3.
|
||||||
|
await memo.evaluate((el: HTMLInputElement) => {
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(2, 2);
|
||||||
|
});
|
||||||
|
await page.evaluate(() => {
|
||||||
|
(window as any).__focusedMemo = document.activeElement;
|
||||||
|
});
|
||||||
|
await memo.press('X');
|
||||||
|
|
||||||
|
// Give the old debounce window a chance to (not) fire.
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// No request fired, and the value/caret are simply intact (nothing swapped).
|
||||||
|
expect(memoRequests).toBe(0);
|
||||||
|
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 swap).
|
||||||
|
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 the HTMX swap 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(); ...` and dispatches
|
||||||
|
// the change that fires the whole-form swap.
|
||||||
|
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 swap.
|
||||||
|
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 swap', 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.
|
||||||
|
// Because the section is rebuilt fresh from the server (no preserved Alpine
|
||||||
|
// state), the server-driven account value lands without any keying tricks.
|
||||||
|
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 -- the regression guard: the section (and its vendor
|
||||||
|
// typeahead) is rebuilt fresh on every swap, so a second change still fires
|
||||||
|
// its request and updates the default account.
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -45,10 +45,10 @@
|
|||||||
[:label {:for "checkbox-all", :class "sr-only"} "checkbox"]]])
|
[:label {:for "checkbox-all", :class "sr-only"} "checkbox"]]])
|
||||||
|
|
||||||
(defn data-grid-
|
(defn data-grid-
|
||||||
[{:keys [headers thead-params id] :as params} & rest]
|
[{:keys [headers thead-params id footer-tbody] :as params} & rest]
|
||||||
[:div.shrink.overflow-y-scroll
|
[:div.shrink.overflow-y-scroll
|
||||||
[:table (merge {:class "w-full text-sm text-left text-gray-500 dark:text-gray-400 shrink"}
|
[:table (merge {:class "w-full text-sm text-left text-gray-500 dark:text-gray-400 shrink"}
|
||||||
(dissoc params :headers :thead-params))
|
(dissoc params :headers :thead-params :footer-tbody))
|
||||||
[:thead (update thead-params :class #(-> "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 group-[.raw]:sticky group-[.raw]:z-10 group-[.raw]:top-0"
|
[:thead (update thead-params :class #(-> "text-xs text-gray-800 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 group-[.raw]:sticky group-[.raw]:z-10 group-[.raw]:top-0"
|
||||||
(hh/add-class (or % ""))))
|
(hh/add-class (or % ""))))
|
||||||
(into
|
(into
|
||||||
@@ -56,7 +56,11 @@
|
|||||||
headers)]
|
headers)]
|
||||||
(into
|
(into
|
||||||
[:tbody {}]
|
[:tbody {}]
|
||||||
rest)]])
|
rest)
|
||||||
|
;; Optional second <tbody> (valid HTML) so callers can keep a stable,
|
||||||
|
;; separately-swappable region in the same table -- e.g. totals rows that
|
||||||
|
;; update without touching the input-bearing rows above them.
|
||||||
|
footer-tbody]])
|
||||||
|
|
||||||
;; needed for tailwind
|
;; needed for tailwind
|
||||||
;; lg:table-cell md:table-cell
|
;; lg:table-cell md:table-cell
|
||||||
|
|||||||
@@ -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))]
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -221,22 +224,27 @@
|
|||||||
:name (fc/field-name)
|
:name (fc/field-name)
|
||||||
:x-model "simpleAccountId"})]))
|
:x-model "simpleAccountId"})]))
|
||||||
(fc/with-field :transaction-account/location
|
(fc/with-field :transaction-account/location
|
||||||
(com/validated-field
|
;; Selecting the account only affects the valid Location options, so the
|
||||||
{:label "Location"
|
;; change swaps just this cell -- nothing else needs to re-render.
|
||||||
:errors (fc/field-errors)
|
[:div {:id "simple-account-location"}
|
||||||
:x-hx-val:account-id "simpleAccountId"
|
(com/validated-field
|
||||||
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
|
{:label "Location"
|
||||||
client-id (assoc :client-id client-id)))
|
:errors (fc/field-errors)
|
||||||
:x-dispatch:changed "simpleAccountId"
|
:x-hx-val:account-id "simpleAccountId"
|
||||||
:hx-trigger "changed"
|
:hx-vals (hx/json (cond-> {:name (fc/field-name)}
|
||||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
|
client-id (assoc :client-id client-id)))
|
||||||
:hx-target "find *"
|
:x-dispatch:changed "simpleAccountId"
|
||||||
:hx-swap "outerHTML"}
|
:hx-trigger "changed"
|
||||||
(location-select*
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
|
||||||
{:name (fc/field-name)
|
:hx-target "#simple-account-location"
|
||||||
:account-location (:account/location account-id)
|
:hx-select "#simple-account-location"
|
||||||
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
|
:hx-swap "outerHTML"
|
||||||
:value location-val})))
|
:hx-include "closest form"}
|
||||||
|
(location-select*
|
||||||
|
{:name (fc/field-name)
|
||||||
|
:account-location (:account/location account-id)
|
||||||
|
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
|
||||||
|
:value location-val}))])
|
||||||
(fc/with-field :transaction-account/amount
|
(fc/with-field :transaction-account/amount
|
||||||
(com/hidden {:name (fc/field-name)
|
(com/hidden {:name (fc/field-name)
|
||||||
:value total}))]]))
|
:value total}))]]))
|
||||||
@@ -244,7 +252,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-select "#wizard-form"
|
||||||
:hx-swap "outerHTML"}
|
:hx-swap "outerHTML"}
|
||||||
"Switch to advanced mode"]]]))
|
"Switch to advanced mode"]]]))
|
||||||
|
|
||||||
@@ -256,9 +265,10 @@
|
|||||||
: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)
|
||||||
: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"
|
||||||
@@ -278,7 +288,9 @@
|
|||||||
:x-model "accountId"}))))
|
:x-model "accountId"}))))
|
||||||
(fc/with-field :transaction-account/location
|
(fc/with-field :transaction-account/location
|
||||||
(com/data-grid-cell
|
(com/data-grid-cell
|
||||||
{}
|
{:id (str "account-location-" index)}
|
||||||
|
;; Selecting an account only affects this row's valid Location options, so the
|
||||||
|
;; change swaps just this cell -- nothing else needs to re-render.
|
||||||
(com/validated-field
|
(com/validated-field
|
||||||
{:errors (fc/field-errors)
|
{:errors (fc/field-errors)
|
||||||
:x-hx-val:account-id "accountId"
|
:x-hx-val:account-id "accountId"
|
||||||
@@ -286,9 +298,11 @@
|
|||||||
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 (str "#account-location-" index)
|
||||||
:hx-swap "outerHTML"}
|
:hx-select (str "#account-location-" index)
|
||||||
|
:hx-swap "outerHTML"
|
||||||
|
: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 +314,29 @@
|
|||||||
{}
|
{}
|
||||||
(com/validated-field
|
(com/validated-field
|
||||||
{:errors (fc/field-errors)}
|
{:errors (fc/field-errors)}
|
||||||
(if (= "%" amount-mode)
|
(let [amount-attrs {:name (fc/field-name)
|
||||||
(com/text-input {:name (fc/field-name)
|
:id (str "account-amount-" index)
|
||||||
:class "w-16 account-amount-field"
|
|
||||||
:value (fc/field-value)
|
|
||||||
:type "number"
|
|
||||||
: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)
|
||||||
|
;; Typing an amount posts the whole form but swaps back only the
|
||||||
|
;; #account-totals tbody -- a sibling of the input-bearing rows, so
|
||||||
|
;; the amount input is never replaced and the caret survives.
|
||||||
|
:hx-target "#account-totals"
|
||||||
|
:hx-select "#account-totals"
|
||||||
|
:hx-swap "outerHTML"
|
||||||
|
: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-select "#wizard-form"
|
||||||
|
:hx-swap "outerHTML"
|
||||||
|
: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,52 +476,52 @@
|
|||||||
: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-select "#wizard-form"
|
||||||
:hx-swap "outerHTML"
|
:hx-swap "outerHTML"
|
||||||
: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 %
|
;; Totals live in their own <tbody id="account-totals"> so the amount
|
||||||
:client-id (-> request :entity :transaction/client :db/id)
|
;; field refreshes them with a plain targeted swap, never swapping the
|
||||||
:amount-mode amount-mode
|
;; input-bearing rows above (which would drop the caret).
|
||||||
:total total}))
|
:footer-tbody
|
||||||
|
[:tbody {:id "account-totals"}
|
||||||
|
(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"}
|
||||||
|
(account-total* request))
|
||||||
|
(com/data-grid-cell {}))
|
||||||
|
(com/data-grid-row {:class "account-balance-row"}
|
||||||
|
(com/data-grid-cell {})
|
||||||
|
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"])
|
||||||
|
(com/data-grid-cell {:id "balance"
|
||||||
|
:class "text-right"}
|
||||||
|
(account-balance* request))
|
||||||
|
(com/data-grid-cell {}))
|
||||||
|
(com/data-grid-row {:class "account-grand-total-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 {}))]}
|
||||||
|
(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
|
(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-select "#wizard-form"
|
||||||
:tr-params {:hx-vals (hx/json {:client-id (:transaction/client snapshot)})}}
|
:hx-swap "outerHTML"
|
||||||
"New account")
|
:hx-include "closest form"
|
||||||
(com/data-grid-row {:class "account-total-row"}
|
:color :secondary}
|
||||||
(com/data-grid-cell {})
|
"New account"))))))
|
||||||
(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"}
|
|
||||||
(account-total* request))
|
|
||||||
(com/data-grid-cell {}))
|
|
||||||
|
|
||||||
(com/data-grid-row {:class "account-balance-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"
|
|
||||||
: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))
|
|
||||||
(com/data-grid-cell {}))
|
|
||||||
|
|
||||||
(com/data-grid-row {:class "account-grand-total-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 manual-coding-section*
|
(defn manual-coding-section*
|
||||||
"Renders the vendor field + account/location section for the manual tab.
|
"Renders the vendor field + account/location section for the manual tab.
|
||||||
@@ -509,10 +535,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-select "#wizard-form"
|
||||||
:hx-swap "outerHTML"
|
:hx-swap "outerHTML"
|
||||||
:hx-sync "this:replace"
|
:hx-sync "this:replace"
|
||||||
:hx-include "closest form"}
|
:hx-include "closest form"}
|
||||||
@@ -528,17 +555,18 @@
|
|||||||
: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))})}
|
[:div {:x-data (hx/json {:simpleAccountId simple-account-id})}
|
||||||
(simple-mode-fields* request)]
|
(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-select "#wizard-form"
|
||||||
:hx-swap "outerHTML"}
|
:hx-swap "outerHTML"}
|
||||||
"Switch to simple mode"]])
|
"Switch to simple mode"]])
|
||||||
(fc/with-field :transaction/accounts
|
(fc/with-field :transaction/accounts
|
||||||
@@ -557,59 +585,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 +593,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,8 +879,12 @@
|
|||||||
{:label "Memo"
|
{:label "Memo"
|
||||||
:errors (fc/field-errors)}
|
:errors (fc/field-errors)}
|
||||||
[:div.w-96
|
[:div.w-96
|
||||||
|
;; Memo affects nothing else, so it issues no request at all -- its
|
||||||
|
;; value just rides along in the form (posted with the next dependent
|
||||||
|
;; change, and merged into the snapshot on save).
|
||||||
(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"})]))
|
||||||
[:div {:x-data (hx/json {:activeForm (if (:transaction/payment (:entity request))
|
[:div {:x-data (hx/json {:activeForm (if (:transaction/payment (:entity request))
|
||||||
@@ -1387,7 +1367,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"))
|
||||||
:render-timeline? false))
|
:render-timeline? false))
|
||||||
(steps [_]
|
(steps [_]
|
||||||
[:links])
|
[:links])
|
||||||
@@ -1430,6 +1411,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 +1432,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 +1459,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 +1484,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 +1494,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 +1572,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 +1584,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)))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -28,7 +28,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"}]
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
[: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"}]
|
||||||
@@ -92,14 +92,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"}]]]]]]))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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")})
|
||||||
|
|||||||
Reference in New Issue
Block a user