diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 31e7937c1487..f6a30f4022eb 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -7583,6 +7583,7 @@ const CONST = {
BULK_ACTION_TYPES: {
EDIT: 'edit',
EXPORT: 'export',
+ DOWNLOAD_PDF: 'downloadPDF',
APPROVE: 'approve',
CHANGE_APPROVER: 'changeApprover',
PAY: 'pay',
diff --git a/src/components/ReportPDFDownloadModal.tsx b/src/components/ReportPDFDownloadModal.tsx
index fca77d09b68f..da62e4398d7f 100644
--- a/src/components/ReportPDFDownloadModal.tsx
+++ b/src/components/ReportPDFDownloadModal.tsx
@@ -23,9 +23,10 @@ type ReportPDFDownloadModalProps = {
reportID: string | undefined;
isVisible: boolean;
onClose: () => void;
+ onModalHide?: () => void;
};
-function ReportPDFDownloadModal({reportID, isVisible, onClose}: ReportPDFDownloadModalProps) {
+function ReportPDFDownloadModal({reportID, isVisible, onClose, onModalHide}: ReportPDFDownloadModalProps) {
const shouldAutoDownloadPDF = useRef(false);
const [reportPDFFilename] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_EXPENSIFY_REPORT_PDF_FILENAME}${reportID}`);
@@ -76,6 +77,7 @@ function ReportPDFDownloadModal({reportID, isVisible, onClose}: ReportPDFDownloa
return (
+ {!!pdfReportID && (
+ setIsPdfModalVisible(false)}
+ onModalHide={handlePdfModalHide}
+ />
+ )}
{!!rejectModalAction && (
{
+ if (isOffline) {
+ showOfflineModal();
+ return;
+ }
if (!moneyRequestReport?.reportID) {
return;
}
diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts
index 320c3bffe03c..49e5a5413163 100644
--- a/src/hooks/useSearchBulkActions.ts
+++ b/src/hooks/useSearchBulkActions.ts
@@ -12,7 +12,7 @@ import {useSearchActionsContext, useSearchStateContext} from '@components/Search
import type {BulkPaySelectionData, PaymentData, SearchQueryJSON} from '@components/Search/types';
import {unholdRequest} from '@libs/actions/IOU/Hold';
import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction';
-import {deleteAppReport, markAsManuallyExported, moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter} from '@libs/actions/Report';
+import {deleteAppReport, exportReportToPDF, markAsManuallyExported, moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter} from '@libs/actions/Report';
import {
approveMoneyRequestOnSearch,
bulkDeleteReports,
@@ -254,6 +254,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false);
const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false);
+ const [isPdfModalVisible, setIsPdfModalVisible] = useState(false);
+ const [pdfReportID, setPdfReportID] = useState(undefined);
const {showConfirmModal} = useConfirmModal();
const [isHoldEducationalModalVisible, setIsHoldEducationalModalVisible] = useState(false);
const [rejectModalAction, setRejectModalAction] = useState {
+ if (isOffline) {
+ setIsOfflineModalVisible(true);
+ return;
+ }
+ if (!reportIDForPDF) {
+ return;
+ }
+ // Using await prevent the double-download on the second PDF export
+ // by clearing Onyx filename before modal is visible
+ await exportReportToPDF({reportID: reportIDForPDF});
+ setPdfReportID(reportIDForPDF);
+ setIsPdfModalVisible(true);
+ },
+ });
+ }
+
const shouldShowHoldOption = !isOffline && selectedTransactionsKeys.every((id) => selectedTransactions[id].canHold);
if (shouldShowHoldOption) {
@@ -1561,6 +1588,11 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
setIsDownloadErrorModalVisible(false);
}, [setIsDownloadErrorModalVisible]);
+ const handlePdfModalHide = useCallback(() => {
+ setPdfReportID(undefined);
+ clearSelectedTransactions();
+ }, [clearSelectedTransactions]);
+
const dismissModalAndUpdateUseHold = useCallback(() => {
setIsHoldEducationalModalVisible(false);
setNameValuePair(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, true, false, !isOffline);
@@ -1596,6 +1628,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
emptyReportsCount,
handleOfflineModalClose,
handleDownloadErrorModalClose,
+ isPdfModalVisible,
+ setIsPdfModalVisible,
+ pdfReportID,
+ handlePdfModalHide,
dismissModalAndUpdateUseHold,
dismissRejectModalBasedOnAction,
isDuplicateOptionVisible,
@@ -1610,3 +1646,4 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
export default useSearchBulkActions;
export {shouldShowBulkDuplicateOption};
+export type {SearchHeaderOptionValue};
diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts
index af39693d4d0d..c89857679610 100644
--- a/src/libs/actions/Report/index.ts
+++ b/src/libs/actions/Report/index.ts
@@ -6161,7 +6161,7 @@ function exportReportToCSV({reportID, transactionIDList}: ExportReportCSVParams,
fileDownload(translate, ApiUtils.getCommandURL({command: WRITE_COMMANDS.EXPORT_REPORT_TO_CSV}), 'Expensify.csv', '', false, formData, CONST.NETWORK.METHOD.POST, onDownloadFailed);
}
-function exportReportToPDF({reportID}: ExportReportPDFParams) {
+async function exportReportToPDF({reportID}: ExportReportPDFParams) {
const optimisticData: Array> = [
{
onyxMethod: Onyx.METHOD.SET,
@@ -6181,7 +6181,7 @@ function exportReportToPDF({reportID}: ExportReportPDFParams) {
reportID,
} satisfies ExportReportPDFParams;
- API.write(WRITE_COMMANDS.EXPORT_REPORT_TO_PDF, params, {optimisticData, failureData});
+ return API.write(WRITE_COMMANDS.EXPORT_REPORT_TO_PDF, params, {optimisticData, failureData});
}
function downloadReportPDF(fileName: string, reportName: string, translate: LocalizedTranslate, currentUserLogin: string, encryptedAuthToken: string) {
diff --git a/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts b/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts
new file mode 100644
index 000000000000..4036c3edfb57
--- /dev/null
+++ b/tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts
@@ -0,0 +1,262 @@
+import {act, renderHook, waitFor} from '@testing-library/react-native';
+import Onyx from 'react-native-onyx';
+import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
+import type {SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types';
+import useSearchBulkActions from '@hooks/useSearchBulkActions';
+import type {SearchHeaderOptionValue} from '@hooks/useSearchBulkActions';
+import {exportReportToPDF} from '@libs/actions/Report';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+jest.mock('@libs/actions/Report', () => ({
+ exportReportToPDF: jest.fn(),
+}));
+
+let mockIsOffline = false;
+jest.mock('@hooks/useNetwork', () => ({
+ __esModule: true,
+ default: () => ({isOffline: mockIsOffline}),
+}));
+
+jest.mock('@hooks/useEnvironment', () => ({
+ __esModule: true,
+ default: () => ({isProduction: false, isDevelopment: true, environment: 'development'}),
+}));
+
+const mockClearSelectedTransactions = jest.fn();
+let mockSelectedTransactions: SelectedTransactions = {};
+let mockSelectedReports: SelectedReports[] = [];
+
+jest.mock('@components/Search/SearchContext', () => ({
+ useSearchStateContext: () => ({
+ selectedTransactions: mockSelectedTransactions,
+ selectedReports: mockSelectedReports,
+ areAllMatchingItemsSelected: false,
+ currentSearchResults: undefined,
+ }),
+ useSearchActionsContext: () => ({
+ clearSelectedTransactions: mockClearSelectedTransactions,
+ selectAllMatchingItems: jest.fn(),
+ }),
+}));
+
+const CURRENT_USER_ACCOUNT_ID = 1;
+
+jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({
+ __esModule: true,
+ default: jest.fn(() => ({
+ login: 'test@example.com',
+ accountID: CURRENT_USER_ACCOUNT_ID,
+ email: 'test@example.com',
+ })),
+}));
+
+// ---- helpers ----
+
+const expenseReportQueryJSON: SearchQueryJSON = {
+ inputQuery: 'type:expense-report status:all',
+ hash: 12345,
+ recentSearchHash: 12345,
+ similarSearchHash: 12345,
+ flatFilters: [],
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT,
+ status: CONST.SEARCH.STATUS.EXPENSE_REPORT.ALL,
+ sortBy: CONST.SEARCH.TABLE_COLUMNS.DATE,
+ sortOrder: CONST.SEARCH.SORT_ORDER.DESC,
+ view: CONST.SEARCH.VIEW.TABLE,
+ filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: 'type', right: 'expense-report'},
+};
+
+function makeSelectedReport(overrides: Partial = {}): SelectedReports {
+ return {
+ reportID: 'report1',
+ policyID: 'policy1',
+ action: CONST.SEARCH.ACTION_TYPES.VIEW,
+ allActions: [CONST.SEARCH.ACTION_TYPES.VIEW],
+ total: 100,
+ currency: 'USD',
+ chatReportID: undefined,
+ ownerAccountID: CURRENT_USER_ACCOUNT_ID,
+ type: CONST.REPORT.TYPE.EXPENSE,
+ ...overrides,
+ };
+}
+
+function getDownloadPDFOption(options: Array>): DropdownOption | undefined {
+ return options.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.DOWNLOAD_PDF);
+}
+
+// ---- tests ----
+
+describe('useSearchBulkActions - Download as PDF', () => {
+ beforeAll(() => {
+ Onyx.init({keys: ONYXKEYS});
+ });
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ mockIsOffline = false;
+ await Onyx.clear();
+ mockSelectedTransactions = {};
+ mockSelectedReports = [];
+
+ await Onyx.merge(ONYXKEYS.SESSION, {accountID: CURRENT_USER_ACCOUNT_ID, email: 'test@example.com'});
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}report1`, {
+ reportID: 'report1',
+ ownerAccountID: CURRENT_USER_ACCOUNT_ID,
+ type: CONST.REPORT.TYPE.EXPENSE,
+ reportName: 'Report report1',
+ });
+ });
+
+ afterEach(async () => {
+ await Onyx.clear();
+ });
+
+ it('should show Download as PDF option when a single expense report is selected', async () => {
+ mockSelectedReports = [makeSelectedReport()];
+ mockSelectedTransactions = {
+ tx1: {
+ isSelected: true,
+ canReject: false,
+ canHold: false,
+ canSplit: false,
+ hasBeenSplit: false,
+ canChangeReport: false,
+ isHeld: false,
+ canUnhold: false,
+ action: CONST.SEARCH.ACTION_TYPES.VIEW,
+ reportID: 'report1',
+ policyID: 'policy1',
+ amount: 100,
+ currency: 'USD',
+ isFromOneTransactionReport: false,
+ },
+ };
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}));
+
+ await waitFor(() => {
+ const pdfOption = getDownloadPDFOption(result.current.headerButtonsOptions);
+ expect(pdfOption).toBeDefined();
+ });
+ });
+
+ it('should call exportReportToPDF exactly once when triggered', async () => {
+ mockSelectedReports = [makeSelectedReport()];
+ mockSelectedTransactions = {
+ tx1: {
+ isSelected: true,
+ canReject: false,
+ canHold: false,
+ canSplit: false,
+ hasBeenSplit: false,
+ canChangeReport: false,
+ isHeld: false,
+ canUnhold: false,
+ action: CONST.SEARCH.ACTION_TYPES.VIEW,
+ reportID: 'report1',
+ policyID: 'policy1',
+ amount: 100,
+ currency: 'USD',
+ isFromOneTransactionReport: false,
+ },
+ };
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}));
+
+ await waitFor(() => {
+ expect(getDownloadPDFOption(result.current.headerButtonsOptions)).toBeDefined();
+ });
+
+ const pdfOption = getDownloadPDFOption(result.current.headerButtonsOptions);
+ await act(async () => {
+ await pdfOption?.onSelected?.();
+ });
+
+ expect(exportReportToPDF).toHaveBeenCalledTimes(1);
+ expect(exportReportToPDF).toHaveBeenCalledWith({reportID: 'report1'});
+ });
+
+ it('should not call exportReportToPDF when offline', async () => {
+ mockIsOffline = true;
+ mockSelectedReports = [makeSelectedReport()];
+ mockSelectedTransactions = {
+ tx1: {
+ isSelected: true,
+ canReject: false,
+ canHold: false,
+ canSplit: false,
+ hasBeenSplit: false,
+ canChangeReport: false,
+ isHeld: false,
+ canUnhold: false,
+ action: CONST.SEARCH.ACTION_TYPES.VIEW,
+ reportID: 'report1',
+ policyID: 'policy1',
+ amount: 100,
+ currency: 'USD',
+ isFromOneTransactionReport: false,
+ },
+ };
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}));
+
+ await waitFor(() => {
+ expect(getDownloadPDFOption(result.current.headerButtonsOptions)).toBeDefined();
+ });
+
+ const pdfOption = getDownloadPDFOption(result.current.headerButtonsOptions);
+ await act(async () => {
+ await pdfOption?.onSelected?.();
+ });
+
+ expect(exportReportToPDF).not.toHaveBeenCalled();
+ });
+
+ it('should not show Download as PDF when multiple reports are selected', async () => {
+ mockSelectedReports = [makeSelectedReport(), makeSelectedReport({reportID: 'report2'})];
+ mockSelectedTransactions = {
+ tx1: {
+ isSelected: true,
+ canReject: false,
+ canHold: false,
+ canSplit: false,
+ hasBeenSplit: false,
+ canChangeReport: false,
+ isHeld: false,
+ canUnhold: false,
+ action: CONST.SEARCH.ACTION_TYPES.VIEW,
+ reportID: 'report1',
+ policyID: 'policy1',
+ amount: 100,
+ currency: 'USD',
+ isFromOneTransactionReport: false,
+ },
+ tx2: {
+ isSelected: true,
+ canReject: false,
+ canHold: false,
+ canSplit: false,
+ hasBeenSplit: false,
+ canChangeReport: false,
+ isHeld: false,
+ canUnhold: false,
+ action: CONST.SEARCH.ACTION_TYPES.VIEW,
+ reportID: 'report2',
+ policyID: 'policy1',
+ amount: 200,
+ currency: 'USD',
+ isFromOneTransactionReport: false,
+ },
+ };
+
+ const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}));
+
+ await waitFor(() => {
+ expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0);
+ });
+
+ expect(getDownloadPDFOption(result.current.headerButtonsOptions)).toBeUndefined();
+ });
+});