diff --git a/packages/desktop-client/e2e/accounts.mobile.test.ts-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-1-chromium-linux.png b/packages/desktop-client/e2e/accounts.mobile.test.ts-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-1-chromium-linux.png index cb84ec53392..1ad2d39d322 100644 Binary files a/packages/desktop-client/e2e/accounts.mobile.test.ts-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-1-chromium-linux.png and b/packages/desktop-client/e2e/accounts.mobile.test.ts-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/accounts.mobile.test.ts-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-2-chromium-linux.png b/packages/desktop-client/e2e/accounts.mobile.test.ts-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-2-chromium-linux.png index 2e33859da81..abf12e0eff4 100644 Binary files a/packages/desktop-client/e2e/accounts.mobile.test.ts-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-2-chromium-linux.png and b/packages/desktop-client/e2e/accounts.mobile.test.ts-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/accounts.mobile.test.ts-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-3-chromium-linux.png b/packages/desktop-client/e2e/accounts.mobile.test.ts-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-3-chromium-linux.png index 03cc182961c..59e18cee676 100644 Binary files a/packages/desktop-client/e2e/accounts.mobile.test.ts-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-3-chromium-linux.png and b/packages/desktop-client/e2e/accounts.mobile.test.ts-snapshots/Mobile-Accounts-opens-individual-account-page-and-checks-that-filtering-is-working-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/reports.test.ts-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-1-chromium-linux.png b/packages/desktop-client/e2e/reports.test.ts-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-1-chromium-linux.png index 85a97c2032f..85f9c02fe53 100644 Binary files a/packages/desktop-client/e2e/reports.test.ts-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-1-chromium-linux.png and b/packages/desktop-client/e2e/reports.test.ts-snapshots/Reports-loads-cash-flow-graph-and-checks-visuals-1-chromium-linux.png differ diff --git a/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.tsx b/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.tsx index d196d053d16..98efc11217f 100644 --- a/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.tsx +++ b/packages/desktop-client/src/components/mobile/budget/CategoryTransactions.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; +import { SchedulesProvider } from 'loot-core/client/data-hooks/schedules'; import { useTransactions, useTransactionsSearch, @@ -113,19 +114,21 @@ export function CategoryTransactions({ } padding={0} > - + + + ); } diff --git a/packages/desktop-client/src/components/mobile/transactions/TransactionListItem.tsx b/packages/desktop-client/src/components/mobile/transactions/TransactionListItem.tsx index f039490a7d4..d0958cbdbce 100644 --- a/packages/desktop-client/src/components/mobile/transactions/TransactionListItem.tsx +++ b/packages/desktop-client/src/components/mobile/transactions/TransactionListItem.tsx @@ -11,16 +11,22 @@ import { useLongPress, } from '@react-aria/interactions'; +import { useCachedSchedules } from 'loot-core/client/data-hooks/schedules'; import { isPreviewId } from 'loot-core/src/shared/transactions'; import { integerToCurrency } from 'loot-core/src/shared/util'; -import { type TransactionEntity } from 'loot-core/types/models'; +import { + type AccountEntity, + type TransactionEntity, +} from 'loot-core/types/models'; import { useAccount } from '../../../hooks/useAccount'; import { useCategories } from '../../../hooks/useCategories'; +import { useDisplayPayee } from '../../../hooks/useDisplayPayee'; import { usePayee } from '../../../hooks/usePayee'; -import { SvgSplit } from '../../../icons/v0'; +import { SvgLeftArrow2, SvgRightArrow2, SvgSplit } from '../../../icons/v0'; import { SvgArrowsSynchronize, + SvgCalendar, SvgCheckCircle1, SvgLockClosed, } from '../../../icons/v2'; @@ -31,12 +37,33 @@ import { Button } from '../../common/Button2'; import { Text } from '../../common/Text'; import { TextOneLine } from '../../common/TextOneLine'; import { View } from '../../common/View'; -import { getPrettyPayee } from '../utils'; import { lookupName, Status } from './TransactionEdit'; const ROW_HEIGHT = 60; +const getTextStyle = ({ + isPreview, +}: { + isPreview: boolean; +}): CSSProperties => ({ + ...styles.text, + fontSize: 14, + ...(isPreview + ? { + fontStyle: 'italic', + color: theme.pageTextLight, + } + : {}), +}); + +const getScheduleIconStyle = ({ isPreview }: { isPreview: boolean }) => ({ + width: 12, + height: 12, + marginRight: 5, + color: isPreview ? theme.pageTextLight : theme.menuItemText, +}); + type TransactionListItemProps = ComponentPropsWithoutRef< typeof ListBoxItem > & { @@ -54,6 +81,8 @@ export function TransactionListItem({ const { value: transaction } = props; const payee = usePayee(transaction?.payee || ''); + const displayPayee = useDisplayPayee({ transaction }); + const account = useAccount(transaction?.account || ''); const transferAccount = useAccount(payee?.transfer_acct || ''); const isPreview = isPreviewId(transaction?.id || ''); @@ -90,7 +119,6 @@ export function TransactionListItem({ is_parent: isParent, is_child: isChild, notes, - schedule: scheduleId, forceUpcoming, } = transaction; @@ -98,11 +126,6 @@ export function TransactionListItem({ const isAdded = newTransactions.includes(id); const categoryName = lookupName(categories, categoryId); - const prettyPayee = getPrettyPayee({ - transaction, - payee, - transferAccount, - }); const specialCategory = account?.offbudget ? 'Off budget' : transferAccount && !transferAccount.offbudget @@ -112,17 +135,7 @@ export function TransactionListItem({ : null; const prettyCategory = specialCategory || categoryName; - - const textStyle: CSSProperties = { - ...styles.text, - fontSize: 14, - ...(isPreview - ? { - fontStyle: 'italic', - color: theme.pageTextLight, - } - : {}), - }; + const textStyle = getTextStyle({ isPreview }); return ( @@ -165,27 +178,23 @@ export function TransactionListItem({ > - {scheduleId && ( - - )} + - {prettyPayee || '(No payee)'} + {displayPayee || '(No payee)'} {isPreview ? ( @@ -282,3 +291,39 @@ export function TransactionListItem({ ); } + +type PayeeIconsProps = { + transaction: TransactionEntity; + transferAccount?: AccountEntity; +}; + +function PayeeIcons({ transaction, transferAccount }: PayeeIconsProps) { + const { id, schedule: scheduleId } = transaction; + const { isLoading: isSchedulesLoading, schedules = [] } = + useCachedSchedules(); + const isPreview = isPreviewId(id); + const schedule = schedules.find(s => s.id === scheduleId); + const isScheduleRecurring = + schedule && schedule._date && !!schedule._date.frequency; + + if (isSchedulesLoading) { + return null; + } + + return ( + <> + {schedule && + (isScheduleRecurring ? ( + + ) : ( + + ))} + {transferAccount && + (transaction.amount > 0 ? ( + + ) : ( + + ))} + + ); +} diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx index 0bae394694d..31491bf6e27 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx @@ -47,6 +47,7 @@ import { } from 'loot-core/src/shared/util'; import { useContextMenu } from '../../hooks/useContextMenu'; +import { useDisplayPayee } from '../../hooks/useDisplayPayee'; import { useMergedRefs } from '../../hooks/useMergedRefs'; import { usePrevious } from '../../hooks/usePrevious'; import { useProperFocus } from '../../hooks/useProperFocus'; @@ -344,32 +345,6 @@ const TransactionHeader = memo( TransactionHeader.displayName = 'TransactionHeader'; -function getPayeePretty(transaction, payee, transferAcct, numHiddenPayees = 0) { - const formatPayeeName = payeeName => - numHiddenPayees > 0 ? `${payeeName} (+${numHiddenPayees} more)` : payeeName; - - const { payee: payeeId } = transaction; - - if (transferAcct) { - return ( - - {formatPayeeName(transferAcct.name)} - - ); - } else if (payee) { - return formatPayeeName(payee.name); - } else if (payeeId && payeeId.startsWith('new:')) { - return formatPayeeName(payeeId.slice('new:'.length)); - } - - return ''; -} - function StatusCell({ id, focused, @@ -497,49 +472,6 @@ function HeaderCell({ ); } -const useParentPayee = ( - payees, - subtransactions, - transferAccountsByTransaction, -) => - useMemo(() => { - if (!subtransactions) { - return null; - } - - const { counts, mostCommonPayeeTransaction } = - subtransactions?.reduce( - ({ counts, ...result }, sub) => { - if (sub.payee) { - counts[sub.payee] = (counts[sub.payee] || 0) + 1; - if (counts[sub.payee] > result.maxCount) { - return { - counts, - maxCount: counts[sub.payee], - mostCommonPayeeTransaction: sub, - }; - } - } - return { counts, ...result }; - }, - { counts: {}, maxCount: 0, mostCommonPayeeTransaction: null }, - ) || {}; - - if (!mostCommonPayeeTransaction) { - return 'Split (no payee)'; - } - - const mostCommonPayee = - getPayeesById(payees)[mostCommonPayeeTransaction.payee]; - const numDistinctPayees = Object.keys(counts).length; - return getPayeePretty( - mostCommonPayeeTransaction, - mostCommonPayee, - transferAccountsByTransaction[mostCommonPayeeTransaction.id], - numDistinctPayees - 1, - ); - }, [subtransactions, payees, transferAccountsByTransaction]); - function PayeeCell({ id, payee, @@ -549,7 +481,6 @@ function PayeeCell({ transferAccountsByTransaction, valueStyle, transaction, - subtransactions, importedPayee, isPreview, onEdit, @@ -563,14 +494,10 @@ function PayeeCell({ const dispatch = useDispatch(); - const parentPayee = useParentPayee( - payees, - subtransactions, - transferAccountsByTransaction, - ); - const transferAccount = transferAccountsByTransaction[transaction.id]; + const displayPayee = useDisplayPayee({ transaction }); + return transaction.is_parent ? ( - {parentPayee} + {displayPayee} ) : ( - parentPayee + displayPayee )} @@ -680,11 +607,10 @@ function PayeeCell({ } }} formatter={() => { - const payeeName = getPayeePretty(transaction, payee, transferAccount); - if (!payeeName && isPreview) { + if (!displayPayee && isPreview) { return '(No payee)'; } - return payeeName; + return displayPayee; }} unexposedContent={props => { const payeeName = ( @@ -1307,7 +1233,6 @@ const Transaction = memo(function Transaction({ )} valueStyle={valueStyle} transaction={transaction} - subtransactions={subtransactions} transferAccountsByTransaction={transferAccountsByTransaction} importedPayee={importedPayee} isPreview={isPreview} diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.test.tsx b/packages/desktop-client/src/components/transactions/TransactionsTable.test.tsx index bb70db01c4f..d9896200e12 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.test.tsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.test.tsx @@ -48,6 +48,10 @@ vi.mock('../../hooks/useFeatureFlag', () => ({ })); const accounts = [generateAccount('Bank of America')]; +vi.mock('../../hooks/useAccounts', () => ({ + useAccounts: () => accounts, +})); + const payees: PayeeEntity[] = [ { id: 'bob-id', @@ -65,6 +69,15 @@ const payees: PayeeEntity[] = [ name: 'This guy on the side of the road', }, ]; +vi.mock('../../hooks/usePayees', async importOriginal => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importOriginal(); + return { + ...actual, + usePayees: () => payees, + }; +}); + const categoryGroups = generateCategoryGroups([ { name: 'Investments and Savings', @@ -79,6 +92,13 @@ const categoryGroups = generateCategoryGroups([ categories: [{ name: 'Big Projects' }, { name: 'Shed' }], }, ]); +vi.mock('../../hooks/useCategories', () => ({ + useCategories: () => ({ + list: categoryGroups.flatMap(g => g.categories), + grouped: categoryGroups, + }), +})); + const usualGroup = categoryGroups[1]; function generateTransactions( @@ -212,6 +232,11 @@ function initBasicServer() { return { data: payees, dependencies: [] }; case 'accounts': return { data: accounts, dependencies: [] }; + case 'transactions': + return { + data: generateTransactions(5, [6]), + dependencies: [], + }; default: throw new Error(`queried unknown table: ${query.table}`); } diff --git a/packages/desktop-client/src/hooks/useDisplayPayee.ts b/packages/desktop-client/src/hooks/useDisplayPayee.ts new file mode 100644 index 00000000000..ab2609b523c --- /dev/null +++ b/packages/desktop-client/src/hooks/useDisplayPayee.ts @@ -0,0 +1,138 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useTransactions } from 'loot-core/client/data-hooks/transactions'; +import { q } from 'loot-core/shared/query'; +import { + type AccountEntity, + type PayeeEntity, + type TransactionEntity, +} from 'loot-core/types/models'; + +import { useAccounts } from './useAccounts'; +import { usePayee } from './usePayee'; +import { usePayees } from './usePayees'; + +type Counts = { + counts: Record; + maxCount: number; + mostCommonPayeeTransaction: TransactionEntity | null; +}; + +type UseDisplayPayeeProps = { + transaction?: TransactionEntity | undefined; +}; + +export function useDisplayPayee({ transaction }: UseDisplayPayeeProps) { + const { t } = useTranslation(); + const subtransactionsQuery = useMemo( + () => q('transactions').filter({ parent_id: transaction?.id }).select('*'), + [transaction?.id], + ); + const { transactions: subtransactions = [] } = useTransactions({ + query: subtransactionsQuery, + }); + + const accounts = useAccounts(); + const payees = usePayees(); + const payee = usePayee(transaction?.payee || ''); + + return useMemo(() => { + if (subtransactions.length === 0) { + return getPrettyPayee({ + t, + transaction, + payee, + transferAccount: accounts.find( + a => + a.id === + payees.find(p => p.id === transaction?.payee)?.transfer_acct, + ), + }); + } + + const { counts, mostCommonPayeeTransaction } = + subtransactions?.reduce( + ({ counts, ...result }, sub) => { + if (sub.payee) { + counts[sub.payee] = (counts[sub.payee] || 0) + 1; + if (counts[sub.payee] > result.maxCount) { + return { + counts, + maxCount: counts[sub.payee], + mostCommonPayeeTransaction: sub, + }; + } + } + return { counts, ...result }; + }, + { counts: {}, maxCount: 0, mostCommonPayeeTransaction: null } as Counts, + ) || {}; + + if (!mostCommonPayeeTransaction) { + return t('Split (no payee)'); + } + + const mostCommonPayee = payees.find( + p => p.id === mostCommonPayeeTransaction.payee, + ); + + if (!mostCommonPayee) { + return t('Split (no payee)'); + } + + const numDistinctPayees = Object.keys(counts).length; + + return getPrettyPayee({ + t, + transaction: mostCommonPayeeTransaction, + payee: mostCommonPayee, + transferAccount: accounts.find( + a => + a.id === + payees.find(p => p.id === mostCommonPayeeTransaction.payee) + ?.transfer_acct, + ), + numHiddenPayees: numDistinctPayees - 1, + }); + }, [subtransactions, payees, accounts, transaction, payee, t]); +} + +type GetPrettyPayeeProps = { + t: ReturnType['t']; + transaction?: TransactionEntity | undefined; + payee?: PayeeEntity | undefined; + transferAccount?: AccountEntity | undefined; + numHiddenPayees?: number | undefined; +}; + +function getPrettyPayee({ + t, + transaction, + payee, + transferAccount, + numHiddenPayees = 0, +}: GetPrettyPayeeProps) { + if (!transaction) { + return ''; + } + + const formatPayeeName = (payeeName: string) => + numHiddenPayees > 0 + ? `${payeeName} ${t('(+{{numHiddenPayees}} more)', { + numHiddenPayees, + })}` + : payeeName; + + const { payee: payeeId } = transaction; + + if (transferAccount) { + return formatPayeeName(transferAccount.name); + } else if (payee) { + return formatPayeeName(payee.name); + } else if (payeeId && payeeId.startsWith('new:')) { + return formatPayeeName(payeeId.slice('new:'.length)); + } + + return ''; +} diff --git a/packages/loot-core/src/client/data-hooks/transactions.ts b/packages/loot-core/src/client/data-hooks/transactions.ts index dd35d0de227..302661e83b0 100644 --- a/packages/loot-core/src/client/data-hooks/transactions.ts +++ b/packages/loot-core/src/client/data-hooks/transactions.ts @@ -25,6 +25,10 @@ import { type PagedQuery, pagedQuery } from '../query-helpers'; import { type ScheduleStatuses, useCachedSchedules } from './schedules'; 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; options?: { pageCount?: number; diff --git a/upcoming-release-notes/4213.md b/upcoming-release-notes/4213.md new file mode 100644 index 00000000000..74d4924cbd8 --- /dev/null +++ b/upcoming-release-notes/4213.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [joel-jeremy] +--- + +useDisplayPayee hook to unify payee names in mobile and desktop.