diff --git a/src/components/MoneyRequestHeaderSecondaryActions.tsx b/src/components/MoneyRequestHeaderSecondaryActions.tsx index 6ff92ce19f94..3af569958888 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'; @@ -342,7 +343,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 d82983387461..4873e0ee3864 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 {getTargetTransactionThreadReportIDForSelection, 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 + ? 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, + ); + }, }); } } @@ -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..7ba9b03046c8 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(); @@ -419,13 +423,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, @@ -435,7 +471,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 76449ceb69bc..49d8a9e39405 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 { @@ -716,4 +742,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..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 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, + ); }); });