From fdc20abd97fa18de7aa7d5c5331a63ba3284fdf4 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Fri, 1 May 2026 12:19:30 +0430 Subject: [PATCH 1/8] Fix bulk expense merge navigation destination --- .../MoneyRequestHeaderSecondaryActions.tsx | 3 +- .../SelectionToolbar.tsx | 2 + src/hooks/useAllTransactions.ts | 16 +- src/hooks/useSearchBulkActions.ts | 61 +++- src/hooks/useSelectedTransactionsActions.ts | 48 ++- src/libs/MergeTransactionUtils.ts | 46 ++- .../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, 1019 insertions(+), 24 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 fc4f43a13658..663f0edab793 100644 --- a/src/components/MoneyRequestHeaderSecondaryActions.tsx +++ b/src/components/MoneyRequestHeaderSecondaryActions.tsx @@ -33,6 +33,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'; @@ -343,7 +344,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 1ad7fab2808a..40882b05a114 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'; @@ -157,6 +158,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 43cc5d5d1d46..3997d31793e1 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -12,7 +12,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, @@ -33,7 +34,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 {getConnectedIntegration} from '@libs/PolicyUtils'; import {getSecondaryExportReportActions, isMergeActionForSelectedTransactions} from '@libs/ReportSecondaryActionUtils'; @@ -225,10 +226,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(); @@ -1338,7 +1341,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, + 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, + ); + }, }); } } @@ -1509,6 +1562,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 fefb1ed701a4..729137199c12 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -7,9 +7,11 @@ 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 {getTargetTransactionThreadReportIDForSelection} from '@libs/MergeTransactionUtils'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import {getIOUActionForTransactionID, getReportAction, isDeletedAction} from '@libs/ReportActionsUtils'; import {isMergeActionForSelectedTransactions, isSplitAction} from '@libs/ReportSecondaryActionUtils'; @@ -89,6 +91,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(); @@ -421,13 +425,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, + login ?? '', + currentUserAccountID, + betas, + report, + iouReportAction, + selectedTransaction, + transactionViolations, + ); + targetTransactionThreadReportIDOverride = createdThreadReport?.reportID; + } + } + + const targetTransactionThreadReportCandidate: TargetTransactionThreadReportCandidate | undefined = targetTransactionThreadReportIDOverride + ? { + transactionID, + threadReportID: targetTransactionThreadReportIDOverride, + } + : undefined; + setupMergeTransactionDataAndNavigate( transactionID, selectedTransactionsList, @@ -437,7 +473,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..bbb79a44bea8 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 {convertToBackendAmount, convertToDisplayString} from './CurrencyUtils'; @@ -58,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', @@ -352,6 +357,43 @@ 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; +} + +function getTargetTransactionThreadReportIDForSearchSelection(transaction: OnyxEntry, selectedTransaction?: TargetTransactionThreadReportIDSource) { + return getTargetTransactionThreadReportIDForSelection(transaction, selectedTransaction); +} + /** * 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 +736,8 @@ export { areTransactionsEligibleForMerge, DERIVED_MERGE_FIELDS, getRateFromMerchant, + getTargetTransactionThreadReportIDForSelection, + 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 339aaf18857f..758f3191a17e 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 { @@ -715,4 +741,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 ef2d7fafaea8..1055f00b9457 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 297e088c0a34..ca92fe9f280b 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; @@ -1263,6 +1273,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); @@ -1308,6 +1579,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..db49f5c6220f 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -9,6 +9,7 @@ import { getMergeFieldUpdatedValues, getMergeFieldValue, getRateFromMerchant, + getTargetTransactionThreadReportIDForSearchSelection, isEmptyMergeValue, selectTargetAndSourceTransactionsForMerge, shouldNavigateToReceiptReview, @@ -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 0d8b309b2e56..17213a13a909 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, @@ -153,6 +156,7 @@ describe('useSelectedTransactionsActions', () => { delete mockSelectedTransactions[key]; } mockIsOffline = false; + mockCurrentSearchResults = undefined; }); afterEach(async () => { @@ -850,6 +854,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; @@ -884,6 +889,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 4e25d3e97115d8e7c9b189a0b6de1cb8bf8556ca Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Thu, 7 May 2026 18:46:35 +0430 Subject: [PATCH 2/8] fixed type and lint error --- tests/actions/MergeTransactionTest.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/actions/MergeTransactionTest.ts b/tests/actions/MergeTransactionTest.ts index 342f04c57d16..acda4da3c8d8 100644 --- a/tests/actions/MergeTransactionTest.ts +++ b/tests/actions/MergeTransactionTest.ts @@ -8,6 +8,7 @@ import { 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'; From 7a0b221f75e5848a7cd494d02701daccdf80042a Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Fri, 8 May 2026 09:51:02 +0430 Subject: [PATCH 3/8] Remove redundant merge thread test-only wrapper - delete getTargetTransactionThreadReportIDForSearchSelection from MergeTransactionUtils - update MergeTransactionUtilsTest to cover getTargetTransactionThreadReportIDForSelection directly - keep behavior unchanged while simplifying the helper surface --- src/libs/MergeTransactionUtils.ts | 5 ----- tests/unit/MergeTransactionUtilsTest.ts | 10 +++++----- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 6856a5d3cd81..e6c4f35a4a16 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -390,10 +390,6 @@ function getTargetTransactionThreadReportIDForSelection( return isValidTargetTransactionThreadReportID(computedThreadReportID) ? computedThreadReportID : undefined; } -function getTargetTransactionThreadReportIDForSearchSelection(transaction: OnyxEntry, selectedTransaction?: TargetTransactionThreadReportIDSource) { - return getTargetTransactionThreadReportIDForSelection(transaction, selectedTransaction); -} - /** * Build the merged transaction data for display by combining target transaction with merge transaction updates * @param targetTransaction - The target transaction to merge into @@ -747,7 +743,6 @@ export { DERIVED_MERGE_FIELDS, getRateFromMerchant, getTargetTransactionThreadReportIDForSelection, - getTargetTransactionThreadReportIDForSearchSelection, getTransactionsAndReportsFromSearch, }; diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index ad48efb9b563..f7aacc0d0cf0 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -10,7 +10,7 @@ import { getMergeFieldUpdatedValues, getMergeFieldValue, getRateFromMerchant, - getTargetTransactionThreadReportIDForSearchSelection, + getTargetTransactionThreadReportIDForSelection, isEmptyMergeValue, selectTargetAndSourceTransactionsForMerge, shouldNavigateToReceiptReview, @@ -96,7 +96,7 @@ describe('MergeTransactionUtils', () => { }); }); - describe('getTargetTransactionThreadReportIDForSearchSelection', () => { + describe('getTargetTransactionThreadReportIDForSelection', () => { it('should prefer selected report action childReportID', () => { const transaction = { ...createRandomTransaction(0), @@ -106,7 +106,7 @@ describe('MergeTransactionUtils', () => { reportAction.reportActionID = 'report-action-id'; reportAction.childReportID = 'child-report-id'; - const result = getTargetTransactionThreadReportIDForSearchSelection(transaction, { + const result = getTargetTransactionThreadReportIDForSelection(transaction, { isSelected: true, canReject: false, canHold: false, @@ -132,7 +132,7 @@ describe('MergeTransactionUtils', () => { transactionThreadReportID: 'snapshot-thread-report-id', }; - const result = getTargetTransactionThreadReportIDForSearchSelection(transaction, { + const result = getTargetTransactionThreadReportIDForSelection(transaction, { isSelected: true, canReject: false, canHold: false, @@ -162,7 +162,7 @@ describe('MergeTransactionUtils', () => { reportID: CONST.REPORT.UNREPORTED_REPORT_ID, }; - const result = getTargetTransactionThreadReportIDForSearchSelection(transaction, { + const result = getTargetTransactionThreadReportIDForSelection(transaction, { isSelected: true, canReject: false, canHold: false, From 9c74ebad53437f641b0f4994d8bcdd4b33d2d589 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Tue, 19 May 2026 09:52:15 +0430 Subject: [PATCH 4/8] fixed type errors. --- src/hooks/useSearchBulkActions.ts | 16 ++++++++-------- src/hooks/useSelectedTransactionsActions.ts | 10 +++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 8576219bf3b4..7c728fe0b51a 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1433,16 +1433,16 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { ? allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${targetTransaction.transactionID}`] : undefined; const createdThreadReport = targetTransaction - ? createTransactionThreadReport( + ? createTransactionThreadReport({ introSelected, - currentUserPersonalDetails.login ?? '', - currentUserPersonalDetails.accountID, + currentUserLogin: currentUserPersonalDetails.login ?? '', + currentUserAccountID: currentUserPersonalDetails.accountID, betas, - selectedReport, - selectedReportAction, - shouldPassTransactionData ? targetTransaction : undefined, - shouldPassTransactionData ? transactionViolations : undefined, - ) + iouReport: selectedReport, + iouReportAction: selectedReportAction, + transaction: shouldPassTransactionData ? targetTransaction : undefined, + transactionViolations: shouldPassTransactionData ? transactionViolations : undefined, + }) : undefined; targetTransactionThreadReportIDOverride = createdThreadReport?.reportID; } diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index eba0708dfae5..356c78dfd65b 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -454,16 +454,16 @@ function useSelectedTransactionsActions({ if (!targetTransactionThreadReportIDOverride) { const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; - const createdThreadReport = createTransactionThreadReport( + const createdThreadReport = createTransactionThreadReport({ introSelected, - login ?? '', + currentUserLogin: login ?? '', currentUserAccountID, betas, - report, + iouReport: report, iouReportAction, - selectedTransaction, + transaction: selectedTransaction, transactionViolations, - ); + }); targetTransactionThreadReportIDOverride = createdThreadReport?.reportID; } } From 10801e115c743a5bbe534f230ba37d629cbf735f Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Fri, 22 May 2026 16:48:17 +0430 Subject: [PATCH 5/8] Remove stale search thread ID merge fallback - stop preserving transactionThreadReportID from the search snapshot in useAllTransactions - let overlapping Onyx transactions fully override search snapshot transactions - keep the merge/search flow aligned with the simplified data merge behavior --- src/hooks/useAllTransactions.ts | 12 ------- tests/unit/hooks/useAllTransactions.test.ts | 39 --------------------- 2 files changed, 51 deletions(-) diff --git a/src/hooks/useAllTransactions.ts b/src/hooks/useAllTransactions.ts index 6ef4fe1f33c6..251d18b77fe1 100644 --- a/src/hooks/useAllTransactions.ts +++ b/src/hooks/useAllTransactions.ts @@ -36,18 +36,6 @@ function useAllTransactions() { ...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]); diff --git a/tests/unit/hooks/useAllTransactions.test.ts b/tests/unit/hooks/useAllTransactions.test.ts index 32f0548d210c..2d3c0d27dafd 100644 --- a/tests/unit/hooks/useAllTransactions.test.ts +++ b/tests/unit/hooks/useAllTransactions.test.ts @@ -143,45 +143,6 @@ 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'; From 567dfb630b8d71bc1af748126f7411c9537b15bc Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Fri, 22 May 2026 17:08:25 +0430 Subject: [PATCH 6/8] removed the remaining related test case --- .../useSelectedTransactionsActions.test.ts | 73 ------------------- 1 file changed, 73 deletions(-) diff --git a/tests/unit/hooks/useSelectedTransactionsActions.test.ts b/tests/unit/hooks/useSelectedTransactionsActions.test.ts index fdea11eaf5db..37509a041c0c 100644 --- a/tests/unit/hooks/useSelectedTransactionsActions.test.ts +++ b/tests/unit/hooks/useSelectedTransactionsActions.test.ts @@ -1110,79 +1110,6 @@ describe('useSelectedTransactionsActions', () => { }); }); - 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'; From 829267fbf44a403c0d4b3002b202c9829124b288 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Wed, 27 May 2026 17:31:23 +0430 Subject: [PATCH 7/8] little refactor --- src/hooks/useAllTransactions.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/hooks/useAllTransactions.ts b/src/hooks/useAllTransactions.ts index c9563c3dec13..d32b8b584425 100644 --- a/src/hooks/useAllTransactions.ts +++ b/src/hooks/useAllTransactions.ts @@ -31,12 +31,10 @@ function useAllTransactions() { {} as Record>, ); - const mergedTransactions = { + return { ...filteredSearchTransactions, ...allTransactionsCollection, }; - - return mergedTransactions; }, [currentSearchResults?.data, allTransactionsCollection]); return allTransactions; From 148f0d49e46bffc2b2b57ad7b5925f268c9b01a7 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Mon, 1 Jun 2026 11:51:02 +0430 Subject: [PATCH 8/8] Fix search origin detection for money request reports Treat SEARCH_MONEY_REQUEST_REPORT as a valid merge search origin. --- src/libs/Navigation/helpers/isSearchOriginForMerge.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/Navigation/helpers/isSearchOriginForMerge.ts b/src/libs/Navigation/helpers/isSearchOriginForMerge.ts index e043e24c4b0e..85f26bf5a8d1 100644 --- a/src/libs/Navigation/helpers/isSearchOriginForMerge.ts +++ b/src/libs/Navigation/helpers/isSearchOriginForMerge.ts @@ -19,7 +19,7 @@ function normalizeRoute(route?: string): string | undefined { } function isSearchOriginForMerge(routeName: string, backTo?: string): boolean { - if (routeName !== SCREENS.RIGHT_MODAL.SEARCH_REPORT) { + if (routeName !== SCREENS.RIGHT_MODAL.SEARCH_REPORT && routeName !== SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT) { return false; }