mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-28 18:40:34 -05:00
Fix transaction hooks and improve transactions loading experience in mobile (#5415)
* Fix transaction hooks and improve transactions loading experience in mobile * Allow skipping of running balance calculation on preview transactions + recalculate running balances if there are any inversed transaction amounts * [autofix.ci] apply automated fixes * Disable PullToRefresh when transaction list is in loading state (See #5080) * Cleanup * Add calculateRunningBalancesTopDown to calculate top down from starting balance * update balance sheet value --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
ccdde60bfe
commit
f54e459e03
@@ -241,6 +241,12 @@ function TransactionListWithPreviews({
|
||||
readonly accountName: AccountEntity['name'] | string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const balanceQueries = useMemo(
|
||||
() => queriesFromAccountId(accountId, account),
|
||||
[accountId, account],
|
||||
);
|
||||
|
||||
const baseTransactionsQuery = useCallback(
|
||||
() =>
|
||||
queries.transactions(accountId).options({ splits: 'all' }).select('*'),
|
||||
@@ -354,11 +360,6 @@ function TransactionListWithPreviews({
|
||||
[dispatch, navigate],
|
||||
);
|
||||
|
||||
const balanceQueries = useMemo(
|
||||
() => queriesFromAccountId(accountId, account),
|
||||
[accountId, account],
|
||||
);
|
||||
|
||||
const transactionsToDisplay = !isSearching
|
||||
? // Do not render child transactions in the list, unless searching
|
||||
previewTransactions.concat(transactions.filter(t => !t.is_child))
|
||||
|
||||
@@ -90,7 +90,7 @@ function TransactionListWithPreviews({
|
||||
);
|
||||
const {
|
||||
transactions,
|
||||
isLoading,
|
||||
isLoading: isTransactionsLoading,
|
||||
isLoadingMore,
|
||||
loadMore: loadMoreTransactions,
|
||||
reload: reloadTransactions,
|
||||
@@ -138,10 +138,11 @@ function TransactionListWithPreviews({
|
||||
month,
|
||||
);
|
||||
|
||||
const { previewTransactions } = useCategoryPreviewTransactions({
|
||||
categoryId: category.id,
|
||||
month,
|
||||
});
|
||||
const { previewTransactions, isLoading: isPreviewTransactionsLoading } =
|
||||
useCategoryPreviewTransactions({
|
||||
categoryId: category.id,
|
||||
month,
|
||||
});
|
||||
|
||||
const transactionsToDisplay = !isSearching
|
||||
? previewTransactions.concat(transactions)
|
||||
@@ -149,7 +150,9 @@ function TransactionListWithPreviews({
|
||||
|
||||
return (
|
||||
<TransactionListWithBalances
|
||||
isLoading={isLoading}
|
||||
isLoading={
|
||||
isSearching ? isTransactionsLoading : isPreviewTransactionsLoading
|
||||
}
|
||||
transactions={transactionsToDisplay}
|
||||
balance={balance}
|
||||
balanceCleared={balanceCleared}
|
||||
@@ -159,8 +162,6 @@ function TransactionListWithPreviews({
|
||||
isLoadingMore={isLoadingMore}
|
||||
onLoadMore={loadMoreTransactions}
|
||||
onOpenTransaction={onOpenTransaction}
|
||||
onRefresh={undefined}
|
||||
account={undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
type TransactionEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { TransactionListItem } from './TransactionListItem';
|
||||
import { ROW_HEIGHT, TransactionListItem } from './TransactionListItem';
|
||||
|
||||
import { FloatingActionBar } from '@desktop-client/components/mobile/FloatingActionBar';
|
||||
import { useScrollListener } from '@desktop-client/components/ScrollProvider';
|
||||
@@ -148,30 +148,34 @@ export function TransactionList({
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading aria-label={t('Loading transactions...')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && (
|
||||
<Loading
|
||||
style={{ paddingBottom: 8 }}
|
||||
aria-label={t('Loading transactions...')}
|
||||
/>
|
||||
)}
|
||||
<ListBox
|
||||
aria-label={t('Transaction list')}
|
||||
selectionMode={selectedTransactions.size > 0 ? 'multiple' : 'single'}
|
||||
selectedKeys={selectedTransactions}
|
||||
dependencies={[selectedTransactions]}
|
||||
renderEmptyState={() => (
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: theme.mobilePageBackground,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 15 }}>
|
||||
<Trans>No transactions</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
renderEmptyState={() =>
|
||||
!isLoading && (
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: theme.mobilePageBackground,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 15 }}>
|
||||
<Trans>No transactions</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
items={sections}
|
||||
>
|
||||
{section => (
|
||||
@@ -217,7 +221,7 @@ export function TransactionList({
|
||||
aria-label={t('Loading more transactions...')}
|
||||
style={{
|
||||
// Same height as transaction list item
|
||||
height: 60,
|
||||
height: ROW_HEIGHT,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -47,7 +47,7 @@ import { usePayee } from '@desktop-client/hooks/usePayee';
|
||||
import { NotesTagFormatter } from '@desktop-client/notes/NotesTagFormatter';
|
||||
import { useSelector } from '@desktop-client/redux';
|
||||
|
||||
const ROW_HEIGHT = 60;
|
||||
export const ROW_HEIGHT = 60;
|
||||
|
||||
const getTextStyle = ({
|
||||
isPreview,
|
||||
|
||||
@@ -142,7 +142,7 @@ export function TransactionListWithBalances({
|
||||
/>
|
||||
</View>
|
||||
<PullToRefresh
|
||||
isPullable={!!onRefresh}
|
||||
isPullable={!isLoading && !!onRefresh}
|
||||
onRefresh={async () => onRefresh?.()}
|
||||
>
|
||||
<TransactionList
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { groupById } from 'loot-core/shared/util';
|
||||
import { groupById, type IntegerAmount } from 'loot-core/shared/util';
|
||||
import {
|
||||
type ScheduleEntity,
|
||||
type AccountEntity,
|
||||
@@ -12,6 +12,8 @@ import { useAccounts } from './useAccounts';
|
||||
import { usePayees } from './usePayees';
|
||||
import { usePreviewTransactions } from './usePreviewTransactions';
|
||||
import { useSheetValue } from './useSheetValue';
|
||||
import { useSyncedPref } from './useSyncedPref';
|
||||
import { calculateRunningBalancesBottomUp } from './useTransactions';
|
||||
|
||||
import { accountBalance } from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
@@ -68,6 +70,8 @@ export function useAccountPreviewTransactions({
|
||||
accountBalance(accountId || ''),
|
||||
);
|
||||
|
||||
const [showBalances] = useSyncedPref(`show-balances-${accountId}`);
|
||||
|
||||
const {
|
||||
previewTransactions: allPreviewTransactions,
|
||||
runningBalances: allRunningBalances,
|
||||
@@ -76,6 +80,7 @@ export function useAccountPreviewTransactions({
|
||||
} = usePreviewTransactions({
|
||||
filter: accountSchedulesFilter,
|
||||
options: {
|
||||
calculateRunningBalances: showBalances === 'true',
|
||||
startingBalance: accountBalanceValue ?? 0,
|
||||
},
|
||||
});
|
||||
@@ -90,20 +95,24 @@ export function useAccountPreviewTransactions({
|
||||
};
|
||||
}
|
||||
|
||||
const previewTransactions = accountPreview({
|
||||
const {
|
||||
transactions: previewTransactions,
|
||||
runningBalances: previewRunningBalances,
|
||||
} = inverseBasedOnAccount({
|
||||
accountId,
|
||||
transactions: allPreviewTransactions,
|
||||
runningBalances: allRunningBalances,
|
||||
startingBalance: accountBalanceValue ?? 0,
|
||||
getPayeeByTransferAccount,
|
||||
getTransferAccountByPayee,
|
||||
});
|
||||
|
||||
const transactionIds = new Set(previewTransactions.map(t => t.id));
|
||||
const runningBalances = allRunningBalances;
|
||||
for (const transactionId of runningBalances.keys()) {
|
||||
if (!transactionIds.has(transactionId)) {
|
||||
runningBalances.delete(transactionId);
|
||||
}
|
||||
}
|
||||
const runningBalances = new Map(
|
||||
[...previewRunningBalances.entries()].filter(([id]) =>
|
||||
transactionIds.has(id),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
@@ -114,17 +123,20 @@ export function useAccountPreviewTransactions({
|
||||
}, [
|
||||
accountId,
|
||||
allPreviewTransactions,
|
||||
accountBalanceValue,
|
||||
allRunningBalances,
|
||||
error,
|
||||
getPayeeByTransferAccount,
|
||||
getTransferAccountByPayee,
|
||||
isLoading,
|
||||
error,
|
||||
]);
|
||||
}
|
||||
|
||||
type AccountPreviewProps = {
|
||||
type InverseBasedOnAccountProps = {
|
||||
accountId?: AccountEntity['id'];
|
||||
transactions: readonly TransactionEntity[];
|
||||
startingBalance: IntegerAmount;
|
||||
runningBalances: Map<TransactionEntity['id'], IntegerAmount>;
|
||||
getPayeeByTransferAccount: (
|
||||
transferAccountId?: AccountEntity['id'],
|
||||
) => PayeeEntity | null;
|
||||
@@ -133,13 +145,18 @@ type AccountPreviewProps = {
|
||||
) => AccountEntity | null;
|
||||
};
|
||||
|
||||
function accountPreview({
|
||||
function inverseBasedOnAccount({
|
||||
accountId,
|
||||
transactions,
|
||||
runningBalances,
|
||||
startingBalance,
|
||||
getPayeeByTransferAccount,
|
||||
getTransferAccountByPayee,
|
||||
}: AccountPreviewProps): TransactionEntity[] {
|
||||
return transactions.map(transaction => {
|
||||
}: InverseBasedOnAccountProps): {
|
||||
transactions: TransactionEntity[];
|
||||
runningBalances: Map<TransactionEntity['id'], IntegerAmount>;
|
||||
} {
|
||||
const mappedTransactions = transactions.map(transaction => {
|
||||
const inverse = transaction.account !== accountId;
|
||||
const subtransactions = transaction.subtransactions?.map(st => ({
|
||||
...st,
|
||||
@@ -151,6 +168,7 @@ function accountPreview({
|
||||
: st.account,
|
||||
}));
|
||||
return {
|
||||
inversed: inverse,
|
||||
...transaction,
|
||||
amount: inverse ? -transaction.amount : transaction.amount,
|
||||
payee:
|
||||
@@ -163,4 +181,22 @@ function accountPreview({
|
||||
...(subtransactions && { subtransactions }),
|
||||
};
|
||||
});
|
||||
|
||||
// Recalculate running balances if any transaction was inversed.
|
||||
// This is necessary because the running balances are calculated based on the
|
||||
// original transaction amounts and accounts, and we need to adjust them
|
||||
// based on the inversed transactions.
|
||||
const anyInversed = mappedTransactions.some(t => t.inversed);
|
||||
const mappedRunningBalances = anyInversed
|
||||
? calculateRunningBalancesBottomUp(
|
||||
mappedTransactions,
|
||||
'all',
|
||||
startingBalance ?? 0,
|
||||
)
|
||||
: runningBalances;
|
||||
|
||||
return {
|
||||
transactions: mappedTransactions,
|
||||
runningBalances: mappedRunningBalances,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,8 +66,8 @@ export function useCategoryPreviewTransactions({
|
||||
return {
|
||||
previewTransactions: [],
|
||||
runningBalances: new Map(),
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,12 +77,9 @@ export function useCategoryPreviewTransactions({
|
||||
);
|
||||
|
||||
const transactionIds = new Set(previewTransactions.map(t => t.id));
|
||||
const runningBalances = allRunningBalances;
|
||||
for (const transactionId of runningBalances.keys()) {
|
||||
if (!transactionIds.has(transactionId)) {
|
||||
runningBalances.delete(transactionId);
|
||||
}
|
||||
}
|
||||
const runningBalances = new Map(
|
||||
[...allRunningBalances].filter(([id]) => transactionIds.has(id)),
|
||||
);
|
||||
|
||||
return {
|
||||
previewTransactions,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||||
import { useEffect, useState, useMemo, useRef } from 'react';
|
||||
|
||||
import * as d from 'date-fns';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import { currentDay, addDays, parseDate } from 'loot-core/shared/months';
|
||||
import { type Query } from 'loot-core/shared/query';
|
||||
import {
|
||||
getUpcomingDays,
|
||||
extractScheduleConds,
|
||||
@@ -22,203 +21,18 @@ import {
|
||||
import { useCachedSchedules } from './useCachedSchedules';
|
||||
import { type ScheduleStatuses } from './useSchedules';
|
||||
import { useSyncedPref } from './useSyncedPref';
|
||||
|
||||
import {
|
||||
pagedQuery,
|
||||
type PagedQuery,
|
||||
} from '@desktop-client/queries/pagedQuery';
|
||||
|
||||
// Mirrors the `splits` AQL option from the server
|
||||
type TransactionSplitsOption = 'all' | 'inline' | 'grouped' | 'none';
|
||||
|
||||
type CalculateRunningBalancesOption =
|
||||
| ((
|
||||
transactions: TransactionEntity[],
|
||||
splits: TransactionSplitsOption,
|
||||
) => Map<TransactionEntity['id'], IntegerAmount>)
|
||||
| boolean;
|
||||
|
||||
type UseTransactionsProps = {
|
||||
/**
|
||||
* The Query class is immutable so it is important to memoize the query object
|
||||
* to prevent unnecessary re-renders i.e. `useMemo`, `useState`, etc.
|
||||
*/
|
||||
query?: Query;
|
||||
/**
|
||||
* The options to configure the hook behavior.
|
||||
*/
|
||||
options?: {
|
||||
/**
|
||||
* The number of transactions to load at a time.
|
||||
* This is used for pagination and should be set to a reasonable number
|
||||
* to avoid loading too many transactions at once.
|
||||
* The default is 50.
|
||||
* @default 50
|
||||
*/
|
||||
pageCount?: 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
|
||||
* (using the default running balance calculation), or a function that takes the
|
||||
* transactions and the query state and returns a map of transaction IDs to running balances.
|
||||
* The function will be called with the transactions and the query state
|
||||
* whenever the transactions are loaded or reloaded.
|
||||
*
|
||||
* The default running balance calculation is a simple sum of the transaction amounts
|
||||
* in reverse order (bottom up). This works well if the transactions are ordered by
|
||||
* date in descending order. If the query orders the transactions differently,
|
||||
* a custom `calculateRunningBalances` function should be used instead.
|
||||
* @default false
|
||||
*/
|
||||
calculateRunningBalances?: CalculateRunningBalancesOption;
|
||||
};
|
||||
};
|
||||
|
||||
type UseTransactionsResult = {
|
||||
/**
|
||||
* The transactions returned by the query.
|
||||
*/
|
||||
transactions: ReadonlyArray<TransactionEntity>;
|
||||
/**
|
||||
* The running balances for the transactions returned by the query.
|
||||
* This is only populated if `calculateRunningBalances` is either set to `true`
|
||||
* 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 },
|
||||
}: 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);
|
||||
|
||||
// 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
onError,
|
||||
options: optionsRef.current.pageCount
|
||||
? { pageCount: optionsRef.current.pageCount }
|
||||
: {},
|
||||
});
|
||||
|
||||
return () => {
|
||||
isUnmounted = true;
|
||||
pagedQueryRef.current?.unsubscribe();
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
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,
|
||||
runningBalances,
|
||||
isLoading,
|
||||
...(error && { error }),
|
||||
reload,
|
||||
loadMore,
|
||||
isLoadingMore,
|
||||
};
|
||||
}
|
||||
import { calculateRunningBalancesBottomUp } from './useTransactions';
|
||||
|
||||
type UsePreviewTransactionsProps = {
|
||||
filter?: (schedule: ScheduleEntity) => boolean;
|
||||
options?: {
|
||||
/**
|
||||
* Whether to calculate running balances.
|
||||
*/
|
||||
calculateRunningBalances?: boolean;
|
||||
/**
|
||||
* The starting balance to start the running balance calculation from.
|
||||
* This is ignored if `calculateRunningBalances` is false.
|
||||
*/
|
||||
startingBalance?: IntegerAmount;
|
||||
};
|
||||
@@ -372,18 +186,20 @@ export function usePreviewTransactions({
|
||||
const ungroupedTransactions = ungroupTransactions(withDefaults);
|
||||
setPreviewTransactions(ungroupedTransactions);
|
||||
|
||||
setRunningBalances(
|
||||
// We always use the bottom up calculation for preview transactions
|
||||
// because the hook controls the order of the transactions. We don't
|
||||
// need to provide a custom way for consumers to calculate the running
|
||||
// balances, at least as of writing.
|
||||
calculateRunningBalancesBottomUp(
|
||||
ungroupedTransactions,
|
||||
// Preview transactions are behaves like 'all' splits
|
||||
'all',
|
||||
optionsRef.current?.startingBalance,
|
||||
),
|
||||
);
|
||||
if (optionsRef.current?.calculateRunningBalances) {
|
||||
setRunningBalances(
|
||||
// We always use the bottom up calculation for preview transactions
|
||||
// because the hook controls the order of the transactions. We don't
|
||||
// need to provide a custom way for consumers to calculate the running
|
||||
// balances, at least as of writing.
|
||||
calculateRunningBalancesBottomUp(
|
||||
ungroupedTransactions,
|
||||
// Preview transactions are behaves like 'all' splits
|
||||
'all',
|
||||
optionsRef.current?.startingBalance,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -409,61 +225,10 @@ export function usePreviewTransactions({
|
||||
};
|
||||
}
|
||||
|
||||
export function isForPreview(
|
||||
schedule: ScheduleEntity,
|
||||
statuses: ScheduleStatuses,
|
||||
) {
|
||||
function isForPreview(schedule: ScheduleEntity, statuses: ScheduleStatuses) {
|
||||
const status = statuses.get(schedule.id);
|
||||
return (
|
||||
!schedule.completed &&
|
||||
['due', 'upcoming', 'missed', 'paid'].includes(status!)
|
||||
);
|
||||
}
|
||||
|
||||
function getCalculateRunningBalancesFn(
|
||||
calculateRunningBalances: CalculateRunningBalancesOption = false,
|
||||
) {
|
||||
return calculateRunningBalances === true
|
||||
? calculateRunningBalancesBottomUp
|
||||
: typeof calculateRunningBalances === 'function'
|
||||
? calculateRunningBalances
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function calculateRunningBalancesBottomUp(
|
||||
transactions: TransactionEntity[],
|
||||
splits: TransactionSplitsOption,
|
||||
startingBalance: IntegerAmount = 0,
|
||||
) {
|
||||
return (
|
||||
transactions
|
||||
.filter(t => {
|
||||
switch (splits) {
|
||||
case 'all':
|
||||
// Only calculate parent/non-split amounts
|
||||
return !t.parent_id;
|
||||
default:
|
||||
// inline
|
||||
// grouped
|
||||
// none
|
||||
return true;
|
||||
}
|
||||
})
|
||||
// We're using `reduceRight` here to calculate the running balance in reverse order (bottom up).
|
||||
.reduceRight((acc, transaction, index, arr) => {
|
||||
const previousTransactionIndex = index + 1;
|
||||
if (previousTransactionIndex >= arr.length) {
|
||||
// This is the last transaction in the list,
|
||||
// so we set the running balance to the starting balance + the amount of the transaction
|
||||
acc.set(transaction.id, startingBalance + transaction.amount);
|
||||
return acc;
|
||||
}
|
||||
const previousTransaction = arr[previousTransactionIndex];
|
||||
const previousRunningBalance = acc.get(previousTransaction.id) ?? 0;
|
||||
const currentRunningBalance =
|
||||
previousRunningBalance + transaction.amount;
|
||||
acc.set(transaction.id, currentRunningBalance);
|
||||
return acc;
|
||||
}, new Map<TransactionEntity['id'], IntegerAmount>())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,103 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
import type { Query } from 'loot-core/shared/query';
|
||||
import type { TransactionEntity } from 'loot-core/types/models';
|
||||
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 {
|
||||
type PagedQuery,
|
||||
pagedQuery,
|
||||
type PagedQuery,
|
||||
} from '@desktop-client/queries/pagedQuery';
|
||||
|
||||
// Mirrors the `splits` AQL option from the server
|
||||
type TransactionSplitsOption = 'all' | 'inline' | 'grouped' | 'none';
|
||||
|
||||
type CalculateRunningBalancesOption =
|
||||
| ((
|
||||
transactions: TransactionEntity[],
|
||||
splits: TransactionSplitsOption,
|
||||
startingBalance?: IntegerAmount,
|
||||
) => Map<TransactionEntity['id'], IntegerAmount>)
|
||||
| boolean;
|
||||
|
||||
type UseTransactionsProps = {
|
||||
/**
|
||||
* The Query class is immutable so it is important to memoize the query object
|
||||
* to prevent unnecessary re-renders i.e. `useMemo`, `useState`, etc.
|
||||
*/
|
||||
query?: Query;
|
||||
/**
|
||||
* The options to configure the hook behavior.
|
||||
*/
|
||||
options?: {
|
||||
/**
|
||||
* The number of transactions to load at a time.
|
||||
* This is used for pagination and should be set to a reasonable number
|
||||
* to avoid loading too many transactions at once.
|
||||
* The default is 50.
|
||||
* @default 50
|
||||
*/
|
||||
pageCount?: 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
|
||||
* (using the default running balance calculation), or a function that takes the
|
||||
* transactions and the query state and returns a map of transaction IDs to running balances.
|
||||
* The function will be called with the transactions and the query state
|
||||
* whenever the transactions are loaded or reloaded.
|
||||
*
|
||||
* The default running balance calculation is a simple sum of the transaction amounts
|
||||
* in reverse order (bottom up). This works well if the transactions are ordered by
|
||||
* date in descending order. If the query orders the transactions differently,
|
||||
* a custom `calculateRunningBalances` function should be used instead.
|
||||
* @default false
|
||||
*/
|
||||
calculateRunningBalances?: CalculateRunningBalancesOption;
|
||||
/**
|
||||
* The starting balance to start the running balance calculation from.
|
||||
* This is ignored if `calculateRunningBalances` is false.
|
||||
* @default 0
|
||||
*/
|
||||
startingBalance?: IntegerAmount;
|
||||
};
|
||||
};
|
||||
|
||||
type UseTransactionsResult = {
|
||||
/**
|
||||
* The transactions returned by the query.
|
||||
*/
|
||||
transactions: ReadonlyArray<TransactionEntity>;
|
||||
/**
|
||||
* The running balances for the transactions returned by the query.
|
||||
* This is only populated if `calculateRunningBalances` is either set to `true`
|
||||
* 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 },
|
||||
options = { pageCount: 50, calculateRunningBalances: false },
|
||||
}: UseTransactionsProps): UseTransactionsResult {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
@@ -37,6 +105,9 @@ export function useTransactions({
|
||||
const [transactions, setTransactions] = useState<
|
||||
ReadonlyArray<TransactionEntity>
|
||||
>([]);
|
||||
const [runningBalances, setRunningBalances] = useState<
|
||||
Map<TransactionEntity['id'], IntegerAmount>
|
||||
>(new Map());
|
||||
|
||||
const pagedQueryRef = useRef<PagedQuery<TransactionEntity> | null>(null);
|
||||
|
||||
@@ -73,6 +144,20 @@ export function useTransactions({
|
||||
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);
|
||||
}
|
||||
},
|
||||
@@ -109,6 +194,7 @@ export function useTransactions({
|
||||
|
||||
return {
|
||||
transactions,
|
||||
runningBalances,
|
||||
isLoading,
|
||||
...(error && { error }),
|
||||
reload,
|
||||
@@ -116,3 +202,93 @@ export function useTransactions({
|
||||
isLoadingMore,
|
||||
};
|
||||
}
|
||||
|
||||
function getCalculateRunningBalancesFn(
|
||||
calculateRunningBalances: CalculateRunningBalancesOption = false,
|
||||
) {
|
||||
return calculateRunningBalances === true
|
||||
? calculateRunningBalancesBottomUp
|
||||
: typeof calculateRunningBalances === 'function'
|
||||
? calculateRunningBalances
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function calculateRunningBalancesBottomUp(
|
||||
transactions: TransactionEntity[],
|
||||
splits: TransactionSplitsOption,
|
||||
startingBalance: IntegerAmount = 0,
|
||||
) {
|
||||
return (
|
||||
transactions
|
||||
.filter(t => {
|
||||
switch (splits) {
|
||||
case 'all':
|
||||
// Only calculate parent/non-split amounts
|
||||
return !t.parent_id;
|
||||
default:
|
||||
// inline
|
||||
// grouped
|
||||
// none
|
||||
return true;
|
||||
}
|
||||
})
|
||||
// We're using `reduceRight` here to calculate the running balance in reverse order (bottom up).
|
||||
.reduceRight((acc, transaction, index, arr) => {
|
||||
const previousTransactionIndex = index + 1;
|
||||
if (previousTransactionIndex >= arr.length) {
|
||||
// This is the last transaction in the list,
|
||||
// so we set the running balance to the starting balance + the amount of the transaction
|
||||
acc.set(transaction.id, startingBalance + transaction.amount);
|
||||
return acc;
|
||||
}
|
||||
const previousTransaction = arr[previousTransactionIndex];
|
||||
const previousRunningBalance = acc.get(previousTransaction.id) ?? 0;
|
||||
const currentRunningBalance =
|
||||
previousRunningBalance + transaction.amount;
|
||||
acc.set(transaction.id, currentRunningBalance);
|
||||
return acc;
|
||||
}, new Map<TransactionEntity['id'], IntegerAmount>())
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateRunningBalancesTopDown(
|
||||
transactions: TransactionEntity[],
|
||||
splits: TransactionSplitsOption,
|
||||
startingBalance: IntegerAmount = 0,
|
||||
) {
|
||||
return transactions
|
||||
.filter(t => {
|
||||
switch (splits) {
|
||||
case 'all':
|
||||
// Only calculate parent/non-split amounts
|
||||
return !t.parent_id;
|
||||
default:
|
||||
// inline
|
||||
// grouped
|
||||
// none
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.reduce((acc, transaction, index, arr) => {
|
||||
if (index === 0) {
|
||||
// This is the first transaction in the list,
|
||||
// so we set the running balance to the starting balance
|
||||
acc.set(transaction.id, startingBalance);
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (index === arr.length - 1) {
|
||||
// This is the last transaction in the list,
|
||||
// so we set the running balance to the amount of the transaction
|
||||
acc.set(transaction.id, transaction.amount);
|
||||
return acc;
|
||||
}
|
||||
|
||||
const previousTransaction = arr[index - 1];
|
||||
const previousRunningBalance = acc.get(previousTransaction.id) ?? 0;
|
||||
const previousAmount = previousTransaction.amount ?? 0;
|
||||
const currentRunningBalance = previousRunningBalance - previousAmount;
|
||||
acc.set(transaction.id, currentRunningBalance);
|
||||
return acc;
|
||||
}, new Map<TransactionEntity['id'], IntegerAmount>());
|
||||
}
|
||||
|
||||
6
upcoming-release-notes/5415.md
Normal file
6
upcoming-release-notes/5415.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Fix transaction hooks and improve transactions loading experience in mobile
|
||||
Reference in New Issue
Block a user