diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 6ad765446877..8724f1be5ea6 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -3292,7 +3292,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { text: translate('common.merge'), icon: expensifyIcons.ArrowCollapse, value: CONST.SEARCH.BULK_ACTION_TYPES.MERGE, - onSelected: () => setupMergeTransactionDataAndNavigate(transactionID, searchedTransactions, localeCompare, getCurrencyDecimals, reports, false, true, undefined), + onSelected: () => + setupMergeTransactionDataAndNavigate(transactionID, searchedTransactions, localeCompare, getCurrencyDecimals, reports, false, true, transactionPolicies), }); } } diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 3be94bd870b5..48b743534aba 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -198,7 +198,7 @@ function getTransactionsAndReportsFromSearch( ): { transactions: Transaction[]; reports: Report[]; - policies: Policy[]; + policies: Array>; } { const transaction1 = searchResults.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionIDs.at(0)}`]; const transaction2 = searchResults.data[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionIDs.at(1)}`]; @@ -210,7 +210,9 @@ function getTransactionsAndReportsFromSearch( reports: [searchResults.data[`${ONYXKEYS.COLLECTION.REPORT}${transaction1?.reportID}`], searchResults.data[`${ONYXKEYS.COLLECTION.REPORT}${transaction2?.reportID}`]].filter( (report) => !!report, ), - policies: [searchResults.data[`${ONYXKEYS.COLLECTION.POLICY}${policyID1}`], searchResults.data[`${ONYXKEYS.COLLECTION.POLICY}${policyID2}`]].filter((policy) => !!policy), + // Not filtered, unlike the arrays above: this is read by index alongside `transactions`, and a self-DM + // transaction has no policyID, so dropping its undefined slot would pair a policy with the wrong transaction. + policies: [searchResults.data[`${ONYXKEYS.COLLECTION.POLICY}${policyID1}`], searchResults.data[`${ONYXKEYS.COLLECTION.POLICY}${policyID2}`]], }; } diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 26e38dc7566e..25e6c022a53f 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -865,7 +865,7 @@ function isMergeAction(parentReport: Report, reportTransactions: Transaction[], function isMergeActionForSelectedTransactions( transactions: Transaction[], reports: Report[], - policies: Policy[], + policies: Array>, rules: OnyxCollection, // The full list is needed because the loop below resolves a different report owner on each pass. personalDetails: OnyxEntry, diff --git a/src/libs/TransactionUtils/getUpdatedTransaction.ts b/src/libs/TransactionUtils/getUpdatedTransaction.ts index 36c51421678b..339a37ff7c79 100644 --- a/src/libs/TransactionUtils/getUpdatedTransaction.ts +++ b/src/libs/TransactionUtils/getUpdatedTransaction.ts @@ -125,7 +125,7 @@ function getRecalculatedDistanceMerchant( } /** - * Given the edit made to the expense, return an updated transaction object. + * Given the edit made to the expense, return an updated transaction object. Tracked expenses store the opposite sign, so the amount follows the report the expense ends up on: a merge moves it to Self-DM in the same change set. */ function getUpdatedTransaction({ transaction, @@ -150,7 +150,7 @@ function getUpdatedTransaction({ getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol']; }): Transaction { - const isUnReportedExpense = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const isUnReportedExpense = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID || transactionChanges.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; // Only changing the first level fields so no need for deep clone now const updatedTransaction = lodashDeepClone(transaction); diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index b121a52d02f7..dc51bb34fa7f 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -22,7 +22,9 @@ import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; import {isPaidGroupPolicy, isPolicyAdmin, resolveCurrentTaxCode} from '@libs/PolicyUtils'; import {getIOUActionForReportID, getReportAction, getTrackExpenseActionableWhisper} from '@libs/ReportActionsUtils'; import { + buildOptimisticCreatedReportAction, buildOptimisticIOUReportAction, + buildTransactionThread, getReportOrDraftReport, getTransactionDetails, isCurrentUserSubmitter, @@ -559,6 +561,8 @@ function mergeTransactionRequest({ // Optimistic delete the source transaction and also delete its report if it was a single expense report const isUnreportedSourceTransaction = sourceTransaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; const selfDMReportID = selfDMReport?.reportID; + const isSelfDMDestinationWithoutReport = mergeTransaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID && !selfDMReportID; + const optimisticDestinationReportID = mergeTransaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID ? (selfDMReportID ?? mergeTransaction.reportID) : mergeTransaction.reportID; const sourceTransactionOptimisticData: Array< OnyxUpdate @@ -713,6 +717,19 @@ function mergeTransactionRequest({ sourceTransactionOptimisticData.push(...optimisticData); sourceTransactionSuccessData.push(...successData); sourceTransactionFailureData.push(...failureData); + + // With a Self-DM destination the surviving expense's optimistic action is written to this same report, so + // the source keeps its pending-delete styling while offline and is removed once the merge succeeds. The + // successData above only clears pendingAction and errors, which would leave the card behind, so drop it. + if (mergeTransaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { + sourceTransactionSuccessData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReportID}`, + value: { + [sourceIouAction.reportActionID]: null, + }, + }); + } } } // Optimistic delete the merge transaction @@ -746,84 +763,180 @@ function mergeTransactionRequest({ }, ); - const optimisticData: Array> = [ + const optimisticData: Array> = [ ...(onyxTargetTransactionData.optimisticData ?? []), optimisticMergeTransactionData, ...optimisticTransactionViolations, ...sourceTransactionOptimisticData, ]; - const failureData: Array> = [...(onyxTargetTransactionData.failureData ?? []), ...failureTransactionViolations, ...sourceTransactionFailureData]; + const failureData: Array> = [ + ...(onyxTargetTransactionData.failureData ?? []), + ...failureTransactionViolations, + ...sourceTransactionFailureData, + ]; const successData: Array> = []; successData.push(...sourceTransactionSuccessData); successData.push(...(onyxTargetTransactionData.successData ?? [])); // User picked a destination report other than the target expense's current report (e.g. the source report). - // Move the surviving expense (targetTransaction) onto mergeTransaction.reportID. + // Move the surviving expense (targetTransaction) onto the destination report. For unreported + // expenses, the API uses reportID "0", but optimistic UI must attach to the real self-DM report. if (mergeTransaction.reportID !== targetTransaction.reportID) { + const newIOUActionType = mergeTransaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID ? CONST.IOU.REPORT_ACTION_TYPE.TRACK : CONST.IOU.REPORT_ACTION_TYPE.CREATE; const newIOUAction = buildOptimisticIOUReportAction({ - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + type: newIOUActionType, amount: mergeTransaction.amount, currency: mergeTransaction.currency, comment: mergeTransaction.description, participants: [], transactionID: mergeTransaction.targetTransactionID, - iouReportID: mergeTransaction.reportID, + iouReportID: optimisticDestinationReportID, delegateAccountIDParam: delegateAccountID, getCurrencyDecimals, }); // IOU action for the surviving expense on its original report (not on mergeTransaction.reportID yet). const targetIOUActionOnOriginalReport = getIOUActionForReportID(targetTransaction.reportID, targetTransaction.transactionID); - const targetTransactionThreadReportID = targetIOUActionOnOriginalReport?.childReportID; + const targetTransactionThreadReportID = targetIOUActionOnOriginalReport?.childReportID ?? targetTransactionThreadReport?.reportID; - if (targetTransactionThreadReportID) { + if (targetTransactionThreadReportID && !isSelfDMDestinationWithoutReport) { // Reattach the transaction thread to the new IOU action on the destination report. newIOUAction.childReportID = targetTransactionThreadReportID; + + // The thread can be one that was never opened, so Onyx has no report for it even though the confirmation + // page holds a copy from the Search snapshot. A report saved by an earlier merge counts as missing too: + // it holds the two parent fields and nothing else, so it has no reportID and would otherwise never heal. + // Merging the parent fields onto either leaves a report the screen reads as unloaded, and we navigate + // straight to this thread after the merge, so it is what the user lands on. + const threadReportInOnyx = getReportOrDraftReport(targetTransactionThreadReportID); + const threadReportKey: `${typeof ONYXKEYS.COLLECTION.REPORT}${string}` = `${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadReportID}`; + const threadReportActionsKey: `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS}${string}` = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetTransactionThreadReportID}`; + const threadLoadingStateKey: `${typeof ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${string}` = `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${targetTransactionThreadReportID}`; + + if (threadReportInOnyx?.reportID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: threadReportKey, + value: { + parentReportID: optimisticDestinationReportID, + parentReportActionID: newIOUAction.reportActionID, + }, + }); + + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: threadReportKey, + value: { + parentReportID: threadReportInOnyx.parentReportID ?? targetTransaction.reportID, + parentReportActionID: threadReportInOnyx.parentReportActionID ?? targetIOUActionOnOriginalReport?.reportActionID, + }, + }); + } else { + // The snapshot copy, when there is one, carries the thread's real fields, so it is the better base. + const seededThreadReport = { + ...buildTransactionThread( + newIOUAction, + getReportOrDraftReport(optimisticDestinationReportID) ?? selfDMReport, + currentUserAccountIDParam, + undefined, + targetTransactionThreadReportID, + ), + ...targetTransactionThreadReport, + reportID: targetTransactionThreadReportID, + parentReportID: optimisticDestinationReportID, + parentReportActionID: newIOUAction.reportActionID, + }; + const seededCreatedAction = buildOptimisticCreatedReportAction({ + emailCreatingAction: currentUserEmailParam, + currentUserAccountID: currentUserAccountIDParam, + currentUserEmail: currentUserEmailParam, + }); + + optimisticData.push( + { + onyxMethod: Onyx.METHOD.SET, + key: threadReportKey, + value: seededThreadReport, + }, + { + // An empty thread still reads as loading, so the placeholder keeps the screen off the skeleton. + onyxMethod: Onyx.METHOD.SET, + key: threadReportActionsKey, + value: {[seededCreatedAction.reportActionID]: seededCreatedAction}, + }, + { + // Only an OpenReport response clears these, and offline that response never arrives. + onyxMethod: Onyx.METHOD.MERGE, + key: threadLoadingStateKey, + value: {hasOnceLoadedReportActions: true, isLoadingInitialReportActions: false}, + }, + ); + + // The response carries the thread's real actions, so the placeholder is dropped rather than left + // alongside them. + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: threadReportActionsKey, + value: {[seededCreatedAction.reportActionID]: null}, + }); + + // Nothing usable was stored before the merge, so a failure drops the thread instead of restoring + // fields onto a report that should not exist. + failureData.push( + { + onyxMethod: Onyx.METHOD.SET, + key: threadReportKey, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: threadReportActionsKey, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: threadLoadingStateKey, + value: null, + }, + ); + } + } + + // For a Self-DM destination the server creates its own TRACK action instead of reusing + // createdIOUReportActionID, so successData deletes our placeholder rather than clearing its + // pendingAction, which would leave a duplicate expense card. The server's onyxData is applied + // first and also updates the thread's parentReportID and parentReportActionID, so we do not set them here. + const isSelfDMDestination = mergeTransaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const destinationReportActionsKey: `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS}${string}` = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${optimisticDestinationReportID}`; + + if (!isSelfDMDestinationWithoutReport) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadReportID}`, + key: destinationReportActionsKey, value: { - parentReportID: mergeTransaction.reportID, - parentReportActionID: newIOUAction.reportActionID, + [newIOUAction.reportActionID]: newIOUAction, + }, + }); + + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: destinationReportActionsKey, + value: { + [newIOUAction.reportActionID]: isSelfDMDestination ? null : {pendingAction: null}, }, }); failureData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadReportID}`, + key: destinationReportActionsKey, value: { - parentReportID: targetTransaction.reportID, - parentReportActionID: targetIOUActionOnOriginalReport.reportActionID, + [newIOUAction.reportActionID]: null, }, }); } - optimisticData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${mergeTransaction.reportID}`, - value: { - [newIOUAction.reportActionID]: newIOUAction, - }, - }); - - successData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${mergeTransaction.reportID}`, - value: { - [newIOUAction.reportActionID]: {pendingAction: null}, - }, - }); - - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${mergeTransaction.reportID}`, - value: { - [newIOUAction.reportActionID]: null, - }, - }); - // Remove the surviving expense's IOU action from its original report so it does not // appear in both reports during offline/optimistic state. if (targetIOUActionOnOriginalReport) { diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 314d9387fc45..6d12c1afe1a9 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -395,6 +395,9 @@ type OpenReportActionParams = { hasReportActions: boolean | undefined; + /** Whether report actions are already available optimistically, so initial loading should be considered complete */ + hasOptimisticReportActions?: boolean; + /** * Whether this report's actions loaded at least once this session (RAM-only, so falsy means a page refresh / * cold start — when a manual unread marker is cleared). Only the report screen passes it; other callers omit @@ -1759,6 +1762,7 @@ function openReport(params: OpenReportActionParams) { isSelfTourViewed, hasCompletedGuidedSetupFlow, hasReportActions, + hasOptimisticReportActions = false, // Defaults to true so only the report screen, the one caller that passes it, can clear a manual unread marker. hasOnceLoadedReportActions = true, shouldMarkAsRead = true, @@ -1814,7 +1818,10 @@ function openReport(params: OpenReportActionParams) { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`, value: { - isLoadingInitialReportActions: true, + hasOnceLoadedReportActions: hasOptimisticReportActions ? true : undefined, + // Offline this only ever clears from the OpenReport response, which never arrives, so setting it for a + // report whose actions are already in Onyx pins the skeleton over content we could render right away. + isLoadingInitialReportActions: !hasOptimisticReportActions && !(isOfflineNetwork() && hasReportActions), isLoadingOlderReportActions: false, hasLoadingOlderReportActionsError: false, isLoadingNewerReportActions: false, @@ -2516,6 +2523,10 @@ type CreateTransactionThreadReportParams = { // TODO: This will be required eventually. Refactor issue: https://github.com/Expensify/App/issues/66424 hasCompletedGuidedSetupFlow?: boolean; + /** Whether the transaction thread actions are fully seeded optimistically */ + hasOptimisticReportActions?: boolean; + + /** The Concierge chat report */ conciergeChat: OnyxEntry; /** The lastModified of the expense report policy's cached full policy snapshot, so the server can leave out the collections the client already has */ @@ -2537,6 +2548,7 @@ function createTransactionThreadReport(params: CreateTransactionThreadReportPara personalDetails, isSelfTourViewed, hasCompletedGuidedSetupFlow, + hasOptimisticReportActions, conciergeChat, fullPolicySnapshotLastModified, delegateAccountID, @@ -2606,6 +2618,7 @@ function createTransactionThreadReport(params: CreateTransactionThreadReportPara currentUserAccountID, isSelfTourViewed, hasCompletedGuidedSetupFlow, + hasOptimisticReportActions, hasReportActions: false, fullPolicySnapshot: !selfDMReportID && reportToUse.policyID && fullPolicySnapshotLastModified ? {policyID: reportToUse.policyID, lastModified: fullPolicySnapshotLastModified} : undefined, diff --git a/src/pages/TransactionMerge/DynamicConfirmationPage.tsx b/src/pages/TransactionMerge/DynamicConfirmationPage.tsx index 70118ea98532..8448f0d7162c 100644 --- a/src/pages/TransactionMerge/DynamicConfirmationPage.tsx +++ b/src/pages/TransactionMerge/DynamicConfirmationPage.tsx @@ -3,6 +3,7 @@ import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import {usePersonalDetails} from '@components/OnyxListItemProvider'; import MoneyRequestView from '@components/ReportActionItem/MoneyRequestView'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -23,14 +24,15 @@ import useSelfDMReport from '@hooks/useSelfDMReport'; import useThemeStyles from '@hooks/useThemeStyles'; import {mergeTransactionRequest} from '@libs/actions/MergeTransaction'; +import {createTransactionThreadReport} from '@libs/actions/Report'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {buildMergedTransactionData, getTransactionThreadReportID, willReportBecomeOneTransactionReportAfterMerge} from '@libs/MergeTransactionUtils'; +import {buildMergedTransactionData, getReportIDForExpense, getTransactionThreadReportID, willReportBecomeOneTransactionReportAfterMerge} from '@libs/MergeTransactionUtils'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {MergeTransactionNavigatorParamList} from '@libs/Navigation/types'; -import {getFilteredReportActionsForReportView, getIOUActionForTransactionID, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import {findSelfDMReportID} from '@libs/ReportUtils'; +import {getFilteredReportActionsForReportView, getIOUActionForReportID, getIOUActionForTransactionID, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {findSelfDMReportID, getReportOrDraftReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -64,6 +66,7 @@ function DynamicConfirmationPage({route}: DynamicConfirmationPageProps) { const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${targetTransactionPolicy?.id}`); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const personalDetails = usePersonalDetails(); const delegateAccountID = useDelegateAccountID(); const currentUserAccountIDParam = currentUserPersonalDetails.accountID; const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; @@ -78,10 +81,11 @@ function DynamicConfirmationPage({route}: DynamicConfirmationPageProps) { const [targetReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(targetTransaction?.reportID)}`); // Reports opened from Search may not be in Onyx yet, so we also read the expenses from the Search snapshot. const {currentSearchResults} = useSearchResultsContext(); - const [targetTransactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadReportID}`); + const [targetTransactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(targetTransactionThreadReportID)}`); const [targetTransactionThreadParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(targetTransactionThreadReport?.parentReportID)}`); const [iouReportOwnerLogin] = usePersonalDetail(targetTransactionThreadParentReport?.ownerAccountID, loginSelector); const [reportPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${getNonEmptyStringOnyxID(targetTransactionThreadParentReport?.policyID)}`); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, { selector: isTrackIntentUserSelector, }); @@ -102,6 +106,9 @@ function DynamicConfirmationPage({route}: DynamicConfirmationPageProps) { const [sourceActionIOUReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(sourceIOUReportID)}`); const [sourceActionChatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(sourceActionIOUReport?.chatReportID)}`); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(conciergeReportID)}`); + // Build the merged transaction data for display const mergedTransactionData = buildMergedTransactionData(targetTransaction, mergeTransaction); @@ -121,6 +128,53 @@ function DynamicConfirmationPage({route}: DynamicConfirmationPageProps) { setIsMergingExpenses(true); + let targetTransactionThreadReportIDForMerge = targetTransactionThreadReportID; + let targetTransactionThreadReportForMerge = targetTransactionThreadReport; + let targetTransactionThreadParentReportForMerge = targetTransactionThreadParentReport; + + if (!targetTransactionThreadReportIDForMerge) { + // Merges started from Search can have the target's report actions only in the snapshot, so fall back to it. + // Without the existing IOU action, createTransactionThreadReport takes openReport's legacy path and builds a + // second optimistic action for an expense that already has one. + const targetExpenseReportID = getReportIDForExpense(targetTransaction); + const targetSnapshotReportActions = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(targetExpenseReportID)}`]; + const targetIOUAction = + getIOUActionForReportID(targetExpenseReportID, targetTransaction.transactionID) ?? + getIOUActionForTransactionID(Object.values(targetSnapshotReportActions ?? {}), targetTransaction.transactionID); + + // That action can already point at a thread: getTransactionThreadReportID reads childReportID through the + // main Onyx collection only, so a thread known just to the snapshot reads as missing here. Reuse it rather + // than calling createTransactionThreadReport, which always mints a fresh ID and would leave the expense + // with a second thread. + const existingTargetThreadReportID = targetIOUAction?.childReportID; + if (existingTargetThreadReportID) { + targetTransactionThreadReportIDForMerge = existingTargetThreadReportID; + targetTransactionThreadReportForMerge = getReportOrDraftReport(existingTargetThreadReportID, [ + currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${existingTargetThreadReportID}`], + ]); + targetTransactionThreadParentReportForMerge = targetTransactionReport; + } else { + const createdTransactionThreadReport = createTransactionThreadReport({ + introSelected, + currentUserLogin: currentUserEmailParam, + currentUserAccountID: currentUserAccountIDParam, + iouReport: targetTransactionReport, + iouReportAction: targetIOUAction, + transaction: targetTransaction, + transactionViolations: allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${targetTransaction.transactionID}`], + personalDetails, + conciergeChat, + hasOptimisticReportActions: true, + }); + + if (createdTransactionThreadReport) { + targetTransactionThreadReportIDForMerge = createdTransactionThreadReport.reportID; + targetTransactionThreadReportForMerge = createdTransactionThreadReport; + targetTransactionThreadParentReportForMerge = targetTransactionReport; + } + } + } + mergeTransactionRequest({ isVendorMatchingBetaEnabled, getCurrencyDecimals, @@ -130,8 +184,8 @@ function DynamicConfirmationPage({route}: DynamicConfirmationPageProps) { targetTransaction, sourceTransaction, transactionsOfDeletableReportCollection, - targetTransactionThreadReport, - targetTransactionThreadParentReport, + targetTransactionThreadReport: targetTransactionThreadReportForMerge, + targetTransactionThreadParentReport: targetTransactionThreadParentReportForMerge, iouReportOwnerLogin, allTransactionViolations, policy: targetTransactionPolicy, @@ -155,10 +209,11 @@ function DynamicConfirmationPage({route}: DynamicConfirmationPageProps) { const reportIDToDismiss = reportID !== CONST.REPORT.UNREPORTED_REPORT_ID ? reportID : undefined; - const searchReportIDToOpen = targetTransactionThreadReportID ?? reportIDToDismiss; + const searchReportIDToOpen = targetTransactionThreadReportIDForMerge ?? reportIDToDismiss; + const isSearchTopmost = isSearchTopmostFullScreenRoute(); // In search, dismiss the merge modal and reopen the expense in the RHP. - if ((isOnSearch || isSearchTopmostFullScreenRoute()) && searchReportIDToOpen) { + if ((isOnSearch || isSearchTopmost) && searchReportIDToOpen) { if (targetTransaction.reportID === mergeTransaction.reportID) { // Only keep the RHP underneath if it belongs to the target. A swapped merge (e.g. cash into a // card/split expense) leaves the source's report underneath, so fall through to the full dismiss. diff --git a/tests/actions/MergeTransactionTest.ts b/tests/actions/MergeTransactionTest.ts index 6425da1a5ade..aa1f44b5fbbe 100644 --- a/tests/actions/MergeTransactionTest.ts +++ b/tests/actions/MergeTransactionTest.ts @@ -4,7 +4,7 @@ import {addComment, openReport} from '@libs/actions/Report'; import * as API from '@libs/API'; import {WRITE_COMMANDS} from '@libs/API/types'; import {getMergeFieldUpdatedValues} from '@libs/MergeTransactionUtils'; -import {getOriginalMessage, getReportAction, isActionOfType} from '@libs/ReportActionsUtils'; +import {getOriginalMessage, getReportAction, isActionOfType, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {buildTransactionThread} from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -70,6 +70,15 @@ function createAllTransactionViolations( return allViolations; } +function isIOUActionForTransaction(reportAction: OnyxEntry, transactionID: string, type: OriginalMessageIOU['type'] = CONST.IOU.REPORT_ACTION_TYPE.CREATE): boolean { + if (!isMoneyRequestAction(reportAction)) { + return false; + } + + const originalMessage = getOriginalMessage(reportAction); + return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && originalMessage?.type === type && originalMessage?.IOUTransactionID === transactionID; +} + type CrossReportMergeToSourceReportFixtures = { targetTransaction: Transaction; sourceTransaction: Transaction; @@ -81,6 +90,7 @@ type CrossReportMergeToSourceReportFixtures = { sourceIOUAction: ReportAction; sourceIOUActionID: string; targetIOUActionID: string; + targetIOUAction: ReportAction; targetTransactionThreadID: string; targetTransactionThread: Report; mockViolations: TransactionViolation[]; @@ -187,6 +197,7 @@ async function setupCrossReportMergeToSourceReportFixtures(): Promise { expect(updatedTargetTransactionThread?.parentReportActionID).toBe(newIOUAction?.reportActionID); }); + it('should move a passed transaction thread when the original IOU action has no child report ID', async () => { + const fixtures = await setupCrossReportMergeToSourceReportFixtures(); + const {targetTransaction, sourceExpenseReport, targetReport, targetIOUActionID, targetIOUAction, targetTransactionThreadID, targetTransactionThread} = fixtures; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetReport.reportID}`, { + [targetIOUActionID]: { + ...targetIOUAction, + childReportID: undefined, + }, + }); + await waitForBatchedUpdates(); + + mockFetch?.pause?.(); + runCrossReportMergeToSourceReportRequest({ + ...fixtures, + targetTransactionThread, + }); + + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); + + const sourceReportActions = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${sourceExpenseReport.reportID}`, + callback: (actions) => { + Onyx.disconnect(connection); + resolve(actions ?? null); + }, + }); + }); + + const newIOUAction = Object.values(sourceReportActions ?? {}).find((action) => isIOUActionForTransaction(action, targetTransaction.transactionID)); + + expect(newIOUAction?.childReportID).toBe(targetTransactionThreadID); + + const updatedTargetTransactionThread = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadID}`, + callback: (report) => { + Onyx.disconnect(connection); + resolve(report ?? null); + }, + }); + }); + + expect(updatedTargetTransactionThread?.parentReportID).toBe(sourceExpenseReport.reportID); + expect(updatedTargetTransactionThread?.parentReportActionID).toBe(newIOUAction?.reportActionID); + }); + + it('should attach optimistic cross-report merge data to self DM when destination reportID is unreported', async () => { + const fixtures = await setupCrossReportMergeToSourceReportFixtures(); + const {mergeTransactionID, mergeTransaction, targetTransaction, sourceTransaction, mockViolations, targetTransactionThreadID, targetTransactionThread} = fixtures; + const selfDMReport = { + ...createRandomReport(5), + reportID: 'self-dm-report-123', + type: CONST.REPORT.TYPE.CHAT, + }; + const unreportedSourceTransaction = { + ...sourceTransaction, + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + }; + const unreportedMergeTransaction = { + ...mergeTransaction, + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${unreportedSourceTransaction.transactionID}`, unreportedSourceTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${mergeTransactionID}`, unreportedMergeTransaction); + await waitForBatchedUpdates(); + + mockFetch?.pause?.(); + mergeTransactionRequest({ + isVendorMatchingBetaEnabled: false, + iouReportOwnerLogin: undefined, + mergeTransactionID, + mergeTransaction: unreportedMergeTransaction, + targetTransaction, + transactionsOfDeletableReportCollection: {[unreportedSourceTransaction.transactionID]: unreportedSourceTransaction}, + sourceTransaction: unreportedSourceTransaction, + policy: undefined, + policyTags: undefined, + policyCategories: undefined, + allTransactionViolations: createAllTransactionViolations(targetTransaction.transactionID, unreportedSourceTransaction.transactionID, mockViolations, mockViolations), + sourceIOUActionThreadReport: undefined, + targetTransactionThreadReport: targetTransactionThread, + targetTransactionThreadParentReport: undefined, + reportPolicyTags: undefined, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, + selfDMReport, + selfDMReportActions: undefined, + delegateAccountID: undefined, + isTrackIntentUser: false, + sourceTransactionThreadReportActions: undefined, + sourceIOUAction: undefined, + sourceActionIOUReport: undefined, + sourceActionChatReport: undefined, + getCurrencyDecimals: getCurrencyDecimalsLocal, + getCurrencySymbol: getCurrencySymbolLocal, + rules: undefined, + }); + + await waitForBatchedUpdates(); + + const optimisticSelfDMReportActions = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`, + callback: (actions) => { + Onyx.disconnect(connection); + resolve(actions ?? null); + }, + }); + }); + const placeholderReportActionID = Object.values(optimisticSelfDMReportActions ?? {}).find((action) => + isIOUActionForTransaction(action, targetTransaction.transactionID, CONST.IOU.REPORT_ACTION_TYPE.TRACK), + )?.reportActionID; + + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); + + const selfDMReportActions = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`, + callback: (actions) => { + Onyx.disconnect(connection); + resolve(actions ?? null); + }, + }); + }); + const unreportedReportActions = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONST.REPORT.UNREPORTED_REPORT_ID}`, + callback: (actions) => { + Onyx.disconnect(connection); + resolve(actions ?? null); + }, + }); + }); + + const newIOUAction = Object.values(selfDMReportActions ?? {}).find((action) => + isIOUActionForTransaction(action, targetTransaction.transactionID, CONST.IOU.REPORT_ACTION_TYPE.TRACK), + ); + + // The server creates its own TRACK action for the Self-DM destination via the MergeTransaction + // response's onyxData; this mock has none, so on success the client deletes its optimistic + // placeholder rather than leaving it around as a duplicate of the (unmocked) server action. + expect(newIOUAction).toBeUndefined(); + expect(unreportedReportActions).toBeNull(); + + const updatedTargetTransactionThread = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadID}`, + callback: (report) => { + Onyx.disconnect(connection); + resolve(report ?? null); + }, + }); + }); + + expect(updatedTargetTransactionThread?.parentReportID).toBe(selfDMReport.reportID); + expect(updatedTargetTransactionThread?.parentReportActionID).toBe(placeholderReportActionID); + }); + + describe('when the destination thread report is missing from Onyx', () => { + const SELF_DM_REPORT_ID = 'self-dm-report-123'; + + /** + * Runs a merge into Self DM for the given fixtures, with the thread report the confirmation page resolved. + * Passing `undefined` covers the merge being started outside Search, where only the IOU action's + * childReportID identifies the thread. + */ + async function runSelfDMMerge(fixtures: CrossReportMergeToSourceReportFixtures, resolvedThreadReport: Report | undefined) { + const {mergeTransactionID, mergeTransaction, targetTransaction, sourceTransaction, mockViolations} = fixtures; + const selfDMReport = { + ...createRandomReport(5), + reportID: SELF_DM_REPORT_ID, + type: CONST.REPORT.TYPE.CHAT, + }; + const unreportedSourceTransaction = {...sourceTransaction, reportID: CONST.REPORT.UNREPORTED_REPORT_ID}; + const unreportedMergeTransaction = {...mergeTransaction, reportID: CONST.REPORT.UNREPORTED_REPORT_ID}; + + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${selfDMReport.reportID}`, selfDMReport); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${unreportedSourceTransaction.transactionID}`, unreportedSourceTransaction); + await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${mergeTransactionID}`, unreportedMergeTransaction); + await waitForBatchedUpdates(); + + mergeTransactionRequest({ + isVendorMatchingBetaEnabled: false, + iouReportOwnerLogin: undefined, + mergeTransactionID, + mergeTransaction: unreportedMergeTransaction, + targetTransaction, + transactionsOfDeletableReportCollection: {[unreportedSourceTransaction.transactionID]: unreportedSourceTransaction}, + sourceTransaction: unreportedSourceTransaction, + policy: undefined, + policyTags: undefined, + policyCategories: undefined, + allTransactionViolations: createAllTransactionViolations(targetTransaction.transactionID, unreportedSourceTransaction.transactionID, mockViolations, mockViolations), + sourceIOUActionThreadReport: undefined, + targetTransactionThreadReport: resolvedThreadReport, + targetTransactionThreadParentReport: undefined, + reportPolicyTags: undefined, + currentUserAccountIDParam: 123, + currentUserEmailParam: 'existing@example.com', + isASAPSubmitBetaEnabled: false, + selfDMReport, + selfDMReportActions: undefined, + delegateAccountID: undefined, + isTrackIntentUser: false, + sourceTransactionThreadReportActions: undefined, + sourceIOUAction: undefined, + sourceActionIOUReport: undefined, + sourceActionChatReport: undefined, + getCurrencyDecimals: getCurrencyDecimalsLocal, + getCurrencySymbol: getCurrencySymbolLocal, + rules: undefined, + }); + await waitForBatchedUpdates(); + } + + it('should seed the whole thread when the page resolved it from the Search snapshot', async () => { + // Given a thread the confirmation page resolved from the snapshot, so a report object is passed in while + // Onyx itself holds nothing under that ID + const fixtures = await setupCrossReportMergeToSourceReportFixtures(); + const {targetTransactionThreadID, targetTransactionThread} = fixtures; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadID}`, null); + + // When the merge re-points that thread onto Self DM + mockFetch?.pause?.(); + await runSelfDMMerge(fixtures, targetTransactionThread); + + // Then the report carries a reportID instead of only the two parent fields, and comes with an action and + // a loaded state. The merge navigates straight here, and an unloaded or empty thread shows a skeleton + // that offline never clears. + const seededThread = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadID}`); + const seededThreadActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetTransactionThreadID}`); + const seededLoadingState = await getOnyxValue(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${targetTransactionThreadID}`); + + expect(seededThread?.reportID).toBe(targetTransactionThreadID); + expect(seededThread?.parentReportID).toBe(SELF_DM_REPORT_ID); + expect(Object.keys(seededThreadActions ?? {}).length).toBe(1); + expect(seededLoadingState?.hasOnceLoadedReportActions).toBe(true); + expect(seededLoadingState?.isLoadingInitialReportActions).toBe(false); + + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); + }); + + it('should seed the whole thread when only the IOU action identifies it', async () => { + // Given a merge started outside Search, so no snapshot copy is passed and the thread is known only from + // the target IOU action's childReportID + const fixtures = await setupCrossReportMergeToSourceReportFixtures(); + const {targetTransactionThreadID} = fixtures; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadID}`, null); + + // When the merge re-points that thread onto Self DM + mockFetch?.pause?.(); + await runSelfDMMerge(fixtures, undefined); + + // Then the thread is still built from scratch rather than written as two loose fields + const seededThread = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadID}`); + const seededThreadActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetTransactionThreadID}`); + + expect(seededThread?.reportID).toBe(targetTransactionThreadID); + expect(seededThread?.parentReportID).toBe(SELF_DM_REPORT_ID); + expect(Object.keys(seededThreadActions ?? {}).length).toBe(1); + + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); + }); + + it('should repair a thread an earlier merge stored without a reportID', async () => { + // Given the two-field report an earlier build wrote for this thread. Onyx has an entry, but it is not a + // usable report, so treating it as present would leave that state stuck for good. + const fixtures = await setupCrossReportMergeToSourceReportFixtures(); + const {targetTransactionThreadID} = fixtures; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadID}`, null); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadID}`, { + parentReportID: 'stale-parent-report', + parentReportActionID: 'stale-parent-action', + }); + + // When the merge re-points that thread onto Self DM + mockFetch?.pause?.(); + await runSelfDMMerge(fixtures, undefined); + + // Then the broken entry is replaced by a usable report + const repairedThread = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadID}`); + + expect(repairedThread?.reportID).toBe(targetTransactionThreadID); + expect(repairedThread?.parentReportID).toBe(SELF_DM_REPORT_ID); + + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); + }); + + it('should drop the placeholder action once the merge succeeds', async () => { + // Given a seeded thread whose single action is the placeholder + const fixtures = await setupCrossReportMergeToSourceReportFixtures(); + const {targetTransactionThreadID, targetTransactionThread} = fixtures; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadID}`, null); + + // When the merge succeeds, so the response brings the thread's real actions + mockFetch?.pause?.(); + await runSelfDMMerge(fixtures, targetTransactionThread); + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); + + // Then the placeholder is removed rather than left sitting alongside them + const threadActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetTransactionThreadID}`); + + expect(Object.values(threadActions ?? {}).filter(Boolean).length).toBe(0); + }); + + it('should drop the seeded thread when the merge fails', async () => { + // Given a thread that exists only because the merge seeded it + const fixtures = await setupCrossReportMergeToSourceReportFixtures(); + const {targetTransactionThreadID, targetTransactionThread} = fixtures; + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadID}`, null); + + // When the server rejects the merge + mockFetch?.pause?.(); + await runSelfDMMerge(fixtures, targetTransactionThread); + mockFetch?.fail?.(); + await mockFetch?.resume?.(); + await waitForBatchedUpdates(); + + // Then it is removed rather than left behind: Onyx held nothing usable before the merge, so restoring + // the parent fields would recreate the same broken report the seeding exists to avoid. + const rolledBackThread = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadID}`); + const rolledBackThreadActions = await getOnyxValue(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${targetTransactionThreadID}`); + + expect(rolledBackThread).toBeUndefined(); + expect(rolledBackThreadActions).toBeUndefined(); + }); + }); + it('should not inject the target IOU action into the source report when a cross-report merge fails', async () => { const fixtures = await setupCrossReportMergeToSourceReportFixtures(); const {sourceExpenseReport, targetIOUActionID, targetTransactionThreadID, targetTransactionThread} = fixtures; diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts index 744b9b677c74..2dbd90a5e123 100644 --- a/tests/actions/ReportTest.ts +++ b/tests/actions/ReportTest.ts @@ -11586,6 +11586,113 @@ describe('actions/Report', () => { TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1); }); + it('should mark optimistic transaction thread actions as loaded when requested', async () => { + const parentReport: OnyxTypes.Report = { + ...createRandomReport(600, undefined), + reportID: '600', + type: CONST.REPORT.TYPE.EXPENSE, + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${parentReport.reportID}`, parentReport); + await waitForBatchedUpdates(); + + const reportAction: OnyxTypes.ReportAction = { + ...createRandomReportAction(6), + reportActionID: 'action-6', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + }; + + const result = Report.createTransactionThreadReport({ + introSelected: TEST_INTRO_SELECTED, + conciergeChat: undefined, + currentUserLogin: TEST_USER_LOGIN, + currentUserAccountID: TEST_USER_ACCOUNT_ID, + personalDetails: undefined, + iouReport: parentReport, + iouReportAction: reportAction, + hasOptimisticReportActions: true, + }); + await waitForBatchedUpdates(); + + const loadingState = await getOnyxValue(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${result?.reportID}`); + + expect(loadingState?.hasOnceLoadedReportActions).toBe(true); + expect(loadingState?.isLoadingInitialReportActions).toBe(false); + }); + + it('should not pin an already-loaded report on the loading skeleton while offline', async () => { + // Given a transaction thread that already exists with its actions in Onyx. Merging into an expense that + // already has a thread skips createTransactionThreadReport, so nothing marks the thread as loaded. + const existingThread: OnyxTypes.Report = { + ...createRandomReport(900, undefined), + reportID: '900', + type: CONST.REPORT.TYPE.CHAT, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${existingThread.reportID}`, existingThread); + await waitForBatchedUpdates(); + + // When the report screen opens it while offline, so the OpenReport response never arrives + const mockFetchOffline = TestHelper.createGlobalFetchMock(); + global.fetch = mockFetchOffline; + mockFetchOffline.pause(); + setHasRadio(false); + + Report.openReport({ + reportID: existingThread.reportID, + introSelected: TEST_INTRO_SELECTED, + conciergeChat: undefined, + currentUserLogin: TEST_USER_LOGIN, + currentUserAccountID: TEST_USER_ACCOUNT_ID, + personalDetails: undefined, + hasReportActions: true, + }); + await waitForBatchedUpdates(); + + // Then it is not left loading, because isLoadingInitialReportActions only clears from that response + const loadingStateOffline = await getOnyxValue(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${existingThread.reportID}`); + + expect(loadingStateOffline?.isLoadingInitialReportActions).toBe(false); + + setHasRadio(true); + await mockFetchOffline.resume(); + }); + + it('should still show the loading state offline for a report with no actions yet', async () => { + // Given a report whose actions are not in Onyx, so there is nothing to render in place of the skeleton + const emptyReport: OnyxTypes.Report = { + ...createRandomReport(902, undefined), + reportID: '902', + type: CONST.REPORT.TYPE.CHAT, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${emptyReport.reportID}`, emptyReport); + await waitForBatchedUpdates(); + + // When the report screen opens it while offline + const mockFetchOffline = TestHelper.createGlobalFetchMock(); + global.fetch = mockFetchOffline; + mockFetchOffline.pause(); + setHasRadio(false); + + Report.openReport({ + reportID: emptyReport.reportID, + introSelected: TEST_INTRO_SELECTED, + conciergeChat: undefined, + currentUserLogin: TEST_USER_LOGIN, + currentUserAccountID: TEST_USER_ACCOUNT_ID, + personalDetails: undefined, + hasReportActions: false, + }); + await waitForBatchedUpdates(); + + // Then the loading state is kept, since showing an empty report would be worse than the skeleton + const loadingStateOffline = await getOnyxValue(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${emptyReport.reportID}`); + + expect(loadingStateOffline?.isLoadingInitialReportActions).toBe(true); + + setHasRadio(true); + await mockFetchOffline.resume(); + }); + it('threads the conciergeChat report into the guided setup data sent with OpenReport', async () => { await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); const conciergeChat: OnyxTypes.Report = {...createRandomReport(777, undefined), reportID: 'concierge-thread-1'};