From ddec4bed576264d0ff3b0dcd7d38cfaeaf89fd22 Mon Sep 17 00:00:00 2001 From: Rodrigo Lino da Costa Date: Fri, 16 Oct 2026 12:43:42 -0300 Subject: [PATCH 1/4] Add a groups-only current-view export option --- src/hooks/useSearchBulkActions.ts | 21 ++++- src/languages/de.ts | 1 + src/languages/el.ts | 1 + src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + .../ExportSearchItemsToCSVParams.ts | 1 + src/libs/actions/Search.ts | 5 +- tests/unit/SearchActionsTest.ts | 44 ++++++++++ .../hooks/useSearchBulkActionsExportTest.ts | 83 +++++++++++++++---- tests/unit/hooks/useSearchBulkActionsTest.ts | 14 +++- 17 files changed, 159 insertions(+), 20 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 839abe84f706..af127c232bfe 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1238,7 +1238,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { ); const handleCSVExport = useCallback( - async (isBasicExport: boolean) => { + async (isBasicExport: boolean, groupsOnly = false) => { if (hasOnlyEmptyReports) { showEmptyReportsDownloadErrorModal(emptyReports.length); return; @@ -1248,7 +1248,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } - const exportName = translate(isBasicExport ? 'export.basicExport' : 'export.currentView'); + const exportName = translate(groupsOnly ? 'export.currentViewGroupsOnly' : isBasicExport ? 'export.basicExport' : 'export.currentView'); if (areAllMatchingItemsSelected) { const supportsAllMatchingExclusions = isExpenseType || isExpenseReportType; @@ -1271,6 +1271,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { isBasicExport: exportParameters.isBasicExport, exportColumnLabels: exportParameters.exportColumnLabels, exportName, + groupsOnly, }); // Clear the selection now that the export has started. The ExportDownloadStatusManager shows the modal. @@ -1293,6 +1294,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { exportColumnLabels: exportParameters.exportColumnLabels, exportName, isGroupExport, + groupsOnly, }, () => { didFail = true; @@ -1333,7 +1335,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { ); const handleBasicExport = useCallback(() => handleCSVExport(true), [handleCSVExport]); - const handleExportCurrentView = useCallback(() => handleCSVExport(false), [handleCSVExport]); + const handleExportCurrentView = useCallback((groupsOnly = false) => handleCSVExport(false, groupsOnly), [handleCSVExport]); const handleApproveWithDEWCheck = useCallback(async () => { if (isOffline) { @@ -2447,6 +2449,19 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { addSeparatorBefore: true, }); + if (isGroupedSearch) { + exportOptions.push({ + text: translate('export.currentViewGroupsOnly'), + value: CONST.SEARCH.BULK_ACTION_TYPES.EXPORT, + icon: expensifyIcons.Table, + onSelected: () => { + handleExportCurrentView(true); + }, + shouldCloseModalOnSelect: true, + shouldCallAfterModalHide: true, + }); + } + if (!allSelectedAreDeleted && canExportGroupWithTemplates) { // Builds a single export sub-menu item for a template. `isDefaultTemplate` picks the icon and `addSeparatorBefore` draws the divider at the top of each group. const buildExportOption = (template: ExportTemplate, isDefaultTemplate: boolean, addSeparatorBefore: boolean): ExportMenuItem => { diff --git a/src/languages/de.ts b/src/languages/de.ts index 2232e54ffe6e..c618e68b4bd4 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -11791,6 +11791,7 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`, exportInProgress: 'Export wird ausgeführt', conciergeWillSend: 'Concierge wird dir die Datei in Kürze senden.', currentView: 'Aktuelle Ansicht', + currentViewGroupsOnly: 'Aktuelle Ansicht (nur Gruppen)', }, exportDownload: { preparingTitle: 'Preparing download...', diff --git a/src/languages/el.ts b/src/languages/el.ts index aabdddf7c603..af6c1492eec8 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -11986,6 +11986,7 @@ ${reportName}`, export: { basicExport: 'Βασική εξαγωγή', currentView: 'Τρέχουσα προβολή', + currentViewGroupsOnly: 'Τρέχουσα προβολή (μόνο ομάδες)', reportLevelExport: 'Όλα τα δεδομένα - σε επίπεδο αναφοράς', expenseLevelExport: 'Όλα τα δεδομένα - επίπεδο δαπάνης', multipleTaxExport: 'Καναδική εξαγωγή πολλαπλών φόρων', diff --git a/src/languages/en.ts b/src/languages/en.ts index d99217726540..a981e3b9d0e4 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -12062,6 +12062,7 @@ const translations = { downloadStatementPDF: 'Download statement', basicExport: 'Basic export', currentView: 'Current view', + currentViewGroupsOnly: 'Current view (groups only)', reportLevelExport: 'All Data - report level', expenseLevelExport: 'All Data - expense level', multipleTaxExport: 'Canadian Multiple Tax Export', diff --git a/src/languages/es.ts b/src/languages/es.ts index 4b83e523f960..7a040e4751a2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -11974,6 +11974,7 @@ ${reportName}`, exportInProgress: 'Exportación en curso', conciergeWillSend: 'Concierge te enviará el archivo en breve.', currentView: 'Vista actual', + currentViewGroupsOnly: 'Vista actual (solo grupos)', }, exportDownload: { preparingTitle: 'Preparando descarga...', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index b90b4e558e0a..bc712a78763e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -11817,6 +11817,7 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`, exportInProgress: 'Export en cours', conciergeWillSend: 'Concierge vous enverra le fichier sous peu.', currentView: 'Vue actuelle', + currentViewGroupsOnly: 'Vue actuelle (groupes uniquement)', }, exportDownload: { preparingTitle: 'Preparing download...', diff --git a/src/languages/it.ts b/src/languages/it.ts index 42a6a4dd4752..05a7c90b3132 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -11741,6 +11741,7 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`, exportInProgress: 'Esportazione in corso', conciergeWillSend: 'Concierge ti invierà il file a breve.', currentView: 'Vista corrente', + currentViewGroupsOnly: 'Vista corrente (solo gruppi)', }, exportDownload: { preparingTitle: 'Preparing download...', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d0fb89e3b691..86f68b655e75 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -11572,6 +11572,7 @@ ${reportName}`, exportInProgress: 'エクスポート処理中', conciergeWillSend: 'Conciergeがまもなくファイルを送信します。', currentView: '現在のビュー', + currentViewGroupsOnly: '現在のビュー(グループのみ)', }, exportDownload: { preparingTitle: 'Preparing download...', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index e0ac5562f928..7839d7818659 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -11702,6 +11702,7 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`, exportInProgress: 'Export bezig', conciergeWillSend: 'Concierge stuurt je het bestand zo meteen.', currentView: 'Huidige weergave', + currentViewGroupsOnly: 'Huidige weergave (alleen groepen)', }, exportDownload: { preparingTitle: 'Preparing download...', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 8b930f2fc707..7f9604be5bf9 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -11722,6 +11722,7 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`, exportInProgress: 'Trwa eksport', conciergeWillSend: 'Concierge wkrótce wyśle Ci plik.', currentView: 'Bieżący widok', + currentViewGroupsOnly: 'Bieżący widok (tylko grupy)', }, exportDownload: { preparingTitle: 'Preparing download...', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 4c3f0d6ff073..96eac0869d67 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -11695,6 +11695,7 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`, exportInProgress: 'Exportação em andamento', conciergeWillSend: 'O Concierge enviará o arquivo para você em breve.', currentView: 'Visualização atual', + currentViewGroupsOnly: 'Visualização atual (somente grupos)', }, exportDownload: { preparingTitle: 'Preparing download...', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 14b6f0ffd63b..81167dc845f7 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -11277,6 +11277,7 @@ ${reportName}`, exportInProgress: '导出进行中', conciergeWillSend: 'Concierge 将很快把文件发送给你。', currentView: '当前视图', + currentViewGroupsOnly: '当前视图(仅分组)', }, exportDownload: { preparingTitle: 'Preparing download...', diff --git a/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts b/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts index a241ba2d6069..130cf494ab38 100644 --- a/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts +++ b/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts @@ -9,6 +9,7 @@ type ExportSearchItemsToCSVParams = { exportColumnLabels: string; exportName: string; isGroupExport?: boolean; + groupsOnly?: boolean; }; export default ExportSearchItemsToCSVParams; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 84badc48dd84..744ad857d1d1 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -2069,7 +2069,7 @@ function rejectMoneyRequestsOnSearch({ type Params = Record; function exportSearchItemsToCSV( - {jsonQuery, reportIDList, transactionIDList, excludedTransactionIDList, isBasicExport, exportColumnLabels, exportName, isGroupExport}: ExportSearchItemsToCSVParams, + {jsonQuery, reportIDList, transactionIDList, excludedTransactionIDList, isBasicExport, exportColumnLabels, exportName, isGroupExport, groupsOnly}: ExportSearchItemsToCSVParams, onDownloadFailed: () => void, translate: LocalizedTranslate, allReportsTransactionsAndViolations: ReportTransactionsAndViolationsDerivedValue | undefined, @@ -2107,6 +2107,7 @@ function exportSearchItemsToCSV( isBasicExport, exportColumnLabels, isGroupExport, + groupsOnly, }) as Params; const formData = new FormData(); @@ -2140,6 +2141,7 @@ function queueExportSearchItemsToCSV({ isBasicExport, exportColumnLabels, exportName, + groupsOnly, }: ExportSearchItemsToCSVParams): string { const exportID = rand64(); const onyxKey = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}` as const; @@ -2174,6 +2176,7 @@ function queueExportSearchItemsToCSV({ exportColumnLabels, exportName, exportID, + groupsOnly, }) as QueueExportSearchItemsToCSVParams; markExportInitiatedLocally(exportID); diff --git a/tests/unit/SearchActionsTest.ts b/tests/unit/SearchActionsTest.ts index ebebf8088601..7d1b12b04e7b 100644 --- a/tests/unit/SearchActionsTest.ts +++ b/tests/unit/SearchActionsTest.ts @@ -194,6 +194,25 @@ describe('SearchActions', () => { }); describe('queueExportSearchItemsToCSV', () => { + it('passes the groups-only option to the queued export', () => { + // Given a summary-only export of a grouped search. + const parameters = { + jsonQuery: '{}', + reportIDList: [], + transactionIDList: [], + isBasicExport: false, + exportColumnLabels: '{}', + exportName: 'Current view (groups only)', + groupsOnly: true, + }; + + // When the export is queued. + queueExportSearchItemsToCSV(parameters); + + // Then the worker receives the summary-only flag. + expect(mockWrite).toHaveBeenCalledWith(WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_ITEMS_TO_CSV, expect.objectContaining({groupsOnly: true}), expect.any(Object)); + }); + it('sets optimistic Onyx data with state preparing and returns exportID', () => { const exportID = queueExportSearchItemsToCSV({ jsonQuery: '{}', @@ -255,6 +274,31 @@ describe('SearchActions', () => { describe('exportSearchItemsToCSV', () => { beforeEach(() => jest.clearAllMocks()); + it('passes the groups-only option in the direct CSV form payload', () => { + // Given a summary-only export of a grouped search. + const appendSpy = jest.spyOn(FormData.prototype, 'append'); + + // When the export is downloaded directly. + exportSearchItemsToCSV( + { + jsonQuery: '{}', + reportIDList: [], + transactionIDList: [], + isBasicExport: false, + exportColumnLabels: '{}', + exportName: 'Current view (groups only)', + groupsOnly: true, + }, + jest.fn(), + translateForTest, + undefined, + ); + + // Then the API receives the summary-only flag. + expect(appendSpy).toHaveBeenCalledWith('groupsOnly', 'true'); + appendSpy.mockRestore(); + }); + it('includes excluded transaction IDs in the direct CSV form payload', () => { const appendSpy = jest.spyOn(FormData.prototype, 'append'); diff --git a/tests/unit/hooks/useSearchBulkActionsExportTest.ts b/tests/unit/hooks/useSearchBulkActionsExportTest.ts index af91676bd091..3276a4cd5f10 100644 --- a/tests/unit/hooks/useSearchBulkActionsExportTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsExportTest.ts @@ -6,7 +6,14 @@ import type {SearchQueryJSON, SelectedReports, SelectedTransactions} from '@comp import useSearchBulkActions from '@hooks/useSearchBulkActions'; import {markAsManuallyExported} from '@libs/actions/Report'; -import {exportSearchItemsToCSV, exportToIntegrationOnSearch, getExportTemplates, queueBulkMarkAsExported, queueExportSearchWithTemplate} from '@libs/actions/Search'; +import { + exportSearchItemsToCSV, + exportToIntegrationOnSearch, + getExportTemplates, + queueBulkMarkAsExported, + queueExportSearchItemsToCSV, + queueExportSearchWithTemplate, +} from '@libs/actions/Search'; import type * as ReportSecondaryActionUtilsModule from '@libs/ReportSecondaryActionUtils'; import {getSelectedGroupFilterEntry} from '@libs/SearchUIUtils'; import type * as SearchUIUtilsModule from '@libs/SearchUIUtils'; @@ -1549,7 +1556,7 @@ describe('useSearchBulkActions - export options', () => { }); }); - it('keeps only Current view for a group selection on a time-based grouping', async () => { + it('keeps only current-view exports for a group selection on a time-based grouping', async () => { // Given a group selected on a search grouped by month, which has no filter a selected group can be turned into mockSelectedTransactions = { [`${CONST.SEARCH.GROUP_PREFIX}2026-10`]: makeSelectedTransaction(), @@ -1565,7 +1572,7 @@ describe('useSearchBulkActions - export options', () => { // Then no template is offered, because the template export could not scope the group and would only show the download error await waitFor(() => { - expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual(['export.currentView']); + expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual(['export.currentView', 'export.currentViewGroupsOnly']); }); }); @@ -1868,7 +1875,8 @@ describe('useSearchBulkActions - export options', () => { }); }); - it('offers Current view as the only plain-CSV export on a grouped search', async () => { + it('offers both current-view CSV options on a grouped search', async () => { + // Given a selected expense in a grouped search. mockSelectedTransactions = {tx1: makeSelectedTransaction()}; const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); @@ -1877,14 +1885,59 @@ describe('useSearchBulkActions - export options', () => { expect(getExportOptionByText(result.current.headerButtonsOptions, 'export.currentView')).toBeDefined(); }); + // When the groups-only option is selected. + getExportOptionByText(result.current.headerButtonsOptions, 'export.currentViewGroupsOnly')?.onSelected?.(); + + // Then it exports group summaries using the current-view metadata. + await waitFor(() => { + expect(exportSearchItemsToCSV).toHaveBeenCalled(); + }); + expect(getLastCSVExportParameters()).toEqual(expect.objectContaining({groupsOnly: true, isBasicExport: false, exportName: 'export.currentViewGroupsOnly'})); expect(getExportOptionTexts(result.current.headerButtonsOptions)).not.toContain('export.basicExport'); + + // When the existing current-view option is selected. + getExportOptionByText(result.current.headerButtonsOptions, 'export.currentView')?.onSelected?.(); + + // Then the full export remains the default. + expect(getLastCSVExportParameters().groupsOnly).toBe(false); + }); + + it('does not offer a groups-only export on an ungrouped search', async () => { + // Given a selected expense in an ungrouped search. + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + const {result} = renderHook(() => useSearchBulkActions({queryJSON: ungroupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); + + // When the export menu becomes available. + await waitFor(() => { + expect(getExportOptionByText(result.current.headerButtonsOptions, 'export.currentView')).toBeDefined(); + }); + + // Then only grouped searches offer the summary-only mode. + expect(getExportOptionByText(result.current.headerButtonsOptions, 'export.currentViewGroupsOnly')).toBeUndefined(); + }); + + it('queues a groups-only export for all matching items', async () => { + // Given all matching expenses selected in a grouped search. + mockAreAllMatchingItemsSelected = true; + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); + await waitFor(() => { + expect(getExportOptionByText(result.current.headerButtonsOptions, 'export.currentViewGroupsOnly')).toBeDefined(); + }); + + // When the groups-only option is selected. + getExportOptionByText(result.current.headerButtonsOptions, 'export.currentViewGroupsOnly')?.onSelected?.(); + + // Then the queued export retains the summary-only flag and grouping. + await waitFor(() => { + expect(queueExportSearchItemsToCSV).toHaveBeenCalledWith(expect.objectContaining({groupsOnly: true, isBasicExport: false})); + }); + const parameters = jest.mocked(queueExportSearchItemsToCSV).mock.calls.at(-1)?.at(0); + expect(JSON.parse(parameters?.jsonQuery ?? '{}')).toHaveProperty('groupBy', CONST.SEARCH.GROUP_BY.CATEGORY); }); - it('keeps the Export entry as a submenu even when only one export option is available', async () => { - // Regression test for https://github.com/Expensify/App/issues/98779: a deleted selection on a grouped search - // offers a single export option ('export.currentView'). While other bulk actions sit alongside it (Undelete - // here), the Export entry must still open the Export submenu (keeping its generic label and subMenuItems) - // rather than collapsing straight into that single option. + it('keeps the Export entry as a submenu for grouped exports alongside other actions', async () => { + // Given deleted expenses in a grouped search, where Undelete sits alongside Export. mockSelectedTransactions = { tx1: makeSelectedTransaction({reportID: CONST.REPORT.TRASH_REPORT_ID}), }; @@ -1892,20 +1945,20 @@ describe('useSearchBulkActions - export options', () => { const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); await waitFor(() => { - expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual(['export.currentView']); + expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual(['export.currentView', 'export.currentViewGroupsOnly']); }); const exportOption = result.current.headerButtonsOptions.find((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT); - expect(exportOption?.subMenuItems).toHaveLength(1); + expect(exportOption?.subMenuItems).toHaveLength(2); expect(exportOption?.text).toBe('common.export'); // The Export row itself labels the submenu here, so the dropdown must not also carry an "Export" header. expect(result.current.bulkActionsMenuHeaderText).toBeUndefined(); }); - it('opens directly onto the single export option when Export is the only bulk action', async () => { + it('opens directly onto grouped export options when Export is the only bulk action', async () => { // Export is the only bulk action offered under select all, so the dropdown has no main menu to go back to. - // The one export option is surfaced directly instead of behind an "Export" row whose submenu would render a - // back arrow leading nowhere, with "Export" kept as a plain dropdown header so the option still has context. + // The export options are surfaced directly instead of behind an "Export" row whose submenu would render a + // back arrow leading nowhere, with "Export" kept as a plain dropdown header so the options still have context. // This only applies to the dropdown: the bar renders each action as its own button, so `headerButtonsOptions` // keeps the nested shape regardless. mockAreAllMatchingItemsSelected = true; @@ -1916,7 +1969,7 @@ describe('useSearchBulkActions - export options', () => { const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); await waitFor(() => { - expect(result.current.dropdownButtonsOptions.map((option) => option.text)).toEqual(['export.currentView']); + expect(result.current.dropdownButtonsOptions.map((option) => option.text)).toEqual(['export.currentView', 'export.currentViewGroupsOnly']); }); const soleOption = result.current.dropdownButtonsOptions.at(0); diff --git a/tests/unit/hooks/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts index d5c952ef1bbd..b3136562c27b 100644 --- a/tests/unit/hooks/useSearchBulkActionsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsTest.ts @@ -349,7 +349,7 @@ describe('useSearchBulkActions - CSV export flow', () => { expect(mockQueueExportSearchItemsToCSV).toHaveBeenCalledWith(expect.objectContaining({excludedTransactionIDList: ['tx2']})); }); - it('exports an excluded unloaded group as a query filter instead of a transaction ID', async () => { + it('exports excluded unloaded groups in a groups-only export as query filters', async () => { const excludedGroupKey = `${CONST.SEARCH.GROUP_PREFIX}123` as const; mockAreAllMatchingItemsSelected = true; mockSelectedTransactions = {tx1: makeSelectedTransaction()}; @@ -378,6 +378,18 @@ describe('useSearchBulkActions - CSV export flow', () => { expect(exportPayload?.jsonQuery).toContain('Excluded merchant'); expect(exportPayload?.jsonQuery).toContain(`"operator":"${CONST.SEARCH.SYNTAX_OPERATORS.NOT_EQUAL_TO}"`); expect(exportPayload?.jsonQuery).not.toContain('group_123'); + + // When the same exclusions are exported as summaries only. + await act(async () => { + getExportMenuItems(result.current.headerButtonsOptions) + .find((item) => item.text === 'export.currentViewGroupsOnly') + ?.onSelected?.(); + }); + + // Then both the summary-only flag and exclusions reach the queued export. + expect(mockQueueExportSearchItemsToCSV).toHaveBeenLastCalledWith( + expect.objectContaining({groupsOnly: true, excludedTransactionIDList: ['tx2'], jsonQuery: exportPayload?.jsonQuery}), + ); }); it('exports a selected merchant group with an exact merchant match', async () => { From a2357218af614c72fdb58a583ae24f090d31dbeb Mon Sep 17 00:00:00 2001 From: Rodrigo Lino da Costa Date: Fri, 16 Oct 2026 16:28:08 -0300 Subject: [PATCH 2/4] Rename the groups-only export parameter to isGroupsOnly --- src/hooks/useSearchBulkActions.ts | 10 +++++----- .../API/parameters/ExportSearchItemsToCSVParams.ts | 2 +- src/libs/actions/Search.ts | 8 ++++---- tests/unit/SearchActionsTest.ts | 8 ++++---- tests/unit/hooks/useSearchBulkActionsExportTest.ts | 6 +++--- tests/unit/hooks/useSearchBulkActionsTest.ts | 2 +- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index af127c232bfe..3f7f0613a132 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1238,7 +1238,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { ); const handleCSVExport = useCallback( - async (isBasicExport: boolean, groupsOnly = false) => { + async (isBasicExport: boolean, isGroupsOnly = false) => { if (hasOnlyEmptyReports) { showEmptyReportsDownloadErrorModal(emptyReports.length); return; @@ -1248,7 +1248,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } - const exportName = translate(groupsOnly ? 'export.currentViewGroupsOnly' : isBasicExport ? 'export.basicExport' : 'export.currentView'); + const exportName = translate(isGroupsOnly ? 'export.currentViewGroupsOnly' : isBasicExport ? 'export.basicExport' : 'export.currentView'); if (areAllMatchingItemsSelected) { const supportsAllMatchingExclusions = isExpenseType || isExpenseReportType; @@ -1271,7 +1271,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { isBasicExport: exportParameters.isBasicExport, exportColumnLabels: exportParameters.exportColumnLabels, exportName, - groupsOnly, + isGroupsOnly, }); // Clear the selection now that the export has started. The ExportDownloadStatusManager shows the modal. @@ -1294,7 +1294,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { exportColumnLabels: exportParameters.exportColumnLabels, exportName, isGroupExport, - groupsOnly, + isGroupsOnly, }, () => { didFail = true; @@ -1335,7 +1335,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { ); const handleBasicExport = useCallback(() => handleCSVExport(true), [handleCSVExport]); - const handleExportCurrentView = useCallback((groupsOnly = false) => handleCSVExport(false, groupsOnly), [handleCSVExport]); + const handleExportCurrentView = useCallback((isGroupsOnly = false) => handleCSVExport(false, isGroupsOnly), [handleCSVExport]); const handleApproveWithDEWCheck = useCallback(async () => { if (isOffline) { diff --git a/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts b/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts index 130cf494ab38..1e982e4d7f44 100644 --- a/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts +++ b/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts @@ -9,7 +9,7 @@ type ExportSearchItemsToCSVParams = { exportColumnLabels: string; exportName: string; isGroupExport?: boolean; - groupsOnly?: boolean; + isGroupsOnly?: boolean; }; export default ExportSearchItemsToCSVParams; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 744ad857d1d1..f8d4070e1db7 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -2069,7 +2069,7 @@ function rejectMoneyRequestsOnSearch({ type Params = Record; function exportSearchItemsToCSV( - {jsonQuery, reportIDList, transactionIDList, excludedTransactionIDList, isBasicExport, exportColumnLabels, exportName, isGroupExport, groupsOnly}: ExportSearchItemsToCSVParams, + {jsonQuery, reportIDList, transactionIDList, excludedTransactionIDList, isBasicExport, exportColumnLabels, exportName, isGroupExport, isGroupsOnly}: ExportSearchItemsToCSVParams, onDownloadFailed: () => void, translate: LocalizedTranslate, allReportsTransactionsAndViolations: ReportTransactionsAndViolationsDerivedValue | undefined, @@ -2107,7 +2107,7 @@ function exportSearchItemsToCSV( isBasicExport, exportColumnLabels, isGroupExport, - groupsOnly, + isGroupsOnly, }) as Params; const formData = new FormData(); @@ -2141,7 +2141,7 @@ function queueExportSearchItemsToCSV({ isBasicExport, exportColumnLabels, exportName, - groupsOnly, + isGroupsOnly, }: ExportSearchItemsToCSVParams): string { const exportID = rand64(); const onyxKey = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}` as const; @@ -2176,7 +2176,7 @@ function queueExportSearchItemsToCSV({ exportColumnLabels, exportName, exportID, - groupsOnly, + isGroupsOnly, }) as QueueExportSearchItemsToCSVParams; markExportInitiatedLocally(exportID); diff --git a/tests/unit/SearchActionsTest.ts b/tests/unit/SearchActionsTest.ts index 7d1b12b04e7b..42ee1a3b4b75 100644 --- a/tests/unit/SearchActionsTest.ts +++ b/tests/unit/SearchActionsTest.ts @@ -203,14 +203,14 @@ describe('SearchActions', () => { isBasicExport: false, exportColumnLabels: '{}', exportName: 'Current view (groups only)', - groupsOnly: true, + isGroupsOnly: true, }; // When the export is queued. queueExportSearchItemsToCSV(parameters); // Then the worker receives the summary-only flag. - expect(mockWrite).toHaveBeenCalledWith(WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_ITEMS_TO_CSV, expect.objectContaining({groupsOnly: true}), expect.any(Object)); + expect(mockWrite).toHaveBeenCalledWith(WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_ITEMS_TO_CSV, expect.objectContaining({isGroupsOnly: true}), expect.any(Object)); }); it('sets optimistic Onyx data with state preparing and returns exportID', () => { @@ -287,7 +287,7 @@ describe('SearchActions', () => { isBasicExport: false, exportColumnLabels: '{}', exportName: 'Current view (groups only)', - groupsOnly: true, + isGroupsOnly: true, }, jest.fn(), translateForTest, @@ -295,7 +295,7 @@ describe('SearchActions', () => { ); // Then the API receives the summary-only flag. - expect(appendSpy).toHaveBeenCalledWith('groupsOnly', 'true'); + expect(appendSpy).toHaveBeenCalledWith('isGroupsOnly', 'true'); appendSpy.mockRestore(); }); diff --git a/tests/unit/hooks/useSearchBulkActionsExportTest.ts b/tests/unit/hooks/useSearchBulkActionsExportTest.ts index 3276a4cd5f10..726a9b346b22 100644 --- a/tests/unit/hooks/useSearchBulkActionsExportTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsExportTest.ts @@ -1892,14 +1892,14 @@ describe('useSearchBulkActions - export options', () => { await waitFor(() => { expect(exportSearchItemsToCSV).toHaveBeenCalled(); }); - expect(getLastCSVExportParameters()).toEqual(expect.objectContaining({groupsOnly: true, isBasicExport: false, exportName: 'export.currentViewGroupsOnly'})); + expect(getLastCSVExportParameters()).toEqual(expect.objectContaining({isGroupsOnly: true, isBasicExport: false, exportName: 'export.currentViewGroupsOnly'})); expect(getExportOptionTexts(result.current.headerButtonsOptions)).not.toContain('export.basicExport'); // When the existing current-view option is selected. getExportOptionByText(result.current.headerButtonsOptions, 'export.currentView')?.onSelected?.(); // Then the full export remains the default. - expect(getLastCSVExportParameters().groupsOnly).toBe(false); + expect(getLastCSVExportParameters().isGroupsOnly).toBe(false); }); it('does not offer a groups-only export on an ungrouped search', async () => { @@ -1930,7 +1930,7 @@ describe('useSearchBulkActions - export options', () => { // Then the queued export retains the summary-only flag and grouping. await waitFor(() => { - expect(queueExportSearchItemsToCSV).toHaveBeenCalledWith(expect.objectContaining({groupsOnly: true, isBasicExport: false})); + expect(queueExportSearchItemsToCSV).toHaveBeenCalledWith(expect.objectContaining({isGroupsOnly: true, isBasicExport: false})); }); const parameters = jest.mocked(queueExportSearchItemsToCSV).mock.calls.at(-1)?.at(0); expect(JSON.parse(parameters?.jsonQuery ?? '{}')).toHaveProperty('groupBy', CONST.SEARCH.GROUP_BY.CATEGORY); diff --git a/tests/unit/hooks/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts index b3136562c27b..fdc798280e81 100644 --- a/tests/unit/hooks/useSearchBulkActionsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsTest.ts @@ -388,7 +388,7 @@ describe('useSearchBulkActions - CSV export flow', () => { // Then both the summary-only flag and exclusions reach the queued export. expect(mockQueueExportSearchItemsToCSV).toHaveBeenLastCalledWith( - expect.objectContaining({groupsOnly: true, excludedTransactionIDList: ['tx2'], jsonQuery: exportPayload?.jsonQuery}), + expect.objectContaining({isGroupsOnly: true, excludedTransactionIDList: ['tx2'], jsonQuery: exportPayload?.jsonQuery}), ); }); From ea298c0cc7388549212dadf13f223b443b91b7c4 Mon Sep 17 00:00:00 2001 From: Rodrigo Lino da Costa Date: Thu, 22 Oct 2026 18:09:57 -0300 Subject: [PATCH 3/4] Fix nested ternary in search export label selection --- src/hooks/useSearchBulkActions.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 3f7f0613a132..1c5654efa91c 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1248,7 +1248,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } - const exportName = translate(isGroupsOnly ? 'export.currentViewGroupsOnly' : isBasicExport ? 'export.basicExport' : 'export.currentView'); + const fullExportNameKey = isBasicExport ? 'export.basicExport' : 'export.currentView'; + const exportName = translate(isGroupsOnly ? 'export.currentViewGroupsOnly' : fullExportNameKey); if (areAllMatchingItemsSelected) { const supportsAllMatchingExclusions = isExpenseType || isExpenseReportType; From 1308132c274492bfde2c2e8686a9844c9880ea7f Mon Sep 17 00:00:00 2001 From: Rodrigo Lino da Costa Date: Thu, 22 Oct 2026 18:13:19 -0300 Subject: [PATCH 4/4] Update card-group export menu expectations for groups-only CSV --- tests/unit/hooks/useSearchBulkActionsExportTest.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/unit/hooks/useSearchBulkActionsExportTest.ts b/tests/unit/hooks/useSearchBulkActionsExportTest.ts index 726a9b346b22..0b0f768b6587 100644 --- a/tests/unit/hooks/useSearchBulkActionsExportTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsExportTest.ts @@ -1635,6 +1635,7 @@ describe('useSearchBulkActions - export options', () => { await waitFor(() => { expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual([ 'export.currentView', + 'export.currentViewGroupsOnly', 'Custom template', 'export.expenseLevelExport', 'export.reconciliationAllExpenses', @@ -1656,6 +1657,7 @@ describe('useSearchBulkActions - export options', () => { await waitFor(() => { expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual([ 'export.currentView', + 'export.currentViewGroupsOnly', 'Custom template', 'export.expenseLevelExport', 'export.reconciliationAllExpenses', @@ -1674,7 +1676,13 @@ describe('useSearchBulkActions - export options', () => { // Then Reconciliation is not offered, because a card group selection only shows the templates the user was already entitled to and must never hand out one they were not await waitFor(() => { - expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual(['export.currentView', 'Custom template', 'export.expenseLevelExport', 'export.reportLevelExport']); + expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual([ + 'export.currentView', + 'export.currentViewGroupsOnly', + 'Custom template', + 'export.expenseLevelExport', + 'export.reportLevelExport', + ]); }); expect(getExportOptionTexts(result.current.headerButtonsOptions)).not.toContain('export.reconciliationAllExpenses'); });