Skip to content
8 changes: 4 additions & 4 deletions __mocks__/reportData/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const REPORT_R14932 = {
childReportID: 'CHILD_REPORT_ID_R14932',
};

const originalMessageR14932: OriginalMessageIOU = {
const originalMessageR14932 = {
currency,
amount,
IOUReportID: REPORT_R14932.IOUReportID,
Expand All @@ -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 = [
{
Expand All @@ -50,7 +50,7 @@ const person = [
},
];

const actionR14932: ReportAction = {
const actionR14932: ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.IOU> = {
person,
message,
reportActionID: REPORT_R14932.reportActionID,
Expand Down Expand Up @@ -86,4 +86,4 @@ const actionR98765: ReportAction = {
created: '2025-02-14 08:12:05.165',
};

export {actionR14932, actionR98765};
export {actionR14932, actionR98765, originalMessageR14932};
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
isConsecutiveChronosAutomaticTimerAction,
isCurrentActionUnread,
isDeletedParentAction,
isIOUActionMatchingTransactionList,
shouldReportActionBeVisible,
wasMessageReceivedWhileOffline,
} from '@libs/ReportActionsUtils';
Expand Down Expand Up @@ -123,6 +124,7 @@ function MoneyRequestReportActionsList({
const [isVisible, setIsVisible] = useState(Visibility.isVisible);
const isFocused = useIsFocused();
const route = useRoute<PlatformStackRouteProp<ReportsSplitNavigatorParamList, typeof SCREENS.REPORT>>();
const reportTransactionIDs = transactions.map((transaction) => transaction.transactionID);

const reportID = report?.reportID;
const linkedReportActionID = route?.params?.reportActionID;
Expand All @@ -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});
Expand Down Expand Up @@ -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);
Expand Down
37 changes: 35 additions & 2 deletions src/libs/ReportActionsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = (
Comment thread
JakubKorytko marked this conversation as resolved.
action: ReportAction,
reportTransactionIDs?: string[],
defaultToFalseForNonIOU = false,
): action is ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.IOU> => {
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.
Expand All @@ -1193,6 +1224,7 @@ function getOneTransactionThreadReportID(
reportID: string | undefined,
reportActions: OnyxEntry<ReportActions> | 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}`];
Expand All @@ -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;
}
Expand Down Expand Up @@ -2529,6 +2561,7 @@ export {
isForwardedAction,
isWhisperActionTargetedToOthers,
isTagModificationAction,
isIOUActionMatchingTransactionList,
isResolvedActionableWhisper,
shouldHideNewMarker,
shouldReportActionBeVisible,
Expand Down
3 changes: 2 additions & 1 deletion src/pages/home/ReportScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
14 changes: 11 additions & 3 deletions src/pages/home/report/ReportActionsView.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -25,6 +26,7 @@ import {
getSortedReportActionsForDisplay,
isCreatedAction,
isDeletedParentAction,
isIOUActionMatchingTransactionList,
isMoneyRequestAction,
shouldReportActionBeVisible,
} from '@libs/ReportActionsUtils';
Expand Down Expand Up @@ -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<OnyxTypes.Transaction>) =>
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
Expand Down Expand Up @@ -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]);
Expand Down
52 changes: 52 additions & 0 deletions tests/unit/ReportActionsUtilsTest.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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[] = [
Expand Down
7 changes: 3 additions & 4 deletions tests/unit/ReportSecondaryActionUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});
Expand Down
Loading