diff --git a/cspell.json b/cspell.json index e125e93d7579..2e5449e32cc1 100644 --- a/cspell.json +++ b/cspell.json @@ -1014,6 +1014,7 @@ "superapp", "superpowered", "supportal", + "surfaceable", "svgs", "symbolicate", "symbolicated", diff --git a/src/components/ExportDownloadStatusManager.tsx b/src/components/ExportDownloadStatusManager.tsx new file mode 100644 index 000000000000..9e92ed6d78dd --- /dev/null +++ b/src/components/ExportDownloadStatusManager.tsx @@ -0,0 +1,97 @@ +import useOnyx from '@hooks/useOnyx'; + +import {clearExportDownload, wasExportInitiatedLocally} 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 export status modal for the whole app. It watches the export collection and shows the modal + * for the active export (preparing or ready). 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. There is no per-screen modal to coordinate with. + */ +function ExportDownloadStatusManager() { + const [exportDownloads, exportDownloadsMetadata] = useOnyx(ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD); + const [openExportID, setOpenExportID] = useState(); + + // Record keys that already existed when this tab loaded. A reload re-reads the collection and re-seeds this, + // which is how an in-flight export resurfaces after a reload. A record that appears later because another + // tab started an export is not in here, so it does not pop the modal open in this tab until it reloads. + const [recordKeysAtLoad, setRecordKeysAtLoad] = useState | undefined>(undefined); + if (recordKeysAtLoad === undefined && exportDownloadsMetadata.status === 'loaded') { + setRecordKeysAtLoad(new Set(Object.keys(exportDownloads ?? {}))); + } + + const surfaceableCandidate = Object.entries(exportDownloads ?? {}).find(([key, exportDownload]) => { + if (!exportDownload) { + return false; + } + + // Concierge hand-off is owned by the BE worker: it delivers via the Concierge chat on success and posts + // a failure notice there too. There is nothing useful to show in a modal, so we never surface these. + if (exportDownload.shouldSendFromConcierge) { + return false; + } + const isSurfaceableState = exportDownload.state === CONST.EXPORT_DOWNLOAD.STATE.PREPARING || exportDownload.state === CONST.EXPORT_DOWNLOAD.STATE.READY; + if (!isSurfaceableState) { + return false; + } + + // Only surface an export this tab owns: one it started itself, or one that already existed when it + // loaded. An export started in another tab must not pop open here until this tab reloads. + const exportID = key.replace(ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD, ''); + return !!recordKeysAtLoad?.has(key) || wasExportInitiatedLocally(exportID); + }); + const [surfaceableKey] = surfaceableCandidate ?? []; + const surfaceableExportID = surfaceableKey?.replace(ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD, ''); + + // Adjust state to avoid a flash. openExportID keeps the modal open through the state changes + // the selector above skips (like the flip to shouldSendFromConcierge), and close it only once + // the record is gone. + if (!openExportID && surfaceableExportID) { + setOpenExportID(surfaceableExportID); + } + if (openExportID && !exportDownloads?.[`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${openExportID}`]) { + setOpenExportID(undefined); + } + + if (!openExportID) { + return null; + } + + const openExportKey = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${openExportID}`; + const exportDownload = exportDownloads?.[openExportKey]; + if (!exportDownload) { + return null; + } + + const handleClose = () => { + if (exportDownload.state === CONST.EXPORT_DOWNLOAD.STATE.PREPARING && !exportDownload.shouldSendFromConcierge) { + return; + } + if (exportDownload.shouldSendFromConcierge) { + setOpenExportID(undefined); + return; + } + clearExportDownload(openExportID, exportDownload); + setOpenExportID(undefined); + }; + + return ( + + ); +} + +ExportDownloadStatusManager.displayName = 'ExportDownloadStatusManager'; + +export default ExportDownloadStatusManager; diff --git a/src/components/ExportDownloadStatusModal.tsx b/src/components/ExportDownloadStatusModal.tsx index 2ea18e9b116b..7eba9c536a89 100644 --- a/src/components/ExportDownloadStatusModal.tsx +++ b/src/components/ExportDownloadStatusModal.tsx @@ -8,6 +8,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'; @@ -15,11 +16,15 @@ 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'; +import type {Session} from '@src/types/onyx'; -import React, {useEffect} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; + +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; import ActivityIndicator from './ActivityIndicator'; @@ -28,6 +33,8 @@ import Modal from './Modal'; import RenderHTML from './RenderHTML'; import Text from './Text'; +const selectEncryptedAuthToken = (session: OnyxEntry) => session?.encryptedAuthToken; + type ExportDownloadStatusModalProps = { /** The export ID to subscribe to */ exportID: string; @@ -51,10 +58,11 @@ function ExportDownloadStatusModal({exportID, isVisible, onClose, failedBody}: E const {login: currentUserLogin} = useCurrentUserPersonalDetails(); const {environment} = useEnvironment(); - const [encryptedAuthToken] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.encryptedAuthToken}); + const [encryptedAuthToken] = useOnyx(ONYXKEYS.SESSION, {selector: selectEncryptedAuthToken}); const [exportDownload] = useOnyx(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}`); const displayedExport = usePreviousDefined(exportDownload); + const wasRecordCleared = !exportDownload && !!displayedExport; const state = displayedExport?.state; const shouldSendFromConcierge = displayedExport?.shouldSendFromConcierge; @@ -70,9 +78,18 @@ function ExportDownloadStatusModal({exportID, isVisible, onClose, failedBody}: E const isFailed = state === CONST.EXPORT_DOWNLOAD.STATE.FAILED; const isEmptyReceipts = isReady && exportType === CONST.EXPORT_DOWNLOAD.TYPE.RECEIPTS && receiptCount === 0; + const wasPreparingRef = useRef(false); + useEffect(() => { + if (state !== CONST.EXPORT_DOWNLOAD.STATE.PREPARING) { + return; + } + + wasPreparingRef.current = true; + }, [state]); + // 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; @@ -85,7 +102,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() || !wasPreparingRef.current) { return; } downloadFile(); @@ -96,10 +114,14 @@ function ExportDownloadStatusModal({exportID, isVisible, onClose, failedBody}: E sendExportFileFromConcierge(exportID, displayedExport ?? undefined); }; const {openConciergeAnywhere} = useOpenConciergeAnywhere(); + const bottomSafeAreaPaddingStyle = useBottomSafeSafeAreaPaddingStyle({addBottomSafeAreaPadding: isSmallScreenWidth, addOfflineIndicatorBottomSafeAreaPadding: false, style: styles.m5}); + + if (wasRecordCleared) { + return null; + } const handleGoToConcierge = () => { - onClose(); - openConciergeAnywhere({forceConcierge: true}); + close(() => openConciergeAnywhere({forceConcierge: true})); }; const handleDownloadFile = () => { @@ -110,7 +132,6 @@ function ExportDownloadStatusModal({exportID, isVisible, onClose, failedBody}: E }; const isNonDismissible = isPreparing; - const bottomSafeAreaPaddingStyle = useBottomSafeSafeAreaPaddingStyle({addBottomSafeAreaPadding: isSmallScreenWidth, addOfflineIndicatorBottomSafeAreaPadding: false, style: styles.m5}); const renderContent = () => { if (isPreparing) { 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 543ccdd26909..139067f0de08 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,9 @@ 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 +238,6 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool return ( <> - {exportDownloadStatusModal} {isDuplicateOptionVisible && ( )} - {exportDownloadStatusModal} ); } diff --git a/src/hooks/useExportActions.ts b/src/hooks/useExportActions.ts index 75aea1317210..5647acc70bb3 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 {getAccountingIntegrationDisplayName} from '@libs/AccountingUtils'; import {exportReceiptsToZip} from '@libs/actions/Export'; @@ -85,7 +85,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', @@ -132,7 +132,7 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa return; } - const exportID = queueExportSearchWithTemplate( + queueExportSearchWithTemplate( { templateName, templateType, @@ -144,7 +144,9 @@ 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> = { @@ -297,8 +299,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 4df8839eaa8c..6e4ffa245a3a 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -124,7 +124,6 @@ import useDefaultExpensePolicy from './useDefaultExpensePolicy'; import useDelegateAccountID from './useDelegateAccountID'; import useDeleteTransactions from './useDeleteTransactions'; import useDuplicateTransactionsAndViolations from './useDuplicateTransactionsAndViolations'; -import useExportDownloadStatusModal from './useExportDownloadStatusModal'; import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; import useLocalize from './useLocalize'; import useNetwork from './useNetwork'; @@ -496,10 +495,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); @@ -833,10 +828,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } const serializedQuery = queryJSON ? serializeQueryJSONForBackend(queryJSON) : JSON.stringify(queryJSON); - let exportID: string; if (areAllMatchingItemsSelected) { - exportID = queueExportSearchWithTemplate( + queueExportSearchWithTemplate( { templateName, templateType, @@ -850,7 +844,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { ); } else { const isGroupExport = !!queryJSON?.groupBy && selectedTransactionsKeys.some((key) => key.startsWith(CONST.SEARCH.GROUP_PREFIX)); - exportID = queueExportSearchWithTemplate( + queueExportSearchWithTemplate( { templateName, templateType, @@ -868,7 +862,10 @@ 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, @@ -879,7 +876,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { queryJSON, selectedTransactionReportIDs, selectedTransactionsKeys, - trackExport, + selectAllMatchingItems, + clearSelectedTransactions, ], ); @@ -962,7 +960,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, @@ -971,7 +969,10 @@ 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; } @@ -1016,10 +1017,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { excludedTransactions, translate, clearSelectedTransactions, + selectAllMatchingItems, hash, currentSearchResults?.data, getCSVExportParameters, - trackExport, ], ); @@ -2413,8 +2414,11 @@ 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); }, }); } @@ -2433,8 +2437,11 @@ 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); }, }); } @@ -2460,8 +2467,11 @@ 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); }, }); } @@ -2793,7 +2803,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { openSearchReportSubmitToPopover, firstTransactionReport, styles.textWrap, - trackExport, + selectAllMatchingItems, allReportsShouldMarkAsDone, noReportsShouldMarkAsDone, queryJSON?.groupBy, @@ -2874,7 +2884,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 9f674c2f27d8..7b55de5ae473 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() { + (); + +function markExportInitiatedLocally(exportID: string) { + locallyInitiatedExportIDs.add(exportID); +} + +function wasExportInitiatedLocally(exportID: string): boolean { + return locallyInitiatedExportIDs.has(exportID); +} + function sendExportFileFromConcierge(exportID: string, exportDownload: OnyxEntry) { const onyxKey = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}` as const; @@ -34,6 +48,7 @@ function sendExportFileFromConcierge(exportID: string, exportDownload: OnyxEntry } function clearExportDownload(exportID: string, exportDownload: OnyxEntry) { + locallyInitiatedExportIDs.delete(exportID); const onyxKey = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}` as const; const optimisticData: AnyOnyxUpdate[] = [ @@ -67,7 +82,17 @@ function clearStaleExportDownloads() { } for (const key of Object.keys(exportDownloads)) { const exportDownload = exportDownloads[key]; - if (!exportDownload || exportDownload.state === CONST.EXPORT_DOWNLOAD.STATE.PREPARING) { + if (!exportDownload) { + continue; + } + + // Never clear a Concierge hand-off: the worker owns the record and deletes it once delivered. + if (exportDownload.shouldSendFromConcierge) { + continue; + } + + // Keep preparing and ready exports so the manager can re-surface them. Only failed leftovers are cleared here. + if (exportDownload.state === CONST.EXPORT_DOWNLOAD.STATE.PREPARING || exportDownload.state === CONST.EXPORT_DOWNLOAD.STATE.READY) { continue; } const exportID = key.replace(ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD, ''); @@ -97,6 +122,8 @@ function exportReportsToPDF(reportIDs: string[]): string { }, ]; + markExportInitiatedLocally(exportID); + write(WRITE_COMMANDS.EXPORT_REPORTS_TO_PDF, {reportIDs: JSON.stringify(reportIDs), exportID}, {optimisticData, failureData}); return exportID; @@ -122,6 +149,8 @@ function exportReceiptsToZip({reportIDs, transactionIDs}: {reportIDs?: string[]; }, ]; + markExportInitiatedLocally(exportID); + write( WRITE_COMMANDS.EXPORT_RECEIPTS_TO_ZIP, { @@ -135,4 +164,4 @@ function exportReceiptsToZip({reportIDs, transactionIDs}: {reportIDs?: string[]; return exportID; } -export {sendExportFileFromConcierge, clearExportDownload, clearStaleExportDownloads, exportReportsToPDF, exportReceiptsToZip}; +export {sendExportFileFromConcierge, clearExportDownload, clearStaleExportDownloads, markExportInitiatedLocally, wasExportInitiatedLocally, exportReportsToPDF, exportReceiptsToZip}; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index b85570730c5b..5f7420340b54 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -106,6 +106,7 @@ import Onyx from 'react-native-onyx'; import type {AdditionalPayOnyxData} from './IOU/PayMoneyRequest'; import type {RejectMoneyRequestData} from './IOU/RejectMoneyRequest'; +import {markExportInitiatedLocally} from './Export'; import {payMoneyRequest} from './IOU/PayMoneyRequest'; import {prepareRejectMoneyRequestData, rejectMoneyRequest} from './IOU/RejectMoneyRequest'; import {approveMoneyRequest} from './IOU/ReportWorkflow'; @@ -1889,6 +1890,8 @@ function queueExportSearchItemsToCSV({ exportID, }) as QueueExportSearchItemsToCSVParams; + markExportInitiatedLocally(exportID); + write(WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_ITEMS_TO_CSV, finalParameters, { optimisticData, failureData, @@ -1940,6 +1943,10 @@ function queueExportSearchWithTemplate( ...(shouldTrackExportProgress ? {exportID} : {}), }) as QueueExportSearchWithTemplateParams; + if (shouldTrackExportProgress) { + markExportInitiatedLocally(exportID); + } + write(WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_WITH_TEMPLATE, finalParameters, onyxData); return exportID; diff --git a/tests/unit/ExportActionsTest.ts b/tests/unit/ExportActionsTest.ts index f46137c047af..1f43c0f84238 100644 --- a/tests/unit/ExportActionsTest.ts +++ b/tests/unit/ExportActionsTest.ts @@ -110,7 +110,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 +128,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/ExportDownloadStatusManagerTest.tsx b/tests/unit/ExportDownloadStatusManagerTest.tsx new file mode 100644 index 000000000000..25cb62d63972 --- /dev/null +++ b/tests/unit/ExportDownloadStatusManagerTest.tsx @@ -0,0 +1,190 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import ExportDownloadStatusManager from '@components/ExportDownloadStatusManager'; + +import {clearExportDownload, sendExportFileFromConcierge, wasExportInitiatedLocally} from '@userActions/Export'; +import type * as Modal from '@userActions/Modal'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@libs/fileDownload'); +jest.mock('@components/RenderHTML', () => { + function MockRenderHTML({html}: {html: string}) { + return html; + } + return MockRenderHTML; +}); +jest.mock('@userActions/Export', () => ({ + sendExportFileFromConcierge: jest.fn(), + clearExportDownload: jest.fn(), + wasExportInitiatedLocally: jest.fn(() => false), +})); +jest.mock('@userActions/Modal', () => ({ + ...jest.requireActual('@userActions/Modal'), + close: jest.fn((cb?: () => void) => cb?.()), +})); +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + isTopmostRouteModalScreen: jest.fn(() => false), + getActiveRouteWithoutParams: jest.fn(() => ''), +})); +jest.mock('@hooks/useOpenConciergeAnywhere', () => ({ + __esModule: true, + default: () => ({ + openConciergeAnywhere: jest.fn(), + isInSidePanel: false, + }), +})); +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({ + translate: (key: string) => key, + }), +})); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ + __esModule: true, + default: () => ({accountID: 123, login: 'test@example.com'}), +})); +jest.mock('@libs/ActiveClientManager', () => ({ + init: jest.fn(), + isReady: jest.fn(() => Promise.resolve()), + isClientTheLeader: jest.fn(() => true), +})); + +const mockClearExportDownload = jest.mocked(clearExportDownload); +const mockSendFromConcierge = jest.mocked(sendExportFileFromConcierge); +const mockWasExportInitiatedLocally = jest.mocked(wasExportInitiatedLocally); + +const EXPORT_ID = 'test-export-123'; +const EXPORT_KEY = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${EXPORT_ID}` as const; +const CSV_FILE_NAME = 'export_2026-06-09_02-41-38_6a277d629c569.csv'; + +describe('ExportDownloadStatusManager', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + mockWasExportInitiatedLocally.mockReturnValue(false); + await Onyx.clear(); + }); + + it('renders the modal for a preparing export', async () => { + await Onyx.set(EXPORT_KEY, {state: 'preparing'}); + + render(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('exportDownload.preparingTitle')).toBeTruthy(); + }); + + it('renders the modal for a ready export', async () => { + await Onyx.set(EXPORT_KEY, {state: 'ready', fileName: CSV_FILE_NAME}); + + render(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('exportDownload.readyTitle')).toBeTruthy(); + }); + + it('renders nothing for a Concierge hand-off in any state (worker owns delivery and failure notice)', async () => { + await Onyx.set(EXPORT_KEY, {state: 'preparing', shouldSendFromConcierge: true}); + + render(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByText('exportDownload.conciergeTitle')).toBeNull(); + expect(screen.queryByText('exportDownload.preparingTitle')).toBeNull(); + }); + + it('renders nothing for a failed export (no dedicated UI for failed non-Concierge state)', async () => { + await Onyx.set(EXPORT_KEY, {state: 'failed'}); + + render(); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByText('exportDownload.failedTitle')).toBeNull(); + }); + + it('dismissing a preparing export is a no-op: does not clear the record or mark it surfaced', async () => { + await Onyx.set(EXPORT_KEY, {state: 'preparing'}); + + render(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByText('exportDownload.sendFromConcierge')); + expect(mockSendFromConcierge).toHaveBeenCalled(); + expect(mockClearExportDownload).not.toHaveBeenCalled(); + }); + + it('dismissing a ready export clears the underlying export', async () => { + await Onyx.set(EXPORT_KEY, {state: 'ready', fileName: CSV_FILE_NAME}); + + render(); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByText('exportDownload.downloadFile')); + + expect(mockClearExportDownload).toHaveBeenCalledWith(EXPORT_ID, expect.objectContaining({state: 'ready'})); + }); + + it('closing after hand-off to Concierge drops the modal without clearing the record', async () => { + await Onyx.set(EXPORT_KEY, {state: 'preparing'}); + render(); + await waitForBatchedUpdatesWithAct(); + + // Hand-off to Concierge + await Onyx.merge(EXPORT_KEY, {shouldSendFromConcierge: true}); + await waitForBatchedUpdatesWithAct(); + + // Trigger onClose and assert the record is preserved. + fireEvent.press(screen.getByText('exportDownload.dismiss')); + expect(mockClearExportDownload).not.toHaveBeenCalled(); + }); + + it('resets and resurfaces a new export after the tracked record is removed', async () => { + await Onyx.set(EXPORT_KEY, {state: 'preparing'}); + render(); + await waitForBatchedUpdatesWithAct(); + + await Onyx.set(EXPORT_KEY, null); + await waitForBatchedUpdatesWithAct(); + expect(screen.queryByText('exportDownload.readyTitle')).toBeNull(); + + const SECOND_KEY = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}second-export` as const; + mockWasExportInitiatedLocally.mockImplementation((id) => id === 'second-export'); + await Onyx.set(SECOND_KEY, {state: 'preparing'}); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByText('exportDownload.preparingTitle')).toBeTruthy(); + }); + + it('does not surface an export that appears after load when this tab did not start it', async () => { + // Tab loads with no export in Onyx, so nothing is in the at-load snapshot. + render(); + await waitForBatchedUpdatesWithAct(); + + // Another tab starts an export. It lands in this tab's Onyx, but this tab did not initiate it. + await Onyx.set(EXPORT_KEY, {state: 'preparing'}); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByText('exportDownload.preparingTitle')).toBeNull(); + }); + + it('surfaces an export this tab started even though it appears after load', async () => { + render(); + await waitForBatchedUpdatesWithAct(); + + mockWasExportInitiatedLocally.mockImplementation((id) => id === EXPORT_ID); + await Onyx.set(EXPORT_KEY, {state: 'preparing'}); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText('exportDownload.preparingTitle')).toBeTruthy(); + }); +}); diff --git a/tests/unit/ExportDownloadStatusModalTest.tsx b/tests/unit/ExportDownloadStatusModalTest.tsx index 23a8fa484cd3..7a06dd19b901 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(); }); @@ -124,13 +138,16 @@ describe('ExportDownloadStatusModal', () => { }); it('auto-downloads CSV on ready state transition with csvexport secureType', async () => { - await Onyx.set(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${EXPORT_ID}`, {state: 'ready', fileName: CSV_FILE_NAME}); - + // Mount while preparing, then transition to ready so the modal auto-downloads only after it watched the transition. + await Onyx.set(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${EXPORT_ID}`, {state: 'preparing'}); renderModal(); await waitForBatchedUpdatesWithAct(); + await Onyx.merge(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${EXPORT_ID}`, {state: 'ready', fileName: CSV_FILE_NAME}); + 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), @@ -146,13 +163,16 @@ describe('ExportDownloadStatusModal', () => { }); it('auto-downloads PDF on ready state transition with pdfreport secureType', async () => { - await Onyx.set(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${EXPORT_ID}`, {state: 'ready', fileName: PDF_FILE_NAME}); - + // Mount while preparing, then transition to ready so the modal auto-downloads only after it watched the transition. + await Onyx.set(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${EXPORT_ID}`, {state: 'preparing'}); renderModal(); await waitForBatchedUpdatesWithAct(); + await Onyx.merge(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${EXPORT_ID}`, {state: 'ready', fileName: PDF_FILE_NAME}); + 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 +187,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 +211,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(); }); @@ -192,7 +227,7 @@ describe('ExportDownloadStatusModal', () => { expect(screen.getByText('exportDownload.close')).toBeTruthy(); }); - it('retains last state when Onyx key becomes null', async () => { + it('renders nothing when the Onyx record is cleared from another tab', async () => { await Onyx.set(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${EXPORT_ID}`, {state: 'ready', fileName: CSV_FILE_NAME}); renderModal(); @@ -203,20 +238,20 @@ describe('ExportDownloadStatusModal', () => { await Onyx.set(`${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${EXPORT_ID}`, null); await waitForBatchedUpdatesWithAct(); - expect(screen.getByText('exportDownload.readyTitle')).toBeTruthy(); + expect(screen.queryByText('exportDownload.readyTitle')).toBeNull(); }); - 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 () => {