diff --git a/.eslintrc.js b/.eslintrc.js index 324dcdf70e9..71946ca6b8d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -161,7 +161,12 @@ module.exports = { ], 'no-with': 'warn', 'no-whitespace-before-property': 'warn', - 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/exhaustive-deps': [ + 'warn', + { + additionalHooks: '(useQuery)', + }, + ], 'require-yield': 'warn', 'rest-spread-spacing': ['warn', 'never'], strict: ['warn', 'never'], diff --git a/bin/package-electron b/bin/package-electron index b827d0015a0..8203cc99df6 100755 --- a/bin/package-electron +++ b/bin/package-electron @@ -36,7 +36,7 @@ fi yarn workspace loot-core build:node -yarn workspace @actual-app/web build --mode=desktop +yarn workspace @actual-app/web build --mode=desktop # electron specific build yarn workspace desktop-electron update-client diff --git a/packages/api/package.json b/packages/api/package.json index b44b0152ff5..4f9c4adc949 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@actual-app/api", - "version": "24.11.0", + "version": "24.12.0", "license": "MIT", "description": "An API for Actual", "engines": { diff --git a/packages/desktop-client/.gitignore b/packages/desktop-client/.gitignore index f635ae3caaa..d00b9758da4 100644 --- a/packages/desktop-client/.gitignore +++ b/packages/desktop-client/.gitignore @@ -10,6 +10,7 @@ playwright-report # production build +build-electron build-stats stats.json diff --git a/packages/desktop-client/e2e/accounts.mobile.test.js b/packages/desktop-client/e2e/accounts.mobile.test.js index 90cf4814f1d..a1487c333db 100644 --- a/packages/desktop-client/e2e/accounts.mobile.test.js +++ b/packages/desktop-client/e2e/accounts.mobile.test.js @@ -27,6 +27,7 @@ test.describe('Mobile Accounts', () => { test('opens the accounts page and asserts on balances', async () => { const accountsPage = await navigation.goToAccountsPage(); + await accountsPage.waitFor(); const account = await accountsPage.getNthAccount(1); @@ -37,7 +38,10 @@ test.describe('Mobile Accounts', () => { test('opens individual account page and checks that filtering is working', async () => { const accountsPage = await navigation.goToAccountsPage(); + await accountsPage.waitFor(); + const accountPage = await accountsPage.openNthAccount(0); + await accountPage.waitFor(); await expect(accountPage.heading).toHaveText('Bank of America'); await expect(accountPage.transactionList).toBeVisible(); @@ -50,6 +54,9 @@ test.describe('Mobile Accounts', () => { await expect(accountPage.transactions).toHaveCount(0); await expect(page).toMatchThemeScreenshots(); + await accountPage.clearSearch(); + await expect(accountPage.transactions).not.toHaveCount(0); + await accountPage.searchByText('Kroger'); await expect(accountPage.transactions).not.toHaveCount(0); await expect(page).toMatchThemeScreenshots(); diff --git a/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-1-chromium-linux.png b/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-1-chromium-linux.png index 8330943d4d3..cb84ec53392 100644 Binary files a/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-1-chromium-linux.png and b/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-2-chromium-linux.png b/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-2-chromium-linux.png index e1165283018..2e33859da81 100644 Binary files a/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-2-chromium-linux.png and b/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-3-chromium-linux.png b/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-3-chromium-linux.png index 3f53d9c2362..03cc182961c 100644 Binary files a/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-3-chromium-linux.png and b/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-7-chromium-linux.png b/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-7-chromium-linux.png index 0fa03838d3a..27b717a14a5 100644 Binary files a/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-7-chromium-linux.png and b/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-7-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-8-chromium-linux.png b/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-8-chromium-linux.png index 56cd4f17bdf..c6506b1d3a6 100644 Binary files a/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-8-chromium-linux.png and b/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-8-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-9-chromium-linux.png b/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-9-chromium-linux.png index 848617dd32e..1d66868a71a 100644 Binary files a/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-9-chromium-linux.png and b/packages/desktop-client/e2e/accounts.mobile.test.js-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-9-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/accounts.test.js b/packages/desktop-client/e2e/accounts.test.js index 1467e6d114c..5c9118172d6 100644 --- a/packages/desktop-client/e2e/accounts.test.js +++ b/packages/desktop-client/e2e/accounts.test.js @@ -62,6 +62,8 @@ test.describe('Accounts', () => { test('creates a transfer from two existing transactions', async () => { accountPage = await navigation.goToAccountPage('For budget'); + await accountPage.waitFor(); + await expect(accountPage.accountName).toHaveText('Budgeted Accounts'); await accountPage.filterByNote('Test Acc Transfer'); @@ -109,6 +111,7 @@ test.describe('Accounts', () => { offBudget: false, balance: 0, }); + await accountPage.waitFor(); }); async function importCsv(screenshot = false) { diff --git a/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Envelope-checks-that-clicking--c18ad-l-redirects-to-the-category-transactions-page-1-chromium-linux.png b/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Envelope-checks-that-clicking--c18ad-l-redirects-to-the-category-transactions-page-1-chromium-linux.png index bc0d475808a..49927fbd97a 100644 Binary files a/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Envelope-checks-that-clicking--c18ad-l-redirects-to-the-category-transactions-page-1-chromium-linux.png and b/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Envelope-checks-that-clicking--c18ad-l-redirects-to-the-category-transactions-page-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Envelope-checks-that-clicking--e995e-l-redirects-to-the-category-transactions-page-3-chromium-linux.png b/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Envelope-checks-that-clicking--e995e-l-redirects-to-the-category-transactions-page-3-chromium-linux.png index f0279600ae4..a35bd67cb6d 100644 Binary files a/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Envelope-checks-that-clicking--e995e-l-redirects-to-the-category-transactions-page-3-chromium-linux.png and b/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Envelope-checks-that-clicking--e995e-l-redirects-to-the-category-transactions-page-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Envelope-checks-that-clicking--ff568-l-redirects-to-the-category-transactions-page-2-chromium-linux.png b/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Envelope-checks-that-clicking--ff568-l-redirects-to-the-category-transactions-page-2-chromium-linux.png index a6fb65dbbbf..febfd876426 100644 Binary files a/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Envelope-checks-that-clicking--ff568-l-redirects-to-the-category-transactions-page-2-chromium-linux.png and b/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Envelope-checks-that-clicking--ff568-l-redirects-to-the-category-transactions-page-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Tracking-checks-that-clicking--11290-l-redirects-to-the-category-transactions-page-3-chromium-linux.png b/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Tracking-checks-that-clicking--11290-l-redirects-to-the-category-transactions-page-3-chromium-linux.png index f0279600ae4..a35bd67cb6d 100644 Binary files a/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Tracking-checks-that-clicking--11290-l-redirects-to-the-category-transactions-page-3-chromium-linux.png and b/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Tracking-checks-that-clicking--11290-l-redirects-to-the-category-transactions-page-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Tracking-checks-that-clicking--57d88-l-redirects-to-the-category-transactions-page-2-chromium-linux.png b/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Tracking-checks-that-clicking--57d88-l-redirects-to-the-category-transactions-page-2-chromium-linux.png index a6fb65dbbbf..febfd876426 100644 Binary files a/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Tracking-checks-that-clicking--57d88-l-redirects-to-the-category-transactions-page-2-chromium-linux.png and b/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Tracking-checks-that-clicking--57d88-l-redirects-to-the-category-transactions-page-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Tracking-checks-that-clicking--5d90c-l-redirects-to-the-category-transactions-page-1-chromium-linux.png b/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Tracking-checks-that-clicking--5d90c-l-redirects-to-the-category-transactions-page-1-chromium-linux.png index bc0d475808a..49927fbd97a 100644 Binary files a/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Tracking-checks-that-clicking--5d90c-l-redirects-to-the-category-transactions-page-1-chromium-linux.png and b/packages/desktop-client/e2e/budget.mobile.test.js-snapshots/Mobile-Budget-Tracking-checks-that-clicking--5d90c-l-redirects-to-the-category-transactions-page-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/page-models/account-page.js b/packages/desktop-client/e2e/page-models/account-page.js index b9d3ee05fe7..47bdb6e961f 100644 --- a/packages/desktop-client/e2e/page-models/account-page.js +++ b/packages/desktop-client/e2e/page-models/account-page.js @@ -30,6 +30,10 @@ export class AccountPage { this.selectTooltip = this.page.getByTestId('transactions-select-tooltip'); } + async waitFor() { + await this.transactionTable.waitFor(); + } + /** * Enter details of a transaction */ diff --git a/packages/desktop-client/e2e/page-models/mobile-account-page.js b/packages/desktop-client/e2e/page-models/mobile-account-page.js index 34a572206bb..009d3b556bd 100644 --- a/packages/desktop-client/e2e/page-models/mobile-account-page.js +++ b/packages/desktop-client/e2e/page-models/mobile-account-page.js @@ -15,6 +15,10 @@ export class MobileAccountPage { }); } + async waitFor() { + await this.transactionList.waitFor(); + } + /** * Retrieve the balance of the account as a number */ @@ -29,6 +33,10 @@ export class MobileAccountPage { await this.searchBox.fill(term); } + async clearSearch() { + await this.searchBox.clear(); + } + /** * Go to transaction creation page */ diff --git a/packages/desktop-client/e2e/page-models/mobile-accounts-page.js b/packages/desktop-client/e2e/page-models/mobile-accounts-page.js index 2a64c3e5531..b440a55b10a 100644 --- a/packages/desktop-client/e2e/page-models/mobile-accounts-page.js +++ b/packages/desktop-client/e2e/page-models/mobile-accounts-page.js @@ -4,9 +4,14 @@ export class MobileAccountsPage { constructor(page) { this.page = page; + this.accountList = this.page.getByLabel('Account list'); this.accounts = this.page.getByTestId('account'); } + async waitFor() { + await this.accountList.waitFor(); + } + /** * Get the name and balance of the nth account */ diff --git a/packages/desktop-client/e2e/page-models/mobile-navigation.js b/packages/desktop-client/e2e/page-models/mobile-navigation.js index ebda4cbb777..6881b4d399e 100644 --- a/packages/desktop-client/e2e/page-models/mobile-navigation.js +++ b/packages/desktop-client/e2e/page-models/mobile-navigation.js @@ -1,3 +1,4 @@ +import { MobileAccountPage } from './mobile-account-page'; import { MobileAccountsPage } from './mobile-accounts-page'; import { MobileBudgetPage } from './mobile-budget-page'; import { MobileTransactionEntryPage } from './mobile-transaction-entry-page'; @@ -22,6 +23,13 @@ export class MobileNavigation { return new MobileAccountsPage(this.page); } + async goToUncategorizedPage() { + const button = this.page.getByRole('button', { name: /uncategorized/ }); + await button.click(); + + return new MobileAccountPage(this.page); + } + async goToTransactionEntryPage() { const link = this.page.getByRole('link', { name: 'Transaction' }); await link.click(); diff --git a/packages/desktop-client/e2e/page-models/reports-page.js b/packages/desktop-client/e2e/page-models/reports-page.js index d6f6705268a..c5c070f3cb8 100644 --- a/packages/desktop-client/e2e/page-models/reports-page.js +++ b/packages/desktop-client/e2e/page-models/reports-page.js @@ -22,8 +22,9 @@ export class ReportsPage { async goToCustomReportPage() { await this.pageContent - .getByRole('button', { name: 'Create new custom report' }) + .getByRole('button', { name: 'Add new widget' }) .click(); + await this.page.getByRole('button', { name: 'New custom report' }).click(); return new CustomReportPage(this.page); } diff --git a/packages/desktop-client/e2e/page-models/settings-page.js b/packages/desktop-client/e2e/page-models/settings-page.js index dcf838cea5b..110dca4665d 100644 --- a/packages/desktop-client/e2e/page-models/settings-page.js +++ b/packages/desktop-client/e2e/page-models/settings-page.js @@ -8,27 +8,10 @@ export class SettingsPage { } async useBudgetType(budgetType) { - await this.enableExperimentalFeature('Budget mode toggle'); - const switchBudgetTypeButton = this.page.getByRole('button', { name: `Switch to ${budgetType} budgeting`, }); await switchBudgetTypeButton.click(); } - - async enableExperimentalFeature(featureName) { - const advancedSettingsButton = this.page.getByTestId('advanced-settings'); - await advancedSettingsButton.click(); - - const experimentalSettingsButton = this.page.getByTestId( - 'experimental-settings', - ); - await experimentalSettingsButton.click(); - - const featureCheckbox = this.page.getByRole('checkbox', { - name: featureName, - }); - await featureCheckbox.click(); - } } diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-custom-reports-Switches-to-Donut-Graph-and-checks-the-visuals-1-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-custom-reports-Switches-to-Donut-Graph-and-checks-the-visuals-1-chromium-linux.png index 2f01d4b21a4..8b6fb7dfc07 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-custom-reports-Switches-to-Donut-Graph-and-checks-the-visuals-1-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-custom-reports-Switches-to-Donut-Graph-and-checks-the-visuals-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-custom-reports-Switches-to-Donut-Graph-and-checks-the-visuals-2-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-custom-reports-Switches-to-Donut-Graph-and-checks-the-visuals-2-chromium-linux.png index 2619303ee1f..e821884d439 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-custom-reports-Switches-to-Donut-Graph-and-checks-the-visuals-2-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-custom-reports-Switches-to-Donut-Graph-and-checks-the-visuals-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-custom-reports-Switches-to-Donut-Graph-and-checks-the-visuals-3-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-custom-reports-Switches-to-Donut-Graph-and-checks-the-visuals-3-chromium-linux.png index e91f7df9a86..986929ddd5d 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-custom-reports-Switches-to-Donut-Graph-and-checks-the-visuals-3-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-custom-reports-Switches-to-Donut-Graph-and-checks-the-visuals-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-1-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-1-chromium-linux.png index c7452497828..a2ece8e2033 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-1-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-2-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-2-chromium-linux.png index 7354ab42626..581cb57fcef 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-2-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-3-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-3-chromium-linux.png index 2bfb72925d6..7319444883a 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-3-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-1-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-1-chromium-linux.png index 693e47ae3f8..846d1b279fb 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-1-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-2-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-2-chromium-linux.png index f8d7f531f39..0ab2b3e9d33 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-2-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-3-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-3-chromium-linux.png index f86c357494f..b105e11b4ac 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-3-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-and-cash-flow-reports-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-1-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-1-chromium-linux.png index 758ccf53f89..f6185ec8f3a 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-1-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-2-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-2-chromium-linux.png index c221cbb0ca9..ea636130ffa 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-2-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-3-chromium-linux.png b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-3-chromium-linux.png index a359fe01e1b..a821b995cd9 100644 Binary files a/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-3-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.js-snapshots/Reports-loads-net-worth-graph-and-checks-visuals-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-1-chromium-linux.png b/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-1-chromium-linux.png index 5f2fd61f65a..4c86c135f63 100644 Binary files a/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-1-chromium-linux.png and b/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-2-chromium-linux.png b/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-2-chromium-linux.png index cd4126a2cbd..da91ec0a42e 100644 Binary files a/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-2-chromium-linux.png and b/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-3-chromium-linux.png b/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-3-chromium-linux.png index 002a17b784e..f6dea23498d 100644 Binary files a/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-3-chromium-linux.png and b/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-4-chromium-linux.png b/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-4-chromium-linux.png index 0f3a4799e2d..d8f0b77169d 100644 Binary files a/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-4-chromium-linux.png and b/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-4-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-5-chromium-linux.png b/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-5-chromium-linux.png index 58c44784a1c..85d5c95b923 100644 Binary files a/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-5-chromium-linux.png and b/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-5-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-6-chromium-linux.png b/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-6-chromium-linux.png index e744a892d65..ea297f12c65 100644 Binary files a/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-6-chromium-linux.png and b/packages/desktop-client/e2e/settings.mobile.test.js-snapshots/Mobile-Settings-checks-that-settings-page-can-be-opened-6-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.mobile.test.js b/packages/desktop-client/e2e/transactions.mobile.test.js index 0327dc2b585..822b3b4c2a8 100644 --- a/packages/desktop-client/e2e/transactions.mobile.test.js +++ b/packages/desktop-client/e2e/transactions.mobile.test.js @@ -48,11 +48,8 @@ test.describe('Mobile Transactions', () => { ); await expect(page).toMatchThemeScreenshots(); - const accountPage = await transactionEntryPage.createTransaction(); - - await expect(accountPage.transactions.nth(0)).toHaveText( - 'KrogerClothing-12.34', - ); + await transactionEntryPage.createTransaction(); + await expect(page.getByLabel('Transaction list')).toHaveCount(0); await expect(page).toMatchThemeScreenshots(); }); @@ -82,4 +79,74 @@ test.describe('Mobile Transactions', () => { 'KrogerClothing-12.34', ); }); + + test('creates an uncategorized transaction from `/accounts/uncategorized` page', async () => { + // Create uncategorized transaction + let transactionEntryPage = await navigation.goToTransactionEntryPage(); + await transactionEntryPage.amountField.fill('12.35'); + // Click anywhere to cancel active edit. + await transactionEntryPage.header.click(); + await transactionEntryPage.fillField( + page.getByTestId('account-field'), + 'Ally Savings', + ); + await transactionEntryPage.createTransaction(); + + const uncategorizedPage = await navigation.goToUncategorizedPage(); + transactionEntryPage = await uncategorizedPage.clickCreateTransaction(); + + await expect(transactionEntryPage.header).toHaveText('New Transaction'); + + await transactionEntryPage.amountField.fill('12.34'); + // Click anywhere to cancel active edit. + await transactionEntryPage.header.click(); + await transactionEntryPage.fillField( + page.getByTestId('payee-field'), + 'Kroger', + ); + + await transactionEntryPage.createTransaction(); + + await expect(uncategorizedPage.transactions.nth(0)).toHaveText( + 'KrogerUncategorized-12.34', + ); + await expect(page).toMatchThemeScreenshots(); + }); + + test('creates a categorized transaction from `/accounts/uncategorized` page', async () => { + // Create uncategorized transaction + let transactionEntryPage = await navigation.goToTransactionEntryPage(); + await transactionEntryPage.amountField.fill('12.35'); + // Click anywhere to cancel active edit. + await transactionEntryPage.header.click(); + await transactionEntryPage.fillField( + page.getByTestId('account-field'), + 'Ally Savings', + ); + await transactionEntryPage.createTransaction(); + + const uncategorizedPage = await navigation.goToUncategorizedPage(); + transactionEntryPage = await uncategorizedPage.clickCreateTransaction(); + + await expect(transactionEntryPage.header).toHaveText('New Transaction'); + + await transactionEntryPage.amountField.fill('12.34'); + // Click anywhere to cancel active edit. + await transactionEntryPage.header.click(); + await transactionEntryPage.fillField( + page.getByTestId('payee-field'), + 'Kroger', + ); + await transactionEntryPage.fillField( + page.getByTestId('category-field'), + 'Clothing', + ); + + await transactionEntryPage.createTransaction(); + + await expect(uncategorizedPage.transactions.nth(0)).toHaveText( + '(No payee)Uncategorized-12.35', + ); + await expect(page).toMatchThemeScreenshots(); + }); }); diff --git a/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-categorized-transaction-from-accounts-uncategorized-page-1-chromium-linux.png b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-categorized-transaction-from-accounts-uncategorized-page-1-chromium-linux.png new file mode 100644 index 00000000000..ad64d91030a Binary files /dev/null and b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-categorized-transaction-from-accounts-uncategorized-page-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-categorized-transaction-from-accounts-uncategorized-page-2-chromium-linux.png b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-categorized-transaction-from-accounts-uncategorized-page-2-chromium-linux.png new file mode 100644 index 00000000000..c7cd31e05e4 Binary files /dev/null and b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-categorized-transaction-from-accounts-uncategorized-page-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-categorized-transaction-from-accounts-uncategorized-page-3-chromium-linux.png b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-categorized-transaction-from-accounts-uncategorized-page-3-chromium-linux.png new file mode 100644 index 00000000000..bdcad535d4a Binary files /dev/null and b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-categorized-transaction-from-accounts-uncategorized-page-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-transaction-via-footer-button-7-chromium-linux.png b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-transaction-via-footer-button-7-chromium-linux.png index d3874ee53c3..5d6a84f5a7a 100644 Binary files a/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-transaction-via-footer-button-7-chromium-linux.png and b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-transaction-via-footer-button-7-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-transaction-via-footer-button-8-chromium-linux.png b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-transaction-via-footer-button-8-chromium-linux.png index 2792869d701..c8ed30e1d63 100644 Binary files a/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-transaction-via-footer-button-8-chromium-linux.png and b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-transaction-via-footer-button-8-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-transaction-via-footer-button-9-chromium-linux.png b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-transaction-via-footer-button-9-chromium-linux.png index 0ae8f40161d..d10b3d513df 100644 Binary files a/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-transaction-via-footer-button-9-chromium-linux.png and b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-a-transaction-via-footer-button-9-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-an-uncategorized-transaction-from-accounts-uncategorized-page-1-chromium-linux.png b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-an-uncategorized-transaction-from-accounts-uncategorized-page-1-chromium-linux.png new file mode 100644 index 00000000000..72f98361371 Binary files /dev/null and b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-an-uncategorized-transaction-from-accounts-uncategorized-page-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-an-uncategorized-transaction-from-accounts-uncategorized-page-2-chromium-linux.png b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-an-uncategorized-transaction-from-accounts-uncategorized-page-2-chromium-linux.png new file mode 100644 index 00000000000..1a2ebaae049 Binary files /dev/null and b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-an-uncategorized-transaction-from-accounts-uncategorized-page-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-an-uncategorized-transaction-from-accounts-uncategorized-page-3-chromium-linux.png b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-an-uncategorized-transaction-from-accounts-uncategorized-page-3-chromium-linux.png new file mode 100644 index 00000000000..533989cdf55 Binary files /dev/null and b/packages/desktop-client/e2e/transactions.mobile.test.js-snapshots/Mobile-Transactions-creates-an-uncategorized-transaction-from-accounts-uncategorized-page-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png index fbac8396fa5..bf1a5b83010 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png index cd6cc7d544e..1cf49f768b1 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png index 5ec0c0efbc5..d6ad5f63278 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-4-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-4-chromium-linux.png index 40a92b53eff..538c80c7459 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-4-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-4-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-5-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-5-chromium-linux.png index 5badb700802..ac6583f5c46 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-5-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-5-chromium-linux.png differ diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index 657fb96bb4f..877539fa9f4 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -1,6 +1,6 @@ { "name": "@actual-app/web", - "version": "24.11.0", + "version": "24.12.0", "license": "MIT", "files": [ "build" @@ -50,8 +50,8 @@ "promise-retry": "^2.0.1", "re-resizable": "^6.9.17", "react": "18.2.0", - "react-aria": "^3.34.3", - "react-aria-components": "^1.3.3", + "react-aria": "^3.35.1", + "react-aria-components": "^1.4.1", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "18.2.0", @@ -65,7 +65,7 @@ "react-router-dom": "6.21.3", "react-simple-pull-to-refresh": "^1.3.3", "react-spring": "^9.7.3", - "react-stately": "^3.10.9", + "react-stately": "^3.33.0", "react-virtualized-auto-sizer": "^1.0.21", "recharts": "^2.10.4", "redux": "^4.2.1", diff --git a/packages/desktop-client/src/components/App.tsx b/packages/desktop-client/src/components/App.tsx index 902dc657053..4c5895c52eb 100644 --- a/packages/desktop-client/src/components/App.tsx +++ b/packages/desktop-client/src/components/App.tsx @@ -40,7 +40,6 @@ import { FinancesApp } from './FinancesApp'; import { ManagementApp } from './manager/ManagementApp'; import { Modals } from './Modals'; import { ResponsiveProvider } from './responsive/ResponsiveProvider'; -import { ScrollProvider } from './ScrollProvider'; import { SidebarProvider } from './sidebar/SidebarProvider'; import { UpdateNotification } from './UpdateNotification'; @@ -180,36 +179,34 @@ export function App() { - + - - - {process.env.REACT_APP_REVIEW_ID && - !Platform.isPlaywright && } - - - - - - + + {process.env.REACT_APP_REVIEW_ID && + !Platform.isPlaywright && } + + + + + - + diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx index 5ce026b8a38..6983b2708be 100644 --- a/packages/desktop-client/src/components/FinancesApp.tsx +++ b/packages/desktop-client/src/components/FinancesApp.tsx @@ -1,5 +1,5 @@ // @ts-strict-ignore -import React, { type ReactElement, useEffect } from 'react'; +import React, { type ReactElement, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { @@ -34,6 +34,7 @@ import { Reports } from './reports'; import { LoadingIndicator } from './reports/LoadingIndicator'; import { NarrowAlternate, WideComponent } from './responsive'; import { useResponsive } from './responsive/ResponsiveProvider'; +import { ScrollProvider } from './ScrollProvider'; import { Settings } from './settings'; import { FloatableSidebar } from './sidebar'; import { Titlebar } from './Titlebar'; @@ -156,6 +157,8 @@ export function FinancesApp() { run(); }, [lastUsedVersion, setLastUsedVersion]); + const scrollableRef = useRef(null); + return ( @@ -179,113 +182,119 @@ export function FinancesApp() { width: '100%', }} > - - - - - - - 0 ? ( - + > + + + + + + 0 ? ( + + ) : ( + // If there are no accounts, we want to redirect the user to + // the All Accounts screen which will prompt them to add an account + + ) ) : ( - // If there are no accounts, we want to redirect the user to - // the All Accounts screen which will prompt them to add an account - + ) - ) : ( - - ) - } - /> - - } /> + } + /> + + } /> + + } + /> + + + + + } + /> + + } /> + } /> + } /> + + + + + } + /> + + } + /> + + } + /> + + + + + } + /> + + + + + } + /> + + {/* redirect all other traffic to the budget page */} + } /> + + - } - /> - - - - - } - /> - - } /> - } /> - } /> - - - - - } - /> - - } - /> - - } - /> - - - - - } - /> - - - - - } - /> - - {/* redirect all other traffic to the budget page */} - } /> + + } /> + } /> + } /> + } /> + - - - - } /> - } /> - } /> - } /> - - + diff --git a/packages/desktop-client/src/components/LoggedInUser.tsx b/packages/desktop-client/src/components/LoggedInUser.tsx index ec7df311bbf..8ad160f2294 100644 --- a/packages/desktop-client/src/components/LoggedInUser.tsx +++ b/packages/desktop-client/src/components/LoggedInUser.tsx @@ -6,6 +6,7 @@ import { useSelector } from 'react-redux'; import { type State } from 'loot-core/src/client/state-types'; import { useActions } from '../hooks/useActions'; +import { useNavigate } from '../hooks/useNavigate'; import { theme, styles } from '../style'; import { Button } from './common/Button2'; @@ -38,9 +39,11 @@ export function LoggedInUser({ getUserData().then(() => setLoading(false)); }, []); + const navigate = useNavigate(); + async function onChangePassword() { await closeBudget(); - window.__navigate('/change-password'); + navigate('/change-password'); } async function onMenuSelect(type) { @@ -52,14 +55,14 @@ export function LoggedInUser({ break; case 'sign-in': await closeBudget(); - window.__navigate('/login'); + navigate('/login'); break; case 'sign-out': signOut(); break; case 'config-server': await closeBudget(); - window.__navigate('/config-server'); + navigate('/config-server'); break; default: } diff --git a/packages/desktop-client/src/components/ManageRules.tsx b/packages/desktop-client/src/components/ManageRules.tsx index bb9ce717ae5..f27660eb438 100644 --- a/packages/desktop-client/src/components/ManageRules.tsx +++ b/packages/desktop-client/src/components/ManageRules.tsx @@ -7,8 +7,11 @@ import React, { type SetStateAction, type Dispatch, } from 'react'; +import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; +import { useSchedules } from 'loot-core/client/data-hooks/schedules'; +import { q } from 'loot-core/shared/query'; import { pushModal } from 'loot-core/src/client/actions/modals'; import { initiallyLoadPayees } from 'loot-core/src/client/actions/queries'; import { send } from 'loot-core/src/platform/client/fetch'; @@ -21,7 +24,6 @@ import { type NewRuleEntity } from 'loot-core/src/types/models'; import { useAccounts } from '../hooks/useAccounts'; import { useCategories } from '../hooks/useCategories'; import { usePayees } from '../hooks/usePayees'; -import { useSchedules } from '../hooks/useSchedules'; import { useSelected, SelectedProvider } from '../hooks/useSelected'; import { theme } from '../style'; @@ -113,7 +115,9 @@ export function ManageRules({ const [filter, setFilter] = useState(''); const dispatch = useDispatch(); - const { data: schedules = [] } = useSchedules(); + const { schedules = [] } = useSchedules({ + query: useMemo(() => q('schedules').select('*'), []), + }); const { list: categories } = useCategories(); const payees = usePayees(); const accounts = useAccounts(); @@ -196,7 +200,9 @@ export function ManageRules({ ]); if (someDeletionsFailed) { - alert('Some rules were not deleted because they are linked to schedules'); + alert( + t('Some rules were not deleted because they are linked to schedules'), + ); } await loadRules(); @@ -259,6 +265,7 @@ export function ManageRules({ const onHover = useCallback(id => { setHoveredRule(id); }, []); + const { t } = useTranslation(); return ( @@ -280,19 +287,19 @@ export function ManageRules({ }} > - Rules are always run in the order that you see them.{' '} + {t('Rules are always run in the order that you see them.')}{' '} - Learn more + {t('Learn more')} @@ -305,7 +312,7 @@ export function ManageRules({ style={{ marginBottom: -1 }} > {filteredRules.length === 0 ? ( - + ) : ( )} diff --git a/packages/desktop-client/src/components/ManageRulesPage.tsx b/packages/desktop-client/src/components/ManageRulesPage.tsx index efb68325139..12f76823d91 100644 --- a/packages/desktop-client/src/components/ManageRulesPage.tsx +++ b/packages/desktop-client/src/components/ManageRulesPage.tsx @@ -1,11 +1,13 @@ import React from 'react'; +import { t } from 'i18next'; + import { ManageRules } from './ManageRules'; import { Page } from './Page'; export function ManageRulesPage() { return ( - + ); diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index ef458ef87b4..58ba6f051f4 100644 --- a/packages/desktop-client/src/components/Modals.tsx +++ b/packages/desktop-client/src/components/Modals.tsx @@ -1,5 +1,6 @@ // @ts-strict-ignore import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { useLocation } from 'react-router-dom'; @@ -44,6 +45,7 @@ import { KeyboardShortcutModal } from './modals/KeyboardShortcutModal'; import { LoadBackupModal } from './modals/LoadBackupModal'; import { ConfirmChangeDocumentDirModal } from './modals/manager/ConfirmChangeDocumentDir'; import { DeleteFileModal } from './modals/manager/DeleteFileModal'; +import { DuplicateFileModal } from './modals/manager/DuplicateFileModal'; import { FilesSettingsModal } from './modals/manager/FilesSettingsModal'; import { ImportActualModal } from './modals/manager/ImportActualModal'; import { ImportModal } from './modals/manager/ImportModal'; @@ -81,6 +83,8 @@ export function Modals() { } }, [location]); + const { t } = useTranslation(); + const modals = modalStack .map(({ name, options }) => { switch (name) { @@ -287,10 +291,12 @@ export function Modals() { Header={props => ( } + title={ + + } /> )} - inputPlaceholder="Category name" + inputPlaceholder={t('Category name')} buttonText="Add" onValidate={options.onValidate} onSubmit={options.onSubmit} @@ -306,12 +312,15 @@ export function Modals() { + } /> )} - inputPlaceholder="Category group name" - buttonText="Add" + inputPlaceholder={t('Category group name')} + buttonText={t('Add')} onValidate={options.onValidate} onSubmit={options.onSubmit} /> @@ -578,6 +587,16 @@ export function Modals() { return ; case 'delete-budget': return ; + case 'duplicate-budget': + return ( + + ); case 'import': return ; case 'files-settings': diff --git a/packages/desktop-client/src/components/Notes.tsx b/packages/desktop-client/src/components/Notes.tsx index ea78c6b8516..28a1ca6f8f8 100644 --- a/packages/desktop-client/src/components/Notes.tsx +++ b/packages/desktop-client/src/components/Notes.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useRef, type CSSProperties } from 'react'; import ReactMarkdown from 'react-markdown'; import { css } from '@emotion/css'; +import { t } from 'i18next'; import remarkGfm from 'remark-gfm'; import { theme } from '../style'; @@ -122,7 +123,7 @@ export function Notes({ value={notes || ''} onChange={e => onChange?.(e.target.value)} onBlur={e => onBlur?.(e.target.value)} - placeholder="Notes (markdown supported)" + placeholder={t('Notes (markdown supported)')} /> ) : ( diff --git a/packages/desktop-client/src/components/NotesButton.tsx b/packages/desktop-client/src/components/NotesButton.tsx index 966fe0395f3..80b2c2492d7 100644 --- a/packages/desktop-client/src/components/NotesButton.tsx +++ b/packages/desktop-client/src/components/NotesButton.tsx @@ -6,6 +6,8 @@ import React, { type CSSProperties, } from 'react'; +import { t } from 'i18next'; + import { send } from 'loot-core/src/platform/client/fetch'; import { useNotes } from '../hooks/useNotes'; @@ -59,7 +61,7 @@ export function NotesButton({ ) : null } diff --git a/packages/desktop-client/src/components/UpdateNotification.tsx b/packages/desktop-client/src/components/UpdateNotification.tsx index e2f9e60b7d6..acbb6ea2382 100644 --- a/packages/desktop-client/src/components/UpdateNotification.tsx +++ b/packages/desktop-client/src/components/UpdateNotification.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { type State } from 'loot-core/src/client/state-types'; @@ -13,6 +14,7 @@ import { Text } from './common/Text'; import { View } from './common/View'; export function UpdateNotification() { + const { t } = useTranslation(); const updateInfo = useSelector((state: State) => state.app.updateInfo); const showUpdateNotification = useSelector( (state: State) => state.app.showUpdateNotification, @@ -40,7 +42,9 @@ export function UpdateNotification() { > - App updated to {updateInfo.version} + + {t('App updated to {{version}}', { version: updateInfo.version })} + @@ -53,7 +57,7 @@ export function UpdateNotification() { textDecoration: 'underline', }} > - Restart + {t('Restart')} {' '} ( - notes + {t('notes')} ) {account ? ( - + ) : ( - + ({ - name: (balanceQuery.name + '-cleared') as `balance-query-${string}-cleared`, - value: 0, - query: balanceQuery.query.filter({ cleared: true }), - }); + const cleared = + useSheetValue<'balance', `balance-query-${string}-cleared`>({ + name: (balanceQuery.name + + '-cleared') as `balance-query-${string}-cleared`, + value: 0, + query: balanceQuery.query.filter({ cleared: true }), + }) ?? 0; const format = useFormat(); const targetDiff = targetBalance - cleared; diff --git a/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx index 735982713be..1939b82e65b 100644 --- a/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/CategoryAutocomplete.tsx @@ -393,7 +393,7 @@ function CategoryItem({ >(balanceBinding); const isToBeBudgetedItem = item.id === 'to-be-budgeted'; - const toBudget = useEnvelopeSheetValue(envelopeBudget.toBudget) ?? 0; + const toBudget = useEnvelopeSheetValue(envelopeBudget.toBudget); return (
{isToBeBudgetedItem diff --git a/packages/desktop-client/src/components/budget/BudgetSummaries.tsx b/packages/desktop-client/src/components/budget/BudgetSummaries.tsx index a6b2e46570f..5335e903dc6 100644 --- a/packages/desktop-client/src/components/budget/BudgetSummaries.tsx +++ b/packages/desktop-client/src/components/budget/BudgetSummaries.tsx @@ -31,7 +31,7 @@ export function BudgetSummaries({ SummaryComponent }: BudgetSummariesProps) { config: { mass: 3, tension: 600, friction: 80 }, })); - const containerRef = useResizeObserver( + const containerRef = useResizeObserver( useCallback(rect => { setWidthState(rect.width); }, []), diff --git a/packages/desktop-client/src/components/budget/BudgetTable.jsx b/packages/desktop-client/src/components/budget/BudgetTable.tsx similarity index 65% rename from packages/desktop-client/src/components/budget/BudgetTable.jsx rename to packages/desktop-client/src/components/budget/BudgetTable.tsx index 785457f2ab5..66481fd64af 100644 --- a/packages/desktop-client/src/components/budget/BudgetTable.jsx +++ b/packages/desktop-client/src/components/budget/BudgetTable.tsx @@ -1,14 +1,24 @@ -import React, { useState } from 'react'; +import React, { + type ComponentPropsWithoutRef, + type KeyboardEvent, + useState, +} from 'react'; + +import { + type CategoryEntity, + type CategoryGroupEntity, +} from 'loot-core/types/models'; import { useCategories } from '../../hooks/useCategories'; import { useLocalPref } from '../../hooks/useLocalPref'; import { theme, styles } from '../../style'; import { View } from '../common/View'; +import { type DropPosition } from '../sort'; import { BudgetCategories } from './BudgetCategories'; import { BudgetSummaries } from './BudgetSummaries'; import { BudgetTotals } from './BudgetTotals'; -import { MonthsProvider } from './MonthsContext'; +import { type MonthBounds, MonthsProvider } from './MonthsContext'; import { findSortDown, findSortUp, @@ -16,7 +26,39 @@ import { separateGroups, } from './util'; -export function BudgetTable(props) { +type BudgetTableProps = { + type: string; + prewarmStartMonth: string; + startMonth: string; + numMonths: number; + monthBounds: MonthBounds; + dataComponents: { + SummaryComponent: ComponentPropsWithoutRef< + typeof BudgetSummaries + >['SummaryComponent']; + BudgetTotalsComponent: ComponentPropsWithoutRef< + typeof BudgetTotals + >['MonthComponent']; + }; + onSaveCategory: (category: CategoryEntity) => void; + onDeleteCategory: (id: CategoryEntity['id']) => void; + onSaveGroup: (group: CategoryGroupEntity) => void; + onDeleteGroup: (id: CategoryGroupEntity['id']) => void; + onApplyBudgetTemplatesInGroup: (groupId: CategoryGroupEntity['id']) => void; + onReorderCategory: (params: { + id: CategoryEntity['id']; + groupId?: CategoryGroupEntity['id']; + targetId: CategoryEntity['id'] | null; + }) => void; + onReorderGroup: (params: { + id: CategoryGroupEntity['id']; + targetId: CategoryEntity['id'] | null; + }) => void; + onShowActivity: (id: CategoryEntity['id'], month?: string) => void; + onBudgetAction: (month: string, type: string, args: unknown) => void; +}; + +export function BudgetTable(props: BudgetTableProps) { const { type, prewarmStartMonth, @@ -35,23 +77,29 @@ export function BudgetTable(props) { onBudgetAction, } = props; - const { grouped: categoryGroups } = useCategories(); + const { grouped: categoryGroups = [] } = useCategories(); const [collapsedGroupIds = [], setCollapsedGroupIdsPref] = useLocalPref('budget.collapsed'); const [showHiddenCategories, setShowHiddenCategoriesPef] = useLocalPref( 'budget.showHiddenCategories', ); - const [editing, setEditing] = useState(null); + const [editing, setEditing] = useState<{ id: string; cell: string } | null>( + null, + ); - const onEditMonth = (id, month) => { + const onEditMonth = (id: string, month: string) => { setEditing(id ? { id, cell: month } : null); }; - const onEditName = id => { + const onEditName = (id: string) => { setEditing(id ? { id, cell: 'name' } : null); }; - const _onReorderCategory = (id, dropPos, targetId) => { + const _onReorderCategory = ( + id: string, + dropPos: DropPosition, + targetId: string, + ) => { const isGroup = !!categoryGroups.find(g => g.id === targetId); if (isGroup) { @@ -63,7 +111,7 @@ export function BudgetTable(props) { const group = categoryGroups.find(g => g.id === groupId); if (group) { - const { categories } = group; + const { categories = [] } = group; onReorderCategory({ id, groupId: group.id, @@ -77,7 +125,7 @@ export function BudgetTable(props) { let targetGroup; for (const group of categoryGroups) { - if (group.categories.find(cat => cat.id === targetId)) { + if (group.categories?.find(cat => cat.id === targetId)) { targetGroup = group; break; } @@ -85,13 +133,17 @@ export function BudgetTable(props) { onReorderCategory({ id, - groupId: targetGroup.id, - ...findSortDown(targetGroup.categories, dropPos, targetId), + groupId: targetGroup?.id, + ...findSortDown(targetGroup?.categories || [], dropPos, targetId), }); } }; - const _onReorderGroup = (id, dropPos, targetId) => { + const _onReorderGroup = ( + id: string, + dropPos: DropPosition, + targetId: string, + ) => { const [expenseGroups] = separateGroups(categoryGroups); // exclude Income group from sortable groups to fix off-by-one error onReorderGroup({ id, @@ -99,13 +151,21 @@ export function BudgetTable(props) { }); }; - const moveVertically = dir => { - const flattened = categoryGroups.reduce((all, group) => { - if (collapsedGroupIds.includes(group.id)) { - return all.concat({ id: group.id, isGroup: true }); - } - return all.concat([{ id: group.id, isGroup: true }, ...group.categories]); - }, []); + const moveVertically = (dir: 1 | -1) => { + const flattened = categoryGroups.reduce( + (all, group) => { + if (collapsedGroupIds.includes(group.id)) { + return all.concat({ id: group.id, isGroup: true }); + } + return all.concat([ + { id: group.id, isGroup: true }, + ...(group?.categories || []), + ]); + }, + [] as Array< + { id: CategoryGroupEntity['id']; isGroup: boolean } | CategoryEntity + >, + ); if (editing) { const idx = flattened.findIndex(item => item.id === editing.id); @@ -114,10 +174,13 @@ export function BudgetTable(props) { while (nextIdx >= 0 && nextIdx < flattened.length) { const next = flattened[nextIdx]; - if (next.isGroup) { + if ('isGroup' in next && next.isGroup) { nextIdx += dir; continue; - } else if (type === 'report' || !next.is_income) { + } else if ( + type === 'report' || + ('is_income' in next && !next.is_income) + ) { onEditMonth(next.id, editing.cell); return; } else { @@ -127,7 +190,7 @@ export function BudgetTable(props) { } }; - const onKeyDown = e => { + const onKeyDown = (e: KeyboardEvent) => { if (!editing) { return null; } @@ -138,7 +201,7 @@ export function BudgetTable(props) { } }; - const onCollapse = collapsedIds => { + const onCollapse = (collapsedIds: string[]) => { setCollapsedGroupIdsPref(collapsedIds); }; @@ -223,6 +286,7 @@ export function BudgetTable(props) { onKeyDown={onKeyDown} > ; +type DynamicBudgetTableProps = Omit< + ComponentProps, + 'numMonths' +> & { + maxMonths: number; + onMonthSelect: (month: string, numMonths: number) => void; +}; export const DynamicBudgetTable = (props: DynamicBudgetTableProps) => { return ( diff --git a/packages/desktop-client/src/components/budget/MonthPicker.tsx b/packages/desktop-client/src/components/budget/MonthPicker.tsx index 501bafd79ff..66f44423871 100644 --- a/packages/desktop-client/src/components/budget/MonthPicker.tsx +++ b/packages/desktop-client/src/components/budget/MonthPicker.tsx @@ -7,12 +7,12 @@ import { useResizeObserver } from '../../hooks/useResizeObserver'; import { styles, theme } from '../../style'; import { View } from '../common/View'; -import { type BoundsProps } from './MonthsContext'; +import { type MonthBounds } from './MonthsContext'; type MonthPickerProps = { startMonth: string; numDisplayed: number; - monthBounds: BoundsProps; + monthBounds: MonthBounds; style: CSSProperties; onSelect: (month: string) => void; }; diff --git a/packages/desktop-client/src/components/budget/MonthsContext.tsx b/packages/desktop-client/src/components/budget/MonthsContext.tsx index 2d5e377402a..dad9b264028 100644 --- a/packages/desktop-client/src/components/budget/MonthsContext.tsx +++ b/packages/desktop-client/src/components/budget/MonthsContext.tsx @@ -3,13 +3,13 @@ import React, { createContext, type ReactNode } from 'react'; import * as monthUtils from 'loot-core/src/shared/months'; -export type BoundsProps = { +export type MonthBounds = { start: string; end: string; }; export function getValidMonthBounds( - bounds: BoundsProps, + bounds: MonthBounds, startMonth: undefined | string, endMonth: string, ) { @@ -29,7 +29,7 @@ export const MonthsContext = createContext(null); type MonthsProviderProps = { startMonth: string | undefined; numMonths: number; - monthBounds: BoundsProps; + monthBounds: MonthBounds; type: string; children: ReactNode; }; diff --git a/packages/desktop-client/src/components/budget/SidebarCategory.tsx b/packages/desktop-client/src/components/budget/SidebarCategory.tsx index 4d3b53e7a04..bdd3f55a54d 100644 --- a/packages/desktop-client/src/components/budget/SidebarCategory.tsx +++ b/packages/desktop-client/src/components/budget/SidebarCategory.tsx @@ -1,5 +1,5 @@ // @ts-strict-ignore -import React, { type CSSProperties, type Ref, useRef, useState } from 'react'; +import React, { type CSSProperties, type Ref, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -7,7 +7,7 @@ import { type CategoryEntity, } from 'loot-core/src/types/models'; -import { useFeatureFlag } from '../../hooks/useFeatureFlag'; +import { useContextMenu } from '../../hooks/useContextMenu'; import { SvgCheveronDown } from '../../icons/v1'; import { theme } from '../../style'; import { Button } from '../common/Button2'; @@ -50,9 +50,9 @@ export function SidebarCategory({ const { t } = useTranslation(); const temporary = category.id === 'new'; - const [menuOpen, setMenuOpen] = useState(false); + const { setMenuOpen, menuOpen, handleContextMenu, resetPosition, position } = + useContextMenu(); const triggerRef = useRef(null); - const contextMenusEnabled = useFeatureFlag('contextMenus'); const displayed = ( { - if (!contextMenusEnabled) return; - e.preventDefault(); - setMenuOpen(true); - }} + onContextMenu={handleContextMenu} >
setMenuOpen(true)} + onPress={() => { + resetPosition(); + setMenuOpen(true); + }} > setMenuOpen(false)} - style={{ width: 200 }} + style={{ width: 200, margin: 1 }} isNonModal + {...position} > { diff --git a/packages/desktop-client/src/components/budget/SidebarGroup.tsx b/packages/desktop-client/src/components/budget/SidebarGroup.tsx index 6230f35b3b1..87e6eaad4d2 100644 --- a/packages/desktop-client/src/components/budget/SidebarGroup.tsx +++ b/packages/desktop-client/src/components/budget/SidebarGroup.tsx @@ -1,8 +1,9 @@ // @ts-strict-ignore -import React, { type CSSProperties, useRef, useState } from 'react'; +import React, { type CSSProperties, useRef } from 'react'; import { type ConnectDragSource } from 'react-dnd'; import { useTranslation } from 'react-i18next'; +import { useContextMenu } from '../../hooks/useContextMenu'; import { useFeatureFlag } from '../../hooks/useFeatureFlag'; import { SvgExpandArrow } from '../../icons/v0'; import { SvgCheveronDown } from '../../icons/v1'; @@ -58,9 +59,9 @@ export function SidebarGroup({ const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled'); const temporary = group.id === 'new'; - const [menuOpen, setMenuOpen] = useState(false); + const { setMenuOpen, menuOpen, handleContextMenu, resetPosition, position } = + useContextMenu(); const triggerRef = useRef(null); - const contextMenusEnabled = useFeatureFlag('contextMenus'); const displayed = ( { onToggleCollapse(group.id); }} - onContextMenu={e => { - if (!contextMenusEnabled) return; - e.preventDefault(); - setMenuOpen(true); - }} + onContextMenu={handleContextMenu} > {!dragPreview && ( setMenuOpen(true)} + onPress={() => { + resetPosition(); + setMenuOpen(true); + }} style={{ padding: 3 }} > @@ -122,8 +122,9 @@ export function SidebarGroup({ placement="bottom start" isOpen={menuOpen} onOpenChange={() => setMenuOpen(false)} - style={{ width: 200 }} + style={{ width: 200, margin: 1 }} isNonModal + {...position} > { @@ -149,7 +150,7 @@ export function SidebarGroup({ { name: 'rename', text: t('Rename') }, !group.is_income && { name: 'toggle-visibility', - text: group.hidden ? t('Show') : t('Hide'), + text: group.hidden ? 'Show' : 'Hide', }, onDelete && { name: 'delete', text: t('Delete') }, ...(isGoalTemplatesEnabled diff --git a/packages/desktop-client/src/components/budget/envelope/BalanceMenu.tsx b/packages/desktop-client/src/components/budget/envelope/BalanceMenu.tsx index 83244cf4a9b..f9762b5cc86 100644 --- a/packages/desktop-client/src/components/budget/envelope/BalanceMenu.tsx +++ b/packages/desktop-client/src/components/budget/envelope/BalanceMenu.tsx @@ -29,7 +29,8 @@ export function BalanceMenu({ const carryover = useEnvelopeSheetValue( envelopeBudget.catCarryover(categoryId), ); - const balance = useEnvelopeSheetValue(envelopeBudget.catBalance(categoryId)); + const balance = + useEnvelopeSheetValue(envelopeBudget.catBalance(categoryId)) ?? 0; return ( {}, }: BalanceMovementMenuProps) { - const catBalance = useEnvelopeSheetValue( - envelopeBudget.catBalance(categoryId), - ); + const catBalance = + useEnvelopeSheetValue(envelopeBudget.catBalance(categoryId)) ?? 0; + const [menu, _setMenu] = useState('menu'); const ref = useRef(null); diff --git a/packages/desktop-client/src/components/budget/envelope/EnvelopeBudgetComponents.tsx b/packages/desktop-client/src/components/budget/envelope/EnvelopeBudgetComponents.tsx index 78a42e0fd8d..0bb0491a2d8 100644 --- a/packages/desktop-client/src/components/budget/envelope/EnvelopeBudgetComponents.tsx +++ b/packages/desktop-client/src/components/budget/envelope/EnvelopeBudgetComponents.tsx @@ -3,7 +3,6 @@ import React, { type CSSProperties, memo, useRef, - useState, } from 'react'; import { useTranslation, Trans } from 'react-i18next'; @@ -14,7 +13,7 @@ import { evalArithmetic } from 'loot-core/src/shared/arithmetic'; import * as monthUtils from 'loot-core/src/shared/months'; import { integerToCurrency, amountToInteger } from 'loot-core/src/shared/util'; -import { useFeatureFlag } from '../../../hooks/useFeatureFlag'; +import { useContextMenu } from '../../../hooks/useContextMenu'; import { useUndo } from '../../../hooks/useUndo'; import { SvgCheveronDown } from '../../../icons/v1'; import { styles, theme } from '../../../style'; @@ -207,8 +206,20 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({ const budgetMenuTriggerRef = useRef(null); const balanceMenuTriggerRef = useRef(null); - const [budgetMenuOpen, setBudgetMenuOpen] = useState(false); - const [balanceMenuOpen, setBalanceMenuOpen] = useState(false); + const { + setMenuOpen: setBudgetMenuOpen, + menuOpen: budgetMenuOpen, + handleContextMenu: handleBudgetContextMenu, + resetPosition: resetBudgetPosition, + position: budgetPosition, + } = useContextMenu(); + const { + setMenuOpen: setBalanceMenuOpen, + menuOpen: balanceMenuOpen, + handleContextMenu: handleBalanceContextMenu, + resetPosition: resetBalancePosition, + position: balancePosition, + } = useContextMenu(); const onMenuAction = (...args: Parameters) => { onBudgetAction(...args); @@ -216,7 +227,6 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({ }; const { showUndoNotification } = useUndo(); - const contextMenusEnabled = useFeatureFlag('contextMenus'); return ( { - if (!contextMenusEnabled) return; if (editing) return; - e.preventDefault(); - setBudgetMenuOpen(true); + handleBudgetContextMenu(e); }} > {!editing && ( @@ -261,9 +270,11 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({ }} > diff --git a/packages/desktop-client/src/components/common/Popover.tsx b/packages/desktop-client/src/components/common/Popover.tsx index 79fe2e3f8f0..c844c5b9c9b 100644 --- a/packages/desktop-client/src/components/common/Popover.tsx +++ b/packages/desktop-client/src/components/common/Popover.tsx @@ -41,6 +41,7 @@ export const Popover = ({ ...styles.tooltip, ...styles.lightScrollbar, padding: 0, + userSelect: 'none', ...style, })} shouldCloseOnInteractOutside={element => { diff --git a/packages/desktop-client/src/components/filters/FilterExpression.tsx b/packages/desktop-client/src/components/filters/FilterExpression.tsx index 19704668997..d9f4ada4558 100644 --- a/packages/desktop-client/src/components/filters/FilterExpression.tsx +++ b/packages/desktop-client/src/components/filters/FilterExpression.tsx @@ -85,17 +85,19 @@ export function FilterExpression({ {mapField(field, options)} {' '} {friendlyOp(op, null)}{' '} - + {!['onbudget', 'offbudget'].includes(op?.toLocaleLowerCase()) && ( + + )} )}
diff --git a/packages/desktop-client/src/components/filters/FiltersMenu.jsx b/packages/desktop-client/src/components/filters/FiltersMenu.jsx index 2c379293cea..17ce8e7a451 100644 --- a/packages/desktop-client/src/components/filters/FiltersMenu.jsx +++ b/packages/desktop-client/src/components/filters/FiltersMenu.jsx @@ -54,6 +54,7 @@ const filterFields = [ 'cleared', 'reconciled', 'saved', + 'transfer', ].map(field => [field, mapField(field)]); function ConfigureField({ @@ -116,8 +117,18 @@ function ConfigureField({ }} /> ) : ( - titleFirst(mapField(field)) + + {titleFirst(mapField(field))} + )} + @@ -221,6 +232,7 @@ function ConfigureField({ } value={value} multi={op === 'oneOf' || op === 'notOneOf'} + op={op} style={{ marginTop: 10 }} onChange={v => { dispatch({ type: 'set-value', value: v }); diff --git a/packages/desktop-client/src/components/filters/updateFilterReducer.ts b/packages/desktop-client/src/components/filters/updateFilterReducer.ts index 97721973312..3b8d92b67b2 100644 --- a/packages/desktop-client/src/components/filters/updateFilterReducer.ts +++ b/packages/desktop-client/src/components/filters/updateFilterReducer.ts @@ -19,7 +19,9 @@ export function updateFilterReducer( action.op === 'is' || action.op === 'doesNotContain' || action.op === 'isNot' || - action.op === 'hasTags') + action.op === 'hasTags' || + action.op === 'onBudget' || + action.op === 'offBudget') ) { // Clear out the value if switching between contains or // is/oneof for the id or string type diff --git a/packages/desktop-client/src/components/manager/BudgetList.tsx b/packages/desktop-client/src/components/manager/BudgetList.tsx index 72a3ebab0c0..12defd2e4dd 100644 --- a/packages/desktop-client/src/components/manager/BudgetList.tsx +++ b/packages/desktop-client/src/components/manager/BudgetList.tsx @@ -64,9 +64,11 @@ function getFileDescription(file: File, t: (key: string) => string) { function FileMenu({ onDelete, onClose, + onDuplicate, }: { onDelete: () => void; onClose: () => void; + onDuplicate?: () => void; }) { function onMenuSelect(type: string) { onClose(); @@ -75,18 +77,30 @@ function FileMenu({ case 'delete': onDelete(); break; + case 'duplicate': + if (onDuplicate) onDuplicate(); + break; default: } } const { t } = useTranslation(); - const items = [{ name: 'delete', text: t('Delete') }]; + const items = [ + ...(onDuplicate ? [{ name: 'duplicate', text: t('Duplicate') }] : []), + { name: 'delete', text: t('Delete') }, + ]; return ; } -function FileMenuButton({ onDelete }: { onDelete: () => void }) { +function FileMenuButton({ + onDelete, + onDuplicate, +}: { + onDelete: () => void; + onDuplicate?: () => void; +}) { const triggerRef = useRef(null); const [menuOpen, setMenuOpen] = useState(false); @@ -108,7 +122,11 @@ function FileMenuButton({ onDelete }: { onDelete: () => void }) { isOpen={menuOpen} onOpenChange={() => setMenuOpen(false)} > - setMenuOpen(false)} /> + setMenuOpen(false)} + onDuplicate={onDuplicate} + /> ); @@ -169,11 +187,13 @@ function FileItem({ quickSwitchMode, onSelect, onDelete, + onDuplicate, }: { file: File; quickSwitchMode: boolean; onSelect: (file: File) => void; onDelete: (file: File) => void; + onDuplicate: (file: File) => void; }) { const { t } = useTranslation(); @@ -239,7 +259,10 @@ function FileItem({ )} {!quickSwitchMode && ( - onDelete(file)} /> + onDelete(file)} + onDuplicate={'id' in file ? () => onDuplicate(file) : undefined} + /> )} @@ -252,11 +275,13 @@ function BudgetFiles({ quickSwitchMode, onSelect, onDelete, + onDuplicate, }: { files: File[]; quickSwitchMode: boolean; onSelect: (file: File) => void; onDelete: (file: File) => void; + onDuplicate: (file: File) => void; }) { function isLocalFile(file: File): file is LocalFile { return file.state === 'local'; @@ -292,6 +317,7 @@ function BudgetFiles({ quickSwitchMode={quickSwitchMode} onSelect={onSelect} onDelete={onDelete} + onDuplicate={onDuplicate} /> )) )} @@ -467,7 +493,19 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) { files={files} quickSwitchMode={quickSwitchMode} onSelect={onSelect} - onDelete={file => dispatch(pushModal('delete-budget', { file }))} + onDelete={(file: File) => + dispatch(pushModal('delete-budget', { file })) + } + onDuplicate={(file: File) => { + if (file && 'id' in file) { + dispatch(pushModal('duplicate-budget', { file, managePage: true })); + } else { + console.error( + 'Attempted to duplicate a cloud file - only local files are supported. Cloud file:', + file, + ); + } + }} /> {!quickSwitchMode && ( { + await onCreateTestFile(); + navigate('/'); + }} > {t('Create test file')} diff --git a/packages/desktop-client/src/components/manager/ManagementApp.jsx b/packages/desktop-client/src/components/manager/ManagementApp.tsx similarity index 94% rename from packages/desktop-client/src/components/manager/ManagementApp.jsx rename to packages/desktop-client/src/components/manager/ManagementApp.tsx index fc0dfc2aeb6..194ecadd1ba 100644 --- a/packages/desktop-client/src/components/manager/ManagementApp.jsx +++ b/packages/desktop-client/src/components/manager/ManagementApp.tsx @@ -2,11 +2,7 @@ import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Navigate, Route, Routes } from 'react-router-dom'; -import { - getUserData, - loadAllFiles, - setAppState, -} from 'loot-core/client/actions'; +import { loggedIn, setAppState } from 'loot-core/client/actions'; import { useMetaThemeColor } from '../../hooks/useMetaThemeColor'; import { theme } from '../../style'; @@ -55,7 +51,9 @@ function Version() { export function ManagementApp() { const { isNarrowWidth } = useResponsive(); - useMetaThemeColor(isNarrowWidth ? theme.mobileConfigServerViewTheme : null); + useMetaThemeColor( + isNarrowWidth ? theme.mobileConfigServerViewTheme : undefined, + ); const files = useSelector(state => state.budgets.allFiles); const isLoading = useSelector(state => state.app.loadingText !== null); @@ -69,16 +67,12 @@ export function ManagementApp() { // runs on mount only useEffect(() => { async function fetchData() { - const userData = await dispatch(getUserData()); - if (userData) { - await dispatch(loadAllFiles()); - } - + await dispatch(loggedIn()); dispatch(setAppState({ managerHasInitialized: true })); } fetchData(); - }, []); + }, [dispatch]); return ( diff --git a/packages/desktop-client/src/components/mobile/MobileBackButton.tsx b/packages/desktop-client/src/components/mobile/MobileBackButton.tsx index 7e1c5bd6387..1338d153d93 100644 --- a/packages/desktop-client/src/components/mobile/MobileBackButton.tsx +++ b/packages/desktop-client/src/components/mobile/MobileBackButton.tsx @@ -1,5 +1,7 @@ import React, { type ComponentPropsWithoutRef } from 'react'; +import { t } from 'i18next'; + import { useNavigate } from '../../hooks/useNavigate'; import { SvgCheveronLeft } from '../../icons/v1'; import { styles } from '../../style'; @@ -35,7 +37,7 @@ export function MobileBackButton({ marginRight: 5, }} > - Back + {t('Back')} ); diff --git a/packages/desktop-client/src/components/mobile/MobileNavTabs.tsx b/packages/desktop-client/src/components/mobile/MobileNavTabs.tsx index 220812f73e6..b40f2bf1c51 100644 --- a/packages/desktop-client/src/components/mobile/MobileNavTabs.tsx +++ b/packages/desktop-client/src/components/mobile/MobileNavTabs.tsx @@ -1,7 +1,7 @@ -// @ts-strict-ignore import React, { + useCallback, + type ComponentProps, type ComponentType, - useEffect, type CSSProperties, } from 'react'; import { NavLink } from 'react-router-dom'; @@ -9,7 +9,6 @@ import { useSpring, animated, config } from 'react-spring'; import { useDrag } from '@use-gesture/react'; -import { usePrevious } from '../../hooks/usePrevious'; import { SvgAdd, SvgCog, @@ -23,16 +22,20 @@ import { SvgCalendar } from '../../icons/v2'; import { theme, styles } from '../../style'; import { View } from '../common/View'; import { useResponsive } from '../responsive/ResponsiveProvider'; -import { useScroll } from '../ScrollProvider'; +import { useScrollListener } from '../ScrollProvider'; const COLUMN_COUNT = 3; const PILL_HEIGHT = 15; const ROW_HEIGHT = 70; +const TOTAL_HEIGHT = ROW_HEIGHT * COLUMN_COUNT; +const OPEN_FULL_Y = 1; +const OPEN_DEFAULT_Y = TOTAL_HEIGHT - ROW_HEIGHT; +const HIDDEN_Y = TOTAL_HEIGHT; + export const MOBILE_NAV_HEIGHT = ROW_HEIGHT + PILL_HEIGHT; export function MobileNavTabs() { const { isNarrowWidth } = useResponsive(); - const { scrollY } = useScroll(); const navTabStyle = { flex: `1 1 ${100 / COLUMN_COUNT}%`, @@ -40,6 +43,43 @@ export function MobileNavTabs() { padding: 10, }; + const [{ y }, api] = useSpring(() => ({ y: OPEN_DEFAULT_Y })); + + const openFull = useCallback( + ({ canceled }: { canceled?: boolean }) => { + // when cancel is true, it means that the user passed the upwards threshold + // so we change the spring config to create a nice wobbly effect + api.start({ + y: OPEN_FULL_Y, + immediate: false, + config: canceled ? config.wobbly : config.stiff, + }); + }, + [api, OPEN_FULL_Y], + ); + + const openDefault = useCallback( + (velocity = 0) => { + api.start({ + y: OPEN_DEFAULT_Y, + immediate: false, + config: { ...config.stiff, velocity }, + }); + }, + [api, OPEN_DEFAULT_Y], + ); + + const hide = useCallback( + (velocity = 0) => { + api.start({ + y: HIDDEN_Y, + immediate: false, + config: { ...config.stiff, velocity }, + }); + }, + [api, HIDDEN_Y], + ); + const navTabs = [ { name: 'Budget', @@ -89,60 +129,22 @@ export function MobileNavTabs() { style: navTabStyle, Icon: SvgCog, }, - ].map(tab => ); + ].map(tab => ( + openDefault()} {...tab} /> + )); const bufferTabsCount = COLUMN_COUNT - (navTabs.length % COLUMN_COUNT); const bufferTabs = Array.from({ length: bufferTabsCount }).map((_, idx) => (
)); - const totalHeight = ROW_HEIGHT * COLUMN_COUNT; - const openY = 0; - const closeY = totalHeight - ROW_HEIGHT; - const hiddenY = totalHeight; - - const [{ y }, api] = useSpring(() => ({ y: totalHeight })); - - const open = ({ canceled }) => { - // when cancel is true, it means that the user passed the upwards threshold - // so we change the spring config to create a nice wobbly effect - api.start({ - y: openY, - immediate: false, - config: canceled ? config.wobbly : config.stiff, - }); - }; - - const close = (velocity = 0) => { - api.start({ - y: closeY, - immediate: false, - config: { ...config.stiff, velocity }, - }); - }; - - const hide = (velocity = 0) => { - api.start({ - y: hiddenY, - immediate: false, - config: { ...config.stiff, velocity }, - }); - }; - - const previousScrollY = usePrevious(scrollY); - - useEffect(() => { - if ( - scrollY && - previousScrollY && - scrollY > previousScrollY && - previousScrollY !== 0 - ) { + useScrollListener(({ isScrolling }) => { + if (isScrolling('down')) { hide(); - } else { - close(); + } else if (isScrolling('up')) { + openDefault(); } - }, [scrollY]); + }); const bind = useDrag( ({ @@ -163,9 +165,9 @@ export function MobileNavTabs() { // the threshold for it to close, or if we reset it to its open position if (last) { if (oy > ROW_HEIGHT * 0.5 || (vy > 0.5 && dy > 0)) { - close(vy); + openDefault(vy); } else { - open({ canceled }); + openFull({ canceled }); } } else { // when the user keeps dragging, we just move the sheet according to @@ -176,7 +178,7 @@ export function MobileNavTabs() { { from: () => [0, y.get()], filterTaps: true, - bounds: { top: -totalHeight, bottom: totalHeight - ROW_HEIGHT }, + bounds: { top: -TOTAL_HEIGHT, bottom: TOTAL_HEIGHT - ROW_HEIGHT }, axis: 'y', rubberband: true, }, @@ -192,7 +194,7 @@ export function MobileNavTabs() { backgroundColor: theme.mobileNavBackground, borderTop: `1px solid ${theme.menuBorder}`, ...styles.shadow, - height: totalHeight + PILL_HEIGHT, + height: TOTAL_HEIGHT + PILL_HEIGHT, width: '100%', position: 'fixed', zIndex: 100, @@ -216,7 +218,7 @@ export function MobileNavTabs() { style={{ flexDirection: 'row', flexWrap: 'wrap', - height: totalHeight, + height: TOTAL_HEIGHT, width: '100%', }} > @@ -237,9 +239,10 @@ type NavTabProps = { path: string; Icon: ComponentType; style?: CSSProperties; + onClick: ComponentProps['onClick']; }; -function NavTab({ Icon: TabIcon, name, path, style }: NavTabProps) { +function NavTab({ Icon: TabIcon, name, path, style, onClick }: NavTabProps) { return ( {name} diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx index eaf49332fb9..e0d0ccccf28 100644 --- a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx +++ b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx @@ -3,13 +3,10 @@ import React, { useCallback, useEffect, useMemo, - useRef, useState, } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { useDebounceCallback } from 'usehooks-ts'; - import { collapseModals, getPayees, @@ -21,11 +18,14 @@ import { updateAccount, } from 'loot-core/client/actions'; import { + accountSchedulesQuery, SchedulesProvider, - useDefaultSchedulesQueryTransform, } from 'loot-core/client/data-hooks/schedules'; +import { + useTransactions, + useTransactionsSearch, +} from 'loot-core/client/data-hooks/transactions'; import * as queries from 'loot-core/client/queries'; -import { type PagedQuery, pagedQuery } from 'loot-core/client/query-helpers'; import { listen, send } from 'loot-core/platform/client/fetch'; import { type Query } from 'loot-core/shared/query'; import { isPreviewId } from 'loot-core/shared/transactions'; @@ -34,10 +34,10 @@ import { type TransactionEntity, } from 'loot-core/types/models'; +import { useAccountPreviewTransactions } from '../../../hooks/useAccountPreviewTransactions'; import { useDateFormat } from '../../../hooks/useDateFormat'; import { useFailedAccounts } from '../../../hooks/useFailedAccounts'; import { useNavigate } from '../../../hooks/useNavigate'; -import { usePreviewTransactions } from '../../../hooks/usePreviewTransactions'; import { styles, theme } from '../../../style'; import { Button } from '../../common/Button2'; import { Text } from '../../common/Text'; @@ -56,7 +56,11 @@ export function AccountTransactions({ readonly accountId?: string; readonly accountName: string; }) { - const schedulesTransform = useDefaultSchedulesQueryTransform(accountId); + const schedulesQuery = useMemo( + () => accountSchedulesQuery(accountId), + [accountId], + ); + return ( } - rightContent={} + rightContent={ + + } /> } padding={0} > - + { - dispatch(updateAccount(account)); - }; + const onSave = useCallback( + (account: AccountEntity) => { + dispatch(updateAccount(account)); + }, + [dispatch], + ); - const onSaveNotes = async (id: string, notes: string) => { + const onSaveNotes = useCallback(async (id: string, notes: string) => { await send('notes-save', { id, note: notes }); - }; + }, []); - const onEditNotes = (id: string) => { - dispatch( - pushModal('notes', { - id: `account-${id}`, - name: account.name, - onSave: onSaveNotes, - }), - ); - }; + const onEditNotes = useCallback( + (id: string) => { + dispatch( + pushModal('notes', { + id: `account-${id}`, + name: account.name, + onSave: onSaveNotes, + }), + ); + }, + [account.name, dispatch, onSaveNotes], + ); - const onCloseAccount = () => { + const onCloseAccount = useCallback(() => { dispatch(openAccountCloseModal(account.id)); - }; + }, [account.id, dispatch]); - const onReopenAccount = () => { + const onReopenAccount = useCallback(() => { dispatch(reopenAccount(account.id)); - }; + }, [account.id, dispatch]); - const onClick = () => { + const onClick = useCallback(() => { dispatch( pushModal('account-menu', { accountId: account.id, @@ -135,7 +147,15 @@ function AccountHeader({ account }: { readonly account: AccountEntity }) { onReopenAccount, }), ); - }; + }, [ + account.id, + dispatch, + onCloseAccount, + onEditNotes, + onReopenAccount, + onSave, + ]); + return ( (); - const [isSearching, setIsSearching] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [transactions, setTransactions] = useState< - ReadonlyArray - >([]); - const prependTransactions = usePreviewTransactions(); - const allTransactions = useMemo( + const baseTransactionsQuery = useCallback( () => - !isSearching ? prependTransactions.concat(transactions) : transactions, - [isSearching, prependTransactions, transactions], + queries.transactions(accountId).options({ splits: 'none' }).select('*'), + [accountId], + ); + + const [transactionsQuery, setTransactionsQuery] = useState( + baseTransactionsQuery(), ); + const { + transactions, + isLoading, + reload: reloadTransactions, + isLoadingMore, + loadMore: loadMoreTransactions, + } = useTransactions({ + query: transactionsQuery, + }); + + const { previewTransactions } = useAccountPreviewTransactions({ + accountId: account?.id || '', + }); const dateFormat = useDateFormat() || 'MM/dd/yyyy'; const dispatch = useDispatch(); const navigate = useNavigate(); - const onRefresh = () => { - dispatch(syncAndDownload(accountId)); - }; - - const makeRootQuery = useCallback( - () => queries.makeTransactionsQuery(accountId).options({ splits: 'none' }), - [accountId], - ); - - const paged = useRef(); - - const updateQuery = useCallback((query: Query) => { - paged.current?.unsubscribe(); - setIsLoading(true); - paged.current = pagedQuery( - query.options({ splits: 'none' }).select('*'), - (data: ReadonlyArray) => { - setTransactions(data); - setIsLoading(false); - }, - { pageCount: 50 }, - ); - }, []); - - const fetchTransactions = useCallback(() => { - const query = makeRootQuery(); - setCurrentQuery(query); - updateQuery(query); - }, [makeRootQuery, updateQuery]); + const onRefresh = useCallback(() => { + if (accountId) { + dispatch(syncAndDownload(accountId)); + } + }, [accountId, dispatch]); - const refetchTransactions = () => { - paged.current?.run(); - }; + useEffect(() => { + if (accountId) { + dispatch(markAccountRead(accountId)); + } + }, [accountId, dispatch]); useEffect(() => { - const unlisten = listen('sync-event', ({ type, tables }) => { + return listen('sync-event', ({ type, tables }) => { if (type === 'applied') { if ( tables.includes('transactions') || tables.includes('category_mapping') || tables.includes('payee_mapping') ) { - refetchTransactions(); + reloadTransactions(); } if (tables.includes('payees') || tables.includes('payee_mapping')) { @@ -266,77 +278,57 @@ function TransactionListWithPreviews({ } } }); - - fetchTransactions(); - dispatch(markAccountRead(accountId)); - return () => unlisten(); - }, [accountId, dispatch, fetchTransactions]); - - const updateSearchQuery = useDebounceCallback( - useCallback( - searchText => { - if (searchText === '' && currentQuery) { - updateQuery(currentQuery); - } else if (searchText && currentQuery) { - updateQuery( - queries.makeTransactionSearchQuery( - currentQuery, - searchText, - dateFormat, - ), - ); - } - - setIsSearching(searchText !== ''); - }, - [currentQuery, dateFormat, updateQuery], - ), - 150, + }, [dispatch, reloadTransactions]); + + const { isSearching, search: onSearch } = useTransactionsSearch({ + updateQuery: setTransactionsQuery, + resetQuery: () => setTransactionsQuery(baseTransactionsQuery()), + dateFormat, + }); + + const onOpenTransaction = useCallback( + (transaction: TransactionEntity) => { + if (!isPreviewId(transaction.id)) { + navigate(`/transactions/${transaction.id}`); + } else { + dispatch( + pushModal('scheduled-transaction-menu', { + transactionId: transaction.id, + onPost: async transactionId => { + const parts = transactionId.split('/'); + await send('schedule/post-transaction', { id: parts[1] }); + dispatch(collapseModals('scheduled-transaction-menu')); + }, + onSkip: async transactionId => { + const parts = transactionId.split('/'); + await send('schedule/skip-next-date', { id: parts[1] }); + dispatch(collapseModals('scheduled-transaction-menu')); + }, + }), + ); + } + }, + [dispatch, navigate], ); - const onSearch = (text: string) => { - updateSearchQuery(text); - }; - - const onOpenTransaction = (transaction: TransactionEntity) => { - if (!isPreviewId(transaction.id)) { - navigate(`/transactions/${transaction.id}`); - } else { - dispatch( - pushModal('scheduled-transaction-menu', { - transactionId: transaction.id, - onPost: async transactionId => { - const parts = transactionId.split('/'); - await send('schedule/post-transaction', { id: parts[1] }); - dispatch(collapseModals('scheduled-transaction-menu')); - }, - onSkip: async transactionId => { - const parts = transactionId.split('/'); - await send('schedule/skip-next-date', { id: parts[1] }); - dispatch(collapseModals('scheduled-transaction-menu')); - }, - }), - ); - } - }; - - const onLoadMore = () => { - paged.current?.fetchNext(); - }; - const balanceQueries = useMemo( () => queriesFromAccountId(accountId, account), [accountId, account], ); + const transactionsToDisplay = !isSearching + ? previewTransactions.concat(transactions) + : transactions; + return ( > = { + name: string; + amount: Binding<'account', SheetFieldName>; + style?: CSSProperties; +}; + +function AccountHeader>({ + name, + amount, + style = {}, +}: AccountHeaderProps) { return ( {props => ( - + + {...props} + style={{ ...styles.text, fontSize: 14 }} + /> )} ); } +type AccountCardProps = { + account: AccountEntity; + updated: boolean; + connected: boolean; + pending: boolean; + failed: boolean; + getBalanceQuery: (account: AccountEntity) => Binding<'account', 'balance'>; + onSelect: (id: string) => void; +}; + function AccountCard({ account, updated, @@ -61,7 +88,7 @@ function AccountCard({ failed, getBalanceQuery, onSelect, -}) { +}: AccountCardProps) { return ( - - ); -}); diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx index e151d15b0f2..8005375a412 100644 --- a/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionEdit.jsx @@ -16,6 +16,7 @@ import { parseISO, isValid as isValidDate, } from 'date-fns'; +import { t } from 'i18next'; import { pushModal, setLastTransaction } from 'loot-core/client/actions'; import { runQuery } from 'loot-core/src/client/query-helpers'; @@ -63,6 +64,7 @@ import { MobilePageHeader, Page } from '../../Page'; import { AmountInput } from '../../util/AmountInput'; import { MobileBackButton } from '../MobileBackButton'; import { FieldLabel, TapField, InputField, ToggleField } from '../MobileForms'; +import { getPrettyPayee } from '../utils'; import { FocusableAmountInput } from './FocusableAmountInput'; @@ -70,18 +72,6 @@ function getFieldName(transactionId, field) { return `${field}-${transactionId}`; } -export function getDescriptionPretty(transaction, payee, transferAcct) { - const { amount } = transaction; - - if (transferAcct) { - return `Transfer ${amount > 0 ? 'from' : 'to'} ${transferAcct.name}`; - } else if (payee) { - return payee.name; - } - - return ''; -} - function serializeTransaction(transaction, dateFormat) { const { date, amount } = transaction; return { @@ -166,7 +156,7 @@ export function Status({ status, isSplit }) { function Footer({ transactions, - adding, + isAdding, onAdd, onSave, onSplit, @@ -242,7 +232,7 @@ function Footer({ Select account - ) : adding ? ( + ) : isAdding ? ( @@ -440,7 +436,7 @@ const ChildTransactionEdit = forwardRef( ChildTransactionEdit.displayName = 'ChildTransactionEdit'; const TransactionEditInner = memo(function TransactionEditInner({ - adding, + isAdding, accounts, categories, payees, @@ -483,10 +479,10 @@ const TransactionEditInner = memo(function TransactionEditInner({ const isInitialMount = useInitialMount(); useEffect(() => { - if (isInitialMount && adding) { + if (isInitialMount && isAdding) { onTotalAmountEdit(); } - }, [adding, isInitialMount, onTotalAmountEdit]); + }, [isAdding, isInitialMount, onTotalAmountEdit]); const getAccount = useCallback( trans => { @@ -502,7 +498,7 @@ const TransactionEditInner = memo(function TransactionEditInner({ [payeesById], ); - const getTransferAcct = useCallback( + const getTransferAccount = useCallback( trans => { const payee = trans && getPayee(trans); return payee?.transfer_acct && accountsById?.[payee.transfer_acct]; @@ -510,24 +506,12 @@ const TransactionEditInner = memo(function TransactionEditInner({ [accountsById, getPayee], ); - const getPrettyPayee = useCallback( - trans => { - if (trans?.is_parent) { - return 'Split'; - } - const transPayee = trans && getPayee(trans); - const transTransferAcct = trans && getTransferAcct(trans); - return getDescriptionPretty(trans, transPayee, transTransferAcct); - }, - [getPayee, getTransferAcct], - ); - const isBudgetTransfer = useCallback( trans => { - const transferAcct = trans && getTransferAcct(trans); + const transferAcct = trans && getTransferAccount(trans); return transferAcct && !transferAcct.offbudget; }, - [getTransferAcct], + [getTransferAccount], ); const getCategory = useCallback( @@ -548,24 +532,12 @@ const TransactionEditInner = memo(function TransactionEditInner({ const onConfirmSave = () => { let transactionsToSave = unserializedTransactions; - if (adding) { + if (isAdding) { transactionsToSave = realizeTempTransactions(unserializedTransactions); } onSave(transactionsToSave); - - if (adding || hasAccountChanged.current) { - const { account: accountId } = unserializedTransaction; - const account = accountsById?.[accountId]; - if (account) { - navigate(`/accounts/${account.id}`); - } else { - // Handle the case where account is undefined - navigate(-1); - } - } else { - navigate(-1); - } + navigate(-1); }; if (unserializedTransaction.reconciled) { @@ -582,14 +554,7 @@ const TransactionEditInner = memo(function TransactionEditInner({ } else { onConfirmSave(); } - }, [ - accountsById, - adding, - dispatch, - navigate, - onSave, - unserializedTransactions, - ]); + }, [isAdding, dispatch, navigate, onSave, unserializedTransactions]); const onUpdateInner = useCallback( async (serializedTransaction, name, value) => { @@ -758,11 +723,11 @@ const TransactionEditInner = memo(function TransactionEditInner({ const account = getAccount(transaction); const isOffBudget = account && !!account.offbudget; - const title = getDescriptionPretty( + const title = getPrettyPayee({ transaction, - getPayee(transaction), - getTransferAcct(transaction), - ); + payee: getPayee(transaction), + transferAccount: getTransferAccount(transaction), + }); const transactionDate = parseDate(transaction.date, dateFormat, new Date()); const dateDefaultValue = monthUtils.dayFromDate(transactionDate); @@ -773,7 +738,7 @@ const TransactionEditInner = memo(function TransactionEditInner({ - + - + - + - Split + {t('Split')} )} - + - + {transaction.reconciled ? ( - + ) : ( - + - + - {!adding && ( + {!isAdding && ( @@ -1057,8 +1023,8 @@ function TransactionEditUnconnected({ const dispatch = useDispatch(); const [transactions, setTransactions] = useState([]); const [fetchedTransactions, setFetchedTransactions] = useState([]); - const adding = useRef(false); - const deleted = useRef(false); + const isAdding = useRef(false); + const isDeleted = useRef(false); useEffect(() => { let unmounted = false; @@ -1089,7 +1055,7 @@ function TransactionEditUnconnected({ if (transactionId !== 'new') { fetchTransaction(); } else { - adding.current = true; + isAdding.current = true; } return () => { @@ -1098,7 +1064,7 @@ function TransactionEditUnconnected({ }, [transactionId]); useEffect(() => { - if (adding.current) { + if (isAdding.current) { setTransactions( makeTemporaryTransactions( locationState?.accountId || lastTransaction?.account || null, @@ -1167,7 +1133,7 @@ function TransactionEditUnconnected({ const onSave = useCallback( async newTransactions => { - if (deleted.current) { + if (isDeleted.current) { return; } @@ -1191,7 +1157,7 @@ function TransactionEditUnconnected({ // } } - if (adding.current) { + if (isAdding.current) { // The first one is always the "parent" and the only one we care // about dispatch(setLastTransaction(newTransactions[0])); @@ -1204,9 +1170,9 @@ function TransactionEditUnconnected({ async id => { const changes = deleteTransaction(transactions, id); - if (adding.current) { + if (isAdding.current) { // Adding a new transactions, this disables saving when the component unmounts - deleted.current = true; + isDeleted.current = true; } else { const _remoteUpdates = await send('transactions-batch-update', { deleted: changes.diff.deleted, @@ -1259,7 +1225,7 @@ function TransactionEditUnconnected({ > + + + ); +} + export function TransactionList({ isLoading, transactions, - isNewTransaction, onOpenTransaction, - scrollProps = {}, + isLoadingMore, onLoadMore, }) { + const { t } = useTranslation(); + const sections = useMemo(() => { // Group by date. We can assume transactions is ordered const sections = []; @@ -55,22 +74,14 @@ export function TransactionList({ sections.length === 0 || transaction.date !== sections[sections.length - 1].date ) { - // Mark the last transaction in the section so it can render - // with a different border - const lastSection = sections[sections.length - 1]; - if (lastSection && lastSection.data.length > 0) { - const lastData = lastSection.data; - lastData[lastData.length - 1].isLast = true; - } - sections.push({ id: `${isPreviewId(transaction.id) ? 'preview/' : ''}${transaction.date}`, date: transaction.date, - data: [], + transactions: [], }); } - sections[sections.length - 1].data.push(transaction); + sections[sections.length - 1].transactions.push(transaction); }); return sections; }, [transactions]); @@ -78,93 +89,96 @@ export function TransactionList({ const dispatchSelected = useSelectedDispatch(); const selectedTransactions = useSelectedItems(); - const onTransactionPress = (transaction, isLongPress = false) => { - const isPreview = isPreviewId(transaction.id); + const onTransactionPress = useCallback( + (transaction, isLongPress = false) => { + const isPreview = isPreviewId(transaction.id); + if (!isPreview && (isLongPress || selectedTransactions.size > 0)) { + dispatchSelected({ type: 'select', id: transaction.id }); + } else { + onOpenTransaction(transaction); + } + }, + [dispatchSelected, onOpenTransaction, selectedTransactions], + ); - if (!isPreview && (isLongPress || selectedTransactions.size > 0)) { - dispatchSelected({ type: 'select', id: transaction.id }); - } else { - onOpenTransaction(transaction); + useScrollListener(({ hasScrolledToEnd }) => { + if (hasScrolledToEnd('down', 100)) { + onLoadMore?.(); } - }; + }); if (isLoading) { - return ( - - - - ); + return ; } return ( <> - {scrollProps.ListHeaderComponent} 0 ? 'multiple' : 'single'} + selectedKeys={selectedTransactions} + dependencies={[selectedTransactions]} + renderEmptyState={() => ( + + No transactions + + )} + items={sections} > - {sections.length === 0 ? ( + {section => (
- -
- No transactions -
-
-
- ) : null} - {sections.map(section => { - return ( -
{monthUtils.format(section.date, 'MMMM dd, yyyy')} - } - key={section.id} +
- {section.data.map((transaction, index, transactions) => { - if (isPreviewId(transaction.id) && transaction.is_child) { - return null; - } - - return ( - - onTransactionPress(trans)} - onLongPress={trans => onTransactionPress(trans, true)} - /> - - ); - })} -
- ); - })} + {monthUtils.format(section.date, 'MMMM dd, yyyy')} + + !isPreviewId(t.id) || !t.is_child, + )} + addIdAndValue + > + {transaction => ( + onTransactionPress(trans)} + onLongPress={trans => onTransactionPress(trans, true)} + /> + )} + + + )}
+ + {isLoadingMore && ( + + )} + {selectedTransactions.size > 0 && ( )} diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionListItem.tsx b/packages/desktop-client/src/components/mobile/transactions/TransactionListItem.tsx new file mode 100644 index 00000000000..f46037d140a --- /dev/null +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionListItem.tsx @@ -0,0 +1,264 @@ +import React, { + type CSSProperties, + type ComponentPropsWithoutRef, +} from 'react'; +import { mergeProps } from 'react-aria'; +import { ListBoxItem } from 'react-aria-components'; +import { useSelector } from 'react-redux'; + +import { + PressResponder, + usePress, + useLongPress, +} from '@react-aria/interactions'; + +import { isPreviewId } from 'loot-core/src/shared/transactions'; +import { integerToCurrency } from 'loot-core/src/shared/util'; +import { type TransactionEntity } from 'loot-core/types/models'; + +import { useAccount } from '../../../hooks/useAccount'; +import { useCategories } from '../../../hooks/useCategories'; +import { usePayee } from '../../../hooks/usePayee'; +import { SvgSplit } from '../../../icons/v0'; +import { + SvgArrowsSynchronize, + SvgCheckCircle1, + SvgLockClosed, +} from '../../../icons/v2'; +import { styles, theme } from '../../../style'; +import { makeAmountFullStyle } from '../../budget/util'; +import { Button } from '../../common/Button2'; +import { Text } from '../../common/Text'; +import { TextOneLine } from '../../common/TextOneLine'; +import { View } from '../../common/View'; +import { getPrettyPayee } from '../utils'; + +import { lookupName, Status } from './TransactionEdit'; + +const ROW_HEIGHT = 60; + +type TransactionListItemProps = ComponentPropsWithoutRef< + typeof ListBoxItem +> & { + isNewTransaction: (transaction: TransactionEntity['id']) => boolean; + onPress: (transaction: TransactionEntity) => void; + onLongPress: (transaction: TransactionEntity) => void; +}; + +export function TransactionListItem({ + onPress, + onLongPress, + ...props +}: TransactionListItemProps) { + const { list: categories } = useCategories(); + + const { value: transaction } = props; + + const payee = usePayee(transaction?.payee || ''); + const account = useAccount(transaction?.account || ''); + const transferAccount = useAccount(payee?.transfer_acct || ''); + const isPreview = isPreviewId(transaction?.id || ''); + + const newTransactions = useSelector(state => state.queries.newTransactions); + + const { longPressProps } = useLongPress({ + accessibilityDescription: 'Long press to select multiple transactions', + onLongPress: () => { + if (isPreview) { + return; + } + + onLongPress(transaction!); + }, + }); + + const { pressProps } = usePress({ + onPress: () => { + onPress(transaction!); + }, + }); + + if (!transaction) { + return null; + } + + const { + id, + amount, + category: categoryId, + cleared: isCleared, + reconciled: isReconciled, + is_parent: isParent, + is_child: isChild, + schedule: scheduleId, + } = transaction; + + const isAdded = newTransactions.includes(id); + const categoryName = lookupName(categories, categoryId); + const prettyPayee = getPrettyPayee({ + transaction, + payee, + transferAccount, + }); + const specialCategory = account?.offbudget + ? 'Off Budget' + : transferAccount && !transferAccount.offbudget + ? 'Transfer' + : isParent + ? 'Split' + : null; + + const prettyCategory = specialCategory || categoryName; + + const textStyle: CSSProperties = { + ...styles.text, + fontSize: 14, + ...(isPreview + ? { + fontStyle: 'italic', + color: theme.pageTextLight, + } + : {}), + }; + + return ( + + {({ isSelected }) => ( + + + + )} + + ); +} diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx index 4a03b2c8fed..b86c131a901 100644 --- a/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionListWithBalances.jsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; -import { useSelector } from 'react-redux'; + +import { t } from 'i18next'; import { SelectedProvider, useSelected } from '../../../hooks/useSelected'; import { SvgSearchAlternate } from '../../../icons/v2'; @@ -64,16 +65,11 @@ export function TransactionListWithBalances({ balanceUncleared, searchPlaceholder = 'Search...', onSearch, + isLoadingMore, onLoadMore, onOpenTransaction, onRefresh, }) { - const newTransactions = useSelector(state => state.queries.newTransactions); - - const isNewTransaction = id => { - return newTransactions.includes(id); - }; - const selectedInst = useSelected('transactions', transactions); return ( @@ -109,7 +105,7 @@ export function TransactionListWithBalances({ @@ -129,7 +125,10 @@ function BalanceWithCleared({ balanceUncleared, balanceCleared, balance }) { flexBasis: '33%', }} > -