diff --git a/__mocks__/reportData/actions.ts b/__mocks__/reportData/actions.ts index 795dbc161793..573bbc9dea16 100644 --- a/__mocks__/reportData/actions.ts +++ b/__mocks__/reportData/actions.ts @@ -19,7 +19,7 @@ const REPORT_R14932 = { childReportID: 'CHILD_REPORT_ID_R14932', }; -const originalMessageR14932: OriginalMessageIOU = { +const originalMessageR14932 = { currency, amount, IOUReportID: REPORT_R14932.IOUReportID, @@ -28,7 +28,7 @@ const originalMessageR14932: OriginalMessageIOU = { type: CONST.IOU.TYPE.CREATE, lastModified: '2025-02-14 08:12:05.165', comment: '', -}; +} satisfies OriginalMessageIOU; const message = [ { @@ -50,7 +50,7 @@ const person = [ }, ]; -const actionR14932: ReportAction = { +const actionR14932: ReportAction = { person, message, reportActionID: REPORT_R14932.reportActionID, @@ -86,4 +86,4 @@ const actionR98765: ReportAction = { created: '2025-02-14 08:12:05.165', }; -export {actionR14932, actionR98765}; +export {actionR14932, actionR98765, originalMessageR14932}; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index a9777ede65bd..a2576951dd31 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -39,6 +39,7 @@ import { isConsecutiveChronosAutomaticTimerAction, isCurrentActionUnread, isDeletedParentAction, + isIOUActionMatchingTransactionList, shouldReportActionBeVisible, wasMessageReceivedWhileOffline, } from '@libs/ReportActionsUtils'; @@ -123,6 +124,7 @@ function MoneyRequestReportActionsList({ const [isVisible, setIsVisible] = useState(Visibility.isVisible); const isFocused = useIsFocused(); const route = useRoute>(); + const reportTransactionIDs = transactions.map((transaction) => transaction.transactionID); const reportID = report?.reportID; const linkedReportActionID = route?.params?.reportActionID; @@ -134,7 +136,7 @@ function MoneyRequestReportActionsList({ }); const mostRecentIOUReportActionID = useMemo(() => getMostRecentIOURequestActionID(reportActions), [reportActions]); - const transactionThreadReportID = getOneTransactionThreadReportID(reportID, reportActions ?? [], false); + const transactionThreadReportID = getOneTransactionThreadReportID(reportID, reportActions ?? [], false, reportTransactionIDs); const firstVisibleReportActionID = useMemo(() => getFirstVisibleReportActionID(reportActions, isOffline), [reportActions, isOffline]); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, {canBeMissing: true}); const [currentUserAccountID] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false, selector: (session) => session?.accountID}); @@ -164,12 +166,13 @@ function MoneyRequestReportActionsList({ return ( isActionVisibleOnMoneyReport && (isOffline || isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors) && - shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canPerformWriteAction) + shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canPerformWriteAction) && + isIOUActionMatchingTransactionList(reportAction, reportTransactionIDs) ); }); return filteredActions.toReversed(); - }, [reportActions, isOffline, canPerformWriteAction]); + }, [reportActions, isOffline, canPerformWriteAction, reportTransactionIDs]); const reportActionSize = useRef(visibleReportActions.length); const lastAction = visibleReportActions.at(-1); diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 523e1f0c0d05..f2a85f3f0269 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -10,7 +10,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Card, Locale, OnyxInputOrEntry, PrivatePersonalDetails} from '@src/types/onyx'; +import type {Card, Locale, OnyxInputOrEntry, OriginalMessageIOU, PrivatePersonalDetails} from '@src/types/onyx'; import type {JoinWorkspaceResolution, OriginalMessageChangeLog, OriginalMessageExportIntegration} from '@src/types/onyx/OriginalMessage'; import type {PolicyReportFieldType} from '@src/types/onyx/Policy'; import type Report from '@src/types/onyx/Report'; @@ -1185,6 +1185,37 @@ function isTagModificationAction(actionName: string): boolean { ); } +/** Whether action has no linked report by design */ +const isIOUActionTypeExcludedFromFiltering = (type: OriginalMessageIOU['type'] | undefined) => + [CONST.IOU.REPORT_ACTION_TYPE.SPLIT, CONST.IOU.REPORT_ACTION_TYPE.TRACK, CONST.IOU.REPORT_ACTION_TYPE.PAY].some((actionType) => actionType === type); + +/** + * Determines whether the given action is an IOU and, if a list of report transaction IDs is provided, + * whether it corresponds to one of those transactions. This covers a rare case where IOU report actions was + * not deleted or moved after the expense was removed from the report. + * + * For compatibility and to avoid using isMoneyRequest next to this function as it is checked here already: + * - If the action is not a money request and `defaultToFalseForNonIOU` is false (default), the result is true. + * - If no `reportTransactionIDs` are provided, the function returns true if the action is an IOU. + * - If `reportTransactionIDs` are provided, the function checks if the IOU transaction ID from the action matches any of them. + */ +const isIOUActionMatchingTransactionList = ( + action: ReportAction, + reportTransactionIDs?: string[], + defaultToFalseForNonIOU = false, +): action is ReportAction => { + if (!isMoneyRequestAction(action)) { + return !defaultToFalseForNonIOU; + } + + if (isIOUActionTypeExcludedFromFiltering(getOriginalMessage(action)?.type) || reportTransactionIDs === undefined) { + return true; + } + + const {IOUTransactionID} = getOriginalMessage(action) ?? {}; + return !!IOUTransactionID && reportTransactionIDs.includes(IOUTransactionID); +}; + /** * Gets the reportID for the transaction thread associated with a report by iterating over the reportActions and identifying the IOU report actions. * Returns a reportID if there is exactly one transaction thread for the report, and null otherwise. @@ -1193,6 +1224,7 @@ function getOneTransactionThreadReportID( reportID: string | undefined, reportActions: OnyxEntry | ReportAction[], isOffline: boolean | undefined = undefined, + reportTransactionIDs?: string[], ): string | undefined { // If the report is not an IOU, Expense report, or Invoice, it shouldn't be treated as one-transaction report. const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; @@ -1207,7 +1239,7 @@ function getOneTransactionThreadReportID( const iouRequestActions = []; for (const action of reportActionsArray) { - if (!isMoneyRequestAction(action)) { + if (!isIOUActionMatchingTransactionList(action, reportTransactionIDs, true)) { // eslint-disable-next-line no-continue continue; } @@ -2529,6 +2561,7 @@ export { isForwardedAction, isWhisperActionTargetedToOthers, isTagModificationAction, + isIOUActionMatchingTransactionList, isResolvedActionableWhisper, shouldHideNewMarker, shouldReportActionBeVisible, diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 2abc3760b3e6..e28add3bb9b5 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -303,7 +303,8 @@ function ReportScreen({route, navigation}: ReportScreenProps) { selector: (allTransactions): OnyxTypes.Transaction[] => selectAllTransactionsForReport(allTransactions, reportIDFromRoute, reportActions), canBeMissing: false, }); - const transactionThreadReportID = getOneTransactionThreadReportID(reportID, reportActions ?? [], isOffline); + const reportTransactionIDs = reportTransactions?.map((transaction) => transaction.transactionID); + const transactionThreadReportID = getOneTransactionThreadReportID(reportID, reportActions ?? [], isOffline, reportTransactionIDs); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, {canBeMissing: true}); const [transactionThreadReportActions = {}] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, {canBeMissing: true}); const combinedReportActions = getCombinedReportActions(reportActions, transactionThreadReportID ?? null, Object.values(transactionThreadReportActions)); diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index 8ce56cb58c19..c0679a28f2b1 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -1,7 +1,7 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; import useCopySelectionHelper from '@hooks/useCopySelectionHelper'; @@ -13,6 +13,7 @@ import {updateLoadingInitialReportAction} from '@libs/actions/Report'; import Timing from '@libs/actions/Timing'; import DateUtils from '@libs/DateUtils'; import getIsReportFullyVisible from '@libs/getIsReportFullyVisible'; +import {selectAllTransactionsForReport} from '@libs/MoneyRequestReportUtils'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; import {generateNewRandomInt, rand64} from '@libs/NumberUtils'; @@ -25,6 +26,7 @@ import { getSortedReportActionsForDisplay, isCreatedAction, isDeletedParentAction, + isIOUActionMatchingTransactionList, isMoneyRequestAction, shouldReportActionBeVisible, } from '@libs/ReportActionsUtils'; @@ -93,6 +95,11 @@ function ReportActionsView({ const prevShouldUseNarrowLayoutRef = useRef(shouldUseNarrowLayout); const reportID = report.reportID; const isReportFullyVisible = useMemo((): boolean => getIsReportFullyVisible(isFocused), [isFocused]); + const [reportTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { + selector: (allTransactions: OnyxCollection) => + selectAllTransactionsForReport(allTransactions, reportID, allReportActions ?? []).map((transaction) => transaction.transactionID), + canBeMissing: true, + }); useEffect(() => { // When we linked to message - we do not need to wait for initial actions - they already exists @@ -192,9 +199,10 @@ function ReportActionsView({ reportActions.filter( (reportAction) => (isOffline || isDeletedParentAction(reportAction) || reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || reportAction.errors) && - shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canPerformWriteAction), + shouldReportActionBeVisible(reportAction, reportAction.reportActionID, canPerformWriteAction) && + isIOUActionMatchingTransactionList(reportAction, reportTransactionIDs), ), - [reportActions, isOffline, canPerformWriteAction], + [reportActions, isOffline, canPerformWriteAction, reportTransactionIDs], ); const newestReportAction = useMemo(() => reportActions?.at(0), [reportActions]); diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index bcbdcc8da684..a3f05912c014 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -1,8 +1,10 @@ import type {KeyValueMapping} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import {isExpenseReport} from '@libs/ReportUtils'; +import {actionR14932 as mockIOUAction, originalMessageR14932 as mockOriginalMessage} from '../../__mocks__/reportData/actions'; import CONST from '../../src/CONST'; import * as ReportActionsUtils from '../../src/libs/ReportActionsUtils'; +import {isIOUActionMatchingTransactionList} from '../../src/libs/ReportActionsUtils'; import ONYXKEYS from '../../src/ONYXKEYS'; import type {Report, ReportAction} from '../../src/types/onyx'; import createRandomReport from '../utils/collections/reports'; @@ -301,6 +303,56 @@ describe('ReportActionsUtils', () => { }); }); + describe('isIOUActionMatchingTransactionList', () => { + const nonIOUAction = { + created: '2022-11-13 22:27:01.825', + reportActionID: '8401445780099176', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + originalMessage: { + html: 'Hello world', + whisperedTo: [], + }, + message: [ + { + html: 'Hello world', + type: 'Action type', + text: 'Action text', + }, + ], + }; + + it('returns false for non-money request actions when defaultToFalseForNonIOU is true', () => { + expect(isIOUActionMatchingTransactionList(nonIOUAction, undefined, true)).toBeFalsy(); + }); + + it('returns true for non-money request actions when defaultToFalseForNonIOU is false', () => { + expect(isIOUActionMatchingTransactionList(nonIOUAction, undefined, false)).toBeTruthy(); + }); + + it('returns true if no reportTransactionIDs are provided', () => { + expect(isIOUActionMatchingTransactionList(mockIOUAction)).toBeTruthy(); + }); + + it('returns true if action is of excluded type', () => { + const action = { + ...mockIOUAction, + originalMessage: { + ...mockOriginalMessage, + type: CONST.IOU.REPORT_ACTION_TYPE.TRACK, + }, + }; + expect(isIOUActionMatchingTransactionList(action, ['124', '125', '126'])).toBeTruthy(); + }); + + it('returns true if IOUTransactionID matches any provided reportTransactionIDs', () => { + expect(isIOUActionMatchingTransactionList(mockIOUAction, ['123', '124', mockOriginalMessage.IOUTransactionID])).toBeTruthy(); + }); + + it('returns false if IOUTransactionID does not match any provided reportTransactionIDs', () => { + expect(isIOUActionMatchingTransactionList(mockIOUAction, ['123', '124'])).toBeFalsy(); + }); + }); + describe('getSortedReportActionsForDisplay', () => { it('should filter out non-whitelisted actions', () => { const input: ReportAction[] = [ diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 1c6bbe5806f0..d3fb7c1e84ac 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -2,11 +2,10 @@ import Onyx from 'react-native-onyx'; import {getSecondaryReportActions, getSecondaryTransactionThreadActions} from '@libs/ReportSecondaryActionUtils'; import CONST from '@src/CONST'; import * as ReportActionsUtils from '@src/libs/ReportActionsUtils'; -import {getOriginalMessage} from '@src/libs/ReportActionsUtils'; import * as ReportUtils from '@src/libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {OriginalMessageIOU, Policy, Report, ReportAction, Transaction, TransactionViolation} from '@src/types/onyx'; -import {actionR14932} from '../../__mocks__/reportData/actions'; +import type {Policy, Report, ReportAction, Transaction, TransactionViolation} from '@src/types/onyx'; +import {actionR14932, originalMessageR14932} from '../../__mocks__/reportData/actions'; const EMPLOYEE_ACCOUNT_ID = 1; const EMPLOYEE_EMAIL = 'employee@mail.com'; @@ -400,7 +399,7 @@ describe('getSecondaryAction', () => { const policy = {} as unknown as Policy; jest.spyOn(ReportUtils, 'canHoldUnholdReportAction').mockReturnValueOnce({canHoldRequest: true, canUnholdRequest: true}); - jest.spyOn(ReportActionsUtils, 'getOneTransactionThreadReportID').mockReturnValueOnce((getOriginalMessage(actionR14932) as OriginalMessageIOU).IOUTransactionID); + jest.spyOn(ReportActionsUtils, 'getOneTransactionThreadReportID').mockReturnValueOnce(originalMessageR14932.IOUTransactionID); const result = getSecondaryReportActions(report, [transaction], {}, policy, undefined, [actionR14932]); expect(result.includes(CONST.REPORT.SECONDARY_ACTIONS.HOLD)).toBe(true); });