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/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.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/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/src/components/ManageRules.tsx b/packages/desktop-client/src/components/ManageRules.tsx index bb9ce717ae5..915db97975d 100644 --- a/packages/desktop-client/src/components/ManageRules.tsx +++ b/packages/desktop-client/src/components/ManageRules.tsx @@ -9,6 +9,8 @@ import React, { } from 'react'; 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 +23,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 +114,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(); diff --git a/packages/desktop-client/src/components/accounts/Account.tsx b/packages/desktop-client/src/components/accounts/Account.tsx index 37936e10c7d..1771ca3168c 100644 --- a/packages/desktop-client/src/components/accounts/Account.tsx +++ b/packages/desktop-client/src/components/accounts/Account.tsx @@ -19,10 +19,14 @@ import { type UndoState } from 'loot-core/server/undo'; import { useFilters } from 'loot-core/src/client/data-hooks/filters'; import { SchedulesProvider, - useDefaultSchedulesQueryTransform, + accountSchedulesQuery, } from 'loot-core/src/client/data-hooks/schedules'; import * as queries from 'loot-core/src/client/queries'; -import { runQuery, pagedQuery } from 'loot-core/src/client/query-helpers'; +import { + runQuery, + pagedQuery, + type PagedQuery, +} from 'loot-core/src/client/query-helpers'; import { send, listen } from 'loot-core/src/platform/client/fetch'; import { currentDay } from 'loot-core/src/shared/months'; import { q, type Query } from 'loot-core/src/shared/query'; @@ -212,7 +216,7 @@ function AllTransactions({ return balances; }, [filtered, prependBalances, balances]); - if (!prependTransactions) { + if (!prependTransactions?.length || filtered) { return children(transactions, balances); } return children(allTransactions, allBalances); @@ -240,7 +244,7 @@ function getField(field?: string) { } type AccountInternalProps = { - accountId?: string; + accountId?: AccountEntity['id'] | 'budgeted' | 'offbudget' | 'uncategorized'; filterConditions: RuleConditionEntity[]; showBalances?: boolean; setShowBalances: (newValue: boolean) => void; @@ -322,7 +326,7 @@ class AccountInternal extends PureComponent< AccountInternalProps, AccountInternalState > { - paged: ReturnType | null; + paged: PagedQuery | null; rootQuery: Query; currentQuery: Query; table: TableRef; @@ -457,7 +461,7 @@ class AccountInternal extends PureComponent< } fetchAllIds = async () => { - const { data } = await runQuery(this.paged?.getQuery().select('id')); + const { data } = await runQuery(this.paged?.query.select('id')); // Remember, this is the `grouped` split type so we need to deal // with the `subtransactions` property return data.reduce((arr: string[], t: TransactionEntity) => { @@ -472,7 +476,7 @@ class AccountInternal extends PureComponent< }; fetchTransactions = (filterConditions?: ConditionEntity[]) => { - const query = this.makeRootQuery(); + const query = this.makeRootTransactionsQuery(); this.rootQuery = this.currentQuery = query; if (filterConditions) this.applyFilters(filterConditions); else this.updateQuery(query); @@ -482,10 +486,10 @@ class AccountInternal extends PureComponent< } }; - makeRootQuery = () => { + makeRootTransactionsQuery = () => { const accountId = this.props.accountId; - return queries.makeTransactionsQuery(accountId); + return queries.transactions(accountId); }; updateQuery(query: Query, isFiltered: boolean = false) { @@ -502,12 +506,9 @@ class AccountInternal extends PureComponent< query = query.filter({ reconciled: { $eq: false } }); } - this.paged = pagedQuery( - query.select('*'), - async ( - data: TransactionEntity[], - prevData: TransactionEntity[] | null, - ) => { + this.paged = pagedQuery(query.select('*'), { + onData: async (groupedData, prevData) => { + const data = ungroupTransactions([...groupedData]); const firstLoad = prevData == null; if (firstLoad) { @@ -529,7 +530,7 @@ class AccountInternal extends PureComponent< this.setState( { transactions: data, - transactionCount: this.paged?.getTotalCount(), + transactionCount: this.paged?.totalCount, transactionsFiltered: isFiltered, loading: false, workingHard: false, @@ -549,12 +550,11 @@ class AccountInternal extends PureComponent< }, ); }, - { + options: { pageCount: 150, onlySync: true, - mapper: ungroupTransactions, }, - ); + }); } UNSAFE_componentWillReceiveProps(nextProps: AccountInternalProps) { @@ -590,7 +590,7 @@ class AccountInternal extends PureComponent< ); } else { this.updateQuery( - queries.makeTransactionSearchQuery( + queries.transactionsSearch( this.currentQuery, this.state.search, this.props.dateFormat, @@ -652,27 +652,19 @@ class AccountInternal extends PureComponent< ); }; - onTransactionsChange = ( - newTransaction: TransactionEntity, - data: TransactionEntity[], - ) => { + onTransactionsChange = (updatedTransaction: TransactionEntity) => { // Apply changes to pagedQuery data - this.paged?.optimisticUpdate( - (data: TransactionEntity[]) => { - if (newTransaction._deleted) { - return data.filter(t => t.id !== newTransaction.id); - } else { - return data.map(t => { - return t.id === newTransaction.id ? newTransaction : t; - }); - } - }, - () => { - return data; - }, - ); + this.paged?.optimisticUpdate(data => { + if (updatedTransaction._deleted) { + return data.filter(t => t.id !== updatedTransaction.id); + } else { + return data.map(t => { + return t.id === updatedTransaction.id ? updatedTransaction : t; + }); + } + }); - this.props.updateNewTransactions(newTransaction.id); + this.props.updateNewTransactions(updatedTransaction.id); }; canCalculateBalance = () => { @@ -696,8 +688,7 @@ class AccountInternal extends PureComponent< } const { data } = await runQuery( - this.paged - ?.getQuery() + this.paged?.query .options({ splits: 'none' }) .select([{ balance: { $sumOver: '$amount' } }]), ); @@ -862,13 +853,13 @@ class AccountInternal extends PureComponent< getBalanceQuery(id?: string) { return { name: `balance-query-${id}`, - query: this.makeRootQuery().calculate({ $sum: '$amount' }), + query: this.makeRootTransactionsQuery().calculate({ $sum: '$amount' }), } as const; } getFilteredAmount = async () => { const { data: amount } = await runQuery( - this.paged?.getQuery().calculate({ $sum: '$amount' }), + this.paged?.query.calculate({ $sum: '$amount' }), ); return amount; }; @@ -1896,10 +1887,13 @@ export function Account() { const savedFiters = useFilters(); const actionCreators = useActions(); - const transform = useDefaultSchedulesQueryTransform(params.id); + const schedulesQuery = useMemo( + () => accountSchedulesQuery(params.id), + [params.id], + ); return ( - + diff --git a/packages/desktop-client/src/components/accounts/Balance.jsx b/packages/desktop-client/src/components/accounts/Balance.jsx index fadd74d9c23..741a1fa96aa 100644 --- a/packages/desktop-client/src/components/accounts/Balance.jsx +++ b/packages/desktop-client/src/components/accounts/Balance.jsx @@ -68,8 +68,13 @@ function SelectedBalance({ selectedItems, account }) { }); let scheduleBalance = null; - const scheduleData = useCachedSchedules(); - const schedules = scheduleData ? scheduleData.schedules : []; + + const { isLoading, schedules = [] } = useCachedSchedules(); + + if (isLoading) { + return null; + } + const previewIds = [...selectedItems] .filter(id => isPreviewId(id)) .map(id => id.slice(8)); diff --git a/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx b/packages/desktop-client/src/components/mobile/accounts/AccountTransactions.tsx index eaf49332fb9..3dda43be537 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,15 @@ import { updateAccount, } from 'loot-core/client/actions'; import { + accountSchedulesQuery, SchedulesProvider, - useDefaultSchedulesQueryTransform, } from 'loot-core/client/data-hooks/schedules'; +import { + usePreviewTransactions, + 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'; @@ -37,7 +38,6 @@ import { 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 ( - + { - 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 +145,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, + loadMore: loadMoreTransactions, + } = useTransactions({ + query: transactionsQuery, + }); + + const { data: previewTransactions, isLoading: isPreviewTransactionsLoading } = + usePreviewTransactions(); 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 +274,56 @@ 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 ( {accounts.length === 0 && } - + {budgetedAccounts.length > 0 && ( )} diff --git a/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.jsx b/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.jsx index 917121fbe8e..c03f1676451 100644 --- a/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.jsx +++ b/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.jsx @@ -1,11 +1,12 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; -import { useDebounceCallback } from 'usehooks-ts'; - import { getPayees } from 'loot-core/client/actions'; +import { + useTransactions, + useTransactionsSearch, +} from 'loot-core/client/data-hooks/transactions'; import * as queries from 'loot-core/client/queries'; -import { pagedQuery } from 'loot-core/client/query-helpers'; import { listen } from 'loot-core/platform/client/fetch'; import * as monthUtils from 'loot-core/shared/months'; import { q } from 'loot-core/shared/query'; @@ -23,99 +24,64 @@ import { TransactionListWithBalances } from '../transactions/TransactionListWith export function CategoryTransactions({ category, month }) { const dispatch = useDispatch(); const navigate = useNavigate(); - const [isLoading, setIsLoading] = useState(true); - const [currentQuery, setCurrentQuery] = useState(); - const [transactions, setTransactions] = useState([]); - - const dateFormat = useDateFormat() || 'MM/dd/yyyy'; - const makeRootQuery = useCallback( + const baseTransactionsQuery = useCallback( () => q('transactions') .options({ splits: 'inline' }) - .filter(getCategoryMonthFilter(category, month)), + .filter(getCategoryMonthFilter(category, month)) + .select('*'), [category, month], ); - const paged = useRef(null); - - const updateQuery = useCallback(query => { - paged.current?.unsubscribe(); - setIsLoading(true); - paged.current = pagedQuery( - query.options({ splits: 'inline' }).select('*'), - data => { - setTransactions(data); - setIsLoading(false); - }, - { pageCount: 50 }, - ); - }, []); + const [transactionsQuery, setTransactionsQuery] = useState( + baseTransactionsQuery(), + ); + const { + transactions, + isLoading, + loadMore: loadMoreTransactions, + reload: reloadTransactions, + } = useTransactions({ + query: transactionsQuery, + }); - const fetchTransactions = useCallback(async () => { - const query = makeRootQuery(); - setCurrentQuery(query); - updateQuery(query); - }, [makeRootQuery, updateQuery]); + const dateFormat = useDateFormat() || 'MM/dd/yyyy'; useEffect(() => { - function setup() { - return listen('sync-event', ({ type, tables }) => { - if (type === 'applied') { - if ( - tables.includes('transactions') || - tables.includes('category_mapping') || - tables.includes('payee_mapping') - ) { - paged.current?.run(); - } - - if (tables.includes('payees') || tables.includes('payee_mapping')) { - dispatch(getPayees()); - } + return listen('sync-event', ({ type, tables }) => { + if (type === 'applied') { + if ( + tables.includes('transactions') || + tables.includes('category_mapping') || + tables.includes('payee_mapping') + ) { + reloadTransactions?.(); } - }); - } - fetchTransactions(); - return setup(); - }, [dispatch, fetchTransactions]); - - const updateSearchQuery = useDebounceCallback( - useCallback( - searchText => { - if (searchText === '' && currentQuery) { - updateQuery(currentQuery); - } else if (searchText && currentQuery) { - updateQuery( - queries.makeTransactionSearchQuery( - currentQuery, - searchText, - dateFormat, - ), - ); + if (tables.includes('payees') || tables.includes('payee_mapping')) { + dispatch(getPayees()); } - }, - [currentQuery, dateFormat, updateQuery], - ), - 150, + } + }); + }, [dispatch, reloadTransactions]); + + const { search: onSearch } = useTransactionsSearch({ + updateQuery: setTransactionsQuery, + resetQuery: () => setTransactionsQuery(baseTransactionsQuery()), + dateFormat, + }); + + const onOpenTransaction = useCallback( + transaction => { + // details of how the native app used to handle preview transactions here can be found at commit 05e58279 + if (!isPreviewId(transaction.id)) { + navigate(`/transactions/${transaction.id}`); + } + }, + [navigate], ); - const onSearch = text => { - updateSearchQuery(text); - }; - - const onLoadMore = () => { - paged.current?.fetchNext(); - }; - - const onOpenTranasction = transaction => { - // details of how the native app used to handle preview transactions here can be found at commit 05e58279 - if (!isPreviewId(transaction.id)) { - navigate(`/transactions/${transaction.id}`); - } - }; - const balance = queries.categoryBalance(category, month); const balanceCleared = queries.categoryBalanceCleared(category, month); const balanceUncleared = queries.categoryBalanceUncleared(category, month); @@ -146,8 +112,8 @@ export function CategoryTransactions({ category, month }) { balanceUncleared={balanceUncleared} searchPlaceholder={`Search ${category.name}`} onSearch={onSearch} - onLoadMore={onLoadMore} - onOpenTransaction={onOpenTranasction} + onLoadMore={loadMoreTransactions} + onOpenTransaction={onOpenTransaction} /> ); diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx b/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx index 195fa4cb32c..c76ed124b9d 100644 --- a/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionList.jsx @@ -44,7 +44,6 @@ export function TransactionList({ transactions, isNewTransaction, onOpenTransaction, - scrollProps = {}, onLoadMore, }) { const sections = useMemo(() => { @@ -105,11 +104,8 @@ export function TransactionList({ return ( <> - {scrollProps.ListHeaderComponent} diff --git a/packages/desktop-client/src/components/modals/EditRuleModal.jsx b/packages/desktop-client/src/components/modals/EditRuleModal.jsx index abd673d1304..952c736074d 100644 --- a/packages/desktop-client/src/components/modals/EditRuleModal.jsx +++ b/packages/desktop-client/src/components/modals/EditRuleModal.jsx @@ -1,10 +1,4 @@ -import React, { - useState, - useEffect, - useRef, - useCallback, - useMemo, -} from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { css } from '@emotion/css'; @@ -309,20 +303,26 @@ function formatAmount(amount) { function ScheduleDescription({ id }) { const dateFormat = useDateFormat() || 'MM/dd/yyyy'; - const scheduleData = useSchedules({ - transform: useCallback(q => q.filter({ id }), [id]), - }); + const scheduleQuery = useMemo( + () => q('schedules').filter({ id }).select('*'), + [id], + ); + const { + schedules, + statuses: scheduleStatuses, + isLoading: isSchedulesLoading, + } = useSchedules({ query: scheduleQuery }); - if (scheduleData == null) { + if (isSchedulesLoading) { return null; } - if (scheduleData.schedules.length === 0) { + if (schedules.length === 0) { return {id}; } - const [schedule] = scheduleData.schedules; - const status = schedule && scheduleData.statuses.get(schedule.id); + const [schedule] = schedules; + const status = schedule && scheduleStatuses.get(schedule.id); return ( diff --git a/packages/desktop-client/src/components/modals/ScheduledTransactionMenuModal.tsx b/packages/desktop-client/src/components/modals/ScheduledTransactionMenuModal.tsx index b815c846124..bf0c83d4d75 100644 --- a/packages/desktop-client/src/components/modals/ScheduledTransactionMenuModal.tsx +++ b/packages/desktop-client/src/components/modals/ScheduledTransactionMenuModal.tsx @@ -1,12 +1,12 @@ import React, { - useCallback, + useMemo, type ComponentPropsWithoutRef, type CSSProperties, } from 'react'; import { useSchedules } from 'loot-core/client/data-hooks/schedules'; import { format } from 'loot-core/shared/months'; -import { type Query } from 'loot-core/shared/query'; +import { q } from 'loot-core/shared/query'; import { theme, styles } from '../../style'; import { Menu } from '../common/Menu'; @@ -33,24 +33,26 @@ export function ScheduledTransactionMenuModal({ borderTop: `1px solid ${theme.pillBorder}`, }; const scheduleId = transactionId?.split('/')?.[1]; - const scheduleData = useSchedules({ - transform: useCallback( - (q: Query) => q.filter({ id: scheduleId }), - [scheduleId], - ), + const schedulesQuery = useMemo( + () => q('schedules').filter({ id: scheduleId }).select('*'), + [scheduleId], + ); + const { isLoading: isSchedulesLoading, schedules } = useSchedules({ + query: schedulesQuery, }); - const schedule = scheduleData?.schedules?.[0]; - if (!schedule) { + if (isSchedulesLoading) { return null; } + const schedule = schedules?.[0]; + return ( {({ state: { close } }) => ( <> } + title={} rightContent={} /> - {format(schedule.next_date, 'MMMM dd, yyyy')} + {format(schedule?.next_date || '', 'MMMM dd, yyyy')} q('schedules').select('*'), []); + const { schedules = [], isLoading } = useSchedules({ query: schedulesQuery }); + + if (isLoading) { + return ( + + + + ); + } return ( dispatch({ type: 'set-transactions', transactions: data }), + { + onData: data => + dispatch({ type: 'set-transactions', transactions: data }), + }, ); return live.unsubscribe; } @@ -337,7 +340,10 @@ export function ScheduleDetails({ id, transaction }) { .filter({ $and: filters }) .select('*') .options({ splits: 'all' }), - data => dispatch({ type: 'set-transactions', transactions: data }), + { + onData: data => + dispatch({ type: 'set-transactions', transactions: data }), + }, ); unsubscribe = live.unsubscribe; } diff --git a/packages/desktop-client/src/components/schedules/ScheduleLink.tsx b/packages/desktop-client/src/components/schedules/ScheduleLink.tsx index 2497457a9f6..a787fe9fe6e 100644 --- a/packages/desktop-client/src/components/schedules/ScheduleLink.tsx +++ b/packages/desktop-client/src/components/schedules/ScheduleLink.tsx @@ -1,12 +1,12 @@ // @ts-strict-ignore -import React, { useCallback, useRef, useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { pushModal } from 'loot-core/client/actions'; import { useSchedules } from 'loot-core/src/client/data-hooks/schedules'; import { send } from 'loot-core/src/platform/client/fetch'; -import { type Query } from 'loot-core/src/shared/query'; +import { q } from 'loot-core/src/shared/query'; import { type ScheduleEntity, type TransactionEntity, @@ -37,17 +37,17 @@ export function ScheduleLink({ const dispatch = useDispatch(); const [filter, setFilter] = useState(accountName || ''); - - const scheduleData = useSchedules({ - transform: useCallback((q: Query) => q.filter({ completed: false }), []), - }); + const schedulesQuery = useMemo( + () => q('schedules').filter({ completed: false }).select('*'), + [], + ); + const { + isLoading: isSchedulesLoading, + schedules, + statuses, + } = useSchedules({ query: schedulesQuery }); const searchInput = useRef(null); - if (scheduleData == null) { - return null; - } - - const { schedules, statuses } = scheduleData; async function onSelect(scheduleId: string) { if (ids?.length > 0) { @@ -131,6 +131,7 @@ export function ScheduleLink({ }} > { + const items: readonly SchedulesTableItem[] = useMemo(() => { const unCompletedSchedules = filteredSchedules.filter(s => !s.completed); if (!allowCompleted) { @@ -425,6 +427,7 @@ export function SchedulesTable({ {!minimal && } { + dispatch(pushModal('schedule-edit', { id })); + }, + [dispatch], + ); - function onEdit(id: ScheduleEntity['id']) { - pushModal('schedule-edit', { id }); - } + const onAdd = useCallback(() => { + dispatch(pushModal('schedule-edit')); + }, [dispatch]); - function onAdd() { - pushModal('schedule-edit'); - } + const onDiscover = useCallback(() => { + dispatch(pushModal('schedules-discover')); + }, [dispatch]); - function onDiscover() { - pushModal('schedules-discover'); - } + const onAction = useCallback( + async (name: ScheduleItemAction, id: ScheduleEntity['id']) => { + switch (name) { + case 'post-transaction': + await send('schedule/post-transaction', { id }); + break; + case 'skip': + await send('schedule/skip-next-date', { id }); + break; + case 'complete': + await send('schedule/update', { + schedule: { id, completed: true }, + }); + break; + case 'restart': + await send('schedule/update', { + schedule: { id, completed: false }, + resetNextDate: true, + }); + break; + case 'delete': + await send('schedule/delete', { id }); + break; + default: + throw new Error(`Unknown action: ${name}`); + } + }, + [], + ); - async function onAction(name: ScheduleItemAction, id: ScheduleEntity['id']) { - switch (name) { - case 'post-transaction': - await send('schedule/post-transaction', { id }); - break; - case 'skip': - await send('schedule/skip-next-date', { id }); - break; - case 'complete': - await send('schedule/update', { - schedule: { id, completed: true }, - }); - break; - case 'restart': - await send('schedule/update', { - schedule: { id, completed: false }, - resetNextDate: true, - }); - break; - case 'delete': - await send('schedule/delete', { id }); - break; - default: - } - } + const schedulesQuery = useMemo(() => q('schedules').select('*'), []); + const { + isLoading: isSchedulesLoading, + schedules, + statuses, + } = useSchedules({ query: schedulesQuery }); return ( @@ -101,6 +109,7 @@ export function Schedules() { s.id === scheduleId) - : null; const buttonStyle = useMemo( () => ({ @@ -775,6 +770,14 @@ function PayeeIcons({ const transferIconStyle = useMemo(() => ({ width: 10, height: 10 }), []); + const { isLoading, schedules = [] } = useCachedSchedules(); + + if (isLoading) { + return null; + } + + const schedule = scheduleId ? schedules.find(s => s.id === scheduleId) : null; + if (schedule == null && transferAccount == null) { // Neither a valid scheduled transaction nor a transfer. return null; diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx index 5d91b50f8cf..95c73607e91 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'; import { format as formatDate, parse as parseDate } from 'date-fns'; import { v4 as uuidv4 } from 'uuid'; +import { SchedulesProvider } from 'loot-core/src/client/data-hooks/schedules'; import { SpreadsheetProvider } from 'loot-core/src/client/SpreadsheetProvider'; import { generateTransaction, @@ -148,27 +149,29 @@ function LiveTransactionTable(props) { - transactions.map(t => t.id)} - > - - {}} - commonPayees={[]} - payees={payees} - addNotification={n => console.log(n)} - onSave={onSave} - onSplit={onSplit} - onAdd={onAdd} - onAddSplit={onAddSplit} - onCreatePayee={onCreatePayee} - /> - - + + transactions.map(t => t.id)} + > + + {}} + commonPayees={[]} + payees={payees} + addNotification={n => console.log(n)} + onSave={onSave} + onSplit={onSplit} + onAdd={onAdd} + onAddSplit={onAddSplit} + onCreatePayee={onCreatePayee} + /> + + + diff --git a/packages/desktop-client/src/hooks/useNotes.ts b/packages/desktop-client/src/hooks/useNotes.ts index 9819b1f65ce..29789e2ffdd 100644 --- a/packages/desktop-client/src/hooks/useNotes.ts +++ b/packages/desktop-client/src/hooks/useNotes.ts @@ -1,11 +1,11 @@ import { useMemo } from 'react'; -import { useLiveQuery } from 'loot-core/client/query-hooks'; +import { useQuery } from 'loot-core/client/query-hooks'; import { q } from 'loot-core/shared/query'; import { type NoteEntity } from 'loot-core/types/models'; export function useNotes(id: string) { - const data = useLiveQuery( + const { data } = useQuery( () => q('notes').filter({ id }).select('*'), [id], ); diff --git a/packages/desktop-client/src/hooks/usePreviewTransactions.ts b/packages/desktop-client/src/hooks/usePreviewTransactions.ts index 3278237e45a..4deba061452 100644 --- a/packages/desktop-client/src/hooks/usePreviewTransactions.ts +++ b/packages/desktop-client/src/hooks/usePreviewTransactions.ts @@ -6,60 +6,63 @@ import { } from 'loot-core/client/data-hooks/schedules'; import { send } from 'loot-core/platform/client/fetch'; import { ungroupTransactions } from 'loot-core/shared/transactions'; -import { type ScheduleEntity } from 'loot-core/types/models'; +import { + type TransactionEntity, + type ScheduleEntity, +} from 'loot-core/types/models'; -import { type TransactionEntity } from '../../../loot-core/src/types/models/transaction.d'; +import { usePrevious } from './usePrevious'; +/** + * @deprecated Please use `usePreviewTransactions` hook from `loot-core/client/data-hooks/transactions` instead. + */ export function usePreviewTransactions( collapseTransactions?: (ids: string[]) => void, ) { - const scheduleData = useCachedSchedules(); - const [previousScheduleData, setPreviousScheduleData] = - useState>(scheduleData); const [previewTransactions, setPreviewTransactions] = useState< TransactionEntity[] >([]); - if (scheduleData !== previousScheduleData) { - setPreviousScheduleData(scheduleData); + const scheduleData = useCachedSchedules(); + const previousScheduleData = usePrevious(scheduleData); + + if (scheduleData.isLoading) { + return []; + } - if (scheduleData) { - // Kick off an async rules application - const schedules = - scheduleData.schedules.filter(s => - isForPreview(s, scheduleData.statuses), - ) || []; + if (scheduleData && scheduleData !== previousScheduleData) { + // Kick off an async rules application + const schedules = scheduleData.schedules.filter(s => + isForPreview(s, scheduleData.statuses), + ); - const baseTrans = schedules.map(schedule => ({ - id: 'preview/' + schedule.id, - payee: schedule._payee, - account: schedule._account, - amount: schedule._amount, - date: schedule.next_date, - schedule: schedule.id, - })); + const baseTrans = schedules.map(schedule => ({ + id: 'preview/' + schedule.id, + payee: schedule._payee, + account: schedule._account, + amount: schedule._amount, + date: schedule.next_date, + schedule: schedule.id, + })); - Promise.all( - baseTrans.map(transaction => send('rules-run', { transaction })), - ).then(newTrans => { - const withDefaults = newTrans.map(t => ({ - ...t, - category: scheduleData.statuses.get(t.schedule), + Promise.all( + baseTrans.map(transaction => send('rules-run', { transaction })), + ).then(newTrans => { + const withDefaults = newTrans.map(t => ({ + ...t, + category: scheduleData.statuses.get(t.schedule), + schedule: t.schedule, + subtransactions: t.subtransactions?.map((st: TransactionEntity) => ({ + ...st, + id: 'preview/' + st.id, schedule: t.schedule, - subtransactions: t.subtransactions?.map((st: TransactionEntity) => ({ - ...st, - id: 'preview/' + st.id, - schedule: t.schedule, - })), - })); - setPreviewTransactions(ungroupTransactions(withDefaults)); - if (collapseTransactions) { - collapseTransactions(withDefaults.map(t => t.id)); - } - }); - } - - return previewTransactions; + })), + })); + setPreviewTransactions(ungroupTransactions(withDefaults)); + if (collapseTransactions) { + collapseTransactions(withDefaults.map(t => t.id)); + } + }); } return previewTransactions; diff --git a/packages/desktop-client/src/hooks/useSchedules.ts b/packages/desktop-client/src/hooks/useSchedules.ts deleted file mode 100644 index c63ac1eb000..00000000000 --- a/packages/desktop-client/src/hooks/useSchedules.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useQuery } from 'loot-core/client/query-hooks'; -import { q } from 'loot-core/shared/query'; -import { type ScheduleEntity } from 'loot-core/types/models'; - -export function useSchedules() { - return useQuery(() => q('schedules').select('*'), []); -} diff --git a/packages/loot-core/src/client/data-hooks/dashboard.ts b/packages/loot-core/src/client/data-hooks/dashboard.ts index 457f813ac06..58afcf28127 100644 --- a/packages/loot-core/src/client/data-hooks/dashboard.ts +++ b/packages/loot-core/src/client/data-hooks/dashboard.ts @@ -2,19 +2,19 @@ import { useMemo } from 'react'; import { q } from '../../shared/query'; import { type Widget } from '../../types/models'; -import { useLiveQuery } from '../query-hooks'; +import { useQuery } from '../query-hooks'; export function useDashboard() { - const queryData = useLiveQuery( + const { data: queryData, isLoading } = useQuery( () => q('dashboard').select('*'), [], ); return useMemo( () => ({ - isLoading: queryData === null, + isLoading, data: queryData || [], }), - [queryData], + [isLoading, queryData], ); } diff --git a/packages/loot-core/src/client/data-hooks/filters.ts b/packages/loot-core/src/client/data-hooks/filters.ts index 5b77204a6b1..c23bc800b89 100644 --- a/packages/loot-core/src/client/data-hooks/filters.ts +++ b/packages/loot-core/src/client/data-hooks/filters.ts @@ -3,9 +3,9 @@ import { useMemo } from 'react'; import { q } from '../../shared/query'; import { type TransactionFilterEntity } from '../../types/models'; -import { useLiveQuery } from '../query-hooks'; +import { useQuery } from '../query-hooks'; -function toJS(rows) { +function toJS(rows): TransactionFilterEntity[] { const filters = rows.map(row => { return { ...row.fields, @@ -20,16 +20,18 @@ function toJS(rows) { } export function useFilters(): TransactionFilterEntity[] { - const filters = toJS( - useLiveQuery(() => q('transaction_filters').select('*'), []) || [], + const { data } = useQuery( + () => q('transaction_filters').select('*'), + [], ); - /** Sort filters by alphabetical order */ - function sort(filters) { - return filters.sort((a, b) => - a.name.trim().localeCompare(b.name.trim(), { ignorePunctuation: true }), - ); - } - - return useMemo(() => sort(filters), [filters]); + return useMemo( + () => + toJS(data ? [...data] : []).sort((a, b) => + a.name + .trim() + .localeCompare(b.name.trim(), undefined, { ignorePunctuation: true }), + ), + [data], + ); } diff --git a/packages/loot-core/src/client/data-hooks/reports.ts b/packages/loot-core/src/client/data-hooks/reports.ts index ec20be3322a..01f6fdf2df6 100644 --- a/packages/loot-core/src/client/data-hooks/reports.ts +++ b/packages/loot-core/src/client/data-hooks/reports.ts @@ -5,7 +5,7 @@ import { type CustomReportData, type CustomReportEntity, } from '../../types/models'; -import { useLiveQuery } from '../query-hooks'; +import { useQuery } from '../query-hooks'; function toJS(rows: CustomReportData[]) { const reports: CustomReportEntity[] = rows.map(row => { @@ -36,7 +36,7 @@ function toJS(rows: CustomReportData[]) { } export function useReports() { - const queryData = useLiveQuery( + const { data: queryData, isLoading } = useQuery( () => q('custom_reports').select('*'), [], ); @@ -54,10 +54,10 @@ export function useReports() { return useMemo( () => ({ - isLoading: queryData === null, - data: sort(toJS(queryData || [])), + isLoading, + data: sort(toJS(queryData ? [...queryData] : [])), }), - [queryData], + [isLoading, queryData], ); } diff --git a/packages/loot-core/src/client/data-hooks/schedules.tsx b/packages/loot-core/src/client/data-hooks/schedules.tsx index db4c06edf8d..612ff885a3e 100644 --- a/packages/loot-core/src/client/data-hooks/schedules.tsx +++ b/packages/loot-core/src/client/data-hooks/schedules.tsx @@ -3,78 +3,132 @@ import React, { createContext, useContext, useEffect, - useMemo, useState, + useRef, + type PropsWithChildren, } from 'react'; import { useSyncedPref } from '@actual-app/web/src/hooks/useSyncedPref'; import { q, type Query } from '../../shared/query'; import { getHasTransactionsQuery, getStatus } from '../../shared/schedules'; -import { type ScheduleEntity } from '../../types/models'; -import { getAccountFilter } from '../queries'; -import { liveQuery } from '../query-helpers'; +import { + type TransactionEntity, + type ScheduleEntity, + type AccountEntity, +} from '../../types/models'; +import { accountFilter } from '../queries'; +import { type LiveQuery, liveQuery } from '../query-helpers'; export type ScheduleStatusType = ReturnType; export type ScheduleStatuses = Map; -function loadStatuses(schedules: ScheduleEntity[], onData, prefs) { - return liveQuery(getHasTransactionsQuery(schedules), onData, { - mapper: data => { +function loadStatuses( + schedules: readonly ScheduleEntity[], + onData: (data: ScheduleStatuses) => void, + onError: (error: Error) => void, + upcomingLength: string, +) { + return liveQuery(getHasTransactionsQuery(schedules), { + onData: data => { const hasTrans = new Set(data.filter(Boolean).map(row => row.schedule)); - return new Map( + const scheduleStatuses = new Map( schedules.map(s => [ s.id, - getStatus(s.next_date, s.completed, hasTrans.has(s.id), prefs), + getStatus( + s.next_date, + s.completed, + hasTrans.has(s.id), + upcomingLength, + ), ]), - ); + ) as ScheduleStatuses; + + onData?.(scheduleStatuses); }, + onError, }); } -type UseSchedulesArgs = { transform?: (q: Query) => Query }; -type UseSchedulesResult = { - schedules: ScheduleEntity[]; +type UseSchedulesProps = { + query?: Query; +}; +type ScheduleData = { + schedules: readonly ScheduleEntity[]; statuses: ScheduleStatuses; -} | null; +}; +type UseSchedulesResult = ScheduleData & { + readonly isLoading: boolean; + readonly error?: Error; +}; export function useSchedules({ - transform, -}: UseSchedulesArgs = {}): UseSchedulesResult { - const [data, setData] = useState(null); - const upcomingLength = useSyncedPref('upcomingScheduledTransactionLength')[0]; + query, +}: UseSchedulesProps = {}): UseSchedulesResult { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(undefined); + const [data, setData] = useState({ + schedules: [], + statuses: new Map(), + }); + const [upcomingLength] = useSyncedPref('upcomingScheduledTransactionLength'); + + const scheduleQueryRef = useRef | null>(null); + const statusQueryRef = useRef | null>(null); + useEffect(() => { - const query = q('schedules').select('*'); - let statusQuery; - const scheduleQuery = liveQuery( - transform ? transform(query) : query, - async (schedules: ScheduleEntity[]) => { - if (scheduleQuery) { - if (statusQuery) { - statusQuery.unsubscribe(); - } - - statusQuery = loadStatuses( - schedules, - (statuses: ScheduleStatuses) => setData({ schedules, statuses }), - upcomingLength, - ); - } + let isUnmounted = false; + + setError(undefined); + + if (!query) { + return; + } + + function onError(error: Error) { + if (!isUnmounted) { + setError(error); + setIsLoading(false); + } + } + + if (query.state.table !== 'schedules') { + onError(new Error('Query must be a schedules query.')); + return; + } + + setIsLoading(true); + + scheduleQueryRef.current = liveQuery(query, { + onData: async schedules => { + statusQueryRef.current = loadStatuses( + schedules, + (statuses: ScheduleStatuses) => { + if (!isUnmounted) { + setData({ schedules, statuses }); + setIsLoading(false); + } + }, + onError, + upcomingLength, + ); }, - ); + onError, + }); return () => { - if (scheduleQuery) { - scheduleQuery.unsubscribe(); - } - if (statusQuery) { - statusQuery.unsubscribe(); - } + isUnmounted = true; + scheduleQueryRef.current?.unsubscribe(); + statusQueryRef.current?.unsubscribe(); }; - }, [upcomingLength, transform]); + }, [query, upcomingLength]); - return data; + return { + isLoading, + error, + ...data, + }; } type SchedulesContextValue = UseSchedulesResult; @@ -83,8 +137,12 @@ const SchedulesContext = createContext( undefined, ); -export function SchedulesProvider({ transform, children }) { - const data = useSchedules({ transform }); +type SchedulesProviderProps = PropsWithChildren<{ + query?: UseSchedulesProps['query']; +}>; + +export function SchedulesProvider({ query, children }: SchedulesProviderProps) { + const data = useSchedules({ query }); return ( {children} @@ -93,28 +151,36 @@ export function SchedulesProvider({ transform, children }) { } export function useCachedSchedules() { - return useContext(SchedulesContext); + const context = useContext(SchedulesContext); + if (!context) { + throw new Error( + 'useCachedSchedules must be used within a SchedulesProvider', + ); + } + return context; } -export function useDefaultSchedulesQueryTransform(accountId) { - return useMemo(() => { - const filterByAccount = getAccountFilter(accountId, '_account'); - const filterByPayee = getAccountFilter(accountId, '_payee.transfer_acct'); - - return (q: Query) => { - q = q.filter({ - $and: [{ '_account.closed': false }], +export function accountSchedulesQuery( + accountId?: AccountEntity['id'] | 'budgeted' | 'offbudget' | 'uncategorized', +) { + const filterByAccount = accountFilter(accountId, '_account'); + const filterByPayee = accountFilter(accountId, '_payee.transfer_acct'); + + let query = q('schedules') + .select('*') + .filter({ + $and: [{ '_account.closed': false }], + }); + + if (accountId) { + if (accountId === 'uncategorized') { + query = query.filter({ next_date: null }); + } else { + query = query.filter({ + $or: [filterByAccount, filterByPayee], }); - if (accountId) { - if (accountId === 'uncategorized') { - q = q.filter({ next_date: null }); - } else { - q = q.filter({ - $or: [filterByAccount, filterByPayee], - }); - } - } - return q.orderBy({ next_date: 'desc' }); - }; - }, [accountId]); + } + } + + return query.orderBy({ next_date: 'desc' }); } diff --git a/packages/loot-core/src/client/data-hooks/transactions.ts b/packages/loot-core/src/client/data-hooks/transactions.ts new file mode 100644 index 00000000000..81669004c62 --- /dev/null +++ b/packages/loot-core/src/client/data-hooks/transactions.ts @@ -0,0 +1,245 @@ +import { useEffect, useRef, useState, useMemo } from 'react'; + +import debounce from 'lodash/debounce'; + +import { send } from '../../platform/client/fetch'; +import { type Query } from '../../shared/query'; +import { ungroupTransactions } from '../../shared/transactions'; +import { + type ScheduleEntity, + type TransactionEntity, +} from '../../types/models'; +import * as queries from '../queries'; +import { type PagedQuery, pagedQuery } from '../query-helpers'; + +import { type ScheduleStatuses, useCachedSchedules } from './schedules'; + +type UseTransactionsProps = { + query?: Query; + options?: { + pageCount?: number; + }; +}; + +type UseTransactionsResult = { + transactions: ReadonlyArray; + isLoading?: boolean; + error?: Error; + reload?: () => void; + loadMore?: () => void; +}; + +export function useTransactions({ + query, + options = { pageCount: 50 }, +}: UseTransactionsProps): UseTransactionsResult { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(undefined); + const [transactions, setTransactions] = useState< + ReadonlyArray + >([]); + + const pagedQueryRef = useRef | null>(null); + + // We don't want to re-render if options changes. + // Putting options in a ref will prevent that and + // allow us to use the latest options on next render. + const optionsRef = useRef(options); + optionsRef.current = options; + + useEffect(() => { + let isUnmounted = false; + + setError(undefined); + + if (!query) { + return; + } + + function onError(error: Error) { + if (!isUnmounted) { + setError(error); + setIsLoading(false); + } + } + + if (query.state.table !== 'transactions') { + onError(new Error('Query must be a transactions query.')); + return; + } + + setIsLoading(true); + + pagedQueryRef.current = pagedQuery(query, { + onData: data => { + if (!isUnmounted) { + setTransactions(data); + setIsLoading(false); + } + }, + onError, + options: { pageCount: optionsRef.current.pageCount }, + }); + + return () => { + isUnmounted = true; + pagedQueryRef.current?.unsubscribe(); + }; + }, [query]); + + return { + transactions, + isLoading, + error, + reload: pagedQueryRef.current?.run, + loadMore: pagedQueryRef.current?.fetchNext, + }; +} + +type UsePreviewTransactionsResult = { + data: ReadonlyArray; + isLoading: boolean; + error?: Error; +}; + +export function usePreviewTransactions(): UsePreviewTransactionsResult { + const [previewTransactions, setPreviewTransactions] = useState< + TransactionEntity[] + >([]); + const { + isLoading: isSchedulesLoading, + error: scheduleQueryError, + schedules, + statuses, + } = useCachedSchedules(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(undefined); + + const scheduleTransactions = useMemo(() => { + if (isSchedulesLoading) { + return []; + } + + // Kick off an async rules application + const schedulesForPreview = schedules.filter(s => + isForPreview(s, statuses), + ); + + return schedulesForPreview.map(schedule => ({ + id: 'preview/' + schedule.id, + payee: schedule._payee, + account: schedule._account, + amount: schedule._amount, + date: schedule.next_date, + schedule: schedule.id, + })); + }, [isSchedulesLoading, schedules, statuses]); + + useEffect(() => { + let isUnmounted = false; + + setError(undefined); + + if (scheduleTransactions.length === 0) { + setPreviewTransactions([]); + return; + } + + setIsLoading(true); + + Promise.all( + scheduleTransactions.map(transaction => + send('rules-run', { transaction }), + ), + ) + .then(newTrans => { + if (!isUnmounted) { + const withDefaults = newTrans.map(t => ({ + ...t, + category: statuses.get(t.schedule), + schedule: t.schedule, + subtransactions: t.subtransactions?.map( + (st: TransactionEntity) => ({ + ...st, + id: 'preview/' + st.id, + schedule: t.schedule, + }), + ), + })); + + setPreviewTransactions(ungroupTransactions(withDefaults)); + setIsLoading(false); + } + }) + .catch(error => { + if (!isUnmounted) { + setError(error); + setIsLoading(false); + } + }); + + return () => { + isUnmounted = true; + }; + }, [scheduleTransactions, schedules, statuses]); + + return { + data: previewTransactions, + isLoading: isLoading || isSchedulesLoading, + error: error || scheduleQueryError, + }; +} + +type UseTransactionsSearchProps = { + updateQuery: (updateFn: (searchQuery: Query) => Query) => void; + resetQuery: () => void; + dateFormat: string; + delayMs?: number; +}; + +type UseTransactionsSearchResult = { + isSearching: boolean; + search: (searchText: string) => void; +}; + +export function useTransactionsSearch({ + updateQuery, + resetQuery, + dateFormat, + delayMs = 150, +}: UseTransactionsSearchProps): UseTransactionsSearchResult { + const [isSearching, setIsSearching] = useState(false); + + const updateSearchQuery = useMemo( + () => + debounce((searchText: string) => { + if (searchText === '') { + resetQuery(); + setIsSearching(false); + } else if (searchText) { + updateQuery(previousQuery => + queries.transactionsSearch(previousQuery, searchText, dateFormat), + ); + setIsSearching(true); + } + }, delayMs), + [dateFormat, delayMs, resetQuery, updateQuery], + ); + + useEffect(() => { + return () => updateSearchQuery.cancel(); + }, [updateSearchQuery]); + + return { + isSearching, + search: updateSearchQuery, + }; +} + +function isForPreview(schedule: ScheduleEntity, statuses: ScheduleStatuses) { + const status = statuses.get(schedule.id); + return ( + !schedule.completed && + (status === 'due' || status === 'upcoming' || status === 'missed') + ); +} diff --git a/packages/loot-core/src/client/data-hooks/widget.ts b/packages/loot-core/src/client/data-hooks/widget.ts index 5610a29ecad..93297755a83 100644 --- a/packages/loot-core/src/client/data-hooks/widget.ts +++ b/packages/loot-core/src/client/data-hooks/widget.ts @@ -2,19 +2,19 @@ import { useMemo } from 'react'; import { q } from '../../shared/query'; import { type Widget } from '../../types/models'; -import { useLiveQuery } from '../query-hooks'; +import { useQuery } from '../query-hooks'; -export function useWidget(id: string, type: W['type']) { - const data = useLiveQuery( +export function useWidget(id: W['id'], type: W['type']) { + const { data = [], isLoading } = useQuery( () => q('dashboard').filter({ id, type }).select('*'), - [id], + [id, type], ); return useMemo( () => ({ - isLoading: data === null, + isLoading, data: data?.[0], }), - [data], + [data, isLoading], ); } diff --git a/packages/loot-core/src/client/queries.ts b/packages/loot-core/src/client/queries.ts index ab8a45bed29..c36659a1378 100644 --- a/packages/loot-core/src/client/queries.ts +++ b/packages/loot-core/src/client/queries.ts @@ -28,7 +28,10 @@ const accountParametrizedField = parametrizedField<'account'>(); const envelopeParametrizedField = parametrizedField<'envelope-budget'>(); const trackingParametrizedField = parametrizedField<'tracking-budget'>(); -export function getAccountFilter(accountId?: string, field = 'account') { +export function accountFilter( + accountId?: AccountEntity['id'] | 'budgeted' | 'offbudget' | 'uncategorized', + field = 'account', +) { if (accountId) { if (accountId === 'budgeted') { return { @@ -64,10 +67,12 @@ export function getAccountFilter(accountId?: string, field = 'account') { return null; } -export function makeTransactionsQuery(accountId?: string) { +export function transactions( + accountId?: AccountEntity['id'] | 'budgeted' | 'offbudget' | 'uncategorized', +) { let query = q('transactions').options({ splits: 'grouped' }); - const filter = getAccountFilter(accountId); + const filter = accountFilter(accountId); if (filter) { query = query.filter(filter); } @@ -75,7 +80,7 @@ export function makeTransactionsQuery(accountId?: string) { return query; } -export function makeTransactionSearchQuery( +export function transactionsSearch( currentQuery: Query, search: string, dateFormat: SyncedPrefs['dateFormat'], diff --git a/packages/loot-core/src/client/query-helpers.test.ts b/packages/loot-core/src/client/query-helpers.test.ts index 81d703b976f..20179f39e72 100644 --- a/packages/loot-core/src/client/query-helpers.test.ts +++ b/packages/loot-core/src/client/query-helpers.test.ts @@ -137,7 +137,7 @@ describe('query helpers', () => { tracer.start(); const query = q('transactions').select('*'); - doQuery(query, data => tracer.event('data', data)); + doQuery(query, { onData: data => tracer.event('data', data) }); await tracer.expect('server-query'); await tracer.expect('data', ['*']); @@ -153,7 +153,10 @@ describe('query helpers', () => { tracer.start(); const query = q('transactions').select('*'); - doQuery(query, data => tracer.event('data', data), { onlySync: true }); + doQuery(query, { + onData: data => tracer.event('data', data), + options: { onlySync: true }, + }); await tracer.expect('server-query'); await tracer.expect('data', ['*']); @@ -168,7 +171,10 @@ describe('query helpers', () => { tracer.start(); const query = q('transactions').select('*'); - doQuery(query, data => tracer.event('data', data), { onlySync: true }); + doQuery(query, { + onData: data => tracer.event('data', data), + options: { onlySync: true }, + }); await tracer.expect('server-query'); await tracer.expect('data', ['*']); @@ -191,8 +197,9 @@ describe('query helpers', () => { tracer.start(); const query = q('transactions').select('*'); - const lq = doQuery(query, data => tracer.event('data', data), { - onlySync: true, + const lq = doQuery(query, { + onData: data => tracer.event('data', data), + options: { onlySync: true }, }); // Users should never call `run` manually but we'll do it to @@ -220,7 +227,10 @@ describe('query helpers', () => { const query = q('transactions').select('*'); - doQuery(query, data => tracer.event('data', data), { onlySync: true }); + doQuery(query, { + onData: data => tracer.event('data', data), + options: { onlySync: true }, + }); // Send a push in the middle of the query running for the first run serverPush('sync-event', { type: 'success', tables: ['transactions'] }); @@ -241,7 +251,10 @@ describe('query helpers', () => { const query = q('transactions').select('*'); - doQuery(query, data => tracer.event('data', data), { onlySync: true }); + doQuery(query, { + onData: data => tracer.event('data', data), + options: { onlySync: true }, + }); await tracer.expect('server-query'); await tracer.expect('data', ['*']); @@ -263,7 +276,9 @@ describe('query helpers', () => { const query = q('transactions').select('*'); - const lq = doQuery(query, data => tracer.event('data', data)); + const lq = doQuery(query, { + onData: data => tracer.event('data', data), + }); await tracer.expect('server-query'); await tracer.expect('data', ['*']); @@ -282,7 +297,8 @@ describe('query helpers', () => { tracer.start(); const query = q('transactions').select('id'); - const paged = pagedQuery(query, data => tracer.event('data', data), { + const paged = pagedQuery(query, { + onData: data => tracer.event('data', data), onPageData: data => tracer.event('page-data', data), }); @@ -294,7 +310,7 @@ describe('query helpers', () => { expect(d[0].id).toBe(data[0].id); }); - expect(paged.getTotalCount()).toBe(data.length); + expect(paged.totalCount).toBe(data.length); await paged.fetchNext(); tracer.expectNow('server-query', ['id']); @@ -305,7 +321,7 @@ describe('query helpers', () => { tracer.expectNow('data', d => { expect(d.length).toBe(1000); }); - expect(paged.isFinished()).toBe(false); + expect(paged.hasNext).toBe(true); await paged.fetchNext(); tracer.expectNow('server-query', ['id']); @@ -316,7 +332,7 @@ describe('query helpers', () => { tracer.expectNow('data', d => { expect(d.length).toBe(1500); }); - expect(paged.isFinished()).toBe(false); + expect(paged.hasNext).toBe(true); await paged.fetchNext(); tracer.expectNow('server-query', ['id']); @@ -328,8 +344,8 @@ describe('query helpers', () => { expect(d.length).toBe(1502); }); - expect(paged.getData()).toEqual(selectData(data, ['id'])); - expect(paged.isFinished()).toBe(true); + expect(paged.data).toEqual(selectData(data, ['id'])); + expect(paged.hasNext).toBe(false); await paged.fetchNext(); // Wait a bit and make sure nothing comes through @@ -342,8 +358,9 @@ describe('query helpers', () => { tracer.start(); const query = q('transactions').select('id'); - pagedQuery(query, data => tracer.event('data', data), { - pageCount: 10, + pagedQuery(query, { + onData: data => tracer.event('data', data), + options: { pageCount: 10 }, }); await tracer.expect('server-query', [{ result: { $count: '*' } }]); @@ -358,7 +375,9 @@ describe('query helpers', () => { tracer.start(); const query = q('transactions').select('id'); - const paged = pagedQuery(query, data => tracer.event('data', data)); + const paged = pagedQuery(query, { + onData: data => tracer.event('data', data), + }); await tracer.expect('server-query', [{ result: { $count: '*' } }]); await tracer.expect('server-query', ['id']); @@ -382,9 +401,10 @@ describe('query helpers', () => { tracer.start(); const query = q('transactions').select('id'); - const paged = pagedQuery(query, data => tracer.event('data', data), { - pageCount: 20, + const paged = pagedQuery(query, { + onData: data => tracer.event('data', data), onPageData: data => tracer.event('page-data', data), + options: { pageCount: 20 }, }); await tracer.expect('server-query', [{ result: { $count: '*' } }]); @@ -417,9 +437,10 @@ describe('query helpers', () => { it('pagedQuery reruns `fetchNext` if data changed underneath it', async () => { const data = initPagingServer(500, { delay: 10 }); const query = q('transactions').select('id'); - const paged = pagedQuery(query, data => tracer.event('data', data), { - pageCount: 20, + const paged = pagedQuery(query, { + onData: data => tracer.event('data', data), onPageData: data => tracer.event('page-data', data), + options: { pageCount: 20 }, }); await paged.fetchNext(); @@ -460,9 +481,10 @@ describe('query helpers', () => { it('pagedQuery fetches up to a specific row', async () => { const data = initPagingServer(500, { delay: 10, eventType: 'all' }); const query = q('transactions').select(['id', 'date']); - const paged = pagedQuery(query, data => tracer.event('data', data), { - pageCount: 20, + const paged = pagedQuery(query, { + onData: data => tracer.event('data', data), onPageData: data => tracer.event('page-data', data), + options: { pageCount: 20 }, }); await paged.run(); diff --git a/packages/loot-core/src/client/query-helpers.ts b/packages/loot-core/src/client/query-helpers.ts index 8a130d80e0e..c1e63e156c4 100644 --- a/packages/loot-core/src/client/query-helpers.ts +++ b/packages/loot-core/src/client/query-helpers.ts @@ -1,88 +1,144 @@ // @ts-strict-ignore import { listen, send } from '../platform/client/fetch'; import { once } from '../shared/async'; -import { getPrimaryOrderBy } from '../shared/query'; +import { getPrimaryOrderBy, type Query } from '../shared/query'; export async function runQuery(query) { return send('query', query.serialize()); } -export function liveQuery( - query, - onData?: (response: Response) => void, - opts?, -): LiveQuery { - const q = new LiveQuery(query, onData, opts); - q.run(); - return q; +export function liveQuery( + query: Query, + { + onData, + onError, + options = {}, + }: { + onData?: Listener; + onError?: (error: Error) => void; + options?: LiveQueryOptions; + }, +): LiveQuery { + return LiveQuery.runLiveQuery(query, onData, onError, options); } -export function pagedQuery(query, onData?, opts?): PagedQuery { - const q = new PagedQuery(query, onData, opts); - q.run(); - return q; +export function pagedQuery( + query: Query, + { + onData, + onError, + onPageData, + options = {}, + }: { + onData?: Listener; + onError?: (error: Error) => void; + onPageData?: (data: Data) => void; + options?: PagedQueryOptions; + }, +): PagedQuery { + return PagedQuery.runPagedQuery( + query, + onData, + onError, + onPageData, + options, + ); } +type Data = ReadonlyArray; + +type Listener = ( + data: Data, + previousData: Data, +) => void; + +type LiveQueryOptions = { + onlySync?: boolean; +}; + // Subscribe and refetch -export class LiveQuery { - _unsubscribe; - data; - dependencies; - error; - listeners; - mappedData; - mapper; - onlySync; - query; +export class LiveQuery { + private _unsubscribeSyncEvent: () => void | null; + private _data: Data; + private _dependencies: Set; + private _listeners: Array>; + private _supportedSyncTypes: Set; + private _query: Query; + private _onError: (error: Error) => void; + + get query() { + return this._query; + } + + get data() { + return this._data; + } + + private set data(data: Data) { + this._data = data; + } + + get isRunning() { + return this._unsubscribeSyncEvent != null; + } // Async coordination - inflight; - inflightRequestId; - restart; + private _inflightRequestId: number | null; + + protected get inflightRequestId() { + return this._inflightRequestId; + } + + private set inflightRequestId(id: number) { + this._inflightRequestId = id; + } constructor( - query, - onData?: (response: Response) => void, - opts: { mapper?; onlySync?: boolean } = {}, + query: Query, + onData: Listener, + onError?: (error: Error) => void, + options: LiveQueryOptions = {}, ) { - this.error = new Error(); - this.query = query; - this.data = null; - this.mappedData = null; - this.dependencies = null; - this.mapper = opts.mapper; - this.onlySync = opts.onlySync; - this.listeners = []; - - // Async coordination - this.inflight = false; - this.restart = false; + this._query = query; + this._data = null; + this._dependencies = null; + this._listeners = []; + this._onError = onError || (() => {}); + + // TODO: error types? + this._supportedSyncTypes = options.onlySync + ? new Set(['success']) + : new Set(['applied', 'success']); if (onData) { this.addListener(onData); } } - addListener(func) { - this.listeners.push(func); + addListener = (func: Listener) => { + this._listeners.push(func); return () => { - this.listeners = this.listeners.filter(l => l !== func); + this._listeners = this._listeners.filter(l => l !== func); }; - } + }; - onData = (data, prevData) => { - for (let i = 0; i < this.listeners.length; i++) { - this.listeners[i](data, prevData); + protected onData = (data: Data, prevData: Data) => { + for (let i = 0; i < this._listeners.length; i++) { + this._listeners[i](data, prevData); } }; - onUpdate = tables => { + protected onError = (error: Error) => { + this._onError(error); + }; + + protected onUpdate = (tables: string[]) => { // We might not know the dependencies if the first query result // hasn't come back yet if ( - this.dependencies == null || - tables.find(table => this.dependencies.has(table)) + this._dependencies == null || + tables.find(table => this._dependencies.has(table)) ) { this.run(); } @@ -90,51 +146,30 @@ export class LiveQuery { run = () => { this.subscribe(); - return this._fetchData(() => runQuery(this.query)); + return this.fetchData(() => runQuery(this._query)); }; - _fetchData = async makeRequest => { - // TODO: precompile queries, or cache compilation results on the - // backend. could give a query an id which makes it cacheable via - // an LRU cache - - const reqId = Math.random(); - this.inflightRequestId = reqId; - - const { data, dependencies } = await makeRequest(); - - // Regardless if this request was cancelled or not, save the - // dependencies. The query can't change so all requests will - // return the same deps. - if (this.dependencies == null) { - this.dependencies = new Set(dependencies); - } - - // Only fire events if this hasn't been cancelled and if we're - // still subscribed (`this._subscribe` will exist) - if (this.inflightRequestId === reqId && this._unsubscribe) { - const prevData = this.mappedData; - this.data = data; - this.mappedData = this.mapData(data); - this.onData(this.mappedData, prevData); - this.inflightRequestId = null; - } + static runLiveQuery = ( + query: Query, + onData: Listener, + onError: (error: Error) => void, + options: LiveQueryOptions = {}, + ) => { + const liveQuery = new LiveQuery(query, onData, onError, options); + liveQuery.run(); + return liveQuery; }; - subscribe = () => { - if (this._unsubscribe == null) { - this._unsubscribe = listen('sync-event', ({ type, tables }) => { + protected subscribe = () => { + if (this._unsubscribeSyncEvent == null) { + this._unsubscribeSyncEvent = listen('sync-event', ({ type, tables }) => { // If the user is doing optimistic updates, they don't want to // always refetch whenever something changes because it would // refetch all data after they've already updated the UI. This // voids the perf benefits of optimistic updates. Allow querys // to only react to remote syncs. By default, queries will // always update to all changes. - // - // TODO: errors? - const syncTypes = this.onlySync ? ['success'] : ['applied', 'success']; - - if (syncTypes.indexOf(type) !== -1) { + if (this._supportedSyncTypes.has(type)) { this.onUpdate(tables); } }); @@ -142,103 +177,156 @@ export class LiveQuery { }; unsubscribe = () => { - if (this._unsubscribe) { - this._unsubscribe(); - this._unsubscribe = null; + if (this._unsubscribeSyncEvent) { + this._unsubscribeSyncEvent(); + this._unsubscribeSyncEvent = null; } }; - mapData = data => { - if (this.mapper) { - return this.mapper(data); - } - return data; + protected _optimisticUpdate = ( + updateFn: (data: Data) => Data, + ) => { + const previousData = this.data; + this.updateData(updateFn); + this.onData(this.data, previousData); }; - getQuery = () => { - return this.query; + optimisticUpdate = (dataFunc: (data: Data) => Data) => { + this._optimisticUpdate(dataFunc); }; - getData = () => { - return this.mappedData; + protected updateData = ( + updateFn: (data: Data) => Data, + ) => { + this.data = updateFn(this.data); }; - getNumListeners = () => { - return this.listeners.length; - }; + protected fetchData = async ( + runQuery: () => Promise<{ + data: Data; + dependencies: Set; + }>, + ) => { + // TODO: precompile queries, or cache compilation results on the + // backend. could give a query an id which makes it cacheable via + // an LRU cache - isRunning = () => { - return this._unsubscribe != null; - }; + const reqId = Math.random(); + this.inflightRequestId = reqId; - _optimisticUpdate = (dataFunc, mappedDataFunc) => { - this.data = dataFunc(this.data); - this.mappedData = mappedDataFunc(this.mappedData); - }; + try { + const { data, dependencies } = await runQuery(); - optimisticUpdate = (dataFunc, mappedDataFunc) => { - const prevMappedData = this.mappedData; - this._optimisticUpdate(dataFunc, mappedDataFunc); - this.onData(this.mappedData, prevMappedData); + // Regardless if this request was cancelled or not, save the + // dependencies. The query can't change so all requests will + // return the same deps. + if (this._dependencies == null) { + this._dependencies = new Set(dependencies); + } + + // Only fire events if this hasn't been cancelled and if we're + // still subscribed (`this.unsubscribeSyncEvent` will exist) + if (this.inflightRequestId === reqId && this._unsubscribeSyncEvent) { + const previousData = this.data; + this.data = data; + this.onData(this.data, previousData); + this.inflightRequestId = null; + } + } catch (e) { + console.log('Error fetching data', e); + this.onError(e); + } }; } +type PagedQueryOptions = LiveQueryOptions & { + pageCount?: number; +}; + // Paging -export class PagedQuery extends LiveQuery { - done; - onPageData; - pageCount; - runPromise; - totalCount; +export class PagedQuery extends LiveQuery { + private _hasReachedEnd: boolean; + private _onPageData: (data: Data) => void; + private _pageCount: number; + private _fetchDataPromise: Promise | null; + private _totalCount: number; + + get hasNext() { + return !this._hasReachedEnd; + } + + get totalCount() { + return this._totalCount; + } constructor( - query, - onData, - opts: { pageCount?: number; onPageData?; mapper?; onlySync? } = {}, + query: Query, + onData: Listener, + onError?: (error: Error) => void, + onPageData?: (data: Data) => void, + options: PagedQueryOptions = {}, ) { - super(query, onData, opts); - this.totalCount = null; - this.pageCount = opts.pageCount || 500; - this.runPromise = null; - this.done = false; - this.onPageData = opts.onPageData || (() => {}); + super(query, onData, onError, options); + this._totalCount = 0; + this._pageCount = options.pageCount || 500; + this._fetchDataPromise = null; + this._hasReachedEnd = false; + this._onPageData = onPageData || (() => {}); } - _fetchCount = () => { + private fetchCount = () => { return runQuery(this.query.calculate({ $count: '*' })).then(({ data }) => { - this.totalCount = data; + this._totalCount = data; }); }; run = () => { this.subscribe(); - this.runPromise = this._fetchData(async () => { - this.done = false; + this._fetchDataPromise = this.fetchData(async () => { + this._hasReachedEnd = false; // Also fetch the total count - this._fetchCount(); + this.fetchCount(); // If data is null, we haven't fetched anything yet so just // fetch the first page return runQuery( this.query.limit( this.data == null - ? this.pageCount - : Math.max(this.data.length, this.pageCount), + ? this._pageCount + : Math.max(this.data.length, this._pageCount), ), ); }); - return this.runPromise; + return this._fetchDataPromise; + }; + + static runPagedQuery = ( + query: Query, + onData?: Listener, + onError?: (error: Error) => void, + onPageData?: (data: Data) => void, + options: PagedQueryOptions = {}, + ) => { + const pagedQuery = new PagedQuery( + query, + onData, + onError, + onPageData, + options, + ); + pagedQuery.run(); + return pagedQuery; }; refetchUpToRow = async (id, defaultOrderBy) => { - this.runPromise = this._fetchData(async () => { - this.done = false; + this._fetchDataPromise = this.fetchData(async () => { + this._hasReachedEnd = false; // Also fetch the total count - this._fetchCount(); + this.fetchCount(); const orderDesc = getPrimaryOrderBy(this.query, defaultOrderBy); if (orderDesc == null) { @@ -274,7 +362,7 @@ export class PagedQuery extends LiveQuery { [order === 'asc' ? '$gt' : '$lt']: fullRow[field], }, }) - .limit(this.pageCount), + .limit(this._pageCount), ); return { @@ -283,21 +371,25 @@ export class PagedQuery extends LiveQuery { }; }); - return this.runPromise; + return this._fetchDataPromise; + }; + + private onPageData = (data: Data) => { + this._onPageData(data); }; // The public version of this function is created below and // throttled by `once` - _fetchNext = async () => { + private _fetchNext = async () => { while (this.inflightRequestId) { - await this.runPromise; + await this._fetchDataPromise; } const previousData = this.data; - if (!this.done) { + if (!this._hasReachedEnd) { const { data } = await runQuery( - this.query.limit(this.pageCount).offset(previousData.length), + this.query.limit(this._pageCount).offset(previousData.length), ); // If either there is an existing request in flight or the data @@ -308,38 +400,28 @@ export class PagedQuery extends LiveQuery { return this._fetchNext(); } else { if (data.length === 0) { - this.done = true; + this._hasReachedEnd = true; } else { - this.done = data.length < this.pageCount; - this.data = this.data.concat(data); - - const prevData = this.mappedData; - const mapped = this.mapData(data); - this.mappedData = this.mappedData.concat(mapped); - this.onPageData(mapped); - this.onData(this.mappedData, prevData); - } - } - } - }; + this._hasReachedEnd = data.length < this._pageCount; - fetchNext = once(this._fetchNext); + const prevData = this.data; + this.updateData(currentData => currentData.concat(data)); - isFinished = () => { - return this.done; - }; + // Handle newly loaded page data + this.onPageData(data); - getTotalCount = () => { - return this.totalCount; + // Handle entire data + this.onData(this.data, prevData); + } + } + } }; - optimisticUpdate = (dataFunc, mappedDataFunc) => { - const prevData = this.data; - const prevMappedData = this.mappedData; + fetchNext: () => Promise = once(this._fetchNext); - this._optimisticUpdate(dataFunc, mappedDataFunc); - this.totalCount += this.data.length - prevData.length; - - this.onData(this.mappedData, prevMappedData); + optimisticUpdate = (updateFn: (data: Data) => Data) => { + const previousData = this.data; + this._optimisticUpdate(updateFn); + this._totalCount += this.data.length - previousData.length; }; } diff --git a/packages/loot-core/src/client/query-hooks.ts b/packages/loot-core/src/client/query-hooks.ts index f2a338919e0..774a38ac648 100644 --- a/packages/loot-core/src/client/query-hooks.ts +++ b/packages/loot-core/src/client/query-hooks.ts @@ -4,43 +4,47 @@ import { type Query } from '../shared/query'; import { liveQuery, type LiveQuery } from './query-helpers'; -/** @deprecated: please use `useQuery`; usage is the same - only the returned value is different (object instead of only the data) */ -export function useLiveQuery( - makeQuery: () => Query, - deps: DependencyList, -): Response | null { - const { data } = useQuery(makeQuery, deps); - return data; -} +type UseQueryResult = { + data: null | ReadonlyArray; + isLoading: boolean; + error?: Error; +}; export function useQuery( - makeQuery: () => Query, - deps: DependencyList, -): { - data: null | Response; - overrideData: (newData: Response) => void; - isLoading: boolean; -} { - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); + makeQuery: () => Query | null, + dependencies: DependencyList, +): UseQueryResult { + // Memo the resulting query. We don't care if the function + // that creates the query changes, only the resulting query. + // Safe to ignore the eslint warning here. // eslint-disable-next-line react-hooks/exhaustive-deps - const query = useMemo(makeQuery, deps); + const query = useMemo(makeQuery, dependencies); + + const [data, setData] = useState | null>(null); + const [isLoading, setIsLoading] = useState(query !== null); + const [error, setError] = useState(undefined); useEffect(() => { - setIsLoading(true); + setError(query === null ? new Error('Query is null') : undefined); + setIsLoading(query !== null); - let live: null | LiveQuery = liveQuery( - query, - async data => { - if (live) { - setIsLoading(false); + if (!query) { + return; + } + + let isUnmounted = false; + let live: null | LiveQuery = liveQuery(query, { + onData: data => { + if (!isUnmounted) { setData(data); + setIsLoading(false); } }, - ); + onError: setError, + }); return () => { - setIsLoading(false); + isUnmounted = true; live?.unsubscribe(); live = null; }; @@ -48,7 +52,7 @@ export function useQuery( return { data, - overrideData: setData, isLoading, + error, }; } diff --git a/packages/loot-core/src/shared/query.ts b/packages/loot-core/src/shared/query.ts index 0dd129b586b..4b38e62ac8c 100644 --- a/packages/loot-core/src/shared/query.ts +++ b/packages/loot-core/src/shared/query.ts @@ -1,9 +1,16 @@ -// @ts-strict-ignore +import { WithRequired } from '../types/util'; + +type ObjectExpression = { + [key: string]: ObjectExpression | unknown; +}; + export type QueryState = { - filterExpressions: Array; - selectExpressions: Array; - groupExpressions: Array; - orderExpressions: Array; + table: string; + tableOptions: Record; + filterExpressions: Array; + selectExpressions: Array; + groupExpressions: Array; + orderExpressions: Array; calculation: boolean; rawMode: boolean; withDead: boolean; @@ -15,8 +22,9 @@ export type QueryState = { export class Query { state: QueryState; - constructor(state) { + constructor(state: WithRequired, 'table'>) { this.state = { + tableOptions: state.tableOptions || {}, filterExpressions: state.filterExpressions || [], selectExpressions: state.selectExpressions || [], groupExpressions: state.groupExpressions || [], @@ -31,14 +39,22 @@ export class Query { }; } - filter(expr) { + filter(expr: ObjectExpression) { return new Query({ ...this.state, filterExpressions: [...this.state.filterExpressions, expr], }); } - unfilter(exprs) { + unfilter(exprs?: Array) { + // Remove all filters if no arguments are passed + if (!exprs) { + return new Query({ + ...this.state, + filterExpressions: [], + }); + } + const exprSet = new Set(exprs); return new Query({ ...this.state, @@ -48,7 +64,14 @@ export class Query { }); } - select(exprs: Array | unknown = []) { + select( + exprs: + | Array + | ObjectExpression + | string + | '*' + | ['*'] = [], + ) { if (!Array.isArray(exprs)) { exprs = [exprs]; } @@ -58,13 +81,13 @@ export class Query { return query; } - calculate(expr) { + calculate(expr: ObjectExpression | string) { const query = this.select({ result: expr }); query.state.calculation = true; return query; } - groupBy(exprs) { + groupBy(exprs: ObjectExpression | string | Array) { if (!Array.isArray(exprs)) { exprs = [exprs]; } @@ -75,7 +98,7 @@ export class Query { }); } - orderBy(exprs) { + orderBy(exprs: ObjectExpression | string | Array) { if (!Array.isArray(exprs)) { exprs = [exprs]; } @@ -86,11 +109,11 @@ export class Query { }); } - limit(num) { + limit(num: number) { return new Query({ ...this.state, limit: num }); } - offset(num) { + offset(num: number) { return new Query({ ...this.state, offset: num }); } @@ -106,16 +129,23 @@ export class Query { return new Query({ ...this.state, validateRefs: false }); } - options(opts) { + options(opts: Record) { return new Query({ ...this.state, tableOptions: opts }); } + reset() { + return q(this.state.table); + } + serialize() { return this.state; } } -export function getPrimaryOrderBy(query, defaultOrderBy) { +export function getPrimaryOrderBy( + query: Query, + defaultOrderBy: ObjectExpression | null, +) { const orderExprs = query.serialize().orderExpressions; if (orderExprs.length === 0) { if (defaultOrderBy) { @@ -133,6 +163,6 @@ export function getPrimaryOrderBy(query, defaultOrderBy) { return { field, order: firstOrder[field] }; } -export function q(table) { +export function q(table: QueryState['table']) { return new Query({ table }); } diff --git a/upcoming-release-notes/3685.md b/upcoming-release-notes/3685.md new file mode 100644 index 00000000000..4e1528ff2c8 --- /dev/null +++ b/upcoming-release-notes/3685.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [joel-jeremy] +--- + +Create a new useTransactions hook to simplify loading of transactions.