diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index 5e1a282b9817..b70e12a00948 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -35,6 +35,7 @@ import initSplitExpense from '@libs/actions/SplitExpenses'; import {setNameValuePair} from '@libs/actions/User'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getExistingTransactionID} from '@libs/IOUUtils'; +import isSearchOriginForMerge from '@libs/Navigation/helpers/isSearchOriginForMerge'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types'; @@ -354,7 +355,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money if (!transaction) { return; } - const isOnSearch = route.name.toLowerCase().startsWith('search'); + const isOnSearch = isSearchOriginForMerge(route.name, route.params?.backTo); setupMergeTransactionDataAndNavigate(transaction.transactionID, [transaction], localeCompare, getCurrencyDecimals, [], false, isOnSearch); }, }, diff --git a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx index 84e20254a316..af6576275051 100644 --- a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx +++ b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx @@ -23,6 +23,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {dismissRejectUseExplanation} from '@libs/actions/IOU/RejectMoneyRequest'; import {queueExportSearchWithTemplate} from '@libs/actions/Search'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types'; @@ -154,6 +155,7 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool onExportOffline: () => setOfflineModalVisible(true), policy, beginExportWithTemplate: (templateName, templateType, transactionIDList) => beginExportWithTemplate(templateName, templateType, transactionIDList), + isOnSearch: isSearchTopmostFullScreenRoute(), onDeleteSelected, }); diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index b348eb67e3a3..acbf661fda4c 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -13,7 +13,15 @@ import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionAction import type {BulkPaySelectionData, PaymentData, SearchFilterKey, SearchQueryJSON, SelectedTransactions} from '@components/Search/types'; import {unholdRequest} from '@libs/actions/IOU/Hold'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; -import {deleteAppReport, exportReportToPDF, markAsManuallyExported, moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter} from '@libs/actions/Report'; +import type {TargetTransactionThreadReportCandidate} from '@libs/actions/MergeTransaction'; +import { + createTransactionThreadReport, + deleteAppReport, + exportReportToPDF, + markAsManuallyExported, + moveIOUReportToPolicy, + moveIOUReportToPolicyAndInviteSubmitter, +} from '@libs/actions/Report'; import { approveMoneyRequestOnSearch, exportSearchItemsToCSV, @@ -33,7 +41,7 @@ import { } from '@libs/actions/Search'; import initSplitExpense from '@libs/actions/SplitExpenses'; import {setNameValuePair} from '@libs/actions/User'; -import {getTransactionsAndReportsFromSearch} from '@libs/MergeTransactionUtils'; +import {getTargetTransactionThreadReportIDForSelection, getTransactionsAndReportsFromSearch} from '@libs/MergeTransactionUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; import {getConnectedIntegration} from '@libs/PolicyUtils'; @@ -326,10 +334,12 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS); const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); const {isBetaEnabled} = usePermissions(); const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); + const [betas] = useOnyx(ONYXKEYS.BETAS); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const defaultExpensePolicy = useDefaultExpensePolicy(); @@ -1603,7 +1613,57 @@ 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), + onSelected: () => { + const transactionKey: `${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}` = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; + const searchSnapshotTransaction = currentSearchResults?.data?.[transactionKey]; + const selectedTransactionInfo = selectedTransactions[transactionID]; + const isSingleSelection = selectedTransactionsKeys.length === 1; + let targetTransactionThreadReportIDOverride = isSingleSelection + ? getTargetTransactionThreadReportIDForSelection(searchSnapshotTransaction ?? searchedTransactions.at(0), selectedTransactionInfo) + : undefined; + + if (!targetTransactionThreadReportIDOverride && isSingleSelection) { + const selectedReport = selectedTransactionInfo?.report ?? reports.at(0) ?? getReportOrDraftReport(searchSnapshotTransaction?.reportID); + const selectedReportAction = selectedTransactionInfo?.reportAction; + const targetTransaction = searchSnapshotTransaction ?? searchedTransactions.at(0); + const shouldPassTransactionData = !selectedReportAction?.reportActionID || targetTransaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + const transactionViolations = targetTransaction + ? allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${targetTransaction.transactionID}`] + : undefined; + const createdThreadReport = targetTransaction + ? createTransactionThreadReport({ + introSelected, + currentUserLogin: currentUserPersonalDetails.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, + betas, + iouReport: selectedReport, + iouReportAction: selectedReportAction, + transaction: shouldPassTransactionData ? targetTransaction : undefined, + transactionViolations: shouldPassTransactionData ? transactionViolations : undefined, + }) + : undefined; + targetTransactionThreadReportIDOverride = createdThreadReport?.reportID; + } + + const targetTransactionThreadReportCandidate: TargetTransactionThreadReportCandidate | undefined = targetTransactionThreadReportIDOverride + ? { + transactionID, + threadReportID: targetTransactionThreadReportIDOverride, + } + : undefined; + + setupMergeTransactionDataAndNavigate( + transactionID, + searchedTransactions, + localeCompare, + getCurrencyDecimals, + reports, + false, + true, + undefined, + targetTransactionThreadReportCandidate, + ); + }, }); } } @@ -1790,6 +1850,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { invokeDuplicateReportHandler, isExpenseReportType, handleDeleteSelectedTransactions, + introSelected, + betas, undeleteTransactions, theme.icon, styles.colorMuted, diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 6f16c4161e8b..b3036f9e4760 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -7,9 +7,11 @@ import {useSearchQueryContext, useSearchSelectionActions, useSearchSelectionCont import {initBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit'; import {unholdRequest} from '@libs/actions/IOU/Hold'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; -import {exportReportToCSV} from '@libs/actions/Report'; +import type {TargetTransactionThreadReportCandidate} from '@libs/actions/MergeTransaction'; +import {createTransactionThreadReport, exportReportToCSV} from '@libs/actions/Report'; import {getExportTemplates, handlePreventSearchAPI} from '@libs/actions/Search'; import initSplitExpense from '@libs/actions/SplitExpenses'; +import {getTargetTransactionThreadReportIDForSelection} from '@libs/MergeTransactionUtils'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import {getIOUActionForTransactionID, getReportAction, isDeletedAction} from '@libs/ReportActionsUtils'; import {isMergeActionForSelectedTransactions, isSplitAction} from '@libs/ReportSecondaryActionUtils'; @@ -91,6 +93,8 @@ function useSelectedTransactionsActions({ const [integrationsExportTemplates] = useOnyx(ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES); const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const {getCurrencyDecimals} = useCurrencyListActions(); @@ -435,13 +439,45 @@ function useSelectedTransactionsActions({ const canMergeTransaction = selectedTransactionsList.length < 3 && report && policy && isMergeActionForSelectedTransactions(selectedTransactionsList, [report], [policy]); if (canMergeTransaction) { - const transactionID = selectedTransactionsList.at(0)?.transactionID; - if (transactionID) { + const selectedTransaction = selectedTransactionsList.at(0); + const transactionID = selectedTransaction?.transactionID; + if (transactionID && selectedTransaction) { options.push({ text: translate('common.merge'), icon: expensifyIcons.ArrowCollapse, value: MERGE, - onSelected: () => + onSelected: () => { + const isSingleSelection = selectedTransactionsList.length === 1; + let targetTransactionThreadReportIDOverride: string | undefined; + const iouReportAction = isSingleSelection ? getIOUActionForTransactionID(reportActions, transactionID) : undefined; + + if (isSingleSelection) { + const selectedTransactionMeta = selectedTransactionsMeta?.[transactionID]; + targetTransactionThreadReportIDOverride = getTargetTransactionThreadReportIDForSelection(selectedTransaction, selectedTransactionMeta, iouReportAction); + + if (!targetTransactionThreadReportIDOverride) { + const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; + const createdThreadReport = createTransactionThreadReport({ + introSelected, + currentUserLogin: login ?? '', + currentUserAccountID, + betas, + iouReport: report, + iouReportAction, + transaction: selectedTransaction, + transactionViolations, + }); + targetTransactionThreadReportIDOverride = createdThreadReport?.reportID; + } + } + + const targetTransactionThreadReportCandidate: TargetTransactionThreadReportCandidate | undefined = targetTransactionThreadReportIDOverride + ? { + transactionID, + threadReportID: targetTransactionThreadReportIDOverride, + } + : undefined; + setupMergeTransactionDataAndNavigate( transactionID, selectedTransactionsList, @@ -451,7 +487,9 @@ function useSelectedTransactionsActions({ false, isOnSearch, selectedTransactionsList.length > 1 ? [policy, policy] : undefined, - ), + targetTransactionThreadReportCandidate, + ); + }, }); } } diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 195e9ed6d960..ea87355b71c5 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -6,7 +6,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {MergeTransaction, Policy, Report, SearchResults, Transaction} from '@src/types/onyx'; +import type {MergeTransaction, Policy, Report, ReportAction, SearchResults, Transaction} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import SafeString from '@src/utils/SafeString'; import {getDecodedLeafCategoryName} from './CategoryUtils'; @@ -60,6 +60,11 @@ type MergeFieldData = { /** Type for merge transaction values that can be null to clear existing values in Onyx */ type MergeTransactionUpdateValues = Partial>; +type TargetTransactionThreadReportIDSource = { + transaction?: OnyxEntry; + reportAction?: OnyxEntry; + [key: string]: unknown; +}; const MERGE_FIELD_TRANSLATION_KEYS = { amount: 'iou.amount', @@ -354,6 +359,39 @@ function getTransactionThreadReportID(transaction: OnyxEntry) { return iouActionOfTargetTransaction?.childReportID; } +function isValidTargetTransactionThreadReportID(reportID: string | undefined) { + return !!reportID && reportID !== CONST.FAKE_REPORT_ID; +} + +function getTargetTransactionThreadReportIDForSelection( + transaction: OnyxEntry, + selectedTransaction?: TargetTransactionThreadReportIDSource, + fallbackReportAction?: OnyxEntry, +) { + const selectedChildReportID = selectedTransaction?.reportAction?.childReportID; + if (isValidTargetTransactionThreadReportID(selectedChildReportID)) { + return selectedChildReportID; + } + + const selectedTransactionThreadReportID = selectedTransaction?.transaction?.transactionThreadReportID; + if (isValidTargetTransactionThreadReportID(selectedTransactionThreadReportID)) { + return selectedTransactionThreadReportID; + } + + const transactionThreadReportID = transaction?.transactionThreadReportID; + if (isValidTargetTransactionThreadReportID(transactionThreadReportID)) { + return transactionThreadReportID; + } + + const fallbackChildReportID = fallbackReportAction?.childReportID; + if (isValidTargetTransactionThreadReportID(fallbackChildReportID)) { + return fallbackChildReportID; + } + + const computedThreadReportID = getTransactionThreadReportID(transaction); + return isValidTargetTransactionThreadReportID(computedThreadReportID) ? computedThreadReportID : undefined; +} + /** * Build the merged transaction data for display by combining target transaction with merge transaction updates * @param targetTransaction - The target transaction to merge into @@ -732,6 +770,7 @@ export { areTransactionsEligibleForMerge, DERIVED_MERGE_FIELDS, getRateFromMerchant, + getTargetTransactionThreadReportIDForSelection, getTransactionsAndReportsFromSearch, }; diff --git a/src/libs/Navigation/helpers/isSearchOriginForMerge.ts b/src/libs/Navigation/helpers/isSearchOriginForMerge.ts new file mode 100644 index 000000000000..85f26bf5a8d1 --- /dev/null +++ b/src/libs/Navigation/helpers/isSearchOriginForMerge.ts @@ -0,0 +1,34 @@ +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute'; + +function normalizeRoute(route?: string): string | undefined { + if (!route) { + return undefined; + } + + const decodedRoute = (() => { + try { + return decodeURIComponent(route); + } catch { + return route; + } + })(); + + return decodedRoute.startsWith('/') ? decodedRoute.substring(1) : decodedRoute; +} + +function isSearchOriginForMerge(routeName: string, backTo?: string): boolean { + if (routeName !== SCREENS.RIGHT_MODAL.SEARCH_REPORT && routeName !== SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT) { + return false; + } + + const normalizedBackTo = normalizeRoute(backTo); + if (normalizedBackTo) { + return normalizedBackTo.startsWith(ROUTES.SEARCH_ROOT.route); + } + + return isSearchTopmostFullScreenRoute(); +} + +export default isSearchOriginForMerge; diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 4a2bb88e86f2..ae7b698319d4 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -12,6 +12,7 @@ import { DERIVED_MERGE_FIELDS, getMergeableDataAndConflictFields, getMergeFieldValue, + getTransactionThreadReportID, selectTargetAndSourceTransactionsForMerge, shouldNavigateToReceiptReview, } from '@libs/MergeTransactionUtils'; @@ -53,6 +54,21 @@ function setMergeTransactionKey(transactionID: string, values: MergeTransactionU Onyx.merge(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, values as OnyxMergeInput<`${typeof ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${string}`>); } +type TargetTransactionThreadReportCandidate = { + transactionID: string; + threadReportID: string; +}; + +function getStoredTargetTransactionThreadReportID(transaction: Transaction | undefined, targetTransactionThreadReportCandidate?: TargetTransactionThreadReportCandidate) { + if (!transaction) { + return undefined; + } + + const candidateThreadReportID = targetTransactionThreadReportCandidate?.transactionID === transaction.transactionID ? targetTransactionThreadReportCandidate.threadReportID : undefined; + + return candidateThreadReportID ?? transaction.transactionThreadReportID ?? getTransactionThreadReportID(transaction); +} + function setupMergeTransactionDataAndNavigate( navigationTransactionID: string, transactions: Transaction[], @@ -62,6 +78,7 @@ function setupMergeTransactionDataAndNavigate( isSelectingSourceTransaction?: boolean, isOnSearch?: boolean, policies?: Array>, + targetTransactionThreadReportCandidate?: TargetTransactionThreadReportCandidate, ) { if (!transactions.length || transactions.length > 2) { return; @@ -70,7 +87,11 @@ function setupMergeTransactionDataAndNavigate( if (transactions.length === 1) { const transaction = transactions.at(0); if (transaction) { - setupMergeTransactionData(navigationTransactionID, {targetTransactionID: transaction.transactionID}); + const storedTargetTransactionThreadReportID = getStoredTargetTransactionThreadReportID(transaction, targetTransactionThreadReportCandidate); + setupMergeTransactionData(navigationTransactionID, { + targetTransactionID: transaction.transactionID, + targetTransactionThreadReportID: storedTargetTransactionThreadReportID, + }); Navigation.navigate(ROUTES.MERGE_TRANSACTION_LIST_PAGE.getRoute(transaction.transactionID, Navigation.getActiveRoute(), isOnSearch)); return; } @@ -86,7 +107,12 @@ function setupMergeTransactionDataAndNavigate( return; } - const setupData = {targetTransactionID: targetTransaction?.transactionID, sourceTransactionID: sourceTransaction?.transactionID}; + const storedTargetTransactionThreadReportID = getStoredTargetTransactionThreadReportID(targetTransaction, targetTransactionThreadReportCandidate); + const setupData = { + targetTransactionID: targetTransaction?.transactionID, + sourceTransactionID: sourceTransaction?.transactionID, + targetTransactionThreadReportID: storedTargetTransactionThreadReportID, + }; if (isSelectingSourceTransaction) { setMergeTransactionKey(navigationTransactionID, setupData); } else { @@ -708,4 +734,5 @@ function mergeTransactionRequest({ API.write(WRITE_COMMANDS.MERGE_TRANSACTION, params, {optimisticData, failureData, successData}); } +export type {TargetTransactionThreadReportCandidate}; export {areTransactionsEligibleForMerge, setupMergeTransactionData, setupMergeTransactionDataAndNavigate, setMergeTransactionKey, getTransactionsForMerging, mergeTransactionRequest}; diff --git a/src/pages/TransactionMerge/ConfirmationPage.tsx b/src/pages/TransactionMerge/ConfirmationPage.tsx index e75e389d3a8a..c3155352bff9 100644 --- a/src/pages/TransactionMerge/ConfirmationPage.tsx +++ b/src/pages/TransactionMerge/ConfirmationPage.tsx @@ -56,7 +56,7 @@ function ConfirmationPage({route}: ConfirmationPageProps) { const {isBetaEnabled} = usePermissions(); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); - const targetTransactionThreadReportID = getTransactionThreadReportID(targetTransaction); + const targetTransactionThreadReportID = mergeTransaction?.targetTransactionThreadReportID ?? getTransactionThreadReportID(targetTransaction); const [targetTransactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${targetTransactionThreadReportID}`); const [targetTransactionThreadParentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(targetTransactionThreadReport?.parentReportID)}`); const [targetTransactionThreadParentReportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${getNonEmptyStringOnyxID(targetTransactionThreadReport?.parentReportID)}`); diff --git a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx index 8e9831a406ae..5508d99040ad 100644 --- a/src/pages/TransactionMerge/MergeTransactionsListContent.tsx +++ b/src/pages/TransactionMerge/MergeTransactionsListContent.tsx @@ -16,6 +16,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {getTransactionsForMerging, setupMergeTransactionData, setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; +import type {TargetTransactionThreadReportCandidate} from '@libs/actions/MergeTransaction'; import {fillMissingReceiptSource} from '@libs/MergeTransactionUtils'; import {getTransactionReportName, isIOUReport} from '@libs/ReportUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; @@ -31,11 +32,12 @@ import MergeTransactionItem from './MergeTransactionItem'; type MergeTransactionsListContentProps = { transactionID: string; mergeTransaction: OnyxEntry; + isOnSearch?: boolean; }; type MergeTransactionListItemType = Transaction & ListItem; -function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTransactionsListContentProps) { +function MergeTransactionsListContent({transactionID, mergeTransaction, isOnSearch}: MergeTransactionsListContentProps) { const illustrations = useMemoizedLazyIllustrations(['EmptyShelves']); const {translate, localeCompare} = useLocalize(); const styles = useThemeStyles(); @@ -121,6 +123,7 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr // Clear the merge transaction data when select a new source transaction to merge setupMergeTransactionData(transactionID, { targetTransactionID: transactionID, + targetTransactionThreadReportID: mergeTransaction?.targetTransactionThreadReportID, sourceTransactionID: item.transactionID, eligibleTransactions: mergeTransaction?.eligibleTransactions, }); @@ -157,10 +160,23 @@ function MergeTransactionsListContent({transactionID, mergeTransaction}: MergeTr } const reports = targetTransactionReport && sourceTransactionReport ? [targetTransactionReport, sourceTransactionReport] : undefined; - setupMergeTransactionDataAndNavigate(transactionID, [targetTransaction, sourceTransaction], localeCompare, getCurrencyDecimals, reports, true, undefined, [ - targetTransactionPolicy, - sourceTransactionPolicy, - ]); + const targetTransactionThreadReportCandidate: TargetTransactionThreadReportCandidate | undefined = mergeTransaction?.targetTransactionThreadReportID + ? { + transactionID: mergeTransaction?.targetTransactionID ?? transactionID, + threadReportID: mergeTransaction.targetTransactionThreadReportID, + } + : undefined; + setupMergeTransactionDataAndNavigate( + transactionID, + [targetTransaction, sourceTransaction], + localeCompare, + getCurrencyDecimals, + reports, + true, + isOnSearch, + [targetTransactionPolicy, sourceTransactionPolicy], + targetTransactionThreadReportCandidate, + ); }; const confirmButtonOptions = { diff --git a/src/pages/TransactionMerge/MergeTransactionsListPage.tsx b/src/pages/TransactionMerge/MergeTransactionsListPage.tsx index 33b372048811..9b64541c892a 100644 --- a/src/pages/TransactionMerge/MergeTransactionsListPage.tsx +++ b/src/pages/TransactionMerge/MergeTransactionsListPage.tsx @@ -18,7 +18,7 @@ type MergeTransactionsListPageProps = PlatformStackScreenProps diff --git a/src/types/onyx/MergeTransaction.ts b/src/types/onyx/MergeTransaction.ts index 467adb5d4c6c..26f57e0efc7e 100644 --- a/src/types/onyx/MergeTransaction.ts +++ b/src/types/onyx/MergeTransaction.ts @@ -9,6 +9,9 @@ type MergeTransaction = { /** Transaction ID we're keeping */ targetTransactionID: string; + /** The report ID of the target transaction thread */ + targetTransactionThreadReportID?: string; + /** ID of the transaction we're merging into that will be deleted */ sourceTransactionID: string; diff --git a/tests/actions/MergeTransactionTest.ts b/tests/actions/MergeTransactionTest.ts index acd60562e109..b8b045532359 100644 --- a/tests/actions/MergeTransactionTest.ts +++ b/tests/actions/MergeTransactionTest.ts @@ -1,9 +1,17 @@ import Onyx from 'react-native-onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {getReportPreviewAction} from '@libs/actions/IOU/MoneyRequestBuilder'; -import {areTransactionsEligibleForMerge, mergeTransactionRequest, setMergeTransactionKey, setupMergeTransactionData} from '@libs/actions/MergeTransaction'; +import { + areTransactionsEligibleForMerge, + mergeTransactionRequest, + setMergeTransactionKey, + setupMergeTransactionData, + setupMergeTransactionDataAndNavigate, +} from '@libs/actions/MergeTransaction'; +import type {TargetTransactionThreadReportCandidate} from '@libs/actions/MergeTransaction'; import {addComment, openReport} from '@libs/actions/Report'; import {WRITE_COMMANDS} from '@libs/API/types'; +import Navigation from '@libs/Navigation/Navigation'; import {getLoginsByAccountIDs} from '@libs/PersonalDetailsUtils'; import {getOriginalMessage, getReportAction} from '@libs/ReportActionsUtils'; import {buildTransactionThread} from '@libs/ReportUtils'; @@ -202,6 +210,8 @@ function runCrossReportMergeToSourceReportRequest(fixtures: CrossReportMergeToSo const TEST_EMAIL = 'test@expensifail.com'; const TEST_ACCOUNT_ID = 1; +const mockLocaleCompare = (a: string, b: string) => a.localeCompare(b); +const mockGetCurrencyDecimals = () => 2; describe('mergeTransactionRequest', () => { let mockFetch: MockFetch; @@ -1432,6 +1442,267 @@ describe('setupMergeTransactionData', () => { }); }); +describe('setupMergeTransactionDataAndNavigate', () => { + beforeEach(() => { + return Onyx.clear().then(waitForBatchedUpdates); + }); + + it('should persist targetTransactionThreadReportID for the bulk merge flow', async () => { + const transactionID = 'merge-transaction-123'; + const threadReportID = 'thread-report-123'; + const targetTransaction = { + ...createRandomTransaction(1), + transactionID: 'target-transaction-123', + reportID: 'report-123', + transactionThreadReportID: threadReportID, + managedCard: false, + bank: CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD, + cardName: CONST.EXPENSE.TYPE.CASH_CARD_NAME, + cardNumber: undefined, + receipt: undefined, + comment: { + comment: 'target', + }, + }; + const sourceTransaction = { + ...createRandomTransaction(2), + transactionID: 'source-transaction-123', + reportID: 'report-456', + managedCard: false, + bank: CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD, + cardName: CONST.EXPENSE.TYPE.CASH_CARD_NAME, + cardNumber: undefined, + receipt: undefined, + comment: { + comment: 'source', + }, + }; + + const navigateSpy = jest.spyOn(Navigation, 'navigate').mockImplementation(jest.fn()); + const getActiveRouteSpy = jest.spyOn(Navigation, 'getActiveRoute').mockReturnValue('/reports/expenses'); + + setupMergeTransactionDataAndNavigate(transactionID, [targetTransaction, sourceTransaction], mockLocaleCompare, mockGetCurrencyDecimals, undefined, false, true); + await waitForBatchedUpdates(); + + const mergeTransaction = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, + callback: (currentMergeTransaction) => { + Onyx.disconnect(connection); + resolve(currentMergeTransaction ?? null); + }, + }); + }); + + expect(mergeTransaction).toEqual({ + targetTransactionID: targetTransaction.transactionID, + sourceTransactionID: sourceTransaction.transactionID, + targetTransactionThreadReportID: threadReportID, + }); + expect(navigateSpy).toHaveBeenCalled(); + + navigateSpy.mockRestore(); + getActiveRouteSpy.mockRestore(); + }); + + it('should persist targetTransactionThreadReportID for the single-selection merge flow when an override is provided', async () => { + const transactionID = 'merge-transaction-single-123'; + const threadReportID = 'thread-report-single-123'; + const targetTransactionThreadReportCandidate: TargetTransactionThreadReportCandidate = { + transactionID: 'target-transaction-single-123', + threadReportID, + }; + const targetTransaction = { + ...createRandomTransaction(1), + transactionID: 'target-transaction-single-123', + reportID: 'report-single-123', + }; + + const navigateSpy = jest.spyOn(Navigation, 'navigate').mockImplementation(jest.fn()); + const getActiveRouteSpy = jest.spyOn(Navigation, 'getActiveRoute').mockReturnValue('/search?q=type:expense'); + + setupMergeTransactionDataAndNavigate( + transactionID, + [targetTransaction], + mockLocaleCompare, + mockGetCurrencyDecimals, + undefined, + false, + true, + undefined, + targetTransactionThreadReportCandidate, + ); + await waitForBatchedUpdates(); + + const mergeTransaction = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, + callback: (currentMergeTransaction) => { + Onyx.disconnect(connection); + resolve(currentMergeTransaction ?? null); + }, + }); + }); + + expect(mergeTransaction).toEqual({ + targetTransactionID: targetTransaction.transactionID, + targetTransactionThreadReportID: threadReportID, + }); + expect(navigateSpy).toHaveBeenCalled(); + + navigateSpy.mockRestore(); + getActiveRouteSpy.mockRestore(); + }); + + it('should persist the override when reseeding the two-transaction merge flow from the list page', async () => { + const transactionID = 'merge-transaction-override-123'; + const threadReportID = 'thread-report-override-123'; + const targetTransactionThreadReportCandidate: TargetTransactionThreadReportCandidate = { + transactionID: 'target-transaction-override-123', + threadReportID, + }; + const targetTransaction = { + ...createRandomTransaction(1), + transactionID: 'target-transaction-override-123', + reportID: 'report-override-123', + managedCard: false, + bank: CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD, + cardName: CONST.EXPENSE.TYPE.CASH_CARD_NAME, + cardNumber: undefined, + receipt: undefined, + comment: { + comment: 'target', + }, + }; + const sourceTransaction = { + ...createRandomTransaction(2), + transactionID: 'source-transaction-override-123', + reportID: 'report-override-456', + managedCard: false, + bank: CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD, + cardName: CONST.EXPENSE.TYPE.CASH_CARD_NAME, + cardNumber: undefined, + receipt: undefined, + comment: { + comment: 'source', + }, + }; + + const navigateSpy = jest.spyOn(Navigation, 'navigate').mockImplementation(jest.fn()); + const getActiveRouteSpy = jest.spyOn(Navigation, 'getActiveRoute').mockReturnValue('/merge/test'); + + setupMergeTransactionDataAndNavigate( + transactionID, + [targetTransaction, sourceTransaction], + mockLocaleCompare, + mockGetCurrencyDecimals, + undefined, + true, + true, + undefined, + targetTransactionThreadReportCandidate, + ); + await waitForBatchedUpdates(); + + const mergeTransaction = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, + callback: (currentMergeTransaction) => { + Onyx.disconnect(connection); + resolve(currentMergeTransaction ?? null); + }, + }); + }); + + expect(mergeTransaction).toEqual({ + targetTransactionID: targetTransaction.transactionID, + sourceTransactionID: sourceTransaction.transactionID, + targetTransactionThreadReportID: threadReportID, + }); + expect(navigateSpy).toHaveBeenCalled(); + + navigateSpy.mockRestore(); + getActiveRouteSpy.mockRestore(); + }); + + it('should ignore the candidate thread when the final target swaps to the selected split expense', async () => { + const transactionID = 'merge-transaction-split-swap-123'; + const cashThreadReportID = 'thread-report-cash-123'; + const splitThreadReportID = 'thread-report-split-123'; + const targetTransactionThreadReportCandidate: TargetTransactionThreadReportCandidate = { + transactionID: 'cash-transaction-123', + threadReportID: cashThreadReportID, + }; + const cashTransaction = { + ...createRandomTransaction(1), + transactionID: 'cash-transaction-123', + reportID: 'report-cash-123', + transactionThreadReportID: cashThreadReportID, + managedCard: false, + bank: CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD, + cardName: CONST.EXPENSE.TYPE.CASH_CARD_NAME, + cardNumber: undefined, + receipt: undefined, + comment: { + comment: 'cash', + }, + }; + const splitTransaction = { + ...createRandomTransaction(2), + transactionID: 'split-transaction-123', + reportID: 'report-split-123', + transactionThreadReportID: splitThreadReportID, + managedCard: false, + bank: CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD, + cardName: CONST.EXPENSE.TYPE.CASH_CARD_NAME, + cardNumber: undefined, + receipt: undefined, + comment: { + ...createRandomTransaction(2).comment, + comment: 'split', + originalTransactionID: 'original-split-transaction-123', + source: CONST.IOU.TYPE.SPLIT, + }, + } as Transaction; + + const navigateSpy = jest.spyOn(Navigation, 'navigate').mockImplementation(jest.fn()); + const getActiveRouteSpy = jest.spyOn(Navigation, 'getActiveRoute').mockReturnValue('/merge/test'); + + setupMergeTransactionDataAndNavigate( + transactionID, + [cashTransaction, splitTransaction], + mockLocaleCompare, + mockGetCurrencyDecimals, + undefined, + true, + true, + undefined, + targetTransactionThreadReportCandidate, + ); + await waitForBatchedUpdates(); + + const mergeTransaction = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, + callback: (currentMergeTransaction) => { + Onyx.disconnect(connection); + resolve(currentMergeTransaction ?? null); + }, + }); + }); + + expect(mergeTransaction).toEqual({ + targetTransactionID: splitTransaction.transactionID, + sourceTransactionID: cashTransaction.transactionID, + targetTransactionThreadReportID: splitThreadReportID, + }); + expect(navigateSpy).toHaveBeenCalled(); + + navigateSpy.mockRestore(); + getActiveRouteSpy.mockRestore(); + }); +}); + describe('setMergeTransactionKey', () => { beforeEach(() => { return Onyx.clear().then(waitForBatchedUpdates); @@ -1477,6 +1748,39 @@ describe('setMergeTransactionKey', () => { description: 'New Description', // Added }); }); + + it('should preserve targetTransactionThreadReportID when updating merge state for confirmation', async () => { + const transactionID = 'test-transaction-456'; + const existingMergeTransaction = { + targetTransactionID: transactionID, + targetTransactionThreadReportID: 'thread-report-456', + eligibleTransactions: [createRandomTransaction(1), createRandomTransaction(2)], + }; + + await Onyx.set(`${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, existingMergeTransaction); + + setMergeTransactionKey(transactionID, { + sourceTransactionID: 'source-transaction-456', + }); + await waitForBatchedUpdates(); + + const mergeTransaction = await new Promise((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.MERGE_TRANSACTION}${transactionID}`, + callback: (currentMergeTransaction) => { + Onyx.disconnect(connection); + resolve(currentMergeTransaction ?? null); + }, + }); + }); + + expect(mergeTransaction).toEqual({ + targetTransactionID: transactionID, + targetTransactionThreadReportID: 'thread-report-456', + sourceTransactionID: 'source-transaction-456', + eligibleTransactions: existingMergeTransaction.eligibleTransactions, + }); + }); }); describe('areTransactionsEligibleForMerge', () => { diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index 3f35d24c32df..1900360f96cd 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -10,6 +10,7 @@ import { getMergeFieldUpdatedValues, getMergeFieldValue, getRateFromMerchant, + getTargetTransactionThreadReportIDForSelection, isEmptyMergeValue, selectTargetAndSourceTransactionsForMerge, shouldNavigateToReceiptReview, @@ -19,6 +20,7 @@ import {isFromCreditCardImport} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import createRandomMergeTransaction from '../utils/collections/mergeTransaction'; +import createRandomReportAction from '../utils/collections/reportActions'; import {createRandomReport} from '../utils/collections/reports'; import createRandomTransaction, {createRandomDistanceRequestTransaction} from '../utils/collections/transaction'; import {translateLocal} from '../utils/TestHelper'; @@ -94,6 +96,92 @@ describe('MergeTransactionUtils', () => { }); }); + describe('getTargetTransactionThreadReportIDForSelection', () => { + it('should prefer selected report action childReportID', () => { + const transaction = { + ...createRandomTransaction(0), + transactionThreadReportID: 'transaction-thread-report-id', + }; + const reportAction = createRandomReportAction(1); + reportAction.reportActionID = 'report-action-id'; + reportAction.childReportID = 'child-report-id'; + + const result = getTargetTransactionThreadReportIDForSelection(transaction, { + isSelected: true, + canReject: false, + canHold: false, + canSplit: false, + hasBeenSplit: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + policyID: undefined, + amount: 0, + currency: 'USD', + isFromOneTransactionReport: false, + reportAction, + }); + + expect(result).toBe('child-report-id'); + }); + + it('should fall back to selected transaction threadReportID before snapshot transaction fields', () => { + const transaction = { + ...createRandomTransaction(0), + transactionThreadReportID: 'snapshot-thread-report-id', + }; + + const result = getTargetTransactionThreadReportIDForSelection(transaction, { + isSelected: true, + canReject: false, + canHold: false, + canSplit: false, + hasBeenSplit: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + policyID: undefined, + amount: 0, + currency: 'USD', + isFromOneTransactionReport: false, + transaction: { + ...createRandomTransaction(1), + transactionThreadReportID: 'selected-thread-report-id', + }, + }); + + expect(result).toBe('selected-thread-report-id'); + }); + + it('should return undefined when no thread metadata is available', () => { + const transaction = { + ...createRandomTransaction(0), + transactionThreadReportID: undefined, + reportID: CONST.REPORT.UNREPORTED_REPORT_ID, + }; + + const result = getTargetTransactionThreadReportIDForSelection(transaction, { + isSelected: true, + canReject: false, + canHold: false, + canSplit: false, + hasBeenSplit: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + policyID: undefined, + amount: 0, + currency: 'USD', + isFromOneTransactionReport: false, + }); + + expect(result).toBeUndefined(); + }); + }); + describe('getMergeFieldValue', () => { it('should return empty string when transaction is undefined', () => { // Given an undefined transaction diff --git a/tests/unit/Navigation/isSearchOriginForMerge.test.ts b/tests/unit/Navigation/isSearchOriginForMerge.test.ts new file mode 100644 index 000000000000..ce425f14664e --- /dev/null +++ b/tests/unit/Navigation/isSearchOriginForMerge.test.ts @@ -0,0 +1,39 @@ +import isSearchOriginForMerge from '@libs/Navigation/helpers/isSearchOriginForMerge'; +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import SCREENS from '@src/SCREENS'; + +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn()); + +describe('isSearchOriginForMerge', () => { + const mockIsSearchTopmostFullScreenRoute = jest.mocked(isSearchTopmostFullScreenRoute); + + beforeEach(() => { + mockIsSearchTopmostFullScreenRoute.mockReset(); + mockIsSearchTopmostFullScreenRoute.mockReturnValue(false); + }); + + it('returns true for SEARCH_REPORT routes with a search root backTo', () => { + expect(isSearchOriginForMerge(SCREENS.RIGHT_MODAL.SEARCH_REPORT, '/search?q=type:expense')).toBe(true); + }); + + it('returns true for SEARCH_REPORT routes with a search report backTo', () => { + expect(isSearchOriginForMerge(SCREENS.RIGHT_MODAL.SEARCH_REPORT, '/search/r/123')).toBe(true); + }); + + it('returns false for SEARCH_REPORT routes with inbox or home backTo routes', () => { + expect(isSearchOriginForMerge(SCREENS.RIGHT_MODAL.SEARCH_REPORT, '/e/123')).toBe(false); + expect(isSearchOriginForMerge(SCREENS.RIGHT_MODAL.SEARCH_REPORT, '/r/123')).toBe(false); + }); + + it('falls back to the topmost fullscreen search route when backTo is missing', () => { + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + + expect(isSearchOriginForMerge(SCREENS.RIGHT_MODAL.SEARCH_REPORT)).toBe(true); + }); + + it('returns false for non-search routes', () => { + mockIsSearchTopmostFullScreenRoute.mockReturnValue(true); + + expect(isSearchOriginForMerge(SCREENS.REPORT, '/search?q=type:expense')).toBe(false); + }); +}); diff --git a/tests/unit/hooks/useSelectedTransactionsActions.test.ts b/tests/unit/hooks/useSelectedTransactionsActions.test.ts index 3ffdf3d643da..1ae0f7affc24 100644 --- a/tests/unit/hooks/useSelectedTransactionsActions.test.ts +++ b/tests/unit/hooks/useSelectedTransactionsActions.test.ts @@ -5,13 +5,13 @@ import type {SelectedTransactions} from '@components/Search/types'; import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions'; import {unholdRequest} from '@libs/actions/IOU/Hold'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; -import {exportReportToCSV} from '@libs/actions/Report'; +import {createTransactionThreadReport, exportReportToCSV} from '@libs/actions/Report'; import initSplitExpense from '@libs/actions/SplitExpenses'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {ReportAction, Session} from '@src/types/onyx'; +import type {ReportAction, SearchResults, Session} from '@src/types/onyx'; import createRandomPolicy from '../../utils/collections/policies'; import createRandomReportAction from '../../utils/collections/reportActions'; import {createRandomReport} from '../../utils/collections/reports'; @@ -42,6 +42,7 @@ jest.mock('@libs/actions/MergeTransaction', () => ({ })); jest.mock('@libs/actions/Report', () => ({ + createTransactionThreadReport: jest.fn(), exportReportToCSV: jest.fn(), getCurrentUserEmail: jest.fn(() => 'test@example.com'), })); @@ -69,6 +70,7 @@ const mockClearSelectedTransactions = jest.fn(); const mockSelectedTransactionIDs: string[] = []; const mockSelectedTransactions: SelectedTransactions = {}; const mockCurrentSearchHash = 12345; +let mockCurrentSearchResults: SearchResults | undefined; jest.mock('@components/Search/SearchContext', () => ({ useSearchQueryContext: () => ({currentSearchHash: mockCurrentSearchHash}), @@ -76,6 +78,7 @@ jest.mock('@components/Search/SearchContext', () => ({ useSearchSelectionContext: () => ({ selectedTransactionIDs: mockSelectedTransactionIDs, selectedTransactions: mockSelectedTransactions, + currentSearchResults: mockCurrentSearchResults, }), useSearchSelectionActions: () => ({ clearSelectedTransactions: mockClearSelectedTransactions, @@ -160,6 +163,7 @@ describe('useSelectedTransactionsActions', () => { delete mockSelectedTransactions[key]; } mockIsOffline = false; + mockCurrentSearchResults = undefined; mockShouldOpenSplitExpenseEditFlowOnDelete.mockReturnValue(false); }); @@ -915,6 +919,7 @@ describe('useSelectedTransactionsActions', () => { const reportActions: ReportAction[] = []; const transaction = createRandomTransaction(1); transaction.transactionID = transactionID; + transaction.transactionThreadReportID = 'existing-thread-report-id'; transaction.managedCard = false; transaction.cardName = CONST.EXPENSE.TYPE.CASH_CARD_NAME; @@ -949,6 +954,218 @@ describe('useSelectedTransactionsActions', () => { mergeOption?.onSelected?.(); - expect(setupMergeTransactionDataAndNavigate).toHaveBeenCalledWith(transaction.transactionID, [transaction], mockLocalCompare, mockGetCurrencyDecimals, [], false, false, undefined); + expect(setupMergeTransactionDataAndNavigate).toHaveBeenCalledWith(transaction.transactionID, [transaction], mockLocalCompare, mockGetCurrencyDecimals, [], false, false, undefined, { + transactionID: transaction.transactionID, + threadReportID: transaction.transactionThreadReportID, + }); + }); + + it('should pass isOnSearch=true for search-origin report checkbox merge', async () => { + const transactionID = 'search-transaction-123'; + const report = createRandomReport(1, undefined); + report.type = CONST.REPORT.TYPE.EXPENSE; + report.statusNum = 0; + report.stateNum = 0; + const policy = createRandomPolicy(1); + const reportActions: ReportAction[] = []; + const transaction = createRandomTransaction(1); + transaction.transactionID = transactionID; + transaction.transactionThreadReportID = 'search-thread-report-id'; + transaction.managedCard = false; + transaction.cardName = CONST.EXPENSE.TYPE.CASH_CARD_NAME; + + mockSelectedTransactionIDs.push(transactionID); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + + jest.spyOn(require('@libs/ReportSecondaryActionUtils'), 'isMergeActionForSelectedTransactions').mockReturnValue(true); + + await Onyx.merge(ONYXKEYS.SESSION, {accountID: 1}); + const {result} = renderHook(() => + useSelectedTransactionsActions({ + report, + reportActions, + allTransactionsLength: 1, + policy, + beginExportWithTemplate: mockBeginExportWithTemplate, + isOnSearch: true, + }), + ); + + await waitFor(() => { + const mergeOption = result.current.options.find((option) => option.value === CONST.REPORT.SELECTED_TRANSACTIONS_BULK_ACTION_TYPES.MERGE); + expect(mergeOption).toBeDefined(); + }); + + const mergeOption = result.current.options.find((option) => option.value === CONST.REPORT.SELECTED_TRANSACTIONS_BULK_ACTION_TYPES.MERGE); + mergeOption?.onSelected?.(); + + expect(setupMergeTransactionDataAndNavigate).toHaveBeenCalledWith(transaction.transactionID, [transaction], mockLocalCompare, mockGetCurrencyDecimals, [], false, true, undefined, { + transactionID: transaction.transactionID, + threadReportID: transaction.transactionThreadReportID, + }); + }); + + it('should use reportActions childReportID as the merge override when transaction thread metadata is missing', async () => { + const transactionID = 'report-action-transaction-123'; + const childReportID = 'child-report-123'; + const report = createRandomReport(1, undefined); + report.type = CONST.REPORT.TYPE.EXPENSE; + report.statusNum = 0; + report.stateNum = 0; + const policy = createRandomPolicy(1); + const reportAction = createRandomReportAction(1); + reportAction.actionName = CONST.REPORT.ACTIONS.TYPE.IOU; + // eslint-disable-next-line @typescript-eslint/no-deprecated -- This test needs a legacy IOU action fixture because the production lookup still reads originalMessage. + reportAction.originalMessage = { + IOUTransactionID: transactionID, + } as never; + reportAction.childReportID = childReportID; + const reportActions: ReportAction[] = [reportAction]; + const transaction = createRandomTransaction(1); + transaction.transactionID = transactionID; + transaction.transactionThreadReportID = undefined; + transaction.managedCard = false; + transaction.cardName = CONST.EXPENSE.TYPE.CASH_CARD_NAME; + + mockSelectedTransactionIDs.push(transactionID); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + + jest.spyOn(require('@libs/ReportSecondaryActionUtils'), 'isMergeActionForSelectedTransactions').mockReturnValue(true); + + await Onyx.merge(ONYXKEYS.SESSION, {accountID: 1}); + const {result} = renderHook(() => + useSelectedTransactionsActions({ + report, + reportActions, + allTransactionsLength: 1, + policy, + beginExportWithTemplate: mockBeginExportWithTemplate, + isOnSearch: true, + }), + ); + + await waitFor(() => { + const mergeOption = result.current.options.find((option) => option.value === CONST.REPORT.SELECTED_TRANSACTIONS_BULK_ACTION_TYPES.MERGE); + expect(mergeOption).toBeDefined(); + }); + + const mergeOption = result.current.options.find((option) => option.value === CONST.REPORT.SELECTED_TRANSACTIONS_BULK_ACTION_TYPES.MERGE); + mergeOption?.onSelected?.(); + + expect(createTransactionThreadReport).not.toHaveBeenCalled(); + expect(setupMergeTransactionDataAndNavigate).toHaveBeenCalledWith(transactionID, [transaction], mockLocalCompare, mockGetCurrencyDecimals, [], false, true, undefined, { + transactionID, + threadReportID: childReportID, + }); + }); + + it('should create a thread and use its reportID as the merge override when no thread metadata exists', async () => { + const transactionID = 'created-thread-transaction-123'; + const createdThreadReportID = 'created-thread-report-123'; + const report = createRandomReport(1, undefined); + report.type = CONST.REPORT.TYPE.EXPENSE; + report.statusNum = 0; + report.stateNum = 0; + const policy = createRandomPolicy(1); + const reportActions: ReportAction[] = []; + const transaction = createRandomTransaction(1); + transaction.transactionID = transactionID; + transaction.transactionThreadReportID = undefined; + transaction.managedCard = false; + transaction.cardName = CONST.EXPENSE.TYPE.CASH_CARD_NAME; + + jest.mocked(createTransactionThreadReport).mockReturnValue({reportID: createdThreadReportID} as never); + + mockSelectedTransactionIDs.push(transactionID); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + + jest.spyOn(require('@libs/ReportSecondaryActionUtils'), 'isMergeActionForSelectedTransactions').mockReturnValue(true); + + await Onyx.merge(ONYXKEYS.SESSION, {accountID: 1}); + const {result} = renderHook(() => + useSelectedTransactionsActions({ + report, + reportActions, + allTransactionsLength: 1, + policy, + beginExportWithTemplate: mockBeginExportWithTemplate, + isOnSearch: true, + }), + ); + + await waitFor(() => { + const mergeOption = result.current.options.find((option) => option.value === CONST.REPORT.SELECTED_TRANSACTIONS_BULK_ACTION_TYPES.MERGE); + expect(mergeOption).toBeDefined(); + }); + + const mergeOption = result.current.options.find((option) => option.value === CONST.REPORT.SELECTED_TRANSACTIONS_BULK_ACTION_TYPES.MERGE); + mergeOption?.onSelected?.(); + + expect(createTransactionThreadReport).toHaveBeenCalled(); + expect(setupMergeTransactionDataAndNavigate).toHaveBeenCalledWith(transactionID, [transaction], mockLocalCompare, mockGetCurrencyDecimals, [], false, true, undefined, { + transactionID, + threadReportID: createdThreadReportID, + }); + }); + + it('should leave the two-selected report checkbox merge path unchanged', async () => { + const firstTransactionID = 'two-selected-transaction-123'; + const secondTransactionID = 'two-selected-transaction-456'; + const report = createRandomReport(1, undefined); + report.type = CONST.REPORT.TYPE.EXPENSE; + report.statusNum = 0; + report.stateNum = 0; + const policy = createRandomPolicy(1); + const firstTransaction = createRandomTransaction(1); + firstTransaction.transactionID = firstTransactionID; + firstTransaction.managedCard = false; + firstTransaction.cardName = CONST.EXPENSE.TYPE.CASH_CARD_NAME; + const secondTransaction = createRandomTransaction(2); + secondTransaction.transactionID = secondTransactionID; + secondTransaction.managedCard = false; + secondTransaction.cardName = CONST.EXPENSE.TYPE.CASH_CARD_NAME; + + mockSelectedTransactionIDs.push(firstTransactionID, secondTransactionID); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${firstTransactionID}`, firstTransaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${secondTransactionID}`, secondTransaction); + + jest.spyOn(require('@libs/ReportSecondaryActionUtils'), 'isMergeActionForSelectedTransactions').mockReturnValue(true); + + await Onyx.merge(ONYXKEYS.SESSION, {accountID: 1}); + const {result} = renderHook(() => + useSelectedTransactionsActions({ + report, + reportActions: [], + allTransactionsLength: 2, + policy, + beginExportWithTemplate: mockBeginExportWithTemplate, + isOnSearch: true, + }), + ); + + await waitFor(() => { + const mergeOption = result.current.options.find((option) => option.value === CONST.REPORT.SELECTED_TRANSACTIONS_BULK_ACTION_TYPES.MERGE); + expect(mergeOption).toBeDefined(); + }); + + const mergeOption = result.current.options.find((option) => option.value === CONST.REPORT.SELECTED_TRANSACTIONS_BULK_ACTION_TYPES.MERGE); + mergeOption?.onSelected?.(); + + expect(createTransactionThreadReport).not.toHaveBeenCalled(); + expect(setupMergeTransactionDataAndNavigate).toHaveBeenCalledWith( + firstTransactionID, + [firstTransaction, secondTransaction], + mockLocalCompare, + mockGetCurrencyDecimals, + [], + false, + true, + [policy, policy], + undefined, + ); }); });