diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 325e3824dadc..ba016d4176ce 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -33,7 +33,7 @@ import Log from '@libs/Log'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; import Performance from '@libs/Performance'; -import {canEditFieldOfMoneyRequest, canHoldUnholdReportAction, selectFilteredReportActions} from '@libs/ReportUtils'; +import {canEditFieldOfMoneyRequest, canHoldUnholdReportAction, isOneTransactionReport, selectFilteredReportActions} from '@libs/ReportUtils'; import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils'; import { createAndOpenSearchTransactionThread, @@ -121,7 +121,7 @@ function mapTransactionItemToSelectedEntry(item: TransactionListItemType, outsta amount: item.modifiedAmount ?? item.amount, convertedAmount: item.convertedAmount, currency: item.currency, - isFromOneTransactionReport: item.isFromOneTransactionReport, + isFromOneTransactionReport: isOneTransactionReport(item.report), ownerAccountID: item.reportAction?.actorAccountID, }, ]; @@ -203,7 +203,7 @@ function prepareTransactionsList(item: TransactionListItemType, selectedTransact convertedAmount: item.convertedAmount, convertedCurrency: item.convertedCurrency, currency: item.currency, - isFromOneTransactionReport: item.isFromOneTransactionReport, + isFromOneTransactionReport: isOneTransactionReport(item.report), ownerAccountID: item.reportAction?.actorAccountID, }, }; @@ -675,7 +675,7 @@ function Search({ // If we're trying to open a transaction without a transaction thread, let's create the thread and navigate the user if (isTransactionItem && item.transactionThreadReportID === CONST.REPORT.UNREPORTED_REPORT_ID) { // If the report is unreported (self DM), we want to open the track expense thread instead of a report with an ID of 0 - const shouldOpenTransactionThread = !item.isFromOneTransactionReport || item.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const shouldOpenTransactionThread = !isOneTransactionReport(item.report) || item.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; createAndOpenSearchTransactionThread(item, hash, backTo, undefined, shouldOpenTransactionThread); if (shouldOpenTransactionThread) { return; @@ -721,12 +721,15 @@ function Search({ return; } - const isFromSelfDM = item.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + let reportID = item.reportID; + if (isTransactionItem && item.transactionThreadReportID !== CONST.REPORT.UNREPORTED_REPORT_ID) { + const isFromSelfDM = item.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const isFromOneTransactionReport = isOneTransactionReport(item.report); - const reportID = - isTransactionItem && (!item.isFromOneTransactionReport || isFromSelfDM) && item.transactionThreadReportID !== CONST.REPORT.UNREPORTED_REPORT_ID - ? item.transactionThreadReportID - : item.reportID; + if (isFromSelfDM || !isFromOneTransactionReport) { + reportID = item.transactionThreadReportID; + } + } if (!reportID) { return; diff --git a/src/libs/MoneyRequestReportUtils.ts b/src/libs/MoneyRequestReportUtils.ts index 248c2ea890b2..4c4415a1d8ce 100644 --- a/src/libs/MoneyRequestReportUtils.ts +++ b/src/libs/MoneyRequestReportUtils.ts @@ -13,6 +13,7 @@ import { hasUpdatedTotal, isInvoiceReport, isMoneyRequestReport, + isOneTransactionReport, isReportTransactionThread, } from './ReportUtils'; import {isTransactionPendingDelete} from './TransactionUtils'; @@ -62,8 +63,9 @@ function getThreadReportIDsForTransactions(reportActions: ReportAction[], transa */ function getReportIDForTransaction(transactionItem: TransactionListItemType) { const isFromSelfDM = transactionItem.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const isFromOneTransactionReport = isOneTransactionReport(transactionItem.report); - return (!transactionItem.isFromOneTransactionReport || isFromSelfDM) && transactionItem.transactionThreadReportID !== CONST.REPORT.UNREPORTED_REPORT_ID + return (!isFromOneTransactionReport || isFromSelfDM) && transactionItem.transactionThreadReportID !== CONST.REPORT.UNREPORTED_REPORT_ID ? transactionItem.transactionThreadReportID : transactionItem.reportID; } diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index ef6dd168b651..4a0b6a2360a7 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2551,6 +2551,14 @@ function hasOnlyNonReimbursableTransactions(iouReportID: string | undefined): bo * Checks if a report has only one transaction associated with it */ function isOneTransactionReport(report: OnyxEntry): boolean { + return report?.transactionCount === 1; +} + +/** + * Checks if a report has only one transaction associated with it + * @deprecated - Use isOneTransactionReport instead + */ +function isOneTransactionReportDeprecated(report: OnyxEntry): boolean { const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`] ?? ([] as ReportAction[]); const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; return !!getOneTransactionThreadReportID(report, chatReport, reportActions); @@ -2560,7 +2568,8 @@ function isOneTransactionReport(report: OnyxEntry): boolean { * Whether the report contains only one expense and the expense should be paid later */ function isPayAtEndExpenseReport(report: OnyxEntry, transactions: Transaction[] | undefined): boolean { - if ((!!transactions && transactions.length !== 1) || !isOneTransactionReport(report)) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + if ((!!transactions && transactions.length !== 1) || !isOneTransactionReportDeprecated(report)) { return false; } @@ -3601,7 +3610,8 @@ function getIconsForIOUReport(report: OnyxInputOrEntry, personalDetails: const isManager = currentUserAccountID === report?.managerID; // For one transaction IOUs, display a simplified report icon - if (isOneTransactionReport(report)) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + if (isOneTransactionReportDeprecated(report)) { return [ownerIcon]; } @@ -13056,6 +13066,7 @@ export { getUnresolvedCardFraudAlertAction, shouldBlockSubmitDueToStrictPolicyRules, isWorkspaceChat, + isOneTransactionReport, }; export type { Ancestor, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 6c0a0055c7c9..d0bbce32b915 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -103,6 +103,7 @@ import { isClosedReport, isInvoiceReport, isMoneyRequestReport, + isOneTransactionReport, isOpenExpenseReport, isOpenReport, isSettled, @@ -1291,7 +1292,7 @@ function getActions( } // Submit/Approve/Pay can only be taken on transactions if the transaction is the only one on the report, otherwise `View` is the only option. // If this condition is not met, return early for performance reasons - if (isTransaction && !transaction?.isFromOneTransactionReport) { + if (isTransaction && !isOneTransactionReport(report)) { return allActions.length > 0 ? allActions : [CONST.SEARCH.ACTION_TYPES.VIEW]; } diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts index 619d9dbf4fde..1d97714062a2 100644 --- a/src/types/onyx/SearchResults.ts +++ b/src/types/onyx/SearchResults.ts @@ -122,6 +122,9 @@ type SearchReport = { /** The status of the current report */ statusNum?: ValueOf; + /** Number of transactions in the report */ + transactionCount?: number; + /** For expense reports, this is the total amount requested */ unheldTotal?: number; @@ -248,9 +251,6 @@ type SearchTransaction = { /** The ID of the money request reportAction associated with the transaction */ moneyRequestReportActionID?: string; - /** Whether the transaction report has only a single transaction */ - isFromOneTransactionReport?: boolean; - /** Whether the transaction has violations or errors */ errors?: OnyxCommon.Errors; diff --git a/tests/actions/EnforceActionExportRestrictions.ts b/tests/actions/EnforceActionExportRestrictions.ts index 5c467834715d..b63c6dbeed4f 100644 --- a/tests/actions/EnforceActionExportRestrictions.ts +++ b/tests/actions/EnforceActionExportRestrictions.ts @@ -18,9 +18,9 @@ describe('ReportUtils', () => { expect(ReportUtils.getReport).toBeUndefined(); }); - it('does not export isOneTransactionReport', () => { + it('does not export isOneTransactionReportDeprecated', () => { // @ts-expect-error the test is asserting that it's undefined, so the TS error is normal - expect(ReportUtils.isOneTransactionReport).toBeUndefined(); + expect(ReportUtils.isOneTransactionReportDeprecated).toBeUndefined(); }); it('does not export getPolicy', () => { diff --git a/tests/unit/MoneyRequestReportUtilsTest.ts b/tests/unit/MoneyRequestReportUtilsTest.ts index 1c0effc8e764..3639707df781 100644 --- a/tests/unit/MoneyRequestReportUtilsTest.ts +++ b/tests/unit/MoneyRequestReportUtilsTest.ts @@ -1,40 +1,141 @@ import type {TransactionListItemType} from '@components/SelectionListWithSections/types'; import {getReportIDForTransaction} from '@libs/MoneyRequestReportUtils'; import CONST from '@src/CONST'; +import type {Policy, Report, ReportAction} from '@src/types/onyx'; -const transactionItemBaseMock = { - reportID: 'report123', - transactionThreadReportID: 'thread123', -} as TransactionListItemType; +const policyBaseMock: Policy = { + id: '123456789A', + name: 'Policy', + role: 'admin', + outputCurrency: 'USD', + type: 'team', + owner: 'admin@test.com', + isPolicyExpenseChatEnabled: true, +}; + +const reportBaseMock: Report = { + chatReportID: '1706144653204915', + created: '2024-12-21 13:05:20', + currency: 'USD', + isWaitingOnBankAccount: false, + managerID: 100, + nonReimbursableTotal: 0, + ownerAccountID: 100, + policyID: policyBaseMock.id, + reportID: '123', + reportName: 'Expense Report #123', + stateNum: 1, + statusNum: 1, + total: -5000, + type: 'expense', + unheldTotal: -5000, + transactionCount: 5, +}; + +const reportActionBaseMock: ReportAction = { + accountID: 100, + actorAccountID: 100, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2024-12-21 13:05:21', + message: [{type: 'COMMENT', html: 'IOU', text: 'IOU'}], + reportActionID: '11111111', + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + IOUTransactionID: '555', + IOUReportID: reportBaseMock.reportID, + }, + reportID: reportBaseMock.reportID, +}; + +const transactionItemBaseMock: TransactionListItemType = { + action: 'submit', + allActions: ['submit'], + amount: -5000, + report: reportBaseMock, + policy: policyBaseMock, + reportAction: reportActionBaseMock, + holdReportAction: undefined, + canDelete: true, + cardID: undefined, + cardName: undefined, + category: '', + comment: {comment: ''}, + created: '2024-12-21', + currency: 'USD', + date: '2024-12-21', + formattedFrom: 'Admin', + formattedMerchant: 'Expense', + formattedTo: '', + formattedTotal: 5000, + from: { + accountID: 18439984, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_3.png', + displayName: 'Admin', + login: 'admin@test.com', + }, + hasEReceipt: false, + keyForList: '1', + merchant: 'Expense', + modifiedAmount: 0, + modifiedCreated: '', + modifiedCurrency: '', + modifiedMerchant: 'Expense', + parentTransactionID: '', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + reportID: reportBaseMock.reportID, + shouldShowMerchant: true, + shouldShowYear: true, + isAmountColumnWide: false, + isTaxAmountColumnWide: false, + tag: '', + to: { + accountID: 0, + avatar: '', + displayName: undefined, + login: undefined, + }, + transactionID: '1', + transactionThreadReportID: '456', + receipt: undefined, + taxAmount: undefined, + mccGroup: undefined, + modifiedMCCGroup: undefined, + moneyRequestReportActionID: '789', + errors: undefined, + filename: undefined, + violations: [], + convertedAmount: -5000, + convertedCurrency: 'USD', +}; describe('MoneyRequestReportUtils', () => { describe('getReportIDForTransaction', () => { it('returns transaction thread ID if its not from one transaction report', () => { - const transactionItem = {...transactionItemBaseMock}; + const transactionItem: TransactionListItemType = {...transactionItemBaseMock}; const resultID = getReportIDForTransaction(transactionItem); - expect(resultID).toBe('thread123'); + expect(resultID).toBe('456'); }); it('returns transaction thread ID if its from self DM', () => { - const transactionItem = {...transactionItemBaseMock, reportID: CONST.REPORT.UNREPORTED_REPORT_ID}; + const transactionItem: TransactionListItemType = {...transactionItemBaseMock, reportID: CONST.REPORT.UNREPORTED_REPORT_ID}; const resultID = getReportIDForTransaction(transactionItem); - expect(resultID).toBe('thread123'); + expect(resultID).toBe('456'); }); it('returns expense reportID if its from one transaction report', () => { - const transactionItem = {...transactionItemBaseMock, isFromOneTransactionReport: true}; + const transactionItem: TransactionListItemType = {...transactionItemBaseMock, report: {...reportBaseMock, transactionCount: 1}}; const resultID = getReportIDForTransaction(transactionItem); - expect(resultID).toBe('report123'); + expect(resultID).toBe('123'); }); it('returns reportID if transaction thread ID is 0 - unreported', () => { - const transactionItem = {...transactionItemBaseMock, transactionThreadReportID: CONST.REPORT.UNREPORTED_REPORT_ID}; + const transactionItem: TransactionListItemType = {...transactionItemBaseMock, transactionThreadReportID: CONST.REPORT.UNREPORTED_REPORT_ID}; const resultID = getReportIDForTransaction(transactionItem); - expect(resultID).toBe('report123'); + expect(resultID).toBe('123'); }); }); }); diff --git a/tests/unit/ReportUtilsGetIconsTest.ts b/tests/unit/ReportUtilsGetIconsTest.ts index 7441f8d04f80..e8724686c942 100644 --- a/tests/unit/ReportUtilsGetIconsTest.ts +++ b/tests/unit/ReportUtilsGetIconsTest.ts @@ -277,6 +277,7 @@ describe('getIcons', () => { type: CONST.REPORT.TYPE.IOU, ownerAccountID: 1, managerID: 2, + transactionCount: 1, }; // Verify report type conditions diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 7d34e9206ca9..5da1dd411344 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -102,6 +102,7 @@ const report1 = { total: -5000, type: 'expense', unheldTotal: -5000, + transactionCount: 1, } as const; const report2 = { @@ -123,6 +124,7 @@ const report2 = { total: -5000, type: 'expense', unheldTotal: -5000, + transactionCount: 1, } as const; const report3 = { @@ -147,6 +149,7 @@ const report3 = { total: 4400, type: 'iou', unheldTotal: 4400, + transactionCount: 2, } as const; const report4 = { @@ -177,6 +180,7 @@ const report5 = { total: 0, type: 'expense', unheldTotal: 0, + transactionCount: 0, } as const; const reportAction1: OnyxTypes.ReportAction = { @@ -392,7 +396,6 @@ const searchResults: OnyxTypes.SearchResults = { created: '2024-12-21', currency: 'USD', hasEReceipt: false, - isFromOneTransactionReport: true, merchant: 'Expense', modifiedAmount: 0, modifiedCreated: '', @@ -426,7 +429,6 @@ const searchResults: OnyxTypes.SearchResults = { created: '2024-12-21', currency: 'USD', hasEReceipt: false, - isFromOneTransactionReport: true, merchant: 'Expense', modifiedAmount: 0, modifiedCreated: '', @@ -461,7 +463,6 @@ const searchResults: OnyxTypes.SearchResults = { created: '2025-03-05', currency: 'VND', hasEReceipt: false, - isFromOneTransactionReport: false, merchant: '(none)', modifiedAmount: 0, modifiedCreated: '', @@ -495,7 +496,6 @@ const searchResults: OnyxTypes.SearchResults = { created: '2025-03-05', currency: 'VND', hasEReceipt: false, - isFromOneTransactionReport: false, merchant: '(none)', modifiedAmount: 0, modifiedCreated: '', @@ -792,7 +792,6 @@ const transactionsListItems = [ login: adminEmail, }, hasEReceipt: false, - isFromOneTransactionReport: true, keyForList: '1', merchant: 'Expense', modifiedAmount: 0, @@ -848,7 +847,6 @@ const transactionsListItems = [ login: adminEmail, }, hasEReceipt: false, - isFromOneTransactionReport: true, keyForList: '2', merchant: 'Expense', modifiedAmount: 0, @@ -903,7 +901,6 @@ const transactionsListItems = [ created: '2025-03-05', currency: 'VND', hasEReceipt: false, - isFromOneTransactionReport: false, merchant: '(none)', modifiedAmount: 0, modifiedCreated: '', @@ -964,7 +961,6 @@ const transactionsListItems = [ created: '2025-03-05', currency: 'VND', hasEReceipt: false, - isFromOneTransactionReport: false, merchant: '(none)', modifiedAmount: 0, modifiedCreated: '', @@ -1043,6 +1039,7 @@ const transactionReportGroupListItems = [ statusNum: 0, to: emptyPersonalDetails, total: -5000, + transactionCount: 1, transactions: [ { action: 'submit', @@ -1071,7 +1068,6 @@ const transactionReportGroupListItems = [ login: adminEmail, }, hasEReceipt: false, - isFromOneTransactionReport: true, keyForList: '1', merchant: 'Expense', modifiedAmount: 0, @@ -1140,6 +1136,7 @@ const transactionReportGroupListItems = [ login: adminEmail, }, total: -5000, + transactionCount: 1, transactions: [ { action: 'review', @@ -1174,7 +1171,6 @@ const transactionReportGroupListItems = [ type: CONST.VIOLATION_TYPES.VIOLATION, }, ], - isFromOneTransactionReport: true, keyForList: '2', merchant: 'Expense', modifiedAmount: 0, @@ -1236,6 +1232,7 @@ const transactionReportGroupListItems = [ stateNum: 1, statusNum: 1, total: 4400, + transactionCount: 2, type: 'iou', unheldTotal: 4400, action: 'pay', @@ -1286,6 +1283,7 @@ const transactionReportGroupListItems = [ statusNum: 0, to: emptyPersonalDetails, total: 0, + transactionCount: 0, transactions: [], type: 'expense', unheldTotal: 0, @@ -1654,13 +1652,18 @@ describe('SearchUIUtils', () => { expect(action).toStrictEqual(CONST.SEARCH.ACTION_TYPES.VIEW); }); test('Should return `View` action for a transaction in a multi-transaction report', () => { + const multiTransactionReportID = 'report_multi'; const multiTransactionID = 'transaction_multi'; const localSearchResults = { ...searchResults.data, + [`report_${multiTransactionReportID}`]: { + ...searchResults.data[`report_${reportID}`], + transactionCount: 2, + }, [`transactions_${multiTransactionID}`]: { ...searchResults.data[`transactions_${transactionID}`], transactionID: multiTransactionID, - isFromOneTransactionReport: false, + reportID: multiTransactionReportID, }, }; const action = SearchUIUtils.getActions(localSearchResults, {}, `transactions_${multiTransactionID}`, CONST.SEARCH.SEARCH_KEYS.EXPENSES, adminAccountID, '').at(0); @@ -1791,7 +1794,7 @@ describe('SearchUIUtils', () => { expect(distanceTransaction).toBeDefined(); expect(distanceTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE); - const expectedPropertyCount = 50; + const expectedPropertyCount = 49; expect(Object.keys(distanceTransaction ?? {}).length).toBe(expectedPropertyCount); }); @@ -1824,7 +1827,7 @@ describe('SearchUIUtils', () => { expect(distanceTransaction).toBeDefined(); expect(distanceTransaction?.iouRequestType).toBe(CONST.IOU.REQUEST_TYPE.DISTANCE); - const expectedPropertyCount = 50; + const expectedPropertyCount = 49; expect(Object.keys(distanceTransaction ?? {}).length).toBe(expectedPropertyCount); }); @@ -2419,7 +2422,6 @@ describe('SearchUIUtils', () => { created: '2025-05-26', currency: 'USD', hasEReceipt: false, - isFromOneTransactionReport: true, merchant: '(none)', modifiedAmount: -1000, modifiedCreated: '2025-05-22', @@ -2546,7 +2548,6 @@ describe('SearchUIUtils', () => { created: '2025-05-26', currency: 'USD', hasEReceipt: false, - isFromOneTransactionReport: true, merchant: '(none)', modifiedAmount: -1000, modifiedCreated: '2025-05-22', diff --git a/tests/unit/Search/handleActionButtonPressTest.ts b/tests/unit/Search/handleActionButtonPressTest.ts index 2ec366ead79f..0cd7612eee2c 100644 --- a/tests/unit/Search/handleActionButtonPressTest.ts +++ b/tests/unit/Search/handleActionButtonPressTest.ts @@ -101,7 +101,6 @@ const mockReportItemWithHold = { created: '2024-12-04', currency: 'USD', hasEReceipt: false, - isFromOneTransactionReport: false, managerID: 1206, merchant: 'Qatar', modifiedAmount: 0, @@ -190,7 +189,6 @@ const mockReportItemWithHold = { created: '2024-12-04', currency: 'USD', hasEReceipt: false, - isFromOneTransactionReport: false, merchant: 'Forbes', modifiedAmount: 0, modifiedCreated: '',