diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 99e360c6ffb7..c8b6e06fd46a 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -11,7 +11,7 @@ import {getCurrencyUnit} from './CurrencyUtils'; import Navigation from './Navigation/Navigation'; import {isPaidGroupPolicy} from './PolicyUtils'; import {getOriginalMessage, isMoneyRequestAction} from './ReportActionsUtils'; -import {isSelfDM} from './ReportUtils'; +import {generateReportID, getChatByParticipants, isSelfDM} from './ReportUtils'; import {endSpan, getSpan, startSpan} from './telemetry/activeSpans'; import {getTagArrayFromName} from './TransactionUtils'; @@ -449,6 +449,16 @@ function getInitialPerDiemTargetReport( return {targetReport, targetIouType, transactionReportID}; } +/** + * Resolves the chat report ID for navigation, generating an optimistic ID if no existing chat is found. + */ +function resolveOptimisticChatReportID(participantAccountIDs: number[], existingReport?: OnyxInputOrEntry) { + const existingChat = existingReport?.reportID ? existingReport : getChatByParticipants(participantAccountIDs); + const optimisticChatReportID = existingChat?.reportID ? undefined : generateReportID(); + const chatReportID = existingChat?.reportID ?? optimisticChatReportID; + return {optimisticChatReportID, chatReportID}; +} + export { calculateAmount, calculateSplitAmountFromPercentage, @@ -466,4 +476,5 @@ export { navigateToConfirmationPage, calculateDefaultReimbursable, getInitialPerDiemTargetReport, + resolveOptimisticChatReportID, }; diff --git a/src/libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab.ts b/src/libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab.ts new file mode 100644 index 000000000000..5159b7cd865f --- /dev/null +++ b/src/libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab.ts @@ -0,0 +1,87 @@ +import {InteractionManager} from 'react-native'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import {getSpan} from '@libs/telemetry/activeSpans'; +import {endSubmitFollowUpActionSpan, setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import isReportOpenInRHP from './isReportOpenInRHP'; +import isReportOpenInSuperWideRHP from './isReportOpenInSuperWideRHP'; +import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute'; +import setNavigationActionToMicrotaskQueue from './setNavigationActionToMicrotaskQueue'; + +/** + * After finishing the action in RHP from the Inbox tab, besides dismissing the modal, we should open the report. + * If the action is done from the report RHP, then we just want to dismiss the money request flow screens. + */ +function dismissModalAndOpenReportInInboxTab(reportID: string | undefined, isInvoice: boolean | undefined, hasMultipleTransactions: boolean) { + const rootState = navigationRef.getRootState(); + const hasSubmitToDestinationVisibleSpan = !!getSpan(CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE); + + if (!isInvoice && isReportOpenInRHP(rootState)) { + const rhpKey = rootState.routes.at(-1)?.state?.key; + if (rhpKey) { + const isSuperWideRHP = isReportOpenInSuperWideRHP(rootState); + + // submit_follow_up_action: only set when the span was started. + if (hasSubmitToDestinationVisibleSpan) { + if (isSuperWideRHP) { + setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY, reportID); + } else if (hasMultipleTransactions && reportID) { + setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT, reportID); + } else { + setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY, reportID); + } + } + // When a report is opened in the super wide RHP, we need to dismiss to the first RHP to show the same report with new expense. + if (isSuperWideRHP) { + Navigation.dismissToPreviousRHP(); + return; + } + // When a report with one expense is opened in the wide RHP and the user adds another expense, RHP should be dismissed and ROUTES.SEARCH_MONEY_REQUEST_REPORT should be displayed. + if (hasMultipleTransactions && reportID) { + // On small screens, dismiss all modals and then navigate to the right report. + // On large screens, dismiss to the previous RHP first, then replace the current route with the new report. + const isNarrowLayout = getIsNarrowLayout(); + if (isNarrowLayout) { + Navigation.dismissModal(); + } else { + Navigation.dismissToPreviousRHP(); + } + setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID}), {forceReplace: !isNarrowLayout}); + }); + return; + } + Navigation.pop(rhpKey); + return; + } + } + if (isSearchTopmostFullScreenRoute() || !reportID) { + if (hasSubmitToDestinationVisibleSpan) { + setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY); + } + Navigation.dismissModal(); + if (hasSubmitToDestinationVisibleSpan) { + // eslint-disable-next-line @typescript-eslint/no-deprecated -- we need to wait for the modal to be dismissed before marking the span + InteractionManager.runAfterInteractions(() => { + endSubmitFollowUpActionSpan(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY); + }); + } + return; + } + if (hasSubmitToDestinationVisibleSpan) { + Navigation.dismissModalWithReport({reportID}, undefined, { + onBeforeNavigate: (willOpenReport) => { + setPendingSubmitFollowUpAction( + willOpenReport ? CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT : CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY, + reportID, + ); + }, + }); + } else { + Navigation.dismissModalWithReport({reportID}); + } +} + +export default dismissModalAndOpenReportInInboxTab; diff --git a/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts new file mode 100644 index 000000000000..8d53a052f9ec --- /dev/null +++ b/src/libs/Navigation/helpers/navigateAfterExpenseCreate.ts @@ -0,0 +1,81 @@ +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; +import Log from '@libs/Log'; +import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; +import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction'; +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import dismissModalAndOpenReportInInboxTab from './dismissModalAndOpenReportInInboxTab'; +import isReportTopmostSplitNavigator from './isReportTopmostSplitNavigator'; +import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute'; + +type NavigateAfterExpenseCreateParams = { + activeReportID?: string; + transactionID?: string; + isFromGlobalCreate?: boolean; + isInvoice?: boolean; + hasMultipleTransactions: boolean; +}; + +/** + * Helper to navigate after an expense is created in order to standardize the post‑creation experience + * when creating an expense from the global create button. + * If the expense is created from the global create button then: + * - If it is created on the inbox tab, it will open the chat report containing that expense. + * - If it is created elsewhere, it will navigate to Reports > Expense and highlight the newly created expense. + */ +function navigateAfterExpenseCreate({activeReportID, transactionID, isFromGlobalCreate, isInvoice, hasMultipleTransactions}: NavigateAfterExpenseCreateParams) { + const isUserOnInbox = isReportTopmostSplitNavigator(); + + // If the expense is not created from global create or is currently on the inbox tab, + // we just need to dismiss the money request flow screens + // and open the report chat containing the IOU report + if (!isFromGlobalCreate || isUserOnInbox || !transactionID) { + dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice, hasMultipleTransactions); + return; + } + + const type = isInvoice ? CONST.SEARCH.DATA_TYPES.INVOICE : CONST.SEARCH.DATA_TYPES.EXPENSE; + + // When already on Search ROOT with the same type (expense vs invoice), we navigate to the same screen (no-op or refresh); record as dismiss_modal_only. + // When on another Search sub-tab (e.g. Chats), or on Search with a different type (e.g. on Invoice, submitting expense), record as navigate_to_search. + const rootState = navigationRef.getRootState(); + const searchNavigatorRoute = rootState?.routes?.findLast((route) => route.name === NAVIGATORS.SEARCH_FULLSCREEN_NAVIGATOR); + const lastSearchRoute = searchNavigatorRoute?.state?.routes?.at(-1); + const alreadyOnSearchRoot = isSearchTopmostFullScreenRoute() && lastSearchRoute?.name === SCREENS.SEARCH.ROOT; + const currentSearchQueryJSON = alreadyOnSearchRoot ? getCurrentSearchQueryJSON() : undefined; + const isSameSearchType = currentSearchQueryJSON?.type === type; + setPendingSubmitFollowUpAction( + alreadyOnSearchRoot && isSameSearchType ? CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_ONLY : CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH, + ); + + const queryString = buildCannedSearchQuery({type}); + const navigateToSearch = () => { + // On the fast path, onConfirm already cleared the flag and dismissed the modal, + // so this branch is only reached on the slow path (user submitted before the + // 300ms pre-insert timer fired). + if (getIsNarrowLayout() && Navigation.getIsFullscreenPreInsertedUnderRHP()) { + Navigation.clearFullscreenPreInsertedFlag(); + Navigation.dismissModal(); + } else if (getIsNarrowLayout()) { + const isRHPStillOnTop = navigationRef.getRootState()?.routes?.at(-1)?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; + if (!alreadyOnSearchRoot || !isSameSearchType || isRHPStillOnTop) { + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString}), {forceReplace: true}); + } else { + Log.info('[IOU] navigateToSearch: already on matching Search root with RHP dismissed — no-op'); + } + } else { + Navigation.revealRouteBeforeDismissingModal(ROUTES.SEARCH_ROOT.getRoute({query: queryString})); + } + }; + + if (navigationRef.isReady()) { + navigateToSearch(); + } else { + Navigation.isNavigationReady().then(navigateToSearch); + } +} + +export default navigateAfterExpenseCreate; diff --git a/src/libs/actions/IOU/PerDiem.ts b/src/libs/actions/IOU/PerDiem.ts index 7a6d4d86c264..347884a50751 100644 --- a/src/libs/actions/IOU/PerDiem.ts +++ b/src/libs/actions/IOU/PerDiem.ts @@ -58,7 +58,6 @@ import { getPolicyTags, getReportPreviewAction, getUserAccountID, - handleNavigateAfterExpenseCreate, highlightTransactionOnSearchRouteIfNeeded, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies, @@ -232,10 +231,10 @@ type PerDiemExpenseInformation = { betas: OnyxEntry; customUnitPolicyID?: string; personalDetails: OnyxEntry; - shouldHandleNavigation?: boolean; shouldPlaySound?: boolean; optimisticReportPreviewActionID?: string; shouldDeferAutoSubmit?: boolean; + optimisticChatReportID?: string; }; type PerDiemExpenseInformationParams = { @@ -255,6 +254,7 @@ type PerDiemExpenseInformationParams = { betas: OnyxEntry; optimisticReportPreviewActionID?: string; personalDetails: OnyxEntry; + optimisticChatReportID?: string; }; type PerDiemExpenseInformationForSelfDM = { @@ -303,6 +303,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI betas, optimisticReportPreviewActionID, personalDetails, + optimisticChatReportID, } = perDiemExpenseInformation; const {payeeAccountID = getUserAccountID(), payeeEmail = getCurrentUserEmail(), participant} = participantParams; const {policy, policyCategories, policyTagList, policyRecentlyUsedCategories, policyRecentlyUsedTags} = policyParams; @@ -340,6 +341,7 @@ function getPerDiemExpenseInformation(perDiemExpenseInformation: PerDiemExpenseI isNewChatReport = true; chatReport = buildOptimisticChatReport({ participantList: [payerAccountID, payeeAccountID], + optimisticReportID: optimisticChatReportID, currentUserAccountID: currentUserAccountIDParam, }); } @@ -890,12 +892,11 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf betas, customUnitPolicyID, personalDetails, - shouldHandleNavigation = true, shouldPlaySound: shouldPlaySoundParam = true, optimisticReportPreviewActionID, shouldDeferAutoSubmit, + optimisticChatReportID, } = submitPerDiemExpenseInformation; - const {payeeAccountID} = participantParams; const {currency, comment = '', category, tag, created, customUnit, attendees, isFromGlobalCreate} = transactionParams; if ( @@ -944,6 +945,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf betas, optimisticReportPreviewActionID, personalDetails, + optimisticChatReportID, }); const activeReportID = isMoneyRequestReport && Navigation.getTopmostReportId() === report?.reportID ? report?.reportID : chatReport.reportID; @@ -988,7 +990,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf playSound(SOUNDS.DONE); } - const shouldDeferWrite = shouldHandleNavigation && isFromGlobalCreate && !isReportTopmostSplitNavigator(); + const shouldDeferWrite = isFromGlobalCreate && !isReportTopmostSplitNavigator(); const apiWrite = () => { API.write(WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST, parameters, onyxData); }; @@ -1006,10 +1008,8 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf highlightTransactionOnSearchRouteIfNeeded(isFromGlobalCreate, transaction.transactionID, CONST.SEARCH.DATA_TYPES.EXPENSE); - handleNavigateAfterExpenseCreate({activeReportID, transactionID: transaction.transactionID, isFromGlobalCreate, shouldHandleNavigation}); - if (activeReportID) { - notifyNewAction(activeReportID, undefined, payeeAccountID === currentUserAccountIDParam); + notifyNewAction(activeReportID, undefined, participantParams.payeeAccountID === currentUserAccountIDParam); } return {iouReport}; diff --git a/src/libs/actions/IOU/SendMoney.ts b/src/libs/actions/IOU/SendMoney.ts index 590d3cc088fd..c3c6c55bf3f1 100644 --- a/src/libs/actions/IOU/SendMoney.ts +++ b/src/libs/actions/IOU/SendMoney.ts @@ -28,7 +28,6 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {Receipt} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {dismissModalAndOpenReportInInboxTab} from '.'; type SendMoneyParamsData = { params: SendMoneyParams; @@ -72,6 +71,7 @@ function getSendMoneyParams({ created, merchant, receipt, + optimisticChatReportID, currentUserAccountID, }: { report: OnyxEntry; @@ -85,6 +85,7 @@ function getSendMoneyParams({ created?: string; merchant?: string; receipt?: Receipt; + optimisticChatReportID?: string; currentUserAccountID: number; }): SendMoneyParamsData { const recipientEmail = addSMSDomainIfPhoneNumber(recipient.login ?? ''); @@ -106,6 +107,7 @@ function getSendMoneyParams({ if (!chatReport) { chatReport = buildOptimisticChatReport({ participantList: [recipientAccountID, managerID], + optimisticReportID: optimisticChatReportID, currentUserAccountID, }); isNewChat = true; @@ -478,22 +480,21 @@ function getSendMoneyParams({ }; } -/** - * @param currentUserAccountID - Account ID of the person sending the money - * @param recipient - The user receiving the money - */ -function sendMoneyElsewhere( - report: OnyxEntry, - quickAction: OnyxEntry, - amount: number, - currency: string, - comment: string, - currentUserAccountID: number, - recipient: Participant, - created?: string, - merchant?: string, - receipt?: Receipt, -) { +type SendMoneyActionParams = { + report: OnyxEntry; + quickAction: OnyxEntry; + amount: number; + currency: string; + comment: string; + currentUserAccountID: number; + recipient: Participant | OptionData; + created?: string; + merchant?: string; + receipt?: Receipt; + optimisticChatReportID?: string; +}; + +function sendMoneyElsewhere({report, quickAction, amount, currency, comment, currentUserAccountID, recipient, created, merchant, receipt, optimisticChatReportID}: SendMoneyActionParams) { const {params, optimisticData, successData, failureData} = getSendMoneyParams({ report, quickAction, @@ -506,6 +507,7 @@ function sendMoneyElsewhere( created, merchant, receipt, + optimisticChatReportID, currentUserAccountID, }); startSpan(CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE, { @@ -522,26 +524,10 @@ function sendMoneyElsewhere( playSound(SOUNDS.DONE); API.write(WRITE_COMMANDS.SEND_MONEY_ELSEWHERE, params, {optimisticData, successData, failureData}); - dismissModalAndOpenReportInInboxTab(params.chatReportID); notifyNewAction(params.chatReportID, undefined, true); } -/** - * @param currentUserAccountID - Account ID of the person sending the money - * @param recipient - The user receiving the money - */ -function sendMoneyWithWallet( - report: OnyxEntry, - quickAction: OnyxEntry, - amount: number, - currency: string, - comment: string, - currentUserAccountID: number, - recipient: Participant | OptionData, - created?: string, - merchant?: string, - receipt?: Receipt, -) { +function sendMoneyWithWallet({report, quickAction, amount, currency, comment, currentUserAccountID, recipient, created, merchant, receipt, optimisticChatReportID}: SendMoneyActionParams) { const {params, optimisticData, successData, failureData} = getSendMoneyParams({ report, quickAction, @@ -554,6 +540,7 @@ function sendMoneyWithWallet( created, merchant, receipt, + optimisticChatReportID, currentUserAccountID, }); startSpan(CONST.TELEMETRY.SPAN_SUBMIT_TO_DESTINATION_VISIBLE, { @@ -570,7 +557,6 @@ function sendMoneyWithWallet( playSound(SOUNDS.DONE); API.write(WRITE_COMMANDS.SEND_MONEY_WITH_WALLET, params, {optimisticData, successData, failureData}); - dismissModalAndOpenReportInInboxTab(params.chatReportID); notifyNewAction(params.chatReportID, undefined, true); } diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 98734084d42c..a31f6ceb5113 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -17,13 +17,22 @@ import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import useReportAttributes from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportTransactions from '@hooks/useReportTransactions'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import {requestMoney} from '@libs/actions/IOU/TrackExpense'; import {setTransactionReport} from '@libs/actions/Transaction'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {calculateDefaultReimbursable, getExistingTransactionID, isMovingTransactionFromTrackExpense, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils'; +import { + calculateDefaultReimbursable, + getExistingTransactionID, + isMovingTransactionFromTrackExpense, + navigateToConfirmationPage, + navigateToParticipantPage, + resolveOptimisticChatReportID, +} from '@libs/IOUUtils'; +import dismissModalAndOpenReportInInboxTabHelper from '@libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab'; import Navigation from '@libs/Navigation/Navigation'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import {getPolicyExpenseChat, getReportOrDraftReport, getTransactionDetails, isMoneyRequestReport, isPolicyExpenseChat, isSelfDM, shouldEnableNegative} from '@libs/ReportUtils'; @@ -85,6 +94,7 @@ function IOURequestStepAmount({ const selfDMReport = useSelfDMReport(); const isReportArchived = useReportIsArchived(report?.reportID); + const reportTransactions = useReportTransactions(report?.reportID); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); @@ -239,11 +249,26 @@ function IOURequestStepAmount({ transactionReportID: report?.reportID, }); if (iouType === CONST.IOU.TYPE.PAY || iouType === CONST.IOU.TYPE.SEND) { + const {optimisticChatReportID, chatReportID} = resolveOptimisticChatReportID( + [participants.at(0)?.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserAccountIDParam], + report, + ); + const sendMoneyParams = { + report, + quickAction, + amount: backendAmount, + currency: selectedCurrency, + comment: '', + currentUserAccountID: currentUserAccountIDParam, + recipient: participants.at(0) ?? {}, + optimisticChatReportID, + }; if (paymentMethod && paymentMethod === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { - sendMoneyWithWallet(report, quickAction, backendAmount, selectedCurrency, '', currentUserAccountIDParam, participants.at(0) ?? {}); - return; + sendMoneyWithWallet(sendMoneyParams); + } else { + sendMoneyElsewhere(sendMoneyParams); } - sendMoneyElsewhere(report, quickAction, backendAmount, selectedCurrency, '', currentUserAccountIDParam, participants.at(0) ?? {}); + dismissModalAndOpenReportInInboxTabHelper(chatReportID, undefined, reportTransactions.length > 0); return; } if (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.REQUEST) { diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 377dc47496a4..d0b7a3c10d7c 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -31,6 +31,7 @@ import usePermissions from '@hooks/usePermissions'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import useReportAttributes from '@hooks/useReportAttributes'; +import useReportTransactions from '@hooks/useReportTransactions'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {completeTestDriveTask} from '@libs/actions/Task'; @@ -48,12 +49,15 @@ import { getExistingTransactionID, isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils, navigateToStartMoneyRequestStep, + resolveOptimisticChatReportID, shouldShowReceiptEmptyState, shouldUseTransactionDraft, } from '@libs/IOUUtils'; import Log from '@libs/Log'; +import dismissModalAndOpenReportInInboxTabHelper from '@libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab'; import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import Navigation from '@libs/Navigation/Navigation'; import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; @@ -256,6 +260,7 @@ function IOURequestStepConfirmation({ const [draftTransactionIDs] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftIDsSelector}); const reportAttributesDerived = useReportAttributes(); + const reportTransactions = useReportTransactions(report?.reportID); const [recentlyUsedDestinations] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_DESTINATIONS}${policyID}`); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const transactionViolationsRef = useRef(transactionViolations); @@ -689,7 +694,20 @@ function IOURequestStepConfirmation({ quickAction, }); } else { - submitPerDiemExpenseIOUActions({ + const isExpenseReport = isMoneyRequestReport(report); + let existingChatReport = report; + if (isExpenseReport) { + existingChatReport = getReportOrDraftReport(report?.chatReportID); + } else if (!report?.reportID && participant.isPolicyExpenseChat && participant.reportID) { + existingChatReport = getReportOrDraftReport(participant.reportID); + } + const {optimisticChatReportID, chatReportID} = resolveOptimisticChatReportID( + [participant.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserPersonalDetails.accountID], + existingChatReport, + ); + const activeReportID = isExpenseReport && Navigation.getTopmostReportId() === report?.reportID ? report?.reportID : chatReportID; + + const result = submitPerDiemExpenseIOUActions({ report, participantParams: { payeeEmail: currentUserPersonalDetails.login, @@ -726,7 +744,16 @@ function IOURequestStepConfirmation({ quickAction, betas, personalDetails, + optimisticChatReportID, }); + if (result && activeReportID) { + navigateAfterExpenseCreate({ + activeReportID, + transactionID: transaction.transactionID, + isFromGlobalCreate: transaction.isFromFloatingActionButton ?? transaction.isFromGlobalCreate, + hasMultipleTransactions: reportTransactions.length > 0, + }); + } } }, [ @@ -747,6 +774,7 @@ function IOURequestStepConfirmation({ quickAction, betas, personalDetails, + reportTransactions.length, ], ); @@ -1226,38 +1254,31 @@ function IOURequestStepConfirmation({ return; } + const {optimisticChatReportID, chatReportID} = resolveOptimisticChatReportID([participant.accountID ?? CONST.DEFAULT_NUMBER_ID, currentUserPersonalDetails.accountID], report); + const sendMoneyParams = { + report, + quickAction, + amount: transaction.amount, + currency, + comment: trimmedComment, + currentUserAccountID: currentUserPersonalDetails.accountID, + recipient: participant, + created: transaction.created, + merchant: transaction.merchant, + receipt: receiptFiles[transaction.transactionID], + optimisticChatReportID, + }; + if (paymentMethod === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { setIsConfirmed(true); - sendMoneyElsewhere( - report, - quickAction, - transaction.amount, - currency, - trimmedComment, - currentUserPersonalDetails.accountID, - participant, - transaction.created, - transaction.merchant, - receiptFiles[transaction.transactionID], - ); - return; - } - - if (paymentMethod === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { + sendMoneyElsewhere(sendMoneyParams); + } else if (paymentMethod === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { setIsConfirmed(true); - sendMoneyWithWallet( - report, - quickAction, - transaction.amount, - currency, - trimmedComment, - currentUserPersonalDetails.accountID, - participant, - transaction.created, - transaction.merchant, - receiptFiles[transaction.transactionID], - ); + sendMoneyWithWallet(sendMoneyParams); + } else { + return; } + dismissModalAndOpenReportInInboxTabHelper(chatReportID, undefined, reportTransactions.length > 0); }, [ transaction?.currency, @@ -1271,6 +1292,7 @@ function IOURequestStepConfirmation({ currentUserPersonalDetails.accountID, receiptFiles, quickAction, + reportTransactions.length, ], ); diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index 7588d2375d72..543aed6ba2fa 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -824,4 +824,37 @@ describe('getExistingTransactionID', () => { expect(IOUUtils.getExistingTransactionID(moneyRequestAction)).toBe('txn123'); }); + + describe('resolveOptimisticChatReportID', () => { + it('should return existing report ID when report has a reportID', () => { + const existingReport = {reportID: 'existing-123'} as Report; + const result = IOUUtils.resolveOptimisticChatReportID([1, 2], existingReport); + + expect(result.chatReportID).toBe('existing-123'); + expect(result.optimisticChatReportID).toBeUndefined(); + }); + + it('should generate optimistic ID when no existing report is provided', () => { + const result = IOUUtils.resolveOptimisticChatReportID([1, 2]); + + expect(result.optimisticChatReportID).toBeDefined(); + expect(result.chatReportID).toBe(result.optimisticChatReportID); + }); + + it('should generate optimistic ID when existing report has no reportID', () => { + const emptyReport = {} as Report; + const result = IOUUtils.resolveOptimisticChatReportID([1, 2], emptyReport); + + expect(result.optimisticChatReportID).toBeDefined(); + expect(result.chatReportID).toBe(result.optimisticChatReportID); + }); + + it('should return consistent chatReportID that is always defined', () => { + const result1 = IOUUtils.resolveOptimisticChatReportID([1, 2], {reportID: 'report-1'} as Report); + const result2 = IOUUtils.resolveOptimisticChatReportID([1, 2]); + + expect(result1.chatReportID).toBeDefined(); + expect(result2.chatReportID).toBeDefined(); + }); + }); }); diff --git a/tests/unit/dismissModalAndOpenReportInInboxTabTest.ts b/tests/unit/dismissModalAndOpenReportInInboxTabTest.ts new file mode 100644 index 000000000000..3b8f78f8aa42 --- /dev/null +++ b/tests/unit/dismissModalAndOpenReportInInboxTabTest.ts @@ -0,0 +1,76 @@ +import dismissModalAndOpenReportInInboxTab from '@libs/Navigation/helpers/dismissModalAndOpenReportInInboxTab'; +import Navigation from '@libs/Navigation/Navigation'; + +const mockIsSearchTopmostFullScreenRoute = jest.fn(); +const mockIsReportOpenInRHP = jest.fn(); +const mockGetSpan = jest.fn(); + +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute() as boolean); +jest.mock('@libs/Navigation/helpers/isReportOpenInRHP', () => () => mockIsReportOpenInRHP() as boolean); +jest.mock('@libs/Navigation/helpers/isReportOpenInSuperWideRHP', () => () => false as boolean); +jest.mock('@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue', () => (callback: () => void) => { + callback(); +}); +jest.mock('@libs/getIsNarrowLayout', () => () => false as boolean); +jest.mock('@libs/telemetry/activeSpans', () => ({ + getSpan: (...args: unknown[]) => mockGetSpan(...args) as boolean, +})); +jest.mock('@libs/telemetry/submitFollowUpAction', () => ({ + setPendingSubmitFollowUpAction: jest.fn(), + endSubmitFollowUpActionSpan: jest.fn(), +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + dismissModal: jest.fn(), + dismissToPreviousRHP: jest.fn(), + dismissModalWithReport: jest.fn(), + pop: jest.fn(), + navigate: jest.fn(), + navigationRef: { + getRootState: jest.fn(() => ({ + routes: [], + })), + }, +})); + +jest.mock('@react-navigation/native'); + +describe('dismissModalAndOpenReportInInboxTab', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetSpan.mockReturnValue(false); + mockIsReportOpenInRHP.mockReturnValue(false); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + }); + + it('should call dismissModalWithReport when report is not in RHP and not on search', () => { + const reportID = 'report-123'; + dismissModalAndOpenReportInInboxTab(reportID, undefined, false); + + expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID}); + }); + + it('should call dismissModal when on search page', () => { + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + dismissModalAndOpenReportInInboxTab('report-123', undefined, false); + + expect(Navigation.dismissModal).toHaveBeenCalled(); + expect(Navigation.dismissModalWithReport).not.toHaveBeenCalled(); + }); + + it('should call dismissModal when reportID is undefined', () => { + dismissModalAndOpenReportInInboxTab(undefined, undefined, false); + + expect(Navigation.dismissModal).toHaveBeenCalled(); + }); + + it('should skip RHP logic for invoices', () => { + mockIsReportOpenInRHP.mockReturnValue(true); + const reportID = 'report-123'; + dismissModalAndOpenReportInInboxTab(reportID, true, false); + + // Should fall through to dismissModalWithReport, not use RHP logic + expect(Navigation.dismissToPreviousRHP).not.toHaveBeenCalled(); + expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID}); + }); +}); diff --git a/tests/unit/navigateAfterExpenseCreateTest.ts b/tests/unit/navigateAfterExpenseCreateTest.ts new file mode 100644 index 000000000000..4bdecf1d2d02 --- /dev/null +++ b/tests/unit/navigateAfterExpenseCreateTest.ts @@ -0,0 +1,163 @@ +import navigateAfterExpenseCreate from '@libs/Navigation/helpers/navigateAfterExpenseCreate'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +const mockIsReportTopmostSplitNavigator = jest.fn(); +const mockIsSearchTopmostFullScreenRoute = jest.fn(); +const mockIsReportOpenInRHP = jest.fn(); +const mockGetIsNarrowLayout = jest.fn(); +const mockGetSpan = jest.fn(); +// Declared but assigned after jest.mock hoisting — use require() to access the mock in tests +let mockSetPendingSubmitFollowUpAction: jest.Mock; +const mockGetCurrentSearchQueryJSON = jest.fn(); + +jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => () => mockIsReportTopmostSplitNavigator() as boolean); +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute() as boolean); +jest.mock('@libs/Navigation/helpers/isReportOpenInRHP', () => () => mockIsReportOpenInRHP() as boolean); +jest.mock('@libs/Navigation/helpers/isReportOpenInSuperWideRHP', () => () => false as boolean); +jest.mock('@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue', () => (callback: () => void) => { + callback(); +}); +jest.mock('@libs/getIsNarrowLayout', () => () => mockGetIsNarrowLayout() as boolean); +jest.mock('@libs/telemetry/activeSpans', () => ({ + getSpan: (...args: unknown[]) => mockGetSpan(...args) as boolean, +})); +jest.mock('@libs/telemetry/submitFollowUpAction', () => ({ + setPendingSubmitFollowUpAction: jest.fn(), + endSubmitFollowUpActionSpan: jest.fn(), +})); +jest.mock('@libs/SearchQueryUtils', () => ({ + buildCannedSearchQuery: jest.fn(({type}: {type: string}) => `type:${type}`), + getCurrentSearchQueryJSON: () => mockGetCurrentSearchQueryJSON() as undefined, +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + dismissModal: jest.fn(), + dismissToPreviousRHP: jest.fn(), + dismissModalWithReport: jest.fn(), + pop: jest.fn(), + navigate: jest.fn(), + revealRouteBeforeDismissingModal: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + getIsFullscreenPreInsertedUnderRHP: jest.fn(() => false), + clearFullscreenPreInsertedFlag: jest.fn(), + navigationRef: { + getRootState: jest.fn(() => ({ + routes: [], + })), + isReady: jest.fn(() => true), + }, +})); + +jest.mock('@react-navigation/native'); + +describe('navigateAfterExpenseCreate', () => { + beforeAll(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const telemetryMock = require('@libs/telemetry/submitFollowUpAction') as {setPendingSubmitFollowUpAction: jest.Mock}; + mockSetPendingSubmitFollowUpAction = telemetryMock.setPendingSubmitFollowUpAction; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockIsReportTopmostSplitNavigator.mockReturnValue(false); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + mockIsReportOpenInRHP.mockReturnValue(false); + mockGetSpan.mockReturnValue(false); + mockGetCurrentSearchQueryJSON.mockReturnValue(undefined); + }); + + it('should dismiss to report when not from global create', () => { + navigateAfterExpenseCreate({ + activeReportID: 'report-123', + transactionID: 'txn-1', + isFromGlobalCreate: false, + hasMultipleTransactions: false, + }); + + expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID: 'report-123'}); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('should dismiss to report when user is on inbox tab', () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + + navigateAfterExpenseCreate({ + activeReportID: 'report-123', + transactionID: 'txn-1', + isFromGlobalCreate: true, + hasMultipleTransactions: false, + }); + + expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID: 'report-123'}); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); + + it('should dismiss to report when transactionID is missing', () => { + navigateAfterExpenseCreate({ + activeReportID: 'report-123', + isFromGlobalCreate: true, + hasMultipleTransactions: false, + }); + + expect(Navigation.dismissModalWithReport).toHaveBeenCalledWith({reportID: 'report-123'}); + }); + + it('should navigate to search on narrow layout when from global create and not on inbox', () => { + mockGetIsNarrowLayout.mockReturnValue(true); + + navigateAfterExpenseCreate({ + activeReportID: 'report-123', + transactionID: 'txn-1', + isFromGlobalCreate: true, + hasMultipleTransactions: false, + }); + + expect(mockSetPendingSubmitFollowUpAction).toHaveBeenCalledWith(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.NAVIGATE_TO_SEARCH); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_ROOT.getRoute({query: 'type:expense'}), {forceReplace: true}); + }); + + it('should reveal route before dismissing modal on wide layout when from global create', () => { + mockGetIsNarrowLayout.mockReturnValue(false); + + navigateAfterExpenseCreate({ + activeReportID: 'report-123', + transactionID: 'txn-1', + isFromGlobalCreate: true, + hasMultipleTransactions: false, + }); + + expect(Navigation.revealRouteBeforeDismissingModal).toHaveBeenCalledWith(ROUTES.SEARCH_ROOT.getRoute({query: 'type:expense'})); + }); + + it('should use invoice data type when isInvoice is true', () => { + mockGetIsNarrowLayout.mockReturnValue(true); + + navigateAfterExpenseCreate({ + activeReportID: 'report-123', + transactionID: 'txn-1', + isFromGlobalCreate: true, + isInvoice: true, + hasMultipleTransactions: false, + }); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_ROOT.getRoute({query: 'type:invoice'}), {forceReplace: true}); + }); + + it('should use pre-insert fast path on narrow layout when fullscreen is pre-inserted', () => { + mockGetIsNarrowLayout.mockReturnValue(true); + (Navigation.getIsFullscreenPreInsertedUnderRHP as jest.Mock).mockReturnValueOnce(true); + + navigateAfterExpenseCreate({ + activeReportID: 'report-123', + transactionID: 'txn-1', + isFromGlobalCreate: true, + hasMultipleTransactions: false, + }); + + expect(Navigation.clearFullscreenPreInsertedFlag).toHaveBeenCalled(); + expect(Navigation.dismissModal).toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); +});