Skip to content

Commit 230b034

Browse files
Detect a group export from the child selection metadata, not the group prefix
Ticking a populated group row stores that group's loaded children stamped with isSelectedViaGroup, not the group key itself. beginExportWithTemplate looked only for the group prefix, so the card-group case this PR enables took the non-group path: it sent the loaded transaction IDs and no cardID: filter, exporting incompletely whenever a group's children were paginated. Both export paths now share getGroupExportScope so the two detections can't drift again, and the test fixture stores the child-only shape the provider actually writes, which reproduces the failure. Co-authored-by: Yauheni Horbach <ZhenjaHorbach@users.noreply.github.com>
1 parent 293e2f3 commit 230b034

2 files changed

Lines changed: 60 additions & 18 deletions

File tree

‎src/hooks/useSearchBulkActions.ts‎

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,26 @@ function isGroupSelection(key: string, transaction: SelectedTransactions[string]
199199
return key.startsWith(CONST.SEARCH.GROUP_PREFIX) || (!!transaction.isSelectedViaGroup && !!transaction.groupKey);
200200
}
201201

202+
/**
203+
* How a selection on a grouped search has to be exported.
204+
*
205+
* Ticking a populated group row stores that group's loaded children stamped with `isSelectedViaGroup`, not the group
206+
* key itself — the group key is only stored when the group had no children loaded. So a group selection has to be
207+
* detected with `isGroupSelection` rather than by looking for the group prefix, or a group whose children are
208+
* paginated exports only the loaded ones instead of everything the group's filter covers.
209+
*
210+
* `transactionIDList` drops the rows covered by that filter and keeps the rows the user ticked individually
211+
* alongside them.
212+
*/
213+
function getGroupExportScope(queryJSON: SearchQueryJSON | undefined, selectedTransactions: SelectedTransactions): {isGroupExport: boolean; transactionIDList: string[]} {
214+
const selectedTransactionsKeys = Object.keys(selectedTransactions);
215+
const isGroupExport = !!queryJSON?.groupBy && Object.entries(selectedTransactions).some(([key, transaction]) => isGroupSelection(key, transaction));
216+
return {
217+
isGroupExport,
218+
transactionIDList: isGroupExport ? selectedTransactionsKeys.filter((key) => !isGroupSelection(key, selectedTransactions[key])) : selectedTransactionsKeys,
219+
};
220+
}
221+
202222
/**
203223
* The group rows a selection covers in full.
204224
*
@@ -958,20 +978,18 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
958978
true,
959979
);
960980
} else {
961-
const isGroupExport = !!queryJSON?.groupBy && selectedTransactionsKeys.some((key) => key.startsWith(CONST.SEARCH.GROUP_PREFIX));
962-
// The selected groups travel as a filter on the query, so the group rows themselves are dropped from the ID
963-
// list. Rows picked individually alongside those groups are kept, matching how "Current view" exports them.
964-
const transactionIDList = isGroupExport ? selectedTransactionsKeys.filter((key) => !isGroupSelection(key, selectedTransactions[key])) : selectedTransactionsKeys;
981+
const {isGroupExport, transactionIDList} = getGroupExportScope(queryJSON, selectedTransactions);
965982
queueExportSearchWithTemplate(
966983
{
967984
templateName,
968985
templateType,
969-
jsonQuery: isGroupExport
970-
? serializeQueryJSONForBackend(
971-
addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data),
972-
getGroupExportExactMatchFilterKeys(queryJSON.groupBy),
973-
)
974-
: '{}',
986+
jsonQuery:
987+
isGroupExport && queryJSON
988+
? serializeQueryJSONForBackend(
989+
addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data),
990+
getGroupExportExactMatchFilterKeys(queryJSON.groupBy),
991+
)
992+
: '{}',
975993
reportIDList: isGroupExport ? [] : selectedTransactionReportIDs,
976994
transactionIDList,
977995
policyID,
@@ -994,7 +1012,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
9941012
currentSearchResults?.data,
9951013
queryJSON,
9961014
selectedTransactionReportIDs,
997-
selectedTransactionsKeys,
9981015
selectAllMatchingItems,
9991016
clearSelectedTransactions,
10001017
],
@@ -1106,8 +1123,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
11061123
return;
11071124
}
11081125

1109-
const isGroupExport = !!queryJSON?.groupBy && Object.entries(selectedTransactions).some(([key, transaction]) => isGroupSelection(key, transaction));
1110-
const transactionIDList = isGroupExport ? selectedTransactionsKeys.filter((key) => !isGroupSelection(key, selectedTransactions[key])) : selectedTransactionsKeys;
1126+
const {isGroupExport, transactionIDList} = getGroupExportScope(queryJSON, selectedTransactions);
11111127
let didFail = false;
11121128
const reportIDList = selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs;
11131129
const queryJSONToExport = isGroupExport && queryJSON ? addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data) : queryJSON;
@@ -1932,7 +1948,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
19321948

19331949
const includeReportLevelExport = ((isExpenseReportType || typeInvoice) && areFullReportsSelected) || (typeExpense && !isExpenseReportType && isAllOneTransactionReport);
19341950

1935-
const isGroupedSearch = !!getValidGroupBy(queryJSON?.groupBy);
1951+
const groupBy = getValidGroupBy(queryJSON?.groupBy);
1952+
const isGroupedSearch = !!groupBy;
19361953

19371954
const policy = selectedPolicyIDs.length === 1 ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedPolicyIDs.at(0)}`] : undefined;
19381955
// 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
@@ -1964,7 +1981,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
19641981
// which most templates can't be scoped by, so template exports are hidden for it. Reconciliation - All Expenses is
19651982
// the exception on a card-grouped search (Card statements and Card accruals): the selected card groups become a
19661983
// `cardID:` filter, which is exactly the card-spend scope that template reports on.
1967-
const includesCardGroupExport = includesGroupExport && getValidGroupBy(queryJSON?.groupBy) === CONST.SEARCH.GROUP_BY.CARD;
1984+
const includesCardGroupExport = includesGroupExport && groupBy === CONST.SEARCH.GROUP_BY.CARD;
19681985
const customTemplatesToShow = includesCardGroupExport ? [] : availableCustomTemplates;
19691986
const defaultTemplatesToShow = includesCardGroupExport
19701987
? availableDefaultTemplates.filter((template) => template.templateName === CONST.REPORT.EXPORT_OPTIONS.RECONCILIATION_ALL_EXPENSES)

‎tests/unit/hooks/useSearchBulkActionsExportTest.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,8 @@ jest.mock('@libs/SearchUIUtils', () => {
205205
getSelectedGroupFilterEntry: jest.fn(),
206206
isGroupEntry: (key: string) => key.startsWith(actualCONSTForSearchUIUtils.SEARCH.GROUP_PREFIX),
207207
navigateToSearchRHP: jest.fn(),
208-
getValidGroupBy: jest.fn((groupBy?: string) => groupBy),
208+
// The real validator, so the `groupBy === GROUP_BY.CARD` comparison is exercised against it rather than an identity stub.
209+
getValidGroupBy: jest.fn(jest.requireActual<{getValidGroupBy: (groupBy?: string) => string | undefined}>('@libs/SearchUIUtils').getValidGroupBy),
209210
getSearchColumnTranslationKey: jest.fn((column: string) => column),
210211
getColumnsToShow: jest.fn(() => []),
211212
insertColumnBeforeTotalAmount: (columns: string[], columnId: string) => {
@@ -1270,14 +1271,24 @@ describe('useSearchBulkActions - export options', () => {
12701271
});
12711272
}
12721273

1273-
/** A selection made by ticking a card group's checkbox: the group row plus the child it selected. */
1274+
/**
1275+
* A selection made by ticking a populated card group's checkbox. `SearchWriteActionsProvider` stores the
1276+
* group's loaded children stamped with `groupKey`/`isSelectedViaGroup` and does NOT store the group key
1277+
* itself — it only stores that when the group had no children loaded (see `selectEmptyCardGroup`).
1278+
*/
12741279
function selectCardGroup() {
12751280
mockSelectedTransactions = {
1276-
[CARD_GROUP_KEY]: makeSelectedTransaction(),
12771281
tx1: makeSelectedTransaction({groupKey: CARD_GROUP_KEY, isSelectedViaGroup: true}),
12781282
};
12791283
}
12801284

