diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 4fdaaa1d9cde..5ac5f64a8909 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -199,6 +199,24 @@ function isGroupSelection(key: string, transaction: SelectedTransactions[string] return key.startsWith(CONST.SEARCH.GROUP_PREFIX) || (!!transaction.isSelectedViaGroup && !!transaction.groupKey); } +/** + * How a selection on a grouped search has to be exported. + * + * Selecting a group stores its loaded children with isSelectedViaGroup. The group key is only stored when no children are loaded. + * Use isGroupSelection rather than the key prefix so exports include every item matching the group filter. + * + * `transactionIDList` drops the rows covered by that filter and keeps the rows the user ticked individually + * alongside them. + */ +function getGroupExportScope(queryJSON: SearchQueryJSON | undefined, selectedTransactions: SelectedTransactions): {isGroupExport: boolean; transactionIDList: string[]} { + const selectedTransactionsKeys = Object.keys(selectedTransactions); + const isGroupExport = !!queryJSON?.groupBy && Object.entries(selectedTransactions).some(([key, transaction]) => isGroupSelection(key, transaction)); + return { + isGroupExport, + transactionIDList: isGroupExport ? selectedTransactionsKeys.filter((key) => !isGroupSelection(key, selectedTransactions[key])) : selectedTransactionsKeys, + }; +} + /** * The group rows a selection covers in full. * @@ -958,19 +976,20 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { true, ); } else { - const isGroupExport = !!queryJSON?.groupBy && selectedTransactionsKeys.some((key) => key.startsWith(CONST.SEARCH.GROUP_PREFIX)); + const {isGroupExport, transactionIDList} = getGroupExportScope(queryJSON, selectedTransactions); queueExportSearchWithTemplate( { templateName, templateType, - jsonQuery: isGroupExport - ? serializeQueryJSONForBackend( - addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data), - getGroupExportExactMatchFilterKeys(queryJSON.groupBy), - ) - : '{}', + jsonQuery: + isGroupExport && queryJSON + ? serializeQueryJSONForBackend( + addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data), + getGroupExportExactMatchFilterKeys(queryJSON.groupBy), + ) + : '{}', reportIDList: isGroupExport ? [] : selectedTransactionReportIDs, - transactionIDList: isGroupExport ? [] : selectedTransactionsKeys, + transactionIDList, policyID, exportName, }, @@ -991,7 +1010,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { currentSearchResults?.data, queryJSON, selectedTransactionReportIDs, - selectedTransactionsKeys, selectAllMatchingItems, clearSelectedTransactions, ], @@ -1103,8 +1121,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } - const isGroupExport = !!queryJSON?.groupBy && Object.entries(selectedTransactions).some(([key, transaction]) => isGroupSelection(key, transaction)); - const transactionIDList = isGroupExport ? selectedTransactionsKeys.filter((key) => !isGroupSelection(key, selectedTransactions[key])) : selectedTransactionsKeys; + const {isGroupExport, transactionIDList} = getGroupExportScope(queryJSON, selectedTransactions); let didFail = false; const reportIDList = selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs; const queryJSONToExport = isGroupExport && queryJSON ? addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data) : queryJSON; @@ -1929,7 +1946,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const includeReportLevelExport = ((isExpenseReportType || typeInvoice) && areFullReportsSelected) || (typeExpense && !isExpenseReportType && isAllOneTransactionReport); - const isGroupedSearch = !!getValidGroupBy(queryJSON?.groupBy); + const groupBy = getValidGroupBy(queryJSON?.groupBy); + const isGroupedSearch = !!groupBy; const policy = selectedPolicyIDs.length === 1 ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedPolicyIDs.at(0)}`] : undefined; // The export templates available to the user, pre-grouped and sorted alphabetically. The basic export is part of the default group so it's sorted alongside @@ -1957,6 +1975,14 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const isReportsTab = isExpenseReportType; const includesGroupExport = isGroupedSearch && Object.entries(selectedTransactions).some(([key, selectedTransaction]) => isGroupSelection(key, selectedTransaction)); + // Group selections are sent as query filters, not report or transaction IDs, so template exports are usually unavailable. + // Reconciliation - All Expenses is supported for card-grouped searches because selected card groups become a `cardID:` + // filter, which template reports can use to scope card spend. + const includesCardGroupExport = includesGroupExport && groupBy === CONST.SEARCH.GROUP_BY.CARD; + const customTemplatesToShow = includesCardGroupExport ? [] : availableCustomTemplates; + const defaultTemplatesToShow = includesCardGroupExport + ? availableDefaultTemplates.filter((template) => template.templateName === CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES) + : availableDefaultTemplates; const canReportBeExported = (report: (typeof selectedReports)[0], exportOption: ValueOf) => { if (!report.reportID) { @@ -2215,7 +2241,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { addSeparatorBefore: true, }); - if (!allSelectedAreDeleted && !includesGroupExport) { + if (!allSelectedAreDeleted && (!includesGroupExport || includesCardGroupExport)) { // 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 => { // The basic export is a plain CSV download, so it uses its own handler rather than the template export flow @@ -2239,10 +2265,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }; // Add each group's templates separately so the icon and the group-boundary divider come from the group itself, not from an index into a combined list. - for (const [index, template] of availableCustomTemplates.entries()) { + for (const [index, template] of customTemplatesToShow.entries()) { exportOptions.push(buildExportOption(template, false, index === 0)); } - for (const [index, template] of availableDefaultTemplates.entries()) { + for (const [index, template] of defaultTemplatesToShow.entries()) { exportOptions.push(buildExportOption(template, true, index === 0)); } } else if (!isGroupedSearch) { diff --git a/tests/unit/hooks/useSearchBulkActionsExportTest.ts b/tests/unit/hooks/useSearchBulkActionsExportTest.ts index e0e43aef46d7..c509ab657873 100644 --- a/tests/unit/hooks/useSearchBulkActionsExportTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsExportTest.ts @@ -6,8 +6,9 @@ import type {SearchQueryJSON, SelectedReports, SelectedTransactions} from '@comp import useSearchBulkActions from '@hooks/useSearchBulkActions'; import {markAsManuallyExported} from '@libs/actions/Report'; -import {exportSearchItemsToCSV, exportToIntegrationOnSearch, getExportTemplates} from '@libs/actions/Search'; +import {exportSearchItemsToCSV, exportToIntegrationOnSearch, getExportTemplates, queueExportSearchWithTemplate} from '@libs/actions/Search'; import type * as ReportSecondaryActionUtilsModule from '@libs/ReportSecondaryActionUtils'; +import {getSelectedGroupFilterEntry} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import type CONSTType from '@src/CONST'; @@ -198,11 +199,14 @@ jest.mock('@hooks/useUndeleteTransactions', () => ({ })); jest.mock('@libs/SearchUIUtils', () => { + const actualCONSTForSearchUIUtils = jest.requireActual<{default: typeof CONSTType}>('@src/CONST').default; return { shouldShowDeleteOption: () => false, getSelectedGroupFilterEntry: jest.fn(), + isGroupEntry: (key: string) => key.startsWith(actualCONSTForSearchUIUtils.SEARCH.GROUP_PREFIX), navigateToSearchRHP: jest.fn(), - getValidGroupBy: jest.fn((groupBy?: string) => groupBy), + // The real validator, so the `groupBy === GROUP_BY.CARD` comparison is exercised against it rather than an identity stub. + getValidGroupBy: jest.fn(jest.requireActual<{getValidGroupBy: (groupBy?: string) => string | undefined}>('@libs/SearchUIUtils').getValidGroupBy), getSearchColumnTranslationKey: jest.fn((column: string) => column), getColumnsToShow: jest.fn(() => []), insertColumnBeforeTotalAmount: (columns: string[], columnId: string) => { @@ -306,6 +310,13 @@ const groupedExpenseQueryJSON: SearchQueryJSON = { groupBy: CONST.SEARCH.GROUP_BY.CATEGORY, }; +/** The shape of the Card statements and Card accruals suggested searches: an expense search grouped by card. */ +const cardGroupedExpenseQueryJSON: SearchQueryJSON = { + ...groupedExpenseQueryJSON, + inputQuery: `type:expense groupBy:${CONST.SEARCH.GROUP_BY.CARD}`, + groupBy: CONST.SEARCH.GROUP_BY.CARD, +}; + const groupedSubmittedViolationQueryJSON: SearchQueryJSON = { ...groupedExpenseQueryJSON, inputQuery: `type:expense groupBy:${CONST.SEARCH.GROUP_BY.FROM} has:${CONST.SEARCH.HAS_VALUES.SUBMITTED_VIOLATION}`, @@ -1234,6 +1245,187 @@ describe('useSearchBulkActions - export options', () => { }); }); + describe('Reconciliation - All Expenses on a card group selection', () => { + const CARD_GROUP_KEY = `${CONST.SEARCH.GROUP_PREFIX}1234`; + // jest.clearAllMocks() only clears recorded calls, so the template list each test installs has to be undone by hand. + const defaultExportTemplates = mockGetExportTemplates.getMockImplementation(); + + afterEach(() => { + mockGetExportTemplates.mockImplementation(defaultExportTemplates); + }); + + /** Offer the Reconciliation template alongside the templates that stay hidden for a group selection. */ + function mockTemplatesIncludingReconciliation() { + mockGetExportTemplates.mockReturnValue({ + customTemplates: [{name: 'Custom template', templateName: 'customTemplate', type: 'in-app', policyID: undefined, description: ''}], + defaultTemplates: [ + {name: 'export.expenseLevelExport', templateName: CONST.REPORT.EXPORT_OPTIONS.EXPENSE_LEVEL_EXPORT, type: 'integrations', policyID: undefined, description: ''}, + { + name: 'export.reconciliationAllExpenses', + templateName: CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES, + type: 'integrations', + policyID: undefined, + description: '', + }, + ], + }); + } + + /** + * A selection made by ticking a populated card group's checkbox. `SearchWriteActionsProvider` stores the + * group's loaded children stamped with `groupKey`/`isSelectedViaGroup` and does NOT store the group key + * itself. It only stores that when the group had no children loaded (see `selectEmptyCardGroup`). + */ + function selectCardGroup() { + mockSelectedTransactions = { + tx1: makeSelectedTransaction({groupKey: CARD_GROUP_KEY, isSelectedViaGroup: true}), + }; + } + + /** Ticking a card group row whose children were never loaded: the group key is stored on its own. */ + function selectEmptyCardGroup() { + mockSelectedTransactions = { + [CARD_GROUP_KEY]: makeSelectedTransaction(), + }; + } + + it('offers the Reconciliation template, and only that template, for a card group selection', async () => { + // Given a user who qualifies for the Reconciliation template, offered alongside the templates that stay hidden for a group selection + mockTemplatesIncludingReconciliation(); + // Given a ticked card group row, because selecting the card group rather than the individual expenses is how an admin reconciles a statement + selectCardGroup(); + + // When the export menu is built for a search grouped by card + const {result} = renderHook(() => useSearchBulkActions({queryJSON: cardGroupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); + + // Then Reconciliation is the one template still offered, because the selected card groups can be expressed as a `cardID:` filter while the other templates have no way to describe a group + await waitFor(() => { + expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual(['export.currentView', 'export.reconciliationAllExpenses']); + }); + }); + + it('offers the Reconciliation template for a card group whose children were never loaded', async () => { + // Given a user who qualifies for the Reconciliation template + mockTemplatesIncludingReconciliation(); + // Given a card group ticked before any of its children were loaded, so the selection holds the group key on its own instead of stamped children + selectEmptyCardGroup(); + + // When the export menu is built for a search grouped by card + const {result} = renderHook(() => useSearchBulkActions({queryJSON: cardGroupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); + + // Then the template is offered just the same, because both shapes a card group selection can take have to reach the carve-out or the option would come and go with how far the list happened to be scrolled + await waitFor(() => { + expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual(['export.currentView', 'export.reconciliationAllExpenses']); + }); + }); + + it('keeps every template hidden when the group selection is grouped by something other than card', async () => { + // Given a user who qualifies for the Reconciliation template + mockTemplatesIncludingReconciliation(); + // Given a ticked group row + selectCardGroup(); + + // When the export menu is built for a search grouped by category instead of by card + const {result} = renderHook(() => useSearchBulkActions({queryJSON: groupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); + + // Then no template is offered at all, because the carve-out is limited to card groups and every other grouping keeps the existing rule that a group cannot be scoped + await waitFor(() => { + expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual(['export.currentView']); + }); + }); + + it('hides the Reconciliation template for a card group selection when the user is not a card-enabled admin', async () => { + // Given a user who does not qualify for the template, so getExportTemplates leaves it out entirely + // Given a ticked card group row + selectCardGroup(); + + // When the export menu is built for a search grouped by card + const {result} = renderHook(() => useSearchBulkActions({queryJSON: cardGroupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); + + // Then only Current view is offered, because the carve-out narrows 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']); + }); + }); + + // Regression test for detecting the group export from the child metadata: with only `isSelectedViaGroup` + // children selected, looking for the group prefix alone misses the group and exports the loaded IDs instead + // of the `cardID:` filter, so a group with paginated children would export incompletely. + it('scopes the Reconciliation export to the selected card groups instead of a transaction ID list', async () => { + // Given a user who qualifies for the Reconciliation template + mockTemplatesIncludingReconciliation(); + // Given a ticked card group + selectCardGroup(); + // Given one expense ticked on its own alongside it, because that expense belongs to another card and so is not covered by the group's filter + mockSelectedTransactions.tx2 = makeSelectedTransaction(); + const searchResults = makeSearchResults([]); + // Given the card group row as it arrives in the search snapshot, which the group's filter entry is derived from + Object.assign(searchResults.data, {[CARD_GROUP_KEY]: {cardID: 1234}}); + mockCurrentSearchResults = searchResults; + jest.mocked(getSelectedGroupFilterEntry).mockReturnValue({key: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, value: 1234}); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: cardGroupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getExportOptionByText(result.current.headerButtonsOptions, 'export.reconciliationAllExpenses')).toBeDefined(); + }); + + // When the user runs the Reconciliation export on that selection + getExportOptionByText(result.current.headerButtonsOptions, 'export.reconciliationAllExpenses')?.onSelected?.(); + + await waitFor(() => { + expect(queueExportSearchWithTemplate).toHaveBeenCalled(); + }); + + const [parameters] = jest.mocked(queueExportSearchWithTemplate).mock.calls.at(-1) ?? []; + // Then the chosen template is the one that runs + expect(parameters?.templateName).toBe(CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES); + // Then the card groups travel as a `cardID:` filter on the query rather than as IDs, so the export covers + // every expense on the card and not only the rows that happened to be loaded, while the expense ticked on + // its own is still sent as an ID so it is not dropped, matching what "Current view" does. + expect(parameters?.reportIDList).toEqual([]); + expect(parameters?.transactionIDList).toEqual(['tx2']); + expect(parameters?.jsonQuery).toContain(CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID); + }); + + // The template export and Current view both scope a group selection through `getGroupExportScope`. Pinning + // Current view to the same expectation is what stops the two paths drifting apart again: the gate that shows + // the Reconciliation option and the gate that scopes it have to agree, or a card group exports incompletely. + it('scopes the Current view export of a card group the same way as the Reconciliation template', async () => { + // Given the same selection the Reconciliation export was checked against, so the two paths are compared on identical input + mockTemplatesIncludingReconciliation(); + selectCardGroup(); + // Given one expense ticked on its own alongside the group, because that expense belongs to another card and so is not covered by the group's filter + mockSelectedTransactions.tx2 = makeSelectedTransaction(); + const searchResults = makeSearchResults([]); + Object.assign(searchResults.data, {[CARD_GROUP_KEY]: {cardID: 1234}}); + mockCurrentSearchResults = searchResults; + jest.mocked(getSelectedGroupFilterEntry).mockReturnValue({key: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, value: 1234}); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: cardGroupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getExportOptionByText(result.current.headerButtonsOptions, 'export.currentView')).toBeDefined(); + }); + + // When the user runs the Current view export instead of the template + getExportOptionByText(result.current.headerButtonsOptions, 'export.currentView')?.onSelected?.(); + + await waitFor(() => { + expect(exportSearchItemsToCSV).toHaveBeenCalled(); + }); + + const {isGroupExport, reportIDList, transactionIDList, query} = getLastCSVExportParameters(); + // Then it covers exactly the same rows, because an admin who exports the same selection twice must get the + // same scope back, and the two paths drifting apart is what made a card group export incompletely. + expect(isGroupExport).toBe(true); + expect(reportIDList).toEqual([]); + expect(transactionIDList).toEqual(['tx2']); + // Then the selected card groups reach the backend as a `cardID:` filter on the query rather than as IDs + expect(JSON.stringify(query)).toContain(CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID); + }); + }); + it('offers Current view as the only plain-CSV export on a grouped search', async () => { mockSelectedTransactions = {tx1: makeSelectedTransaction()};