Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fdc20ab
Fix bulk expense merge navigation destination
marufsharifi May 1, 2026
b0890bc
Merge branch 'main' into fix/bulk-merge-expense-open-transaction-thread
marufsharifi May 7, 2026
4e25d3e
fixed type and lint error
marufsharifi May 7, 2026
487991b
Merge branch 'main' into fix/bulk-merge-expense-open-transaction-thread
marufsharifi May 7, 2026
0f9c2b9
Merge branch 'main' into fix/bulk-merge-expense-open-transaction-thread
marufsharifi May 8, 2026
7a0b221
Remove redundant merge thread test-only wrapper
marufsharifi May 8, 2026
3f15660
Merge branch 'main' into fix/bulk-merge-expense-open-transaction-thread
marufsharifi May 14, 2026
e51bd4d
Merge branch 'main' into fix/bulk-merge-expense-open-transaction-thread
marufsharifi May 18, 2026
dac5aa3
Merge branch 'main' into fix/bulk-merge-expense-open-transaction-thread
marufsharifi May 19, 2026
9c74eba
fixed type errors.
marufsharifi May 19, 2026
1cf76ea
Merge branch 'main' into fix/bulk-merge-expense-open-transaction-thread
marufsharifi May 22, 2026
10801e1
Remove stale search thread ID merge fallback
marufsharifi May 22, 2026
567dfb6
removed the remaining related test case
marufsharifi May 22, 2026
a54ff12
Merge branch 'main' into fix/bulk-merge-expense-open-transaction-thread
marufsharifi May 27, 2026
829267f
little refactor
marufsharifi May 27, 2026
9444506
Merge branch 'main' into fix/bulk-merge-expense-open-transaction-thread
marufsharifi Jun 1, 2026
148f0d4
Fix search origin detection for money request reports
marufsharifi Jun 1, 2026
65270cd
Merge branch 'main' into fix/bulk-merge-expense-open-transaction-thread
marufsharifi Jun 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/components/MoneyRequestHeaderSecondaryActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import initSplitExpense from '@libs/actions/SplitExpenses';
import {setNameValuePair} from '@libs/actions/User';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {getExistingTransactionID} from '@libs/IOUUtils';
import isSearchOriginForMerge from '@libs/Navigation/helpers/isSearchOriginForMerge';
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types';
import type {ReportsSplitNavigatorParamList, RightModalNavigatorParamList} from '@libs/Navigation/types';
Expand Down Expand Up @@ -354,7 +355,7 @@ function MoneyRequestHeaderSecondaryActions({reportID, onBackButtonPress}: Money
if (!transaction) {
return;
}
const isOnSearch = route.name.toLowerCase().startsWith('search');
const isOnSearch = isSearchOriginForMerge(route.name, route.params?.backTo);
setupMergeTransactionDataAndNavigate(transaction.transactionID, [transaction], localeCompare, getCurrencyDecimals, [], false, isOnSearch);
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {dismissRejectUseExplanation} from '@libs/actions/IOU/RejectMoneyRequest';
import {queueExportSearchWithTemplate} from '@libs/actions/Search';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute';
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types';
import type {ReportsSplitNavigatorParamList} from '@libs/Navigation/types';
Expand Down Expand Up @@ -154,6 +155,7 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool
onExportOffline: () => setOfflineModalVisible(true),
policy,
beginExportWithTemplate: (templateName, templateType, transactionIDList) => beginExportWithTemplate(templateName, templateType, transactionIDList),
isOnSearch: isSearchTopmostFullScreenRoute(),
onDeleteSelected,
});

