From 9834215d5b8dfc69cc9dfba26cecc6542f54b086 Mon Sep 17 00:00:00 2001 From: Linh Date: Sun, 8 Mar 2026 15:27:18 +0700 Subject: [PATCH 1/8] chore: make changeMoneyRequestHoldStatus pure function receive iouTransaction --- src/components/MoneyReportHeader.tsx | 12 +- src/components/MoneyRequestHeader.tsx | 10 +- src/libs/ReportUtils.ts | 31 +- .../BaseReportActionContextMenu.tsx | 1 + .../report/ContextMenu/ContextMenuActions.tsx | 13 +- .../BaseReportActionContextMenuTest.tsx | 307 ++++++++++++++++++ tests/unit/ReportUtilsTest.ts | 104 ++++++ 7 files changed, 445 insertions(+), 33 deletions(-) create mode 100644 tests/ui/components/BaseReportActionContextMenuTest.tsx diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 73bcffccb11c..50e135dcc502 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -896,7 +896,7 @@ function MoneyReportHeader({ setIsHoldEducationalModalVisible(false); setNameValuePair(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, true, false, !shouldFailAllRequests); if (requestParentReportAction) { - changeMoneyRequestHoldStatus(requestParentReportAction); + changeMoneyRequestHoldStatus(requestParentReportAction, transaction); } }; @@ -904,7 +904,7 @@ function MoneyReportHeader({ if (rejectModalAction === CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD) { dismissRejectUseExplanation(); if (requestParentReportAction) { - changeMoneyRequestHoldStatus(requestParentReportAction); + changeMoneyRequestHoldStatus(requestParentReportAction, transaction); } } else if (rejectModalAction === CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK) { dismissRejectUseExplanation(); @@ -1204,7 +1204,7 @@ function MoneyReportHeader({ if (IOUActions.length) { for (const action of IOUActions) { - changeMoneyRequestHoldStatus(action); + changeMoneyRequestHoldStatus(action, transaction); } return; } @@ -1214,7 +1214,7 @@ function MoneyReportHeader({ return; } - changeMoneyRequestHoldStatus(moneyRequestAction); + changeMoneyRequestHoldStatus(moneyRequestAction, transaction); }} /> ), @@ -1464,7 +1464,7 @@ function MoneyReportHeader({ const isDismissed = isReportSubmitter ? dismissedHoldUseExplanation : dismissedRejectUseExplanation; if (isDismissed || isChatReportDM) { - changeMoneyRequestHoldStatus(requestParentReportAction); + changeMoneyRequestHoldStatus(requestParentReportAction, transaction); } else if (isReportSubmitter) { setIsHoldEducationalModalVisible(true); } else { @@ -1487,7 +1487,7 @@ function MoneyReportHeader({ return; } - changeMoneyRequestHoldStatus(requestParentReportAction); + changeMoneyRequestHoldStatus(requestParentReportAction, transaction); }, }, [CONST.REPORT.SECONDARY_ACTIONS.SPLIT]: { diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index cf1dca3ca4a0..4638102a9856 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -340,7 +340,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre return; } - changeMoneyRequestHoldStatus(parentReportAction); + changeMoneyRequestHoldStatus(parentReportAction, transaction); }} /> ), @@ -411,7 +411,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre setIsHoldEducationalModalVisible(false); setNameValuePair(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, true, false, !shouldFailAllRequests); if (parentReportAction) { - changeMoneyRequestHoldStatus(parentReportAction); + changeMoneyRequestHoldStatus(parentReportAction, transaction); } }; @@ -419,7 +419,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre if (rejectModalAction === CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD) { dismissRejectUseExplanation(); if (parentReportAction) { - changeMoneyRequestHoldStatus(parentReportAction); + changeMoneyRequestHoldStatus(parentReportAction, transaction); } } else { dismissRejectUseExplanation(); @@ -449,7 +449,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre const isDismissed = isReportSubmitter ? dismissedHoldUseExplanation : dismissedRejectUseExplanation; if (isDismissed || isParentChatReportDM) { - changeMoneyRequestHoldStatus(parentReportAction); + changeMoneyRequestHoldStatus(parentReportAction, transaction); } else if (isReportSubmitter) { setIsHoldEducationalModalVisible(true); } else { @@ -471,7 +471,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre return; } - changeMoneyRequestHoldStatus(parentReportAction); + changeMoneyRequestHoldStatus(parentReportAction, transaction); }, }, [CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.SPLIT]: { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 5b296a00bb19..fe7f24eedbdd 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1128,8 +1128,8 @@ Onyx.connect({ callback: (value) => (betaConfiguration = value ?? {}), }); -let allTransactions: OnyxCollection = {}; -let reportsTransactions: Record = {}; +let deprecatedAllTransactions: OnyxCollection = {}; +let deprecatedReportsTransactions: Record = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.TRANSACTION, waitForCollectionCallback: true, @@ -1137,9 +1137,9 @@ Onyx.connect({ if (!value) { return; } - allTransactions = Object.fromEntries(Object.entries(value).filter(([, transaction]) => transaction)); + deprecatedAllTransactions = Object.fromEntries(Object.entries(value).filter(([, transaction]) => transaction)); - reportsTransactions = Object.values(value).reduce>((all, transaction) => { + deprecatedReportsTransactions = Object.values(value).reduce>((all, transaction) => { const reportsMap = all; if (!transaction?.reportID) { return reportsMap; @@ -1287,7 +1287,7 @@ function reportTransactionsSelector(transactions: OnyxCollection, r return Object.values(transactions).filter((transaction): transaction is Transaction => !!transaction && transaction.reportID === reportID); } -function getReportTransactions(reportID: string | undefined, allReportsTransactions: Record = reportsTransactions): Transaction[] { +function getReportTransactions(reportID: string | undefined, allReportsTransactions: Record = deprecatedReportsTransactions): Transaction[] { if (!reportID) { return []; } @@ -2323,7 +2323,7 @@ function hasExpenses(reportID?: string, transactions?: Array transaction?.reportID === reportID); } - return !!Object.values(allTransactions ?? {}).find((transaction) => transaction?.reportID === reportID); + return !!Object.values(deprecatedAllTransactions ?? {}).find((transaction) => transaction?.reportID === reportID); } /** @@ -4370,7 +4370,7 @@ function requiresAttentionFromCurrentUser(optionOrReport: OnyxEntry | Op /** * Checks if the report contains at least one Non-Reimbursable transaction */ -function hasNonReimbursableTransactions(iouReportID: string | undefined, reportsTransactionsParam: Record = reportsTransactions): boolean { +function hasNonReimbursableTransactions(iouReportID: string | undefined, reportsTransactionsParam: Record = deprecatedReportsTransactions): boolean { const transactions = getReportTransactions(iouReportID, reportsTransactionsParam); return transactions.filter((transaction) => transaction.reimbursable === false).length > 0; } @@ -4867,7 +4867,7 @@ function canEditFieldOfMoneyRequest( const iouMessage = getOriginalMessage(reportAction); const moneyRequestReport = report ?? (iouMessage?.IOUReportID ? (getReport(iouMessage?.IOUReportID, allReports) ?? ({} as Report)) : ({} as Report)); - const transaction = linkedTransaction ?? allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${iouMessage?.IOUTransactionID}`] ?? ({} as Transaction); + const transaction = linkedTransaction ?? deprecatedAllTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${iouMessage?.IOUTransactionID}`] ?? ({} as Transaction); if (isSettled(String(moneyRequestReport.reportID)) || isReportIDApproved(String(moneyRequestReport.reportID))) { return false; @@ -5057,7 +5057,7 @@ function canHoldUnholdReportAction( return {canHoldRequest, canUnholdRequest}; } -const changeMoneyRequestHoldStatus = (reportAction: OnyxEntry): void => { +const changeMoneyRequestHoldStatus = (reportAction: OnyxEntry, iouTransaction: OnyxEntry): void => { if (!isMoneyRequestAction(reportAction)) { return; } @@ -5075,8 +5075,7 @@ const changeMoneyRequestHoldStatus = (reportAction: OnyxEntry): vo return; } - const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? ({} as Transaction); - const isOnHold = isOnHoldTransactionUtils(transaction); + const isOnHold = isOnHoldTransactionUtils(iouTransaction); const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport.policyID}`]; if (isOnHold) { @@ -5151,7 +5150,7 @@ function getLinkedTransaction(reportAction: OnyxEntry transaction.transactionID === transactionID) : allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + return transactions ? transactions.find((transaction) => transaction.transactionID === transactionID) : deprecatedAllTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; } /** @@ -8834,7 +8833,7 @@ function hasEmptyReportsForPolicy( reports: OnyxCollection | undefined, policyID: string | undefined, accountID?: number, - reportsTransactionsParam: Record = reportsTransactions, + reportsTransactionsParam: Record = deprecatedReportsTransactions, ): boolean { if (!accountID || !policyID) { return false; @@ -8876,7 +8875,7 @@ function hasEmptyReportsForPolicy( function getPolicyIDsWithEmptyReportsForAccount( reports: OnyxCollection | undefined, accountID?: number, - reportsTransactionsParam: Record = reportsTransactions, + reportsTransactionsParam: Record = deprecatedReportsTransactions, ): Record { if (!accountID) { return {}; @@ -10607,7 +10606,7 @@ function getReportActionWithSmartscanError(reportActions: ReportAction[], report } const transactionID = isSplitOrTrackAction ? getOriginalMessage(action)?.IOUTransactionID : undefined; - const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? {}; + const transaction = deprecatedAllTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? {}; const isTransactionThreadError = isSplitOrTrackAction && hasMissingSmartscanFieldsTransactionUtils(transaction as Transaction, report); return isTransactionThreadError; @@ -11215,7 +11214,7 @@ function createDraftTransactionAndNavigateToParticipantSelector({ return; } - const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? ({} as Transaction); + const transaction = deprecatedAllTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`] ?? ({} as Transaction); const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? ([] as ReportAction[]); if (!transaction || !reportActions) { diff --git a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx index cdd2b0054036..0ce28048a758 100755 --- a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -406,6 +406,7 @@ function BaseReportActionContextMenu({ currentUserAccountID: currentUserPersonalDetails?.accountID, currentUserPersonalDetails, encryptedAuthToken, + iouTransaction, }; if ('renderContent' in contextAction) { diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index c87e83c8a2d7..aa25d569012f 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -287,6 +287,7 @@ type ContextMenuActionPayload = { showDelegateNoAccessModal?: () => void; currentUserPersonalDetails: ReturnType; encryptedAuthToken: string; + iouTransaction: OnyxEntry; }; type OnPress = (closePopover: boolean, payload: ContextMenuActionPayload, selection?: string, reportID?: string, draftMessage?: string) => void; @@ -552,19 +553,19 @@ const ContextMenuActions: ContextMenuAction[] = [ const holdReportAction = getReportAction(moneyRequestAction?.childReportID, `${iouTransaction?.comment?.hold ?? ''}`); return canHoldUnholdReportAction(moneyRequestReport, moneyRequestAction, holdReportAction, iouTransaction, moneyRequestPolicy).canUnholdRequest; }, - onPress: (closePopover, {moneyRequestAction, isDelegateAccessRestricted, showDelegateNoAccessModal}) => { + onPress: (closePopover, {moneyRequestAction, iouTransaction, isDelegateAccessRestricted, showDelegateNoAccessModal}) => { if (isDelegateAccessRestricted) { hideContextMenu(false, showDelegateNoAccessModal); return; } if (closePopover) { - hideContextMenu(false, () => changeMoneyRequestHoldStatus(moneyRequestAction)); + hideContextMenu(false, () => changeMoneyRequestHoldStatus(moneyRequestAction, iouTransaction)); return; } // No popover to hide, call changeMoneyRequestHoldStatus immediately - changeMoneyRequestHoldStatus(moneyRequestAction); + changeMoneyRequestHoldStatus(moneyRequestAction, iouTransaction); }, getDescription: () => {}, sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.UNHOLD, @@ -580,19 +581,19 @@ const ContextMenuActions: ContextMenuAction[] = [ const holdReportAction = getReportAction(moneyRequestAction?.childReportID, `${iouTransaction?.comment?.hold ?? ''}`); return canHoldUnholdReportAction(moneyRequestReport, moneyRequestAction, holdReportAction, iouTransaction, moneyRequestPolicy).canHoldRequest; }, - onPress: (closePopover, {moneyRequestAction, isDelegateAccessRestricted, showDelegateNoAccessModal}) => { + onPress: (closePopover, {moneyRequestAction, iouTransaction, isDelegateAccessRestricted, showDelegateNoAccessModal}) => { if (isDelegateAccessRestricted) { hideContextMenu(false, showDelegateNoAccessModal); return; } if (closePopover) { - hideContextMenu(false, () => changeMoneyRequestHoldStatus(moneyRequestAction)); + hideContextMenu(false, () => changeMoneyRequestHoldStatus(moneyRequestAction, iouTransaction)); return; } // No popover to hide, call changeMoneyRequestHoldStatus immediately - changeMoneyRequestHoldStatus(moneyRequestAction); + changeMoneyRequestHoldStatus(moneyRequestAction, iouTransaction); }, getDescription: () => {}, sentryLabel: CONST.SENTRY_LABEL.CONTEXT_MENU.HOLD, diff --git a/tests/ui/components/BaseReportActionContextMenuTest.tsx b/tests/ui/components/BaseReportActionContextMenuTest.tsx new file mode 100644 index 000000000000..869ae4c0ea20 --- /dev/null +++ b/tests/ui/components/BaseReportActionContextMenuTest.tsx @@ -0,0 +1,307 @@ +import {act, render, waitFor} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import BaseReportActionContextMenu from '@pages/inbox/report/ContextMenu/BaseReportActionContextMenu'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {PersonalDetailsList} from '@src/types/onyx'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +jest.mock('@components/ActionSheetAwareScrollView', () => ({ + useActionSheetAwareScrollViewActions: () => ({ + transitionActionSheetState: jest.fn(), + }), +})); + +type MockContextMenuItemProps = { + sentryLabel?: string; + onPress?: (event: unknown) => void; + [key: string]: unknown; +}; + +const mockContextMenuItemProps: MockContextMenuItemProps[] = []; + +jest.mock('@components/ContextMenuItem', () => { + function MockContextMenuItem(props: MockContextMenuItemProps) { + const {sentryLabel, onPress} = props; + mockContextMenuItemProps.push({...props, sentryLabel, onPress}); + return null; + } + + return MockContextMenuItem; +}); + +jest.mock('@components/DelegateNoAccessModalProvider', () => ({ + useDelegateNoAccessState: () => ({isDelegateAccessRestricted: false}), + useDelegateNoAccessActions: () => ({showDelegateNoAccessModal: jest.fn()}), +})); + +jest.mock('@components/FocusTrap/FocusTrapForModal', () => { + function MockFocusTrapForModal({children}: {children: React.ReactNode}) { + return children; + } + + return MockFocusTrapForModal; +}); + +jest.mock('@components/OnyxListItemProvider', () => ({ + useSession: () => ({encryptedAuthToken: 'token'}), +})); + +jest.mock('@hooks/useArrowKeyFocusManager', () => () => [-1, jest.fn()] as const); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({accountID: 1, login: 'user@test.com', email: 'user@test.com'})); +jest.mock('@hooks/useEnvironment', () => () => ({isProduction: false})); +jest.mock('@hooks/useGetExpensifyCardFromReportAction', () => () => undefined); +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: () => ({ + Bell: undefined, + Bug: undefined, + ChatBubbleReply: undefined, + ChatBubbleUnread: undefined, + Checkmark: undefined, + Concierge: undefined, + Copy: undefined, + Download: undefined, + Exit: undefined, + Flag: undefined, + LinkCopy: undefined, + Mail: undefined, + Pencil: undefined, + Pin: undefined, + Stopwatch: undefined, + ThreeDots: undefined, + Trashcan: undefined, + }), +})); + +jest.mock('@hooks/useLocalize', () => () => ({ + translate: (key: string) => key, + getLocalDateFromDatetime: jest.fn(), +})); +jest.mock('@hooks/useNetwork', () => () => ({isOffline: false})); +jest.mock('@hooks/usePaginatedReportActions', () => () => ({reportActions: []})); +jest.mock('@hooks/useReportIsArchived', () => () => false); +jest.mock('@hooks/useResponsiveLayout', () => () => ({shouldUseNarrowLayout: true, isSmallScreenWidth: false})); +jest.mock('@hooks/useRestoreInputFocus', () => () => {}); +jest.mock('@hooks/useStyleUtils', () => () => ({ + getReportActionContextMenuStyles: () => ({}), +})); +jest.mock('@hooks/useTransactionsAndViolationsForReport', () => () => ({transactions: {}})); + +jest.mock('@userActions/Session', () => ({ + isAnonymousUser: () => false, + signOutAndRedirectToSignIn: jest.fn(), +})); + +jest.mock('@pages/inbox/report/ContextMenu/ReportActionContextMenu', () => ({ + hideContextMenu: jest.fn((_: boolean, onHideCallback?: () => void) => onHideCallback?.()), + showContextMenu: jest.fn(), +})); + +const mockUnholdRequest = jest.fn(); +jest.mock('@libs/actions/IOU/Hold', () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = jest.requireActual('@libs/actions/IOU/Hold'); + return { + ...actual, + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + unholdRequest: (...args: Parameters) => mockUnholdRequest(...args), + }; +}); + +const mockNavigate = jest.fn(); +const mockSetParams = jest.fn(); +const mockIsReady = jest.fn(() => false); +const mockGetActiveRoute = jest.fn(() => ''); +const mockGetCurrentRoute = jest.fn(() => undefined as {name: string; params: Record} | undefined); + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: (...args: unknown[]) => mockNavigate(...args) as void, + setParams: (...args: unknown[]) => mockSetParams(...args) as void, + getActiveRoute: () => mockGetActiveRoute(), + navigationRef: { + isReady: () => mockIsReady(), + getCurrentRoute: () => mockGetCurrentRoute(), + }, +})); + +const currentUserAccountID = 1; +const originalReportID = '100'; +const reportActionID = '200'; +const childReportID = '300'; +const iouReportID = '400'; +const transactionID = '500'; +const policyID = 'policy1'; +const holdActionID = 'holdAction1'; +const testPersonalDetails: PersonalDetailsList = { + [currentUserAccountID]: { + accountID: currentUserAccountID, + login: 'user@test.com', + displayName: 'Test User', + }, +}; +async function seedOnyxData({isOnHold}: {isOnHold: boolean}) { + await Onyx.clear(); + + // Session and personal details for current user (needed by canHoldUnholdReportAction / canModifyHoldStatus) + await Onyx.merge(ONYXKEYS.SESSION, { + accountID: currentUserAccountID, + email: 'user@test.com', + }); + + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, testPersonalDetails); + + // Policy (needed by canModifyHoldStatus for isPolicyAdmin, and by changeMoneyRequestHoldStatus) + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + id: policyID, + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + }); + + // Original report (chat report containing the report action) + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${originalReportID}`, { + reportID: originalReportID, + type: CONST.REPORT.TYPE.CHAT, + }); + + // Report actions on the original report + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${originalReportID}`, { + [reportActionID]: { + reportActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + childReportID, + }, + parentIOUAction: { + reportActionID: 'parentIOUAction', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: currentUserAccountID, + childReportID, + originalMessage: { + IOUReportID: iouReportID, + IOUTransactionID: transactionID, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + }, + }, + }); + + // Child report (expense report) linked from the report action + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${childReportID}`, { + reportID: childReportID, + type: CONST.REPORT.TYPE.EXPENSE, + parentReportID: originalReportID, + parentReportActionID: 'parentIOUAction', + policyID, + managerID: currentUserAccountID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }); + + // Report actions on child report + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${childReportID}`, { + iouAction: { + reportActionID: 'iouAction', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: currentUserAccountID, + childReportID, + originalMessage: { + IOUReportID: iouReportID, + IOUTransactionID: transactionID, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + }, + }, + ...(isOnHold + ? { + [holdActionID]: { + reportActionID: holdActionID, + actionName: CONST.REPORT.ACTIONS.TYPE.HOLD, + actorAccountID: currentUserAccountID, + }, + } + : {}), + }); + + // IOU report referenced by the money request action + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, { + reportID: iouReportID, + type: CONST.REPORT.TYPE.EXPENSE, + policyID, + managerID: currentUserAccountID, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }); + + // Transaction (on hold or not) + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + transactionID, + reportID: iouReportID, + comment: isOnHold ? {hold: holdActionID} : {}, + }); + + await waitForBatchedUpdates(); +} + +async function getContextMenuItemOnPress(sentryLabel: string): Promise<(event: unknown) => void> { + let contextMenuItem = mockContextMenuItemProps.find((item) => item.sentryLabel === sentryLabel); + + await waitFor(() => { + contextMenuItem = mockContextMenuItemProps.find((item) => item.sentryLabel === sentryLabel); + expect(contextMenuItem).toBeDefined(); + expect(contextMenuItem?.onPress).toBeDefined(); + }); + + return contextMenuItem?.onPress ?? (() => undefined); +} + +describe('BaseReportActionContextMenu hold/unhold action', () => { + beforeEach(async () => { + jest.clearAllMocks(); + mockContextMenuItemProps.length = 0; + }); + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); + }); + + it('navigates to hold reason page when pressing the hold action', async () => { + await seedOnyxData({isOnHold: false}); + + render( + , + ); + + const onPress = await getContextMenuItemOnPress(CONST.SENTRY_LABEL.CONTEXT_MENU.HOLD); + await act(async () => { + onPress({}); + }); + + expect(mockNavigate).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledWith(ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(CONST.POLICY.TYPE.TEAM, transactionID, childReportID, encodeURIComponent(mockGetActiveRoute()))); + }); + + it('calls unholdRequest when pressing the unhold action', async () => { + await seedOnyxData({isOnHold: true}); + + render( + , + ); + + const onPress = await getContextMenuItemOnPress(CONST.SENTRY_LABEL.CONTEXT_MENU.UNHOLD); + await act(async () => { + onPress({}); + }); + + expect(mockUnholdRequest).toHaveBeenCalledTimes(1); + expect(mockUnholdRequest).toHaveBeenCalledWith(transactionID, childReportID, expect.objectContaining({id: policyID})); + }); +}); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 081c6df5e3b4..428e40b1cfcc 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -4,10 +4,13 @@ import {act, renderHook} from '@testing-library/react-native'; import {addDays, format as formatDate} from 'date-fns'; import type {OnyxCollection, OnyxEntry, OnyxKey} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import {transaction} from 'tests/data/Invoice'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import usePolicyData from '@hooks/usePolicyData'; import useReportIsArchived from '@hooks/useReportIsArchived'; +// eslint-disable-next-line no-restricted-syntax +import * as HoldUtils from '@libs/actions/IOU/Hold'; import {putOnHold} from '@libs/actions/IOU/Hold'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import type {OnboardingTaskLinks} from '@libs/actions/Welcome/OnboardingFlow'; @@ -57,6 +60,7 @@ import { canRejectReportAction, canSeeDefaultRoom, canUserPerformWriteAction, + changeMoneyRequestHoldStatus, createDraftTransactionAndNavigateToParticipantSelector, doesReportBelongToWorkspace, excludeParticipantsForDisplay, @@ -4343,6 +4347,106 @@ describe('ReportUtils', () => { }); }); + describe('changeMoneyRequestHoldStatus', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should unhold request when transaction is already on hold', async () => { + // Given a money request report, report action, and transaction that is on hold + const reportID = '101'; + const policyID = '102'; + const transactionID = '123'; + const childReportID = '555'; + const moneyRequestReport = { + ...createExpenseReport(101), + reportID, + policyID, + } as Report; + const reportAction = buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 123, + currency: 'USD', + comment: '', + participants: [], + transactionID, + iouReportID: moneyRequestReport.reportID, + }); + reportAction.childReportID = childReportID; + + const iouTransaction = { + ...createRandomTransaction(123), + transactionID, + reportID: moneyRequestReport.reportID, + comment: { + hold: '999', + }, + } as Transaction; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport.reportID}`, moneyRequestReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport.policyID}`, { + id: moneyRequestReport.policyID, + type: CONST.POLICY.TYPE.TEAM, + }); + await waitForBatchedUpdates(); + + const unholdRequestSpy = jest.spyOn(HoldUtils, 'unholdRequest').mockImplementation(() => undefined); + + // When changeMoneyRequestHoldStatus is called + changeMoneyRequestHoldStatus(reportAction, iouTransaction); + + // Then unholdRequest should be called with the correct parameters and navigation should not be called + expect(unholdRequestSpy).toHaveBeenCalledWith(transactionID, childReportID, expect.objectContaining({id: policyID})); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('should navigate to hold reason when transaction is not on hold', async () => { + // Given a money request report, report action, and transaction that is not on hold + const reportID = '201'; + const policyID = '202'; + const transactionID = '456'; + const childReportID = '777'; + const moneyRequestReport = { + ...createExpenseReport(201), + reportID, + policyID, + } as Report; + + const reportAction = buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 123, + currency: 'USD', + comment: '', + participants: [], + transactionID, + iouReportID: moneyRequestReport.reportID, + }); + reportAction.childReportID = childReportID; + + const iouTransaction = { + ...createRandomTransaction(456), + transactionID, + reportID: moneyRequestReport.reportID, + comment: {}, + } as Transaction; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport.reportID}`, moneyRequestReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${moneyRequestReport.policyID}`, { + id: moneyRequestReport.policyID, + type: CONST.POLICY.TYPE.TEAM, + }); + await waitForBatchedUpdates(); + + // When changeMoneyRequestHoldStatus is called + changeMoneyRequestHoldStatus(reportAction, iouTransaction); + + // Then navigation should be called with the correct parameters + expect(Navigation.navigate).toHaveBeenCalledWith( + ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(CONST.POLICY.TYPE.TEAM, transactionID, childReportID, encodeURIComponent('mock-route')), + ); + }); + }); + describe('canDeleteMoneyRequestReport', () => { it('should allow deletion if the report is open invoice report', async () => { const invoiceReport = {...createInvoiceReport(343), ownerAccountID: currentUserAccountID, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}; From b42db89c13c970d439ea1b1e19223666ef6f22f0 Mon Sep 17 00:00:00 2001 From: Linh Date: Tue, 10 Mar 2026 18:45:03 +0700 Subject: [PATCH 2/8] chore: eslint fail --- tests/unit/ReportUtilsTest.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index 3980820e9867..aa43cf4d35ff 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -4,7 +4,6 @@ import {act, renderHook} from '@testing-library/react-native'; import {addDays, format as formatDate} from 'date-fns'; import type {OnyxCollection, OnyxEntry, OnyxKey} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import {transaction} from 'tests/data/Invoice'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import usePolicyData from '@hooks/usePolicyData'; From 59964c96be5dae5eff6e3890e031c83e50562c41 Mon Sep 17 00:00:00 2001 From: Linh Date: Tue, 10 Mar 2026 20:09:57 +0700 Subject: [PATCH 3/8] fix: look up the correct transaction for each action when bulk toggling hold status --- src/components/MoneyReportHeader.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index bd403bd49778..4b98ab7ab5d6 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1247,7 +1247,9 @@ function MoneyReportHeader({ if (IOUActions.length) { for (const action of IOUActions) { - changeMoneyRequestHoldStatus(action, transaction); + const actionIOUTransactionID = isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined; + const linkedTransaction = actionIOUTransactionID ? transactions.find((item) => item.transactionID === actionIOUTransactionID) : undefined; + changeMoneyRequestHoldStatus(action, linkedTransaction); } return; } From bb8f43c5d47c53d130aae8d68f77e12f728df108 Mon Sep 17 00:00:00 2001 From: Linh Date: Tue, 10 Mar 2026 20:15:08 +0700 Subject: [PATCH 4/8] fix: look up the correct transaction for each action when bulk toggling hold status --- src/components/MoneyReportHeader.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 4b98ab7ab5d6..b44f84653ded 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1258,8 +1258,9 @@ function MoneyReportHeader({ if (!moneyRequestAction) { return; } - - changeMoneyRequestHoldStatus(moneyRequestAction, transaction); + const actionIOUTransactionID = isMoneyRequestAction(moneyRequestAction) ? getOriginalMessage(moneyRequestAction)?.IOUTransactionID : undefined; + const linkedTransaction = actionIOUTransactionID ? transactions.find((item) => item.transactionID === actionIOUTransactionID) : undefined; + changeMoneyRequestHoldStatus(moneyRequestAction, linkedTransaction); }} /> ), From 22c80828f7bf7ff6dea5181ec31bcae1591f310d Mon Sep 17 00:00:00 2001 From: Linh Date: Tue, 10 Mar 2026 21:28:03 +0700 Subject: [PATCH 5/8] chore: add comment to explain ignore type error --- tests/ui/components/BaseReportActionContextMenuTest.tsx | 4 ++-- tests/unit/ReportUtilsTest.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ui/components/BaseReportActionContextMenuTest.tsx b/tests/ui/components/BaseReportActionContextMenuTest.tsx index 869ae4c0ea20..50e0059512f7 100644 --- a/tests/ui/components/BaseReportActionContextMenuTest.tsx +++ b/tests/ui/components/BaseReportActionContextMenuTest.tsx @@ -101,11 +101,11 @@ jest.mock('@pages/inbox/report/ContextMenu/ReportActionContextMenu', () => ({ const mockUnholdRequest = jest.fn(); jest.mock('@libs/actions/IOU/Hold', () => { - // eslint-disable-next-line @typescript-eslint/consistent-type-imports + // eslint-disable-next-line @typescript-eslint/consistent-type-imports -- Ignoring type errors for testing purposes const actual = jest.requireActual('@libs/actions/IOU/Hold'); return { ...actual, - // eslint-disable-next-line @typescript-eslint/no-unsafe-return + // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- Ignoring type errors for testing purposes unholdRequest: (...args: Parameters) => mockUnholdRequest(...args), }; }); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index aa43cf4d35ff..d55257c8dd35 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -8,7 +8,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import usePolicyData from '@hooks/usePolicyData'; import useReportIsArchived from '@hooks/useReportIsArchived'; -// eslint-disable-next-line no-restricted-syntax +// eslint-disable-next-line no-restricted-syntax -- Ignoring type errors for testing purposes import * as HoldUtils from '@libs/actions/IOU/Hold'; import {putOnHold} from '@libs/actions/IOU/Hold'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; From 47c5914e7da26b7e6673ee5ec744268aa5415c91 Mon Sep 17 00:00:00 2001 From: Linh Date: Tue, 10 Mar 2026 21:41:16 +0700 Subject: [PATCH 6/8] refactor: extract getLinkedIOUTransaction to deduplicate transaction lookup --- src/components/MoneyReportHeader.tsx | 9 +++------ src/libs/ReportUtils.ts | 6 ++++++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index b44f84653ded..a67b62778715 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -72,6 +72,7 @@ import { getAllReportActionsErrorsAndReportActionThatRequiresAttention, getIntegrationIcon, getIntegrationNameFromExportMessage as getIntegrationNameFromExportMessageUtils, + getLinkedIOUTransaction, getNextApproverAccountID, getNonHeldAndFullAmount, getPolicyExpenseChat, @@ -1247,9 +1248,7 @@ function MoneyReportHeader({ if (IOUActions.length) { for (const action of IOUActions) { - const actionIOUTransactionID = isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined; - const linkedTransaction = actionIOUTransactionID ? transactions.find((item) => item.transactionID === actionIOUTransactionID) : undefined; - changeMoneyRequestHoldStatus(action, linkedTransaction); + changeMoneyRequestHoldStatus(action, getLinkedIOUTransaction(action, transactions)); } return; } @@ -1258,9 +1257,7 @@ function MoneyReportHeader({ if (!moneyRequestAction) { return; } - const actionIOUTransactionID = isMoneyRequestAction(moneyRequestAction) ? getOriginalMessage(moneyRequestAction)?.IOUTransactionID : undefined; - const linkedTransaction = actionIOUTransactionID ? transactions.find((item) => item.transactionID === actionIOUTransactionID) : undefined; - changeMoneyRequestHoldStatus(moneyRequestAction, linkedTransaction); + changeMoneyRequestHoldStatus(moneyRequestAction, getLinkedIOUTransaction(moneyRequestAction, transactions)); }} /> ), diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 7ea6caafbb02..f13c5e210f2e 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -13087,6 +13087,11 @@ function getReportFieldMaps(report: OnyxEntry, fieldList: Record, transactions: Transaction[]): OnyxEntry { + const transactionID = isMoneyRequestAction(reportAction) ? getOriginalMessage(reportAction)?.IOUTransactionID : undefined; + return transactionID ? transactions.find((item) => item.transactionID === transactionID) : undefined; +} + export { areAllRequestsBeingSmartScanned, buildOptimisticAddCommentReportAction, @@ -13497,6 +13502,7 @@ export { getReportForHeader, isReportOpenOrUnsubmitted, getIconsForExpenseReport, + getLinkedIOUTransaction, }; export type { From b8ead1194eecd80fce9d27ee4ce479d4de2fc09b Mon Sep 17 00:00:00 2001 From: Linh Date: Thu, 12 Mar 2026 16:03:04 +0700 Subject: [PATCH 7/8] chore: write unit test for getLinkedIOUTransaction --- tests/unit/ReportUtilsTest.ts | 54 +++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index a38217f279be..0e15890b51dc 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -79,6 +79,7 @@ import { getIconsForParticipants, getIndicatedMissingPaymentMethod, getIOUReportActionDisplayMessage, + getLinkedIOUTransaction, getMostRecentlyVisitedReport, getOriginalReportID, getOutstandingChildRequest, @@ -4589,6 +4590,59 @@ describe('ReportUtils', () => { }); }); + describe('getLinkedIOUTransaction', () => { + it('should return the matching transaction for a money request action', () => { + const transactionID = 'txn-123'; + const transaction = { + ...createRandomTransaction(100), + transactionID, + } as Transaction; + const otherTransaction = { + ...createRandomTransaction(200), + transactionID: 'txn-other', + } as Transaction; + + const reportAction = buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 100, + currency: 'USD', + comment: '', + participants: [], + transactionID, + iouReportID: 'report-1', + }); + + const result = getLinkedIOUTransaction(reportAction, [otherTransaction, transaction]); + expect(result).toBe(transaction); + }); + + it('should return undefined when no matching transaction exists', () => { + const reportAction = buildOptimisticIOUReportAction({ + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 100, + currency: 'USD', + comment: '', + participants: [], + transactionID: 'txn-missing', + iouReportID: 'report-1', + }); + + const result = getLinkedIOUTransaction(reportAction, []); + expect(result).toBeUndefined(); + }); + + it('should return undefined for non-money-request actions', () => { + const action = buildOptimisticCreatedReportAction('user@test.com'); + const transaction = { + ...createRandomTransaction(100), + transactionID: 'txn-123', + } as Transaction; + + const result = getLinkedIOUTransaction(action, [transaction]); + expect(result).toBeUndefined(); + }); + }); + describe('canDeleteMoneyRequestReport', () => { it('should allow deletion if the report is open invoice report', async () => { const invoiceReport = {...createInvoiceReport(343), ownerAccountID: currentUserAccountID, stateNum: CONST.REPORT.STATE_NUM.OPEN, statusNum: CONST.REPORT.STATUS_NUM.OPEN}; From 092a22abf7fb826cfd2738fca161022b71cd9b1c Mon Sep 17 00:00:00 2001 From: Linh Date: Thu, 12 Mar 2026 16:11:51 +0700 Subject: [PATCH 8/8] chore: add condition for iouTransaction --- src/libs/ReportUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index d4a81409cfef..1afd57b4b3fe 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -5084,8 +5084,8 @@ const changeMoneyRequestHoldStatus = (reportAction: OnyxEntry, iou const transactionID = getOriginalMessage(reportAction)?.IOUTransactionID; - if (!transactionID) { - Log.warn('Missing transactionID during the change of the money request hold status'); + if (!transactionID || !iouTransaction) { + Log.warn('Missing transactionID or iouTransaction during the change of the money request hold status'); return; }