diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 26779cf6ce82..22ec069e61c8 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -73,6 +73,7 @@ import { getAllReportActionsErrorsAndReportActionThatRequiresAttention, getIntegrationIcon, getIntegrationNameFromExportMessage as getIntegrationNameFromExportMessageUtils, + getLinkedIOUTransaction, getNextApproverAccountID, getNonHeldAndFullAmount, getPolicyExpenseChat, @@ -947,7 +948,7 @@ function MoneyReportHeader({ setIsHoldEducationalModalVisible(false); setNameValuePair(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, true, false, !shouldFailAllRequests); if (requestParentReportAction) { - changeMoneyRequestHoldStatus(requestParentReportAction); + changeMoneyRequestHoldStatus(requestParentReportAction, transaction); } }; @@ -955,7 +956,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(); @@ -1254,7 +1255,7 @@ function MoneyReportHeader({ if (IOUActions.length) { for (const action of IOUActions) { - changeMoneyRequestHoldStatus(action); + changeMoneyRequestHoldStatus(action, getLinkedIOUTransaction(action, transactions)); } return; } @@ -1263,8 +1264,7 @@ function MoneyReportHeader({ if (!moneyRequestAction) { return; } - - changeMoneyRequestHoldStatus(moneyRequestAction); + changeMoneyRequestHoldStatus(moneyRequestAction, getLinkedIOUTransaction(moneyRequestAction, transactions)); }} /> ), @@ -1526,7 +1526,7 @@ function MoneyReportHeader({ const isDismissed = isReportSubmitter ? dismissedHoldUseExplanation : dismissedRejectUseExplanation; if (isDismissed || isChatReportDM) { - changeMoneyRequestHoldStatus(requestParentReportAction); + changeMoneyRequestHoldStatus(requestParentReportAction, transaction); } else if (isReportSubmitter) { setIsHoldEducationalModalVisible(true); } else { @@ -1549,7 +1549,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 e86b5aa05bd5..ac991fd062da 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -361,7 +361,7 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre return; } - changeMoneyRequestHoldStatus(parentReportAction); + changeMoneyRequestHoldStatus(parentReportAction, transaction); }} /> ), @@ -432,7 +432,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); } }; @@ -440,7 +440,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(); @@ -470,7 +470,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 { @@ -492,7 +492,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 910024b7f1cd..3abe4a07e6b2 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -1140,8 +1140,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, @@ -1149,9 +1149,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); } /** @@ -4387,7 +4387,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; } @@ -4885,7 +4885,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; @@ -5075,7 +5075,7 @@ function canHoldUnholdReportAction( return {canHoldRequest, canUnholdRequest}; } -const changeMoneyRequestHoldStatus = (reportAction: OnyxEntry): void => { +const changeMoneyRequestHoldStatus = (reportAction: OnyxEntry, iouTransaction: OnyxEntry): void => { if (!isMoneyRequestAction(reportAction)) { return; } @@ -5088,13 +5088,12 @@ const changeMoneyRequestHoldStatus = (reportAction: OnyxEntry): vo 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; } - 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) { @@ -5169,7 +5168,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}`]; } /** @@ -8860,7 +8859,7 @@ function hasEmptyReportsForPolicy( reports: OnyxCollection | undefined, policyID: string | undefined, accountID?: number, - reportsTransactionsParam: Record = reportsTransactions, + reportsTransactionsParam: Record = deprecatedReportsTransactions, ): boolean { if (!accountID || !policyID) { return false; @@ -8902,7 +8901,7 @@ function hasEmptyReportsForPolicy( function getPolicyIDsWithEmptyReportsForAccount( reports: OnyxCollection | undefined, accountID?: number, - reportsTransactionsParam: Record = reportsTransactions, + reportsTransactionsParam: Record = deprecatedReportsTransactions, ): Record { if (!accountID) { return {}; @@ -10638,7 +10637,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; @@ -11246,7 +11245,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) { @@ -13069,6 +13068,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, buildConciergeGreetingReportAction, @@ -13479,6 +13483,7 @@ export { getReportForHeader, isReportOpenOrUnsubmitted, getIconsForExpenseReport, + getLinkedIOUTransaction, }; export type { diff --git a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx index 86d5ff1d046c..5f714cd5a77a 100755 --- a/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/BaseReportActionContextMenu.tsx @@ -407,6 +407,7 @@ function BaseReportActionContextMenu({ currentUserAccountID: currentUserPersonalDetails?.accountID, currentUserPersonalDetails, encryptedAuthToken, + iouTransaction, bankAccountList, }; diff --git a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx index 4602cf6b237b..625c4fa9340f 100644 --- a/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx +++ b/src/pages/inbox/report/ContextMenu/ContextMenuActions.tsx @@ -288,6 +288,7 @@ type ContextMenuActionPayload = { showDelegateNoAccessModal?: () => void; currentUserPersonalDetails: ReturnType; encryptedAuthToken: string; + iouTransaction: OnyxEntry; bankAccountList: OnyxEntry; }; @@ -554,19 +555,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, @@ -582,19 +583,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..50e0059512f7 --- /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 -- 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 -- Ignoring type errors for testing purposes + 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 9db06b91b916..2924aad985fb 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -8,6 +8,8 @@ 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 -- 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'; import type {OnboardingTaskLinks} from '@libs/actions/Welcome/OnboardingFlow'; @@ -57,6 +59,7 @@ import { canRejectReportAction, canSeeDefaultRoom, canUserPerformWriteAction, + changeMoneyRequestHoldStatus, createDraftTransactionAndNavigateToParticipantSelector, doesReportBelongToWorkspace, excludeParticipantsForDisplay, @@ -76,6 +79,7 @@ import { getIconsForParticipants, getIndicatedMissingPaymentMethod, getIOUReportActionDisplayMessage, + getLinkedIOUTransaction, getMostRecentlyVisitedReport, getOriginalReportID, getOutstandingChildRequest, @@ -4485,6 +4489,159 @@ 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('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};