From 9a183f9db5abcaee0ae5361920762471e6f8c256 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Thu, 23 Apr 2026 12:23:46 +0300 Subject: [PATCH 01/37] make empty reports selectable --- src/components/Search/index.tsx | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 94efd70392b3..3cfc2f5b9f59 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -157,7 +157,7 @@ function mapTransactionItemToSelectedEntry( ]; } -function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType): [string, SelectedTransactionInfo] { +function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType | TransactionGroupListItemType): [string, SelectedTransactionInfo] { return [ item.keyForList ?? '', { @@ -170,7 +170,7 @@ function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType) isHeld: false, canUnhold: false, canChangeReport: false, - action: item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW, + action: (item as TransactionReportGroupListItemType).action ?? CONST.SEARCH.ACTION_TYPES.VIEW, reportID: item.reportID, policyID: item.policyID ?? CONST.POLICY.ID_FAKE, amount: 0, @@ -722,7 +722,7 @@ function Search({ continue; } - if (transactionGroup.transactions.length === 0 && isTransactionReportGroupListItemType(transactionGroup)) { + if (transactionGroup.transactions.length === 0) { const reportKey = transactionGroup.keyForList; if (transactionGroup.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { continue; @@ -926,8 +926,7 @@ function Search({ const areItemsGrouped = !!validGroupBy || type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const totalSelectableItemsCount = areItemsGrouped ? (filteredData as TransactionGroupListItemType[]).reduce((count, item) => { - // For empty reports, count the report itself as a selectable item - if (item.transactions.length === 0 && isTransactionReportGroupListItemType(item)) { + if (item.transactions.length === 0 && item.keyForList) { if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return count; } @@ -983,19 +982,14 @@ function Search({ const currentTransactions = itemTransactions ?? item.transactions; - // Handle empty reports - treat the report itself as selectable - if (currentTransactions.length === 0 && isTransactionReportGroupListItemType(item)) { + if (currentTransactions.length === 0 && item.keyForList) { const reportKey = item.keyForList; - if (!reportKey) { - return; - } if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return; } if (selectedTransactions[reportKey]?.isSelected) { - // Deselect the empty report const reducedSelectedTransactions: SelectedTransactions = { ...selectedTransactions, }; @@ -1005,7 +999,7 @@ function Search({ return; } - const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(item); + const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(item as TransactionGroupListItemType); const updatedTransactions = { ...selectedTransactions, [reportKey]: emptyReportSelection, @@ -1353,7 +1347,7 @@ function Search({ let updatedTransactions: SelectedTransactions; if (areItemsGrouped) { const allSelections: Array<[string, SelectedTransactionInfo]> = (filteredData as TransactionGroupListItemType[]).flatMap((item) => { - if (item.transactions.length === 0 && isTransactionReportGroupListItemType(item) && item.keyForList) { + if (item.transactions.length === 0 && item.keyForList) { if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return []; } From c790ddbcce690781017177118070caddd94ab083 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Thu, 23 Apr 2026 12:24:19 +0300 Subject: [PATCH 02/37] enable empty reports checkbox --- src/components/Search/SearchList/index.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index c86df63ad687..be43ac906d38 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -233,11 +233,11 @@ function SearchList({ return data; }, [data, groupBy, type]); const emptyReports = useMemo(() => { - if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(data)) { - return data.filter((item) => item.transactions.length === 0); + if (isTransactionGroupListItemArray(data)) { + return data.filter((item) => item.transactions.length === 0 && item.keyForList); } return []; - }, [data, type]); + }, [data]); const selectedItemsLength = useMemo(() => { const selectedTransactionsCount = flattenedItems.reduce((acc, item) => { @@ -245,7 +245,7 @@ function SearchList({ return acc + (isTransactionSelected ? 1 : 0); }, 0); - if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(data)) { + if (isTransactionGroupListItemArray(data)) { const selectedEmptyReports = emptyReports.reduce((acc, item) => { const isEmptyReportSelected = !!(item.keyForList && selectedTransactions[item.keyForList]?.isSelected); return acc + (isEmptyReportSelected ? 1 : 0); @@ -255,10 +255,10 @@ function SearchList({ } return selectedTransactionsCount; - }, [flattenedItems, type, data, emptyReports, selectedTransactions]); + }, [flattenedItems, data, emptyReports, selectedTransactions]); const totalItems = useMemo(() => { - if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(data)) { + if (isTransactionGroupListItemArray(data)) { const selectableEmptyReports = emptyReports.filter((item) => item.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const selectableTransactions = flattenedItems.filter((item) => { if ('pendingAction' in item) { @@ -276,7 +276,7 @@ function SearchList({ return true; }); return selectableTransactions.length; - }, [data, type, flattenedItems, emptyReports]); + }, [data, flattenedItems, emptyReports]); const {translate} = useLocalize(); const {isOffline} = useNetwork(); From e018d95fa039c8c8eaef046cbf10cc130bd75f6b Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Thu, 23 Apr 2026 12:24:42 +0300 Subject: [PATCH 03/37] enable empty reports checkbox --- .../ListItem/TransactionGroupListItem.tsx | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 171ecbbbd0f6..510d4c09cc65 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -155,9 +155,7 @@ function TransactionGroupListItem({ const isSelectAllChecked = isEmptyReportSelected || (selectedItemsLength === transactionsWithoutPendingDelete.length && transactionsWithoutPendingDelete.length > 0); const isIndeterminate = selectedItemsLength > 0 && selectedItemsLength !== transactionsWithoutPendingDelete.length; - // Currently only the transaction report groups have transactions where the empty view makes sense const shouldDisplayEmptyView = isEmpty && isExpenseReportType; - const isDisabledOrEmpty = isEmpty || isDisabled; const refreshTransactions = () => { if (!groupItem.transactionsQueryJSON) { @@ -306,7 +304,7 @@ function TransactionGroupListItem({ ({ ({ ({ ({ ({ ({ ({ ({ ({ Date: Thu, 23 Apr 2026 12:26:53 +0300 Subject: [PATCH 04/37] pass jsonQuery for group exports --- src/hooks/useSearchBulkActions.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 136e1fe756bd..80eda748884f 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -355,12 +355,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { setIsOfflineModalVisible(true); return; } + const serializedQuery = queryJSON ? serializeQueryJSONForBackend(queryJSON) : JSON.stringify(queryJSON); if (areAllMatchingItemsSelected) { queueExportSearchWithTemplate({ templateName, templateType, - jsonQuery: queryJSON ? serializeQueryJSONForBackend(queryJSON) : JSON.stringify(queryJSON), + jsonQuery: serializedQuery, reportIDList: [], transactionIDList: [], policyID, @@ -369,7 +370,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { queueExportSearchWithTemplate({ templateName, templateType, - jsonQuery: '{}', + jsonQuery: queryJSON?.groupBy ? serializedQuery : '{}', reportIDList: selectedTransactionReportIDs, transactionIDList: selectedTransactionsKeys, policyID, From 14a4678b16bc2be84e70d0b2df56431f7a87beaa Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Thu, 23 Apr 2026 12:28:35 +0300 Subject: [PATCH 05/37] fix: default to group exports when transaction is mixed --- src/hooks/useSearchBulkActions.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 80eda748884f..a853d73fcd3b 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -450,13 +450,15 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } + const includesEmptyReports = Object.values(selectedTransactions).some((selectedTransaction) => !selectedTransaction?.transaction); + const reportIDList = selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs; let didFail = false; await exportSearchItemsToCSV( { query: status, jsonQuery: queryJSON ? serializeQueryJSONForBackend(queryJSON) : JSON.stringify(queryJSON), - reportIDList: selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs, - transactionIDList: selectedTransactionsKeys, + reportIDList: includesEmptyReports ? [] : reportIDList, + transactionIDList: includesEmptyReports ? [] : selectedTransactionsKeys, }, () => { didFail = true; From fe8dd789e137c0e3d07ab08a3e41eb4626b3df8c Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Thu, 23 Apr 2026 12:29:12 +0300 Subject: [PATCH 06/37] only show basic export for group exports --- src/hooks/useSearchBulkActions.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index a853d73fcd3b..54b818b5dc1e 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -887,6 +887,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const connectedIntegration = getConnectedIntegration(policy); const isReportsTab = isExpenseReportType; + const includesGroupExport = Object.values(selectedTransactions).some(selectedTransaction => !selectedTransaction?.transaction); const canReportBeExported = (report: (typeof selectedReports)[0], exportOption: ValueOf) => { if (!report.reportID) { @@ -1005,6 +1006,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); for (const template of exportTemplates) { + if (includesGroupExport) { + break; + } exportOptions.push({ text: template.name, icon: expensifyIcons.Table, From 9e1f2e404bcec371c7a778f1564c2bbbf4b06ca4 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Thu, 23 Apr 2026 12:51:59 +0300 Subject: [PATCH 07/37] prettier --- src/hooks/useSearchBulkActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 54b818b5dc1e..c49a2efbc9b1 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -887,7 +887,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const connectedIntegration = getConnectedIntegration(policy); const isReportsTab = isExpenseReportType; - const includesGroupExport = Object.values(selectedTransactions).some(selectedTransaction => !selectedTransaction?.transaction); + const includesGroupExport = Object.values(selectedTransactions).some((selectedTransaction) => !selectedTransaction?.transaction); const canReportBeExported = (report: (typeof selectedReports)[0], exportOption: ValueOf) => { if (!report.reportID) { From 3a4bd0781669a4a33fa0e0b7483de495b9c6845f Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Thu, 23 Apr 2026 14:47:45 +0300 Subject: [PATCH 08/37] fix lint --- src/hooks/useSearchBulkActions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 6c8d0e43bcf3..16d995f9d1b8 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -491,6 +491,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { selectedReports, selectedReportIDs, selectedTransactionReportIDs, + selectedTransactions, selectedTransactionsKeys, translate, clearSelectedTransactions, From 4c5cd270b076f1195cd280a443ffd3f080571089 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Thu, 23 Apr 2026 17:14:16 +0300 Subject: [PATCH 09/37] keep group context --- src/hooks/useSearchBulkActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 16d995f9d1b8..be54e4be9a6b 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -916,7 +916,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const connectedIntegration = getConnectedIntegration(policy); const isReportsTab = isExpenseReportType; - const includesGroupExport = Object.values(selectedTransactions).some((selectedTransaction) => !selectedTransaction?.transaction); + const includesGroupExport = Object.entries(selectedTransactions).some(([key, selectedTransaction]) => key.startsWith('group_') && !selectedTransaction?.transaction); const canReportBeExported = (report: (typeof selectedReports)[0], exportOption: ValueOf) => { if (!report.reportID) { From ae4dd8ab3d76a31065b8ac1c5d699322d5e01d26 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Thu, 23 Apr 2026 17:15:30 +0300 Subject: [PATCH 10/37] strengthen group selection check --- src/hooks/useSearchBulkActions.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index be54e4be9a6b..4037ce05179c 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -463,15 +463,15 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } - const includesEmptyReports = Object.values(selectedTransactions).some((selectedTransaction) => !selectedTransaction?.transaction); + const includesGroupExport = Object.entries(selectedTransactions).some(([key, selectedTransaction]) => key.startsWith('group_') && !selectedTransaction?.transaction); const reportIDList = selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs; let didFail = false; await exportSearchItemsToCSV( { query: status, jsonQuery: queryJSON ? serializeQueryJSONForBackend(queryJSON) : JSON.stringify(queryJSON), - reportIDList: includesEmptyReports ? [] : reportIDList, - transactionIDList: includesEmptyReports ? [] : selectedTransactionsKeys, + reportIDList: includesGroupExport ? [] : reportIDList, + transactionIDList: includesGroupExport ? [] : selectedTransactionsKeys, }, () => { didFail = true; From aa4b6bb560bf2125714e659aec15b7f566712619 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Mon, 27 Apr 2026 14:19:24 +0300 Subject: [PATCH 11/37] avoid nested option when menuItems is 1 --- src/hooks/useSearchBulkActions.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 1d598da52693..33088a6913e8 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1052,6 +1052,11 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return exportOptions; }; + const subMenuItems = getExportOptions(); + if(subMenuItems.length === 1) { + return subMenuItems; + } + const exportButtonOption: DropdownOption & Pick = { icon: expensifyIcons.Export, rightIcon: expensifyIcons.ArrowRight, @@ -1059,7 +1064,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { backButtonText: translate('common.export'), value: CONST.SEARCH.BULK_ACTION_TYPES.EXPORT, shouldCloseModalOnSelect: true, - subMenuItems: getExportOptions(), + subMenuItems: subMenuItems, }; if (areAllMatchingItemsSelected) { From 060deb6855d941f3a81ec6256ab8082eae72857d Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Mon, 27 Apr 2026 14:47:02 +0300 Subject: [PATCH 12/37] fix incorrect footer summary when groups are selected --- src/components/Search/index.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index ea6c04437366..cde78456a0ae 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -162,6 +162,9 @@ function mapTransactionItemToSelectedEntry( } function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType | TransactionGroupListItemType): [string, SelectedTransactionInfo] { + const currency = isTransactionReportGroupListItemType(item) ? item.currency : ''; + const amount = isTransactionReportGroupListItemType(item) ? (item.totalDisplaySpend ?? 0) : (item as TransactionReportGroupListItemType).total; + return [ item.keyForList ?? '', { @@ -177,8 +180,9 @@ function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType action: (item as TransactionReportGroupListItemType).action ?? CONST.SEARCH.ACTION_TYPES.VIEW, reportID: item.reportID, policyID: item.policyID ?? CONST.POLICY.ID_FAKE, - amount: 0, - currency: '', + amount: amount ?? 0, + currency: currency ?? '', + ...(currency ? {groupCurrency: currency} : {}), }, ]; } From 67adbd211cfd14890a773e055f607a69feff0f61 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Mon, 27 Apr 2026 15:45:31 +0300 Subject: [PATCH 13/37] fix failing test --- src/hooks/useSearchBulkActions.ts | 36 ++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 33088a6913e8..bceebb99dbdd 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1053,19 +1053,29 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }; const subMenuItems = getExportOptions(); - if(subMenuItems.length === 1) { - return subMenuItems; - } - - const exportButtonOption: DropdownOption & Pick = { - icon: expensifyIcons.Export, - rightIcon: expensifyIcons.ArrowRight, - text: translate('common.export'), - backButtonText: translate('common.export'), - value: CONST.SEARCH.BULK_ACTION_TYPES.EXPORT, - shouldCloseModalOnSelect: true, - subMenuItems: subMenuItems, - }; + const singleExportSubMenuItem = subMenuItems.length === 1 ? subMenuItems.at(0) : undefined; + + const exportButtonOption: DropdownOption & Pick = singleExportSubMenuItem + ? { + icon: expensifyIcons.Export, + text: singleExportSubMenuItem.text, + value: CONST.SEARCH.BULK_ACTION_TYPES.EXPORT, + shouldCloseModalOnSelect: singleExportSubMenuItem.shouldCloseModalOnSelect ?? true, + shouldCallAfterModalHide: singleExportSubMenuItem.shouldCallAfterModalHide, + onSelected: () => singleExportSubMenuItem.onSelected?.(), + description: singleExportSubMenuItem.description, + displayInDefaultIconColor: singleExportSubMenuItem.displayInDefaultIconColor, + additionalIconStyles: singleExportSubMenuItem.additionalIconStyles, + } + : { + icon: expensifyIcons.Export, + rightIcon: expensifyIcons.ArrowRight, + text: translate('common.export'), + backButtonText: translate('common.export'), + value: CONST.SEARCH.BULK_ACTION_TYPES.EXPORT, + shouldCloseModalOnSelect: true, + subMenuItems, + }; if (areAllMatchingItemsSelected) { return [exportButtonOption]; From d283a2b5c59e97ba24a9d25ef77554cf2f086ad3 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Mon, 27 Apr 2026 16:27:22 +0300 Subject: [PATCH 14/37] address PR reviews --- src/components/Search/index.tsx | 2 +- src/hooks/useSearchBulkActions.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index cde78456a0ae..763c556e0225 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1026,7 +1026,7 @@ function Search({ return; } - const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(item as TransactionGroupListItemType); + const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(item); const updatedTransactions = { ...selectedTransactions, [reportKey]: emptyReportSelection, diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index bceebb99dbdd..b0293ff423e6 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -463,7 +463,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } - const includesGroupExport = Object.entries(selectedTransactions).some(([key, selectedTransaction]) => key.startsWith('group_') && !selectedTransaction?.transaction); + const includesGroupExport = Object.entries(selectedTransactions).some(([key, selectedTransaction]) => key.startsWith(CONST.SEARCH.GROUP_PREFIX) && !selectedTransaction?.transaction); const reportIDList = selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs; let didFail = false; await exportSearchItemsToCSV( @@ -916,7 +916,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const connectedIntegration = getConnectedIntegration(policy); const isReportsTab = isExpenseReportType; - const includesGroupExport = Object.entries(selectedTransactions).some(([key, selectedTransaction]) => key.startsWith('group_') && !selectedTransaction?.transaction); + const includesGroupExport = Object.entries(selectedTransactions).some(([key, selectedTransaction]) => key.startsWith(CONST.SEARCH.GROUP_PREFIX) && !selectedTransaction?.transaction); const canReportBeExported = (report: (typeof selectedReports)[0], exportOption: ValueOf) => { if (!report.reportID) { From 250e3097d8cca92e6b6bc3c0800c5ee9553ab207 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Mon, 27 Apr 2026 16:31:41 +0300 Subject: [PATCH 15/37] prettier --- src/hooks/useSearchBulkActions.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index b0293ff423e6..5a35bc74b180 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -916,7 +916,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const connectedIntegration = getConnectedIntegration(policy); const isReportsTab = isExpenseReportType; - const includesGroupExport = Object.entries(selectedTransactions).some(([key, selectedTransaction]) => key.startsWith(CONST.SEARCH.GROUP_PREFIX) && !selectedTransaction?.transaction); + const includesGroupExport = Object.entries(selectedTransactions).some( + ([key, selectedTransaction]) => key.startsWith(CONST.SEARCH.GROUP_PREFIX) && !selectedTransaction?.transaction, + ); const canReportBeExported = (report: (typeof selectedReports)[0], exportOption: ValueOf) => { if (!report.reportID) { From 106e288a79682d7794b73775ff4fa6c43d10d7f6 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Mon, 27 Apr 2026 17:12:17 +0300 Subject: [PATCH 16/37] propagate group selection into transactions when expanded --- src/components/Search/index.tsx | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 763c556e0225..0eb68630c4b7 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -768,15 +768,18 @@ function Search({ // This ensures report-level selection persists when new transactions are added. // Also check if the report itself was selected (when it was empty) by checking the reportID key const reportKey = transactionGroup.keyForList; - const wasReportSelected = reportKey && reportKey in selectedTransactions; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const hasAnySelected = isExpenseReportType && (wasReportSelected || transactionGroup.transactions.some((transaction) => transaction.transactionID in selectedTransactions)); + const wasReportSelected = !!(reportKey && reportKey in selectedTransactions); + const hasIndividualSelectedInGroup = transactionGroup.transactions.some( + (transaction) => (!!transaction.keyForList && transaction.keyForList in selectedTransactions) || transaction.transactionID in selectedTransactions, + ); + const propagateSelectionToAllRows = (isExpenseReportType && (wasReportSelected || hasIndividualSelectedInGroup)) || (wasReportSelected && !isExpenseReportType); for (const transactionItem of transactionGroup.transactions) { - const isSelected = transactionItem.transactionID in selectedTransactions; + const listKey = transactionItem.keyForList ?? transactionItem.transactionID; + const isSelected = listKey in selectedTransactions || transactionItem.transactionID in selectedTransactions; - // Include transaction if: already individually selected, part of select-all, or (for expense reports) part of a partially-selected report - const shouldInclude = isSelected || areAllMatchingItemsSelected || (isExpenseReportType && hasAnySelected); + // Include transaction if: already individually selected, part of select-all, or group-level propagation (expense report / empty group expanded) + const shouldInclude = isSelected || areAllMatchingItemsSelected || propagateSelectionToAllRows; if (!shouldInclude) { continue; } @@ -797,7 +800,9 @@ function Search({ searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`] ?? transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - newTransactionList[transactionItem.transactionID] = { + const previousSelection = selectedTransactions[listKey] ?? selectedTransactions[transactionItem.transactionID]; + + newTransactionList[listKey] = { transaction: transactionItem, action: transactionItem.action, canHold: canHoldRequest, @@ -814,7 +819,7 @@ function Search({ policy: transactionItem.policy, }), // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - isSelected: areAllMatchingItemsSelected || selectedTransactions[transactionItem.transactionID]?.isSelected || isExpenseReportType, + isSelected: areAllMatchingItemsSelected || !!previousSelection?.isSelected || propagateSelectionToAllRows, canReject: canRejectRequest, reportID: transactionItem.reportID, policyID: transactionItem.report?.policyID, @@ -836,7 +841,8 @@ function Search({ if (!Object.hasOwn(transactionItem, 'transactionID') || !('transactionID' in transactionItem)) { continue; } - if (!(transactionItem.transactionID in selectedTransactions) && !areAllMatchingItemsSelected) { + const listKey = transactionItem.keyForList ?? transactionItem.transactionID; + if (!(listKey in selectedTransactions) && !(transactionItem.transactionID in selectedTransactions) && !areAllMatchingItemsSelected) { continue; } @@ -853,7 +859,9 @@ function Search({ const itemTransaction = searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; const originalItemTransaction = searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - newTransactionList[transactionItem.transactionID] = { + const flatPreviousSelection = selectedTransactions[listKey] ?? selectedTransactions[transactionItem.transactionID]; + + newTransactionList[listKey] = { transaction: transactionItem, action: transactionItem.action, canHold: canHoldRequest, @@ -870,7 +878,7 @@ function Search({ policy: transactionItem.policy, }), // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - isSelected: areAllMatchingItemsSelected || selectedTransactions[transactionItem.transactionID].isSelected, + isSelected: areAllMatchingItemsSelected || !!flatPreviousSelection?.isSelected, canReject: canRejectRequest, reportID: transactionItem.reportID, policyID: transactionItem.report?.policyID, From fb19c606d10555ee3b564e00a33f803cdb441822 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Thu, 21 May 2026 07:57:45 +0300 Subject: [PATCH 17/37] fix minor ui bugs --- .../Search/SearchBulkActionsButton.tsx | 14 +++++++++- src/components/Search/SearchList/index.tsx | 7 +++-- src/components/Search/index.tsx | 17 +++++++---- src/components/Search/types.ts | 3 ++ src/pages/Search/SearchPage.tsx | 28 ++++++++++++++----- 5 files changed, 54 insertions(+), 15 deletions(-) diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index 99fc669a0e4a..f69efd168b31 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -108,8 +108,20 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { return reportIDs.size; } + const isGroupedSelection = selectedTransactionsKeys.some((key) => key.startsWith(CONST.SEARCH.GROUP_PREFIX) || !!selectedTransactions[key]?.groupKey); + if (isGroupedSelection) { + const uniqueGroupKeys = new Set(); + for (const key of selectedTransactionsKeys) { + const groupKey = key.startsWith(CONST.SEARCH.GROUP_PREFIX) ? key : selectedTransactions[key]?.groupKey; + if (groupKey) { + uniqueGroupKeys.add(groupKey); + } + } + return uniqueGroupKeys.size; + } + return selectedTransactionsKeys.length; - }, [selectedTransactions, selectedTransactionsKeys.length, isExpenseReportType]); + }, [selectedTransactions, selectedTransactionsKeys, isExpenseReportType]); const selectionButtonText = areAllMatchingItemsSelected ? translate('search.exportAll.allMatchingItemsSelected') : translate('workspace.common.selected', {count: selectedItemsCount}); diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 0d40bcf6c3d7..0867b23fd500 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -492,7 +492,10 @@ function SearchList({ const tableHeaderVisible = canSelectMultiple || !!SearchTableHeader; const selectAllButtonVisible = canSelectMultiple && !SearchTableHeader; - const isSelectAllChecked = selectedItemsLength > 0 && selectedItemsLength === totalItems && hasLoadedAllTransactions; + const selectionIncludesUnloadedGroups = Object.entries(selectedTransactions).some( + ([key, selectedTransaction]) => key.startsWith(CONST.SEARCH.GROUP_PREFIX) && !selectedTransaction?.transaction, + ); + const isSelectAllChecked = selectedItemsLength > 0 && selectedItemsLength === totalItems && (selectionIncludesUnloadedGroups || !!hasLoadedAllTransactions); const content = ( @@ -507,7 +510,7 @@ function SearchList({ 0 && (selectedItemsLength !== totalItems || !hasLoadedAllTransactions)} + isIndeterminate={selectedItemsLength > 0 && (selectedItemsLength !== totalItems || (!selectionIncludesUnloadedGroups && !hasLoadedAllTransactions))} onPress={() => { onAllCheckboxPress(); }} diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index b8979d660056..d06d13b6f86d 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -122,6 +122,7 @@ function mapTransactionItemToSelectedEntry( currentUserAccountID: number, outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue, allowNegativeAmount = true, + groupKey?: string, ): [string, SelectedTransactionInfo] { const {canHoldRequest, canUnholdRequest} = canHoldUnholdReportAction(item.report, item.reportAction, item.holdReportAction, item, item.policy, currentUserAccountID); const canRejectRequest = item.report ? canRejectReportAction(currentUserLogin, item.report) : false; @@ -159,6 +160,7 @@ function mapTransactionItemToSelectedEntry( ownerAccountID: item.reportAction?.actorAccountID, reportAction: item.reportAction, report: item.report, + groupKey, }, ]; } @@ -197,6 +199,7 @@ function prepareTransactionsList( currentUserLogin: string, currentUserAccountID: number, outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue, + groupKey?: string, ) { if (selectedTransactions[item.keyForList]?.isSelected) { const {[item.keyForList]: omittedTransaction, ...transactions} = selectedTransactions; @@ -212,6 +215,7 @@ function prepareTransactionsList( currentUserAccountID, outstandingReportsByPolicyID, false, + groupKey, ); return { @@ -642,11 +646,8 @@ function Search({ if (!validGroupBy) { return true; } - // For group-by views, check if all transactions in groups have been loaded return (baseFilteredData as TransactionGroupListItemType[]).every((item) => { const snapshot = item.transactionsQueryJSON?.hash || item.transactionsQueryJSON?.hash === 0 ? groupByTransactionSnapshots[String(item.transactionsQueryJSON.hash)] : undefined; - // If snapshot doesn't exist, the group hasn't been expanded yet (transactions not loaded) - // If snapshot exists and has hasMoreResults: true, not all transactions are loaded return !!snapshot && !snapshot?.search?.hasMoreResults; }); }, [validGroupBy, baseFilteredData, groupByTransactionSnapshots]); @@ -829,6 +830,7 @@ function Search({ reportAction: transactionItem.reportAction, isFromOneTransactionReport: isOneTransactionReport(transactionItem.report), report: transactionItem.report, + groupKey: !isExpenseReportType ? (transactionGroup.keyForList ?? undefined) : undefined, }; } } @@ -1005,6 +1007,10 @@ function Search({ } const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${item.transactionID}`] as OnyxEntry; const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; + const parentGroupKey = + validGroupBy && !isExpenseReportType + ? (filteredData as TransactionGroupListItemType[]).find((group) => group.transactions.some((t) => t.keyForList === item.keyForList))?.keyForList ?? undefined + : undefined; const updatedTransactions = prepareTransactionsList( item, itemTransaction, @@ -1013,6 +1019,7 @@ function Search({ email ?? '', accountID, outstandingReportsByPolicyID, + parentGroupKey, ); setSelectedTransactions(updatedTransactions, filteredData); updateSelectAllMatchingItemsState(updatedTransactions); @@ -1073,7 +1080,7 @@ function Search({ const originalItemTransaction = searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`] ?? transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID); + return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID, true, item.keyForList ?? undefined); }), ), }; @@ -1364,7 +1371,7 @@ function Search({ .map((transactionItem) => { const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID); + return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID, true, item.keyForList ?? undefined); }); }); updatedTransactions = Object.fromEntries(allSelections); diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 5687cac135a2..29775e084d4c 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -88,6 +88,9 @@ type SelectedTransactionInfo = { reportAction?: ReportAction; report?: Report; + + /** The keyForList of the parent group this transaction was selected from, if any */ + groupKey?: string; }; /** Model of selected transactions */ diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index c94f720a7993..ed044d47c328 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -124,14 +124,28 @@ function SearchPage({route}: SearchPageProps) { const shouldUseClientTotal = selectedTransactionsKeys.length > 0 || !metadata?.count || (selectedTransactionsKeys.length > 0 && !areAllMatchingItemsSelected); const selectedTransactionItems = Object.values(selectedTransactions); const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.groupCurrency ?? selectedTransactionItems.at(0)?.currency; + + const isGroupedSelection = selectedTransactionsKeys.some((key) => key.startsWith(CONST.SEARCH.GROUP_PREFIX) || !!selectedTransactions[key]?.groupKey); + const numberOfExpense = shouldUseClientTotal - ? selectedTransactionsKeys.reduce((count, key) => { - const item = selectedTransactions[key]; - if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) { - return count; - } - return count + 1; - }, 0) + ? isGroupedSelection + ? (() => { + const uniqueGroupKeys = new Set(); + for (const key of selectedTransactionsKeys) { + const groupKey = key.startsWith(CONST.SEARCH.GROUP_PREFIX) ? key : selectedTransactions[key]?.groupKey; + if (groupKey) { + uniqueGroupKeys.add(groupKey); + } + } + return uniqueGroupKeys.size; + })() + : selectedTransactionsKeys.reduce((count, key) => { + const item = selectedTransactions[key]; + if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) { + return count; + } + return count + 1; + }, 0) : metadata?.count; const total = shouldUseClientTotal ? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0) : metadata?.total; From 1bdd11158d584499ac430789d096a8056e4f1031 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Thu, 21 May 2026 17:03:44 +0300 Subject: [PATCH 18/37] address PR reviews --- src/components/Search/index.tsx | 56 +++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index d06d13b6f86d..5a06c1eae5d4 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -166,8 +166,29 @@ function mapTransactionItemToSelectedEntry( } function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType | TransactionGroupListItemType): [string, SelectedTransactionInfo] { - const currency = isTransactionReportGroupListItemType(item) ? item.currency : ''; - const amount = isTransactionReportGroupListItemType(item) ? (item.totalDisplaySpend ?? 0) : (item as TransactionReportGroupListItemType).total; + if (isTransactionReportGroupListItemType(item)) { + const currency = item.currency ?? ''; + return [ + item.keyForList ?? '', + { + isFromOneTransactionReport: false, + isSelected: true, + canHold: false, + canSplit: false, + canReject: false, + hasBeenSplit: false, + isHeld: false, + canUnhold: false, + canChangeReport: false, + action: item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW, + reportID: item.reportID, + policyID: item.policyID ?? CONST.POLICY.ID_FAKE, + amount: item.totalDisplaySpend ?? item.total ?? 0, + currency, + ...(currency ? {groupCurrency: currency} : {}), + }, + ]; + } return [ item.keyForList ?? '', @@ -181,12 +202,11 @@ function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType isHeld: false, canUnhold: false, canChangeReport: false, - action: (item as TransactionReportGroupListItemType).action ?? CONST.SEARCH.ACTION_TYPES.VIEW, + action: CONST.SEARCH.ACTION_TYPES.VIEW, reportID: item.reportID, policyID: item.policyID ?? CONST.POLICY.ID_FAKE, - amount: amount ?? 0, - currency: currency ?? '', - ...(currency ? {groupCurrency: currency} : {}), + amount: 0, + currency: '', }, ]; } @@ -1009,7 +1029,7 @@ function Search({ const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; const parentGroupKey = validGroupBy && !isExpenseReportType - ? (filteredData as TransactionGroupListItemType[]).find((group) => group.transactions.some((t) => t.keyForList === item.keyForList))?.keyForList ?? undefined + ? ((filteredData as TransactionGroupListItemType[]).find((group) => group.transactions.some((t) => t.keyForList === item.keyForList))?.keyForList ?? undefined) : undefined; const updatedTransactions = prepareTransactionsList( item, @@ -1080,7 +1100,16 @@ function Search({ const originalItemTransaction = searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`] ?? transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID, true, item.keyForList ?? undefined); + return mapTransactionItemToSelectedEntry( + transactionItem, + itemTransaction, + originalItemTransaction, + email ?? '', + accountID, + outstandingReportsByPolicyID, + true, + item.keyForList ?? undefined, + ); }), ), }; @@ -1371,7 +1400,16 @@ function Search({ .map((transactionItem) => { const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID, true, item.keyForList ?? undefined); + return mapTransactionItemToSelectedEntry( + transactionItem, + itemTransaction, + originalItemTransaction, + email ?? '', + accountID, + outstandingReportsByPolicyID, + true, + item.keyForList ?? undefined, + ); }); }); updatedTransactions = Object.fromEntries(allSelections); From 72c9ba244aef8c4a410f128090e0b626b69d3daf Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Thu, 21 May 2026 18:47:07 +0300 Subject: [PATCH 19/37] fix lint --- src/components/Search/index.tsx | 22 ++---------------- src/pages/Search/SearchPage.tsx | 41 +++++++++++++++++---------------- 2 files changed, 23 insertions(+), 40 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index e797c863c10e..c2b9847dcb98 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1105,34 +1105,16 @@ function Search({ currentTransactions .filter((t) => !isTransactionPendingDelete(t)) .map((transactionItem) => { -<<<<<<< HEAD - const itemTransaction = (searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] ?? - transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`]) as OnyxEntry; - const originalItemTransaction = - searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`] ?? - transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - return mapTransactionItemToSelectedEntry( - transactionItem, - itemTransaction, - originalItemTransaction, - email ?? '', - accountID, - outstandingReportsByPolicyID, - true, - item.keyForList ?? undefined, - ); -======= const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID); ->>>>>>> origin/main + return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID, true, item.keyForList ?? undefined); }), ), }; setSelectedTransactions(updatedTransactions, filteredData); updateSelectAllMatchingItemsState(updatedTransactions); }, - [selectedTransactions, setSelectedTransactions, filteredData, updateSelectAllMatchingItemsState, transactions, email, accountID, outstandingReportsByPolicyID], + [selectedTransactions, setSelectedTransactions, filteredData, updateSelectAllMatchingItemsState, transactions, email, accountID, outstandingReportsByPolicyID, isExpenseReportType, validGroupBy], ); const onSelectRowInMobileSelectionMode = (item: SearchListItem) => { diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 5d78c58c075c..4b51203ef600 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -127,26 +127,27 @@ function SearchPage({route}: SearchPageProps) { const isGroupedSelection = selectedTransactionsKeys.some((key) => key.startsWith(CONST.SEARCH.GROUP_PREFIX) || !!selectedTransactions[key]?.groupKey); - const numberOfExpense = shouldUseClientTotal - ? isGroupedSelection - ? (() => { - const uniqueGroupKeys = new Set(); - for (const key of selectedTransactionsKeys) { - const groupKey = key.startsWith(CONST.SEARCH.GROUP_PREFIX) ? key : selectedTransactions[key]?.groupKey; - if (groupKey) { - uniqueGroupKeys.add(groupKey); - } - } - return uniqueGroupKeys.size; - })() - : selectedTransactionsKeys.reduce((count, key) => { - const item = selectedTransactions[key]; - if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) { - return count; - } - return count + 1; - }, 0) - : metadata?.count; + let numberOfExpense: number | undefined; + if (!shouldUseClientTotal) { + numberOfExpense = metadata?.count; + } else if (isGroupedSelection) { + const uniqueGroupKeys = new Set(); + for (const key of selectedTransactionsKeys) { + const groupKey = key.startsWith(CONST.SEARCH.GROUP_PREFIX) ? key : selectedTransactions[key]?.groupKey; + if (groupKey) { + uniqueGroupKeys.add(groupKey); + } + } + numberOfExpense = uniqueGroupKeys.size; + } else { + numberOfExpense = selectedTransactionsKeys.reduce((count, key) => { + const item = selectedTransactions[key]; + if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) { + return count; + } + return count + 1; + }, 0); + } const total = shouldUseClientTotal ? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0) : metadata?.total; return {count: numberOfExpense, total, currency}; From ef70ca2255d0e2d4d0fdb20d8f80d1540b1f4a2d Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Thu, 21 May 2026 18:50:18 +0300 Subject: [PATCH 20/37] prettier --- src/components/Search/index.tsx | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index c2b9847dcb98..0983f82a4dc8 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1107,14 +1107,34 @@ function Search({ .map((transactionItem) => { const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID, true, item.keyForList ?? undefined); + return mapTransactionItemToSelectedEntry( + transactionItem, + itemTransaction, + originalItemTransaction, + email ?? '', + accountID, + outstandingReportsByPolicyID, + true, + item.keyForList ?? undefined, + ); }), ), }; setSelectedTransactions(updatedTransactions, filteredData); updateSelectAllMatchingItemsState(updatedTransactions); }, - [selectedTransactions, setSelectedTransactions, filteredData, updateSelectAllMatchingItemsState, transactions, email, accountID, outstandingReportsByPolicyID, isExpenseReportType, validGroupBy], + [ + selectedTransactions, + setSelectedTransactions, + filteredData, + updateSelectAllMatchingItemsState, + transactions, + email, + accountID, + outstandingReportsByPolicyID, + isExpenseReportType, + validGroupBy, + ], ); const onSelectRowInMobileSelectionMode = (item: SearchListItem) => { From 56fa5c34a1eda11249155549c9eaa863da18efc3 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Fri, 22 May 2026 18:45:14 +0300 Subject: [PATCH 21/37] add filtering --- src/hooks/useSearchBulkActions.ts | 79 +++++++++++++++++++++++++++---- src/libs/SearchUIUtils.ts | 26 ++++++++++ 2 files changed, 96 insertions(+), 9 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index d12c19277dd6..cadc68e8e67c 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -10,7 +10,7 @@ import type {PaymentMethodType} from '@components/KYCWall/types'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; -import type {BulkPaySelectionData, PaymentData, SearchQueryJSON} from '@components/Search/types'; +import type {BulkPaySelectionData, PaymentData, SearchFilterKey, SearchQueryJSON, SelectedTransactions} from '@components/Search/types'; import {unholdRequest} from '@libs/actions/IOU/Hold'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import {deleteAppReport, exportReportToPDF, markAsManuallyExported, moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter} from '@libs/actions/Report'; @@ -52,8 +52,8 @@ import { isIOUReport as isIOUReportUtil, isSelfDM, } from '@libs/ReportUtils'; -import {serializeQueryJSONForBackend} from '@libs/SearchQueryUtils'; -import {navigateToSearchRHP, shouldShowDeleteOption} from '@libs/SearchUIUtils'; +import {buildSearchQueryJSON, buildSearchQueryString, serializeQueryJSONForBackend} from '@libs/SearchQueryUtils'; +import {getSelectedGroupFilterEntry, navigateToSearchRHP, shouldShowDeleteOption} from '@libs/SearchUIUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import { hasCustomUnitOutOfPolicyViolation, @@ -73,6 +73,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {BillingGraceEndPeriod, Policy, Report, ReportNameValuePairs, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import useAllPolicyExpenseChatReportActions from './useAllPolicyExpenseChatReportActions'; import useAllTransactions from './useAllTransactions'; @@ -120,6 +121,58 @@ function getRestrictedPolicyID( ); } +function addSelectedGroupsFilter( + queryJSON: SearchQueryJSON, + selectedTransactions: SelectedTransactions, + searchData: SearchResultDataType | undefined, +): SearchQueryJSON { + const {groupBy} = queryJSON; + if (!groupBy || !searchData) { + return queryJSON; + } + + const groupKeys = new Set(); + for (const [key, info] of Object.entries(selectedTransactions)) { + if (key.startsWith(CONST.SEARCH.GROUP_PREFIX)) { + groupKeys.add(key); + } else if (info?.groupKey) { + groupKeys.add(info.groupKey); + } + } + + if (groupKeys.size === 0) { + return queryJSON; + } + + const filterEntries: Array<{key: SearchFilterKey; value: string | number}> = []; + for (const key of groupKeys) { + const group = searchData[key as keyof SearchResultDataType]; + if (!group) { + continue; + } + const entry = getSelectedGroupFilterEntry(groupBy, group); + if (entry) { + filterEntries.push(entry); + } + } + + if (filterEntries.length === 0) { + return queryJSON; + } + + const filterKey = filterEntries.at(0)?.key; + if (!filterKey) { + return queryJSON; + } + const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== filterKey); + newFlatFilters.push({ + key: filterKey, + filters: filterEntries.map((e) => ({operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: e.value})), + }); + + return buildSearchQueryJSON(buildSearchQueryString({...queryJSON, flatFilters: newFlatFilters})) ?? queryJSON; +} + type ShouldShowBulkDuplicateParams = { selectedTransactionsKeys: string[]; selectedTransactions: Record; @@ -435,12 +488,15 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { policyID, }); } else { + const filteredQuery = queryJSON?.groupBy + ? serializeQueryJSONForBackend(addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data)) + : '{}'; queueExportSearchWithTemplate({ templateName, templateType, - jsonQuery: queryJSON?.groupBy ? serializedQuery : '{}', + jsonQuery: filteredQuery, reportIDList: selectedTransactionReportIDs, - transactionIDList: selectedTransactionsKeys, + transactionIDList: selectedTransactionsKeys.filter((key) => !key.startsWith(CONST.SEARCH.GROUP_PREFIX)), policyID, }); } @@ -458,6 +514,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }, [ selectedReports, + selectedTransactions, isOffline, areAllMatchingItemsSelected, showConfirmModal, @@ -518,15 +575,18 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } - const includesGroupExport = Object.entries(selectedTransactions).some(([key, selectedTransaction]) => key.startsWith(CONST.SEARCH.GROUP_PREFIX) && !selectedTransaction?.transaction); + const transactionIDList = selectedTransactionsKeys.filter((key) => !key.startsWith(CONST.SEARCH.GROUP_PREFIX)); const reportIDList = selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs; + const exportQuery = queryJSON?.groupBy + ? addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data) + : queryJSON; let didFail = false; await exportSearchItemsToCSV( { query: status, - jsonQuery: queryJSON ? serializeQueryJSONForBackend(queryJSON) : JSON.stringify(queryJSON), - reportIDList: includesGroupExport ? [] : reportIDList, - transactionIDList: includesGroupExport ? [] : selectedTransactionsKeys, + jsonQuery: exportQuery ? serializeQueryJSONForBackend(exportQuery) : JSON.stringify(exportQuery), + reportIDList, + transactionIDList, }, () => { didFail = true; @@ -553,6 +613,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { showConfirmModal, hash, selectAllMatchingItems, + currentSearchResults?.data, ]); const handleApproveWithDEWCheck = useCallback(async () => { diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index b27eb693367b..b4abe554a931 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -2890,6 +2890,31 @@ function getReportSections({ return [reportIDToTransactionsValues, reportIDToTransactionsValues.length, hasDeletedTransaction]; } +function getSelectedGroupFilterEntry(groupBy: string, groupData: unknown): {key: SearchFilterKey; value: string | number} | undefined { + switch (groupBy) { + case CONST.SEARCH.GROUP_BY.FROM: + return {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, value: (groupData as SearchMemberGroup).accountID}; + case CONST.SEARCH.GROUP_BY.CARD: + return {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, value: (groupData as SearchCardGroup).cardID}; + case CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID: + return {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.WITHDRAWAL_ID, value: (groupData as SearchWithdrawalIDGroup).entryID}; + case CONST.SEARCH.GROUP_BY.CATEGORY: { + const category = (groupData as SearchCategoryGroup).category; + return {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, value: !category ? CONST.SEARCH.CATEGORY_EMPTY_VALUE : category}; + } + case CONST.SEARCH.GROUP_BY.MERCHANT: { + const merchant = (groupData as SearchMerchantGroup).merchant; + return {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, value: merchant === '' ? CONST.SEARCH.MERCHANT_EMPTY_VALUE : merchant}; + } + case CONST.SEARCH.GROUP_BY.TAG: { + const tag = (groupData as SearchTagGroup).tag; + return {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, value: tag === '' || tag === '(untagged)' ? CONST.SEARCH.TAG_EMPTY_VALUE : tag}; + } + default: + return undefined; + } +} + function buildSpecificGroupQuery(queryJSON: SearchQueryJSON, filterKey: SearchFilterKey, filterValue: string | number): SearchQueryJSON | undefined { const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== filterKey); newFlatFilters.push({key: filterKey, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: filterValue}]}); @@ -5986,6 +6011,7 @@ export { getSearchReportAvatarProps, isTodoSearch, getActiveGroupSearchHashes, + getSelectedGroupFilterEntry, adjustTimeRangeToDateFilters, getDateDisplayValue, shouldShowFilter, From 2c1f58732293fc00312efb2c8139e1bceb47d018 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Fri, 22 May 2026 18:49:02 +0300 Subject: [PATCH 22/37] remove misinformed fix --- .../Search/SearchBulkActionsButton.tsx | 14 +------ src/components/Search/SearchList/index.tsx | 7 +--- src/components/Search/index.tsx | 42 +++++-------------- src/components/Search/types.ts | 3 -- src/pages/Search/SearchPage.tsx | 33 ++++----------- 5 files changed, 22 insertions(+), 77 deletions(-) diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index f69efd168b31..99fc669a0e4a 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -108,20 +108,8 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { return reportIDs.size; } - const isGroupedSelection = selectedTransactionsKeys.some((key) => key.startsWith(CONST.SEARCH.GROUP_PREFIX) || !!selectedTransactions[key]?.groupKey); - if (isGroupedSelection) { - const uniqueGroupKeys = new Set(); - for (const key of selectedTransactionsKeys) { - const groupKey = key.startsWith(CONST.SEARCH.GROUP_PREFIX) ? key : selectedTransactions[key]?.groupKey; - if (groupKey) { - uniqueGroupKeys.add(groupKey); - } - } - return uniqueGroupKeys.size; - } - return selectedTransactionsKeys.length; - }, [selectedTransactions, selectedTransactionsKeys, isExpenseReportType]); + }, [selectedTransactions, selectedTransactionsKeys.length, isExpenseReportType]); const selectionButtonText = areAllMatchingItemsSelected ? translate('search.exportAll.allMatchingItemsSelected') : translate('workspace.common.selected', {count: selectedItemsCount}); diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 5b21514ee771..88c12f791c5d 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -504,10 +504,7 @@ function SearchList({ const tableHeaderVisible = canSelectMultiple || !!SearchTableHeader; const selectAllButtonVisible = canSelectMultiple && !SearchTableHeader; - const selectionIncludesUnloadedGroups = Object.entries(selectedTransactions).some( - ([key, selectedTransaction]) => key.startsWith(CONST.SEARCH.GROUP_PREFIX) && !selectedTransaction?.transaction, - ); - const isSelectAllChecked = selectedItemsLength > 0 && selectedItemsLength === totalItems && (selectionIncludesUnloadedGroups || !!hasLoadedAllTransactions); + const isSelectAllChecked = selectedItemsLength > 0 && selectedItemsLength === totalItems && hasLoadedAllTransactions; const content = ( @@ -522,7 +519,7 @@ function SearchList({ 0 && (selectedItemsLength !== totalItems || (!selectionIncludesUnloadedGroups && !hasLoadedAllTransactions))} + isIndeterminate={selectedItemsLength > 0 && (selectedItemsLength !== totalItems || !hasLoadedAllTransactions)} onPress={() => { onAllCheckboxPress(); }} diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 0983f82a4dc8..0eb8945d9275 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -124,7 +124,6 @@ function mapTransactionItemToSelectedEntry( currentUserAccountID: number, outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue, allowNegativeAmount = true, - groupKey?: string, ): [string, SelectedTransactionInfo] { const {canHoldRequest, canUnholdRequest} = canHoldUnholdReportAction(item.report, item.reportAction, item.holdReportAction, item, item.policy, currentUserAccountID); const canRejectRequest = item.report ? canRejectReportAction(currentUserLogin, item.report) : false; @@ -162,7 +161,6 @@ function mapTransactionItemToSelectedEntry( ownerAccountID: item.reportAction?.actorAccountID, reportAction: item.reportAction, report: item.report, - groupKey, }, ]; } @@ -221,7 +219,6 @@ function prepareTransactionsList( currentUserLogin: string, currentUserAccountID: number, outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue, - groupKey?: string, ) { if (selectedTransactions[item.keyForList]?.isSelected) { const {[item.keyForList]: omittedTransaction, ...transactions} = selectedTransactions; @@ -237,7 +234,6 @@ function prepareTransactionsList( currentUserAccountID, outstandingReportsByPolicyID, false, - groupKey, ); return { @@ -668,8 +664,11 @@ function Search({ if (!validGroupBy) { return true; } + // For group-by views, check if all transactions in groups have been loaded return (baseFilteredData as TransactionGroupListItemType[]).every((item) => { const snapshot = item.transactionsQueryJSON?.hash || item.transactionsQueryJSON?.hash === 0 ? groupByTransactionSnapshots[String(item.transactionsQueryJSON.hash)] : undefined; + // If snapshot doesn't exist, the group hasn't been expanded yet (transactions not loaded) + // If snapshot exists and has hasMoreResults: true, not all transactions are loaded return !!snapshot && !snapshot?.search?.hasMoreResults; }); }, [validGroupBy, baseFilteredData, groupByTransactionSnapshots]); @@ -852,7 +851,6 @@ function Search({ reportAction: transactionItem.reportAction, isFromOneTransactionReport: isOneTransactionReport(transactionItem.report), report: transactionItem.report, - groupKey: !isExpenseReportType ? (transactionGroup.keyForList ?? undefined) : undefined, }; } } @@ -1037,10 +1035,6 @@ function Search({ } const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${item.transactionID}`] as OnyxEntry; const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - const parentGroupKey = - validGroupBy && !isExpenseReportType - ? ((filteredData as TransactionGroupListItemType[]).find((group) => group.transactions.some((t) => t.keyForList === item.keyForList))?.keyForList ?? undefined) - : undefined; const updatedTransactions = prepareTransactionsList( item, itemTransaction, @@ -1049,7 +1043,6 @@ function Search({ email ?? '', accountID, outstandingReportsByPolicyID, - parentGroupKey, ); setSelectedTransactions(updatedTransactions, filteredData); updateSelectAllMatchingItemsState(updatedTransactions); @@ -1105,18 +1098,12 @@ function Search({ currentTransactions .filter((t) => !isTransactionPendingDelete(t)) .map((transactionItem) => { - const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; - const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - return mapTransactionItemToSelectedEntry( - transactionItem, - itemTransaction, - originalItemTransaction, - email ?? '', - accountID, - outstandingReportsByPolicyID, - true, - item.keyForList ?? undefined, - ); + const itemTransaction = (searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] ?? + transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`]) as OnyxEntry; + const originalItemTransaction = + searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`] ?? + transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; + return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID); }), ), }; @@ -1437,16 +1424,7 @@ function Search({ .map((transactionItem) => { const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - return mapTransactionItemToSelectedEntry( - transactionItem, - itemTransaction, - originalItemTransaction, - email ?? '', - accountID, - outstandingReportsByPolicyID, - true, - item.keyForList ?? undefined, - ); + return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID); }); }); updatedTransactions = Object.fromEntries(allSelections); diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 29775e084d4c..5687cac135a2 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -88,9 +88,6 @@ type SelectedTransactionInfo = { reportAction?: ReportAction; report?: Report; - - /** The keyForList of the parent group this transaction was selected from, if any */ - groupKey?: string; }; /** Model of selected transactions */ diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 4b51203ef600..1f8516295875 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -124,30 +124,15 @@ function SearchPage({route}: SearchPageProps) { const shouldUseClientTotal = selectedTransactionsKeys.length > 0 || !metadata?.count; const selectedTransactionItems = Object.values(selectedTransactions); const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.groupCurrency ?? selectedTransactionItems.at(0)?.currency; - - const isGroupedSelection = selectedTransactionsKeys.some((key) => key.startsWith(CONST.SEARCH.GROUP_PREFIX) || !!selectedTransactions[key]?.groupKey); - - let numberOfExpense: number | undefined; - if (!shouldUseClientTotal) { - numberOfExpense = metadata?.count; - } else if (isGroupedSelection) { - const uniqueGroupKeys = new Set(); - for (const key of selectedTransactionsKeys) { - const groupKey = key.startsWith(CONST.SEARCH.GROUP_PREFIX) ? key : selectedTransactions[key]?.groupKey; - if (groupKey) { - uniqueGroupKeys.add(groupKey); - } - } - numberOfExpense = uniqueGroupKeys.size; - } else { - numberOfExpense = selectedTransactionsKeys.reduce((count, key) => { - const item = selectedTransactions[key]; - if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) { - return count; - } - return count + 1; - }, 0); - } + const numberOfExpense = shouldUseClientTotal + ? selectedTransactionsKeys.reduce((count, key) => { + const item = selectedTransactions[key]; + if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) { + return count; + } + return count + 1; + }, 0) + : metadata?.count; const total = shouldUseClientTotal ? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0) : metadata?.total; return {count: numberOfExpense, total, currency}; From f2ff4efc0c3efd1072f21f316c5663d0f8e038d2 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Fri, 22 May 2026 19:04:58 +0300 Subject: [PATCH 23/37] fix selection count --- src/components/Search/SearchBulkActionsButton.tsx | 10 ++++++++-- src/components/Search/index.tsx | 9 ++++----- src/pages/Search/SearchPage.tsx | 4 ++++ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index 99fc669a0e4a..4c22e9e7e59a 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -108,8 +108,14 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { return reportIDs.size; } - return selectedTransactionsKeys.length; - }, [selectedTransactions, selectedTransactionsKeys.length, isExpenseReportType]); + return selectedTransactionsKeys.reduce((count, key) => { + if (key.startsWith(CONST.SEARCH.GROUP_PREFIX)) { + const group = searchData?.[key as keyof typeof searchData] as {count?: number} | undefined; + return count + (group?.count ?? 0); + } + return count + 1; + }, 0); + }, [selectedTransactions, selectedTransactionsKeys, isExpenseReportType, searchData]); const selectionButtonText = areAllMatchingItemsSelected ? translate('search.exportAll.allMatchingItemsSelected') : translate('workspace.common.selected', {count: selectedItemsCount}); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 0eb8945d9275..482c547bf82b 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -667,9 +667,8 @@ function Search({ // For group-by views, check if all transactions in groups have been loaded return (baseFilteredData as TransactionGroupListItemType[]).every((item) => { const snapshot = item.transactionsQueryJSON?.hash || item.transactionsQueryJSON?.hash === 0 ? groupByTransactionSnapshots[String(item.transactionsQueryJSON.hash)] : undefined; - // If snapshot doesn't exist, the group hasn't been expanded yet (transactions not loaded) // If snapshot exists and has hasMoreResults: true, not all transactions are loaded - return !!snapshot && !snapshot?.search?.hasMoreResults; + return item.transactions.length === 0 || !snapshot || !snapshot?.search?.hasMoreResults; }); }, [validGroupBy, baseFilteredData, groupByTransactionSnapshots]); @@ -986,15 +985,15 @@ function Search({ } return (filteredData as TransactionGroupListItemType[]).reduce((count, item) => { - // For empty reports, count the report itself as a selectable item - if (item.transactions.length === 0 && isTransactionReportGroupListItemType(item)) { + // For empty groups, count the group itself as a selectable item + if (item.transactions.length === 0 && item.keyForList) { if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return count; } return count + 1; } - // For regular reports, count all transactions except pending delete ones + // For groups with transactions, count all transactions except pending delete ones const selectableTransactions = item.transactions.filter((transaction) => !isTransactionPendingDelete(transaction)); return count + selectableTransactions.length; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 1f8516295875..68c0d8216eba 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -126,6 +126,10 @@ function SearchPage({route}: SearchPageProps) { const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.groupCurrency ?? selectedTransactionItems.at(0)?.currency; const numberOfExpense = shouldUseClientTotal ? selectedTransactionsKeys.reduce((count, key) => { + if (key.startsWith(CONST.SEARCH.GROUP_PREFIX)) { + const group = currentSearchResults?.data?.[key as keyof typeof currentSearchResults.data] as {count?: number} | undefined; + return count + (group?.count ?? 0); + } const item = selectedTransactions[key]; if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) { return count; From 7b4e7ebe2653443fa4eafeca6dfa7f1c1e61bfcd Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Fri, 22 May 2026 19:11:49 +0300 Subject: [PATCH 24/37] fix lint --- src/components/Search/index.tsx | 3 +-- src/hooks/useSearchBulkActions.ts | 4 +--- src/pages/Search/SearchPage.tsx | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 482c547bf82b..f4b235b61a45 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1118,8 +1118,7 @@ function Search({ email, accountID, outstandingReportsByPolicyID, - isExpenseReportType, - validGroupBy, + searchResults?.data, ], ); diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index cadc68e8e67c..45d031c93752 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -132,11 +132,9 @@ function addSelectedGroupsFilter( } const groupKeys = new Set(); - for (const [key, info] of Object.entries(selectedTransactions)) { + for (const key of Object.keys(selectedTransactions)) { if (key.startsWith(CONST.SEARCH.GROUP_PREFIX)) { groupKeys.add(key); - } else if (info?.groupKey) { - groupKeys.add(info.groupKey); } } diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 68c0d8216eba..19faf46bf4dc 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -140,7 +140,7 @@ function SearchPage({route}: SearchPageProps) { const total = shouldUseClientTotal ? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0) : metadata?.total; return {count: numberOfExpense, total, currency}; - }, [metadata?.count, metadata?.currency, metadata?.total, selectedTransactions, selectedTransactionsKeys, shouldAllowFooterTotals]); + }, [metadata?.count, metadata?.currency, metadata?.total, selectedTransactions, selectedTransactionsKeys, shouldAllowFooterTotals, currentSearchResults]); const onSortPressedCallback = useCallback(() => { setIsSorting(true); From b20da917ae2188ef46eecbd4405fe0a61573dbf5 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Fri, 22 May 2026 19:25:52 +0300 Subject: [PATCH 25/37] prettier --- src/components/Search/index.tsx | 12 +----------- src/hooks/useSearchBulkActions.ts | 14 +++----------- 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index afd8771b977a..fa8fa429e8cf 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1117,17 +1117,7 @@ function Search({ setSelectedTransactions(updatedTransactions); updateSelectAllMatchingItemsState(updatedTransactions); }, - [ - selectedTransactions, - setSelectedTransactions, - - updateSelectAllMatchingItemsState, - transactions, - email, - accountID, - outstandingReportsByPolicyID, - searchResults?.data, - ], + [selectedTransactions, setSelectedTransactions, updateSelectAllMatchingItemsState, transactions, email, accountID, outstandingReportsByPolicyID, searchResults?.data], ); const onSelectRowInMobileSelectionMode = (item: SearchListItem) => { diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 45d031c93752..861a4acf352d 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -121,11 +121,7 @@ function getRestrictedPolicyID( ); } -function addSelectedGroupsFilter( - queryJSON: SearchQueryJSON, - selectedTransactions: SelectedTransactions, - searchData: SearchResultDataType | undefined, -): SearchQueryJSON { +function addSelectedGroupsFilter(queryJSON: SearchQueryJSON, selectedTransactions: SelectedTransactions, searchData: SearchResultDataType | undefined): SearchQueryJSON { const {groupBy} = queryJSON; if (!groupBy || !searchData) { return queryJSON; @@ -486,9 +482,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { policyID, }); } else { - const filteredQuery = queryJSON?.groupBy - ? serializeQueryJSONForBackend(addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data)) - : '{}'; + const filteredQuery = queryJSON?.groupBy ? serializeQueryJSONForBackend(addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data)) : '{}'; queueExportSearchWithTemplate({ templateName, templateType, @@ -575,9 +569,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const transactionIDList = selectedTransactionsKeys.filter((key) => !key.startsWith(CONST.SEARCH.GROUP_PREFIX)); const reportIDList = selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs; - const exportQuery = queryJSON?.groupBy - ? addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data) - : queryJSON; + const exportQuery = queryJSON?.groupBy ? addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data) : queryJSON; let didFail = false; await exportSearchItemsToCSV( { From 044ff04fea630d4d9ce8ff4590d25606a20ce996 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Tue, 26 May 2026 11:10:07 +0300 Subject: [PATCH 26/37] prevent passing redundnt transaction/reportIDs for group exports --- src/components/Search/index.tsx | 6 ++++-- src/components/Search/types.ts | 3 +++ src/hooks/useSearchBulkActions.ts | 24 +++++++++++++----------- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 41646f55819b..f958464e87ee 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1102,7 +1102,8 @@ function Search({ const originalItemTransaction = searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`] ?? transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID); + const [key, entry] = mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID); + return [key, {...entry, groupKey: item.keyForList}]; }), ), }; @@ -1412,7 +1413,8 @@ function Search({ .map((transactionItem) => { const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID); + const [key, entry] = mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID); + return [key, {...entry, groupKey: item.keyForList}] as [string, SelectedTransactionInfo]; }); }); updatedTransactions = Object.fromEntries(allSelections); diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index dbb91696bfc6..a49b70449fe9 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -90,6 +90,9 @@ type SelectedTransactionInfo = { reportAction?: ReportAction; report?: Report; + + /** The group key this transaction belongs to when in a grouped view */ + groupKey?: string; }; /** Model of selected transactions */ diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 4f3f832217bb..f9c6ebf0c1d1 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -130,9 +130,11 @@ function addSelectedGroupsFilter(queryJSON: SearchQueryJSON, selectedTransaction } const groupKeys = new Set(); - for (const key of Object.keys(selectedTransactions)) { + for (const [key, value] of Object.entries(selectedTransactions)) { if (key.startsWith(CONST.SEARCH.GROUP_PREFIX)) { groupKeys.add(key); + } else if (value.groupKey) { + groupKeys.add(value.groupKey); } } @@ -520,13 +522,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { policyID, }); } else { - const filteredQuery = queryJSON?.groupBy ? serializeQueryJSONForBackend(addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data)) : '{}'; + const isGroupExport = !!queryJSON?.groupBy; queueExportSearchWithTemplate({ templateName, templateType, - jsonQuery: filteredQuery, - reportIDList: selectedTransactionReportIDs, - transactionIDList: selectedTransactionsKeys.filter((key) => !key.startsWith(CONST.SEARCH.GROUP_PREFIX)), + jsonQuery: isGroupExport ? serializeQueryJSONForBackend(addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data)) : '{}', + reportIDList: isGroupExport ? [] : selectedTransactionReportIDs, + transactionIDList: isGroupExport ? [] : selectedTransactionsKeys, policyID, }); } @@ -605,16 +607,16 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } - const transactionIDList = selectedTransactionsKeys.filter((key) => !key.startsWith(CONST.SEARCH.GROUP_PREFIX)); - const reportIDList = selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs; - const exportQuery = queryJSON?.groupBy ? addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data) : queryJSON; + const isGroupExport = !!queryJSON?.groupBy; let didFail = false; await exportSearchItemsToCSV( { query: status, - jsonQuery: exportQuery ? serializeQueryJSONForBackend(exportQuery) : JSON.stringify(exportQuery), - reportIDList, - transactionIDList, + jsonQuery: isGroupExport + ? serializeQueryJSONForBackend(addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data)) + : queryJSON ? serializeQueryJSONForBackend(queryJSON) : JSON.stringify(queryJSON), + reportIDList: isGroupExport ? [] : (selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs), + transactionIDList: isGroupExport ? [] : selectedTransactionsKeys, }, () => { didFail = true; From e2ab5bbdb787a2e5e1a3e3bf177a6be85585c253 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Tue, 26 May 2026 15:35:07 +0300 Subject: [PATCH 27/37] export all expanded selections as individual rows --- src/hooks/useSearchBulkActions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index f9c6ebf0c1d1..738018f58a0e 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -522,7 +522,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { policyID, }); } else { - const isGroupExport = !!queryJSON?.groupBy; + const isGroupExport = !!queryJSON?.groupBy && selectedTransactionsKeys.some((key) => key.startsWith(CONST.SEARCH.GROUP_PREFIX)); queueExportSearchWithTemplate({ templateName, templateType, @@ -607,7 +607,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return; } - const isGroupExport = !!queryJSON?.groupBy; + const isGroupExport = !!queryJSON?.groupBy && selectedTransactionsKeys.some((key) => key.startsWith(CONST.SEARCH.GROUP_PREFIX)); let didFail = false; await exportSearchItemsToCSV( { From 61925a4f68624126636044eb490060c9d1f16d18 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Tue, 26 May 2026 15:37:04 +0300 Subject: [PATCH 28/37] prettier --- src/components/Search/index.tsx | 18 ++++++++++++++++-- src/hooks/useSearchBulkActions.ts | 6 ++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index f958464e87ee..0fb53d3f7b2d 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1102,7 +1102,14 @@ function Search({ const originalItemTransaction = searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`] ?? transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - const [key, entry] = mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID); + const [key, entry] = mapTransactionItemToSelectedEntry( + transactionItem, + itemTransaction, + originalItemTransaction, + email ?? '', + accountID, + outstandingReportsByPolicyID, + ); return [key, {...entry, groupKey: item.keyForList}]; }), ), @@ -1413,7 +1420,14 @@ function Search({ .map((transactionItem) => { const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - const [key, entry] = mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID); + const [key, entry] = mapTransactionItemToSelectedEntry( + transactionItem, + itemTransaction, + originalItemTransaction, + email ?? '', + accountID, + outstandingReportsByPolicyID, + ); return [key, {...entry, groupKey: item.keyForList}] as [string, SelectedTransactionInfo]; }); }); diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 738018f58a0e..8f0f04b1368f 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -614,8 +614,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { query: status, jsonQuery: isGroupExport ? serializeQueryJSONForBackend(addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data)) - : queryJSON ? serializeQueryJSONForBackend(queryJSON) : JSON.stringify(queryJSON), - reportIDList: isGroupExport ? [] : (selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs), + : queryJSON + ? serializeQueryJSONForBackend(queryJSON) + : JSON.stringify(queryJSON), + reportIDList: isGroupExport ? [] : selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs, transactionIDList: isGroupExport ? [] : selectedTransactionsKeys, }, () => { From e92d8c756d68ce658649b494334106b268b015c4 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Tue, 26 May 2026 19:26:55 +0300 Subject: [PATCH 29/37] fix lint --- src/hooks/useSearchBulkActions.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 8f0f04b1368f..a374681fcad1 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -608,16 +608,15 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } const isGroupExport = !!queryJSON?.groupBy && selectedTransactionsKeys.some((key) => key.startsWith(CONST.SEARCH.GROUP_PREFIX)); + const reportIDList = selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs; let didFail = false; await exportSearchItemsToCSV( { query: status, jsonQuery: isGroupExport ? serializeQueryJSONForBackend(addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data)) - : queryJSON - ? serializeQueryJSONForBackend(queryJSON) - : JSON.stringify(queryJSON), - reportIDList: isGroupExport ? [] : selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs, + : serializeQueryJSONForBackend(queryJSON ?? ({} as SearchQueryJSON)), + reportIDList: isGroupExport ? [] : reportIDList, transactionIDList: isGroupExport ? [] : selectedTransactionsKeys, }, () => { From 72762fcb1001c1ca5985a334981b145031ed3f4c Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Tue, 26 May 2026 19:27:17 +0300 Subject: [PATCH 30/37] handle expense level selections in group view --- .../Search/SearchBulkActionsButton.tsx | 8 +++++++ src/components/Search/index.tsx | 23 ++++++++++++++++++- src/pages/Search/SearchPage.tsx | 8 +++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index c3fe0c431a61..69a94a433b8b 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -108,8 +108,16 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { return reportIDs.size; } + const expandedGroupKeys = new Set( + Object.values(selectedTransactions) + .map((t) => t.groupKey) + .filter(Boolean), + ); return selectedTransactionsKeys.reduce((count, key) => { if (key.startsWith(CONST.SEARCH.GROUP_PREFIX)) { + if (expandedGroupKeys.has(key)) { + return count; + } const group = searchData?.[key as keyof typeof searchData] as {count?: number} | undefined; return count + (group?.count ?? 0); } diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 0fb53d3f7b2d..b11149860713 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1043,6 +1043,27 @@ function Search({ accountID, outstandingReportsByPolicyID, ); + + // When all transactions in a group are individually selected, register the group key so export treats it as a grouped selection. + if (areItemsGrouped) { + const parentGroup = (filteredData as TransactionGroupListItemType[]).find((g) => g.transactions.some((t) => t.keyForList === item.keyForList)); + const groupKey = parentGroup?.keyForList; + if (groupKey) { + const selectableTransactions = parentGroup.transactions.filter((t) => !isTransactionPendingDelete(t)); + const allSelected = selectableTransactions.every((t) => updatedTransactions[t.keyForList]?.isSelected); + if (allSelected) { + updatedTransactions[groupKey] = mapEmptyReportToSelectedEntry(parentGroup)[1]; + selectableTransactions.forEach((t) => { + if (updatedTransactions[t.keyForList]) { + updatedTransactions[t.keyForList] = {...updatedTransactions[t.keyForList], groupKey}; + } + }); + } else { + delete updatedTransactions[groupKey]; + } + } + } + setSelectedTransactions(updatedTransactions); updateSelectAllMatchingItemsState(updatedTransactions); return; @@ -1117,7 +1138,7 @@ function Search({ setSelectedTransactions(updatedTransactions); updateSelectAllMatchingItemsState(updatedTransactions); }, - [selectedTransactions, setSelectedTransactions, updateSelectAllMatchingItemsState, transactions, email, accountID, outstandingReportsByPolicyID, searchResults?.data], + [selectedTransactions, setSelectedTransactions, updateSelectAllMatchingItemsState, transactions, email, accountID, outstandingReportsByPolicyID, searchResults?.data, areItemsGrouped, filteredData], ); const onSelectRowInMobileSelectionMode = (item: SearchListItem) => { diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index d8340948c590..9ee9b19e1ed5 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -127,9 +127,17 @@ function SearchPage({route}: SearchPageProps) { const shouldUseClientTotal = selectedTransactionsKeys.length > 0 || !metadata?.count; const selectedTransactionItems = Object.values(selectedTransactions); const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.groupCurrency ?? selectedTransactionItems.at(0)?.currency; + const expandedGroupKeys = new Set( + Object.values(selectedTransactions) + .map((t) => t.groupKey) + .filter(Boolean), + ); const numberOfExpense = shouldUseClientTotal ? selectedTransactionsKeys.reduce((count, key) => { if (key.startsWith(CONST.SEARCH.GROUP_PREFIX)) { + if (expandedGroupKeys.has(key)) { + return count; + } const group = currentSearchResults?.data?.[key as keyof typeof currentSearchResults.data] as {count?: number} | undefined; return count + (group?.count ?? 0); } From e3867afb3c5bd18ac9aed1f777b3dc735973272b Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Tue, 26 May 2026 19:34:10 +0300 Subject: [PATCH 31/37] fix lint --- src/components/Search/index.tsx | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index b11149860713..2cff3b595b54 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1046,18 +1046,21 @@ function Search({ // When all transactions in a group are individually selected, register the group key so export treats it as a grouped selection. if (areItemsGrouped) { - const parentGroup = (filteredData as TransactionGroupListItemType[]).find((g) => g.transactions.some((t) => t.keyForList === item.keyForList)); + const parentGroup = (filteredData as TransactionGroupListItemType[]).find((group) => + group.transactions.some((transaction) => transaction.keyForList === item.keyForList), + ); const groupKey = parentGroup?.keyForList; if (groupKey) { - const selectableTransactions = parentGroup.transactions.filter((t) => !isTransactionPendingDelete(t)); - const allSelected = selectableTransactions.every((t) => updatedTransactions[t.keyForList]?.isSelected); + const selectableTransactions = parentGroup.transactions.filter((transaction) => !isTransactionPendingDelete(transaction)); + const allSelected = selectableTransactions.every((transaction) => updatedTransactions[transaction.keyForList]?.isSelected); if (allSelected) { updatedTransactions[groupKey] = mapEmptyReportToSelectedEntry(parentGroup)[1]; - selectableTransactions.forEach((t) => { - if (updatedTransactions[t.keyForList]) { - updatedTransactions[t.keyForList] = {...updatedTransactions[t.keyForList], groupKey}; + for (const transaction of selectableTransactions) { + if (!updatedTransactions[transaction.keyForList]) { + continue; } - }); + updatedTransactions[transaction.keyForList] = {...updatedTransactions[transaction.keyForList], groupKey}; + } } else { delete updatedTransactions[groupKey]; } @@ -1138,7 +1141,18 @@ function Search({ setSelectedTransactions(updatedTransactions); updateSelectAllMatchingItemsState(updatedTransactions); }, - [selectedTransactions, setSelectedTransactions, updateSelectAllMatchingItemsState, transactions, email, accountID, outstandingReportsByPolicyID, searchResults?.data, areItemsGrouped, filteredData], + [ + selectedTransactions, + setSelectedTransactions, + updateSelectAllMatchingItemsState, + transactions, + email, + accountID, + outstandingReportsByPolicyID, + searchResults?.data, + areItemsGrouped, + filteredData, + ], ); const onSelectRowInMobileSelectionMode = (item: SearchListItem) => { From dd63857b18030797cf5e7739f2e22a4e68312cc4 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Wed, 27 May 2026 17:21:19 +0300 Subject: [PATCH 32/37] address PR reviews --- .../Search/SearchBulkActionsButton.tsx | 8 ------ .../Search/SearchList/ListItem/types.ts | 6 +++++ src/components/Search/index.tsx | 26 ++++++------------- src/hooks/useSearchBulkActions.ts | 1 + src/pages/Search/SearchPage.tsx | 8 ------ 5 files changed, 15 insertions(+), 34 deletions(-) diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index 69a94a433b8b..c3fe0c431a61 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -108,16 +108,8 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { return reportIDs.size; } - const expandedGroupKeys = new Set( - Object.values(selectedTransactions) - .map((t) => t.groupKey) - .filter(Boolean), - ); return selectedTransactionsKeys.reduce((count, key) => { if (key.startsWith(CONST.SEARCH.GROUP_PREFIX)) { - if (expandedGroupKeys.has(key)) { - return count; - } const group = searchData?.[key as keyof typeof searchData] as {count?: number} | undefined; return count + (group?.count ?? 0); } diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts index d74fa21f2c56..945e04e3d332 100644 --- a/src/components/Search/SearchList/ListItem/types.ts +++ b/src/components/Search/SearchList/ListItem/types.ts @@ -193,6 +193,12 @@ type TransactionGroupListItemType = ListItem & { /** Whether the report was rejected (REJECTED or REJECTEDTOSUBMITTER) */ isRejectedReport?: boolean; + + /** Total value of transactions in the group */ + total?: number; + + /** Currency of the group total */ + currency?: string; }; type ExpenseReportListItemType = TransactionReportGroupListItemType; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 2cff3b595b54..300047ea4450 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -191,6 +191,8 @@ function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType ]; } + const currency = item.currency ?? ''; + return [ item.keyForList ?? '', { @@ -206,8 +208,9 @@ function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType action: CONST.SEARCH.ACTION_TYPES.VIEW, reportID: item.reportID, policyID: item.policyID ?? CONST.POLICY.ID_FAKE, - amount: 0, - currency: '', + amount: item.total ?? 0, + currency, + ...(currency ? {groupCurrency: currency} : {}), }, ]; } @@ -1044,26 +1047,13 @@ function Search({ outstandingReportsByPolicyID, ); - // When all transactions in a group are individually selected, register the group key so export treats it as a grouped selection. + // Tag individual transactions with their parent group key so export filtering can derive the group when needed. if (areItemsGrouped) { const parentGroup = (filteredData as TransactionGroupListItemType[]).find((group) => group.transactions.some((transaction) => transaction.keyForList === item.keyForList), ); - const groupKey = parentGroup?.keyForList; - if (groupKey) { - const selectableTransactions = parentGroup.transactions.filter((transaction) => !isTransactionPendingDelete(transaction)); - const allSelected = selectableTransactions.every((transaction) => updatedTransactions[transaction.keyForList]?.isSelected); - if (allSelected) { - updatedTransactions[groupKey] = mapEmptyReportToSelectedEntry(parentGroup)[1]; - for (const transaction of selectableTransactions) { - if (!updatedTransactions[transaction.keyForList]) { - continue; - } - updatedTransactions[transaction.keyForList] = {...updatedTransactions[transaction.keyForList], groupKey}; - } - } else { - delete updatedTransactions[groupKey]; - } + if (parentGroup?.keyForList && updatedTransactions[item.keyForList]) { + updatedTransactions[item.keyForList] = {...updatedTransactions[item.keyForList], groupKey: parentGroup.keyForList}; } } diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index a374681fcad1..c8f495f369b0 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1070,6 +1070,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const includesGroupExport = Object.entries(selectedTransactions).some( ([key, selectedTransaction]) => key.startsWith(CONST.SEARCH.GROUP_PREFIX) && !selectedTransaction?.transaction, ); + console.log({includesGroupExport, selectedTransactions}); const canReportBeExported = (report: (typeof selectedReports)[0], exportOption: ValueOf) => { if (!report.reportID) { diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 9ee9b19e1ed5..d8340948c590 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -127,17 +127,9 @@ function SearchPage({route}: SearchPageProps) { const shouldUseClientTotal = selectedTransactionsKeys.length > 0 || !metadata?.count; const selectedTransactionItems = Object.values(selectedTransactions); const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.groupCurrency ?? selectedTransactionItems.at(0)?.currency; - const expandedGroupKeys = new Set( - Object.values(selectedTransactions) - .map((t) => t.groupKey) - .filter(Boolean), - ); const numberOfExpense = shouldUseClientTotal ? selectedTransactionsKeys.reduce((count, key) => { if (key.startsWith(CONST.SEARCH.GROUP_PREFIX)) { - if (expandedGroupKeys.has(key)) { - return count; - } const group = currentSearchResults?.data?.[key as keyof typeof currentSearchResults.data] as {count?: number} | undefined; return count + (group?.count ?? 0); } From 551353a92a8ac86be98867b810acdd80fad4f335 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Wed, 27 May 2026 17:21:55 +0300 Subject: [PATCH 33/37] remove console logs --- src/hooks/useSearchBulkActions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index c8f495f369b0..a374681fcad1 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1070,7 +1070,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const includesGroupExport = Object.entries(selectedTransactions).some( ([key, selectedTransaction]) => key.startsWith(CONST.SEARCH.GROUP_PREFIX) && !selectedTransaction?.transaction, ); - console.log({includesGroupExport, selectedTransactions}); const canReportBeExported = (report: (typeof selectedReports)[0], exportOption: ValueOf) => { if (!report.reportID) { From b60bb52e57b9559cc0f6d9839e0de2b05f6a755a Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Fri, 29 May 2026 17:55:41 +0300 Subject: [PATCH 34/37] fix: keep groupKeys --- src/components/Search/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 355c94ff4094..b6bc206616a0 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -862,6 +862,7 @@ function Search({ reportAction: transactionItem.reportAction, isFromOneTransactionReport: isOneTransactionReport(transactionItem.report), report: transactionItem.report, + groupKey: previousSelection?.groupKey ?? (propagateSelectionToAllRows && !isExpenseReportType ? reportKey : undefined), }; } } From f25282929225336f38df66ef5a60f291e10a4050 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Fri, 29 May 2026 18:06:43 +0300 Subject: [PATCH 35/37] fix lint --- src/components/Search/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 28d00562f59d..8253bc2326cd 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -1177,7 +1177,6 @@ function Search({ isProduction, areItemsGrouped, filteredData, - selfDMReport, ], ); From 526712c59630d5f2040088e1ba35194acb0fc62b Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Mon, 1 Jun 2026 12:25:58 +0300 Subject: [PATCH 36/37] fix unselected list when group is expanded --- .../Search/SearchList/ListItem/TransactionGroupListItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index afc675c1b0c8..f8fdcde1b50d 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -276,7 +276,7 @@ function TransactionGroupListItem({ }; const onPress = (event?: ModifiedMouseEvent) => { - if (isExpenseReportType || transactions.length === 0) { + if (isExpenseReportType) { onSelectRow(item, transactionPreviewData, event); } if (!isExpenseReportType) { From a8373663ab2107a5d44e7e0b3b7d8ed613d19e72 Mon Sep 17 00:00:00 2001 From: Getabalew Tesfaye Date: Mon, 1 Jun 2026 12:43:44 +0300 Subject: [PATCH 37/37] fix lint --- 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 ad1cfbae021a..7d4a249c7853 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -662,13 +662,14 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const isGroupExport = !!queryJSON?.groupBy && selectedTransactionsKeys.some((key) => key.startsWith(CONST.SEARCH.GROUP_PREFIX)); let didFail = false; const exportParameters = getCSVExportParameters(isBasicExport); + const reportIDList = selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs; await exportSearchItemsToCSV( { query: status, jsonQuery: isGroupExport ? serializeQueryJSONForBackend(addSelectedGroupsFilter(queryJSON, selectedTransactions, currentSearchResults?.data)) : exportParameters.jsonQuery, - reportIDList: isGroupExport ? [] : (selectedReports.length > 0 ? selectedReportIDs : selectedTransactionReportIDs), + reportIDList: isGroupExport ? [] : reportIDList, transactionIDList: isGroupExport ? [] : selectedTransactionsKeys, isBasicExport: exportParameters.isBasicExport, exportColumnLabels: exportParameters.exportColumnLabels,