Skip to content
56 changes: 41 additions & 15 deletions src/hooks/useSearchBulkActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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,
},
Expand All @@ -991,7 +1010,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
currentSearchResults?.data,
queryJSON,
selectedTransactionReportIDs,
selectedTransactionsKeys,
selectAllMatchingItems,
clearSelectedTransactions,
],
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof CONST.REPORT.EXPORT_OPTIONS>) => {
if (!report.reportID) {
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
Loading
Loading