diff --git a/src/CONST/index.ts b/src/CONST/index.ts index c769079f27a3..5e29be66e8fe 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6474,6 +6474,11 @@ const CONST = { ERROR: 'error', INFO: 'info', }, + FILE_DOWNLOAD_STATUS: { + GENERATING: 'generating', + READY: 'ready', + FAILED: 'failed', + }, REPORT_DETAILS_MENU_ITEM: { GO_TO_ROOM: 'goToRoom', MEMBERS: 'member', diff --git a/src/components/ExpensifyCardStatementPDFDownloadModal.tsx b/src/components/ExpensifyCardStatementPDFDownloadModal.tsx index e53f6b447341..b43e9224761d 100644 --- a/src/components/ExpensifyCardStatementPDFDownloadModal.tsx +++ b/src/components/ExpensifyCardStatementPDFDownloadModal.tsx @@ -6,20 +6,24 @@ import useOnyx from '@hooks/useOnyx'; import {downloadExpensifyCardStatementPDF} from '@libs/ExpensifyCardStatementUtils'; import type {ExpensifyCardStatementParams} from '@libs/ExpensifyCardStatementUtils'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import React from 'react'; -import PDFDownloadModal from './PDFDownloadModal'; +import type {ModalProps} from './Modal/Global/ModalContext'; -type ExpensifyCardStatementPDFDownloadModalProps = { +import FileDownloadModalWrapper from './Modal/Global/FileDownloadModalWrapper'; + +type ExpensifyCardStatementPDFDownloadModalProps = ModalProps & { statementParams: ExpensifyCardStatementParams; - isVisible: boolean; - onClose: () => void; - onModalHide?: () => void; }; -function ExpensifyCardStatementPDFDownloadModal({statementParams, isVisible, onClose, onModalHide}: ExpensifyCardStatementPDFDownloadModalProps) { +/** + * Rendered by the global modal stack, so its Onyx subscription lives as long as the modal does. The caller that opened + * it can unmount while the statement is generating and the modal still picks up the file. + */ +function ExpensifyCardStatementPDFDownloadModal({statementParams, closeModal, resolveModal}: ExpensifyCardStatementPDFDownloadModalProps) { const [expensifyCardStatement] = useOnyx(ONYXKEYS.EXPENSIFY_CARD_STATEMENT); const [session] = useOnyx(ONYXKEYS.SESSION); const {translate} = useLocalize(); @@ -32,18 +36,17 @@ function ExpensifyCardStatementPDFDownloadModal({statementParams, isVisible, onC const isGeneratingPDF = expensifyCardStatement?.isGenerating === true; const statementFileName = statementKey ? expensifyCardStatement?.[statementKey] : undefined; const hasFinishedPDFDownload = !!statementKey && !isGeneratingPDF && typeof statementFileName === 'string' && statementFileName.length > 0; - const message = hasFinishedPDFDownload ? translate('reportDetailsPage.successPDF') : translate('reportDetailsPage.waitForPDF'); return ( - { + onDownload={() => { if (!statementKey || typeof statementFileName !== 'string') { return; } @@ -53,6 +56,5 @@ function ExpensifyCardStatementPDFDownloadModal({statementParams, isVisible, onC ); } -ExpensifyCardStatementPDFDownloadModal.displayName = 'ExpensifyCardStatementPDFDownloadModal'; - export default ExpensifyCardStatementPDFDownloadModal; +export type {ExpensifyCardStatementPDFDownloadModalProps}; diff --git a/src/components/PDFDownloadModal.tsx b/src/components/Modal/FileDownloadModal.tsx similarity index 67% rename from src/components/PDFDownloadModal.tsx rename to src/components/Modal/FileDownloadModal.tsx index 3de46f8088b3..3d153d58f5ee 100644 --- a/src/components/PDFDownloadModal.tsx +++ b/src/components/Modal/FileDownloadModal.tsx @@ -1,3 +1,10 @@ +import ActivityIndicator from '@components/ActivityIndicator'; +import Button from '@components/Button'; +import HeaderTitle from '@components/HeaderTitle'; +import Icon from '@components/Icon'; +import {PressableWithFeedback} from '@components/Pressable'; +import Text from '@components/Text'; + import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -7,34 +14,32 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; +import type {ValueOf} from 'type-fest'; + import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; -import ActivityIndicator from './ActivityIndicator'; -import Button from './Button'; -import HeaderTitle from './HeaderTitle'; -import Icon from './Icon'; -import Modal from './Modal'; -import {PressableWithFeedback} from './Pressable'; -import Text from './Text'; +import Modal from '.'; -type PDFDownloadModalProps = { - /** Whether the PDF has finished generating and is ready to download */ - hasFinishedPDFDownload: boolean; +type FileDownloadStatus = ValueOf; - /** Whether the PDF generation failed, so there is nothing left to wait for or download */ - hasPDFDownloadFailed?: boolean; +type FileDownloadModalProps = { + /** Header text shown at the top of the modal */ + title: string; + + /** Whether the file is still generating, ready to download, or failed to generate */ + status: FileDownloadStatus; /** Message shown under the header (generating / ready / error copy) */ message: string; - /** Downloads the generated PDF; called on auto-download and on the download button press */ - onDownloadPDF: () => void; + /** Downloads the generated file; called on auto-download and on the download button press */ + onDownload: () => void; /** Whether pressing the download button also closes the modal */ shouldCloseOnDownload?: boolean; - /** Whether the download button uses the success (green) style once the PDF is ready */ + /** Whether the download button uses the success (green) style once the file is ready */ shouldUseSuccessButton?: boolean; isVisible: boolean; @@ -50,23 +55,23 @@ type PDFDownloadModalProps = { }; /** - * Shared modal for OldDot-generated PDF downloads (report PDFs, Expensify Card statements, etc.). It shows a - * generating/ready state, auto-downloads once generation finishes while the modal is open, and offers a manual - * download button. Callers supply the status, copy, and download action for their specific PDF. + * Shared modal for server-generated file downloads (report PDFs, Expensify Card statements, etc.). It shows a + * generating/ready/failed state, auto-downloads once generation finishes while the modal is open, and offers a manual + * download button. Callers supply the status, copy, and download action for their specific file. */ -function PDFDownloadModal({ - hasFinishedPDFDownload, - hasPDFDownloadFailed = false, +function FileDownloadModal({ + title, + status, message, - onDownloadPDF, + onDownload, shouldCloseOnDownload = false, shouldUseSuccessButton = false, isVisible, shouldTreatModalAsCovering = false, onClose, onModalHide, -}: PDFDownloadModalProps) { - const shouldAutoDownloadPDF = useRef(false); +}: FileDownloadModalProps) { + const shouldAutoDownload = useRef(false); const {translate} = useLocalize(); // We need to use isSmallScreenWidth here because the Modal breaks in RHP with shouldUseNarrowLayout. @@ -76,17 +81,20 @@ function PDFDownloadModal({ const theme = useTheme(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Close']); + const isReady = status === CONST.FILE_DOWNLOAD_STATUS.READY; + const hasFailed = status === CONST.FILE_DOWNLOAD_STATUS.FAILED; + useEffect(() => { - shouldAutoDownloadPDF.current = isVisible; + shouldAutoDownload.current = isVisible; }, [isVisible]); useEffect(() => { - if (!isVisible || !shouldAutoDownloadPDF.current || !hasFinishedPDFDownload) { + if (!isVisible || !shouldAutoDownload.current || !isReady) { return; } - onDownloadPDF(); - shouldAutoDownloadPDF.current = false; - }, [hasFinishedPDFDownload, isVisible, onDownloadPDF]); + onDownload(); + shouldAutoDownload.current = false; + }, [isReady, isVisible, onDownload]); const bottomSafeAreaPaddingStyle = useBottomSafeSafeAreaPaddingStyle({ addBottomSafeAreaPadding: isSmallScreenWidth, @@ -110,13 +118,13 @@ function PDFDownloadModal({ - {translate('reportDetailsPage.generatingPDF')} + {title} {message} - {!hasFinishedPDFDownload && !hasPDFDownloadFailed && ( + {status === CONST.FILE_DOWNLOAD_STATUS.GENERATING && ( setIsVisible(false)} - onModalHide={() => { - if (isVisible) { - return; - } - closeModal({action: ModalActions.CLOSE}); - }} - /> - ); -} - -export default ExpensifyCardStatementPDFDownloadModalWrapper; -export type {ExpensifyCardStatementPDFDownloadModalWrapperProps}; diff --git a/src/components/Modal/Global/FileDownloadModalWrapper.tsx b/src/components/Modal/Global/FileDownloadModalWrapper.tsx new file mode 100644 index 000000000000..4d729fdd8e30 --- /dev/null +++ b/src/components/Modal/Global/FileDownloadModalWrapper.tsx @@ -0,0 +1,58 @@ +import type {FileDownloadModalProps} from '@components/Modal/FileDownloadModal'; +import FileDownloadModal from '@components/Modal/FileDownloadModal'; + +import CONST from '@src/CONST'; + +import type {ValueOf} from 'type-fest'; + +import React, {useRef, useState} from 'react'; + +import type {ModalProps} from './ModalContext'; + +import {ModalActions} from './ModalContext'; + +type FileDownloadModalWrapperProps = ModalProps & Omit; + +function FileDownloadModalWrapper({closeModal, resolveModal, ...props}: FileDownloadModalWrapperProps) { + const [isVisible, setIsVisible] = useState(true); + + // The props at the moment the user dismissed the modal. The props keep changing as the file generates until the + // modal has finished hiding, so render these instead to keep a dismissed modal from changing while it fades out. + const [dismissedProps, setDismissedProps] = useState(); + const closeActionRef = useRef>(ModalActions.CLOSE); + const hasClosedModalRef = useRef(false); + + const handleClose = () => { + if (!isVisible) { + return; + } + + // Dismissing while the file is still generating cancels the download. Once it is ready or has failed, the + // flow has run its course. + closeActionRef.current = props.status === CONST.FILE_DOWNLOAD_STATUS.GENERATING ? ModalActions.CLOSE : ModalActions.CONFIRM; + setDismissedProps(props); + setIsVisible(false); + }; + + // Remove the modal from the stack only once it has finished hiding, so callers awaiting the show promise run + // their cleanup after the modal is gone. + const handleModalHide = () => { + if (isVisible || hasClosedModalRef.current) { + return; + } + hasClosedModalRef.current = true; + closeModal({action: closeActionRef.current}); + }; + + return ( + + ); +} + +export default FileDownloadModalWrapper; +export type {FileDownloadModalWrapperProps}; diff --git a/src/components/Modal/Global/ModalContext.tsx b/src/components/Modal/Global/ModalContext.tsx index 2fcd46d7b7cb..deccf915cc89 100644 --- a/src/components/Modal/Global/ModalContext.tsx +++ b/src/components/Modal/Global/ModalContext.tsx @@ -173,5 +173,5 @@ function ModalProvider({children}: {children: React.ReactNode}) { ); } -export type {ModalProps}; +export type {ModalProps, ModalStateChangePayload}; export {ModalProvider, useModal, ModalActions}; diff --git a/src/components/Modal/Global/ReportPDFDownloadModalWrapper.tsx b/src/components/Modal/Global/ReportPDFDownloadModalWrapper.tsx deleted file mode 100644 index 8fc1ce4b33b4..000000000000 --- a/src/components/Modal/Global/ReportPDFDownloadModalWrapper.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import ReportPDFDownloadModal from '@components/ReportPDFDownloadModal'; - -import React, {useState} from 'react'; - -import type {ModalProps} from './ModalContext'; - -import {ModalActions} from './ModalContext'; - -type ReportPDFDownloadModalWrapperProps = ModalProps & { - reportID: string; -}; - -function ReportPDFDownloadModalWrapper({closeModal, reportID}: ReportPDFDownloadModalWrapperProps) { - const [isVisible, setIsVisible] = useState(true); - - return ( - setIsVisible(false)} - onModalHide={() => { - if (isVisible) { - return; - } - closeModal({action: ModalActions.CLOSE}); - }} - /> - ); -} - -export default ReportPDFDownloadModalWrapper; -export type {ReportPDFDownloadModalWrapperProps}; diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 866ead5ca229..1590cfa78d83 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -394,7 +394,9 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo const {exportActionEntries} = useExportActions({ reportID, policy, - onPDFModalOpen: openPDFDownload, + onPDFModalOpen: () => { + openPDFDownload(); + }, }); const shareActions = useShareActions({reportID, policy}); diff --git a/src/components/MoneyReportHeaderModals.tsx b/src/components/MoneyReportHeaderModals.tsx index f9a2681f2085..7821666a916b 100644 --- a/src/components/MoneyReportHeaderModals.tsx +++ b/src/components/MoneyReportHeaderModals.tsx @@ -5,6 +5,7 @@ import useHoldMenuModal from '@hooks/useHoldMenuModal'; import useMoneyReportHeaderEducationalModals from '@hooks/useMoneyReportHeaderEducationalModals'; import useOnyx from '@hooks/useOnyx'; import useReportIsArchived from '@hooks/useReportIsArchived'; +import useReportPDFDownloadModal from '@hooks/useReportPDFDownloadModal'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -16,13 +17,12 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ReactNode} from 'react'; -import React, {useRef, useState} from 'react'; +import React from 'react'; import type {HoldMenuParams} from './MoneyReportHeaderModalsContext'; import MoneyReportHeaderModalsContext from './MoneyReportHeaderModalsContext'; import {MoneyReportTransactionThreadProvider} from './MoneyReportTransactionThreadContext'; -import ReportPDFDownloadModal from './ReportPDFDownloadModal'; type MoneyReportHeaderModalsProps = { reportID: string | undefined; @@ -41,11 +41,6 @@ function MoneyReportHeaderModals({reportID, children}: MoneyReportHeaderModalsPr /** Rendered inside the transaction thread provider because the educational modals' follow-up actions need the transaction thread */ function MoneyReportHeaderModalsContent({reportID, children}: MoneyReportHeaderModalsProps) { - // PDF modal state - const [isPDFModalVisible, setIsPDFModalVisible] = useState(false); - // Callback invoked when the PDF modal is dismissed while still generating (e.g. Submit via PDF retracts the submit). - const onPDFCancelRef = useRef<(() => void) | undefined>(undefined); - // Fetch data from IDs const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); @@ -70,6 +65,7 @@ function MoneyReportHeaderModalsContent({reportID, children}: MoneyReportHeaderM // Imperative modals const {showHoldMenu} = useHoldMenuModal(); const {showOfflineModal, showDownloadErrorModal} = useAlertModals(); + const {showReportPDFDownloadModal} = useReportPDFDownloadModal(); const {openHoldEducational, openRejectModal} = useMoneyReportHeaderEducationalModals(moneyRequestReport?.reportID); const openHoldMenu = ({requestType, paymentType, methodID, onConfirm}: HoldMenuParams): Promise => { @@ -101,9 +97,11 @@ function MoneyReportHeaderModalsContent({reportID, children}: MoneyReportHeaderM const contextValue = { openHoldMenu, - openPDFDownload: (options?: {onCancel?: () => void}) => { - onPDFCancelRef.current = options?.onCancel; - setIsPDFModalVisible(true); + openPDFDownload: () => { + if (!moneyRequestReport?.reportID) { + return Promise.resolve(undefined); + } + return showReportPDFDownloadModal({reportID: moneyRequestReport.reportID}); }, openHoldEducational, openRejectModal, @@ -111,18 +109,7 @@ function MoneyReportHeaderModalsContent({reportID, children}: MoneyReportHeaderM showDownloadErrorModal, }; - return ( - - {children} - - setIsPDFModalVisible(false)} - onCancel={() => onPDFCancelRef.current?.()} - /> - - ); + return {children}; } export default MoneyReportHeaderModals; diff --git a/src/components/MoneyReportHeaderModalsContext.tsx b/src/components/MoneyReportHeaderModalsContext.tsx index 417bbf7a7f53..6f4dba421fda 100644 --- a/src/components/MoneyReportHeaderModalsContext.tsx +++ b/src/components/MoneyReportHeaderModalsContext.tsx @@ -5,6 +5,8 @@ import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import {createContext, useContext} from 'react'; +import type {ModalStateChangePayload} from './Modal/Global/ModalContext'; + type HoldMenuParams = { requestType?: ActionHandledType; paymentType?: PaymentMethodType; @@ -14,7 +16,8 @@ type HoldMenuParams = { type MoneyReportHeaderModalsContextValue = { openHoldMenu: (params: HoldMenuParams) => Promise; - openPDFDownload: (options?: {onCancel?: () => void}) => void; + /** Resolves once the PDF download modal has finished hiding, with `CLOSE` if it was dismissed while the PDF was still generating */ + openPDFDownload: () => Promise; openHoldEducational: () => void; openRejectModal: (action: RejectModalAction) => void; showOfflineModal: () => void; @@ -23,7 +26,7 @@ type MoneyReportHeaderModalsContextValue = { const defaultValue: MoneyReportHeaderModalsContextValue = { openHoldMenu: () => Promise.resolve(), - openPDFDownload: () => {}, + openPDFDownload: () => Promise.resolve(undefined), openHoldEducational: () => {}, openRejectModal: () => {}, showOfflineModal: () => {}, diff --git a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx index 58dde9b9352d..6e98feaed827 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/SubmitPrimaryAction.tsx @@ -1,6 +1,7 @@ import AnimatedSubmitButton from '@components/AnimatedSubmitButton'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import {useMoneyReportHeaderModals} from '@components/MoneyReportHeaderModalsContext'; import {usePaymentAnimationsContext} from '@components/PaymentAnimationsContext'; import {ReportSubmitToPopoverAnchor, useOpenReportSubmitToPopover} from '@components/ReportSubmitToPopoverAnchor'; @@ -184,24 +185,15 @@ function SubmitPrimaryActionContent({reportID, isDisabled}: SubmitPrimaryActionP if (shouldExportToPDF) { // If the user cancels while the PDF is still generating, discard the submission (retract it back // to draft) so they can resubmit — matching the pre-submit-via-PDF behavior. - openPDFDownload({ - onCancel: () => { - // Stop the "Submitted" animation in lockstep with the retract so the header goes straight - // back to the Submit button instead of finishing the animation on a report that is open again. - stopAnimation(); - retractReport( - moneyRequestReport, - chatReport, - policy, - accountID, - email ?? '', - hasViolations, - isASAPSubmitBetaEnabled, - delegateEmail, - isTrackIntentUser, - rules, - ); - }, + openPDFDownload().then((result) => { + if (result?.action !== ModalActions.CLOSE) { + return; + } + + // Stop the "Submitted" animation in lockstep with the retract so the header goes straight + // back to the Submit button instead of finishing the animation on a report that is open again. + stopAnimation(); + retractReport(moneyRequestReport, chatReport, policy, accountID, email ?? '', hasViolations, isASAPSubmitBetaEnabled, delegateEmail, isTrackIntentUser, rules); }); } }, diff --git a/src/components/ReportPDFDownloadModal.tsx b/src/components/ReportPDFDownloadModal.tsx index 4b84f2c07186..6bcc4f4e75d4 100644 --- a/src/components/ReportPDFDownloadModal.tsx +++ b/src/components/ReportPDFDownloadModal.tsx @@ -11,19 +11,19 @@ import ONYXKEYS from '@src/ONYXKEYS'; import React from 'react'; -import PDFDownloadModal from './PDFDownloadModal'; +import type {ModalProps} from './Modal/Global/ModalContext'; -type ReportPDFDownloadModalProps = { - reportID: string | undefined; - isVisible: boolean; - onClose: () => void; - onModalHide?: () => void; +import FileDownloadModalWrapper from './Modal/Global/FileDownloadModalWrapper'; - /** Called when the modal is dismissed while the PDF is still generating (e.g. Submit via PDF retracts the submit). */ - onCancel?: () => void; +type ReportPDFDownloadModalProps = ModalProps & { + reportID: string; }; -function ReportPDFDownloadModal({reportID, isVisible, onClose, onModalHide, onCancel}: ReportPDFDownloadModalProps) { +/** + * Rendered by the global modal stack, so its Onyx subscriptions live as long as the modal does. The caller that opened + * it can unmount while the PDF is generating and the modal still picks up the filename. + */ +function ReportPDFDownloadModal({reportID, closeModal, resolveModal}: ReportPDFDownloadModalProps) { const [reportPDFFilename] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_EXPENSIFY_REPORT_PDF_FILENAME}${reportID}`); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [session] = useOnyx(ONYXKEYS.SESSION); @@ -43,7 +43,7 @@ function ReportPDFDownloadModal({reportID, isVisible, onClose, onModalHide, onCa // Excluded while offline: the request is legitimately queued and waiting for connectivity, not stuck. // Keyed by reportID (not just a boolean) so if this modal is ever reused for a different report without // unmounting, the timer restarts against the new report instead of firing late and blaming the wrong one. - useStallLogger(isVisible && !isOffline && !reportPDFFilename ? reportID : false, '[PDFStall] reportPDFFilename never resolved to a filename or error while the download modal was open', { + useStallLogger(!isOffline && !reportPDFFilename ? reportID : false, '[PDFStall] reportPDFFilename never resolved to a filename or error while the download modal was open', { reportID, }); @@ -57,25 +57,24 @@ function ReportPDFDownloadModal({reportID, isVisible, onClose, onModalHide, onCa return translate('reportDetailsPage.successPDF'); })(); - // reportPDFFilename is null while the backend is still generating, so a dismissal at that point is a cancel - // (e.g. Submit via PDF retracts the submit); once it holds a filename or 'error' the submit has already resolved. - const handleClose = () => { - if (!reportPDFFilename) { - onCancel?.(); + // reportPDFFilename is null while the backend is still generating, so the wrapper reports a dismissal at that point + // as a cancel (e.g. Submit via PDF retracts the submit). Once it holds a filename or 'error' the submit has resolved. + const status = (() => { + if (hasPDFDownloadFailed) { + return CONST.FILE_DOWNLOAD_STATUS.FAILED; } - onClose(); - }; + return hasFinishedPDFDownload ? CONST.FILE_DOWNLOAD_STATUS.READY : CONST.FILE_DOWNLOAD_STATUS.GENERATING; + })(); return ( - { + shouldTreatModalAsCovering + onDownload={() => { if (!hasFinishedPDFDownload) { return; } @@ -85,6 +84,5 @@ function ReportPDFDownloadModal({reportID, isVisible, onClose, onModalHide, onCa ); } -ReportPDFDownloadModal.displayName = 'ReportPDFDownloadModal'; - export default ReportPDFDownloadModal; +export type {ReportPDFDownloadModalProps}; diff --git a/src/hooks/useExpensifyCardStatementPDFDownloadModal.ts b/src/hooks/useExpensifyCardStatementPDFDownloadModal.ts index cfbb727770e5..db9fadd2f786 100644 --- a/src/hooks/useExpensifyCardStatementPDFDownloadModal.ts +++ b/src/hooks/useExpensifyCardStatementPDFDownloadModal.ts @@ -1,9 +1,9 @@ -import type {ExpensifyCardStatementPDFDownloadModalWrapperProps} from '@components/Modal/Global/ExpensifyCardStatementPDFDownloadModalWrapper'; -import ExpensifyCardStatementPDFDownloadModalWrapper from '@components/Modal/Global/ExpensifyCardStatementPDFDownloadModalWrapper'; -import type {ModalProps} from '@components/Modal/Global/ModalContext'; +import ExpensifyCardStatementPDFDownloadModal from '@components/ExpensifyCardStatementPDFDownloadModal'; +import type {ExpensifyCardStatementPDFDownloadModalProps} from '@components/ExpensifyCardStatementPDFDownloadModal'; import {useModal} from '@components/Modal/Global/ModalContext'; +import type {ModalProps} from '@components/Modal/Global/ModalContext'; -type ExpensifyCardStatementPDFDownloadModalOptions = Omit; +type ExpensifyCardStatementPDFDownloadModalOptions = Omit; // A fixed id lets a second show call update the open modal in place, which is how the statementKey reaches it // once GetExpensifyCardStatementPDF returns. @@ -12,13 +12,9 @@ const EXPENSIFY_CARD_STATEMENT_PDF_DOWNLOAD_MODAL_ID = 'expensifyCardStatementPD const useExpensifyCardStatementPDFDownloadModal = () => { const context = useModal(); - const showExpensifyCardStatementPDFDownloadModal = (options: ExpensifyCardStatementPDFDownloadModalOptions) => { - return context.showModal({ - component: ExpensifyCardStatementPDFDownloadModalWrapper, - id: EXPENSIFY_CARD_STATEMENT_PDF_DOWNLOAD_MODAL_ID, - props: options, - }); - }; + /** Resolves once the modal has finished hiding, with `CLOSE` if it was dismissed while the statement was still generating. */ + const showExpensifyCardStatementPDFDownloadModal = (options: ExpensifyCardStatementPDFDownloadModalOptions) => + context.showModal({component: ExpensifyCardStatementPDFDownloadModal, id: EXPENSIFY_CARD_STATEMENT_PDF_DOWNLOAD_MODAL_ID, props: options}); const closeExpensifyCardStatementPDFDownloadModal = () => context.closeModalByID(EXPENSIFY_CARD_STATEMENT_PDF_DOWNLOAD_MODAL_ID); diff --git a/src/hooks/useReportPDFDownloadModal.ts b/src/hooks/useReportPDFDownloadModal.ts index ea08357b1b47..f69c77beea8a 100644 --- a/src/hooks/useReportPDFDownloadModal.ts +++ b/src/hooks/useReportPDFDownloadModal.ts @@ -1,19 +1,15 @@ -import type {ModalProps} from '@components/Modal/Global/ModalContext'; import {useModal} from '@components/Modal/Global/ModalContext'; -import type {ReportPDFDownloadModalWrapperProps} from '@components/Modal/Global/ReportPDFDownloadModalWrapper'; -import ReportPDFDownloadModalWrapper from '@components/Modal/Global/ReportPDFDownloadModalWrapper'; +import type {ModalProps} from '@components/Modal/Global/ModalContext'; +import ReportPDFDownloadModal from '@components/ReportPDFDownloadModal'; +import type {ReportPDFDownloadModalProps} from '@components/ReportPDFDownloadModal'; -type ReportPDFDownloadModalOptions = Omit; +type ReportPDFDownloadModalOptions = Omit; const useReportPDFDownloadModal = () => { const context = useModal(); - const showReportPDFDownloadModal = (options: ReportPDFDownloadModalOptions) => { - return context.showModal({ - component: ReportPDFDownloadModalWrapper, - props: options, - }); - }; + /** Resolves once the modal has finished hiding, with `CLOSE` if it was dismissed while the PDF was still generating. */ + const showReportPDFDownloadModal = (options: ReportPDFDownloadModalOptions) => context.showModal({component: ReportPDFDownloadModal, props: options}); return {showReportPDFDownloadModal}; }; diff --git a/src/libs/actions/IOU/ReportWorkflow.ts b/src/libs/actions/IOU/ReportWorkflow.ts index 2225dab6fa7c..02c0cc8c780a 100644 --- a/src/libs/actions/IOU/ReportWorkflow.ts +++ b/src/libs/actions/IOU/ReportWorkflow.ts @@ -1312,7 +1312,7 @@ function submitReport({ // Submit via PDF: on a Submit workspace where the submitter submits to themselves, the backend generates the // report PDF and writes its filename into nvp_expensify_report_PDFFilename_{reportID}. Prime that NVP the same way - // exportReportToPDF does so the ReportPDFDownloadModal shows progress and auto-downloads once the filename arrives. + // exportReportToPDF does so the report PDF download modal shows progress and auto-downloads once the filename arrives. if (shouldExportToPDF) { optimisticData.push({ onyxMethod: Onyx.METHOD.SET, diff --git a/tests/unit/Search/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx index f68d7220e8fc..5da0ea033961 100644 --- a/tests/unit/Search/SearchBulkActionsButtonTest.tsx +++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx @@ -39,7 +39,7 @@ jest.mock('@components/ButtonWithDropdownMenu', () => ({ __esModule: true, default: (props: MockButtonProps) => mockButtonWithDropdownMenu(props), })); -jest.mock('@components/ReportPDFDownloadModal', () => () => null); +jest.mock('@components/Modal/FileDownloadModal', () => () => null); jest.mock('@components/KYCWall', () => ({ __esModule: true, default: ({children}: {children: (triggerKYCFlow: jest.Mock, buttonRef: React.RefObject) => React.ReactNode}) => children(jest.fn(), {current: null}), diff --git a/tests/unit/components/ExpensifyCardStatementPDFDownloadModalWrapperTest.tsx b/tests/unit/components/ExpensifyCardStatementPDFDownloadModalWrapperTest.tsx deleted file mode 100644 index e28cfd6b6ace..000000000000 --- a/tests/unit/components/ExpensifyCardStatementPDFDownloadModalWrapperTest.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import {act, render} from '@testing-library/react-native'; - -import ExpensifyCardStatementPDFDownloadModal from '@components/ExpensifyCardStatementPDFDownloadModal'; -import ExpensifyCardStatementPDFDownloadModalWrapper from '@components/Modal/Global/ExpensifyCardStatementPDFDownloadModalWrapper'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; - -import React from 'react'; - -jest.mock('@components/ExpensifyCardStatementPDFDownloadModal', () => jest.fn(() => null)); - -const STATEMENT_PARAMS = {policyID: 'policy1', feedCountry: 'US', entryIDs: [123], statementKey: 'statement-key'}; - -describe('ExpensifyCardStatementPDFDownloadModalWrapper', () => { - const mockedModal = jest.mocked(ExpensifyCardStatementPDFDownloadModal); - const getLatestModalProps = () => mockedModal.mock.calls.at(-1)?.[0]; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('removes itself from the modal stack only after the modal has finished hiding', () => { - // Given the wrapper rendered by the global modal stack - const closeModal = jest.fn(); - render( - , - ); - - // Then it opens the statement modal with the params passed through the show API - expect(getLatestModalProps()?.isVisible).toBe(true); - expect(getLatestModalProps()?.statementParams).toEqual(STATEMENT_PARAMS); - - // When a hide event fires while the modal is still visible - act(() => { - getLatestModalProps()?.onModalHide?.(); - }); - - // Then the wrapper stays on the stack - expect(closeModal).not.toHaveBeenCalled(); - - // When the user closes the modal - act(() => { - getLatestModalProps()?.onClose(); - }); - - // Then it starts hiding but is not removed until the hide animation ends, so callers awaiting the show - // promise run their cleanup after the modal is gone - expect(getLatestModalProps()?.isVisible).toBe(false); - expect(closeModal).not.toHaveBeenCalled(); - - act(() => { - getLatestModalProps()?.onModalHide?.(); - }); - expect(closeModal).toHaveBeenCalledWith({action: ModalActions.CLOSE}); - }); -}); diff --git a/tests/unit/components/FileDownloadModalWrapperTest.tsx b/tests/unit/components/FileDownloadModalWrapperTest.tsx new file mode 100644 index 000000000000..a908d98c7912 --- /dev/null +++ b/tests/unit/components/FileDownloadModalWrapperTest.tsx @@ -0,0 +1,167 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ActivityIndicator from '@components/ActivityIndicator'; +import Modal from '@components/Modal'; +import type {FileDownloadModalWrapperProps} from '@components/Modal/Global/FileDownloadModalWrapper'; +import FileDownloadModalWrapper from '@components/Modal/Global/FileDownloadModalWrapper'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +jest.mock('@components/ActivityIndicator', () => jest.fn(() => null)); +jest.mock('@components/Modal', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); + +describe('FileDownloadModalWrapper', () => { + const mockedModal = jest.mocked(Modal); + const mockedActivityIndicator = jest.mocked(ActivityIndicator); + const getLatestModalProps = () => mockedModal.mock.calls.at(-1)?.[0]; + + const getWrapperElement = (props: Partial = {}) => ( + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('removes itself from the modal stack once, and only after the modal has finished hiding', () => { + // Given the wrapper rendered by the global modal stack + const closeModal = jest.fn(); + render(getWrapperElement({closeModal})); + expect(getLatestModalProps()?.isVisible).toBe(true); + + // When a hide event fires while the modal is still visible + act(() => { + getLatestModalProps()?.onModalHide?.(); + }); + + // Then the wrapper stays on the stack + expect(closeModal).not.toHaveBeenCalled(); + + // When the user closes the modal + act(() => { + getLatestModalProps()?.onClose?.(); + }); + + // Then the modal starts hiding but is not removed until the hide animation ends, so callers awaiting the show + // promise run their cleanup after the modal is gone + expect(getLatestModalProps()?.isVisible).toBe(false); + expect(closeModal).not.toHaveBeenCalled(); + + // When the hide event fires, even more than once + act(() => { + getLatestModalProps()?.onModalHide?.(); + getLatestModalProps()?.onModalHide?.(); + }); + + // Then the wrapper removes itself exactly once, reporting a dismissal while generating as a cancel + expect(closeModal).toHaveBeenCalledTimes(1); + expect(closeModal).toHaveBeenCalledWith({action: ModalActions.CLOSE}); + }); + + it.each([CONST.FILE_DOWNLOAD_STATUS.READY, CONST.FILE_DOWNLOAD_STATUS.FAILED])('confirms when dismissed once the file is %s', (status) => { + // Given a file that is no longer generating, so there is nothing left to cancel + const closeModal = jest.fn(); + render(getWrapperElement({closeModal, status})); + + // When the user closes the modal and it finishes hiding + act(() => { + getLatestModalProps()?.onClose?.(); + }); + act(() => { + getLatestModalProps()?.onModalHide?.(); + }); + + // Then the show promise resolves as confirmed, so callers like Submit via PDF keep what they did + expect(closeModal).toHaveBeenCalledWith({action: ModalActions.CONFIRM}); + }); + + it('keeps a dismissed modal unchanged while it hides', () => { + // Given the user dismissed the modal while the file was generating + const closeModal = jest.fn(); + const {rerender} = render(getWrapperElement({closeModal})); + act(() => { + getLatestModalProps()?.onClose?.(); + }); + + // When the caller updates the modal in place with the ready state during the hide animation + rerender(getWrapperElement({closeModal, status: CONST.FILE_DOWNLOAD_STATUS.READY, message: 'reportDetailsPage.successPDF'})); + + // Then the hiding modal keeps the state it had when dismissed, and still reports the dismissal as a cancel + expect(screen.getByText('message')).toBeOnTheScreen(); + expect(screen.queryByText('common.download')).not.toBeOnTheScreen(); + act(() => { + getLatestModalProps()?.onModalHide?.(); + }); + expect(closeModal).toHaveBeenCalledWith({action: ModalActions.CLOSE}); + }); + + it('shows the header text the caller passes', () => { + // Given a caller with its own header text + render(getWrapperElement({title: 'reportDetailsPage.generatingPDF'})); + + // Then the modal shows it + expect(screen.getByText('reportDetailsPage.generatingPDF')).toBeOnTheScreen(); + }); + + it('shows the spinner and a cancel button while the file is still generating', () => { + // Given a file that is still generating + render(getWrapperElement({status: CONST.FILE_DOWNLOAD_STATUS.GENERATING, message: 'reportDetailsPage.waitForPDF'})); + + // Then the spinner and the generating copy are shown + expect(screen.getByText('reportDetailsPage.waitForPDF')).toBeOnTheScreen(); + expect(mockedActivityIndicator).toHaveBeenCalled(); + expect(screen.getByText('common.cancel')).toBeOnTheScreen(); + }); + + it('stops the spinner and offers a close button when the file generation fails', () => { + // Given a file whose generation failed + render(getWrapperElement({status: CONST.FILE_DOWNLOAD_STATUS.FAILED, message: 'reportDetailsPage.errorPDF'})); + + // Then there is nothing left to wait for, so the spinner stops and the button closes the modal + expect(screen.getByText('reportDetailsPage.errorPDF')).toBeOnTheScreen(); + expect(mockedActivityIndicator).not.toHaveBeenCalled(); + expect(screen.getByText('common.close')).toBeOnTheScreen(); + }); + + it('auto-downloads once when the file becomes ready while the modal is open', () => { + // Given an open modal waiting on the file + const onDownload = jest.fn(); + const {rerender} = render(getWrapperElement({onDownload})); + expect(onDownload).not.toHaveBeenCalled(); + + // When the caller updates the modal in place with the ready state + rerender(getWrapperElement({onDownload, status: CONST.FILE_DOWNLOAD_STATUS.READY, message: 'reportDetailsPage.successPDF'})); + rerender(getWrapperElement({onDownload, status: CONST.FILE_DOWNLOAD_STATUS.READY, message: 'reportDetailsPage.successPDF'})); + + // Then the file downloads once and the button offers a manual download + expect(onDownload).toHaveBeenCalledTimes(1); + expect(mockedActivityIndicator).toHaveBeenCalledTimes(1); + expect(screen.getByText('common.download')).toBeOnTheScreen(); + }); + + it('closes the modal after a manual download only when asked to', () => { + // Given a ready file whose caller wants the modal closed on download + const onDownload = jest.fn(); + render(getWrapperElement({onDownload, status: CONST.FILE_DOWNLOAD_STATUS.READY, shouldCloseOnDownload: true})); + onDownload.mockClear(); + + // When the user presses the download button + fireEvent.press(screen.getByText('common.download')); + + // Then the file downloads and the modal starts hiding + expect(onDownload).toHaveBeenCalledTimes(1); + expect(getLatestModalProps()?.isVisible).toBe(false); + }); +}); diff --git a/tests/unit/components/ReportPDFDownloadModalTest.tsx b/tests/unit/components/ReportPDFDownloadModalTest.tsx deleted file mode 100644 index 2503885e803d..000000000000 --- a/tests/unit/components/ReportPDFDownloadModalTest.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import {render, screen} from '@testing-library/react-native'; - -import ActivityIndicator from '@components/ActivityIndicator'; -import ReportPDFDownloadModal from '@components/ReportPDFDownloadModal'; - -import useOnyx from '@hooks/useOnyx'; - -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; - -import React from 'react'; - -jest.mock('@components/ActivityIndicator', () => jest.fn(() => null)); -jest.mock('@components/Modal', () => jest.fn(({children}: {children: React.ReactNode}) => children)); -jest.mock('@hooks/useOnyx', () => jest.fn()); -jest.mock('@libs/actions/Report', () => ({downloadReportPDF: jest.fn()})); -jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); -jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: 'employee@mail.com'}))); -jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); - -const REPORT_ID = '1'; - -describe('ReportPDFDownloadModal', () => { - const mockedUseOnyx = jest.mocked(useOnyx); - const mockedActivityIndicator = jest.mocked(ActivityIndicator); - - const mockPDFFilename = (reportPDFFilename: string | undefined) => { - (mockedUseOnyx as jest.Mock).mockImplementation((key: string) => { - if (key === `${ONYXKEYS.COLLECTION.NVP_EXPENSIFY_REPORT_PDF_FILENAME}${REPORT_ID}`) { - return [reportPDFFilename]; - } - return [undefined]; - }); - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - const renderModal = () => - render( - , - ); - - it('shows the spinner while the PDF is still generating', () => { - mockPDFFilename(undefined); - - renderModal(); - - expect(screen.getByText('reportDetailsPage.waitForPDF')).toBeOnTheScreen(); - expect(mockedActivityIndicator).toHaveBeenCalled(); - }); - - it('stops the spinner and offers a close button when the PDF generation fails', () => { - mockPDFFilename(CONST.REPORT_DETAILS_MENU_ITEM.ERROR); - - renderModal(); - - expect(screen.getByText('reportDetailsPage.errorPDF')).toBeOnTheScreen(); - expect(mockedActivityIndicator).not.toHaveBeenCalled(); - expect(screen.getByText('common.close')).toBeOnTheScreen(); - }); - - it('stops the spinner and offers a download button once the PDF is ready', () => { - mockPDFFilename('report.pdf'); - - renderModal(); - - expect(screen.getByText('reportDetailsPage.successPDF')).toBeOnTheScreen(); - expect(mockedActivityIndicator).not.toHaveBeenCalled(); - expect(screen.getByText('common.download')).toBeOnTheScreen(); - }); -}); diff --git a/tests/unit/hooks/useExpensifyCardStatementPDFDownloadModalTest.tsx b/tests/unit/hooks/useExpensifyCardStatementPDFDownloadModalTest.tsx new file mode 100644 index 000000000000..e12736581a3a --- /dev/null +++ b/tests/unit/hooks/useExpensifyCardStatementPDFDownloadModalTest.tsx @@ -0,0 +1,256 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import FileDownloadModal from '@components/Modal/FileDownloadModal'; +import {ModalActions, ModalProvider} from '@components/Modal/Global/ModalContext'; + +import useExpensifyCardStatementPDFDownloadModal from '@hooks/useExpensifyCardStatementPDFDownloadModal'; +import useOnyx from '@hooks/useOnyx'; + +import {downloadExpensifyCardStatementPDF} from '@libs/ExpensifyCardStatementUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React, {useSyncExternalStore} from 'react'; + +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +// Counts real mounts, so a test can tell an in-place props update apart from a remount. +const mockOnFileDownloadModalMount = jest.fn(); +jest.mock('@components/Modal/FileDownloadModal', () => { + const {useEffect} = jest.requireActual('react'); + return jest.fn(() => { + useEffect(() => { + mockOnFileDownloadModalMount(); + }, []); + return null; + }); +}); +jest.mock('@hooks/useOnyx', () => jest.fn()); +jest.mock('@libs/ExpensifyCardStatementUtils', () => ({downloadExpensifyCardStatementPDF: jest.fn()})); +jest.mock('@hooks/useEnvironment', () => jest.fn(() => ({environment: 'development'}))); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: 'employee@mail.com'}))); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); + +const STATEMENT_PARAMS = {policyID: 'policy1', feedCountry: 'US', entryIDs: [123]}; +const STATEMENT_KEY = 'statement-key'; + +let mockOnyxValues: Record = {}; +const onyxListeners = new Set<() => void>(); +const subscribeToOnyx = (listener: () => void) => { + onyxListeners.add(listener); + return () => onyxListeners.delete(listener); +}; + +// A minimal reactive store, so an update re-renders the modal the global modal stack renders, like a real Onyx subscription. +const useMockOnyx = (key: string) => [useSyncExternalStore(subscribeToOnyx, () => mockOnyxValues[key])]; + +describe('useExpensifyCardStatementPDFDownloadModal', () => { + const mockedFileDownloadModal = jest.mocked(FileDownloadModal); + const getLatestModalProps = () => mockedFileDownloadModal.mock.calls.at(-1)?.[0]; + + const renderStatementPDFDownloadModal = () => + renderHook(() => useExpensifyCardStatementPDFDownloadModal(), { + wrapper: ({children}: {children: React.ReactNode}) => {children}, + }); + + // Mirrors a backend-pushed Onyx update. + const setStatementInOnyx = async (statement: Record) => { + await act(async () => { + mockOnyxValues = {...mockOnyxValues, [ONYXKEYS.EXPENSIFY_CARD_STATEMENT]: statement}; + for (const listener of onyxListeners) { + listener(); + } + await waitForBatchedUpdates(); + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockOnyxValues = {[ONYXKEYS.SESSION]: {encryptedAuthToken: 'token'}}; + (jest.mocked(useOnyx) as jest.Mock).mockImplementation(useMockOnyx); + }); + + it('updates the open modal in place as the statementKey and file arrive', async () => { + // Given the modal opened before GetExpensifyCardStatementPDF has returned a statementKey + const {result} = renderStatementPDFDownloadModal(); + let firstPromise: Promise | undefined; + await act(async () => { + firstPromise = result.current.showExpensifyCardStatementPDFDownloadModal({statementParams: STATEMENT_PARAMS}); + await waitForBatchedUpdates(); + }); + + // Then it shows the generating state with the card statement button flags + expect(getLatestModalProps()).toEqual( + expect.objectContaining({ + isVisible: true, + title: 'reportDetailsPage.generatingPDF', + status: CONST.FILE_DOWNLOAD_STATUS.GENERATING, + message: 'reportDetailsPage.waitForPDF', + shouldCloseOnDownload: true, + shouldUseSuccessButton: true, + }), + ); + + // When the statementKey arrives through a second show call and the backend pushes the generated file + let secondPromise: Promise | undefined; + await act(async () => { + secondPromise = result.current.showExpensifyCardStatementPDFDownloadModal({statementParams: {...STATEMENT_PARAMS, statementKey: STATEMENT_KEY}}); + await waitForBatchedUpdates(); + }); + await setStatementInOnyx({isGenerating: false, [STATEMENT_KEY]: 'statement.pdf'}); + + // Then the same modal instance switches to the ready state + expect(getLatestModalProps()).toEqual(expect.objectContaining({isVisible: true, status: CONST.FILE_DOWNLOAD_STATUS.READY, message: 'reportDetailsPage.successPDF'})); + expect(mockOnFileDownloadModalMount).toHaveBeenCalledTimes(1); + + // When the modal downloads the file, which also closes it, and it finishes hiding + await act(async () => { + getLatestModalProps()?.onDownload(); + getLatestModalProps()?.onClose(); + await waitForBatchedUpdates(); + }); + await act(async () => { + getLatestModalProps()?.onModalHide?.(); + await waitForBatchedUpdates(); + }); + + // Then it downloads the statement for that key, and both callers learn the flow completed + expect(downloadExpensifyCardStatementPDF).toHaveBeenCalledWith(expect.any(Function), 'statement.pdf', STATEMENT_KEY, 'employee@mail.com', 'token', 'development'); + await expect(firstPromise).resolves.toEqual({action: ModalActions.CONFIRM}); + await expect(secondPromise).resolves.toEqual({action: ModalActions.CONFIRM}); + }); + + it('keeps updating the modal after the caller unmounts', async () => { + // Given a caller, such as the Search page, that opens the modal and then unmounts while the statement is generating + let shouldRenderCaller = true; + const {result, rerender} = renderHook(() => useExpensifyCardStatementPDFDownloadModal(), { + wrapper: ({children}: {children: React.ReactNode}) => {shouldRenderCaller ? children : null}, + }); + await act(async () => { + result.current.showExpensifyCardStatementPDFDownloadModal({statementParams: {...STATEMENT_PARAMS, statementKey: STATEMENT_KEY}}); + await waitForBatchedUpdates(); + }); + shouldRenderCaller = false; + rerender({}); + + // When the backend pushes the generated file + await setStatementInOnyx({isGenerating: false, [STATEMENT_KEY]: 'statement.pdf'}); + + // Then the modal, which owns its own subscription, still switches to the ready state and offers the download + expect(getLatestModalProps()).toEqual(expect.objectContaining({isVisible: true, status: CONST.FILE_DOWNLOAD_STATUS.READY})); + act(() => { + getLatestModalProps()?.onDownload(); + }); + expect(downloadExpensifyCardStatementPDF).toHaveBeenCalledWith(expect.any(Function), 'statement.pdf', STATEMENT_KEY, 'employee@mail.com', 'token', 'development'); + }); + + it('keeps the generating state while the statement is still generating', async () => { + // Given the modal opened with a statementKey whose file is cached but being regenerated + const {result} = renderStatementPDFDownloadModal(); + await act(async () => { + result.current.showExpensifyCardStatementPDFDownloadModal({statementParams: {...STATEMENT_PARAMS, statementKey: STATEMENT_KEY}}); + await waitForBatchedUpdates(); + }); + + // When Onyx says the statement is generating + await setStatementInOnyx({isGenerating: true, [STATEMENT_KEY]: 'statement.pdf'}); + + // Then the modal still waits instead of offering a stale file + expect(getLatestModalProps()?.status).toBe(CONST.FILE_DOWNLOAD_STATUS.GENERATING); + }); + + it('resolves the show promise only after the modal has finished hiding', async () => { + // Given an open modal + const {result} = renderStatementPDFDownloadModal(); + let hasResolved = false; + await act(async () => { + result.current.showExpensifyCardStatementPDFDownloadModal({statementParams: STATEMENT_PARAMS}).then(() => { + hasResolved = true; + }); + await waitForBatchedUpdates(); + }); + + // When the user dismisses it + await act(async () => { + getLatestModalProps()?.onClose(); + await waitForBatchedUpdates(); + }); + + // Then it starts hiding but the caller, which clears the selection on resolve, is not told it is gone yet + expect(getLatestModalProps()?.isVisible).toBe(false); + expect(hasResolved).toBe(false); + + // When the hide animation ends + await act(async () => { + getLatestModalProps()?.onModalHide?.(); + await waitForBatchedUpdates(); + }); + + // Then the show promise resolves + expect(hasResolved).toBe(true); + }); + + it('does not reopen a dismissed modal when the statementKey arrives during the hide animation', async () => { + // Given the user dismissed the modal before the statementKey arrived + const {result} = renderStatementPDFDownloadModal(); + await act(async () => { + result.current.showExpensifyCardStatementPDFDownloadModal({statementParams: STATEMENT_PARAMS}); + await waitForBatchedUpdates(); + }); + await act(async () => { + getLatestModalProps()?.onClose(); + await waitForBatchedUpdates(); + }); + + // When the statementKey and file arrive while the modal is hiding + await act(async () => { + result.current.showExpensifyCardStatementPDFDownloadModal({statementParams: {...STATEMENT_PARAMS, statementKey: STATEMENT_KEY}}); + await waitForBatchedUpdates(); + }); + await setStatementInOnyx({isGenerating: false, [STATEMENT_KEY]: 'statement.pdf'}); + + // Then the hiding modal is left alone + expect(getLatestModalProps()).toEqual(expect.objectContaining({isVisible: false, status: CONST.FILE_DOWNLOAD_STATUS.GENERATING})); + + // When the hide animation ends and another update arrives + await act(async () => { + getLatestModalProps()?.onModalHide?.(); + await waitForBatchedUpdates(); + }); + const renderCountAfterHide = mockedFileDownloadModal.mock.calls.length; + await setStatementInOnyx({isGenerating: false, [STATEMENT_KEY]: 'statement-2.pdf'}); + + // Then the modal is not shown again + expect(mockedFileDownloadModal.mock.calls.length).toBe(renderCountAfterHide); + expect(mockOnFileDownloadModalMount).toHaveBeenCalledTimes(1); + }); + + it('closes the modal by id without reopening it on a later update', async () => { + // Given an open modal waiting on the statement + const {result} = renderStatementPDFDownloadModal(); + let hasResolved = false; + await act(async () => { + result.current.showExpensifyCardStatementPDFDownloadModal({statementParams: {...STATEMENT_PARAMS, statementKey: STATEMENT_KEY}}).then(() => { + hasResolved = true; + }); + await waitForBatchedUpdates(); + }); + + // When the request fails and the caller closes the modal by id + await act(async () => { + result.current.closeExpensifyCardStatementPDFDownloadModal(); + await waitForBatchedUpdates(); + }); + const renderCountAfterClose = mockedFileDownloadModal.mock.calls.length; + + // Then the modal is gone and its promise resolves + expect(hasResolved).toBe(true); + + // When the file arrives afterwards + await setStatementInOnyx({isGenerating: false, [STATEMENT_KEY]: 'statement.pdf'}); + + // Then the modal is not shown again + expect(mockedFileDownloadModal.mock.calls.length).toBe(renderCountAfterClose); + }); +}); diff --git a/tests/unit/hooks/useReportPDFDownloadModalTest.tsx b/tests/unit/hooks/useReportPDFDownloadModalTest.tsx new file mode 100644 index 000000000000..5c406b0c9103 --- /dev/null +++ b/tests/unit/hooks/useReportPDFDownloadModalTest.tsx @@ -0,0 +1,278 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import FileDownloadModal from '@components/Modal/FileDownloadModal'; +import {ModalActions, ModalProvider} from '@components/Modal/Global/ModalContext'; + +import useOnyx from '@hooks/useOnyx'; +import useReportPDFDownloadModal from '@hooks/useReportPDFDownloadModal'; +import useStallLogger from '@hooks/useStallLogger'; + +import {downloadReportPDF} from '@libs/actions/Report'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React, {useSyncExternalStore} from 'react'; + +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +// Counts real mounts, so a test can tell an in-place props update apart from a remount. +const mockOnFileDownloadModalMount = jest.fn(); +jest.mock('@components/Modal/FileDownloadModal', () => { + const {useEffect} = jest.requireActual('react'); + return jest.fn(() => { + useEffect(() => { + mockOnFileDownloadModalMount(); + }, []); + return null; + }); +}); +jest.mock('@hooks/useOnyx', () => jest.fn()); +jest.mock('@hooks/useStallLogger', () => jest.fn()); +jest.mock('@libs/actions/Report', () => ({downloadReportPDF: jest.fn()})); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({login: 'employee@mail.com'}))); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); + +const REPORT_ID = '1'; +const PDF_FILENAME_KEY = `${ONYXKEYS.COLLECTION.NVP_EXPENSIFY_REPORT_PDF_FILENAME}${REPORT_ID}`; + +let mockOnyxValues: Record = {}; +const onyxListeners = new Set<() => void>(); +const subscribeToOnyx = (listener: () => void) => { + onyxListeners.add(listener); + return () => onyxListeners.delete(listener); +}; + +// A minimal reactive store, so an update re-renders the modal the global modal stack renders, like a real Onyx subscription. +const useMockOnyx = (key: string) => [useSyncExternalStore(subscribeToOnyx, () => mockOnyxValues[key])]; + +describe('useReportPDFDownloadModal', () => { + const mockedFileDownloadModal = jest.mocked(FileDownloadModal); + const getLatestModalProps = () => mockedFileDownloadModal.mock.calls.at(-1)?.[0]; + + const renderReportPDFDownloadModal = () => + renderHook(() => useReportPDFDownloadModal(), { + wrapper: ({children}: {children: React.ReactNode}) => {children}, + }); + + // Mirrors a backend-pushed Onyx update. + const setOnyxValue = async (key: string, value: unknown) => { + await act(async () => { + mockOnyxValues = {...mockOnyxValues, [key]: value}; + for (const listener of onyxListeners) { + listener(); + } + await waitForBatchedUpdates(); + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockOnyxValues = { + [`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`]: {reportID: REPORT_ID, reportName: 'Expense Report'}, + [ONYXKEYS.SESSION]: {encryptedAuthToken: 'token'}, + }; + (jest.mocked(useOnyx) as jest.Mock).mockImplementation(useMockOnyx); + }); + + it('updates the open modal in place as the PDF generates', async () => { + // Given the modal opened for a report whose PDF is still generating + const {result} = renderReportPDFDownloadModal(); + await act(async () => { + result.current.showReportPDFDownloadModal({reportID: REPORT_ID}); + await waitForBatchedUpdates(); + }); + + // Then it shows the generating state with the spinner copy + expect(getLatestModalProps()).toEqual( + expect.objectContaining({ + isVisible: true, + title: 'reportDetailsPage.generatingPDF', + status: CONST.FILE_DOWNLOAD_STATUS.GENERATING, + message: 'reportDetailsPage.waitForPDF', + shouldTreatModalAsCovering: true, + }), + ); + + // When the backend pushes the generated filename + await setOnyxValue(PDF_FILENAME_KEY, 'report.pdf'); + + // Then the same modal instance switches to the ready state instead of being remounted + expect(getLatestModalProps()).toEqual(expect.objectContaining({isVisible: true, status: CONST.FILE_DOWNLOAD_STATUS.READY, message: 'reportDetailsPage.successPDF'})); + expect(mockOnFileDownloadModalMount).toHaveBeenCalledTimes(1); + + // When the modal downloads the file + act(() => { + getLatestModalProps()?.onDownload(); + }); + + // Then it downloads the report's PDF + expect(downloadReportPDF).toHaveBeenCalledWith('report.pdf', 'Expense Report', expect.any(Function), 'employee@mail.com', 'token'); + }); + + it('shows the failed state when the PDF generation fails', async () => { + // Given the modal opened for a report + const {result} = renderReportPDFDownloadModal(); + await act(async () => { + result.current.showReportPDFDownloadModal({reportID: REPORT_ID}); + await waitForBatchedUpdates(); + }); + + // When the backend reports the generation failed + await setOnyxValue(PDF_FILENAME_KEY, CONST.REPORT_DETAILS_MENU_ITEM.ERROR); + + // Then the modal shows the error state, which stops the spinner + expect(getLatestModalProps()).toEqual(expect.objectContaining({status: CONST.FILE_DOWNLOAD_STATUS.FAILED, message: 'reportDetailsPage.errorPDF'})); + }); + + it('resolves with CLOSE when dismissed while the PDF is still generating', async () => { + // Given the modal opened by Submit via PDF, which retracts the submit when the modal is cancelled + const onResolve = jest.fn(); + const {result} = renderReportPDFDownloadModal(); + await act(async () => { + result.current.showReportPDFDownloadModal({reportID: REPORT_ID}).then(onResolve); + await waitForBatchedUpdates(); + }); + + // When the user dismisses it before the filename arrives, and the close fires again from another control + await act(async () => { + getLatestModalProps()?.onClose(); + getLatestModalProps()?.onClose(); + await waitForBatchedUpdates(); + }); + await act(async () => { + getLatestModalProps()?.onModalHide?.(); + await waitForBatchedUpdates(); + }); + + // Then the caller is told once that the modal was cancelled + expect(onResolve).toHaveBeenCalledTimes(1); + expect(onResolve).toHaveBeenCalledWith({action: ModalActions.CLOSE}); + }); + + it('resolves with CONFIRM when dismissed after the PDF is ready', async () => { + // Given the modal opened by Submit via PDF and the filename has arrived, so the submit has already resolved + const onResolve = jest.fn(); + const {result} = renderReportPDFDownloadModal(); + await act(async () => { + result.current.showReportPDFDownloadModal({reportID: REPORT_ID}).then(onResolve); + await waitForBatchedUpdates(); + }); + await setOnyxValue(PDF_FILENAME_KEY, 'report.pdf'); + + // When the user dismisses the modal and it finishes hiding + await act(async () => { + getLatestModalProps()?.onClose(); + await waitForBatchedUpdates(); + }); + await act(async () => { + getLatestModalProps()?.onModalHide?.(); + await waitForBatchedUpdates(); + }); + + // Then the caller is told the flow completed, so the submit is kept + expect(onResolve).toHaveBeenCalledWith({action: ModalActions.CONFIRM}); + }); + + it('resolves the show promise only after the modal has finished hiding', async () => { + // Given an open modal + const {result} = renderReportPDFDownloadModal(); + let hasResolved = false; + await act(async () => { + result.current.showReportPDFDownloadModal({reportID: REPORT_ID}).then(() => { + hasResolved = true; + }); + await waitForBatchedUpdates(); + }); + + // When the user dismisses it + await act(async () => { + getLatestModalProps()?.onClose(); + await waitForBatchedUpdates(); + }); + + // Then it starts hiding but the caller is not told it is gone yet + expect(getLatestModalProps()?.isVisible).toBe(false); + expect(hasResolved).toBe(false); + + // When the hide animation ends + await act(async () => { + getLatestModalProps()?.onModalHide?.(); + await waitForBatchedUpdates(); + }); + + // Then the show promise resolves + expect(hasResolved).toBe(true); + }); + + it('does not reopen a dismissed modal when the filename arrives late', async () => { + // Given the user dismissed the modal while the PDF was still generating + const {result} = renderReportPDFDownloadModal(); + await act(async () => { + result.current.showReportPDFDownloadModal({reportID: REPORT_ID}); + await waitForBatchedUpdates(); + }); + await act(async () => { + getLatestModalProps()?.onClose(); + await waitForBatchedUpdates(); + }); + + // When the filename arrives while the modal is hiding, and again after it has gone + await setOnyxValue(PDF_FILENAME_KEY, 'report.pdf'); + expect(getLatestModalProps()).toEqual(expect.objectContaining({isVisible: false, status: CONST.FILE_DOWNLOAD_STATUS.GENERATING})); + await act(async () => { + getLatestModalProps()?.onModalHide?.(); + await waitForBatchedUpdates(); + }); + const renderCountAfterHide = mockedFileDownloadModal.mock.calls.length; + await setOnyxValue(PDF_FILENAME_KEY, 'report-2.pdf'); + + // Then the modal is not shown again + expect(mockedFileDownloadModal.mock.calls.length).toBe(renderCountAfterHide); + expect(mockOnFileDownloadModalMount).toHaveBeenCalledTimes(1); + }); + + it('keeps updating the modal after the caller unmounts', async () => { + // Given a caller, such as the report header, that opens the modal and then unmounts while the PDF is generating + let shouldRenderCaller = true; + const {result, rerender} = renderHook(() => useReportPDFDownloadModal(), { + wrapper: ({children}: {children: React.ReactNode}) => {shouldRenderCaller ? children : null}, + }); + await act(async () => { + result.current.showReportPDFDownloadModal({reportID: REPORT_ID}); + await waitForBatchedUpdates(); + }); + shouldRenderCaller = false; + rerender({}); + + // When the backend pushes the generated filename + await setOnyxValue(PDF_FILENAME_KEY, 'report.pdf'); + + // Then the modal, which owns its own subscriptions, still switches to the ready state and offers the download + expect(getLatestModalProps()).toEqual(expect.objectContaining({isVisible: true, status: CONST.FILE_DOWNLOAD_STATUS.READY})); + act(() => { + getLatestModalProps()?.onDownload(); + }); + expect(downloadReportPDF).toHaveBeenCalledWith('report.pdf', 'Expense Report', expect.any(Function), 'employee@mail.com', 'token'); + }); + + it('logs a stall only while the open modal is waiting on the filename', async () => { + // Given the modal opened for a report whose PDF is still generating + const {result} = renderReportPDFDownloadModal(); + expect(useStallLogger).not.toHaveBeenCalled(); + await act(async () => { + result.current.showReportPDFDownloadModal({reportID: REPORT_ID}); + await waitForBatchedUpdates(); + }); + + // Then the stall timer is keyed by the report it waits on + expect(jest.mocked(useStallLogger).mock.calls.at(-1)?.[0]).toBe(REPORT_ID); + + // When the filename arrives + await setOnyxValue(PDF_FILENAME_KEY, 'report.pdf'); + + // Then the stall timer stops + expect(jest.mocked(useStallLogger).mock.calls.at(-1)?.[0]).toBe(false); + }); +}); diff --git a/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts b/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts index 30d8355b4d9c..72f72f0b0f8d 100644 --- a/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts @@ -59,6 +59,14 @@ jest.mock('@hooks/useExpensifyCardStatementPDFDownloadModal', () => ({ }), })); +// The report PDF hook owns its own Onyx subscriptions and modal lifecycle (covered by useReportPDFDownloadModalTest), +// so only its show call is observed here. +const mockShowReportPDFDownloadModal = jest.fn, [{reportID: string}]>(() => Promise.resolve({action: 'CLOSE'})); +jest.mock('@hooks/useReportPDFDownloadModal', () => ({ + __esModule: true, + default: () => ({showReportPDFDownloadModal: mockShowReportPDFDownloadModal}), +})); + let mockIsOffline = false; jest.mock('@hooks/useNetwork', () => ({ __esModule: true, @@ -364,6 +372,8 @@ describe('useSearchBulkActions - Download report', () => { expect(exportReportToPDF).toHaveBeenCalledTimes(1); expect(exportReportToPDF).toHaveBeenCalledWith({reportID: '1'}); + expect(mockShowReportPDFDownloadModal).toHaveBeenCalledTimes(1); + expect(mockShowReportPDFDownloadModal).toHaveBeenCalledWith({reportID: '1'}); }); it('should not call exportReportToPDF when offline', async () => {