1285+
/** Ticking a card group row whose children were never loaded: the group key is stored on its own. */
1286+
function selectEmptyCardGroup() {
1287+
mockSelectedTransactions = {
1288+
[CARD_GROUP_KEY]: makeSelectedTransaction(),
1289+
};
1290+
}
1291+
12811292
it('offers the Reconciliation template, and only that template, for a card group selection', async () => {
12821293
mockTemplatesIncludingReconciliation();
12831294
selectCardGroup();
@@ -1289,6 +1300,17 @@ describe('useSearchBulkActions - export options', () => {
12891300
});
12901301
});
12911302

1303+
it('offers the Reconciliation template for a card group whose children were never loaded', async () => {
1304+
mockTemplatesIncludingReconciliation();
1305+
selectEmptyCardGroup();
1306+
1307+
const {result} = renderHook(() => useSearchBulkActions({queryJSON: cardGroupedExpenseQueryJSON}), {wrapper: OnyxListItemProvider});
1308+
1309+
await waitFor(() => {
1310+
expect(getExportOptionTexts(result.current.headerButtonsOptions)).toEqual(['export.currentView', 'export.reconciliationAllExpenses']);
1311+
});
1312+
});
1313+
12921314
it('keeps every template hidden when the group selection is grouped by something other than card', async () => {
12931315
mockTemplatesIncludingReconciliation();
12941316
selectCardGroup();
@@ -1311,6 +1333,9 @@ describe('useSearchBulkActions - export options', () => {
13111333
});
13121334
});
13131335

1336+
// Regression test for detecting the group export from the child metadata: with only `isSelectedViaGroup`
1337+
// children selected, looking for the group prefix alone misses the group and exports the loaded IDs instead
1338+
// of the `cardID:` filter, so a group with paginated children would export incompletely.
13141339
it('scopes the Reconciliation export to the selected card groups instead of a transaction ID list', async () => {
13151340
mockTemplatesIncludingReconciliation();
13161341
selectCardGroup();

0 commit comments

Comments
 (0)