diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 0bca7c4b749f..f50ae74d8b1f 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -1371,6 +1371,14 @@ const CONST = {
DUPLICATE: 'duplicate',
MOVE_EXPENSE: 'moveExpense',
},
+ SELECTED_TRANSACTIONS_BULK_ACTION_TYPES: {
+ HOLD: 'hold',
+ UNHOLD: 'unhold',
+ MOVE: 'move',
+ MERGE: 'merge',
+ SPLIT: 'split',
+ DUPLICATE: 'duplicate',
+ },
ADD_EXPENSE_OPTIONS: {
CREATE_NEW_EXPENSE: 'createNewExpense',
ADD_UNREPORTED_EXPENSE: 'addUnreportedExpense',
@@ -7464,6 +7472,7 @@ const CONST = {
REJECT: 'reject',
CHANGE_REPORT: 'changeReport',
SPLIT: 'split',
+ DUPLICATE: 'duplicate',
},
TRANSACTION_TYPE: {
CASH: 'cash',
diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx
index ee31608a9325..3f9347b886f4 100644
--- a/src/components/MoneyReportHeader.tsx
+++ b/src/components/MoneyReportHeader.tsx
@@ -147,6 +147,7 @@ import MoneyReportHeaderMoreContent from './MoneyReportHeaderMoreContent';
import MoneyReportHeaderPrimaryAction from './MoneyReportHeaderPrimaryAction';
import {usePersonalDetails} from './OnyxListItemProvider';
import type {PopoverMenuItem} from './PopoverMenu';
+import BulkDuplicateHandler from './Search/BulkDuplicateHandler';
import {useSearchActionsContext, useSearchStateContext} from './Search/SearchContext';
import type {PaymentActionParams} from './SettlementButton/types';
import Text from './Text';
@@ -513,6 +514,10 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt
options: originalSelectedTransactionsOptions,
handleDeleteTransactions,
handleDeleteTransactionsWithNavigation,
+ isDuplicateOptionVisible,
+ setDuplicateHandler,
+ allTransactions: allTransactionsForDuplicate,
+ allReports: allReportsForDuplicate,
} = useSelectedTransactionsActions({
report: moneyRequestReport,
reportActions,
@@ -2025,67 +2030,79 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt
}
return (
-
-
- {shouldDisplayNarrowMoreButton && (
-
- {!shouldShowSelectedTransactionsButton && primaryActionComponent}
- {!!applicableSecondaryActions.length && !shouldShowSelectedTransactionsButton && (
- confirmPayment({paymentType: type})}
- primaryAction={primaryAction}
- applicableSecondaryActions={applicableSecondaryActions}
- dropdownMenuRef={dropdownMenuRef}
- onOptionsMenuHide={handleOptionsMenuHide}
- ref={kycWallRef}
- />
- )}
- {shouldShowSelectedTransactionsButton && {renderSelectionModeDropdown()}}
-
- )}
-
- {!shouldDisplayNarrowMoreButton &&
- (shouldShowSelectedTransactionsButton ? (
- {renderSelectionModeDropdown(styles.w100)}
- ) : (
-
- {!!primaryAction && {primaryActionComponent}}
- {!!applicableSecondaryActions.length && (
- confirmPayment({paymentType: type})}
- primaryAction={primaryAction}
- applicableSecondaryActions={applicableSecondaryActions}
- dropdownMenuRef={dropdownMenuRef}
- onOptionsMenuHide={handleOptionsMenuHide}
- ref={kycWallRef}
- />
- )}
-
- ))}
-
-
-
-
-
+ <>
+ {isDuplicateOptionVisible && (
+ clearSelectedTransactions(true)}
+ />
+ )}
+
+
+ {shouldDisplayNarrowMoreButton && (
+
+ {!shouldShowSelectedTransactionsButton && primaryActionComponent}
+ {!!applicableSecondaryActions.length && !shouldShowSelectedTransactionsButton && (
+ confirmPayment({paymentType: type})}
+ primaryAction={primaryAction}
+ applicableSecondaryActions={applicableSecondaryActions}
+ dropdownMenuRef={dropdownMenuRef}
+ onOptionsMenuHide={handleOptionsMenuHide}
+ ref={kycWallRef}
+ />
+ )}
+ {shouldShowSelectedTransactionsButton && {renderSelectionModeDropdown()}}
+
+ )}
+
+ {!shouldDisplayNarrowMoreButton &&
+ (shouldShowSelectedTransactionsButton ? (
+ {renderSelectionModeDropdown(styles.w100)}
+ ) : (
+
+ {!!primaryAction && {primaryActionComponent}}
+ {!!applicableSecondaryActions.length && (
+ confirmPayment({paymentType: type})}
+ primaryAction={primaryAction}
+ applicableSecondaryActions={applicableSecondaryActions}
+ dropdownMenuRef={dropdownMenuRef}
+ onOptionsMenuHide={handleOptionsMenuHide}
+ ref={kycWallRef}
+ />
+ )}
+
+ ))}
+
+
+
+
+
+ >
);
}
diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx
index bf67d022d50b..2b2d08b6bc53 100644
--- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx
+++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx
@@ -18,6 +18,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback';
import {usePersonalDetails} from '@components/OnyxListItemProvider';
import {PressableWithFeedback} from '@components/Pressable';
import ScrollView from '@components/ScrollView';
+import BulkDuplicateHandler from '@components/Search/BulkDuplicateHandler';
import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext';
import Text from '@components/Text';
import useConfirmModal from '@hooks/useConfirmModal';
@@ -281,7 +282,13 @@ function MoneyRequestReportActionsList({reportID: reportIDProp, onLayout}: Money
[showConfirmModal, translate, selectedTransactionIDs.length, transactions, route.params?.backTo, chatReport?.reportID],
);
- const {options: originalSelectedTransactionsOptions} = useSelectedTransactionsActions({
+ const {
+ options: originalSelectedTransactionsOptions,
+ isDuplicateOptionVisible,
+ setDuplicateHandler,
+ allTransactions: allTransactionsForDuplicate,
+ allReports: allReportsForDuplicate,
+ } = useSelectedTransactionsActions({
report,
reportActions,
allTransactionsLength: transactions.length,
@@ -840,144 +847,156 @@ function MoneyRequestReportActionsList({reportID: reportIDProp, onLayout}: Money
}, [styles.chatItem.paddingBottom, styles.chatItem.paddingTop, windowHeight, linkedReportActionID]);
return (
-
- {shouldUseNarrowLayout && isMobileSelectionModeEnabled && (
-
- null}
- options={selectedTransactionsOptions}
- customText={translate('workspace.common.selected', {
- count: selectedTransactionIDs.length,
- })}
- isSplitButton={false}
- shouldAlwaysShowDropdownMenu
- shouldPopoverUseScrollView={popoverUseScrollView}
- wrapperStyle={[styles.w100, styles.ph5]}
- />
-
- 0 && selectedTransactionIDs.length !== transactionsWithoutPendingDelete.length}
- onPress={() => {
- if (selectedTransactionIDs.length !== 0) {
- clearSelectedTransactions(true);
- } else {
- setSelectedTransactions(transactionsWithoutPendingDelete.map((t) => t.transactionID));
- }
- }}
- />
- {
- if (isSelectAllChecked) {
- clearSelectedTransactions(true);
- } else {
- setSelectedTransactions(transactionsWithoutPendingDelete.map((t) => t.transactionID));
- }
- }}
- accessibilityLabel={translate('accessibilityHints.selectAllItems')}
- role="button"
- accessibilityState={{checked: isSelectAllChecked}}
- dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
- sentryLabel={CONST.SENTRY_LABEL.REPORT.MONEY_REQUEST_REPORT_ACTIONS_LIST_SELECT_ALL}
- >
- {translate('workspace.people.selectAll')}
-
-
-
- )}
-
-
+ {isDuplicateOptionVisible && (
+ clearSelectedTransactions(true)}
/>
- {isEmpty(visibleReportActions) && isEmpty(transactions) && !showReportActionsLoadingState ? (
-
-
+ {shouldUseNarrowLayout && isMobileSelectionModeEnabled && (
+
+ null}
+ options={selectedTransactionsOptions}
+ customText={translate('workspace.common.selected', {
+ count: selectedTransactionIDs.length,
+ })}
+ isSplitButton={false}
+ shouldAlwaysShowDropdownMenu
+ shouldPopoverUseScrollView={popoverUseScrollView}
+ wrapperStyle={[styles.w100, styles.ph5]}
/>
-
+ 0 && selectedTransactionIDs.length !== transactionsWithoutPendingDelete.length}
+ onPress={() => {
+ if (selectedTransactionIDs.length !== 0) {
+ clearSelectedTransactions(true);
+ } else {
+ setSelectedTransactions(transactionsWithoutPendingDelete.map((t) => t.transactionID));
+ }
+ }}
+ />
+ {
+ if (isSelectAllChecked) {
+ clearSelectedTransactions(true);
+ } else {
+ setSelectedTransactions(transactionsWithoutPendingDelete.map((t) => t.transactionID));
+ }
+ }}
+ accessibilityLabel={translate('accessibilityHints.selectAllItems')}
+ role="button"
+ accessibilityState={{checked: isSelectAllChecked}}
+ dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
+ sentryLabel={CONST.SENTRY_LABEL.REPORT.MONEY_REQUEST_REPORT_ACTIONS_LIST_SELECT_ALL}
+ >
+ {translate('workspace.people.selectAll')}
+
+
+
+ )}
+
+
+ {isEmpty(visibleReportActions) && isEmpty(transactions) && !showReportActionsLoadingState ? (
+
+
+
+
+ ) : (
+
+
+ 0}
+ isLoadingInitialReportActions={showReportActionsLoadingState}
+ />
+ >
+ }
+ keyboardShouldPersistTaps="handled"
+ onScroll={trackVerticalScrolling}
+ contentContainerStyle={[shouldUseNarrowLayout ? styles.pt4 : styles.pt3]}
+ ref={reportScrollManager.ref}
+ ListEmptyComponent={!isOffline && showReportActionsLoadingState ? : undefined} // This skeleton component is only used for loading state, the empty state is handled by SearchMoneyRequestReportEmptyState
+ removeClippedSubviews={false}
+ initialScrollKey={linkedReportActionID}
/>
-
- ) : (
-
-
- 0}
- isLoadingInitialReportActions={showReportActionsLoadingState}
- />
- >
- }
- keyboardShouldPersistTaps="handled"
- onScroll={trackVerticalScrolling}
- contentContainerStyle={[shouldUseNarrowLayout ? styles.pt4 : styles.pt3]}
- ref={reportScrollManager.ref}
- ListEmptyComponent={!isOffline && showReportActionsLoadingState ? : undefined} // This skeleton component is only used for loading state, the empty state is handled by SearchMoneyRequestReportEmptyState
- removeClippedSubviews={false}
- initialScrollKey={linkedReportActionID}
+ )}
+
+ setIsDownloadErrorModalVisible(false)}
+ secondOptionText={translate('common.buttonConfirm')}
+ isVisible={isDownloadErrorModalVisible}
+ onClose={() => setIsDownloadErrorModalVisible(false)}
+ />
+ setOfflineModalVisible(false)}
+ secondOptionText={translate('common.buttonConfirm')}
+ isVisible={offlineModalVisible}
+ onClose={() => setOfflineModalVisible(false)}
+ />
+ {!!rejectModalAction && (
+
)}
- setIsDownloadErrorModalVisible(false)}
- secondOptionText={translate('common.buttonConfirm')}
- isVisible={isDownloadErrorModalVisible}
- onClose={() => setIsDownloadErrorModalVisible(false)}
- />
- setOfflineModalVisible(false)}
- secondOptionText={translate('common.buttonConfirm')}
- isVisible={offlineModalVisible}
- onClose={() => setOfflineModalVisible(false)}
- />
- {!!rejectModalAction && (
-
- )}
-
+ >
);
}
diff --git a/src/components/Search/BulkDuplicateHandler.tsx b/src/components/Search/BulkDuplicateHandler.tsx
new file mode 100644
index 000000000000..978e8b4725bd
--- /dev/null
+++ b/src/components/Search/BulkDuplicateHandler.tsx
@@ -0,0 +1,30 @@
+import {useEffect} from 'react';
+import type {OnyxCollection} from 'react-native-onyx';
+import useBulkDuplicateAction from '@hooks/useBulkDuplicateAction';
+import type {Report, Transaction} from '@src/types/onyx';
+
+type BulkDuplicateHandlerProps = {
+ selectedTransactionsKeys: string[];
+ allTransactions: OnyxCollection;
+ allReports: OnyxCollection | undefined;
+ searchData: Record | undefined;
+ onHandlerReady: (handler: () => void) => void;
+ onAfterDuplicate?: () => void;
+};
+
+/**
+ * Invisible component that subscribes to action-time Onyx data for bulk duplication.
+ * Only mounted when the duplicate option is visible, avoiding unnecessary global
+ * subscriptions on the search page for users who aren't duplicating.
+ */
+function BulkDuplicateHandler({selectedTransactionsKeys, allTransactions, allReports, searchData, onHandlerReady, onAfterDuplicate}: BulkDuplicateHandlerProps) {
+ const handleDuplicate = useBulkDuplicateAction({selectedTransactionsKeys, allTransactions, allReports, searchData, onAfterDuplicate});
+
+ useEffect(() => {
+ onHandlerReady(handleDuplicate);
+ }, [handleDuplicate, onHandlerReady]);
+
+ return null;
+}
+
+export default BulkDuplicateHandler;
diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx
index 5d6b11072253..d0184c76a33c 100644
--- a/src/components/Search/SearchBulkActionsButton.tsx
+++ b/src/components/Search/SearchBulkActionsButton.tsx
@@ -24,6 +24,7 @@ import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
+import BulkDuplicateHandler from './BulkDuplicateHandler';
import {useSearchActionsContext, useSearchStateContext} from './SearchContext';
import type {BulkPaySelectionData, SearchQueryJSON} from './types';
@@ -68,6 +69,11 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) {
handleNonReimbursablePaymentErrorModalClose,
dismissModalAndUpdateUseHold,
dismissRejectModalBasedOnAction,
+ isDuplicateOptionVisible,
+ setDuplicateHandler,
+ allTransactions,
+ allReports,
+ searchData,
} = useSearchBulkActions({queryJSON});
const currentSelectedPolicyID = selectedPolicyIDs?.at(0);
const currentSelectedReportID = selectedTransactionReportIDs?.at(0) ?? selectedReportIDs?.at(0);
@@ -101,6 +107,15 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) {
return (
<>
+ {isDuplicateOptionVisible && (
+
+ )}
;
+ allReports: OnyxCollection | undefined;
+ searchData: Record | undefined;
+ onAfterDuplicate?: () => void;
+};
+
+/**
+ * Hook that subscribes to action-time-only Onyx data needed for bulk expense duplication.
+ * Designed to be called inside a component that only mounts when the duplicate option is visible,
+ * so these subscriptions don't exist for users who aren't actively duplicating.
+ */
+function useBulkDuplicateAction({selectedTransactionsKeys, allTransactions, allReports, searchData, onAfterDuplicate}: UseBulkDuplicateActionParams) {
+ const {accountID} = useCurrentUserPersonalDetails();
+ const {clearSelectedTransactions} = useSearchActionsContext();
+ const defaultExpensePolicy = useDefaultExpensePolicy();
+ const {isBetaEnabled} = usePermissions();
+ const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT);
+
+ const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
+ const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
+ const [quickAction] = useOnyx(ONYXKEYS.NVP_QUICK_ACTION_GLOBAL_CREATE);
+ const [policyRecentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES);
+ const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector});
+ const [transactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {selector: validTransactionDraftsSelector});
+ const draftTransactionIDs = Object.keys(transactionDrafts ?? {});
+ const [betas] = useOnyx(ONYXKEYS.BETAS);
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
+ const [recentWaypoints] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS);
+ const [targetPolicyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultExpensePolicy?.id}`);
+ const [targetPolicyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${defaultExpensePolicy?.id}`);
+
+ const sourcePolicyIDMap: Record = {};
+ for (const transactionID of selectedTransactionsKeys) {
+ const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
+ const reportID = transaction?.reportID;
+ if (!reportID) {
+ continue;
+ }
+ const report = (searchData?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] as Report | undefined) ?? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`];
+ sourcePolicyIDMap[transactionID] = report?.policyID;
+ }
+
+ const handleDuplicate = () => {
+ const activePolicyExpenseChat = getPolicyExpenseChat(accountID, defaultExpensePolicy?.id);
+
+ bulkDuplicateExpenses({
+ transactionIDs: selectedTransactionsKeys,
+ allTransactions: allTransactions ?? {},
+ sourcePolicyIDMap,
+ targetPolicy: (defaultExpensePolicy ?? undefined) as OnyxEntry,
+ targetPolicyCategories: targetPolicyCategories ?? {},
+ targetPolicyTags: targetPolicyTags ?? {},
+ targetReport: activePolicyExpenseChat,
+ personalDetails,
+ isASAPSubmitBetaEnabled,
+ introSelected,
+ activePolicyID,
+ quickAction,
+ policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [],
+ isSelfTourViewed,
+ transactionDrafts,
+ draftTransactionIDs,
+ betas,
+ recentWaypoints,
+ });
+
+ if (onAfterDuplicate) {
+ onAfterDuplicate();
+ } else {
+ clearSelectedTransactions(undefined, true);
+ }
+ };
+
+ return handleDuplicate;
+}
+
+export default useBulkDuplicateAction;
diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts
index dcbe85086717..7481b7d6a8a3 100644
--- a/src/hooks/useSearchBulkActions.ts
+++ b/src/hooks/useSearchBulkActions.ts
@@ -39,24 +39,28 @@ import {getSecondaryExportReportActions, isMergeActionForSelectedTransactions} f
import {
canEditMultipleTransactions,
getIntegrationIcon,
+ getPolicyExpenseChat,
getReportOrDraftReport,
hasOnlyNonReimbursableTransactions,
+ isArchivedReport,
isBusinessInvoiceRoom,
isCurrentUserSubmitter,
+ isDM,
isExpenseReport as isExpenseReportUtil,
isInvoiceReport,
isIOUReport as isIOUReportUtil,
+ isSelfDM,
} from '@libs/ReportUtils';
import {serializeQueryJSONForBackend} from '@libs/SearchQueryUtils';
import {navigateToSearchRHP, shouldShowDeleteOption} from '@libs/SearchUIUtils';
import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils';
-import {hasTransactionBeenRejected} from '@libs/TransactionUtils';
+import {hasCustomUnitOutOfPolicyViolation, hasTransactionBeenRejected, isDistanceRequest, isManagedCardTransaction, isPerDiemRequest, isScanning} from '@libs/TransactionUtils';
import variables from '@styles/variables';
import {canIOUBePaid, dismissRejectUseExplanation, initBulkEditDraftTransaction} from '@userActions/IOU';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {BillingGraceEndPeriod, Policy, Report, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx';
+import type {BillingGraceEndPeriod, Policy, Report, ReportNameValuePairs, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx';
import type DeepValueOf from '@src/types/utils/DeepValueOf';
import useAllPolicyExpenseChatReportActions from './useAllPolicyExpenseChatReportActions';
import useAllTransactions from './useAllTransactions';
@@ -64,6 +68,8 @@ import useBulkPayOptions from './useBulkPayOptions';
import useConfirmModal from './useConfirmModal';
import {useCurrencyListActions} from './useCurrencyList';
import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails';
+import useDefaultExpensePolicy from './useDefaultExpensePolicy';
+import useEnvironment from './useEnvironment';
import {useMemoizedLazyExpensifyIcons} from './useLazyAsset';
import useLocalize from './useLocalize';
import useNetwork from './useNetwork';
@@ -95,11 +101,96 @@ function getRestrictedPolicyID(
);
}
+type ShouldShowBulkDuplicateParams = {
+ selectedTransactionsKeys: string[];
+ selectedTransactions: Record;
+ allTransactions: OnyxCollection | undefined;
+ allReports: OnyxCollection | undefined;
+ allTransactionViolations: OnyxCollection | undefined;
+ allReportNameValuePairs: OnyxCollection | undefined;
+ defaultExpensePolicyID: string | undefined;
+ activePolicyExpenseChat: Report | undefined;
+ typeExpenseReport: boolean;
+ searchData: Record | undefined;
+};
+
+/**
+ * Determines whether the bulk duplicate option should be shown for the selected transactions.
+ * Mirrors the single-duplicate guards from MoneyReportHeader/MoneyRequestHeader.
+ */
+function shouldShowBulkDuplicateOption({
+ selectedTransactionsKeys,
+ selectedTransactions,
+ allTransactions,
+ allReports,
+ allTransactionViolations,
+ allReportNameValuePairs,
+ defaultExpensePolicyID,
+ activePolicyExpenseChat,
+ typeExpenseReport,
+ searchData,
+}: ShouldShowBulkDuplicateParams): boolean {
+ if (typeExpenseReport || selectedTransactionsKeys.length === 0) {
+ return false;
+ }
+
+ const searchReports = searchData
+ ? Object.keys(searchData)
+ .filter((key) => key.startsWith(ONYXKEYS.COLLECTION.REPORT))
+ .map((key) => searchData[key] as Report)
+ .filter((report): report is Report => report != null && 'reportID' in report)
+ : [];
+
+ return selectedTransactionsKeys.every((id) => {
+ const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`];
+ if (!transaction || isManagedCardTransaction(transaction) || isScanning(transaction)) {
+ return false;
+ }
+
+ const dates = transaction?.comment?.customUnit?.attributes?.dates;
+ if (isPerDiemRequest(transaction) && (!dates?.start || !dates?.end)) {
+ return false;
+ }
+
+ const reportID = selectedTransactions[id]?.reportID;
+ const submitterReport = reportID ? getReportOrDraftReport(reportID, searchReports) : undefined;
+ if (submitterReport && !isCurrentUserSubmitter(submitterReport)) {
+ return false;
+ }
+
+ const transactionViolations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${id}`];
+ if (hasCustomUnitOutOfPolicyViolation(transactionViolations)) {
+ return false;
+ }
+
+ const report = reportID ? ((searchData?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] as Report | undefined) ?? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]) : undefined;
+
+ if (isPerDiemRequest(transaction) && report?.policyID && defaultExpensePolicyID !== report.policyID) {
+ return false;
+ }
+
+ if (isDistanceRequest(transaction) && reportID) {
+ const chatReportID = report?.chatReportID ?? report?.parentReportID;
+ const chatReport = chatReportID
+ ? ((searchData?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] as Report | undefined) ?? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`])
+ : undefined;
+ const reportNVP = allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`];
+ const chatReportNVP = chatReportID ? allReportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${chatReportID}`] : undefined;
+ if (isArchivedReport(reportNVP) || isArchivedReport(chatReportNVP) || (activePolicyExpenseChat && chatReport && (isDM(chatReport) || isSelfDM(chatReport)))) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+}
+
function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
const {translate, localeCompare, formatPhoneNumber} = useLocalize();
const styles = useThemeStyles();
const theme = useTheme();
const {isOffline} = useNetwork();
+ const {isProduction} = useEnvironment();
const {isDelegateAccessRestricted} = useDelegateNoAccessState();
const {showDelegateNoAccessModal} = useDelegateNoAccessActions();
const {selectedTransactions, selectedReports, areAllMatchingItemsSelected, currentSearchResults, currentSearchKey} = useSearchStateContext();
@@ -125,6 +216,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
const {isBetaEnabled} = usePermissions();
const [ownerBillingGracePeriodEnd] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END);
+ const defaultExpensePolicy = useDefaultExpensePolicy();
+
// Cache the last search results that had data, so the merge option remains available
// while results are temporarily unset (e.g. during sorting/loading).
const lastNonEmptySearchResultsRef = useRef(undefined);
@@ -165,6 +258,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
'Exclamation',
'MoneyBag',
'ArrowSplit',
+ 'ExpenseCopy',
'QBOSquare',
'XeroSquare',
'NetSuiteSquare',
@@ -721,6 +815,49 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
);
}, [selectedTransactionReportIDs, currentUserPersonalDetails?.accountID, currentSearchResults?.data]);
+ const duplicateHandlerRef = useRef<() => void>(() => {});
+ const setDuplicateHandler = useCallback((handler: () => void) => {
+ duplicateHandlerRef.current = handler;
+ }, []);
+ const invokeDuplicateHandler = useCallback(() => {
+ duplicateHandlerRef.current();
+ }, []);
+
+ const activePolicyExpenseChat = useMemo(
+ () => getPolicyExpenseChat(currentUserPersonalDetails.accountID, defaultExpensePolicy?.id),
+ [currentUserPersonalDetails.accountID, defaultExpensePolicy?.id],
+ );
+
+ const isDuplicateOptionVisible = useMemo(
+ () =>
+ !isProduction &&
+ shouldShowBulkDuplicateOption({
+ selectedTransactionsKeys,
+ selectedTransactions,
+ allTransactions,
+ allReports,
+ allTransactionViolations,
+ allReportNameValuePairs,
+ defaultExpensePolicyID: defaultExpensePolicy?.id,
+ activePolicyExpenseChat,
+ typeExpenseReport: isExpenseReportType,
+ searchData: currentSearchResults?.data,
+ }),
+ [
+ isProduction,
+ selectedTransactionsKeys,
+ selectedTransactions,
+ allTransactions,
+ allReports,
+ allTransactionViolations,
+ allReportNameValuePairs,
+ defaultExpensePolicy?.id,
+ activePolicyExpenseChat,
+ isExpenseReportType,
+ currentSearchResults?.data,
+ ],
+ );
+
const headerButtonsOptions = useMemo(() => {
if (selectedTransactionsKeys.length === 0 || status == null || !hash) {
return CONST.EMPTY_ARRAY as unknown as Array>;
@@ -729,15 +866,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
const options: Array> = [];
const isAnyTransactionOnHold = Object.values(selectedTransactions).some((transaction) => transaction.isHeld);
- const typeExpenseReport = queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT;
-
const getExportOptions = () => {
const areFullReportsSelected = selectedTransactionReportIDs.length === selectedReportIDs.length && selectedTransactionReportIDs.every((id) => selectedReportIDs.includes(id));
const typeInvoice = queryJSON?.type === CONST.REPORT.TYPE.INVOICE;
const typeExpense = queryJSON?.type === CONST.REPORT.TYPE.EXPENSE;
const isAllOneTransactionReport = Object.values(selectedTransactions).every((transaction) => transaction.isFromOneTransactionReport);
- const includeReportLevelExport = ((typeExpenseReport || typeInvoice) && areFullReportsSelected) || (typeExpense && !typeExpenseReport && isAllOneTransactionReport);
+ const includeReportLevelExport = ((isExpenseReportType || typeInvoice) && areFullReportsSelected) || (typeExpense && !isExpenseReportType && isAllOneTransactionReport);
const policy = selectedPolicyIDs.length === 1 ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedPolicyIDs.at(0)}`] : undefined;
const exportTemplates = getExportTemplates(integrationsExportTemplates ?? [], csvExportLayouts ?? {}, translate, policy, includeReportLevelExport);
@@ -745,7 +880,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
const exportOptions: PopoverMenuItem[] = [];
const connectedIntegration = getConnectedIntegration(policy);
- const isReportsTab = typeExpenseReport;
+ const isReportsTab = isExpenseReportType;
const canReportBeExported = (report: (typeof selectedReports)[0], exportOption: ValueOf) => {
if (!report.reportID) {
@@ -893,7 +1028,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
return [exportButtonOption];
}
- const isExpenseReportSearch = typeExpenseReport || searchResults?.search.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT;
+ const isExpenseReportSearch = isExpenseReportType || searchResults?.search.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT;
const selectedTransactionsList = Object.values(selectedTransactions)
.map((transaction) => transaction.transaction)
.filter((transaction): transaction is Transaction => !!transaction);
@@ -1161,7 +1296,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
const canAllTransactionsBeMoved = selectedTransactionsKeys.every((id) => selectedTransactions[id].canChangeReport);
- if (canAllTransactionsBeMoved && !hasMultipleOwners && !typeExpenseReport) {
+ if (canAllTransactionsBeMoved && !hasMultipleOwners && !isExpenseReportType) {
options.push({
text: translate('iou.moveExpenses'),
icon: expensifyIcons.DocumentMerge,
@@ -1190,6 +1325,16 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
});
}
+ if (isDuplicateOptionVisible) {
+ options.push({
+ text: translate('search.bulkActions.duplicateExpense', {count: selectedTransactionsKeys.length}),
+ icon: expensifyIcons.ExpenseCopy,
+ value: CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE,
+ shouldCloseModalOnSelect: true,
+ onSelected: invokeDuplicateHandler,
+ });
+ }
+
if (shouldShowDeleteOption(selectedTransactions, currentSearchResults?.data, selectedReports, queryJSON?.type)) {
options.push({
icon: expensifyIcons.Trashcan,
@@ -1263,8 +1408,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
currentUserPersonalDetails?.login,
bankAccountList,
styles.integrationIcon,
- styles.colorMuted,
- styles.fontWeightNormal,
styles.textWrap,
showConfirmModal,
clearSelectedTransactions,
@@ -1284,6 +1427,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
localeCompare,
firstTransaction,
firstTransactionPolicy,
+ isDuplicateOptionVisible,
+ invokeDuplicateHandler,
+ isExpenseReportType,
handleDeleteSelectedTransactions,
theme.icon,
styles.colorMuted,
@@ -1350,7 +1496,14 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
handleNonReimbursablePaymentErrorModalClose,
dismissModalAndUpdateUseHold,
dismissRejectModalBasedOnAction,
+ isDuplicateOptionVisible,
+ setDuplicateHandler,
+ allTransactions,
+ allReports,
+ searchData: currentSearchResults?.data,
};
}
export default useSearchBulkActions;
+export {shouldShowBulkDuplicateOption};
+export type {ShouldShowBulkDuplicateParams};
diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts
index 197dcc276899..0b4fa4b3a837 100644
--- a/src/hooks/useSelectedTransactionsActions.ts
+++ b/src/hooks/useSelectedTransactionsActions.ts
@@ -1,4 +1,4 @@
-import {useState} from 'react';
+import {useCallback, useMemo, useRef, useState} from 'react';
import {DeviceEventEmitter} from 'react-native';
import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider';
@@ -21,6 +21,7 @@ import {
canHoldUnholdReportAction,
canRejectReportAction,
canUserPerformWriteAction as canUserPerformWriteActionReportUtils,
+ getPolicyExpenseChat,
getReportOrDraftReport,
isInvoiceReport,
isMoneyRequestReport as isMoneyRequestReportUtils,
@@ -37,21 +38,19 @@ import type {Policy, Report, ReportAction, Session, Transaction} from '@src/type
import useAllTransactions from './useAllTransactions';
import {useCurrencyListActions} from './useCurrencyList';
import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails';
+import useDefaultExpensePolicy from './useDefaultExpensePolicy';
import useDeleteTransactions from './useDeleteTransactions';
import useDuplicateTransactionsAndViolations from './useDuplicateTransactionsAndViolations';
+import useEnvironment from './useEnvironment';
import {useMemoizedLazyExpensifyIcons} from './useLazyAsset';
import useLocalize from './useLocalize';
import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus';
import useOnyx from './useOnyx';
import usePermissions from './usePermissions';
import useReportIsArchived from './useReportIsArchived';
+import {shouldShowBulkDuplicateOption} from './useSearchBulkActions';
-// We do not use PRIMARY_REPORT_ACTIONS or SECONDARY_REPORT_ACTIONS because they weren't meant to be used in this situation. `value` property of returned options is later ignored.
-const HOLD = 'HOLD';
-const UNHOLD = 'UNHOLD';
-const MOVE = 'MOVE';
-const MERGE = 'MERGE';
-const SPLIT = 'SPLIT';
+const {HOLD, UNHOLD, MOVE, MERGE, SPLIT, DUPLICATE} = CONST.REPORT.SELECTED_TRANSACTIONS_BULK_ACTION_TYPES;
function useSelectedTransactionsActions({
report,
@@ -90,6 +89,7 @@ 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 [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS);
const {getCurrencyDecimals} = useCurrencyListActions();
const expensifyIcons = useMemoizedLazyExpensifyIcons([
@@ -102,6 +102,7 @@ function useSelectedTransactionsActions({
'ArrowCollapse',
'ArrowSplit',
'ThumbsDown',
+ 'ExpenseCopy',
'Pencil',
] as const);
@@ -110,6 +111,9 @@ function useSelectedTransactionsActions({
const {isBetaEnabled} = usePermissions();
const {deleteTransactions} = useDeleteTransactions({report, reportActions, policy});
const {login, accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
+ const defaultExpensePolicy = useDefaultExpensePolicy();
+ const {isProduction} = useEnvironment();
+
const selectedTransactionsList = selectedTransactionIDs.reduce((acc, transactionID) => {
const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
if (transaction) {
@@ -151,6 +155,53 @@ function useSelectedTransactionsActions({
const {translate, localeCompare} = useLocalize();
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
+ const selectedTransactionsForDuplicate = useMemo(() => {
+ const map: Record = {};
+ for (const id of selectedTransactionIDs) {
+ const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`];
+ map[id] = {reportID: transaction?.reportID};
+ }
+ return map;
+ }, [selectedTransactionIDs, allTransactions]);
+
+ const activePolicyExpenseChat = useMemo(() => getPolicyExpenseChat(currentUserAccountID, defaultExpensePolicy?.id), [currentUserAccountID, defaultExpensePolicy?.id]);
+
+ const isDuplicateOptionVisible = useMemo(
+ () =>
+ !isProduction &&
+ shouldShowBulkDuplicateOption({
+ selectedTransactionsKeys: selectedTransactionIDs,
+ selectedTransactions: selectedTransactionsForDuplicate,
+ allTransactions,
+ allReports,
+ allTransactionViolations,
+ allReportNameValuePairs,
+ defaultExpensePolicyID: defaultExpensePolicy?.id,
+ activePolicyExpenseChat,
+ typeExpenseReport: false,
+ searchData: undefined,
+ }),
+ [
+ isProduction,
+ selectedTransactionIDs,
+ selectedTransactionsForDuplicate,
+ allTransactions,
+ allReports,
+ allTransactionViolations,
+ allReportNameValuePairs,
+ defaultExpensePolicy?.id,
+ activePolicyExpenseChat,
+ ],
+ );
+
+ const duplicateHandlerRef = useRef<() => void>(() => {});
+ const setDuplicateHandler = useCallback((handler: () => void) => {
+ duplicateHandlerRef.current = handler;
+ }, []);
+ const invokeDuplicateHandler = useCallback(() => {
+ duplicateHandlerRef.current();
+ }, []);
+
// eslint-disable-next-line @typescript-eslint/no-deprecated
const isTrackExpenseThread = isTrackExpenseReport(report);
const isInvoice = isInvoiceReport(report);
@@ -423,6 +474,17 @@ function useSelectedTransactionsActions({
});
}
+ if (isDuplicateOptionVisible) {
+ // eslint-disable-next-line react-hooks/refs -- invokeDuplicateHandler reads a ref, but only at event-handler time (onSelected), never during render
+ options.push({
+ text: translate('search.bulkActions.duplicateExpense', {count: selectedTransactionIDs.length}),
+ icon: expensifyIcons.ExpenseCopy,
+ value: DUPLICATE,
+ shouldCloseModalOnSelect: true,
+ onSelected: invokeDuplicateHandler,
+ });
+ }
+
const canAllSelectedTransactionsBeRemoved = selectedTransactionsList.every((transaction) => {
const canRemoveTransaction = canDeleteCardTransactionByLiabilityType(transaction);
const action = getIOUActionForTransactionID(reportActions, transaction.transactionID);
@@ -459,6 +521,10 @@ function useSelectedTransactionsActions({
isDeleteModalVisible,
showDeleteModal,
hideDeleteModal,
+ isDuplicateOptionVisible,
+ setDuplicateHandler,
+ allTransactions,
+ allReports,
};
}
diff --git a/src/languages/de.ts b/src/languages/de.ts
index b7be0e9631ae..98ed57600f10 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -7574,6 +7574,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc
hold: 'Warteschleife',
unhold: 'Zurückhalten aufheben',
reject: 'Ablehnen',
+ duplicateExpense: ({count}: {count: number}) => `${count === 1 ? 'Ausgabe' : 'Ausgaben'} duplizieren`,
noOptionsAvailable: 'Für die ausgewählte Ausgabengruppe sind keine Optionen verfügbar.',
},
filtersHeader: 'Filter',
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 0022c81752a1..a944e1827ccd 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -7545,6 +7545,7 @@ const translations = {
hold: 'Hold',
unhold: 'Remove hold',
reject: 'Reject',
+ duplicateExpense: ({count}: {count: number}) => `Duplicate ${count === 1 ? 'expense' : 'expenses'}`,
noOptionsAvailable: 'No options available for the selected group of expenses.',
},
filtersHeader: 'Filters',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 92346b3d6b8f..4f631b51b756 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -7411,6 +7411,7 @@ ${amount} para ${merchant} - ${date}`,
hold: 'Retener',
unhold: 'Desbloquear',
reject: 'Rechazar',
+ duplicateExpense: ({count}: {count: number}) => `Duplicar ${count === 1 ? 'gasto' : 'gastos'}`,
noOptionsAvailable: 'No hay opciones disponibles para el grupo de gastos seleccionado.',
},
filtersHeader: 'Filtros',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index c2aa900f79c0..022f92dd0aeb 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -7597,6 +7597,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e
hold: 'En attente',
unhold: 'Supprimer la mise en attente',
reject: 'Rejeter',
+ duplicateExpense: ({count}: {count: number}) => `Dupliquer ${count === 1 ? 'la dépense' : 'les dépenses'}`,
noOptionsAvailable: 'Aucune option n’est disponible pour le groupe de dépenses sélectionné.',
},
filtersHeader: 'Filtres',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index b910fed6f908..1e8afaf4cb02 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -7563,6 +7563,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`,
hold: 'Metti in attesa',
unhold: 'Rimuovi blocco',
reject: 'Rifiuta',
+ duplicateExpense: ({count}: {count: number}) => `Duplica ${count === 1 ? 'spesa' : 'spese'}`,
noOptionsAvailable: 'Nessuna opzione disponibile per il gruppo di spese selezionato.',
},
filtersHeader: 'Filtri',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index 0ebc55937bb9..39282bb575f9 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -7473,6 +7473,7 @@ ${reportName}
hold: '保留',
unhold: '保留を解除',
reject: '却下',
+ duplicateExpense: ({count}: {count: number}) => `${count === 1 ? '経費を複製' : '経費を一括複製'}`,
noOptionsAvailable: '選択した経費グループには利用できるオプションがありません。',
},
filtersHeader: 'フィルター',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 2f7a35e0a223..c5e17fcf067f 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -7540,6 +7540,7 @@ Voeg meer bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`,
hold: 'Vasthouden',
unhold: 'Blokkering opheffen',
reject: 'Afwijzen',
+ duplicateExpense: ({count}: {count: number}) => `${count === 1 ? 'Declaratie' : 'Declaraties'} dupliceren`,
noOptionsAvailable: 'Geen opties beschikbaar voor de geselecteerde groep onkosten.',
},
filtersHeader: 'Filters',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 2d28dbb7c4be..b34b9e740a8a 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -7530,6 +7530,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`,
hold: 'Wstrzymaj',
unhold: 'Usuń blokadę',
reject: 'Odrzuć',
+ duplicateExpense: ({count}: {count: number}) => `Duplikuj ${count === 1 ? 'wydatek' : 'wydatki'}`,
noOptionsAvailable: 'Brak opcji dostępnych dla wybranej grupy wydatków.',
},
filtersHeader: 'Filtry',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index b522847f5a5d..10e1410c3a63 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -7533,6 +7533,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`,
hold: 'Reter',
unhold: 'Remover bloqueio',
reject: 'Rejeitar',
+ duplicateExpense: ({count}: {count: number}) => `Duplicar ${count === 1 ? 'despesa' : 'despesas'}`,
noOptionsAvailable: 'Nenhuma opção disponível para o grupo de despesas selecionado.',
},
filtersHeader: 'Filtros',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 50070945fd46..4b78353bf446 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -7337,6 +7337,7 @@ ${reportName}
hold: '暂挂',
unhold: '解除保留',
reject: '拒绝',
+ duplicateExpense: ({count}: {count: number}) => `复制${count === 1 ? '报销' : '报销费用'}`,
noOptionsAvailable: '所选报销的费用组没有可用选项。',
},
filtersHeader: '筛选器',
diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts
index 07f42760db9f..c7cb36b7b74f 100644
--- a/src/libs/actions/IOU/Duplicate.ts
+++ b/src/libs/actions/IOU/Duplicate.ts
@@ -8,6 +8,7 @@ import type {MergeDuplicatesParams, ResolveDuplicatesParams} from '@libs/API/par
import {WRITE_COMMANDS} from '@libs/API/types';
import DateUtils from '@libs/DateUtils';
import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils';
+import {getExistingTransactionID} from '@libs/IOUUtils';
import * as NumberUtils from '@libs/NumberUtils';
import Parser from '@libs/Parser';
import {getIOUActionForReportID, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils';
@@ -17,6 +18,7 @@ import {
buildOptimisticHoldReportAction,
buildOptimisticResolvedDuplicatesReportAction,
buildTransactionThread,
+ generateReportID,
getTransactionDetails,
} from '@libs/ReportUtils';
import playSound, {SOUNDS} from '@libs/Sound';
@@ -656,6 +658,8 @@ type DuplicateExpenseTransactionParams = {
personalDetails: OnyxEntry;
recentWaypoints: OnyxEntry;
targetPolicyTags: OnyxEntry;
+ shouldPlaySound?: boolean;
+ shouldDeferAutoSubmit?: boolean;
};
function duplicateExpenseTransaction({
@@ -678,6 +682,8 @@ function duplicateExpenseTransaction({
personalDetails,
recentWaypoints,
targetPolicyTags,
+ shouldPlaySound = true,
+ shouldDeferAutoSubmit = false,
}: DuplicateExpenseTransactionParams) {
if (!transaction) {
return;
@@ -705,6 +711,7 @@ function duplicateExpenseTransaction({
action: CONST.IOU.ACTION.CREATE,
transactionParams,
shouldHandleNavigation: false,
+ shouldPlaySound,
shouldGenerateTransactionThreadReport: true,
isASAPSubmitBetaEnabled,
currentUserAccountIDParam: userAccountID,
@@ -717,6 +724,7 @@ function duplicateExpenseTransaction({
isSelfTourViewed,
betas,
personalDetails,
+ shouldDeferAutoSubmit,
};
// If no workspace is provided the expense should be unreported
@@ -928,5 +936,102 @@ function duplicateReport({
playSound(SOUNDS.DONE);
}
-export {getIOUActionForTransactions, mergeDuplicates, resolveDuplicates, duplicateExpenseTransaction, duplicateReport};
-export type {DuplicateExpenseTransactionParams, DuplicateReportParams};
+type BulkDuplicateExpensesParams = {
+ transactionIDs: string[];
+ allTransactions: NonNullable>;
+ sourcePolicyIDMap: Record;
+ targetPolicy: OnyxEntry;
+ targetPolicyCategories: OnyxEntry;
+ targetPolicyTags: OnyxEntry;
+ targetReport: OnyxEntry;
+ personalDetails: OnyxEntry;
+ isASAPSubmitBetaEnabled: boolean;
+ introSelected: OnyxEntry;
+ activePolicyID: string | undefined;
+ quickAction: OnyxEntry;
+ policyRecentlyUsedCurrencies: string[];
+ isSelfTourViewed: boolean;
+ transactionDrafts: Record | undefined;
+ draftTransactionIDs: string[];
+ betas: OnyxEntry;
+ recentWaypoints: OnyxEntry;
+};
+
+function bulkDuplicateExpenses({
+ transactionIDs,
+ allTransactions,
+ sourcePolicyIDMap,
+ targetPolicy,
+ targetPolicyCategories,
+ targetPolicyTags,
+ targetReport,
+ personalDetails,
+ isASAPSubmitBetaEnabled,
+ introSelected,
+ activePolicyID,
+ quickAction,
+ policyRecentlyUsedCurrencies,
+ isSelfTourViewed,
+ transactionDrafts,
+ draftTransactionIDs,
+ betas,
+ recentWaypoints,
+}: BulkDuplicateExpensesParams) {
+ const transactionsToDuplicate = transactionIDs.map((id) => allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]).filter((t): t is OnyxTypes.Transaction => !!t);
+
+ if (transactionsToDuplicate.length === 0) {
+ return;
+ }
+
+ const optimisticChatReportID = generateReportID();
+ const optimisticIOUReportID = generateReportID();
+
+ // After the first iteration creates a new optimistic IOU report, subsequent
+ // iterations must know its ID so getMoneyRequestInformation can find and
+ // MERGE into it instead of SET-overwriting it. We carry a local copy of
+ // targetReport whose iouReportID is patched after the first pass.
+ let currentTargetReport = targetReport;
+
+ for (let i = 0; i < transactionsToDuplicate.length; i++) {
+ const item = transactionsToDuplicate.at(i);
+ if (!item) {
+ continue;
+ }
+ const isLastExpense = i === transactionsToDuplicate.length - 1;
+ const existingTransactionID = getExistingTransactionID(item.linkedTrackedExpenseReportAction);
+ const existingTransactionDraft = existingTransactionID ? transactionDrafts?.[existingTransactionID] : undefined;
+
+ duplicateExpenseTransaction({
+ transaction: item,
+ optimisticChatReportID,
+ optimisticIOUReportID,
+ isASAPSubmitBetaEnabled,
+ introSelected,
+ activePolicyID,
+ quickAction,
+ policyRecentlyUsedCurrencies,
+ isSelfTourViewed,
+ customUnitPolicyID: (isDistanceRequest(item) ? sourcePolicyIDMap[item.transactionID] : undefined) ?? targetPolicy?.id,
+ targetPolicy: targetPolicy ?? undefined,
+ targetPolicyCategories: targetPolicyCategories ?? {},
+ targetReport: currentTargetReport,
+ existingTransactionDraft,
+ draftTransactionIDs,
+ betas,
+ personalDetails,
+ recentWaypoints,
+ targetPolicyTags,
+ shouldPlaySound: false,
+ shouldDeferAutoSubmit: !isLastExpense,
+ });
+
+ if (currentTargetReport && !currentTargetReport.iouReportID) {
+ currentTargetReport = {...currentTargetReport, iouReportID: optimisticIOUReportID};
+ }
+ }
+
+ playSound(SOUNDS.DONE);
+}
+
+export {getIOUActionForTransactions, mergeDuplicates, resolveDuplicates, duplicateExpenseTransaction, bulkDuplicateExpenses, duplicateReport};
+export type {DuplicateExpenseTransactionParams, BulkDuplicateExpensesParams, DuplicateReportParams};
diff --git a/tests/unit/hooks/useSearchBulkActionsDuplicateTest.ts b/tests/unit/hooks/useSearchBulkActionsDuplicateTest.ts
new file mode 100644
index 000000000000..7c39e3bd8032
--- /dev/null
+++ b/tests/unit/hooks/useSearchBulkActionsDuplicateTest.ts
@@ -0,0 +1,866 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import {renderHook, waitFor} from '@testing-library/react-native';
+import {useEffect} from 'react';
+import Onyx from 'react-native-onyx';
+import type {SearchQueryJSON, SelectedTransactions} from '@components/Search/types';
+import useBulkDuplicateAction from '@hooks/useBulkDuplicateAction';
+import useSearchBulkActions from '@hooks/useSearchBulkActions';
+import {bulkDuplicateExpenses} from '@libs/actions/IOU/Duplicate';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Policy, PolicyCategories, PolicyTagLists, Report} from '@src/types/onyx';
+import createRandomTransaction, {createRandomDistanceRequestTransaction} from '../../utils/collections/transaction';
+
+jest.mock('@libs/actions/IOU/Duplicate', () => ({
+ bulkDuplicateExpenses: jest.fn(),
+}));
+
+jest.mock('@libs/actions/Search', () => ({
+ getExportTemplates: jest.fn(() => []),
+ exportSearchItemsToCSV: jest.fn(),
+ queueExportSearchItemsToCSV: jest.fn(),
+ queueExportSearchWithTemplate: jest.fn(),
+ approveMoneyRequestOnSearch: jest.fn(),
+ bulkDeleteReports: jest.fn(),
+ getLastPolicyBankAccountID: jest.fn(),
+ getLastPolicyPaymentMethod: jest.fn(),
+ getPayMoneyOnSearchInvoiceParams: jest.fn(),
+ getPayOption: jest.fn(() => ({shouldEnableBulkPayOption: false, isFirstTimePayment: false})),
+ getReportType: jest.fn(),
+ getTotalFormattedAmount: jest.fn(() => ''),
+ isCurrencySupportWalletBulkPay: jest.fn(() => false),
+ payMoneyRequestOnSearch: jest.fn(),
+ submitMoneyRequestOnSearch: jest.fn(),
+ unholdMoneyRequestOnSearch: jest.fn(),
+}));
+
+jest.mock('@libs/actions/MergeTransaction', () => ({
+ setupMergeTransactionDataAndNavigate: jest.fn(),
+}));
+
+jest.mock('@libs/actions/SplitExpenses.ts', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+
+jest.mock('@libs/actions/Report', () => ({
+ deleteAppReport: jest.fn(),
+ moveIOUReportToPolicy: jest.fn(),
+ moveIOUReportToPolicyAndInviteSubmitter: jest.fn(),
+}));
+
+jest.mock('@libs/actions/User', () => ({
+ setNameValuePair: jest.fn(),
+}));
+
+jest.mock('@libs/Navigation/Navigation', () => ({
+ navigate: jest.fn(),
+ getActiveRoute: jest.fn(() => '/test'),
+}));
+
+const mockTranslate = jest.fn((key: string) => key);
+const mockLocaleCompare = jest.fn((a: string, b: string) => a && b);
+const mockFormatPhoneNumber = jest.fn((phone: string) => phone);
+
+jest.mock('@hooks/useLocalize', () => ({
+ __esModule: true,
+ default: () => ({
+ translate: mockTranslate,
+ localeCompare: mockLocaleCompare,
+ formatPhoneNumber: mockFormatPhoneNumber,
+ }),
+}));
+
+jest.mock('@hooks/useThemeStyles', () => ({
+ __esModule: true,
+ default: () => ({colorMuted: {}, fontWeightNormal: {}, textWrap: {}}),
+}));
+
+jest.mock('@hooks/useTheme', () => ({
+ __esModule: true,
+ default: () => ({icon: ''}),
+}));
+
+jest.mock('@hooks/useNetwork', () => ({
+ __esModule: true,
+ default: () => ({isOffline: false}),
+}));
+
+jest.mock('@hooks/useEnvironment', () => ({
+ __esModule: true,
+ default: () => ({isProduction: false, isDevelopment: true, environment: 'development'}),
+}));
+
+jest.mock('@components/DelegateNoAccessModalProvider', () => ({
+ useDelegateNoAccessState: () => ({isDelegateAccessRestricted: false}),
+ useDelegateNoAccessActions: () => ({showDelegateNoAccessModal: jest.fn()}),
+}));
+
+jest.mock('@hooks/useConfirmModal', () => ({
+ __esModule: true,
+ default: () => ({showConfirmModal: jest.fn()}),
+}));
+
+jest.mock('@hooks/usePermissions', () => ({
+ __esModule: true,
+ default: () => ({isBetaEnabled: () => false}),
+}));
+
+jest.mock('@hooks/useSelfDMReport', () => ({
+ __esModule: true,
+ default: () => undefined,
+}));
+
+jest.mock('@hooks/useBulkPayOptions', () => ({
+ __esModule: true,
+ default: () => ({bulkPayButtonOptions: [], latestBankItems: []}),
+}));
+
+let mockDefaultExpensePolicy: Policy | undefined;
+jest.mock('@hooks/useDefaultExpensePolicy', () => ({
+ __esModule: true,
+ default: () => mockDefaultExpensePolicy,
+}));
+
+const mockClearSelectedTransactions = jest.fn();
+const mockSelectAllMatchingItems = jest.fn();
+let mockSelectedTransactions: SelectedTransactions = {};
+let mockSelectedReports: never[] = [];
+let mockAreAllMatchingItemsSelected = false;
+
+jest.mock('@components/Search/SearchContext', () => ({
+ useSearchStateContext: () => ({
+ selectedTransactions: mockSelectedTransactions,
+ selectedReports: mockSelectedReports,
+ areAllMatchingItemsSelected: mockAreAllMatchingItemsSelected,
+ currentSearchResults: undefined,
+ }),
+ useSearchActionsContext: () => ({
+ clearSelectedTransactions: mockClearSelectedTransactions,
+ selectAllMatchingItems: mockSelectAllMatchingItems,
+ }),
+}));
+
+const CURRENT_USER_ACCOUNT_ID = 1;
+
+jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ login: 'test@example.com',
+ accountID: CURRENT_USER_ACCOUNT_ID,
+ email: 'test@example.com',
+ })),
+}));
+
+const baseQueryJSON: SearchQueryJSON = {
+ inputQuery: 'type:expense status:all',
+ hash: 12345,
+ recentSearchHash: 12345,
+ similarSearchHash: 12345,
+ flatFilters: [],
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE,
+ status: CONST.SEARCH.STATUS.EXPENSE.ALL,
+ sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE,
+ sortOrder: CONST.SEARCH.SORT_ORDER.DESC,
+ view: CONST.SEARCH.VIEW.TABLE,
+ filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: 'type', right: 'expense'},
+};
+
+function makeSelectedTransaction(overrides: Partial = {}): SelectedTransactions[string] {
+ return {
+ isSelected: true,
+ canReject: false,
+ canHold: false,
+ canSplit: false,
+ hasBeenSplit: false,
+ canChangeReport: false,
+ isHeld: false,
+ canUnhold: false,
+ action: CONST.SEARCH.ACTION_TYPES.VIEW,
+ reportID: 'report1',
+ policyID: 'policy1',
+ amount: 100,
+ currency: 'USD',
+ isFromOneTransactionReport: false,
+ ...overrides,
+ };
+}
+
+/**
+ * Renders useSearchBulkActions + useBulkDuplicateAction together (mirrors the real app tree
+ * where BulkDuplicateHandler mounts when the option is visible and populates the handler ref).
+ */
+function useSearchBulkActionsWithDuplicate({queryJSON}: {queryJSON: SearchQueryJSON}) {
+ const actions = useSearchBulkActions({queryJSON});
+ const {setDuplicateHandler, allTransactions, allReports, searchData} = actions;
+ const handleDuplicate = useBulkDuplicateAction({
+ selectedTransactionsKeys: Object.keys(mockSelectedTransactions),
+ allTransactions,
+ allReports,
+ searchData,
+ });
+ useEffect(() => {
+ setDuplicateHandler(handleDuplicate);
+ }, [handleDuplicate, setDuplicateHandler]);
+ return actions;
+}
+
+describe('useSearchBulkActions - duplicate option', () => {
+ beforeAll(() => {
+ Onyx.init({keys: ONYXKEYS});
+ });
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ await Onyx.clear();
+ mockSelectedTransactions = {};
+ mockSelectedReports = [];
+ mockAreAllMatchingItemsSelected = false;
+ mockDefaultExpensePolicy = undefined;
+
+ await Onyx.merge(ONYXKEYS.SESSION, {accountID: CURRENT_USER_ACCOUNT_ID, email: 'test@example.com'});
+
+ const defaultReportIDs = ['report1', 'r0', 'r1', 'r2', 'r3'];
+ for (const reportID of defaultReportIDs) {
+ // eslint-disable-next-line no-await-in-loop
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {
+ reportID,
+ ownerAccountID: CURRENT_USER_ACCOUNT_ID,
+ type: CONST.REPORT.TYPE.EXPENSE,
+ reportName: `Report ${reportID}`,
+ });
+ }
+ });
+
+ afterEach(async () => {
+ await Onyx.clear();
+ });
+
+ // ============================================================
+ // Visibility tests
+ // ============================================================
+
+ it('should show duplicate option for a single cash expense', async () => {
+ const transactionID = '100';
+ const transaction = {
+ ...createRandomTransaction(1),
+ transactionID,
+ managedCard: false,
+ };
+
+ mockSelectedTransactions = {[transactionID]: makeSelectedTransaction()};
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction);
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));
+
+ await waitFor(() => {
+ const opt = result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE);
+ expect(opt).toBeDefined();
+ expect(opt?.text).toBe('search.bulkActions.duplicateExpense');
+ });
+ });
+
+ it('should not show duplicate option for a card expense', async () => {
+ const transactionID = '200';
+ const transaction = {...createRandomTransaction(1), transactionID, managedCard: true};
+
+ mockSelectedTransactions = {[transactionID]: makeSelectedTransaction()};
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction);
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));
+ await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0));
+
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined();
+ });
+
+ it('should not show duplicate option when selection includes a mix of cash and card expenses', async () => {
+ const cashTxn = {...createRandomTransaction(1), transactionID: '300', managedCard: false};
+ const cardTxn = {...createRandomTransaction(2), transactionID: '301', managedCard: true};
+
+ mockSelectedTransactions = {
+ '300': makeSelectedTransaction({reportID: 'r1'}),
+ '301': makeSelectedTransaction({reportID: 'r2'}),
+ };
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}300`, cashTxn);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}301`, cardTxn);
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));
+ await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0));
+
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined();
+ });
+
+ it('should show duplicate option for multiple cash expenses', async () => {
+ const txn1 = {...createRandomTransaction(1), transactionID: '400', managedCard: false};
+ const txn2 = {...createRandomTransaction(2), transactionID: '401', managedCard: false};
+
+ mockSelectedTransactions = {
+ '400': makeSelectedTransaction({reportID: 'r1'}),
+ '401': makeSelectedTransaction({reportID: 'r2'}),
+ };
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}400`, txn1);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}401`, txn2);
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));
+ await waitFor(() => {
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined();
+ });
+ });
+
+ it('should not show duplicate option for expense_report type', async () => {
+ const txn = {...createRandomTransaction(1), transactionID: '500', managedCard: false};
+ mockSelectedTransactions = {'500': makeSelectedTransaction()};
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}500`, txn);
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: {...baseQueryJSON, type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT}}));
+ await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0));
+
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined();
+ });
+
+ it('should not show duplicate option when no transactions are selected', () => {
+ mockSelectedTransactions = {};
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined();
+ });
+
+ it('should show duplicate option for a Per Diem expense with dates', async () => {
+ const txnID = '1500';
+ const txn = {
+ ...createRandomTransaction(1),
+ transactionID: txnID,
+ managedCard: false,
+ iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM,
+ comment: {
+ type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT,
+ customUnit: {
+ name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL,
+ attributes: {dates: {start: '2026-03-01', end: '2026-03-05'}},
+ },
+ },
+ };
+
+ mockSelectedTransactions = {[txnID]: makeSelectedTransaction()};
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn);
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));
+
+ await waitFor(() => {
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined();
+ });
+ });
+
+ it('should show duplicate option for a Distance expense', async () => {
+ const txnID = '1600';
+ const txn = {
+ ...createRandomDistanceRequestTransaction(1),
+ transactionID: txnID,
+ managedCard: false,
+ };
+
+ mockSelectedTransactions = {[txnID]: makeSelectedTransaction()};
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn);
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));
+
+ await waitFor(() => {
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined();
+ });
+ });
+
+ it('should show duplicate option for a mix of cash, Per Diem, and Distance expenses', async () => {
+ const cashTxn = {...createRandomTransaction(1), transactionID: '1700', managedCard: false};
+ const perDiemTxn = {
+ ...createRandomTransaction(2),
+ transactionID: '1701',
+ managedCard: false,
+ iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM,
+ comment: {
+ type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT,
+ customUnit: {
+ name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL,
+ attributes: {dates: {start: '2026-03-01', end: '2026-03-05'}},
+ },
+ },
+ };
+ const distanceTxn = {...createRandomDistanceRequestTransaction(3), transactionID: '1702', managedCard: false};
+
+ mockSelectedTransactions = {
+ '1700': makeSelectedTransaction({reportID: 'r1'}),
+ '1701': makeSelectedTransaction({reportID: 'r2'}),
+ '1702': makeSelectedTransaction({reportID: 'r3'}),
+ };
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1700`, cashTxn);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1701`, perDiemTxn);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1702`, distanceTxn);
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));
+
+ await waitFor(() => {
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined();
+ });
+ });
+
+ it('should not show duplicate option when card expense is mixed with Per Diem or Distance', async () => {
+ const cardTxn = {...createRandomTransaction(1), transactionID: '1800', managedCard: true};
+ const perDiemTxn = {
+ ...createRandomTransaction(2),
+ transactionID: '1801',
+ managedCard: false,
+ iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM,
+ comment: {
+ type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT,
+ customUnit: {
+ name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL,
+ attributes: {dates: {start: '2026-03-01', end: '2026-03-05'}},
+ },
+ },
+ };
+ const distanceTxn = {...createRandomDistanceRequestTransaction(3), transactionID: '1802', managedCard: false};
+
+ mockSelectedTransactions = {
+ '1800': makeSelectedTransaction({reportID: 'r1'}),
+ '1801': makeSelectedTransaction({reportID: 'r2'}),
+ '1802': makeSelectedTransaction({reportID: 'r3'}),
+ };
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1800`, cardTxn);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1801`, perDiemTxn);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1802`, distanceTxn);
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));
+ await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0));
+
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined();
+ });
+
+ it('should show duplicate option for expenses in any state (submit, approve, pay, done)', async () => {
+ const states = [CONST.SEARCH.ACTION_TYPES.SUBMIT, CONST.SEARCH.ACTION_TYPES.APPROVE, CONST.SEARCH.ACTION_TYPES.PAY, CONST.SEARCH.ACTION_TYPES.DONE];
+
+ const transactionIDs = states.map((_, i) => `1900${i}`);
+ for (const [i, txnID] of transactionIDs.entries()) {
+ const txn = {...createRandomTransaction(i + 1), transactionID: txnID, managedCard: false};
+ // eslint-disable-next-line no-await-in-loop
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn);
+ }
+
+ mockSelectedTransactions = Object.fromEntries(
+ transactionIDs.map((id, i) => [id, makeSelectedTransaction({reportID: `r${i}`, action: states.at(i) ?? CONST.SEARCH.ACTION_TYPES.VIEW})]),
+ );
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));
+
+ await waitFor(() => {
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined();
+ });
+ });
+
+ it('should show duplicate option for expenses with VIEW action (unreported/paid)', async () => {
+ const txnID = '2000';
+ const txn = {...createRandomTransaction(1), transactionID: txnID, managedCard: false};
+ mockSelectedTransactions = {[txnID]: makeSelectedTransaction({action: CONST.SEARCH.ACTION_TYPES.VIEW})};
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn);
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));
+
+ await waitFor(() => {
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined();
+ });
+ });
+
+ // ============================================================
+ // Data / invocation tests
+ // ============================================================
+
+ it('should call bulkDuplicateExpenses with all selected transaction IDs', async () => {
+ const txn1 = {...createRandomTransaction(1), transactionID: '600', managedCard: false};
+ const txn2 = {...createRandomTransaction(2), transactionID: '601', managedCard: false};
+
+ mockSelectedTransactions = {
+ '600': makeSelectedTransaction({reportID: 'r1'}),
+ '601': makeSelectedTransaction({reportID: 'r2'}),
+ };
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}600`, txn1);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}601`, txn2);
+
+ const {result} = renderHook(() => useSearchBulkActionsWithDuplicate({queryJSON: baseQueryJSON}));
+
+ await waitFor(() => {
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined();
+ });
+
+ result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)?.onSelected?.();
+
+ expect(bulkDuplicateExpenses).toHaveBeenCalledTimes(1);
+ expect(bulkDuplicateExpenses).toHaveBeenCalledWith(
+ expect.objectContaining({
+ transactionIDs: expect.arrayContaining(['600', '601']),
+ }),
+ );
+ expect(mockClearSelectedTransactions).toHaveBeenCalledWith(undefined, true);
+ });
+
+ it('should pass defaultExpensePolicy as targetPolicy when available', async () => {
+ const policyID = 'POLICY_TEAM_1';
+ const teamPolicy = {id: policyID, type: CONST.POLICY.TYPE.TEAM, name: 'Test Workspace'} as Policy;
+ mockDefaultExpensePolicy = teamPolicy;
+
+ const txnID = '700';
+ const txn = {...createRandomTransaction(1), transactionID: txnID, managedCard: false};
+ mockSelectedTransactions = {[txnID]: makeSelectedTransaction({policyID})};
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn);
+
+ const {result} = renderHook(() => useSearchBulkActionsWithDuplicate({queryJSON: baseQueryJSON}));
+
+ await waitFor(() => {
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined();
+ });
+
+ result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)?.onSelected?.();
+
+ expect(bulkDuplicateExpenses).toHaveBeenCalledWith(
+ expect.objectContaining({
+ targetPolicy: teamPolicy,
+ }),
+ );
+ });
+
+ it('should pass undefined targetPolicy when no defaultExpensePolicy exists', async () => {
+ mockDefaultExpensePolicy = undefined;
+
+ const txnID = '800';
+ const txn = {...createRandomTransaction(1), transactionID: txnID, managedCard: false};
+ mockSelectedTransactions = {[txnID]: makeSelectedTransaction()};
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn);
+
+ const {result} = renderHook(() => useSearchBulkActionsWithDuplicate({queryJSON: baseQueryJSON}));
+
+ await waitFor(() => {
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined();
+ });
+
+ result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)?.onSelected?.();
+
+ expect(bulkDuplicateExpenses).toHaveBeenCalledWith(
+ expect.objectContaining({
+ targetPolicy: undefined,
+ }),
+ );
+ });
+
+ it('should resolve policy categories and tags from defaultExpensePolicy', async () => {
+ const policyID = 'POLICY_CAT_TEST';
+ const teamPolicy = {id: policyID, type: CONST.POLICY.TYPE.TEAM, name: 'Category WS'} as Policy;
+ mockDefaultExpensePolicy = teamPolicy;
+
+ const categories: PolicyCategories = {
+ Food: {name: 'Food', enabled: true, areCommentsRequired: false, externalID: '', origin: ''},
+ Travel: {name: 'Travel', enabled: true, areCommentsRequired: false, externalID: '', origin: ''},
+ };
+ const tags: PolicyTagLists = {
+ Tag: {name: 'Tag', required: false, tags: {ProjectA: {name: 'ProjectA', enabled: true, 'GL Code': '', pendingAction: undefined}}, orderWeight: 1},
+ };
+
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, categories);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, tags);
+
+ const txnID = '900';
+ const txn = {...createRandomTransaction(1), transactionID: txnID, managedCard: false, category: 'Food'};
+ mockSelectedTransactions = {[txnID]: makeSelectedTransaction({policyID})};
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn);
+
+ const {result} = renderHook(() => useSearchBulkActionsWithDuplicate({queryJSON: baseQueryJSON}));
+
+ await waitFor(() => {
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined();
+ });
+
+ result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)?.onSelected?.();
+
+ expect(bulkDuplicateExpenses).toHaveBeenCalledWith(
+ expect.objectContaining({
+ targetPolicy: teamPolicy,
+ targetPolicyCategories: categories,
+ targetPolicyTags: tags,
+ }),
+ );
+ });
+
+ it('should pass activePolicyExpenseChat as targetReport when it exists', async () => {
+ const policyID = 'POLICY_REPORT_TEST';
+ const teamPolicy = {id: policyID, type: CONST.POLICY.TYPE.TEAM, name: 'Report WS'} as Policy;
+ mockDefaultExpensePolicy = teamPolicy;
+
+ const policyExpenseChat: Report = {
+ reportID: 'pec_123',
+ policyID,
+ type: CONST.REPORT.TYPE.EXPENSE,
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ ownerAccountID: CURRENT_USER_ACCOUNT_ID,
+ reportName: 'Test Workspace',
+ };
+
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat);
+
+ const txnID = '1000';
+ const txn = {...createRandomTransaction(1), transactionID: txnID, managedCard: false};
+ mockSelectedTransactions = {[txnID]: makeSelectedTransaction({policyID})};
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn);
+
+ const {result} = renderHook(() => useSearchBulkActionsWithDuplicate({queryJSON: baseQueryJSON}));
+
+ await waitFor(() => {
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined();
+ });
+
+ result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)?.onSelected?.();
+
+ expect(bulkDuplicateExpenses).toHaveBeenCalledWith(
+ expect.objectContaining({
+ targetReport: expect.objectContaining({
+ reportID: policyExpenseChat.reportID,
+ policyID,
+ }),
+ }),
+ );
+ });
+
+ it('should duplicate expenses from multiple policies using the default expense policy data', async () => {
+ const defaultPolicyID = 'DEFAULT_POLICY';
+ const otherPolicyID = 'OTHER_POLICY';
+ const teamPolicy = {id: defaultPolicyID, type: CONST.POLICY.TYPE.TEAM, name: 'Default WS'} as Policy;
+ mockDefaultExpensePolicy = teamPolicy;
+
+ const defaultCategories: PolicyCategories = {
+ Materials: {name: 'Materials', enabled: true, areCommentsRequired: false, externalID: '', origin: ''},
+ };
+ const otherCategories: PolicyCategories = {
+ Supplies: {name: 'Supplies', enabled: true, areCommentsRequired: false, externalID: '', origin: ''},
+ };
+
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${defaultPolicyID}`, defaultCategories);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${otherPolicyID}`, otherCategories);
+
+ const txn1 = {...createRandomTransaction(1), transactionID: '1100', managedCard: false, category: 'Materials'};
+ const txn2 = {...createRandomTransaction(2), transactionID: '1101', managedCard: false, category: 'Supplies'};
+
+ mockSelectedTransactions = {
+ '1100': makeSelectedTransaction({reportID: 'r1', policyID: defaultPolicyID}),
+ '1101': makeSelectedTransaction({reportID: 'r2', policyID: otherPolicyID}),
+ };
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1100`, txn1);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1101`, txn2);
+
+ const {result} = renderHook(() => useSearchBulkActionsWithDuplicate({queryJSON: baseQueryJSON}));
+
+ await waitFor(() => {
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined();
+ });
+
+ result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)?.onSelected?.();
+
+ expect(bulkDuplicateExpenses).toHaveBeenCalledTimes(1);
+ expect(bulkDuplicateExpenses).toHaveBeenCalledWith(
+ expect.objectContaining({
+ transactionIDs: expect.arrayContaining(['1100', '1101']),
+ targetPolicy: teamPolicy,
+ targetPolicyCategories: defaultCategories,
+ }),
+ );
+ });
+
+ it('should pass empty categories and tags when defaultExpensePolicy is undefined', async () => {
+ mockDefaultExpensePolicy = undefined;
+
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}somePolicyID`, {
+ Ignored: {name: 'Ignored', enabled: true, areCommentsRequired: false, externalID: '', origin: ''},
+ });
+
+ const txnID = '1200';
+ const txn = {...createRandomTransaction(1), transactionID: txnID, managedCard: false, category: 'Ignored'};
+ mockSelectedTransactions = {[txnID]: makeSelectedTransaction()};
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn);
+
+ const {result} = renderHook(() => useSearchBulkActionsWithDuplicate({queryJSON: baseQueryJSON}));
+
+ await waitFor(() => {
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined();
+ });
+
+ result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)?.onSelected?.();
+
+ expect(bulkDuplicateExpenses).toHaveBeenCalledWith(
+ expect.objectContaining({
+ targetPolicy: undefined,
+ targetPolicyCategories: {},
+ targetPolicyTags: {},
+ }),
+ );
+ });
+
+ it('should include allTransactions data in bulkDuplicateExpenses call', async () => {
+ const txn1 = {...createRandomTransaction(1), transactionID: '1300', managedCard: false, category: 'A', amount: 5000};
+ const txn2 = {...createRandomTransaction(2), transactionID: '1301', managedCard: false, category: 'B', amount: 7500};
+
+ mockSelectedTransactions = {
+ '1300': makeSelectedTransaction({reportID: 'r1'}),
+ '1301': makeSelectedTransaction({reportID: 'r2'}),
+ };
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1300`, txn1);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}1301`, txn2);
+
+ const {result} = renderHook(() => useSearchBulkActionsWithDuplicate({queryJSON: baseQueryJSON}));
+
+ await waitFor(() => {
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeDefined();
+ });
+
+ result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)?.onSelected?.();
+
+ expect(bulkDuplicateExpenses).toHaveBeenCalledWith(
+ expect.objectContaining({
+ allTransactions: expect.objectContaining({
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}1300`]: expect.objectContaining({transactionID: '1300', category: 'A', amount: 5000}),
+ [`${ONYXKEYS.COLLECTION.TRANSACTION}1301`]: expect.objectContaining({transactionID: '1301', category: 'B', amount: 7500}),
+ }),
+ }),
+ );
+ });
+
+ it('should not show duplicate option for per-diem expense on non-default workspace', async () => {
+ const defaultPolicyID = 'DEFAULT_POLICY';
+ const otherPolicyID = 'OTHER_POLICY';
+ mockDefaultExpensePolicy = {id: defaultPolicyID, type: CONST.POLICY.TYPE.TEAM, name: 'Default WS'} as Policy;
+
+ const txnID = '2100';
+ const txn = {
+ ...createRandomTransaction(1),
+ transactionID: txnID,
+ managedCard: false,
+ iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM,
+ comment: {type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT, customUnit: {name: CONST.CUSTOM_UNITS.NAME_PER_DIEM_INTERNATIONAL}},
+ };
+
+ const report: Report = {
+ reportID: 'r_other',
+ policyID: otherPolicyID,
+ type: CONST.REPORT.TYPE.EXPENSE,
+ reportName: 'Other WS Report',
+ };
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}r_other`, report);
+
+ mockSelectedTransactions = {[txnID]: makeSelectedTransaction({reportID: 'r_other', policyID: otherPolicyID})};
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn);
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));
+ await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0));
+
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined();
+ });
+
+ it('should not show duplicate option for transaction with custom unit out of policy violation', async () => {
+ const txnID = '2200';
+ const txn = {...createRandomTransaction(1), transactionID: txnID, managedCard: false};
+
+ mockSelectedTransactions = {[txnID]: makeSelectedTransaction()};
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${txnID}`, [{name: CONST.VIOLATIONS.CUSTOM_UNIT_OUT_OF_POLICY, type: CONST.VIOLATION_TYPES.VIOLATION}]);
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));
+ await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0));
+
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined();
+ });
+
+ it('should not show duplicate option for distance expense from archived report', async () => {
+ const txnID = '2300';
+ const reportID = 'r_archived';
+ const txn = {
+ ...createRandomDistanceRequestTransaction(1),
+ transactionID: txnID,
+ managedCard: false,
+ };
+
+ const report: Report = {
+ reportID,
+ policyID: 'policy1',
+ type: CONST.REPORT.TYPE.EXPENSE,
+ reportName: 'Archived Report',
+ };
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`, {private_isArchived: new Date().toISOString()});
+
+ mockSelectedTransactions = {[txnID]: makeSelectedTransaction({reportID})};
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn);
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));
+ await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0));
+
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined();
+ });
+
+ it('should not show duplicate option for distance expense from DM chat', async () => {
+ const defaultPolicyID = 'DEFAULT_POLICY';
+ mockDefaultExpensePolicy = {id: defaultPolicyID, type: CONST.POLICY.TYPE.TEAM, name: 'Default WS'} as Policy;
+
+ const txnID = '2400';
+ const expenseReportID = 'r_expense_dm';
+ const chatReportID = 'r_chat_dm';
+ const txn = {
+ ...createRandomDistanceRequestTransaction(1),
+ transactionID: txnID,
+ managedCard: false,
+ };
+
+ const policyExpenseChat: Report = {
+ reportID: 'pec_dm',
+ policyID: defaultPolicyID,
+ type: CONST.REPORT.TYPE.EXPENSE,
+ chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ ownerAccountID: CURRENT_USER_ACCOUNT_ID,
+ reportName: 'Default WS',
+ };
+ const chatReport: Report = {
+ reportID: chatReportID,
+ type: CONST.REPORT.TYPE.CHAT,
+ reportName: 'DM Chat',
+ };
+ const expenseReport: Report = {
+ reportID: expenseReportID,
+ policyID: defaultPolicyID,
+ type: CONST.REPORT.TYPE.EXPENSE,
+ chatReportID,
+ reportName: 'DM Expense',
+ };
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, chatReport);
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, expenseReport);
+
+ mockSelectedTransactions = {[txnID]: makeSelectedTransaction({reportID: expenseReportID})};
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn);
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));
+ await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0));
+
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined();
+ });
+
+ it('should not show duplicate option when all selected transactions are scanning', async () => {
+ const txnID = '1400';
+ const txn = {
+ ...createRandomTransaction(1),
+ transactionID: txnID,
+ managedCard: false,
+ merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT,
+ modifiedMerchant: '',
+ receipt: {state: CONST.IOU.RECEIPT_STATE.SCAN_READY, source: 'test.jpg'},
+ };
+
+ mockSelectedTransactions = {[txnID]: makeSelectedTransaction()};
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${txnID}`, txn);
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON}));
+
+ await waitFor(() => expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0));
+ expect(result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DUPLICATE)).toBeUndefined();
+ });
+});
diff --git a/tests/unit/hooks/useSelectedTransactionsActions.test.ts b/tests/unit/hooks/useSelectedTransactionsActions.test.ts
index 87c2870cd46c..8317feede74a 100644
--- a/tests/unit/hooks/useSelectedTransactionsActions.test.ts
+++ b/tests/unit/hooks/useSelectedTransactionsActions.test.ts
@@ -520,7 +520,7 @@ describe('useSelectedTransactionsActions', () => {
expect(result.current.options.length).toBeGreaterThan(0);
});
- const holdOption = result.current.options.find((option) => option.value === 'HOLD');
+ const holdOption = result.current.options.find((option) => option.value === 'hold');
expect(holdOption).toBeDefined();
expect(holdOption?.text).toBe('iou.hold');
});
@@ -568,7 +568,7 @@ describe('useSelectedTransactionsActions', () => {
expect(result.current.options.length).toBeGreaterThan(0);
});
- const holdOption = result.current.options.find((option) => option.value === 'HOLD');
+ const holdOption = result.current.options.find((option) => option.value === 'hold');
holdOption?.onSelected?.();
expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS.getRoute({reportID: report.reportID}));
@@ -618,7 +618,7 @@ describe('useSelectedTransactionsActions', () => {
expect(result.current.options.length).toBeGreaterThan(0);
});
- const unholdOption = result.current.options.find((option) => option.value === 'UNHOLD');
+ const unholdOption = result.current.options.find((option) => option.value === 'unhold');
expect(unholdOption).toBeDefined();
expect(unholdOption?.text).toBe('iou.unhold');
@@ -673,7 +673,7 @@ describe('useSelectedTransactionsActions', () => {
expect(result.current.options.length).toBeGreaterThan(0);
});
- const unholdOption = result.current.options.find((option) => option.value === 'UNHOLD');
+ const unholdOption = result.current.options.find((option) => option.value === 'unhold');
expect(unholdOption).toBeDefined();
unholdOption?.onSelected?.();
@@ -722,11 +722,11 @@ describe('useSelectedTransactionsActions', () => {
// The EXPORT option appears immediately, but MOVE depends on selectedTransactionsList
// which requires the Onyx transaction data to be fully loaded.
await waitFor(() => {
- const moveOption = result.current.options.find((option) => option.value === 'MOVE');
+ const moveOption = result.current.options.find((option) => option.value === 'move');
expect(moveOption).toBeDefined();
});
- const moveOption = result.current.options.find((option) => option.value === 'MOVE');
+ const moveOption = result.current.options.find((option) => option.value === 'move');
expect(moveOption?.text).toBe('iou.moveExpenses');
});
@@ -767,7 +767,7 @@ describe('useSelectedTransactionsActions', () => {
);
await waitFor(() => {
- const moveOption = result.current.options.find((option) => option.value === 'MOVE');
+ const moveOption = result.current.options.find((option) => option.value === 'move');
expect(moveOption).toBeDefined();
});
@@ -829,11 +829,11 @@ describe('useSelectedTransactionsActions', () => {
// The EXPORT option appears immediately, but SPLIT depends on selectedTransactionsList
// which requires the Onyx transaction data to be fully loaded.
await waitFor(() => {
- const splitOption = result.current.options.find((option) => option.value === 'SPLIT');
+ const splitOption = result.current.options.find((option) => option.value === 'split');
expect(splitOption).toBeDefined();
});
- const splitOption = result.current.options.find((option) => option.value === 'SPLIT');
+ const splitOption = result.current.options.find((option) => option.value === 'split');
expect(splitOption?.text).toBe('iou.split');
splitOption?.onSelected?.();
@@ -876,11 +876,11 @@ describe('useSelectedTransactionsActions', () => {
// The EXPORT option appears immediately, but MERGE depends on selectedTransactionsList
// which requires the Onyx transaction data to be fully loaded.
await waitFor(() => {
- const mergeOption = result.current.options.find((option) => option.value === 'MERGE');
+ const mergeOption = result.current.options.find((option) => option.value === 'merge');
expect(mergeOption).toBeDefined();
});
- const mergeOption = result.current.options.find((option) => option.value === 'MERGE');
+ const mergeOption = result.current.options.find((option) => option.value === 'merge');
expect(mergeOption?.text).toBe('common.merge');
mergeOption?.onSelected?.();