diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index 52e8f8ec1b3f..73a9d8946182 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -34,7 +34,7 @@ import type {BulkPaySelectionData, SearchQueryJSON} from './types'; import BulkDuplicateHandler from './BulkDuplicateHandler'; import BulkDuplicateReportHandler from './BulkDuplicateReportHandler'; -import {useSearchSelectionContext} from './SearchContext'; +import {useSearchResultsContext, useSearchSelectionContext} from './SearchContext'; type SearchBulkActionsButtonProps = { queryJSON: SearchQueryJSON; @@ -46,7 +46,8 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { // We need isSmallScreenWidth (not just shouldUseNarrowLayout) because DecisionModal requires it for correct modal type // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); - const {selectedTransactions, selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {selectedTransactions, excludedTransactions, selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {currentSearchResults} = useSearchResultsContext(); const kycWallRef = useContext(KYCWallContext); const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); @@ -122,7 +123,13 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { }, 0); }, [selectedTransactions, selectedTransactionsKeys, isExpenseReportType, searchData]); - const selectionButtonText = areAllMatchingItemsSelected ? translate('search.exportAll.allMatchingItemsSelected') : translate('workspace.common.selected', {count: selectedItemsCount}); + // While "select all matching" is on, the real count is the server-wide total minus the rows the user excluded. + const excludedTransactionsCount = Object.keys(excludedTransactions).length; + const allMatchingSelectedItemsCount = Math.max((currentSearchResults?.search?.count ?? selectedItemsCount) - excludedTransactionsCount, 0); + const selectionButtonText = + areAllMatchingItemsSelected && excludedTransactionsCount === 0 + ? translate('search.exportAll.allMatchingItemsSelected') + : translate('workspace.common.selected', {count: areAllMatchingItemsSelected ? allMatchingSelectedItemsCount : selectedItemsCount}); return ( <> diff --git a/src/components/Search/SearchContextDefinitions.ts b/src/components/Search/SearchContextDefinitions.ts index 9f32d0e75525..92e59e9cb3d4 100644 --- a/src/components/Search/SearchContextDefinitions.ts +++ b/src/components/Search/SearchContextDefinitions.ts @@ -48,6 +48,7 @@ const defaultSearchResultsActions: SearchResultsActionsValue = { const defaultSearchSelectionContext: SearchSelectionContextValue = { currentSelectedTransactionReportID: undefined, selectedTransactions: {}, + excludedTransactions: {}, selectedTransactionIDs: [], selectedReports: [], shouldTurnOffSelectionMode: false, diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 1f9da483e4e0..3adfe7e804a5 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -20,7 +20,7 @@ type SearchSelectionFooterProps = { // Self-subscribing footer leaf. Owns the `selectedTransactions` read so a checkbox press re-renders only this // footer — not SearchPage and the list it contains. function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { - const {selectedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {selectedTransactions, excludedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext(); const {currentSearchResults} = useSearchResultsContext(); const {currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); @@ -36,6 +36,12 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { const shouldUseClientTotal = !metadata?.count || (selectedTransactionsKeys.length > 0 && !areAllMatchingItemsSelected); const selectedTransactionItems = Object.values(selectedTransactions); const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.groupCurrency ?? selectedTransactionItems.at(0)?.currency; + + // In "select all matching" mode the footer reflects the server-wide metadata; subtract the rows the user excluded. + const excludedTransactionItems = Object.values(excludedTransactions); + const excludedTransactionsCount = excludedTransactionItems.length; + const excludedTotal = excludedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0); + const count = shouldUseClientTotal ? selectedTransactionsKeys.reduce((acc, key) => { if (isGroupEntry(key)) { @@ -48,8 +54,10 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { } return acc + 1; }, 0) - : metadata?.count; - const total = shouldUseClientTotal ? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0) : metadata?.total; + : (metadata?.count ?? 0) - excludedTransactionsCount; + const total = shouldUseClientTotal + ? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0) + : (metadata?.total ?? 0) - excludedTotal; return ( 0 || Object.values(selectionState.selectedTransactions).some((t) => t.isSelected); + // "Select all matching" is always a non-empty selection (all matching minus a few exclusions), even after the + // user unchecks every currently-visible row — so it must count as having a selection on its own. + const hasSelectedTransactions = + selectionState.areAllMatchingItemsSelected || selectionState.selectedTransactionIDs.length > 0 || Object.values(selectionState.selectedTransactions).some((t) => t.isSelected); const selectionValue: SearchSelectionContextValue = { ...selectionState, @@ -242,18 +276,22 @@ function useSyncSelectedReports(data: SearchData) { /** Narrow per-row selection read: whether the row for `keyForList` is selected (or covered by select-all). */ function useRowSelection(keyForList: string | undefined): {isSelected: boolean} { - const {selectedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {selectedTransactions, excludedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext(); if (!keyForList) { return {isSelected: false}; } - return {isSelected: areAllMatchingItemsSelected || !!selectedTransactions[keyForList]?.isSelected}; + // While "select all matching" is on, a row is selected unless the user explicitly excluded it. + return {isSelected: (areAllMatchingItemsSelected && !excludedTransactions[keyForList]) || !!selectedTransactions[keyForList]?.isSelected}; } /** Aggregate count of currently-selected transactions, for the selection top bar. */ function useSelectionCounts(): {selected: number} { - const {selectedTransactions} = useSearchSelectionContext(); - const selected = Object.values(selectedTransactions).filter((value) => value?.isSelected).length; - return {selected}; + const {selectedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const visibleSelected = Object.values(selectedTransactions).filter((value) => value?.isSelected).length; + // In "select all matching" mode the true total lives in the search metadata (see SearchBulkActionsButton / + // SearchSelectionFooter). Report a positive count here so the bulk-actions bar stays visible even after the user + // excludes every currently-visible row. + return {selected: areAllMatchingItemsSelected ? Math.max(visibleSelected, 1) : visibleSelected}; } export {SearchSelectionProvider, useSyncSelectedReports, useRowSelection, useSelectionCounts}; diff --git a/src/components/Search/SearchWriteActionsProvider.tsx b/src/components/Search/SearchWriteActionsProvider.tsx index 16c43bf91fca..44bcf2d30f4d 100644 --- a/src/components/Search/SearchWriteActionsProvider.tsx +++ b/src/components/Search/SearchWriteActionsProvider.tsx @@ -127,7 +127,7 @@ function useReconcileSelectionWithData({ reportNameValuePairs, outstandingReportsByPolicyID, }: ReconcileSelectionParams) { - const {selectedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {selectedTransactions, excludedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext(); const {applySelection} = useSearchSelectionActions(); useEffect(() => { @@ -150,7 +150,7 @@ function useReconcileSelectionWithData({ if (transactionGroup.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { continue; } - if (reportKey && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) { + if (reportKey && !excludedTransactions[reportKey] && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) { const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(transactionGroup); newTransactionList[reportKey] = { ...emptyReportSelection, @@ -173,9 +173,11 @@ function useReconcileSelectionWithData({ for (const transactionItem of transactionGroup.transactions) { const listKey = transactionItem.keyForList ?? transactionItem.transactionID; const isSelected = listKey in selectedTransactions || transactionItem.transactionID in selectedTransactions; + // A row the user unchecked while select-all is on must not be re-added by a later data reconcile. + const isExcluded = !!excludedTransactions[listKey] || !!excludedTransactions[transactionItem.transactionID]; // Include transaction if: already individually selected, part of select-all, or group-level propagation (expense report / empty group expanded) - const shouldInclude = isSelected || areAllMatchingItemsSelected || propagateSelectionToAllRows; + const shouldInclude = !isExcluded && (isSelected || areAllMatchingItemsSelected || propagateSelectionToAllRows); if (!shouldInclude) { continue; } @@ -218,7 +220,8 @@ function useReconcileSelectionWithData({ continue; } const listKey = transactionItem.keyForList ?? transactionItem.transactionID; - if (!(listKey in selectedTransactions) && !(transactionItem.transactionID in selectedTransactions) && !areAllMatchingItemsSelected) { + const isExcluded = !!excludedTransactions[listKey] || !!excludedTransactions[transactionItem.transactionID]; + if (isExcluded || (!(listKey in selectedTransactions) && !(transactionItem.transactionID in selectedTransactions) && !areAllMatchingItemsSelected)) { continue; } @@ -392,7 +395,7 @@ function SearchWriteActionsProvider({ return updatedTransactions; }, - {totalSelectableItemsCount}, + {totalSelectableItemsCount, shouldUpdateMatchingExclusions: true}, ); return; } @@ -456,7 +459,7 @@ function SearchWriteActionsProvider({ ), }; }, - {totalSelectableItemsCount}, + {totalSelectableItemsCount, shouldUpdateMatchingExclusions: true}, ); }; diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index c91c016d69e8..ffe7fe6900b7 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -211,6 +211,13 @@ type SearchResultsActionsValue = { type SearchSelectionContextValue = { currentSelectedTransactionReportID: string | undefined; selectedTransactions: SelectedTransactions; + /** + * Rows the user explicitly unchecked while "select all matching" was active. Represents the "all matching + * EXCEPT these" state that the single `areAllMatchingItemsSelected` flag can't express on its own. Only ever + * populated while `areAllMatchingItemsSelected` is true; cleared whenever select-all is toggled or the + * selection is cleared. + */ + excludedTransactions: SelectedTransactions; selectedTransactionIDs: string[]; selectedReports: SelectedReports[]; shouldTurnOffSelectionMode: boolean; @@ -235,7 +242,10 @@ type SearchSelectionActionsValue = { * thus re-rendering on — selection state. Passing `data` derives `selectedReports` in the same commit; passing * `totalSelectableItemsCount` unchecks "select all matching" when the new selection no longer covers every item. */ - applySelection: (updater: (previousSelectedTransactions: SelectedTransactions) => SelectedTransactions, options?: {data?: SearchData; totalSelectableItemsCount?: number}) => void; + applySelection: ( + updater: (previousSelectedTransactions: SelectedTransactions) => SelectedTransactions, + options?: {data?: SearchData; totalSelectableItemsCount?: number; shouldUpdateMatchingExclusions?: boolean}, + ) => void; setSelectedReports: (reports: SelectedReports[]) => void; setCurrentSelectedTransactionReportID: (reportID: string | undefined) => void; /** If you want to clear `selectedTransactionIDs`, pass `true` as the first argument */ diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 30a35e7bf474..baffee9f6dd4 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -354,7 +354,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {isProduction} = useEnvironment(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); - const {selectedTransactions, selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {selectedTransactions, excludedTransactions, selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext(); const {currentSearchResults} = useSearchResultsContext(); const {currentSearchKey} = useSearchQueryContext(); const {clearSelectedTransactions, selectAllMatchingItems} = useSearchSelectionActions(); @@ -632,6 +632,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { jsonQuery: serializedQuery, reportIDList: [], transactionIDList: [], + // "All matching except these": the backend re-runs the query and skips the excluded IDs. + excludedTransactionIDList: Object.keys(excludedTransactions), policyID, exportName, }, @@ -657,6 +659,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { [ selectedReports, selectedTransactions, + excludedTransactions, isOffline, areAllMatchingItemsSelected, currentSearchResults?.data, @@ -735,6 +738,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { jsonQuery: exportParameters.jsonQuery, reportIDList, transactionIDList: selectedTransactionsKeys, + // "All matching except these": the backend re-runs the query and skips the excluded IDs. + excludedTransactionIDList: Object.keys(excludedTransactions), isBasicExport: exportParameters.isBasicExport, exportColumnLabels: exportParameters.exportColumnLabels, exportName, @@ -778,6 +783,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { selectedReportIDs, selectedTransactionReportIDs, selectedTransactions, + excludedTransactions, selectedTransactionsKeys, translate, clearSelectedTransactions, diff --git a/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts b/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts index b7309bf30f30..c1f1f690246a 100644 --- a/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts +++ b/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts @@ -5,6 +5,8 @@ type ExportSearchItemsToCSVParams = { jsonQuery: SearchQueryString; reportIDList: string[]; transactionIDList: string[]; + /** When "select all matching" is active, the IDs the user explicitly excluded so the backend skips them. */ + excludedTransactionIDList?: string[]; isBasicExport: boolean; exportColumnLabels: string; exportName: string; diff --git a/src/libs/API/parameters/ExportSearchWithTemplateParams.ts b/src/libs/API/parameters/ExportSearchWithTemplateParams.ts index 4fd69ce08433..3e8954d6dc38 100644 --- a/src/libs/API/parameters/ExportSearchWithTemplateParams.ts +++ b/src/libs/API/parameters/ExportSearchWithTemplateParams.ts @@ -6,6 +6,8 @@ type ExportSearchWithTemplateParams = { jsonQuery: SearchQueryString; reportIDList: string[]; transactionIDList: string[]; + /** When "select all matching" is active, the IDs the user explicitly excluded so the backend skips them. */ + excludedTransactionIDList?: string[]; policyID: string | undefined; exportName: string; }; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 2f29d73f1177..4fdfbc577e3b 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -1372,7 +1372,16 @@ function exportSearchItemsToCSV( ); } -function queueExportSearchItemsToCSV({query, jsonQuery, reportIDList, transactionIDList, isBasicExport, exportColumnLabels, exportName}: ExportSearchItemsToCSVParams): string { +function queueExportSearchItemsToCSV({ + query, + jsonQuery, + reportIDList, + transactionIDList, + excludedTransactionIDList, + isBasicExport, + exportColumnLabels, + exportName, +}: ExportSearchItemsToCSVParams): string { const exportID = rand64(); const onyxKey = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}` as const; @@ -1402,6 +1411,7 @@ function queueExportSearchItemsToCSV({query, jsonQuery, reportIDList, transactio jsonQuery, reportIDList, transactionIDList, + excludedTransactionIDList, isBasicExport, exportColumnLabels, exportName, @@ -1417,7 +1427,7 @@ function queueExportSearchItemsToCSV({query, jsonQuery, reportIDList, transactio } function queueExportSearchWithTemplate( - {templateName, templateType, jsonQuery, reportIDList, transactionIDList, policyID, exportName}: ExportSearchWithTemplateParams, + {templateName, templateType, jsonQuery, reportIDList, transactionIDList, excludedTransactionIDList, policyID, exportName}: ExportSearchWithTemplateParams, shouldTrackExportProgress = false, ): string { const exportID = rand64(); @@ -1454,6 +1464,7 @@ function queueExportSearchWithTemplate( jsonQuery, reportIDList, transactionIDList, + excludedTransactionIDList, policyID, exportName, ...(shouldTrackExportProgress ? {exportID} : {}), diff --git a/tests/ui/CategoryListItemHeaderTest.tsx b/tests/ui/CategoryListItemHeaderTest.tsx index abfcf88cb49c..07efface03b2 100644 --- a/tests/ui/CategoryListItemHeaderTest.tsx +++ b/tests/ui/CategoryListItemHeaderTest.tsx @@ -36,6 +36,7 @@ const mockSearchStateContext = { selectedReports: [], selectedTransactionIDs: [], selectedTransactions: {}, + excludedTransactions: {}, shouldTurnOffSelectionMode: false, shouldResetSearchQuery: false, lastSearchType: undefined, diff --git a/tests/ui/MerchantListItemHeaderTest.tsx b/tests/ui/MerchantListItemHeaderTest.tsx index 00364be612db..9910c1b606be 100644 --- a/tests/ui/MerchantListItemHeaderTest.tsx +++ b/tests/ui/MerchantListItemHeaderTest.tsx @@ -36,6 +36,7 @@ const mockSearchStateContext = { selectedReports: [], selectedTransactionIDs: [], selectedTransactions: {}, + excludedTransactions: {}, shouldTurnOffSelectionMode: false, shouldResetSearchQuery: false, lastSearchType: undefined, diff --git a/tests/ui/MonthListItemHeaderTest.tsx b/tests/ui/MonthListItemHeaderTest.tsx index a429838ed05f..6344ea3e05e9 100644 --- a/tests/ui/MonthListItemHeaderTest.tsx +++ b/tests/ui/MonthListItemHeaderTest.tsx @@ -36,6 +36,7 @@ const mockSearchStateContext = { selectedReports: [], selectedTransactionIDs: [], selectedTransactions: {}, + excludedTransactions: {}, shouldTurnOffSelectionMode: false, shouldResetSearchQuery: false, lastSearchType: undefined, diff --git a/tests/ui/ReportListItemHeaderTest.tsx b/tests/ui/ReportListItemHeaderTest.tsx index 893891ba849c..ae731e16c579 100644 --- a/tests/ui/ReportListItemHeaderTest.tsx +++ b/tests/ui/ReportListItemHeaderTest.tsx @@ -37,6 +37,7 @@ const mockSearchStateContext = { selectedReports: [], selectedTransactionIDs: [], selectedTransactions: {}, + excludedTransactions: {}, shouldTurnOffSelectionMode: false, currentSearchKey: undefined, currentSearchQueryJSON: undefined, diff --git a/tests/ui/WeekListItemHeaderTest.tsx b/tests/ui/WeekListItemHeaderTest.tsx index c49d3c1b9f5e..cd5805902f95 100644 --- a/tests/ui/WeekListItemHeaderTest.tsx +++ b/tests/ui/WeekListItemHeaderTest.tsx @@ -35,6 +35,7 @@ const mockSearchStateContext = { selectedReports: [], selectedTransactionIDs: [], selectedTransactions: {}, + excludedTransactions: {}, shouldTurnOffSelectionMode: false, shouldResetSearchQuery: false, lastSearchType: undefined, diff --git a/tests/ui/YearListItemHeaderTest.tsx b/tests/ui/YearListItemHeaderTest.tsx index c320bda4eb56..7efd478ac26b 100644 --- a/tests/ui/YearListItemHeaderTest.tsx +++ b/tests/ui/YearListItemHeaderTest.tsx @@ -36,6 +36,7 @@ const mockSearchStateContext = { selectedReports: [], selectedTransactionIDs: [], selectedTransactions: {}, + excludedTransactions: {}, shouldTurnOffSelectionMode: false, shouldResetSearchQuery: false, lastSearchType: undefined, diff --git a/tests/unit/Search/useRowSelectionTest.tsx b/tests/unit/Search/useRowSelectionTest.tsx index 27b9d068f996..198bb3e00cf3 100644 --- a/tests/unit/Search/useRowSelectionTest.tsx +++ b/tests/unit/Search/useRowSelectionTest.tsx @@ -10,6 +10,7 @@ import React from 'react'; const baseSelectionContext = { currentSelectedTransactionReportID: undefined, + excludedTransactions: {}, selectedTransactionIDs: [], selectedReports: [], shouldTurnOffSelectionMode: false, @@ -64,16 +65,18 @@ function renderRowSelection({ keyForList, selectedTransactions, areAllMatchingItemsSelected, + excludedTransactions = {}, }: { keyForList: string | undefined; selectedTransactions: SelectedTransactions; areAllMatchingItemsSelected: boolean; + excludedTransactions?: SelectedTransactions; }): {isSelected: boolean} { - return renderWithSelection(() => useRowSelection(keyForList), {...baseSelectionContext, areAllMatchingItemsSelected, selectedTransactions}); + return renderWithSelection(() => useRowSelection(keyForList), {...baseSelectionContext, areAllMatchingItemsSelected, selectedTransactions, excludedTransactions}); } -function renderSelectionCounts(selectedTransactions: SelectedTransactions): {selected: number} { - return renderWithSelection(() => useSelectionCounts(), {...baseSelectionContext, selectedTransactions}); +function renderSelectionCounts(selectedTransactions: SelectedTransactions, areAllMatchingItemsSelected = false): {selected: number} { + return renderWithSelection(() => useSelectionCounts(), {...baseSelectionContext, selectedTransactions, areAllMatchingItemsSelected}); } describe('useRowSelection', () => { @@ -86,6 +89,28 @@ describe('useRowSelection', () => { expect(renderRowSelection({keyForList: 'tx_not_in_map', selectedTransactions: {}, areAllMatchingItemsSelected: true}).isSelected).toBe(true); }); + it('marks an excluded row not-selected while areAllMatchingItemsSelected is true', () => { + expect( + renderRowSelection({ + keyForList: 'tx_excluded', + selectedTransactions: {}, + areAllMatchingItemsSelected: true, + excludedTransactions: buildSelected('tx_excluded'), + }).isSelected, + ).toBe(false); + }); + + it('keeps a different row selected when another row is excluded in select-all mode', () => { + expect( + renderRowSelection({ + keyForList: 'tx_still_selected', + selectedTransactions: {}, + areAllMatchingItemsSelected: true, + excludedTransactions: buildSelected('tx_excluded'), + }).isSelected, + ).toBe(true); + }); + it('returns not-selected when keyForList is undefined', () => { expect(renderRowSelection({keyForList: undefined, selectedTransactions: buildSelected('tx_1'), areAllMatchingItemsSelected: false}).isSelected).toBe(false); }); @@ -101,4 +126,8 @@ describe('useSelectionCounts', () => { it('returns 0 when nothing is selected', () => { expect(renderSelectionCounts({}).selected).toBe(0); }); + + it('stays positive in select-all mode even when every visible row is excluded (empty map)', () => { + expect(renderSelectionCounts({}, true).selected).toBe(1); + }); }); diff --git a/tests/unit/Search/useSyncSelectedReportsTest.tsx b/tests/unit/Search/useSyncSelectedReportsTest.tsx index 2c742787d0d1..ee0f931e4df6 100644 --- a/tests/unit/Search/useSyncSelectedReportsTest.tsx +++ b/tests/unit/Search/useSyncSelectedReportsTest.tsx @@ -15,6 +15,7 @@ const createSetSelectedReportsMock = () => jest.fn(); const baseSelectionContext = { currentSelectedTransactionReportID: undefined, + excludedTransactions: {}, selectedTransactionIDs: [], selectedReports: [], shouldTurnOffSelectionMode: false, diff --git a/tests/unit/hooks/useSearchBulkActionsExportTest.ts b/tests/unit/hooks/useSearchBulkActionsExportTest.ts index 7b7bef30a810..4c288ad7a58b 100644 --- a/tests/unit/hooks/useSearchBulkActionsExportTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsExportTest.ts @@ -205,6 +205,7 @@ let mockCurrentSearchResults: SearchResults | undefined; jest.mock('@components/Search/SearchContext', () => ({ useSearchSelectionContext: () => ({ selectedTransactions: mockSelectedTransactions, + excludedTransactions: {}, selectedReports: mockSelectedReports, areAllMatchingItemsSelected: false, }), diff --git a/tests/unit/hooks/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts index 7daeed8ff4ed..fd610d9cb4c6 100644 --- a/tests/unit/hooks/useSearchBulkActionsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsTest.ts @@ -159,6 +159,7 @@ let mockAreAllMatchingItemsSelected = false; jest.mock('@components/Search/SearchContext', () => ({ useSearchSelectionContext: () => ({ selectedTransactions: mockSelectedTransactions, + excludedTransactions: {}, selectedReports: mockSelectedReports, areAllMatchingItemsSelected: mockAreAllMatchingItemsSelected, }), diff --git a/tests/utils/MockSearchContextProvider.tsx b/tests/utils/MockSearchContextProvider.tsx index e95248fc0c24..4bb5453f259d 100644 --- a/tests/utils/MockSearchContextProvider.tsx +++ b/tests/utils/MockSearchContextProvider.tsx @@ -48,6 +48,7 @@ function splitState(value: SearchStateContextValue): { }, selection: { selectedTransactions: value.selectedTransactions, + excludedTransactions: value.excludedTransactions, selectedTransactionIDs: value.selectedTransactionIDs, selectedReports: value.selectedReports, currentSelectedTransactionReportID: value.currentSelectedTransactionReportID,