From b3415606e3fc0e13e982a0499aeea6754aa17272 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Fri, 24 Apr 2026 00:22:59 +0430 Subject: [PATCH 1/3] Fix bulk merge post-merge navigation to open target thread report --- .../MoneyRequestHeaderSecondaryActions.tsx | 3 +- .../SelectionToolbar.tsx | 2 + src/hooks/useAllTransactions.ts | 16 +- src/hooks/useSearchBulkActions.ts | 61 +++- src/hooks/useSelectedTransactionsActions.ts | 66 +++- src/libs/MergeTransactionUtils.ts | 21 ++ .../helpers/isSearchOriginForMerge.ts | 34 ++ src/libs/actions/MergeTransaction.ts | 31 +- .../TransactionMerge/ConfirmationPage.tsx | 2 +- .../MergeTransactionsListContent.tsx | 26 +- .../MergeTransactionsListPage.tsx | 3 +- src/types/onyx/MergeTransaction.ts | 3 + tests/actions/MergeTransactionTest.ts | 306 +++++++++++++++++- tests/unit/MergeTransactionUtilsTest.ts | 88 +++++ .../Navigation/isSearchOriginForMerge.test.ts | 39 +++ tests/unit/hooks/useAllTransactions.test.ts | 39 +++ .../useSelectedTransactionsActions.test.ts | 296 ++++++++++++++++- 17 files changed, 1013 insertions(+), 23 deletions(-) create mode 100644 src/libs/Navigation/helpers/isSearchOriginForMerge.ts create mode 100644 tests/unit/Navigation/isSearchOriginForMerge.test.ts diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index 9e2b0e8cc3f0..e0ad74e9c370 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -32,6 +32,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'; @@ -340,7 +341,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.tsx b/src/components/MoneyRequestReportView/SelectionToolbar.tsx index db681d035305..4bbb7213be7a 100644 --- a/src/components/MoneyRequestReportView/SelectionToolbar.tsx +++ b/src/components/MoneyRequestReportView/SelectionToolbar.tsx @@ -28,6 +28,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'; @@ -165,6 +166,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/useAllTransactions.ts b/src/hooks/useAllTransactions.ts index 2c385ee1c603..6ef4fe1f33c6 100644 --- a/src/hooks/useAllTransactions.ts +++ b/src/hooks/useAllTransactions.ts @@ -31,10 +31,24 @@ function useAllTransactions() { {} as Record>, ); - return { + const mergedTransactions = { ...filteredSearchTransactions, ...allTransactionsCollection, }; + + for (const [key, snapshotTransaction] of Object.entries(filteredSearchTransactions)) { + const onyxTransaction = mergedTransactions[key]; + if (!snapshotTransaction?.transactionThreadReportID || !onyxTransaction || onyxTransaction.transactionThreadReportID) { + continue; + } + + mergedTransactions[key] = { + ...onyxTransaction, + transactionThreadReportID: snapshotTransaction.transactionThreadReportID, + }; + } + + return mergedTransactions; }, [currentSearchResults?.data, allTransactionsCollection]); return allTransactions; diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index d82983387461..93df9bb3c98d 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -11,7 +11,8 @@ import {useSearchActionsContext, useSearchStateContext} from '@components/Search import type {BulkPaySelectionData, PaymentData, SearchQueryJSON} from '@components/Search/types'; import {unholdRequest} from '@libs/actions/IOU/Hold'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; -import {deleteAppReport, markAsManuallyExported, moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter} from '@libs/actions/Report'; +import type {TargetTransactionThreadReportCandidate} from '@libs/actions/MergeTransaction'; +import {createTransactionThreadReport, deleteAppReport, markAsManuallyExported, moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter} from '@libs/actions/Report'; import { approveMoneyRequestOnSearch, bulkDeleteReports, @@ -32,7 +33,7 @@ import { } from '@libs/actions/Search'; import initSplitExpense from '@libs/actions/SplitExpenses'; import {setNameValuePair} from '@libs/actions/User'; -import {getTransactionsAndReportsFromSearch} from '@libs/MergeTransactionUtils'; +import {getTargetTransactionThreadReportIDForSearchSelection, getTransactionsAndReportsFromSearch} from '@libs/MergeTransactionUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getConnectedIntegration} from '@libs/PolicyUtils'; import {getSecondaryExportReportActions, isMergeActionForSelectedTransactions} from '@libs/ReportSecondaryActionUtils'; @@ -224,10 +225,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 defaultExpensePolicy = useDefaultExpensePolicy(); const undeleteTransactions = useUndeleteTransactions(); @@ -1331,7 +1334,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 + ? getTargetTransactionThreadReportIDForSearchSelection(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, + currentUserPersonalDetails.login ?? '', + currentUserPersonalDetails.accountID, + betas, + selectedReport, + selectedReportAction, + shouldPassTransactionData ? targetTransaction : undefined, + 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, + ); + }, }); } } @@ -1502,6 +1555,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { invokeDuplicateReportHandler, isExpenseReportType, handleDeleteSelectedTransactions, + introSelected, + betas, undeleteTransactions, currentUserPersonalDetails?.email, theme.icon, diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 2fddcc0fb925..8f10165436c6 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -7,7 +7,8 @@ import {useSearchActionsContext, useSearchStateContext} from '@components/Search 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 Navigation, {navigationRef} from '@libs/Navigation/Navigation'; @@ -89,6 +90,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(); @@ -419,13 +422,64 @@ 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: () => { + let targetTransactionThreadReportIDOverride: string | undefined; + + if (selectedTransactionsList.length === 1) { + const selectedTransactionMeta = selectedTransactionsMeta?.[transactionID]; + const selectedTransactionChildReportID = selectedTransactionMeta?.reportAction?.childReportID; + if (selectedTransactionChildReportID && selectedTransactionChildReportID !== CONST.FAKE_REPORT_ID) { + targetTransactionThreadReportIDOverride = selectedTransactionChildReportID; + } + + const selectedTransactionThreadReportID = selectedTransactionMeta?.transaction?.transactionThreadReportID; + if (!targetTransactionThreadReportIDOverride && selectedTransactionThreadReportID && selectedTransactionThreadReportID !== CONST.FAKE_REPORT_ID) { + targetTransactionThreadReportIDOverride = selectedTransactionThreadReportID; + } + + if ( + !targetTransactionThreadReportIDOverride && + selectedTransaction.transactionThreadReportID && + selectedTransaction.transactionThreadReportID !== CONST.FAKE_REPORT_ID + ) { + targetTransactionThreadReportIDOverride = selectedTransaction.transactionThreadReportID; + } + + const iouReportAction = getIOUActionForTransactionID(reportActions, transactionID); + if (!targetTransactionThreadReportIDOverride && iouReportAction?.childReportID && iouReportAction.childReportID !== CONST.FAKE_REPORT_ID) { + targetTransactionThreadReportIDOverride = iouReportAction.childReportID; + } + + if (!targetTransactionThreadReportIDOverride) { + const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; + const createdThreadReport = createTransactionThreadReport( + introSelected, + login ?? '', + currentUserAccountID, + betas, + report, + iouReportAction, + selectedTransaction, + transactionViolations, + ); + targetTransactionThreadReportIDOverride = createdThreadReport?.reportID; + } + } + + const targetTransactionThreadReportCandidate: TargetTransactionThreadReportCandidate | undefined = targetTransactionThreadReportIDOverride + ? { + transactionID, + threadReportID: targetTransactionThreadReportIDOverride, + } + : undefined; + setupMergeTransactionDataAndNavigate( transactionID, selectedTransactionsList, @@ -435,7 +489,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 5b376d909029..f17093d38c48 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -1,6 +1,7 @@ import {deepEqual} from 'fast-equals'; import type {OnyxEntry} from 'react-native-onyx'; import type {TupleToUnion} from 'type-fest'; +import type {SelectedTransactionInfo} from '@components/Search/types'; import type {CurrencyListActionsContextType} from '@components/CurrencyListContextProvider'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; @@ -352,6 +353,25 @@ function getTransactionThreadReportID(transaction: OnyxEntry) { return iouActionOfTargetTransaction?.childReportID; } +function getTargetTransactionThreadReportIDForSearchSelection(transaction: OnyxEntry, selectedTransaction?: SelectedTransactionInfo) { + const selectedChildReportID = selectedTransaction?.reportAction?.childReportID; + if (selectedChildReportID && selectedChildReportID !== CONST.FAKE_REPORT_ID) { + return selectedChildReportID; + } + + const selectedTransactionThreadReportID = selectedTransaction?.transaction?.transactionThreadReportID; + if (selectedTransactionThreadReportID && selectedTransactionThreadReportID !== CONST.FAKE_REPORT_ID) { + return selectedTransactionThreadReportID; + } + + if (transaction?.transactionThreadReportID && transaction.transactionThreadReportID !== CONST.FAKE_REPORT_ID) { + return transaction.transactionThreadReportID; + } + + const computedThreadReportID = getTransactionThreadReportID(transaction); + return computedThreadReportID && computedThreadReportID !== CONST.FAKE_REPORT_ID ? 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 @@ -694,6 +714,7 @@ export { areTransactionsEligibleForMerge, DERIVED_MERGE_FIELDS, getRateFromMerchant, + getTargetTransactionThreadReportIDForSearchSelection, getTransactionsAndReportsFromSearch, }; diff --git a/src/libs/Navigation/helpers/isSearchOriginForMerge.ts b/src/libs/Navigation/helpers/isSearchOriginForMerge.ts new file mode 100644 index 000000000000..e043e24c4b0e --- /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) { + 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 b6b2a6115127..1a6ea0ad5497 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 { @@ -717,4 +743,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 6bc398a43166..4f5405cea457 100644 --- a/src/pages/TransactionMerge/ConfirmationPage.tsx +++ b/src/pages/TransactionMerge/ConfirmationPage.tsx @@ -54,7 +54,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 5aed45a09bcd..7198a04bac97 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 f205cfca1b1e..c3b4c7fb9512 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 5f77ba6732ea..a6c737a81fc9 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'; -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'; @@ -63,6 +71,8 @@ function createAllTransactionViolations( 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; @@ -1262,6 +1272,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); @@ -1307,6 +1578,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 8d4f4efd977f..5602a0c25eec 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -7,6 +7,7 @@ import { getMergeFieldErrorText, getMergeFieldTranslationKey, getMergeFieldUpdatedValues, + getTargetTransactionThreadReportIDForSearchSelection, getMergeFieldValue, getRateFromMerchant, isEmptyMergeValue, @@ -18,6 +19,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'; @@ -93,6 +95,92 @@ describe('MergeTransactionUtils', () => { }); }); + describe('getTargetTransactionThreadReportIDForSearchSelection', () => { + 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 = getTargetTransactionThreadReportIDForSearchSelection(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 = getTargetTransactionThreadReportIDForSearchSelection(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 = getTargetTransactionThreadReportIDForSearchSelection(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/useAllTransactions.test.ts b/tests/unit/hooks/useAllTransactions.test.ts index 2d3c0d27dafd..32f0548d210c 100644 --- a/tests/unit/hooks/useAllTransactions.test.ts +++ b/tests/unit/hooks/useAllTransactions.test.ts @@ -143,6 +143,45 @@ describe('useAllTransactions', () => { expect(result.current?.[`${ONYXKEYS.COLLECTION.TRANSACTION}txn1`]?.amount).toBe(2000); }); + it('should preserve search transactionThreadReportID when collection data does not have it yet', async () => { + const searchTransaction = createRandomTransaction(1); + const collectionTransaction = createRandomTransaction(1); + searchTransaction.transactionID = 'txn1'; + collectionTransaction.transactionID = 'txn1'; + searchTransaction.transactionThreadReportID = 'thread-report-123'; + collectionTransaction.transactionThreadReportID = undefined; + searchTransaction.amount = 1000; + collectionTransaction.amount = 2000; + + mockCurrentSearchResults = { + search: { + offset: 0, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + hasMoreResults: false, + hasResults: true, + isLoading: false, + }, + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}txn1`]: searchTransaction, + }, + } as unknown as SearchResults; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}txn1`, collectionTransaction); + + const {result} = renderHook(() => useAllTransactions()); + + await waitFor(() => { + expect(result.current).toBeDefined(); + }); + + expect(result.current?.[`${ONYXKEYS.COLLECTION.TRANSACTION}txn1`]).toEqual( + expect.objectContaining({ + amount: 2000, + transactionThreadReportID: 'thread-report-123', + }), + ); + }); + it('should filter out non-transaction keys from search results', async () => { const transaction = createRandomTransaction(1); transaction.transactionID = 'txn1'; diff --git a/tests/unit/hooks/useSelectedTransactionsActions.test.ts b/tests/unit/hooks/useSelectedTransactionsActions.test.ts index 8317feede74a..c21511cb8b5f 100644 --- a/tests/unit/hooks/useSelectedTransactionsActions.test.ts +++ b/tests/unit/hooks/useSelectedTransactionsActions.test.ts @@ -6,13 +6,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'; @@ -43,6 +43,7 @@ jest.mock('@libs/actions/MergeTransaction', () => ({ })); jest.mock('@libs/actions/Report', () => ({ + createTransactionThreadReport: jest.fn(), exportReportToCSV: jest.fn(), getCurrentUserEmail: jest.fn(() => 'test@example.com'), })); @@ -70,12 +71,14 @@ const mockClearSelectedTransactions = jest.fn(); const mockSelectedTransactionIDs: string[] = []; const mockSelectedTransactions: SelectedTransactions = {}; const mockCurrentSearchHash = 12345; +let mockCurrentSearchResults: SearchResults | undefined; jest.mock('@components/Search/SearchContext', () => ({ useSearchStateContext: () => ({ selectedTransactionIDs: mockSelectedTransactionIDs, currentSearchHash: mockCurrentSearchHash, selectedTransactions: mockSelectedTransactions, + currentSearchResults: mockCurrentSearchResults, }), useSearchActionsContext: () => ({ clearSelectedTransactions: mockClearSelectedTransactions, @@ -154,6 +157,7 @@ describe('useSelectedTransactionsActions', () => { delete mockSelectedTransactions[key]; } mockIsOffline = false; + mockCurrentSearchResults = undefined; }); afterEach(async () => { @@ -851,6 +855,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; @@ -885,6 +890,291 @@ 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 preserve snapshot thread metadata when selected search-origin transactions are merged', async () => { + const transactionID = 'snapshot-transaction-123'; + const threadReportID = '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 onyxTransaction = createRandomTransaction(1); + onyxTransaction.transactionID = transactionID; + onyxTransaction.managedCard = false; + onyxTransaction.cardName = CONST.EXPENSE.TYPE.CASH_CARD_NAME; + onyxTransaction.transactionThreadReportID = undefined; + + const snapshotTransaction = { + ...onyxTransaction, + transactionThreadReportID: threadReportID, + }; + + mockCurrentSearchResults = { + search: { + offset: 0, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + hasMoreResults: false, + hasResults: true, + isLoading: false, + }, + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: snapshotTransaction, + }, + } as unknown as SearchResults; + + mockSelectedTransactionIDs.push(transactionID); + + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, onyxTransaction); + + 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( + transactionID, + [expect.objectContaining({transactionID, transactionThreadReportID: threadReportID})], + mockLocalCompare, + mockGetCurrencyDecimals, + [], + false, + true, + undefined, + {transactionID, threadReportID}, + ); + }); + + 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, + ); }); }); From 9a22f68f145c65ff1e0f72bdfc260503fd7ec2a1 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Mon, 27 Apr 2026 13:47:50 +0430 Subject: [PATCH 2/3] Refactor merge target thread resolution Extract the transaction thread report ID fallback logic into a shared MergeTransactionUtils helper and reuse it from both search bulk merge and selected transaction merge actions. This removes duplicated resolution logic while preserving the existing search helper export for unit test compatibility. --- src/hooks/useSearchBulkActions.ts | 4 +-- src/hooks/useSelectedTransactionsActions.ts | 28 +++------------ src/libs/MergeTransactionUtils.ts | 39 ++++++++++++++++----- 3 files changed, 38 insertions(+), 33 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 93df9bb3c98d..4873e0ee3864 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -33,7 +33,7 @@ import { } from '@libs/actions/Search'; import initSplitExpense from '@libs/actions/SplitExpenses'; import {setNameValuePair} from '@libs/actions/User'; -import {getTargetTransactionThreadReportIDForSearchSelection, getTransactionsAndReportsFromSearch} from '@libs/MergeTransactionUtils'; +import {getTargetTransactionThreadReportIDForSelection, getTransactionsAndReportsFromSearch} from '@libs/MergeTransactionUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getConnectedIntegration} from '@libs/PolicyUtils'; import {getSecondaryExportReportActions, isMergeActionForSelectedTransactions} from '@libs/ReportSecondaryActionUtils'; @@ -1340,7 +1340,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const selectedTransactionInfo = selectedTransactions[transactionID]; const isSingleSelection = selectedTransactionsKeys.length === 1; let targetTransactionThreadReportIDOverride = isSingleSelection - ? getTargetTransactionThreadReportIDForSearchSelection(searchSnapshotTransaction ?? searchedTransactions.at(0), selectedTransactionInfo) + ? getTargetTransactionThreadReportIDForSelection(searchSnapshotTransaction ?? searchedTransactions.at(0), selectedTransactionInfo) : undefined; if (!targetTransactionThreadReportIDOverride && isSingleSelection) { diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 8f10165436c6..7ba9b03046c8 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -11,6 +11,7 @@ import type {TargetTransactionThreadReportCandidate} from '@libs/actions/MergeTr 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'; @@ -430,32 +431,13 @@ function useSelectedTransactionsActions({ icon: expensifyIcons.ArrowCollapse, value: MERGE, onSelected: () => { + const isSingleSelection = selectedTransactionsList.length === 1; let targetTransactionThreadReportIDOverride: string | undefined; + const iouReportAction = isSingleSelection ? getIOUActionForTransactionID(reportActions, transactionID) : undefined; - if (selectedTransactionsList.length === 1) { + if (isSingleSelection) { const selectedTransactionMeta = selectedTransactionsMeta?.[transactionID]; - const selectedTransactionChildReportID = selectedTransactionMeta?.reportAction?.childReportID; - if (selectedTransactionChildReportID && selectedTransactionChildReportID !== CONST.FAKE_REPORT_ID) { - targetTransactionThreadReportIDOverride = selectedTransactionChildReportID; - } - - const selectedTransactionThreadReportID = selectedTransactionMeta?.transaction?.transactionThreadReportID; - if (!targetTransactionThreadReportIDOverride && selectedTransactionThreadReportID && selectedTransactionThreadReportID !== CONST.FAKE_REPORT_ID) { - targetTransactionThreadReportIDOverride = selectedTransactionThreadReportID; - } - - if ( - !targetTransactionThreadReportIDOverride && - selectedTransaction.transactionThreadReportID && - selectedTransaction.transactionThreadReportID !== CONST.FAKE_REPORT_ID - ) { - targetTransactionThreadReportIDOverride = selectedTransaction.transactionThreadReportID; - } - - const iouReportAction = getIOUActionForTransactionID(reportActions, transactionID); - if (!targetTransactionThreadReportIDOverride && iouReportAction?.childReportID && iouReportAction.childReportID !== CONST.FAKE_REPORT_ID) { - targetTransactionThreadReportIDOverride = iouReportAction.childReportID; - } + targetTransactionThreadReportIDOverride = getTargetTransactionThreadReportIDForSelection(selectedTransaction, selectedTransactionMeta, iouReportAction); if (!targetTransactionThreadReportIDOverride) { const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index f17093d38c48..bbb79a44bea8 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -1,13 +1,12 @@ import {deepEqual} from 'fast-equals'; import type {OnyxEntry} from 'react-native-onyx'; import type {TupleToUnion} from 'type-fest'; -import type {SelectedTransactionInfo} from '@components/Search/types'; import type {CurrencyListActionsContextType} from '@components/CurrencyListContextProvider'; 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 {convertToBackendAmount, convertToDisplayString} from './CurrencyUtils'; @@ -59,6 +58,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', @@ -353,23 +357,41 @@ function getTransactionThreadReportID(transaction: OnyxEntry) { return iouActionOfTargetTransaction?.childReportID; } -function getTargetTransactionThreadReportIDForSearchSelection(transaction: OnyxEntry, selectedTransaction?: SelectedTransactionInfo) { +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 (selectedChildReportID && selectedChildReportID !== CONST.FAKE_REPORT_ID) { + if (isValidTargetTransactionThreadReportID(selectedChildReportID)) { return selectedChildReportID; } const selectedTransactionThreadReportID = selectedTransaction?.transaction?.transactionThreadReportID; - if (selectedTransactionThreadReportID && selectedTransactionThreadReportID !== CONST.FAKE_REPORT_ID) { + if (isValidTargetTransactionThreadReportID(selectedTransactionThreadReportID)) { return selectedTransactionThreadReportID; } - if (transaction?.transactionThreadReportID && transaction.transactionThreadReportID !== CONST.FAKE_REPORT_ID) { - return transaction.transactionThreadReportID; + const transactionThreadReportID = transaction?.transactionThreadReportID; + if (isValidTargetTransactionThreadReportID(transactionThreadReportID)) { + return transactionThreadReportID; + } + + const fallbackChildReportID = fallbackReportAction?.childReportID; + if (isValidTargetTransactionThreadReportID(fallbackChildReportID)) { + return fallbackChildReportID; } const computedThreadReportID = getTransactionThreadReportID(transaction); - return computedThreadReportID && computedThreadReportID !== CONST.FAKE_REPORT_ID ? computedThreadReportID : undefined; + return isValidTargetTransactionThreadReportID(computedThreadReportID) ? computedThreadReportID : undefined; +} + +function getTargetTransactionThreadReportIDForSearchSelection(transaction: OnyxEntry, selectedTransaction?: TargetTransactionThreadReportIDSource) { + return getTargetTransactionThreadReportIDForSelection(transaction, selectedTransaction); } /** @@ -714,6 +736,7 @@ export { areTransactionsEligibleForMerge, DERIVED_MERGE_FIELDS, getRateFromMerchant, + getTargetTransactionThreadReportIDForSelection, getTargetTransactionThreadReportIDForSearchSelection, getTransactionsAndReportsFromSearch, }; From f03bf99f9dee16dbe0fedf9ef2721123535397f9 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Mon, 27 Apr 2026 13:50:47 +0430 Subject: [PATCH 3/3] fixed prettier --- tests/unit/MergeTransactionUtilsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index 5602a0c25eec..db49f5c6220f 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -7,9 +7,9 @@ import { getMergeFieldErrorText, getMergeFieldTranslationKey, getMergeFieldUpdatedValues, - getTargetTransactionThreadReportIDForSearchSelection, getMergeFieldValue, getRateFromMerchant, + getTargetTransactionThreadReportIDForSearchSelection, isEmptyMergeValue, selectTargetAndSourceTransactionsForMerge, shouldNavigateToReceiptReview,