Expand Down
68 changes: 65 additions & 3 deletions src/hooks/useSearchBulkActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@ import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionAction
import type {BulkPaySelectionData, PaymentData, SearchFilterKey, SearchQueryJSON, SelectedTransactions} from '@components/Search/types';
import {unholdRequest} from '@libs/actions/IOU/Hold';
import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction';
import {deleteAppReport, exportReportToPDF, markAsManuallyExported, moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter} from '@libs/actions/Report';
import type {TargetTransactionThreadReportCandidate} from '@libs/actions/MergeTransaction';
import {
createTransactionThreadReport,
deleteAppReport,
exportReportToPDF,
markAsManuallyExported,
moveIOUReportToPolicy,
moveIOUReportToPolicyAndInviteSubmitter,
} from '@libs/actions/Report';
import {
approveMoneyRequestOnSearch,
exportSearchItemsToCSV,
Expand All @@ -33,7 +41,7 @@ import {
} from '@libs/actions/Search';
import initSplitExpense from '@libs/actions/SplitExpenses';
import {setNameValuePair} from '@libs/actions/User';
import {getTransactionsAndReportsFromSearch} from '@libs/MergeTransactionUtils';
import {getTargetTransactionThreadReportIDForSelection, getTransactionsAndReportsFromSearch} from '@libs/MergeTransactionUtils';
import Navigation from '@libs/Navigation/Navigation';
import {getLoginByAccountID} from '@libs/PersonalDetailsUtils';
import {getConnectedIntegration} from '@libs/PolicyUtils';
Expand Down Expand Up @@ -326,10 +334,12 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS);
const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION);
const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
const [userBillingGracePeriodEnds] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END);
const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED);
const {isBetaEnabled} = usePermissions();
const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END);
const [betas] = useOnyx(ONYXKEYS.BETAS);
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);

