refactor(charts): unify on Chart.js, remove Chartist #10

Closed
notid wants to merge 139 commits from integreat-unify-charts into master
6 changed files with 299 additions and 147 deletions
Showing only changes of commit ae0788e6dd - Show all commits

View File

@@ -234,7 +234,7 @@ test.describe('Bulk Code Transactions - Happy Path', () => {
await openBulkCodeModal(page);
// Should show all transactions
await expect(page.locator('text=Bulk editing 3 transactions')).toBeVisible();
await expect(page.locator('text=Bulk editing 5 transactions')).toBeVisible();
// Add account at 100%
await addNewAccount(page);

View File

@@ -103,27 +103,19 @@ async function selectAccountFromTypeahead(page: any, rowIndex: number, accountNa
}
async function findAccountRow(page: any, rowIndex: number) {
const allRows = page.locator('#account-grid-body tbody tr');
const rowCount = await allRows.count();
const accountRows = page.locator('#account-grid-body tbody tr.account-row');
const rowCount = await accountRows.count();
let accountRowIndex = 0;
for (let i = 0; i < rowCount; i++) {
const row = allRows.nth(i);
const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0;
if (hasAccountInput) {
if (accountRowIndex === rowIndex) {
return row;
}
accountRowIndex++;
}
if (rowIndex >= rowCount) {
throw new Error(`Could not find account row at index ${rowIndex}. Only ${rowCount} account rows found.`);
}
throw new Error(`Could not find account row at index ${rowIndex}`);
return accountRows.nth(rowIndex);
}
async function setAccountAmount(page: any, rowIndex: number, amount: string) {
const row = await findAccountRow(page, rowIndex);
const amountInput = row.locator('input[name*="transaction-account/amount"]').first();
const amountInput = row.locator('.account-amount-field');
await amountInput.fill(amount);
await amountInput.dispatchEvent('change');
await page.waitForTimeout(300);
@@ -164,34 +156,24 @@ async function getAccountLocation(page: any, rowIndex: number): Promise<string>
}
async function removeAllAccounts(page: any) {
const allRows = page.locator('#account-grid-body tbody tr');
const rowCount = await allRows.count();
const accountRows = page.locator('#account-grid-body tbody tr.account-row');
const rowCount = await accountRows.count();
for (let i = rowCount - 1; i >= 0; i--) {
const row = allRows.nth(i);
const hasAccountInput = await row.locator('input[name*="transaction-account/account"]').count() > 0;
if (hasAccountInput) {
// Click the X button to remove (it's an <a> tag, not a <button>)
const removeButton = row.locator('a').first();
await removeButton.click();
// Wait for the Alpine.js removal animation (500ms + buffer)
await page.waitForTimeout(700);
}
const row = accountRows.nth(i);
const removeButton = row.locator('.account-remove-action');
await removeButton.click();
// Wait for the Alpine.js removal animation (500ms + buffer)
await page.waitForTimeout(700);
}
}
async function saveTransaction(page: any) {
// Submit the form directly instead of clicking the button
// The Done button might not have type="submit"
await page.evaluate(() => {
const form = document.querySelector('#wizard-form') as HTMLFormElement;
if (form) {
form.dispatchEvent(new Event('submit', { bubbles: true }));
}
});
// Click the save button to submit the form via HTMX
await page.locator('.wizard-save-action').click();
// Wait for the modal to close
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'hidden', timeout: 10000 });
// Wait for the modal to close (longer timeout for parallel test load)
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'hidden', timeout: 20000 });
}
async function toggleToPercentMode(page: any) {
@@ -226,6 +208,9 @@ test.describe('Transaction Edit Shared Location', () => {
// Step 1: Open edit modal and add an account with Shared location
await openEditModal(page, transactionIndex);
// Remove any existing accounts from previous tests
await removeAllAccounts(page);
// Add a new account row
await addNewAccount(page);
@@ -261,11 +246,17 @@ test.describe('Transaction Edit Full Workflow', () => {
// Step 1: Open edit modal and code with 100% to one account
await openEditModal(page);
// Switch to percentage mode
// Switch to percentage mode first (this re-renders the grid from server state)
await toggleToPercentMode(page);
// Add a new account row
await addNewAccount(page);
// Check if there's already an account from previous tests
const allRows = page.locator('#account-grid-body tbody tr');
const hasExistingAccount = await allRows.locator('input[name*="transaction-account/account"]').count() > 0;
if (!hasExistingAccount) {
// Add a new account row if none exist
await addNewAccount(page);
}
// Select the account
await selectAccountFromTypeahead(page, 0, 'Test');
@@ -289,6 +280,7 @@ test.describe('Transaction Edit Full Workflow', () => {
// Add a second account at 50%
await addNewAccount(page);
await page.waitForTimeout(1000);
await selectAccountFromTypeahead(page, 1, 'Second');
await setAccountAmount(page, 1, '50');
@@ -310,8 +302,8 @@ test.describe('Transaction Edit Full Workflow', () => {
const row0 = await findAccountRow(page, 0);
const row1 = await findAccountRow(page, 1);
const amount0 = row0.locator('input[name*="transaction-account/amount"]').first();
const amount1 = row1.locator('input[name*="transaction-account/amount"]').first();
const amount0 = row0.locator('.account-amount-field');
const amount1 = row1.locator('.account-amount-field');
// Each should be $50.00 (or close to it)
const val0 = await amount0.inputValue();
@@ -331,6 +323,9 @@ test.describe('Transaction Edit Validation', () => {
await openEditModal(page, 2);
// Stay in dollar mode (default)
// Remove any existing accounts from previous tests
await removeAllAccounts(page);
await page.waitForTimeout(2000);
// Add an account
await addNewAccount(page);
await selectAccountFromTypeahead(page, 0, 'Test');
@@ -338,17 +333,12 @@ test.describe('Transaction Edit Validation', () => {
// Set amount to $50 (which doesn't match the $300 transaction)
await setAccountAmount(page, 0, '50');
// Try to save - this should fail because $50 != $100
// We submit the form and expect the modal to stay open
await page.evaluate(() => {
const form = document.querySelector('#wizard-form') as HTMLFormElement;
if (form) {
form.dispatchEvent(new Event('submit', { bubbles: true }));
}
});
// Try to save - this should fail because $50 != $300
// Click the save button to submit the form via HTMX
await page.locator('.wizard-save-action').click();
// Wait a bit for the response
await page.waitForTimeout(1000);
// Wait for the response (longer timeout for parallel test load)
await page.waitForTimeout(3000);
// Modal should still be open (save failed)
await expect(page.locator('#modal-holder[x-show="open"]')).toBeVisible();
@@ -358,7 +348,7 @@ test.describe('Transaction Edit Validation', () => {
await expect(form).toBeVisible();
// Verify the account row is still there with our $50 value
const amountInput = page.locator('input[name*="transaction-account/amount"]').first();
const amountInput = page.locator('.account-amount-field').first();
const value = await amountInput.inputValue();
expect(parseFloat(value)).toBeCloseTo(50.0, 1);

View File

@@ -0,0 +1,131 @@
import { test, expect } from '@playwright/test';
async function navigateToTransactions(page: any, path: string = '/transaction2') {
await page.setExtraHTTPHeaders({
'x-clients': '"mine"'
});
await page.goto(path);
await page.waitForSelector('table tbody tr');
}
async function setAmountFilter(page: any, gte: string, lte: string) {
const amountGte = page.locator('input[name="amount-gte"]').first();
const amountLte = page.locator('input[name="amount-lte"]').first();
await amountGte.fill(gte);
await amountLte.fill(lte);
// Trigger the filter form submission via change event
await amountLte.dispatchEvent('change');
// Wait for HTMX to update the table and push URL
await page.waitForTimeout(1000);
}
async function getTableRowCount(page: any): Promise<number> {
const rows = page.locator('table tbody tr');
return await rows.count();
}
async function clickTransactionNavLink(page: any, linkText: string) {
const link = page.locator(`a:has-text("${linkText}")`).first();
await link.click({ force: true });
// Wait for navigation and table to load
await page.waitForTimeout(1000);
await page.waitForSelector('table tbody tr', { timeout: 10000 });
}
test.describe('Transaction Navigation - Amount Filter Persistence', () => {
test('should persist amount filter when navigating from All to Unapproved', async ({ page }) => {
// Step 1: Navigate to All transactions page
await navigateToTransactions(page, '/transaction2');
// Step 2: Set amount filter
await setAmountFilter(page, '250', '');
// Step 3: Verify the URL updated with the filter
await page.waitForURL(url => url.search.includes('amount-gte=250'), { timeout: 5000 });
// Step 4: Click the Unapproved nav link
await clickTransactionNavLink(page, 'Unapproved');
// Step 5: Verify amount filter is preserved in URL after navigation
const unapprovedUrl = page.url();
expect(unapprovedUrl).toContain('amount-gte=250');
// Step 6: Verify filter still applies (only 1 row - the 300 transaction)
const filteredCount = await getTableRowCount(page);
expect(filteredCount).toBe(1);
});
test('should persist amount filter when navigating from Unapproved to All', async ({ page }) => {
// Step 1: Navigate to Unapproved page with amount filter already in URL
await navigateToTransactions(page, '/transaction2/unapproved?amount-gte=200');
// Step 2: Click All nav link
await clickTransactionNavLink(page, 'All');
// Step 3: Verify amount filter is preserved
const allUrl = page.url();
expect(allUrl).toContain('amount-gte=200');
});
test('should persist amount filter when navigating to Client Review', async ({ page }) => {
// Step 1: Navigate to All page and set amount filter
await navigateToTransactions(page, '/transaction2');
await setAmountFilter(page, '', '250');
// Step 2: Wait for URL to update
await page.waitForURL(url => url.search.includes('amount-lte=250'), { timeout: 5000 });
// Step 3: Click Client Review nav link
await clickTransactionNavLink(page, 'Client Review');
// Step 4: Verify filter persisted
const feedbackUrl = page.url();
expect(feedbackUrl).toContain('amount-lte=250');
});
});
test.describe('Transaction Navigation - Date Filter Persistence', () => {
test('should persist date-range preset when navigating between pages', async ({ page }) => {
// Step 1: Navigate with date-range=all (includes 2022 test data)
await navigateToTransactions(page, '/transaction2?date-range=all');
// Step 2: Click Unapproved nav link
await clickTransactionNavLink(page, 'Unapproved');
// Step 3: Verify date-range persisted
const unapprovedUrl = page.url();
expect(unapprovedUrl).toContain('date-range=all');
});
});
test.describe('Transaction Sort - Default Newest First', () => {
test('should show transactions sorted by date descending by default', async ({ page }) => {
await navigateToTransactions(page, '/transaction2');
// Verify no explicit sort in URL initially
expect(page.url()).not.toContain('sort=');
// The default sort should be newest first (descending by date)
// We can verify this by checking that clicking Date header toggles to asc
const dateHeader = page.locator('th').filter({ hasText: 'Date' }).first();
await dateHeader.click();
// Wait for HTMX to update
await page.waitForTimeout(800);
// The URL should now have an explicit sort parameter (ascending because we toggled from default desc)
const currentUrl = page.url();
expect(currentUrl).toContain('sort=date%3Aasc');
// Click again to toggle to descending
await dateHeader.click();
await page.waitForTimeout(800);
const toggledUrl = page.url();
expect(toggledUrl).toContain('sort=date%3Adesc');
});
});

View File

@@ -19,6 +19,7 @@
[auto-ap.ssr.hiccup-helper :as hh]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.svg :as svg]
[auto-ap.time :as atime]
[bidi.bidi :as bidi]
[hiccup.util :as hu]))
@@ -58,6 +59,31 @@
(update-in c [1 1 :class] (fn [c]
(hh/add-class (or c "") " flex items-center p-2 pl-11 w-full text-base font-normal rounded-lg transition duration-75 group hover:bg-gray-100 dark:text-white dark:hover:bg-gray-700")))])])
(defn- transaction-nav-params [request]
(let [qp (:query-params request)]
(cond-> {}
(:amount-gte qp) (assoc :amount-gte (:amount-gte qp))
(:amount-lte qp) (assoc :amount-lte (:amount-lte qp))
(:vendor qp) (assoc :vendor (:db/id (:vendor qp)))
(:account qp) (assoc :account (:db/id (:account qp)))
(:bank-account qp) (assoc :bank-account (:db/id (:bank-account qp)))
(:linked-to qp) (assoc :linked-to (:linked-to qp))
(:description qp) (assoc :description (:description qp))
(:location qp) (assoc :location (:location qp))
(:client-id qp) (assoc :client-id (:client-id qp))
(:unresolved qp) (assoc :unresolved (:unresolved qp))
(:potential-duplicates qp) (assoc :potential-duplicates (:potential-duplicates qp))
(:start-date qp) (assoc :start-date (atime/unparse-local (:start-date qp) atime/normal-date))
(:end-date qp) (assoc :end-date (atime/unparse-local (:end-date qp) atime/normal-date))
(:per-page qp) (assoc :per-page (:per-page qp)))))
(defn- transaction-nav-url [request route & {:keys [default-params] :or {default-params {:date-range "month"}}}]
(let [preserved (transaction-nav-params request)]
(hu/url (bidi/path-for ssr-routes/only-routes route)
(if (or (:start-date preserved) (:end-date preserved))
preserved
(merge default-params preserved)))))
(defn left-aside- [{:keys [nav page-specific]} & _]
[:aside {:id "left-nav",
:class "fixed top-0 left-0 pt-16 z-20 w-64 h-screen transition-transform",
@@ -260,25 +286,26 @@
(sub-menu- {:selector "transactions"
:active? (= "transactions" selected)}
(menu-button- {:href (hu/url (bidi/path-for ssr-routes/only-routes
::transaction-routes/page)
{:date-range "month"})
(menu-button- {:href (transaction-nav-url request ::transaction-routes/page)
:active? (= ::transaction-routes/page (:matched-route request))
:hx-boost "true"} "All")
(menu-button- {:href (hu/url (bidi/path-for ssr-routes/only-routes
::transaction-routes/unapproved-page)
{:date-range "month"})
:hx-boost "true"
:hx-vals "js:(() => { const p = new URLSearchParams(window.location.search); p.delete('status'); p.delete('start'); p.delete('per-page'); return Object.fromEntries(p); })()"}
"All")
(menu-button- {:href (transaction-nav-url request ::transaction-routes/unapproved-page)
:active? (= ::transaction-routes/unapproved-page (:matched-route request))
:hx-boost "true"} "Unapproved")
(menu-button- {:href (hu/url (bidi/path-for ssr-routes/only-routes
::transaction-routes/requires-feedback-page)
{:date-range "month"})
:hx-boost "true"
:hx-vals "js:(() => { const p = new URLSearchParams(window.location.search); p.delete('status'); p.delete('start'); p.delete('per-page'); return Object.fromEntries(p); })()"}
"Unapproved")
(menu-button- {:href (transaction-nav-url request ::transaction-routes/requires-feedback-page)
:active? (= ::transaction-routes/requires-feedback-page (:matched-route request))
:hx-boost "true"} "Client Review")
(menu-button- {:href (hu/url (bidi/path-for ssr-routes/only-routes ::transaction-routes/approved-page)
{:date-range "month"})
:hx-boost "true"
:hx-vals "js:(() => { const p = new URLSearchParams(window.location.search); p.delete('status'); p.delete('start'); p.delete('per-page'); return Object.fromEntries(p); })()"}
"Client Review")
(menu-button- {:href (transaction-nav-url request ::transaction-routes/approved-page)
:active? (= ::transaction-routes/approved-page (:matched-route request))
:hx-boost "true"} "Approved")
:hx-boost "true"
:hx-vals "js:(() => { const p = new URLSearchParams(window.location.search); p.delete('status'); p.delete('start'); p.delete('per-page'); return Object.fromEntries(p); })()"}
"Approved")
(when (can? (:identity request)
{:subject :transaction :activity :insights})
(menu-button- {:href (bidi/path-for ssr-routes/only-routes

View File

@@ -251,7 +251,7 @@
(merge-query {:query {:find ['?sort-default '?e]}})))]
(->> (observable-query query)
(apply-sort-4 (assoc query-params :default-asc? true))
(apply-sort-4 (assoc query-params :default-asc? false))
(apply-pagination query-params))))
(defn fetch-page [request]

View File

@@ -181,7 +181,8 @@
(defn transaction-account-row* [{:keys [value client-id amount-mode total]}]
(com/data-grid-row
(-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
(-> {:class "account-row"
:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value))))
:accountId (fc/field-value (:transaction-account/account value))})
:data-key "show"
:x-ref "p"}
@@ -217,22 +218,23 @@
'[:account/location])))
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value (fc/field-value)}))))
(fc/with-field :transaction-account/amount
(com/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)}
(if (= "%" amount-mode)
(com/text-input {:name (fc/field-name)
:class "w-16"
:value (fc/field-value)
:type "number"
:step "0.01"})
(com/money-input {:name (fc/field-name)
:class "w-16"
:value (fc/field-value)})))))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))
(fc/with-field :transaction-account/amount
(com/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)}
(if (= "%" amount-mode)
(com/text-input {:name (fc/field-name)
:class "w-16 account-amount-field"
:value (fc/field-value)
:type "number"
:step "0.01"})
(com/money-input {:name (fc/field-name)
:class "w-16 account-amount-field"
:value (fc/field-value)})))))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"
:class "account-remove-action"} svg/x))))
(defn- account-field-name [index field]
(str "step-params[transaction/accounts][" index "]["
@@ -245,46 +247,48 @@
(defn transaction-account-row-no-cursor* [{:keys [account index client-id amount-mode total]}]
(com/data-grid-row
(-> {:x-data (hx/json {:show true
(-> {:class "account-row"
:x-data (hx/json {:show true
:accountId (:transaction-account/account account)})
:data-key "show"
:x-ref "p"}
hx/alpine-mount-then-appear)
(com/hidden {:name (account-field-name index :db/id)
:value (or (:db/id account) "")})
(com/data-grid-cell
{}
(com/validated-field
(com/hidden {:name (account-field-name index :db/id)
:value (or (:db/id account) "")})
(com/data-grid-cell
{}
(account-typeahead* {:value (:transaction-account/account account)
:client-id client-id
:name (account-field-name index :transaction-account/account)
:x-model "accountId"})))
(com/data-grid-cell
{}
(com/validated-field
(com/validated-field
{}
(account-typeahead* {:value (:transaction-account/account account)
:client-id client-id
:name (account-field-name index :transaction-account/account)
:x-model "accountId"})))
(com/data-grid-cell
{}
(location-select* {:name (account-field-name index :transaction-account/location)
:account-location (:account/location (cond->> (:transaction-account/account account)
(nat-int? (:transaction-account/account account)) (dc/pull (dc/db conn)
'[:account/location])))
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value (:transaction-account/location account)})))
(com/data-grid-cell
{}
(com/validated-field
(com/validated-field
{}
(location-select* {:name (account-field-name index :transaction-account/location)
:account-location (:account/location (cond->> (:transaction-account/account account)
(nat-int? (:transaction-account/account account)) (dc/pull (dc/db conn)
'[:account/location])))
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value (:transaction-account/location account)})))
(com/data-grid-cell
{}
(if (= "%" amount-mode)
(com/text-input {:name (account-field-name index :transaction-account/amount)
:class "w-16"
:value (:transaction-account/amount account)
:type "number"
:step "0.01"})
(com/money-input {:name (account-field-name index :transaction-account/amount)
:class "w-16"
:value (:transaction-account/amount account)}))))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))
(com/validated-field
{}
(if (= "%" amount-mode)
(com/text-input {:name (account-field-name index :transaction-account/amount)
:class "w-16 account-amount-field"
:value (:transaction-account/amount account)
:type "number"
:step "0.01"})
(com/money-input {:name (account-field-name index :transaction-account/amount)
:class "w-16 account-amount-field"
:value (:transaction-account/amount account)}))))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"
:class "account-remove-action"} svg/x))))
(defn location-select [{{:keys [name account-id client-id value] :as qp} :query-params}]
(html-response (location-select* {:name name
@@ -385,36 +389,36 @@
:index (count (:transaction/accounts snapshot))
: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"
: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-total-row"}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
(com/data-grid-cell {:id "total"
:class "text-right"
:hx-trigger "change from:closest form target:.amount-field"
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/account-total)
:hx-target "this"
:hx-swap "innerHTML"}
(account-total* request))
(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"
: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-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 {}
(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 {})))))
(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 toggle-amount-mode [request]
(let [snapshot (-> request :multi-form-state :snapshot)
@@ -952,7 +956,7 @@
(account-grid-body* request)]))]]]])
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate
:next-button (com/button {:color :primary :x-ref "next" :class "w-32"} "Done"))
:next-button (com/button {:color :primary :x-ref "next" :class "w-32 wizard-save-action"} "Done"))
:validation-route ::route/edit-wizard-navigate)))
(defmulti save-handler (fn [request]