From 821ecd70118b0c6b6fb392024aba92ad5f79af76 Mon Sep 17 00:00:00 2001 From: Mohammad Luthfi Fathur Rahman Date: Mon, 10 Aug 2026 16:25:47 +0700 Subject: [PATCH 1/5] Keep ready exports on reload instead of clearing them --- src/libs/actions/Export.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/Export.ts b/src/libs/actions/Export.ts index 095cf818f97e..4213c7384351 100644 --- a/src/libs/actions/Export.ts +++ b/src/libs/actions/Export.ts @@ -67,7 +67,10 @@ function clearStaleExportDownloads() { } for (const key of Object.keys(exportDownloads)) { const exportDownload = exportDownloads[key]; - if (!exportDownload || exportDownload.state === CONST.EXPORT_DOWNLOAD.STATE.PREPARING) { + // Keep preparing and ready exports: preparing is still in flight, and ready is a finished file the + // user may not have seen yet (they closed or reloaded before it surfaced), so ExportDownloadStatusManager + // can re-surface it. Only failed leftovers are cleared here. + if (!exportDownload || exportDownload.state === CONST.EXPORT_DOWNLOAD.STATE.PREPARING || exportDownload.state === CONST.EXPORT_DOWNLOAD.STATE.READY) { continue; } const exportID = key.replace(ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD, ''); From 29c6b16ed3d9b43656efad72214368e9395e443c Mon Sep 17 00:00:00 2001 From: Mohammad Luthfi Fathur Rahman Date: Mon, 10 Aug 2026 16:25:48 +0700 Subject: [PATCH 2/5] Show export status in one app-level modal instead of per screen --- .../ExportDownloadStatusManager.tsx | 74 +++++++++++++++++++ src/components/MoneyReportHeader.tsx | 17 ++--- .../ExportDownloadStatusContext.tsx | 42 ----------- .../SelectionToolbar/index.tsx | 8 +- .../Search/SearchBulkActionsButton.tsx | 2 - src/hooks/useExportActions.ts | 13 ++-- src/hooks/useExportDownloadStatusModal.tsx | 56 -------------- src/hooks/useSearchBulkActions.ts | 34 ++++----- .../Navigation/AppNavigator/AuthScreens.tsx | 2 + 9 files changed, 110 insertions(+), 138 deletions(-) create mode 100644 src/components/ExportDownloadStatusManager.tsx delete mode 100644 src/components/MoneyReportHeaderActions/ExportDownloadStatusContext.tsx delete mode 100644 src/hooks/useExportDownloadStatusModal.tsx diff --git a/src/components/ExportDownloadStatusManager.tsx b/src/components/ExportDownloadStatusManager.tsx new file mode 100644 index 000000000000..b58740833bef --- /dev/null +++ b/src/components/ExportDownloadStatusManager.tsx @@ -0,0 +1,74 @@ +import useOnyx from '@hooks/useOnyx'; + +import {clearExportDownload} from '@libs/actions/Export'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React, {useState} 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); + + // Locally dismissed exports, so closing the modal hides them even when the record must stay in Onyx (the + // Concierge path, where the worker still needs to read it). A set, not a single ID, so several lingering + // records (e.g. multiple Concierge hand-offs) all stay hidden instead of taking turns re-surfacing. Reset on + // reload, so a genuinely leftover ready export still re-surfaces. + const [dismissedExportIDs, setDismissedExportIDs] = useState>(() => new Set()); + + const activeEntry = Object.entries(exportDownloads ?? {}).find(([key, exportDownload]) => { + const id = key.replace(ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD, ''); + return ( + !dismissedExportIDs.has(id) && + (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 = () => { + // The modal blocks dismissal while still preparing (unless handed to Concierge), so this is belt-and-suspenders. + if (exportDownload?.state === CONST.EXPORT_DOWNLOAD.STATE.PREPARING && !exportDownload?.shouldSendFromConcierge) { + return; + } + // Hide the modal locally. For the Concierge path the worker deletes the record after sending, so clearing + // it here would wipe shouldSendFromConcierge before the worker reads it; dismissing locally closes the + // modal (and lets "Go to Concierge" navigate) without touching the record. + setDismissedExportIDs((prev) => { + const next = new Set(prev); + next.add(exportID); + return next; + }); + if (!exportDownload?.shouldSendFromConcierge) { + clearExportDownload(exportID, exportDownload); + } + }; + + return ( + + ); +} + +ExportDownloadStatusManager.displayName = 'ExportDownloadStatusManager'; + +export default ExportDownloadStatusManager; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 36f579be3708..5de0d71169b1 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -27,7 +27,6 @@ import {View} from 'react-native'; import HeaderLoadingBar from './HeaderLoadingBar'; import HeaderWithBackButton from './HeaderWithBackButton'; import MoneyReportHeaderActions from './MoneyReportHeaderActions'; -import {ExportDownloadStatusProvider} from './MoneyReportHeaderActions/ExportDownloadStatusContext'; import MoneyReportHeaderModals from './MoneyReportHeaderModals'; import MoneyReportHeaderMoreContent from './MoneyReportHeaderMoreContent'; import MoneyRequestReportNavigation from './MoneyRequestReportView/MoneyRequestReportNavigation'; @@ -49,15 +48,13 @@ type MoneyReportHeaderProps = { function MoneyReportHeader({reportID, shouldDisplayBackButton = false, onBackButtonPress}: MoneyReportHeaderProps) { return ( - - - - - + + + ); } diff --git a/src/components/MoneyReportHeaderActions/ExportDownloadStatusContext.tsx b/src/components/MoneyReportHeaderActions/ExportDownloadStatusContext.tsx deleted file mode 100644 index 4a46ba784640..000000000000 --- a/src/components/MoneyReportHeaderActions/ExportDownloadStatusContext.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 2744b24afd24..4f0f6ded763b 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/ExportDownloadStatusContext'; import type {PopoverMenuItem} from '@components/PopoverMenu'; +import {useSearchSelectionActions} from '@components/Search/SearchContext'; import {exportReceiptsToZip} from '@libs/actions/Export'; import {openOldDotLink} from '@libs/actions/Link'; @@ -70,7 +70,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', @@ -116,7 +116,7 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa return; } - const exportID = queueExportSearchWithTemplate( + queueExportSearchWithTemplate( { templateName, templateType, @@ -128,7 +128,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> = { @@ -278,8 +279,8 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa if (!moneyRequestReport?.reportID) { return; } - const exportID = exportReceiptsToZip([moneyRequestReport.reportID]); - trackExport(exportID); + exportReceiptsToZip([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 1a1484172ce1..84ba54edf7b3 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -116,7 +116,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'; @@ -452,10 +451,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); @@ -742,10 +737,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } const serializedQuery = queryJSON ? serializeQueryJSONForBackend(queryJSON) : JSON.stringify(queryJSON); - let exportID: string; if (areAllMatchingItemsSelected) { - exportID = queueExportSearchWithTemplate( + queueExportSearchWithTemplate( { templateName, templateType, @@ -759,7 +753,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { ); } else { const isGroupExport = !!queryJSON?.groupBy && selectedTransactionsKeys.some((key) => key.startsWith(CONST.SEARCH.GROUP_PREFIX)); - exportID = queueExportSearchWithTemplate( + queueExportSearchWithTemplate( { templateName, templateType, @@ -777,7 +771,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { true, ); } - trackExport(exportID); + // Clear the selection now that the export has started; ExportDownloadStatusManager shows the modal. + selectAllMatchingItems(false); + clearSelectedTransactions(undefined, true); }, [ selectedReports, @@ -788,7 +784,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { queryJSON, selectedTransactionReportIDs, selectedTransactionsKeys, - trackExport, + selectAllMatchingItems, + clearSelectedTransactions, ], ); @@ -853,7 +850,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } const reportIDList = selectedReports?.map((report) => report?.reportID).filter((reportID) => reportID !== undefined) ?? []; const exportParameters = getCSVExportParameters(isBasicExport, queryJSON); - const exportID = queueExportSearchItemsToCSV({ + queueExportSearchItemsToCSV({ jsonQuery: exportParameters.jsonQuery, reportIDList, transactionIDList: selectedTransactionsKeys, @@ -861,7 +858,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { exportColumnLabels: exportParameters.exportColumnLabels, exportName, }); - trackExport(exportID); + // Clear the selection now that the export has started; ExportDownloadStatusManager shows the modal. + selectAllMatchingItems(false); + clearSelectedTransactions(undefined, true); return; } @@ -904,10 +903,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { selectedTransactionsKeys, translate, clearSelectedTransactions, + selectAllMatchingItems, hash, currentSearchResults?.data, getCSVExportParameters, - trackExport, ], ); @@ -2105,8 +2104,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; ExportDownloadStatusManager shows the modal. + selectAllMatchingItems(false); + clearSelectedTransactions(undefined, true); }, }); } @@ -2446,7 +2447,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { isProduction, shouldOpenSplitExpenseEditFlowOnDelete, styles.textWrap, - trackExport, + selectAllMatchingItems, allReportsShouldMarkAsDone, noReportsShouldMarkAsDone, queryJSON?.groupBy, @@ -2523,7 +2524,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() { + Date: Mon, 10 Aug 2026 16:25:49 +0700 Subject: [PATCH 3/5] Update export tests for the app-level status modal --- tests/unit/ExportActionsTest.ts | 4 +- tests/unit/hooks/useExportActionsTest.ts | 9 +- .../hooks/useExportDownloadStatusModalTest.ts | 107 ------------------ .../useSearchBulkActionsDownloadPDFTest.ts | 1 - tests/unit/hooks/useSearchBulkActionsTest.ts | 3 - 5 files changed, 7 insertions(+), 117 deletions(-) delete mode 100644 tests/unit/hooks/useExportDownloadStatusModalTest.ts diff --git a/tests/unit/ExportActionsTest.ts b/tests/unit/ExportActionsTest.ts index 6a2398887fbf..177b61f70fac 100644 --- a/tests/unit/ExportActionsTest.ts +++ b/tests/unit/ExportActionsTest.ts @@ -102,7 +102,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; @@ -120,7 +120,7 @@ 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'})); }); diff --git a/tests/unit/hooks/useExportActionsTest.ts b/tests/unit/hooks/useExportActionsTest.ts index 9c83f3e23625..c09606426476 100644 --- a/tests/unit/hooks/useExportActionsTest.ts +++ b/tests/unit/hooks/useExportActionsTest.ts @@ -5,7 +5,7 @@ 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 +27,9 @@ jest.mock('@libs/actions/Link', () => ({ openOldDotLink: jest.fn(), })); -jest.mock('@components/MoneyReportHeaderActions/ExportDownloadStatusContext', () => ({ - useExportDownloadStatus: () => ({trackExport: mockTrackExport}), +jest.mock('@components/Search/SearchContext', () => ({ + ...jest.requireActual('@components/Search/SearchContext'), + useSearchSelectionActions: () => ({clearSelectedTransactions: mockClearSelectedTransactions}), })); let mockIsOffline = false; @@ -113,7 +114,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/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts index 9d564379471a..ba4892bcafd8 100644 --- a/tests/unit/hooks/useSearchBulkActionsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsTest.ts @@ -258,7 +258,6 @@ describe('useSearchBulkActions - CSV export flow', () => { }); expect(mockQueueExportSearchItemsToCSV).toHaveBeenCalled(); - expect(result.current.exportDownloadStatusModal).not.toBeNull(); }); it('handleBasicExport with manual selection does not track any export', async () => { @@ -272,7 +271,6 @@ describe('useSearchBulkActions - CSV export flow', () => { }); expect(mockQueueExportSearchItemsToCSV).not.toHaveBeenCalled(); - expect(result.current.exportDownloadStatusModal).toBeNull(); }); it('beginExportWithTemplate tracks the export', async () => { @@ -294,7 +292,6 @@ describe('useSearchBulkActions - CSV export flow', () => { }); expect(mockQueueExportSearchWithTemplate).toHaveBeenCalled(); - expect(result.current.exportDownloadStatusModal).not.toBeNull(); } }); }); From 548a32e81b87f409bfcc042d67360d7bf5425472 Mon Sep 17 00:00:00 2001 From: Mohammad Luthfi Fathur Rahman Date: Thu, 13 Aug 2026 12:13:55 +0700 Subject: [PATCH 4/5] Skip Concierge hand-offs in the export status manager --- .../ExportDownloadStatusManager.tsx | 39 ++++++------------- src/components/ExportDownloadStatusModal.tsx | 32 +-------------- src/libs/actions/Export.ts | 7 +++- tests/unit/ExportActionsTest.ts | 14 +++++++ tests/unit/ExportDownloadStatusModalTest.tsx | 35 +---------------- 5 files changed, 34 insertions(+), 93 deletions(-) diff --git a/src/components/ExportDownloadStatusManager.tsx b/src/components/ExportDownloadStatusManager.tsx index b58740833bef..6ec31075c4f2 100644 --- a/src/components/ExportDownloadStatusManager.tsx +++ b/src/components/ExportDownloadStatusManager.tsx @@ -5,7 +5,7 @@ import {clearExportDownload} from '@libs/actions/Export'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import React, {useState} from 'react'; +import React from 'react'; import ExportDownloadStatusModal from './ExportDownloadStatusModal'; @@ -19,21 +19,16 @@ import ExportDownloadStatusModal from './ExportDownloadStatusModal'; function ExportDownloadStatusManager() { const [exportDownloads] = useOnyx(ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD); - // Locally dismissed exports, so closing the modal hides them even when the record must stay in Onyx (the - // Concierge path, where the worker still needs to read it). A set, not a single ID, so several lingering - // records (e.g. multiple Concierge hand-offs) all stay hidden instead of taking turns re-surfacing. Reset on - // reload, so a genuinely leftover ready export still re-surfaces. - const [dismissedExportIDs, setDismissedExportIDs] = useState>(() => new Set()); - - const activeEntry = Object.entries(exportDownloads ?? {}).find(([key, exportDownload]) => { - const id = key.replace(ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD, ''); - return ( - !dismissedExportIDs.has(id) && + // Skip Concierge hand-offs: once the user opts into Concierge delivery, the file (or the error) is delivered + // through the Concierge chat, so the modal has nothing left to show. Setting shouldSendFromConcierge is what + // closes the modal, and the worker deletes the record once it is done. + const activeEntry = Object.entries(exportDownloads ?? {}).find( + ([, exportDownload]) => + !exportDownload?.shouldSendFromConcierge && (exportDownload?.state === CONST.EXPORT_DOWNLOAD.STATE.PREPARING || exportDownload?.state === CONST.EXPORT_DOWNLOAD.STATE.READY || - exportDownload?.state === CONST.EXPORT_DOWNLOAD.STATE.FAILED) - ); - }); + exportDownload?.state === CONST.EXPORT_DOWNLOAD.STATE.FAILED), + ); if (!activeEntry) { return null; @@ -43,21 +38,11 @@ function ExportDownloadStatusManager() { const exportDownload = activeEntry[1]; const handleClose = () => { - // The modal blocks dismissal while still preparing (unless handed to Concierge), so this is belt-and-suspenders. - if (exportDownload?.state === CONST.EXPORT_DOWNLOAD.STATE.PREPARING && !exportDownload?.shouldSendFromConcierge) { + // The modal blocks dismissal while still preparing, so this is belt-and-suspenders. + if (exportDownload?.state === CONST.EXPORT_DOWNLOAD.STATE.PREPARING) { return; } - // Hide the modal locally. For the Concierge path the worker deletes the record after sending, so clearing - // it here would wipe shouldSendFromConcierge before the worker reads it; dismissing locally closes the - // modal (and lets "Go to Concierge" navigate) without touching the record. - setDismissedExportIDs((prev) => { - const next = new Set(prev); - next.add(exportID); - return next; - }); - if (!exportDownload?.shouldSendFromConcierge) { - clearExportDownload(exportID, exportDownload); - } + clearExportDownload(exportID, exportDownload); }; return ( diff --git a/src/components/ExportDownloadStatusModal.tsx b/src/components/ExportDownloadStatusModal.tsx index 086291969a4a..1021e25a7069 100644 --- a/src/components/ExportDownloadStatusModal.tsx +++ b/src/components/ExportDownloadStatusModal.tsx @@ -2,7 +2,6 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import useOpenConciergeAnywhere from '@hooks/useOpenConciergeAnywhere'; import usePreviousDefined from '@hooks/usePreviousDefined'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -64,7 +63,6 @@ function ExportDownloadStatusModal({exportID, isVisible, onClose, failedBody}: E const receiptCount = displayedExport?.receiptCount; const failedReceiptCount = displayedExport?.failedReceiptCount ?? 0; const isPreparing = state === CONST.EXPORT_DOWNLOAD.STATE.PREPARING && !shouldSendFromConcierge; - const isConcierge = !!shouldSendFromConcierge; const isReady = state === CONST.EXPORT_DOWNLOAD.STATE.READY; const isFailed = state === CONST.EXPORT_DOWNLOAD.STATE.FAILED; const isEmptyReceipts = isReady && exportType === CONST.EXPORT_DOWNLOAD.TYPE.RECEIPTS && receiptCount === 0; @@ -94,17 +92,11 @@ function ExportDownloadStatusModal({exportID, isVisible, onClose, failedBody}: E const handleSendFromConcierge = () => { sendExportFileFromConcierge(exportID, displayedExport ?? undefined); }; - const {openConciergeAnywhere} = useOpenConciergeAnywhere(); - - const handleGoToConcierge = () => { - onClose(); - openConciergeAnywhere({forceConcierge: true}); - }; const handleDownloadFile = () => { downloadFile(); - // Clearing the export download is owned by the parent's onClose handler (it runs on every dismissal and - // skips the clear for the Concierge path). Clearing here too would queue a duplicate ClearExportDownload write. + // Clearing the export download is owned by the parent's onClose handler (it runs on every dismissal). + // Clearing here too would queue a duplicate ClearExportDownload write. onClose(); }; @@ -128,26 +120,6 @@ function ExportDownloadStatusModal({exportID, isVisible, onClose, failedBody}: E ); } - if (isConcierge) { - return ( - <> - {translate('exportDownload.conciergeTitle')} - {translate('exportDownload.conciergeBody')} -