const defaultExpensePolicy = useDefaultExpensePolicy();
Expand Down Expand Up @@ -1603,7 +1613,57 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
text: translate('common.merge'),
icon: expensifyIcons.ArrowCollapse,
value: CONST.SEARCH.BULK_ACTION_TYPES.MERGE,
onSelected: () => setupMergeTransactionDataAndNavigate(transactionID, searchedTransactions, localeCompare, getCurrencyDecimals, reports, false, true),
onSelected: () => {
Comment thread
marufsharifi marked this conversation as resolved.
const transactionKey: `${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}` = `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`;
const searchSnapshotTransaction = currentSearchResults?.data?.[transactionKey];
const selectedTransactionInfo = selectedTransactions[transactionID];
const isSingleSelection = selectedTransactionsKeys.length === 1;
let targetTransactionThreadReportIDOverride = isSingleSelection
? getTargetTransactionThreadReportIDForSelection(searchSnapshotTransaction ?? searchedTransactions.at(0), selectedTransactionInfo)
: undefined;

if (!targetTransactionThreadReportIDOverride && isSingleSelection) {
const selectedReport = selectedTransactionInfo?.report ?? reports.at(0) ?? getReportOrDraftReport(searchSnapshotTransaction?.reportID);
const selectedReportAction = selectedTransactionInfo?.reportAction;
const targetTransaction = searchSnapshotTransaction ?? searchedTransactions.at(0);
const shouldPassTransactionData = !selectedReportAction?.reportActionID || targetTransaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
const transactionViolations = targetTransaction
? allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${targetTransaction.transactionID}`]
: undefined;
const createdThreadReport = targetTransaction
? createTransactionThreadReport({
introSelected,
currentUserLogin: currentUserPersonalDetails.login ?? '',
currentUserAccountID: currentUserPersonalDetails.accountID,
betas,
iouReport: selectedReport,
iouReportAction: selectedReportAction,
transaction: shouldPassTransactionData ? targetTransaction : undefined,
transactionViolations: shouldPassTransactionData ? transactionViolations : undefined,
})
: undefined;
targetTransactionThreadReportIDOverride = createdThreadReport?.reportID;
}

const targetTransactionThreadReportCandidate: TargetTransactionThreadReportCandidate | undefined = targetTransactionThreadReportIDOverride
? {
transactionID,
threadReportID: targetTransactionThreadReportIDOverride,
}
: undefined;

setupMergeTransactionDataAndNavigate(
transactionID,
searchedTransactions,
localeCompare,
getCurrencyDecimals,
reports,
false,
true,
undefined,
targetTransactionThreadReportCandidate,
);
},
});
}
}
Expand Down Expand Up @@ -1790,6 +1850,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
invokeDuplicateReportHandler,
isExpenseReportType,
handleDeleteSelectedTransactions,
introSelected,
betas,
undeleteTransactions,
theme.icon,
styles.colorMuted,
Expand Down
48 changes: 43 additions & 5 deletions src/hooks/useSelectedTransactionsActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {useSearchQueryContext, useSearchSelectionActions, useSearchSelectionCont
import {initBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit';
import {unholdRequest} from '@libs/actions/IOU/Hold';
import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction';
import {exportReportToCSV} from '@libs/actions/Report';
import type {TargetTransactionThreadReportCandidate} from '@libs/actions/MergeTransaction';
import {createTransactionThreadReport, exportReportToCSV} from '@libs/actions/Report';
import {getExportTemplates, handlePreventSearchAPI} from '@libs/actions/Search';
import initSplitExpense from '@libs/actions/SplitExpenses';
import {getTargetTransactionThreadReportIDForSelection} from '@libs/MergeTransactionUtils';
import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
import {getIOUActionForTransactionID, getReportAction, isDeletedAction} from '@libs/ReportActionsUtils';
import {isMergeActionForSelectedTransactions, isSplitAction} from '@libs/ReportSecondaryActionUtils';
Expand Down Expand Up @@ -91,6 +93,8 @@ function useSelectedTransactionsActions({
const [integrationsExportTemplates] = useOnyx(ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES);
const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS);
const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
const [betas] = useOnyx(ONYXKEYS.BETAS);
const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS);
const {getCurrencyDecimals} = useCurrencyListActions();

Expand Down Expand Up @@ -435,13 +439,45 @@ function useSelectedTransactionsActions({

const canMergeTransaction = selectedTransactionsList.length < 3 && report && policy && isMergeActionForSelectedTransactions(selectedTransactionsList, [report], [policy]);
if (canMergeTransaction) {
const transactionID = selectedTransactionsList.at(0)?.transactionID;
if (transactionID) {
const selectedTransaction = selectedTransactionsList.at(0);
const transactionID = selectedTransaction?.transactionID;
if (transactionID && selectedTransaction) {
options.push({
text: translate('common.merge'),
icon: expensifyIcons.ArrowCollapse,
value: MERGE,
onSelected: () =>
onSelected: () => {
const isSingleSelection = selectedTransactionsList.length === 1;
let targetTransactionThreadReportIDOverride: string | undefined;
const iouReportAction = isSingleSelection ? getIOUActionForTransactionID(reportActions, transactionID) : undefined;

if (isSingleSelection) {
const selectedTransactionMeta = selectedTransactionsMeta?.[transactionID];
targetTransactionThreadReportIDOverride = getTargetTransactionThreadReportIDForSelection(selectedTransaction, selectedTransactionMeta, iouReportAction);

if (!targetTransactionThreadReportIDOverride) {
const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`];
const createdThreadReport = createTransactionThreadReport({
introSelected,
currentUserLogin: login ?? '',
currentUserAccountID,
betas,
iouReport: report,
iouReportAction,
transaction: selectedTransaction,
transactionViolations,
});
targetTransactionThreadReportIDOverride = createdThreadReport?.reportID;
}
}

const targetTransactionThreadReportCandidate: TargetTransactionThreadReportCandidate | undefined = targetTransactionThreadReportIDOverride
? {
transactionID,
threadReportID: targetTransactionThreadReportIDOverride,
}
: undefined;

