From 8c7e93f1a70508f746adae78b5be8ec0268703c2 Mon Sep 17 00:00:00 2001 From: apeyada Date: Mon, 11 May 2026 07:28:13 +0100 Subject: [PATCH 1/2] feat: Add "Download as PDF" option to Reports page bulk actions dropdown --- src/CONST/index.ts | 1 + src/components/ReportPDFDownloadModal.tsx | 4 ++- .../Search/SearchBulkActionsButton.tsx | 13 +++++++ src/hooks/useExportActions.ts | 4 +++ src/hooks/useSearchBulkActions.ts | 36 ++++++++++++++++++- src/libs/actions/Report/index.ts | 4 +-- 6 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 0caf3f0dd595..e90b69ff272e 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7630,6 +7630,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 82cd43bd6319..446744fa943a 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, @@ -246,6 +246,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; + } + await exportReportToPDF({reportID: reportIDForPDF}); + setPdfReportID(reportIDForPDF); + setIsPdfModalVisible(true); + }, + }); + } + const shouldShowHoldOption = !isOffline && selectedTransactionsKeys.every((id) => selectedTransactions[id].canHold); if (shouldShowHoldOption) { @@ -1556,6 +1581,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); @@ -1591,6 +1621,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { emptyReportsCount, handleOfflineModalClose, handleDownloadErrorModalClose, + isPdfModalVisible, + setIsPdfModalVisible, + pdfReportID, + handlePdfModalHide, dismissModalAndUpdateUseHold, dismissRejectModalBasedOnAction, isDuplicateOptionVisible, diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 142a9a3c61d8..d339659d42d7 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -5983,7 +5983,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, @@ -6003,7 +6003,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) { From d70ed80834449b73c77da6956e9285e3c15cb54f Mon Sep 17 00:00:00 2001 From: apeyada Date: Thu, 14 May 2026 10:57:31 +0100 Subject: [PATCH 2/2] add tests, comment --- src/hooks/useSearchBulkActions.ts | 3 + .../useSearchBulkActionsDownloadPDFTest.ts | 262 ++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 tests/unit/hooks/useSearchBulkActionsDownloadPDFTest.ts diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 7103c4c00a91..49e5a5413163 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1305,6 +1305,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { 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); @@ -1644,3 +1646,4 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { export default useSearchBulkActions; export {shouldShowBulkDuplicateOption}; +export type {SearchHeaderOptionValue}; 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(); + }); +});