diff --git a/src/components/ExportDownloadStatusManager.tsx b/src/components/ExportDownloadStatusManager.tsx
new file mode 100644
index 000000000000..e3e42f9a2630
--- /dev/null
+++ b/src/components/ExportDownloadStatusManager.tsx
@@ -0,0 +1,67 @@
+import useOnyx from '@hooks/useOnyx';
+
+import {clearExportDownload, markExportDownloadSurfaced} from '@libs/actions/Export';
+
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+import React from 'react';
+
+import ExportDownloadStatusModal from './ExportDownloadStatusModal';
+
+/**
+ * Renders the queued export status modal for the whole app. It watches the export collection and shows the modal
+ * for the active export (preparing, ready, or failed). Because it is the single owner of the modal and reads
+ * straight from Onyx, a screen only has to start an export (which writes its record); this shows the progress,
+ * delivers the file when it is ready, and still surfaces it after a reload or once the screen that started it is
+ * gone. There is no per-screen modal to coordinate with.
+ */
+function ExportDownloadStatusManager() {
+ const [exportDownloads] = useOnyx(ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD);
+
+ const activeEntry = Object.entries(exportDownloads ?? {}).find(([, exportDownload]) => {
+ if (!exportDownload) {
+ return false;
+ }
+ if (exportDownload.shouldSendFromConcierge) {
+ return !exportDownload.hasBeenSurfaced;
+ }
+ return (
+ exportDownload.state === CONST.EXPORT_DOWNLOAD.STATE.PREPARING ||
+ exportDownload.state === CONST.EXPORT_DOWNLOAD.STATE.READY ||
+ exportDownload.state === CONST.EXPORT_DOWNLOAD.STATE.FAILED
+ );
+ });
+
+ if (!activeEntry) {
+ return null;
+ }
+
+ const exportID = activeEntry[0].replace(ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD, '');
+ const exportDownload = activeEntry[1];
+
+ const handleClose = () => {
+ if (exportDownload?.shouldSendFromConcierge) {
+ markExportDownloadSurfaced(exportID);
+ return;
+ }
+ // The modal already blocks dismissal while preparing, so this is an extra guard.
+ if (exportDownload?.state === CONST.EXPORT_DOWNLOAD.STATE.PREPARING) {
+ return;
+ }
+ clearExportDownload(exportID, exportDownload);
+ };
+
+ return (
+
+ );
+}
+
+ExportDownloadStatusManager.displayName = 'ExportDownloadStatusManager';
+
+export default ExportDownloadStatusManager;
diff --git a/src/components/ExportDownloadStatusModal.tsx b/src/components/ExportDownloadStatusModal.tsx
index 086291969a4a..75e6cbfbfd40 100644
--- a/src/components/ExportDownloadStatusModal.tsx
+++ b/src/components/ExportDownloadStatusModal.tsx
@@ -7,6 +7,7 @@ import usePreviousDefined from '@hooks/usePreviousDefined';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
+import {isClientTheLeader} from '@libs/ActiveClientManager';
import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL';
import {isMobileSafari} from '@libs/Browser';
import {getOldDotURLFromEnvironment} from '@libs/Environment/Environment';
@@ -14,6 +15,7 @@ import fileDownload from '@libs/fileDownload';
import {buildSecureDownloadURL} from '@libs/UrlUtils';
import {sendExportFileFromConcierge} from '@userActions/Export';
+import {close} from '@userActions/Modal';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -71,7 +73,7 @@ function ExportDownloadStatusModal({exportID, isVisible, onClose, failedBody}: E
// Build the secure download URL the same way downloadReportPDF does, so the host always follows
// the app's current environment (instead of the env baked into a backend-built URL) and authenticates
- // via the encryptedAuthToken — no separate OldDot sign-in needed.
+ // via the encryptedAuthToken, so no separate OldDot sign-in is needed.
const downloadFile = () => {
if (!fileName || !currentUserLogin) {
return;
@@ -84,7 +86,8 @@ function ExportDownloadStatusModal({exportID, isVisible, onClose, failedBody}: E
};
useEffect(() => {
- if (!isReady || !fileName || shouldSendFromConcierge || isEmptyReceipts) {
+ // Only the leader tab auto-downloads, so a ready export isn't downloaded once per open tab.
+ if (!isReady || !fileName || shouldSendFromConcierge || isEmptyReceipts || !isClientTheLeader()) {
return;
}
downloadFile();
@@ -97,8 +100,7 @@ function ExportDownloadStatusModal({exportID, isVisible, onClose, failedBody}: E
const {openConciergeAnywhere} = useOpenConciergeAnywhere();
const handleGoToConcierge = () => {
- onClose();
- openConciergeAnywhere({forceConcierge: true});
+ close(() => openConciergeAnywhere({forceConcierge: true}));
};
const handleDownloadFile = () => {
diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx
index 92f168746b29..1fba8db9c14a 100644
--- a/src/components/MoneyReportHeader.tsx
+++ b/src/components/MoneyReportHeader.tsx
@@ -24,7 +24,6 @@ import {View} from 'react-native';
import HeaderLoadingBar from './HeaderLoadingBar';
import HeaderWithBackButton from './HeaderWithBackButton';
import MoneyReportHeaderActions from './MoneyReportHeaderActions';
-import {ExportDownloadStatusProvider} from './MoneyReportHeaderActions/ExportDownloadStatusProvider';
import MoneyReportHeaderModals from './MoneyReportHeaderModals';
import MoneyReportHeaderMoreContent from './MoneyReportHeaderMoreContent';
import {PaymentAnimationsProvider} from './PaymentAnimationsContext';
@@ -44,15 +43,13 @@ type MoneyReportHeaderProps = {
function MoneyReportHeader({reportID, shouldDisplayBackButton = false, onBackButtonPress}: MoneyReportHeaderProps) {
return (
-
-
-
-
-
+
+
+
);
}
diff --git a/src/components/MoneyReportHeaderActions/ExportDownloadStatusProvider.tsx b/src/components/MoneyReportHeaderActions/ExportDownloadStatusProvider.tsx
deleted file mode 100644
index 4a46ba784640..000000000000
--- a/src/components/MoneyReportHeaderActions/ExportDownloadStatusProvider.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import {useSearchSelectionActions} from '@components/Search/SearchContext';
-
-import useExportDownloadStatusModal from '@hooks/useExportDownloadStatusModal';
-
-import React, {createContext, useContext} from 'react';
-
-type ExportDownloadStatusContextValue = {
- /** Start tracking a queued export so the shared status modal renders for it */
- trackExport: (exportID: string) => void;
-};
-
-const ExportDownloadStatusContext = createContext({
- trackExport: () => {},
-});
-
-type ExportDownloadStatusProviderProps = {
- /** The children to render inside the provider */
- children: React.ReactNode;
-};
-
-/**
- * Owns the queued export status modal for the money report header. The state lives here, above the
- * two mutually-exclusive layout branches in MoneyReportHeader, so the modal survives orientation /
- * layout changes that remount the header actions subtree.
- */
-function ExportDownloadStatusProvider({children}: ExportDownloadStatusProviderProps) {
- const {clearSelectedTransactions} = useSearchSelectionActions();
- const {trackExport, exportDownloadStatusModal} = useExportDownloadStatusModal(() => clearSelectedTransactions(true));
-
- return (
-
- {exportDownloadStatusModal}
- {children}
-
- );
-}
-
-function useExportDownloadStatus(): ExportDownloadStatusContextValue {
- return useContext(ExportDownloadStatusContext);
-}
-
-export {ExportDownloadStatusProvider, useExportDownloadStatus};
diff --git a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx
index a7abed31dcc6..b84b12f1a46d 100644
--- a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx
+++ b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx
@@ -9,7 +9,6 @@ import BulkDuplicateHandler from '@components/Search/BulkDuplicateHandler';
import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext';
import useConfirmModal from '@hooks/useConfirmModal';
-import useExportDownloadStatusModal from '@hooks/useExportDownloadStatusModal';
import useFilterSelectedTransactions from '@hooks/useFilterSelectedTransactions';
import useLocalize from '@hooks/useLocalize';
import useMobileSelectionMode from '@hooks/useMobileSelectionMode';
@@ -82,7 +81,6 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool
const isMobileSelectionModeEnabled = useMobileSelectionMode();
const {showConfirmModal} = useConfirmModal();
- const {trackExport, exportDownloadStatusModal} = useExportDownloadStatusModal(() => clearSelectedTransactions(true));
const [offlineModalVisible, setOfflineModalVisible] = useState(false);
const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false);
@@ -100,7 +98,7 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool
return;
}
- const exportID = queueExportSearchWithTemplate(
+ queueExportSearchWithTemplate(
{
templateName,
templateType,
@@ -112,7 +110,8 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool
},
true,
);
- trackExport(exportID);
+ // Clear the selection now that the export has started; the app-level ExportDownloadStatusManager shows the modal.
+ clearSelectedTransactions(true);
};
const onDeleteSelected = (handleDeleteTransactions: () => void, handleDeleteTransactionsWithNavigation: (backToRoute?: Route) => void) => {
@@ -238,7 +237,6 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool
return (
<>
- {exportDownloadStatusModal}
{isDuplicateOptionVisible && (
)}
- {exportDownloadStatusModal}
>
);
}
diff --git a/src/hooks/useExportActions.ts b/src/hooks/useExportActions.ts
index b3766a0d1601..13432094c251 100644
--- a/src/hooks/useExportActions.ts
+++ b/src/hooks/useExportActions.ts
@@ -1,6 +1,6 @@
import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
-import {useExportDownloadStatus} from '@components/MoneyReportHeaderActions/ExportDownloadStatusProvider';
import type {PopoverMenuItem} from '@components/PopoverMenu';
+import {useSearchSelectionActions} from '@components/Search/SearchContext';
import {exportReceiptsToZip} from '@libs/actions/Export';
import {openOldDotLink} from '@libs/actions/Link';
@@ -83,7 +83,7 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa
const {showDecisionModal} = useDecisionModal();
const {triggerExportOrConfirm} = useExportAgainModal(moneyRequestReport?.reportID, moneyRequestReport?.policyID);
- const {trackExport} = useExportDownloadStatus();
+ const {clearSelectedTransactions} = useSearchSelectionActions();
const expensifyIcons = useMemoizedLazyExpensifyIcons([
'Table',
@@ -129,7 +129,7 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa
return;
}
- const exportID = queueExportSearchWithTemplate(
+ queueExportSearchWithTemplate(
{
templateName,
templateType,
@@ -141,7 +141,8 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa
},
true,
);
- trackExport(exportID);
+ // Clear the selection now that the export has started. The app-level ExportDownloadStatusManager shows the modal.
+ clearSelectedTransactions(true);
};
const exportSubmenuOptions: Record> = {
@@ -291,8 +292,8 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa
if (!moneyRequestReport?.reportID) {
return;
}
- const exportID = exportReceiptsToZip({reportIDs: [moneyRequestReport.reportID]});
- trackExport(exportID);
+ exportReceiptsToZip({reportIDs: [moneyRequestReport.reportID]});
+ clearSelectedTransactions(true);
},
},
[CONST.REPORT.SECONDARY_ACTIONS.PRINT]: {
diff --git a/src/hooks/useExportDownloadStatusModal.tsx b/src/hooks/useExportDownloadStatusModal.tsx
deleted file mode 100644
index d8d7f13eacfc..000000000000
--- a/src/hooks/useExportDownloadStatusModal.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import ExportDownloadStatusModal from '@components/ExportDownloadStatusModal';
-
-import {clearExportDownload} from '@libs/actions/Export';
-
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-
-import React, {useState} from 'react';
-
-import useOnyx from './useOnyx';
-
-type UseExportDownloadStatusModalReturn = {
- /** Start tracking a queued export so the status modal renders for it */
- trackExport: (exportID: string) => void;
-
- /** The realtime export status modal for the in-progress export (or null when none is active). Render it directly in the consumer. */
- exportDownloadStatusModal: React.JSX.Element | null;
-};
-
-/**
- * Encapsulates the shared wiring for the queued export status modal (ExportDownloadStatusModal): it tracks the
- * active export, renders the modal, and handles close/cleanup (no-op while still preparing, unless handed off to
- * Concierge). Used by every surface that triggers a tracked template export so the modal wiring lives in one place.
- *
- * @param onCleanup - Optional extra cleanup to run once the modal is dismissed (e.g. clearing the selection).
- */
-function useExportDownloadStatusModal(onCleanup?: () => void): UseExportDownloadStatusModalReturn {
- const [activeExportID, setActiveExportID] = useState(undefined);
- const [activeExportDownload] = useOnyx(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${activeExportID}`);
-
- const handleExportModalClose = () => {
- // Keep the modal open while the export is still preparing (unless it was handed off to Concierge).
- if (activeExportDownload?.state === CONST.EXPORT_DOWNLOAD.STATE.PREPARING && !activeExportDownload?.shouldSendFromConcierge) {
- return;
- }
- // For the Concierge path the worker deletes the NVP after sending, so clearing it here would wipe
- // shouldSendFromConcierge before the worker reads it and the file would never reach Concierge.
- if (activeExportID && !activeExportDownload?.shouldSendFromConcierge) {
- clearExportDownload(activeExportID, activeExportDownload);
- }
- setActiveExportID(undefined);
- onCleanup?.();
- };
-
- const exportDownloadStatusModal = activeExportID ? (
-
- ) : null;
-
- return {trackExport: setActiveExportID, exportDownloadStatusModal};
-}
-
-export default useExportDownloadStatusModal;
diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts
index f55b9f5c1f22..37a81735c6c4 100644
--- a/src/hooks/useSearchBulkActions.ts
+++ b/src/hooks/useSearchBulkActions.ts
@@ -121,7 +121,6 @@ import useDelegateAccountID from './useDelegateAccountID';
import useDeleteTransactions from './useDeleteTransactions';
import useDuplicateTransactionsAndViolations from './useDuplicateTransactionsAndViolations';
import useEnvironment from './useEnvironment';
-import useExportDownloadStatusModal from './useExportDownloadStatusModal';
import {useMemoizedLazyExpensifyIcons} from './useLazyAsset';
import useLocalize from './useLocalize';
import useNetwork from './useNetwork';
@@ -494,10 +493,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
> | null>(null);
const [emptyReportsCount, setEmptyReportsCount] = useState(0);
- const {trackExport, exportDownloadStatusModal} = useExportDownloadStatusModal(() => {
- selectAllMatchingItems(false);
- clearSelectedTransactions(undefined, true);
- });
const [dismissedRejectUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION);
const [dismissedHoldUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION);
@@ -820,10 +815,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
return;
}
const serializedQuery = queryJSON ? serializeQueryJSONForBackend(queryJSON) : JSON.stringify(queryJSON);
- let exportID: string;
if (areAllMatchingItemsSelected) {
- exportID = queueExportSearchWithTemplate(
+ queueExportSearchWithTemplate(
{
templateName,
templateType,
@@ -837,7 +831,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
);
} else {
const isGroupExport = !!queryJSON?.groupBy && selectedTransactionsKeys.some((key) => key.startsWith(CONST.SEARCH.GROUP_PREFIX));
- exportID = queueExportSearchWithTemplate(
+ queueExportSearchWithTemplate(
{
templateName,
templateType,
@@ -855,7 +849,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
true,
);
}
- trackExport(exportID);
+ // Clear the selection now that the export has started. The ExportDownloadStatusManager shows the modal.
+ selectAllMatchingItems(false);
+ clearSelectedTransactions(undefined, true);
},
[
selectedReports,
@@ -866,7 +862,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
queryJSON,
selectedTransactionReportIDs,
selectedTransactionsKeys,
- trackExport,
+ selectAllMatchingItems,
+ clearSelectedTransactions,
],
);
@@ -947,7 +944,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
}
const reportIDList = selectedReports?.map((report) => report?.reportID).filter((reportID) => reportID !== undefined) ?? [];
const exportParameters = getCSVExportParameters(isBasicExport, allMatchingExportData?.queryJSON ?? queryJSON);
- const exportID = queueExportSearchItemsToCSV({
+ queueExportSearchItemsToCSV({
jsonQuery: exportParameters.jsonQuery,
reportIDList,
transactionIDList: selectedTransactionsKeys,
@@ -956,7 +953,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
exportColumnLabels: exportParameters.exportColumnLabels,
exportName,
});
- trackExport(exportID);
+ // Clear the selection now that the export has started. The ExportDownloadStatusManager shows the modal.
+ selectAllMatchingItems(false);
+ clearSelectedTransactions(undefined, true);
return;
}
@@ -1001,10 +1000,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
excludedTransactions,
translate,
clearSelectedTransactions,
+ selectAllMatchingItems,
hash,
currentSearchResults?.data,
getCSVExportParameters,
- trackExport,
],
);
@@ -2355,8 +2354,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
setIsPdfModalVisible(true);
return;
}
- const exportID = exportReportsToPDF(selectedReportIDs);
- trackExport(exportID);
+ exportReportsToPDF(selectedReportIDs);
+ // Clear the selection now that the export has started. The ExportDownloadStatusManager shows the modal.
+ selectAllMatchingItems(false);
+ clearSelectedTransactions(undefined, true);
},
});
}
@@ -2375,8 +2376,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
setIsOfflineModalVisible(true);
return;
}
- const exportID = exportReceiptsToZip({reportIDs: selectedReportIDs});
- trackExport(exportID);
+ exportReceiptsToZip({reportIDs: selectedReportIDs});
+ // Clear the selection now that the export has started. The ExportDownloadStatusManager shows the modal.
+ selectAllMatchingItems(false);
+ clearSelectedTransactions(undefined, true);
},
});
}
@@ -2402,8 +2405,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
setIsOfflineModalVisible(true);
return;
}
- const exportID = exportReceiptsToZip({transactionIDs});
- trackExport(exportID);
+ exportReceiptsToZip({transactionIDs});
+ // Clear the selection now that the export has started. The ExportDownloadStatusManager shows the modal.
+ selectAllMatchingItems(false);
+ clearSelectedTransactions(undefined, true);
},
});
}
@@ -2747,7 +2752,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
isProduction,
shouldOpenSplitExpenseEditFlowOnDelete,
styles.textWrap,
- trackExport,
+ selectAllMatchingItems,
allReportsShouldMarkAsDone,
noReportsShouldMarkAsDone,
queryJSON?.groupBy,
@@ -2828,7 +2833,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
handleExpensifyCardStatementPDFModalHide,
isExpensifyCardStatementMultiFeedAlertVisible,
handleExpensifyCardStatementMultiFeedAlertClose,
- exportDownloadStatusModal,
dismissModalAndUpdateUseHold,
dismissRejectModalBasedOnAction,
isDuplicateOptionVisible,
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index d33ff7a10b95..6a8b7f699371 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -1,5 +1,6 @@
import ComposeProviders from '@components/ComposeProviders';
import DelegateNoAccessModalProvider from '@components/DelegateNoAccessModalProvider';
+import ExportDownloadStatusManager from '@components/ExportDownloadStatusManager';
import GPSInProgressModal from '@components/GPSInProgressModal';
import GPSTripStateChecker from '@components/GPSTripStateChecker';
import {KeyboardDismissibleFlatListContextProvider} from '@components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext';
@@ -167,6 +168,7 @@ function AuthScreens() {
+
{
);
});
+ test('markExportDownloadSurfaced merges hasBeenSurfaced client-side without touching the rest of the record', async () => {
+ const exportID = 'test-export-surfaced';
+ const onyxKey = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}` as const;
+ await Onyx.set(onyxKey, {state: 'preparing', shouldSendFromConcierge: true});
+ await waitForBatchedUpdates();
+
+ Export.markExportDownloadSurfaced(exportID);
+ await waitForBatchedUpdates();
+
+ const value = await getOnyxValue(onyxKey);
+ expect(value).toEqual({state: 'preparing', shouldSendFromConcierge: true, hasBeenSurfaced: true});
+ });
+
test('clearExportDownload sets the Onyx key to null', async () => {
const exportID = 'test-export-789';
const onyxKey = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}` as const;
@@ -110,7 +123,7 @@ describe('Export actions', () => {
expect(value).toEqual(expect.objectContaining({state: 'failed'}));
});
- test('clearStaleExportDownloads clears ready/failed entries but preserves preparing ones', async () => {
+ test('clearStaleExportDownloads clears failed entries but keeps preparing and ready ones', async () => {
const key1 = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}stale-1` as const;
const key2 = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}stale-2` as const;
const key3 = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}stale-3` as const;
@@ -128,8 +141,34 @@ describe('Export actions', () => {
const value1 = await getOnyxValue(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}stale-1`);
const value2 = await getOnyxValue(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}stale-2`);
const value3 = await getOnyxValue(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}stale-3`);
- expect(value1).toBeUndefined();
+ expect(value1).toEqual(expect.objectContaining({state: 'ready'}));
expect(value2).toBeUndefined();
expect(value3).toEqual(expect.objectContaining({state: 'preparing'}));
});
+
+ test('clearStaleExportDownloads leaves a preparing Concierge hand-off untouched', async () => {
+ const key = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}stale-concierge` as const;
+ await Onyx.merge(key, {state: 'preparing', shouldSendFromConcierge: true});
+ await waitForBatchedUpdates();
+
+ Export.clearStaleExportDownloads();
+ await waitForBatchedUpdates();
+
+ // Concierge delivery is owned by the worker, which deletes the record when it is done, so the stale
+ // cleanup leaves the record as-is instead of clearing it.
+ const value = await getOnyxValue(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}stale-concierge`);
+ expect(value).toEqual({state: 'preparing', shouldSendFromConcierge: true});
+ });
+
+ test('clearStaleExportDownloads leaves a failed Concierge hand-off untouched', async () => {
+ const key = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}stale-concierge-failed` as const;
+ await Onyx.merge(key, {state: 'failed', shouldSendFromConcierge: true});
+ await waitForBatchedUpdates();
+
+ Export.clearStaleExportDownloads();
+ await waitForBatchedUpdates();
+
+ const value = await getOnyxValue(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}stale-concierge-failed`);
+ expect(value).toEqual({state: 'failed', shouldSendFromConcierge: true});
+ });
});
diff --git a/tests/unit/ExportDownloadStatusModalTest.tsx b/tests/unit/ExportDownloadStatusModalTest.tsx
index 23a8fa484cd3..8fc2efde9cfb 100644
--- a/tests/unit/ExportDownloadStatusModalTest.tsx
+++ b/tests/unit/ExportDownloadStatusModalTest.tsx
@@ -5,6 +5,7 @@ import ExportDownloadStatusModal from '@components/ExportDownloadStatusModal';
import fileDownload from '@libs/fileDownload';
import {clearExportDownload, sendExportFileFromConcierge} from '@userActions/Export';
+import * as Modal from '@userActions/Modal';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -24,6 +25,11 @@ jest.mock('@userActions/Export', () => ({
sendExportFileFromConcierge: jest.fn(),
clearExportDownload: jest.fn(),
}));
+jest.mock('@userActions/Modal', () => ({
+ ...jest.requireActual('@userActions/Modal'),
+ // Run the after-close callback synchronously so the test can assert what "Go to Concierge" opens next.
+ close: jest.fn((cb?: () => void) => cb?.()),
+}));
jest.mock('@libs/Navigation/Navigation', () => ({
navigate: jest.fn(),
isNavigationReady: jest.fn(() => Promise.resolve()),
@@ -48,10 +54,17 @@ jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({
__esModule: true,
default: () => ({accountID: 123, login: 'test@example.com'}),
}));
+const mockIsClientTheLeader = jest.fn(() => true);
+jest.mock('@libs/ActiveClientManager', () => ({
+ init: jest.fn(),
+ isReady: jest.fn(() => Promise.resolve()),
+ isClientTheLeader: () => mockIsClientTheLeader(),
+}));
const mockFileDownload = jest.mocked(fileDownload);
const mockSendFromConcierge = jest.mocked(sendExportFileFromConcierge);
const mockClearExportDownload = jest.mocked(clearExportDownload);
+const mockModalClose = jest.mocked(Modal.close);
const EXPORT_ID = 'test-export-123';
const CSV_FILE_NAME = 'export_2026-06-09_02-41-38_6a277d629c569.csv';
@@ -75,6 +88,7 @@ describe('ExportDownloadStatusModal', () => {
beforeEach(async () => {
jest.clearAllMocks();
+ mockIsClientTheLeader.mockReturnValue(true);
await Onyx.clear();
});
@@ -130,7 +144,7 @@ describe('ExportDownloadStatusModal', () => {
await waitForBatchedUpdatesWithAct();
const expectedURLPart = `secure?secureType=csvexport&filename=${encodeURIComponent(CSV_FILE_NAME)}&downloadName=${encodeURIComponent(CSV_FILE_NAME)}`;
- // shouldUnlink (arg 9) is left undefined so the platform default cleans up the temp file; appendTimestamp (arg 10) is false so the OS-recorded download time isn't duplicated in the name.
+ // shouldUnlink (arg 9) is left undefined so the platform default cleans up the temp file. appendTimestamp (arg 10) is false so the download time recorded by the OS is not duplicated in the name.
expect(mockFileDownload).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining(expectedURLPart),
@@ -152,7 +166,7 @@ describe('ExportDownloadStatusModal', () => {
await waitForBatchedUpdatesWithAct();
const expectedURLPart = `secure?secureType=pdfreport&filename=${encodeURIComponent(PDF_FILE_NAME)}&downloadName=${encodeURIComponent(PDF_FILE_NAME)}`;
- // shouldUnlink (arg 9) is left undefined so the platform default cleans up the temp file; appendTimestamp (arg 10) is false so the OS-recorded download time isn't duplicated in the name.
+ // shouldUnlink (arg 9) is left undefined so the platform default cleans up the temp file. appendTimestamp (arg 10) is false so the download time recorded by the OS is not duplicated in the name.
expect(mockFileDownload).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining(expectedURLPart),
@@ -167,6 +181,21 @@ describe('ExportDownloadStatusModal', () => {
);
});
+ it('does not auto-download on a non-leader tab, but the manual Download button still works', async () => {
+ mockIsClientTheLeader.mockReturnValue(false);
+ await Onyx.set(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${EXPORT_ID}`, {state: 'ready', fileName: CSV_FILE_NAME});
+
+ renderModal();
+ await waitForBatchedUpdatesWithAct();
+
+ // Only the leader tab auto-downloads, so a non-leader tab must not trigger a duplicate download.
+ expect(mockFileDownload).not.toHaveBeenCalled();
+
+ // The manual Download button is not leader-gated, so a deliberate click still downloads.
+ fireEvent.press(screen.getByText('exportDownload.downloadFile'));
+ expect(mockFileDownload).toHaveBeenCalled();
+ });
+
it('shows ready state with a Download button and no Close button', async () => {
await Onyx.set(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${EXPORT_ID}`, {state: 'ready', fileName: CSV_FILE_NAME});
@@ -176,7 +205,7 @@ describe('ExportDownloadStatusModal', () => {
expect(screen.getByText('exportDownload.readyTitle')).toBeTruthy();
expect(screen.getByText('exportDownload.readyBody')).toBeTruthy();
expect(screen.getByText('exportDownload.downloadFile')).toBeTruthy();
- // The Close button is removed in the ready state; the modal is dismissible and Download closes it.
+ // The Close button is removed in the ready state. The modal is dismissible and Download closes it.
expect(screen.queryByText('exportDownload.close')).toBeNull();
});
@@ -206,17 +235,17 @@ describe('ExportDownloadStatusModal', () => {
expect(screen.getByText('exportDownload.readyTitle')).toBeTruthy();
});
- it('"Go to Concierge" navigates and closes', async () => {
- const onClose = jest.fn();
+ it('"Go to Concierge" closes the modal and then opens the Concierge side panel', async () => {
await Onyx.set(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${EXPORT_ID}`, {state: 'preparing', shouldSendFromConcierge: true});
- renderModal({onClose});
+ renderModal();
await waitForBatchedUpdatesWithAct();
fireEvent.press(screen.getByText('exportDownload.goToConcierge'));
- expect(onClose).toHaveBeenCalled();
- expect(mockOpenConciergeAnywhere).toHaveBeenCalled();
+ // The side panel is opened through Modal.close's after-hide callback so it does not race the closing modal.
+ expect(mockModalClose).toHaveBeenCalled();
+ expect(mockOpenConciergeAnywhere).toHaveBeenCalledWith({forceConcierge: true});
});
it('shows partial failure body when failedReportCount > 0 in ready state', async () => {
diff --git a/tests/unit/hooks/useExportActionsTest.ts b/tests/unit/hooks/useExportActionsTest.ts
index 003334f6ac76..124fac2df11c 100644
--- a/tests/unit/hooks/useExportActionsTest.ts
+++ b/tests/unit/hooks/useExportActionsTest.ts
@@ -1,11 +1,13 @@
import {act, renderHook} from '@testing-library/react-native';
+import type * as SearchContextModule from '@components/Search/SearchContext';
+
import useExportActions from '@hooks/useExportActions';
import {queueExportSearchWithTemplate} from '@libs/actions/Search';
const mockQueueExportSearchWithTemplate = jest.mocked(queueExportSearchWithTemplate);
-const mockTrackExport = jest.fn();
+const mockClearSelectedTransactions = jest.fn();
const REPORT_ID = 'report1';
const POLICY_ID = 'policy1';
@@ -27,8 +29,9 @@ jest.mock('@libs/actions/Link', () => ({
openOldDotLink: jest.fn(),
}));
-jest.mock('@components/MoneyReportHeaderActions/ExportDownloadStatusProvider', () => ({
- useExportDownloadStatus: () => ({trackExport: mockTrackExport}),
+jest.mock('@components/Search/SearchContext', () => ({
+ ...jest.requireActual('@components/Search/SearchContext'),
+ useSearchSelectionActions: () => ({clearSelectedTransactions: mockClearSelectedTransactions}),
}));
let mockIsOffline = false;
@@ -113,7 +116,7 @@ describe('useExportActions - template export status modal', () => {
},
true,
);
- expect(mockTrackExport).toHaveBeenCalledWith('mock-export-id');
+ expect(mockClearSelectedTransactions).toHaveBeenCalledWith(true);
});
it('does not queue the export and shows the offline modal when offline', () => {
diff --git a/tests/unit/hooks/useExportDownloadStatusModalTest.ts b/tests/unit/hooks/useExportDownloadStatusModalTest.ts
deleted file mode 100644
index 8ea3d41b6188..000000000000
--- a/tests/unit/hooks/useExportDownloadStatusModalTest.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-import {act, renderHook} from '@testing-library/react-native';
-
-import useExportDownloadStatusModal from '@hooks/useExportDownloadStatusModal';
-
-import {clearExportDownload} from '@libs/actions/Export';
-
-import CONST from '@src/CONST';
-
-import type {ReactElement} from 'react';
-
-const mockClearExportDownload = jest.mocked(clearExportDownload);
-
-jest.mock('@libs/actions/Export', () => ({
- clearExportDownload: jest.fn(),
-}));
-
-jest.mock('@hooks/useLocalize', () => ({
- __esModule: true,
- default: () => ({translate: (key: string) => key}),
-}));
-
-let mockExportDownload: {state?: string; shouldSendFromConcierge?: boolean} | undefined;
-jest.mock('@hooks/useOnyx', () => ({
- __esModule: true,
- default: () => [mockExportDownload],
-}));
-
-type ExportDownloadStatusModalProps = {exportID: string; onClose: () => void};
-
-describe('useExportDownloadStatusModal', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- mockExportDownload = undefined;
- });
-
- it('renders no modal until an export is tracked', () => {
- const {result} = renderHook(() => useExportDownloadStatusModal());
- expect(result.current.exportDownloadStatusModal).toBeNull();
- });
-
- it('renders the status modal for the tracked export', () => {
- const {result} = renderHook(() => useExportDownloadStatusModal());
-
- act(() => {
- result.current.trackExport('export-1');
- });
-
- const modal: ReactElement | null = result.current.exportDownloadStatusModal;
- expect(modal?.props.exportID).toBe('export-1');
- });
-
- it('clears the download, runs cleanup and hides the modal on close', () => {
- const onCleanup = jest.fn();
- const {result} = renderHook(() => useExportDownloadStatusModal(onCleanup));
-
- act(() => {
- result.current.trackExport('export-1');
- });
- const modal: ReactElement | null = result.current.exportDownloadStatusModal;
-
- act(() => {
- modal?.props.onClose();
- });
-
- expect(mockClearExportDownload).toHaveBeenCalledWith('export-1', undefined);
- expect(onCleanup).toHaveBeenCalled();
- expect(result.current.exportDownloadStatusModal).toBeNull();
- });
-
- it('keeps the export NVP intact when sending via Concierge', () => {
- mockExportDownload = {state: CONST.EXPORT_DOWNLOAD.STATE.READY, shouldSendFromConcierge: true};
- const onCleanup = jest.fn();
- const {result} = renderHook(() => useExportDownloadStatusModal(onCleanup));
-
- act(() => {
- result.current.trackExport('export-1');
- });
- const modal: ReactElement | null = result.current.exportDownloadStatusModal;
-
- act(() => {
- modal?.props.onClose();
- });
-
- expect(mockClearExportDownload).not.toHaveBeenCalled();
- expect(onCleanup).toHaveBeenCalled();
- expect(result.current.exportDownloadStatusModal).toBeNull();
- });
-
- it('keeps the modal open and skips cleanup while the export is still preparing', () => {
- mockExportDownload = {state: CONST.EXPORT_DOWNLOAD.STATE.PREPARING};
- const onCleanup = jest.fn();
- const {result} = renderHook(() => useExportDownloadStatusModal(onCleanup));
-
- act(() => {
- result.current.trackExport('export-1');
- });
- const modal: ReactElement | null = result.current.exportDownloadStatusModal;
-
- act(() => {
- modal?.props.onClose();
- });
-
- expect(mockClearExportDownload).not.toHaveBeenCalled();
- expect(onCleanup).not.toHaveBeenCalled();
- expect(result.current.exportDownloadStatusModal).not.toBeNull();
- });
-});
diff --git a/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts b/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts
index 39418c32d0f0..37a87017a5a1 100644
--- a/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts
+++ b/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts
@@ -464,7 +464,6 @@ describe('useSearchBulkActions - Download as PDF', () => {
expect(exportReportsToPDF).toHaveBeenCalledTimes(1);
expect(exportReportsToPDF).toHaveBeenCalledWith(expect.arrayContaining(['1', '2']));
expect(exportReportToPDF).not.toHaveBeenCalled();
- expect(result.current.exportDownloadStatusModal).not.toBeNull();
});
it('should show Export as PDF for selected Expensify Card settlement groups', async () => {
diff --git a/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts b/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts
index 393d2917d9c8..aa269f37c8cf 100644
--- a/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts
+++ b/tests/unit/hooks/useSearchBulkActionsDownloadReceiptsTest.ts
@@ -316,7 +316,6 @@ describe('useSearchBulkActions - Download receipts', () => {
expect(exportReceiptsToZip).toHaveBeenCalledTimes(1);
expect(exportReceiptsToZip).toHaveBeenCalledWith({reportIDs: expect.arrayContaining(['1', '2'])});
- expect(result.current.exportDownloadStatusModal).not.toBeNull();
});
it('shows the offline modal and does not export when offline', async () => {
diff --git a/tests/unit/hooks/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts
index 4ea9bdd5099d..bd645da3d610 100644
--- a/tests/unit/hooks/useSearchBulkActionsTest.ts
+++ b/tests/unit/hooks/useSearchBulkActionsTest.ts
@@ -286,7 +286,6 @@ describe('useSearchBulkActions - CSV export flow', () => {
expect(mockQueueExportSearchItemsToCSV).toHaveBeenCalled();
expect(mockQueueExportSearchItemsToCSV).toHaveBeenCalledWith(expect.objectContaining({excludedTransactionIDList: ['tx2']}));
- expect(result.current.exportDownloadStatusModal).not.toBeNull();
});
it('exports an excluded unloaded group as a query filter instead of a transaction ID', async () => {
@@ -466,7 +465,6 @@ describe('useSearchBulkActions - CSV export flow', () => {
});
expect(mockQueueExportSearchItemsToCSV).not.toHaveBeenCalled();
- expect(result.current.exportDownloadStatusModal).toBeNull();
});
it('beginExportWithTemplate tracks the export', async () => {
@@ -492,7 +490,6 @@ describe('useSearchBulkActions - CSV export flow', () => {
});
expect(mockQueueExportSearchWithTemplate).toHaveBeenCalled();
- expect(result.current.exportDownloadStatusModal).not.toBeNull();
});
it('hides template exports when an all-matching expense selection has exclusions', async () => {