From b755ec9c77e41b5708481112dd5be7a8baacda8e Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Sun, 13 Sep 2026 21:38:50 +0700 Subject: [PATCH 1/2] Expense-Empty report can be downloaded via reconciliation in workspace chat --- src/hooks/useExportActions.ts | 20 ++++++++++++++++++++ tests/unit/hooks/useExportActionsTest.ts | 16 +++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/hooks/useExportActions.ts b/src/hooks/useExportActions.ts index 7fae127faeda..f2a40ffe70dc 100644 --- a/src/hooks/useExportActions.ts +++ b/src/hooks/useExportActions.ts @@ -123,7 +123,23 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa }); }; + const showEmptyReportDownloadErrorModal = () => { + showDecisionModal({ + title: translate('common.downloadFailedTitle'), + prompt: translate('common.downloadFailedEmptyReportDescription', {count: 1}), + secondOptionText: translate('common.buttonConfirm'), + }); + }; + + // A report without expenses has nothing to export, so the export is blocked the same way it is in the Search export flow. + const isEmptyReport = transactionIDs.length === 0 && (moneyRequestReport?.transactionCount ?? 0) === 0; + const beginExportWithTemplate = (templateName: string, templateType: string, transactionIDList: string[], exportName: string, policyID?: string) => { + if (isEmptyReport) { + showEmptyReportDownloadErrorModal(); + return; + } + if (isOffline) { showOfflineModal(); return; @@ -160,6 +176,10 @@ function useExportActions({reportID, policy, onPDFModalOpen}: UseExportActionsPa if (!moneyRequestReport) { return; } + if (isEmptyReport) { + showEmptyReportDownloadErrorModal(); + return; + } if (isOffline) { showOfflineModal(); return; diff --git a/tests/unit/hooks/useExportActionsTest.ts b/tests/unit/hooks/useExportActionsTest.ts index 124fac2df11c..352a8d18cb3f 100644 --- a/tests/unit/hooks/useExportActionsTest.ts +++ b/tests/unit/hooks/useExportActionsTest.ts @@ -70,9 +70,10 @@ jest.mock('@hooks/usePaginatedReportActions', () => ({ default: () => ({reportActions: []}), })); +let mockReportTransactions: Record = {transaction1: {transactionID: '1'}}; jest.mock('@hooks/useTransactionsAndViolationsForReport', () => ({ __esModule: true, - default: () => ({transactions: {}}), + default: () => ({transactions: mockReportTransactions}), })); jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({ @@ -95,6 +96,7 @@ describe('useExportActions - template export status modal', () => { beforeEach(() => { jest.clearAllMocks(); mockIsOffline = false; + mockReportTransactions = {transaction1: {transactionID: '1'}}; }); it('queues the export with progress tracking', () => { @@ -130,4 +132,16 @@ describe('useExportActions - template export status modal', () => { expect(mockQueueExportSearchWithTemplate).not.toHaveBeenCalled(); expect(mockShowDecisionModal).toHaveBeenCalled(); }); + + it('does not queue the export and shows the empty report modal when the report has no expenses', () => { + mockReportTransactions = {}; + const {result} = renderHook(() => useExportActions({reportID: REPORT_ID})); + + act(() => { + result.current.beginExportWithTemplate('Test Template', 'csv', [], EXPORT_NAME, POLICY_ID); + }); + + expect(mockQueueExportSearchWithTemplate).not.toHaveBeenCalled(); + expect(mockShowDecisionModal).toHaveBeenCalledWith(expect.objectContaining({prompt: 'common.downloadFailedEmptyReportDescription'})); + }); }); From 61a43c996124636af13fa61e483a670a7dfeb86e Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Thu, 17 Sep 2026 09:18:25 +0700 Subject: [PATCH 2/2] add empty guard in curren view & basic export --- src/hooks/useSearchBulkActions.ts | 34 +++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 5c55480d9bf3..4c3861f6ffe7 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -648,6 +648,20 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { .map((report) => report.reportID) .filter((reportID) => reportID !== undefined); + // Reports with no expenses have nothing to export, so every export flow blocks them instead of producing an empty file. + const emptyReports = useMemo( + () => + selectedReports.filter((selectedReport) => { + if (!selectedReport) { + return false; + } + const fullReport = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReport.reportID}`]; + return !!fullReport && (fullReport.transactionCount ?? 0) === 0; + }), + [selectedReports, currentSearchResults?.data], + ); + const hasOnlyEmptyReports = selectedReports.length > 0 && emptyReports.length === selectedReports.length; + const payableSelectedReports = useMemo(() => selectedReports.filter((report) => report.canPay), [selectedReports]); const payableSelectedReportIDs = useMemo(() => payableSelectedReports.map((report) => report.reportID).filter((reportID) => reportID !== undefined), [payableSelectedReports]); const payScopedReports = useMemo(() => (payableSelectedReports.length > 0 ? payableSelectedReports : selectedReports), [payableSelectedReports, selectedReports]); @@ -916,16 +930,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const beginExportWithTemplate = useCallback( (templateName: string, templateType: string, policyID: string | undefined, exportName: string) => { - const emptyReports = - selectedReports?.filter((selectedReport) => { - if (!selectedReport) { - return false; - } - const fullReport = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReport.reportID}`]; - return !!fullReport && (fullReport.transactionCount ?? 0) === 0; - }) ?? []; - const hasOnlyEmptyReports = selectedReports.length > 0 && emptyReports.length === selectedReports.length; - if (hasOnlyEmptyReports) { setEmptyReportsCount(emptyReports.length); setIsDownloadErrorModalVisible(true); @@ -976,7 +980,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { clearSelectedTransactions(undefined, true); }, [ - selectedReports, + hasOnlyEmptyReports, + emptyReports.length, selectedTransactions, isOffline, areAllMatchingItemsSelected, @@ -1056,6 +1061,11 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const handleCSVExport = useCallback( async (isBasicExport: boolean) => { + if (hasOnlyEmptyReports) { + setEmptyReportsCount(emptyReports.length); + setIsDownloadErrorModalVisible(true); + return; + } if (isOffline) { setIsOfflineModalVisible(true); return; @@ -1119,6 +1129,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } }, [ + hasOnlyEmptyReports, + emptyReports.length, isOffline, areAllMatchingItemsSelected, queryJSON,