Retrofit useTransactions to use react-query under the hood (#6757)

* Retrofit useTransactions to use react-query under the hood

* Add release notes for PR #6757

* Update packages/desktop-client/src/transactions/queries.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Disable when there is no query parameter

* Fix typecheck errors

* Remove space

* Update tests

* Coderabbit: Add pageSize to query key

* Use isPending instead of isFetching

* Unexport mockStore

* Revert variables

* Change category from Enhancements to Maintenance

Refactor the useTransactions hook to improve data fetching with react-query.

* Fix lint errors

* Fix lint errors

* Migrate setupTests.js to TypeScript with proper types (#6871)

* Initial plan

* Rename setupTests.js to setupTests.ts and add proper types

Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>

* Extract Size type to avoid duplication

Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>

* Add release note for setupTests TypeScript migration

Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>

* Rename release note file to match PR number 6871

Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>

* [autofix.ci] apply automated fixes

* Update transactionQueries

* Delete setupTests PR release note

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Joel Jeremy Marquez
2026-02-11 08:58:07 -08:00
committed by GitHub
parent e3e4b13d2b
commit 37a7d0eccd
28 changed files with 241 additions and 319 deletions

View File

@@ -33,7 +33,7 @@
"@rollup/plugin-inject": "^5.0.5",
"@swc/core": "^1.15.8",
"@swc/helpers": "^0.5.18",
"@tanstack/react-query": "^5.90.5",
"@tanstack/react-query": "^5.90.19",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "16.3.0",

View File

@@ -10,7 +10,7 @@ import type { AccountEntity } from 'loot-core/types/models';
import { ReconcileMenu, ReconcilingMessage } from './Reconcile';
import { useSheetValue } from '@desktop-client/hooks/useSheetValue';
import { TestProvider } from '@desktop-client/redux/mock';
import { TestProviders } from '@desktop-client/mocks';
vi.mock('@desktop-client/hooks/useSheetValue', () => ({
useSheetValue: vi.fn(),
@@ -40,14 +40,14 @@ describe('ReconcilingMessage math & UI', () => {
const onCreateTransaction = vi.fn();
render(
<TestProvider>
<TestProviders>
<ReconcilingMessage
balanceQuery={makeBalanceQuery()}
targetBalance={5000}
onDone={onDone}
onCreateTransaction={onCreateTransaction}
/>
</TestProvider>,
</TestProviders>,
);
expect(screen.getByText('All reconciled!')).toBeInTheDocument();
@@ -67,14 +67,14 @@ describe('ReconcilingMessage math & UI', () => {
const onCreateTransaction = vi.fn();
render(
<TestProvider>
<TestProviders>
<ReconcilingMessage
balanceQuery={makeBalanceQuery()}
targetBalance={10000}
onDone={vi.fn()}
onCreateTransaction={onCreateTransaction}
/>
</TestProvider>,
</TestProviders>,
);
// Formatted amounts present
@@ -95,14 +95,14 @@ describe('ReconcilingMessage math & UI', () => {
const onCreateTransaction = vi.fn();
render(
<TestProvider>
<TestProviders>
<ReconcilingMessage
balanceQuery={makeBalanceQuery()}
targetBalance={10000}
onDone={vi.fn()}
onCreateTransaction={onCreateTransaction}
/>
</TestProvider>,
</TestProviders>,
);
expect(screen.getByText('120.00')).toBeInTheDocument();
@@ -133,13 +133,13 @@ describe('ReconcileMenu arithmetic evaluation', () => {
const onClose = vi.fn();
render(
<TestProvider>
<TestProviders>
<ReconcileMenu
account={baseAccount as AccountEntity}
onReconcile={onReconcile}
onClose={onClose}
/>
</TestProvider>,
</TestProviders>,
);
const input = screen.getByRole('textbox');
@@ -162,13 +162,13 @@ describe('ReconcileMenu arithmetic evaluation', () => {
const onClose = vi.fn();
render(
<TestProvider>
<TestProviders>
<ReconcileMenu
account={baseAccount as AccountEntity}
onReconcile={onReconcile}
onClose={onClose}
/>
</TestProvider>,
</TestProviders>,
);
const input = screen.getByRole('textbox');
@@ -200,13 +200,13 @@ describe('ReconcileMenu arithmetic evaluation', () => {
connectedAccount.balance_current = 4321;
render(
<TestProvider>
<TestProviders>
<ReconcileMenu
account={connectedAccount}
onReconcile={onReconcile}
onClose={onClose}
/>
</TestProvider>,
</TestProviders>,
);
// Fill from last synced value (43.21)
@@ -223,13 +223,13 @@ describe('ReconcileMenu arithmetic evaluation', () => {
const onReconcile = vi.fn();
const onClose = vi.fn();
render(
<TestProvider>
<TestProviders>
<ReconcileMenu
account={baseAccount as AccountEntity}
onReconcile={onReconcile}
onClose={onClose}
/>
</TestProvider>,
</TestProviders>,
);
const input = screen.getByRole('textbox');
@@ -247,13 +247,13 @@ describe('ReconcileMenu arithmetic evaluation', () => {
const onReconcile = vi.fn();
const onClose = vi.fn();
render(
<TestProvider>
<TestProviders>
<ReconcileMenu
account={baseAccount as AccountEntity}
onReconcile={onReconcile}
onClose={onClose}
/>
</TestProvider>,
</TestProviders>,
);
await userEvent.click(screen.getByRole('button', { name: 'Reconcile' }));

View File

@@ -11,7 +11,7 @@ import type { PayeeAutocompleteProps } from './PayeeAutocomplete';
import { AuthProvider } from '@desktop-client/auth/AuthProvider';
import { useCommonPayees } from '@desktop-client/hooks/usePayees';
import { TestProvider } from '@desktop-client/redux/mock';
import { TestProviders } from '@desktop-client/mocks';
const PAYEE_SELECTOR = '[data-testid][role=option]';
const PAYEE_SECTION_SELECTOR = '[data-testid$="-item-group"]';
@@ -74,7 +74,7 @@ function renderPayeeAutocomplete(
};
render(
<TestProvider>
<TestProviders>
<AuthProvider>
<div data-testid="autocomplete-test">
<PayeeAutocomplete
@@ -86,7 +86,7 @@ function renderPayeeAutocomplete(
/>
</div>
</AuthProvider>
</TestProvider>,
</TestProviders>,
);
return screen.getByTestId('autocomplete-test');
}

View File

@@ -86,10 +86,10 @@ function TransactionListWithPreviews({
const {
transactions,
runningBalances,
isLoading: isTransactionsLoading,
reload: reloadTransactions,
isLoadingMore,
loadMore: loadMoreTransactions,
isPending: isTransactionsLoading,
refetch: reloadTransactions,
isFetchingNextPage: isLoadingMoreTransactions,
fetchNextPage: fetchMoreTransactions,
} = useTransactions({
query: transactionsQuery,
options: {
@@ -223,8 +223,8 @@ function TransactionListWithPreviews({
balanceUncleared={balanceBindings.uncleared}
runningBalances={allBalances}
showRunningBalances={shouldCalculateRunningBalances}
isLoadingMore={isLoadingMore}
onLoadMore={loadMoreTransactions}
isLoadingMore={isLoadingMoreTransactions}
onLoadMore={fetchMoreTransactions}
searchPlaceholder={t('Search {{accountName}}', {
accountName: account.name,
})}

View File

@@ -41,10 +41,10 @@ function TransactionListWithPreviews() {
);
const {
transactions,
isLoading: isTransactionsLoading,
reload: reloadTransactions,
isLoadingMore,
loadMore: loadMoreTransactions,
isPending: isTransactionsLoading,
refetch: reloadTransactions,
isFetchingNextPage: isLoadingMoreTransactions,
fetchNextPage: fetchMoreTransactions,
} = useTransactions({
query: transactionsQuery,
});
@@ -144,8 +144,8 @@ function TransactionListWithPreviews() {
}
transactions={transactionsToDisplay}
balance={balanceBindings.balance}
isLoadingMore={isLoadingMore}
onLoadMore={loadMoreTransactions}
isLoadingMore={isLoadingMoreTransactions}
onLoadMore={fetchMoreTransactions}
searchPlaceholder={t('Search All Accounts')}
onSearch={onSearch}
onOpenTransaction={onOpenTransaction}

View File

@@ -43,10 +43,10 @@ function TransactionListWithPreviews() {
);
const {
transactions,
isLoading: isTransactionsLoading,
reload: reloadTransactions,
isLoadingMore,
loadMore: loadMoreTransactions,
isPending: isTransactionsLoading,
refetch: reloadTransactions,
isFetchingNextPage: isLoadingMoreTransactions,
fetchNextPage: fetchMoreTransactions,
} = useTransactions({
query: transactionsQuery,
});
@@ -155,8 +155,8 @@ function TransactionListWithPreviews() {
}
transactions={transactionsToDisplay}
balance={balanceBindings.balance}
isLoadingMore={isLoadingMore}
onLoadMore={loadMoreTransactions}
isLoadingMore={isLoadingMoreTransactions}
onLoadMore={fetchMoreTransactions}
searchPlaceholder={t('Search Off Budget Accounts')}
onSearch={onSearch}
onOpenTransaction={onOpenTransaction}

View File

@@ -43,10 +43,10 @@ function TransactionListWithPreviews() {
);
const {
transactions,
isLoading: isTransactionsLoading,
reload: reloadTransactions,
isLoadingMore,
loadMore: loadMoreTransactions,
isPending: isTransactionsLoading,
refetch: reloadTransactions,
isFetchingNextPage: isLoadingMoreTransactions,
fetchNextPage: fetchMoreTransactions,
} = useTransactions({
query: transactionsQuery,
});
@@ -155,8 +155,8 @@ function TransactionListWithPreviews() {
}
transactions={transactionsToDisplay}
balance={balanceBindings.balance}
isLoadingMore={isLoadingMore}
onLoadMore={loadMoreTransactions}
isLoadingMore={isLoadingMoreTransactions}
onLoadMore={fetchMoreTransactions}
searchPlaceholder={t('Search On Budget Accounts')}
onSearch={onSearch}
onOpenTransaction={onOpenTransaction}

View File

@@ -666,14 +666,14 @@ function UncategorizedTransactionsBanner(props) {
[],
);
const { transactions, isLoading } = useTransactions({
const { transactions, isPending: isTransactionsLoading } = useTransactions({
query: transactionsQuery,
options: {
pageCount: 1000,
pageSize: 1000,
},
});
if (isLoading || transactions.length === 0) {
if (isTransactionsLoading || transactions.length === 0) {
return null;
}

View File

@@ -59,10 +59,10 @@ function TransactionListWithPreviews({
);
const {
transactions,
isLoading: isTransactionsLoading,
isLoadingMore,
loadMore: loadMoreTransactions,
reload: reloadTransactions,
isPending: isTransactionsLoading,
isFetchingNextPage: isLoadingMoreTransactions,
fetchNextPage: fetchMoreTransactions,
refetch: reloadTransactions,
} = useTransactions({
query: transactionsQuery,
});
@@ -130,8 +130,8 @@ function TransactionListWithPreviews({
balanceUncleared={balanceUncleared}
searchPlaceholder={`Search ${category.name}`}
onSearch={onSearch}
isLoadingMore={isLoadingMore}
onLoadMore={loadMoreTransactions}
isLoadingMore={isLoadingMoreTransactions}
onLoadMore={fetchMoreTransactions}
onOpenTransaction={onOpenTransaction}
/>
);

View File

@@ -27,10 +27,10 @@ export function UncategorizedTransactions() {
);
const {
transactions,
isLoading,
isLoadingMore,
loadMore: loadMoreTransactions,
reload: reloadTransactions,
isPending: isTransactionsLoading,
isFetchingNextPage: isLoadingMoreTransactions,
fetchNextPage: fetchMoreTransactions,
refetch: reloadTransactions,
} = useTransactions({
query: transactionsQuery,
});
@@ -73,13 +73,13 @@ export function UncategorizedTransactions() {
return (
<SchedulesProvider>
<TransactionListWithBalances
isLoading={isLoading}
isLoading={isTransactionsLoading}
transactions={transactions}
balance={balance}
searchPlaceholder="Search uncategorized transactions"
onSearch={onSearch}
isLoadingMore={isLoadingMore}
onLoadMore={loadMoreTransactions}
isLoadingMore={isLoadingMoreTransactions}
onLoadMore={fetchMoreTransactions}
onOpenTransaction={onOpenTransaction}
/>
</SchedulesProvider>

View File

@@ -10,7 +10,7 @@ import { MobilePayeesPage } from './MobilePayeesPage';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { usePayeeRuleCounts } from '@desktop-client/hooks/usePayeeRuleCounts';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { TestProvider } from '@desktop-client/redux/mock';
import { TestProviders } from '@desktop-client/mocks';
vi.mock('@use-gesture/react', () => ({
useDrag: vi.fn().mockReturnValue(() => ({})),
@@ -57,9 +57,9 @@ describe('MobilePayeesPage', () => {
const renderPayeesPage = () => {
return render(
<TestProvider>
<TestProviders>
<MobilePayeesPage />
</TestProvider>,
</TestProviders>,
);
};

View File

@@ -3,7 +3,7 @@ import { vi } from 'vitest';
import { GoCardlessExternalMsgModal } from './GoCardlessExternalMsgModal';
import { TestProvider } from '@desktop-client/redux/mock';
import { TestProviders } from '@desktop-client/mocks';
vi.mock('@desktop-client/hooks/useGlobalPref', () => ({
useGlobalPref: () => [null],
@@ -50,9 +50,9 @@ describe('GoCardlessExternalMsgModal - Country Auto-selection', () => {
});
render(
<TestProvider>
<TestProviders>
<GoCardlessExternalMsgModal {...mockProps} />
</TestProvider>,
</TestProviders>,
);
const countryInput = screen.getByPlaceholderText('(please select)');
@@ -76,9 +76,9 @@ describe('GoCardlessExternalMsgModal - Country Auto-selection', () => {
});
render(
<TestProvider>
<TestProviders>
<GoCardlessExternalMsgModal {...mockProps} />
</TestProvider>,
</TestProviders>,
);
const countryInput = screen.getByPlaceholderText('(please select)');
@@ -101,9 +101,9 @@ describe('GoCardlessExternalMsgModal - Country Auto-selection', () => {
});
render(
<TestProvider>
<TestProviders>
<GoCardlessExternalMsgModal {...mockProps} />
</TestProvider>,
</TestProviders>,
);
const countryInput = screen.getByPlaceholderText('(please select)');
@@ -126,9 +126,9 @@ describe('GoCardlessExternalMsgModal - Country Auto-selection', () => {
});
render(
<TestProvider>
<TestProviders>
<GoCardlessExternalMsgModal {...mockProps} />
</TestProvider>,
</TestProviders>,
);
const countryInput = screen.getByPlaceholderText('(please select)');

View File

@@ -6,7 +6,9 @@ import { render, screen } from '@testing-library/react';
import { Change } from './Change';
import { store } from '@desktop-client/redux/store';
import { configureAppStore } from '@desktop-client/redux/store';
const store = configureAppStore();
describe('Change', () => {
it('renders a positive amount with a plus sign and positive color', () => {

View File

@@ -110,8 +110,10 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
const [dirty, setDirty] = useState(false);
const [latestTransaction, setLatestTransaction] = useState('');
const { transactions: transactionsGrouped, loadMore: loadMoreTransactions } =
useTransactions({ query });
const {
transactions: transactionsGrouped,
fetchNextPage: loadMoreTransactions,
} = useTransactions({ query });
const allTransactions = useMemo(
() => ungroupTransactions(transactionsGrouped as TransactionEntity[]),

View File

@@ -8,7 +8,7 @@ import {
useMultiuserEnabled,
} from '@desktop-client/components/ServerContext';
import { useSyncServerStatus } from '@desktop-client/hooks/useSyncServerStatus';
import { TestProvider } from '@desktop-client/redux/mock';
import { TestProviders } from '@desktop-client/mocks';
vi.mock('@desktop-client/hooks/useSyncServerStatus', () => ({
useSyncServerStatus: vi.fn(),
@@ -28,7 +28,7 @@ describe('AuthSettings', () => {
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
vi.mocked(useLoginMethod).mockReturnValue('password');
const { container } = render(<AuthSettings />, { wrapper: TestProvider });
const { container } = render(<AuthSettings />, { wrapper: TestProviders });
expect(container.firstChild).toBeNull();
});
@@ -42,7 +42,7 @@ describe('AuthSettings', () => {
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
vi.mocked(useLoginMethod).mockReturnValue('password');
render(<AuthSettings />, { wrapper: TestProvider });
render(<AuthSettings />, { wrapper: TestProviders });
const startUsingButton = screen.getByRole('button', {
name: /start using openid/i,
@@ -59,7 +59,7 @@ describe('AuthSettings', () => {
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
vi.mocked(useLoginMethod).mockReturnValue('openid');
render(<AuthSettings />, { wrapper: TestProvider });
render(<AuthSettings />, { wrapper: TestProviders });
const disableButton = screen.getByRole('button', {
name: /disable openid/i,
@@ -82,7 +82,7 @@ describe('AuthSettings', () => {
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
vi.mocked(useLoginMethod).mockReturnValue('password');
render(<AuthSettings />, { wrapper: TestProvider });
render(<AuthSettings />, { wrapper: TestProviders });
const startUsingButton = screen.getByRole('button', {
name: /start using openid/i,
@@ -99,7 +99,7 @@ describe('AuthSettings', () => {
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
vi.mocked(useLoginMethod).mockReturnValue('openid');
render(<AuthSettings />, { wrapper: TestProvider });
render(<AuthSettings />, { wrapper: TestProviders });
const disableButton = screen.getByRole('button', {
name: /disable openid/i,
@@ -116,7 +116,7 @@ describe('AuthSettings', () => {
vi.mocked(useMultiuserEnabled).mockReturnValue(true);
vi.mocked(useLoginMethod).mockReturnValue('openid');
render(<AuthSettings />, { wrapper: TestProvider });
render(<AuthSettings />, { wrapper: TestProviders });
const warningText = screen.getByText(
/disabling openid will deactivate multi-user mode\./i,

View File

@@ -33,7 +33,7 @@ import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
import { SelectedProviderWithItems } from '@desktop-client/hooks/useSelected';
import { SplitsExpandedProvider } from '@desktop-client/hooks/useSplitsExpanded';
import { SpreadsheetProvider } from '@desktop-client/hooks/useSpreadsheet';
import { TestProvider } from '@desktop-client/redux/mock';
import { TestProviders } from '@desktop-client/mocks';
vi.mock('loot-core/platform/client/fetch');
vi.mock('../../hooks/useFeatureFlag', () => ({
@@ -195,7 +195,7 @@ function LiveTransactionTable(props: LiveTransactionTableProps) {
// implementation properly uses the right latest state even if the
// hook dependencies haven't changed
return (
<TestProvider>
<TestProviders>
<AuthProvider>
<SpreadsheetProvider>
<SchedulesProvider>
@@ -226,7 +226,7 @@ function LiveTransactionTable(props: LiveTransactionTableProps) {
</SchedulesProvider>
</SpreadsheetProvider>
</AuthProvider>
</TestProvider>
</TestProviders>
);
}

View File

@@ -40,7 +40,7 @@ export function DisplayPayeeProvider({
);
const { transactions: allSubtransactions = [] } = useTransactions({
query: subtransactionsQuery,
options: { pageCount: transactions.length * 5 },
options: { pageSize: transactions.length * 5 },
});
const accounts = useAccounts();

View File

@@ -1,11 +1,16 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import type {
InfiniteData,
UseInfiniteQueryResult,
} from '@tanstack/react-query';
import type { Query } from 'loot-core/shared/query';
import type { IntegerAmount } from 'loot-core/shared/util';
import type { TransactionEntity } from 'loot-core/types/models';
import { pagedQuery } from '@desktop-client/queries/pagedQuery';
import type { PagedQuery } from '@desktop-client/queries/pagedQuery';
import { transactionQueries } from '@desktop-client/transactions';
// Mirrors the `splits` AQL option from the server
type TransactionSplitsOption = 'all' | 'inline' | 'grouped' | 'none';
@@ -35,7 +40,7 @@ type UseTransactionsProps = {
* The default is 50.
* @default 50
*/
pageCount?: number;
pageSize?: number;
/**
* Whether to calculate running balances for the transactions returned by the query.
* This can be set to `true` to calculate running balances for all transactions
@@ -60,7 +65,10 @@ type UseTransactionsProps = {
};
};
type UseTransactionsResult = {
type UseTransactionsResult = UseInfiniteQueryResult<
InfiniteData<TransactionEntity[]>,
Error
> & {
/**
* The transactions returned by the query.
*/
@@ -71,105 +79,26 @@ type UseTransactionsResult = {
* or a function that implements the calculation in the options.
*/
runningBalances: Map<TransactionEntity['id'], IntegerAmount>;
/**
* Whether the transactions are currently being loaded.
*/
isLoading: boolean;
/**
* An error that occurred while loading the transactions.
*/
error?: Error;
/**
* Reload the transactions.
*/
reload: () => void;
/**
* Load more transactions.
*/
loadMore: () => void;
/**
* Whether more transactions are currently being loaded.
*/
isLoadingMore: boolean;
};
export function useTransactions({
query,
options = { pageCount: 50, calculateRunningBalances: false },
options = { pageSize: 50, calculateRunningBalances: false },
}: UseTransactionsProps): UseTransactionsResult {
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<Error | undefined>(undefined);
const [transactions, setTransactions] = useState<
ReadonlyArray<TransactionEntity>
>([]);
const [runningBalances, setRunningBalances] = useState<
Map<TransactionEntity['id'], IntegerAmount>
>(new Map());
const pagedQueryRef = useRef<PagedQuery<TransactionEntity> | null>(null);
const queryResult = useInfiniteQuery(
transactionQueries.aql({ query, pageSize: options.pageSize }),
);
// 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<TransactionEntity>(query, {
onData: data => {
if (!isUnmounted) {
setTransactions(data);
const calculateFn = getCalculateRunningBalancesFn(
optionsRef.current?.calculateRunningBalances,
);
if (calculateFn) {
setRunningBalances(
calculateFn(
data,
query.state.tableOptions?.splits as TransactionSplitsOption,
optionsRef.current?.startingBalance,
),
);
}
setIsLoading(false);
}
},
onError,
options: optionsRef.current.pageCount
? { pageCount: optionsRef.current.pageCount }
: {},
});
return () => {
isUnmounted = true;
pagedQueryRef.current?.unsubscribe();
};
}, [query]);
const calculateRunningBalancesOptionFn = getCalculateRunningBalancesFn(
options?.calculateRunningBalances,
);
const startingBalanceOption = options?.startingBalance;
const splitsOption = query?.state.tableOptions
?.splits as TransactionSplitsOption;
// Recalculate running balances whenever the transactions change or the
// calculation options change (for example, when toggling show/hide
@@ -177,55 +106,32 @@ export function useTransactions({
// dependent only on `query` above, but this effect ensures running
// balances are updated in-place when the caller changes options.
useEffect(() => {
const calculateFn = getCalculateRunningBalancesFn(
options?.calculateRunningBalances,
);
if (calculateFn) {
setRunningBalances(
calculateFn(
transactions,
query?.state.tableOptions?.splits as TransactionSplitsOption,
options?.startingBalance,
),
);
if (calculateRunningBalancesOptionFn) {
if (queryResult.isSuccess) {
const transactions = queryResult.data.pages.flat();
setRunningBalances(
calculateRunningBalancesOptionFn(
transactions,
splitsOption,
startingBalanceOption,
),
);
}
} else {
setRunningBalances(new Map());
}
}, [
transactions,
query,
options?.calculateRunningBalances,
options?.startingBalance,
queryResult.data,
queryResult.isSuccess,
calculateRunningBalancesOptionFn,
startingBalanceOption,
splitsOption,
]);
const loadMore = useCallback(async () => {
if (!pagedQueryRef.current) {
return;
}
setIsLoadingMore(true);
await pagedQueryRef.current
.fetchNext()
.catch(setError)
.finally(() => {
setIsLoadingMore(false);
});
}, []);
const reload = useCallback(() => {
pagedQueryRef.current?.run();
}, []);
return {
transactions,
...queryResult,
transactions: queryResult.data ? queryResult.data.pages.flat() : [],
runningBalances,
isLoading,
...(error && { error }),
reload,
loadMore,
isLoadingMore,
};
}

View File

@@ -26,12 +26,17 @@ import * as notificationsSlice from './notifications/notificationsSlice';
import * as payeesSlice from './payees/payeesSlice';
import * as prefsSlice from './prefs/prefsSlice';
import { aqlQuery } from './queries/aqlQuery';
import { store } from './redux/store';
import { configureAppStore } from './redux/store';
import * as tagsSlice from './tags/tagsSlice';
import * as transactionsSlice from './transactions/transactionsSlice';
import { redo, undo } from './undo';
import * as usersSlice from './users/usersSlice';
const queryClient = new QueryClient();
window.__TANSTACK_QUERY_CLIENT__ = queryClient;
const store = configureAppStore();
const boundActions = bindActionCreators(
{
...accountsSlice.actions,
@@ -82,9 +87,6 @@ window.$send = send;
window.$query = aqlQuery;
window.$q = q;
const queryClient = new QueryClient();
window.__TANSTACK_QUERY_CLIENT__ = queryClient;
const container = document.getElementById('root');
const root = createRoot(container);
root.render(

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from 'react';
import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { configureAppStore } from './redux/store';
import type { AppStore } from './redux/store';
let mockQueryClient = new QueryClient();
let mockStore: AppStore = configureAppStore();
export function resetTestProviders() {
mockQueryClient = new QueryClient();
mockStore = configureAppStore();
}
export function TestProviders({ children }: { children: ReactNode }) {
return (
<Provider store={mockStore}>
<QueryClientProvider client={mockQueryClient}>
{children}
</QueryClientProvider>
</Provider>
);
}

View File

@@ -1,75 +0,0 @@
import React from 'react';
import type { ReactNode } from 'react';
import { Provider } from 'react-redux';
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import type { store as realStore } from './store';
import {
name as accountsSliceName,
reducer as accountsSliceReducer,
} from '@desktop-client/accounts/accountsSlice';
import {
name as appSliceName,
reducer as appSliceReducer,
} from '@desktop-client/app/appSlice';
import {
name as budgetfilesSliceName,
reducer as budgetfilesSliceReducer,
} from '@desktop-client/budgetfiles/budgetfilesSlice';
import {
name as modalsSliceName,
reducer as modalsSliceReducer,
} from '@desktop-client/modals/modalsSlice';
import {
name as notificationsSliceName,
reducer as notificationsSliceReducer,
} from '@desktop-client/notifications/notificationsSlice';
import {
name as payeesSliceName,
reducer as payeesSliceReducer,
} from '@desktop-client/payees/payeesSlice';
import {
name as prefsSliceName,
reducer as prefsSliceReducer,
} from '@desktop-client/prefs/prefsSlice';
import {
name as tagsSliceName,
reducer as tagsSliceReducer,
} from '@desktop-client/tags/tagsSlice';
import {
name as transactionsSliceName,
reducer as transactionsSliceReducer,
} from '@desktop-client/transactions/transactionsSlice';
import {
name as usersSliceName,
reducer as usersSliceReducer,
} from '@desktop-client/users/usersSlice';
const appReducer = combineReducers({
[accountsSliceName]: accountsSliceReducer,
[appSliceName]: appSliceReducer,
[budgetfilesSliceName]: budgetfilesSliceReducer,
[modalsSliceName]: modalsSliceReducer,
[notificationsSliceName]: notificationsSliceReducer,
[payeesSliceName]: payeesSliceReducer,
[prefsSliceName]: prefsSliceReducer,
[transactionsSliceName]: transactionsSliceReducer,
[tagsSliceName]: tagsSliceReducer,
[usersSliceName]: usersSliceReducer,
});
export let mockStore: typeof realStore = configureStore({
reducer: appReducer,
});
export function resetMockStore() {
mockStore = configureStore({
reducer: appReducer,
});
}
export function TestProvider({ children }: { children: ReactNode }) {
return <Provider store={mockStore}>{children}</Provider>;
}

View File

@@ -86,6 +86,17 @@ export const store = configureStore({
}).prepend(notifyOnRejectedActionsMiddleware.middleware),
});
export function configureAppStore() {
return configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
// TODO: Fix this in a separate PR. Remove non-serializable states in the store.
serializableCheck: false,
}).prepend(notifyOnRejectedActionsMiddleware.middleware),
});
}
export type AppStore = typeof store;
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@@ -1,14 +1,23 @@
import '@testing-library/jest-dom';
import type { ReactNode } from 'react';
import { resetTestProviders } from './mocks';
import { installPolyfills } from './polyfills';
import { resetMockStore } from './redux/mock';
installPolyfills();
global.IS_TESTING = true;
global.Actual = {};
global.Actual = {} as typeof global.Actual;
type Size = { height: number; width: number };
type AutoSizerProps = {
renderProp?: (size: Size) => ReactNode;
children?: (size: Size) => ReactNode;
};
vi.mock('react-virtualized-auto-sizer', () => {
const AutoSizer = props => {
const AutoSizer = (props: AutoSizerProps) => {
const render = props.renderProp ?? props.children;
return render ? render({ height: 1000, width: 600 }) : null;
};
@@ -22,13 +31,13 @@ vi.mock('react-virtualized-auto-sizer', () => {
global.Date.now = () => 123456789;
global.__resetWorld = () => {
resetMockStore();
resetTestProviders();
};
process.on('unhandledRejection', reason => {
process.on('unhandledRejection', (reason: unknown) => {
console.error('REJECTION', reason);
});
global.afterEach(() => {
afterEach(() => {
global.__resetWorld();
});

View File

@@ -0,0 +1 @@
export * from './queries';

View File

@@ -0,0 +1,33 @@
import { infiniteQueryOptions, keepPreviousData } from '@tanstack/react-query';
import type { Query } from 'loot-core/shared/query';
import type { TransactionEntity } from 'loot-core/types/models';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
export const transactionQueries = {
all: () => ['transactions'],
aql: ({ query, pageSize = 50 }: { query?: Query; pageSize?: number }) =>
infiniteQueryOptions<TransactionEntity[]>({
queryKey: [...transactionQueries.all(), 'aql', query, pageSize],
queryFn: async ({ pageParam }) => {
if (!query) {
// Shouldn't happen because of the enabled flag, but needed to satisfy TS
throw new Error('No query provided.');
}
const queryWithOffset = query
.offset((pageParam as number) * pageSize)
.limit(pageSize);
const { data }: { data: TransactionEntity[] } =
await aqlQuery(queryWithOffset);
return data;
},
placeholderData: keepPreviousData,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => {
return lastPage.length < pageSize ? undefined : pages.length;
},
enabled: !!query,
}),
};

View File

@@ -31,7 +31,7 @@ const addWatchers = (): Plugin => ({
const injectShims = (): Plugin[] => {
const buildShims = path.resolve('./src/build-shims.js');
const commonInject = {
exclude: ['src/setupTests.js'],
exclude: ['src/setupTests.ts'],
global: [buildShims, 'global'],
};
@@ -216,7 +216,7 @@ export default defineConfig(async ({ mode }) => {
include: ['src/**/*.{test,spec}.?(c|m)[jt]s?(x)'],
environment: 'jsdom',
globals: true,
setupFiles: './src/setupTests.js',
setupFiles: './src/setupTests.ts',
testTimeout: 10000,
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
// print only console.error