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.
|