setupMergeTransactionDataAndNavigate(
transactionID,
selectedTransactionsList,
Expand All @@ -451,7 +487,9 @@ function useSelectedTransactionsActions({
false,
isOnSearch,
selectedTransactionsList.length > 1 ? [policy, policy] : undefined,
),
targetTransactionThreadReportCandidate,
);
},
});
}
}
Expand Down
41 changes: 40 additions & 1 deletion src/libs/MergeTransactionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import type {MergeTransaction, Policy, Report, SearchResults, Transaction} from '@src/types/onyx';
import type {MergeTransaction, Policy, Report, ReportAction, SearchResults, Transaction} from '@src/types/onyx';
import type {Attendee} from '@src/types/onyx/IOU';
import SafeString from '@src/utils/SafeString';
import {getDecodedLeafCategoryName} from './CategoryUtils';
Expand Down Expand Up @@ -60,6 +60,11 @@ type MergeFieldData = {

/** Type for merge transaction values that can be null to clear existing values in Onyx */
type MergeTransactionUpdateValues = Partial<Record<keyof MergeTransaction, MergeTransaction[keyof MergeTransaction] | null>>;
type TargetTransactionThreadReportIDSource = {
transaction?: OnyxEntry<Transaction>;
reportAction?: OnyxEntry<ReportAction>;
[key: string]: unknown;
};

const MERGE_FIELD_TRANSLATION_KEYS = {
amount: 'iou.amount',
Expand Down Expand Up @@ -354,6 +359,39 @@ function getTransactionThreadReportID(transaction: OnyxEntry<Transaction>) {
return iouActionOfTargetTransaction?.childReportID;
}

function isValidTargetTransactionThreadReportID(reportID: string | undefined) {
return !!reportID && reportID !== CONST.FAKE_REPORT_ID;
}

function getTargetTransactionThreadReportIDForSelection(
transaction: OnyxEntry<Transaction>,
selectedTransaction?: TargetTransactionThreadReportIDSource,
fallbackReportAction?: OnyxEntry<ReportAction>,
) {
const selectedChildReportID = selectedTransaction?.reportAction?.childReportID;
if (isValidTargetTransactionThreadReportID(selectedChildReportID)) {
return selectedChildReportID;
}

const selectedTransactionThreadReportID = selectedTransaction?.transaction?.transactionThreadReportID;
if (isValidTargetTransactionThreadReportID(selectedTransactionThreadReportID)) {
return selectedTransactionThreadReportID;
}

const transactionThreadReportID = transaction?.transactionThreadReportID;
if (isValidTargetTransactionThreadReportID(transactionThreadReportID)) {
return transactionThreadReportID;
}

const fallbackChildReportID = fallbackReportAction?.childReportID;
if (isValidTargetTransactionThreadReportID(fallbackChildReportID)) {
return fallbackChildReportID;
}

const computedThreadReportID = getTransactionThreadReportID(transaction);
return isValidTargetTransactionThreadReportID(computedThreadReportID) ? computedThreadReportID : undefined;
}

/**
* Build the merged transaction data for display by combining target transaction with merge transaction updates
* @param targetTransaction - The target transaction to merge into
Expand Down Expand Up @@ -732,6 +770,7 @@ export {
areTransactionsEligibleForMerge,
DERIVED_MERGE_FIELDS,
getRateFromMerchant,
getTargetTransactionThreadReportIDForSelection,
getTransactionsAndReportsFromSearch,
};

Expand Down
34 changes: 34 additions & 0 deletions src/libs/Navigation/helpers/isSearchOriginForMerge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
import isSearchTopmostFullScreenRoute from './isSearchTopmostFullScreenRoute';

function normalizeRoute(route?: string): string | undefined {
if (!route) {
return undefined;
}

const decodedRoute = (() => {
try {
return decodeURIComponent(route);
} catch {
return route;
}
})();

return decodedRoute.startsWith('/') ? decodedRoute.substring(1) : decodedRoute;
}

function isSearchOriginForMerge(routeName: string, backTo?: string): boolean {
if (routeName !== SCREENS.RIGHT_MODAL.SEARCH_REPORT && routeName !== SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT) {
return false;
}

const normalizedBackTo = normalizeRoute(backTo);
if (normalizedBackTo) {
return normalizedBackTo.startsWith(ROUTES.SEARCH_ROOT.route);
}

return isSearchTopmostFullScreenRoute();
}

export default isSearchOriginForMerge;
Loading
Loading