From fc4c23882c4682ec4f4088c127dbe4c6df7eceb2 Mon Sep 17 00:00:00 2001 From: emkhalid Date: Sat, 11 Jul 2026 23:11:00 +0430 Subject: [PATCH 01/16] fix: preserve all matching selection after deselecting an expense --- .../Search/SearchBulkActionsButton.tsx | 54 +++--- .../Search/SearchContextDefinitions.ts | 1 + .../ListItem/ExpenseReportListItem.tsx | 2 +- .../ListItem/TransactionGroupListItem.tsx | 2 +- .../Search/SearchSelectionFooter.tsx | 47 +++-- .../Search/SearchSelectionProvider.tsx | 53 +++++- .../Search/SearchWriteActionsProvider.tsx | 23 ++- src/components/Search/index.tsx | 50 ++++- src/components/Search/types.ts | 8 +- src/hooks/useSearchBulkActions.ts | 7 +- src/hooks/useSearchShouldCalculateTotals.ts | 17 +- .../ExportSearchItemsToCSVParams.ts | 1 + src/libs/actions/Search.ts | 15 +- tests/ui/CategoryListItemHeaderTest.tsx | 1 + tests/ui/MerchantListItemHeaderTest.tsx | 1 + tests/ui/MonthListItemHeaderTest.tsx | 1 + tests/ui/ReportListItemHeaderTest.tsx | 1 + tests/ui/WeekListItemHeaderTest.tsx | 1 + tests/ui/YearListItemHeaderTest.tsx | 1 + .../Search/SearchBulkActionsButtonTest.tsx | 133 ++++++++++++++ .../unit/Search/SearchSelectionFooterTest.tsx | 102 +++++++++++ .../Search/SearchSelectionProviderTest.tsx | 172 ++++++++++++++++++ tests/unit/Search/useRowSelectionTest.tsx | 16 +- .../Search/useSyncSelectedReportsTest.tsx | 1 + tests/unit/SearchActionsTest.ts | 63 ++++++- tests/unit/hooks/useSearchBulkActionsTest.ts | 5 + .../useSearchShouldCalculateTotals.test.ts | 20 +- tests/utils/MockSearchContextProvider.tsx | 1 + 28 files changed, 721 insertions(+), 78 deletions(-) create mode 100644 tests/unit/Search/SearchBulkActionsButtonTest.tsx create mode 100644 tests/unit/Search/SearchSelectionFooterTest.tsx create mode 100644 tests/unit/Search/SearchSelectionProviderTest.tsx diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index 1a8c0a1dd09b..330ad18683b1 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -48,7 +48,7 @@ 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(); @@ -102,35 +102,43 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { const isExpenseReportType = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const popoverUseScrollView = shouldPopoverUseScrollView(headerButtonsOptions); - const selectedItemsCount = useMemo(() => { - if (!selectedTransactions) { - return 0; - } + const {selectedItemsCount, excludedItemsCount} = useMemo(() => { + const getItemsCount = (transactionsToCount: typeof selectedTransactions) => { + if (isExpenseReportType) { + const reportIDs = new Set( + Object.values(transactionsToCount) + .map((transaction) => transaction?.reportID) + .filter((reportID): reportID is string => !!reportID), + ); + return reportIDs.size; + } - if (isExpenseReportType) { - const reportIDs = new Set( - Object.values(selectedTransactions) - .map((transaction) => transaction?.reportID) - .filter((reportID): reportID is string => !!reportID), - ); - return reportIDs.size; - } + return Object.keys(transactionsToCount).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); + }; - 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]); + return { + selectedItemsCount: getItemsCount(selectedTransactions), + excludedItemsCount: getItemsCount(excludedTransactions), + }; + }, [excludedTransactions, selectedTransactions, isExpenseReportType, searchData]); const allMatchingItemsCount = currentSearchResults?.search?.count; - const isAllMatchingItemsCountLoading = areAllMatchingItemsSelected && typeof allMatchingItemsCount !== 'number' && !isOffline && !!currentSearchResults?.search?.isLoading; + const selectedAllMatchingItemsCount = typeof allMatchingItemsCount === 'number' ? Math.max(allMatchingItemsCount - excludedItemsCount, 0) : undefined; + const hasExcludedItems = Object.keys(excludedTransactions).length > 0; + const isAllMatchingItemsCountLoading = + areAllMatchingItemsSelected && hasExcludedItems && typeof allMatchingItemsCount !== 'number' && !isOffline && !!currentSearchResults?.search?.isLoading; let selectionButtonText: string; if (areAllMatchingItemsSelected) { selectionButtonText = - typeof allMatchingItemsCount !== 'number' ? translate('search.exportAll.allMatchingItemsSelected') : translate('workspace.common.selected', {count: allMatchingItemsCount}); + !hasExcludedItems || selectedAllMatchingItemsCount === undefined + ? translate('search.exportAll.allMatchingItemsSelected') + : translate('workspace.common.selected', {count: selectedAllMatchingItemsCount}); } else { selectionButtonText = translate('workspace.common.selected', {count: selectedItemsCount}); } 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/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index c011fea116d2..06bb27ef148a 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -105,7 +105,7 @@ function ExpenseReportListItemInner({ const transactionsWithoutPendingDelete = (reportItem.transactions ?? []).filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const areAllReportTransactionsSelected = transactionsWithoutPendingDelete.length > 0 && transactionsWithoutPendingDelete.every((transaction) => selectedTransactions[transaction.keyForList]?.isSelected); - const isSelected = liveRowSelected || areAllReportTransactionsSelected; + const isSelected = transactionsWithoutPendingDelete.length > 0 ? areAllReportTransactionsSelected : liveRowSelected; const {translate} = useLocalize(); const {isLargeScreenWidth} = useResponsiveLayout(); const {currentSearchHash, currentSearchKey} = useSearchQueryContext(); diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 7904f026f255..8a100b1ea423 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -223,7 +223,7 @@ function TransactionGroupListItem({ const StyleUtils = useStyleUtils(); const {isSelected: liveRowSelected} = useRowSelection(item?.keyForList); - const isItemSelected = isSelectAllChecked || liveRowSelected; + const isItemSelected = isSelectAllChecked || (transactionsWithoutPendingDelete.length === 0 && liveRowSelected); const animatedHighlightStyle = useAnimatedHighlightStyle({ shouldHighlight: item?.shouldAnimateInHighlight ?? false, diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 7c40a8c30c0a..a47d6386e4e6 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -20,13 +20,15 @@ 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, areAllMatchingItemsSelected); const metadata = searchResults?.search; const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); + const excludedTransactionsKeys = Object.keys(excludedTransactions); + const isExpenseReportType = currentSearchQueryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const shouldShowFooter = (!areAllMatchingItemsSelected && selectedTransactionsKeys.length > 0) || (shouldAllowFooterTotals && !!metadata?.count); if (!shouldShowFooter) { @@ -36,20 +38,35 @@ 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; - const count = shouldUseClientTotal - ? selectedTransactionsKeys.reduce((acc, key) => { - if (isGroupEntry(key)) { - const group = currentSearchResults?.data?.[key]; - return acc + (group?.count ?? 0); - } - const item = selectedTransactions[key]; - if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) { - return acc; - } - return acc + 1; - }, 0) - : metadata?.count; - const total = shouldUseClientTotal ? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0) : metadata?.total; + const getTransactionCount = (transactionKeys: string[], transactions: typeof selectedTransactions) => { + if (isExpenseReportType) { + return new Set( + Object.values(transactions) + .map((transaction) => transaction.reportID) + .filter((reportID): reportID is string => !!reportID), + ).size; + } + return transactionKeys.reduce((acc, key) => { + if (isGroupEntry(key)) { + const group = currentSearchResults?.data?.[key]; + return acc + (group?.count ?? 0); + } + const item = transactions[key]; + if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) { + return acc; + } + return acc + 1; + }, 0); + }; + const getTransactionTotal = (transactions: typeof selectedTransactionItems) => + transactions.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0); + const excludedCount = getTransactionCount(excludedTransactionsKeys, excludedTransactions); + const count = shouldUseClientTotal ? getTransactionCount(selectedTransactionsKeys, selectedTransactions) : Math.max((metadata?.count ?? 0) - excludedCount, 0); + const total = shouldUseClientTotal + ? getTransactionTotal(selectedTransactionItems) + : metadata?.total === undefined + ? undefined + : metadata.total - getTransactionTotal(Object.values(excludedTransactions)); return ( { setSelectionState((prevState) => { const selectedTransactions = updater(prevState.selectedTransactions); @@ -87,12 +90,31 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { } const totalSelectableItemsCount = options?.totalSelectableItemsCount; - const areAllMatchingItemsSelected = + let areAllMatchingItemsSelected = totalSelectableItemsCount && totalSelectableItemsCount !== Object.keys(selectedTransactions).length ? false : prevState.areAllMatchingItemsSelected; + let excludedTransactions = prevState.excludedTransactions; + + if (prevState.areAllMatchingItemsSelected && options?.shouldPreserveAllMatchingSelection) { + areAllMatchingItemsSelected = true; + excludedTransactions = {...prevState.excludedTransactions}; + for (const [key, transaction] of Object.entries(prevState.selectedTransactions)) { + if (!Object.hasOwn(selectedTransactions, key)) { + excludedTransactions[key] = transaction; + } + } + for (const key of Object.keys(selectedTransactions)) { + if (!Object.hasOwn(prevState.selectedTransactions, key) && Object.hasOwn(excludedTransactions, key)) { + delete excludedTransactions[key]; + } + } + } else if (!areAllMatchingItemsSelected) { + excludedTransactions = {}; + } return { ...prevState, selectedTransactions, + excludedTransactions, areAllMatchingItemsSelected, selectedReports: options?.data ? deriveSelectedReports(selectedTransactions, options.data) : prevState.selectedReports, shouldTurnOffSelectionMode: false, @@ -126,12 +148,13 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { const selectAllMatchingItems: SearchSelectionActionsValue['selectAllMatchingItems'] = (shouldSelectAll) => { setSelectionState((prevState) => { - if (prevState.areAllMatchingItemsSelected === shouldSelectAll) { + if (prevState.areAllMatchingItemsSelected === shouldSelectAll && isEmptyObject(prevState.excludedTransactions)) { return prevState; } return { ...prevState, areAllMatchingItemsSelected: shouldSelectAll, + excludedTransactions: {}, }; }); }; @@ -147,13 +170,20 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { } setSelectionState((prevState) => { - if (prevState.selectedReports.length === 0 && isEmptyObject(prevState.selectedTransactions) && !prevState.shouldTurnOffSelectionMode && !prevState.areAllMatchingItemsSelected) { + if ( + prevState.selectedReports.length === 0 && + isEmptyObject(prevState.selectedTransactions) && + isEmptyObject(prevState.excludedTransactions) && + !prevState.shouldTurnOffSelectionMode && + !prevState.areAllMatchingItemsSelected + ) { return prevState; } return { ...prevState, shouldTurnOffSelectionMode, selectedTransactions: {}, + excludedTransactions: {}, selectedReports: [], areAllMatchingItemsSelected: false, }; @@ -167,9 +197,10 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { setSelectionState((prevState) => { const hasSelectedTransactions = !isEmptyObject(prevState.selectedTransactions); + const hasExcludedTransactions = !isEmptyObject(prevState.excludedTransactions); const hasSelectedIDs = prevState.selectedTransactionIDs.length > 0; - if (!hasSelectedTransactions && !hasSelectedIDs) { + if (!hasSelectedTransactions && !hasExcludedTransactions && !hasSelectedIDs) { return prevState; } @@ -184,6 +215,11 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { }, {} as SelectedTransactions); newState.selectedTransactions = newSelectedTransactions; } + if (hasExcludedTransactions) { + const newExcludedTransactions = {...prevState.excludedTransactions}; + delete newExcludedTransactions[transactionID]; + newState.excludedTransactions = newExcludedTransactions; + } if (hasSelectedIDs) { newState.selectedTransactionIDs = prevState.selectedTransactionIDs.filter((ID) => transactionID !== ID); } @@ -191,7 +227,8 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { }); }; - const hasSelectedTransactions = selectionState.selectedTransactionIDs.length > 0 || Object.values(selectionState.selectedTransactions).some((t) => t.isSelected); + const hasSelectedTransactions = + selectionState.areAllMatchingItemsSelected || selectionState.selectedTransactionIDs.length > 0 || Object.values(selectionState.selectedTransactions).some((t) => t.isSelected); const selectionValue: SearchSelectionContextValue = { ...selectionState, @@ -242,11 +279,11 @@ 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}; + return {isSelected: (areAllMatchingItemsSelected && !Object.hasOwn(excludedTransactions, keyForList)) || !!selectedTransactions[keyForList]?.isSelected}; } /** Aggregate count of currently-selected transactions, for the selection top bar. */ diff --git a/src/components/Search/SearchWriteActionsProvider.tsx b/src/components/Search/SearchWriteActionsProvider.tsx index 16c43bf91fca..55b7d3ffd3ff 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 && !Object.hasOwn(excludedTransactions, reportKey) && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) { const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(transactionGroup); newTransactionList[reportKey] = { ...emptyReportSelection, @@ -172,10 +172,11 @@ function useReconcileSelectionWithData({ for (const transactionItem of transactionGroup.transactions) { const listKey = transactionItem.keyForList ?? transactionItem.transactionID; + const isExcluded = Object.hasOwn(excludedTransactions, listKey) || Object.hasOwn(excludedTransactions, transactionItem.transactionID); const isSelected = listKey in selectedTransactions || transactionItem.transactionID in selectedTransactions; // 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; } @@ -205,7 +206,7 @@ function useReconcileSelectionWithData({ newTransactionList[listKey] = { ...baseEntry, - isSelected: areAllMatchingItemsSelected || !!previousSelection?.isSelected || propagateSelectionToAllRows, + isSelected: !isExcluded && (areAllMatchingItemsSelected || !!previousSelection?.isSelected || propagateSelectionToAllRows), canReject: currentUserEmail && transactionItem.report ? canRejectReportAction(currentUserEmail, transactionItem.report) : false, policyID: transactionItem.report?.policyID, groupKey: previousSelection?.groupKey ?? (propagateSelectionToAllRows && !isExpenseReportType ? reportKey : undefined), @@ -218,6 +219,10 @@ function useReconcileSelectionWithData({ continue; } const listKey = transactionItem.keyForList ?? transactionItem.transactionID; + const isExcluded = Object.hasOwn(excludedTransactions, listKey) || Object.hasOwn(excludedTransactions, transactionItem.transactionID); + if (isExcluded) { + continue; + } if (!(listKey in selectedTransactions) && !(transactionItem.transactionID in selectedTransactions) && !areAllMatchingItemsSelected) { continue; } @@ -266,9 +271,9 @@ function useReconcileSelectionWithData({ // so `selectedReports` is derived atomically and a stale `useSyncSelectedReports` derivation can't briefly // clear it (which would close screens like SearchChangeApproverPage that dismiss on empty `selectedReports`). applySelection(() => newTransactionList, {data: filteredData}); - // `selectedTransactions` is intentionally omitted from the deps and read from closure instead (see the - // hook doc above): including it would re-run this reconcile on every checkbox press. We only want it to - // run when the underlying data, focus, or select-all state changes. + // `selectedTransactions` and `excludedTransactions` are intentionally omitted from the deps and read from + // closure instead (see the hook doc above): including them would re-run this reconcile on every checkbox + // press. We only want it to run when the underlying data, focus, or select-all state changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [filteredData, applySelection, areAllMatchingItemsSelected, isFocused, outstandingReportsByPolicyID, isExpenseReportType]); } @@ -392,7 +397,7 @@ function SearchWriteActionsProvider({ return updatedTransactions; }, - {totalSelectableItemsCount}, + {totalSelectableItemsCount, shouldPreserveAllMatchingSelection: true}, ); return; } @@ -456,7 +461,7 @@ function SearchWriteActionsProvider({ ), }; }, - {totalSelectableItemsCount}, + {totalSelectableItemsCount, shouldPreserveAllMatchingSelection: true}, ); }; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 1aec03d8cdf5..5bd147ea194e 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -15,7 +15,7 @@ import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSaveSortedReportIDs from '@hooks/useSaveSortedReportIDs'; import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; -import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; +import useSearchShouldCalculateTotals, {getSearchRequestOffsetForMissingAllMatchingCount} from '@hooks/useSearchShouldCalculateTotals'; import useStableArrayReference from '@hooks/useStableArrayReference'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -180,7 +180,17 @@ function Search({ const [, cardFeedsResult] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); const searchDataType = useMemo(() => (shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type), [shouldUseLiveData, searchResults?.search?.type]); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, offset === 0, areAllMatchingItemsSelected); + const isAllMatchingItemsCountMissing = areAllMatchingItemsSelected && typeof searchResults?.search?.count !== 'number'; + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, offset === 0 || isAllMatchingItemsCountMissing, areAllMatchingItemsSelected); + const previousShouldCalculateTotals = usePrevious(shouldCalculateTotals); + const searchRequestOffset = getSearchRequestOffsetForMissingAllMatchingCount(offset, searchResults?.search?.offset, isAllMatchingItemsCountMissing); + + useEffect(() => { + if (searchRequestOffset === offset) { + return; + } + setOffset(searchRequestOffset); + }, [offset, searchRequestOffset]); const previousReportActions = usePrevious(reportActions); const {translate} = useLocalize(); @@ -399,10 +409,28 @@ function Search({ return; } + // Once a totals request for an already-loaded page completes, `shouldCalculateTotals` switches + // back to false. Do not immediately repeat that same page request without totals; the next real + // pagination offset change will trigger the appropriate request. + const didJustFinishAllMatchingTotalsRequest = + areAllMatchingItemsSelected && + previousShouldCalculateTotals === true && + !shouldCalculateTotals && + typeof searchResults?.search?.count === 'number' && + searchResults.search.offset === offset; + if (didJustFinishAllMatchingTotalsRequest) { + return; + } + const didEnableAllMatchingTotalsWithExistingCount = + areAllMatchingItemsSelected && previousShouldCalculateTotals === false && shouldCalculateTotals && typeof searchResults?.search?.count === 'number'; + if (didEnableAllMatchingTotalsWithExistingCount) { + return; + } + handleSearch({ queryJSON, searchKey: currentSearchKey, - offset, + offset: searchRequestOffset, shouldCalculateTotals, prevReportsLength: filteredDataLength, isLoading: !!searchResults?.search?.isLoading, @@ -410,7 +438,7 @@ function Search({ // We don't need to run the effect on change of isFocused. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [handleSearch, hasErrors, isOffline, offset, queryJSON, currentSearchKey, shouldCalculateTotals, validGroupBy]); + }, [handleSearch, hasErrors, isOffline, offset, queryJSON, currentSearchKey, shouldCalculateTotals, validGroupBy, searchRequestOffset]); useEffect(() => { if (!shouldRetrySearchWithTotalsOrGroupedRef.current || searchResults?.search?.isLoading || (!shouldCalculateTotals && !validGroupBy)) { @@ -428,12 +456,22 @@ function Search({ handleSearch({ queryJSON, searchKey: currentSearchKey, - offset, + offset: searchRequestOffset, shouldCalculateTotals: true, prevReportsLength: filteredDataLength, isLoading: false, }); - }, [filteredDataLength, handleSearch, offset, queryJSON, currentSearchKey, searchResults?.search?.count, searchResults?.search?.isLoading, shouldCalculateTotals, validGroupBy]); + }, [ + filteredDataLength, + handleSearch, + queryJSON, + currentSearchKey, + searchResults?.search?.count, + searchResults?.search?.isLoading, + shouldCalculateTotals, + validGroupBy, + searchRequestOffset, + ]); useEffect(() => { if (!isSearchResultsEmpty || prevIsSearchResultEmpty) { diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index e75f6790c4fb..216d6ad3620a 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -212,6 +212,8 @@ type SearchResultsActionsValue = { type SearchSelectionContextValue = { currentSelectedTransactionReportID: string | undefined; selectedTransactions: SelectedTransactions; + /** Loaded transactions explicitly excluded from an all-matching selection. */ + excludedTransactions: SelectedTransactions; selectedTransactionIDs: string[]; selectedReports: SelectedReports[]; shouldTurnOffSelectionMode: boolean; @@ -235,8 +237,12 @@ type SearchSelectionActionsValue = { * next one, so callers (e.g. the screen-level write actions) can read-modify-write without subscribing to — and * 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. + * `shouldPreserveAllMatchingSelection` keeps that mode active for row toggles and records removed rows as exclusions. */ - applySelection: (updater: (previousSelectedTransactions: SelectedTransactions) => SelectedTransactions, options?: {data?: SearchData; totalSelectableItemsCount?: number}) => void; + applySelection: ( + updater: (previousSelectedTransactions: SelectedTransactions) => SelectedTransactions, + options?: {data?: SearchData; totalSelectableItemsCount?: number; shouldPreserveAllMatchingSelection?: 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 812260daf452..f0861eb635bc 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -357,7 +357,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(); @@ -559,6 +559,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {status, hash} = queryJSON ?? {}; const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); + const excludedTransactionIDs = Object.keys(excludedTransactions); const firstTransactionID = selectedTransactionsKeys.at(0); const firstTransaction = (firstTransactionID ? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${firstTransactionID}`] : undefined) ?? @@ -728,7 +729,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const exportName = translate(isBasicExport ? 'export.basicExport' : 'export.currentView'); if (areAllMatchingItemsSelected) { - if (selectedTransactionsKeys.length === 0 || status == null || !hash) { + if (status == null || !hash) { return; } const reportIDList = selectedReports?.map((report) => report?.reportID).filter((reportID) => reportID !== undefined) ?? []; @@ -738,6 +739,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { jsonQuery: exportParameters.jsonQuery, reportIDList, transactionIDList: selectedTransactionsKeys, + excludedTransactionIDList: excludedTransactionIDs, isBasicExport: exportParameters.isBasicExport, exportColumnLabels: exportParameters.exportColumnLabels, exportName, @@ -782,6 +784,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { selectedTransactionReportIDs, selectedTransactions, selectedTransactionsKeys, + excludedTransactionIDs, translate, clearSelectedTransactions, hash, diff --git a/src/hooks/useSearchShouldCalculateTotals.ts b/src/hooks/useSearchShouldCalculateTotals.ts index bfbac7546488..bac80b3ac54d 100644 --- a/src/hooks/useSearchShouldCalculateTotals.ts +++ b/src/hooks/useSearchShouldCalculateTotals.ts @@ -7,16 +7,22 @@ import {useMemo} from 'react'; import useOnyx from './useOnyx'; +function getSearchRequestOffsetForMissingAllMatchingCount(offset: number, serverOffset: number | undefined, isAllMatchingItemsCountMissing: boolean): number { + if (!isAllMatchingItemsCountMissing) { + return offset; + } + return Math.min(offset, serverOffset ?? offset); +} + function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, searchHash: number | undefined, enabled: boolean, areAllMatchingItemsSelected = false) { const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); const shouldCalculateTotals = useMemo(() => { - // When the user selects all matching items we always want the server-computed count/total, - // even for an ad-hoc query that isn't a suggested or saved search. This must bypass the - // `enabled` (offset === 0) gate so totals are still requested when more results were loaded - // before select-all was triggered. + // All-matching selections need the server-computed count/total, including for ad-hoc queries. + // The caller enables this only while the initial offset is active or the count is still missing, + // so later pagination requests can avoid recalculating totals. if (areAllMatchingItemsSelected) { - return true; + return enabled; } if (!enabled) { @@ -52,3 +58,4 @@ function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, search } export default useSearchShouldCalculateTotals; +export {getSearchRequestOffsetForMissingAllMatchingCount}; diff --git a/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts b/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts index b7309bf30f30..95484d00453d 100644 --- a/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts +++ b/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts @@ -5,6 +5,7 @@ type ExportSearchItemsToCSVParams = { jsonQuery: SearchQueryString; reportIDList: string[]; transactionIDList: string[]; + excludedTransactionIDList?: string[]; isBasicExport: boolean; exportColumnLabels: string; exportName: string; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index ec18332cf4ae..fecfad70a4a0 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -1315,7 +1315,7 @@ function rejectMoneyRequestsOnSearch( type Params = Record; function exportSearchItemsToCSV( - {query, jsonQuery, reportIDList, transactionIDList, isBasicExport, exportColumnLabels, exportName}: ExportSearchItemsToCSVParams, + {query, jsonQuery, reportIDList, transactionIDList, excludedTransactionIDList, isBasicExport, exportColumnLabels, exportName}: ExportSearchItemsToCSVParams, onDownloadFailed: () => void, translate: LocalizedTranslate, ) { @@ -1349,6 +1349,7 @@ function exportSearchItemsToCSV( jsonQuery, reportIDList: Array.from(reportIDSet), transactionIDList, + ...(excludedTransactionIDList?.length ? {excludedTransactionIDList} : {}), isBasicExport, exportColumnLabels, }) as Params; @@ -1376,7 +1377,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; @@ -1406,6 +1416,7 @@ function queueExportSearchItemsToCSV({query, jsonQuery, reportIDList, transactio jsonQuery, reportIDList, transactionIDList, + ...(excludedTransactionIDList?.length ? {excludedTransactionIDList} : {}), isBasicExport, exportColumnLabels, exportName, 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/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx new file mode 100644 index 000000000000..cdd9f20a56ed --- /dev/null +++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx @@ -0,0 +1,133 @@ +import {render} from '@testing-library/react-native'; + +import SearchBulkActionsButton from '@components/Search/SearchBulkActionsButton'; +import type {SearchQueryJSON, SelectedTransactions} from '@components/Search/types'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +const mockButtonWithDropdownMenu = jest.fn(() => null); +let mockExcludedTransactions: SelectedTransactions = {}; +let mockSearchCount: number | undefined; +let mockSearchIsLoading = false; + +jest.mock('@components/ButtonWithDropdownMenu', () => ({ + __esModule: true, + default: (props: unknown) => mockButtonWithDropdownMenu(props), +})); +jest.mock('@components/DecisionModal', () => () => null); +jest.mock('@components/HoldOrRejectEducationalModal', () => () => null); +jest.mock('@components/HoldSubmitterEducationalModal', () => () => null); +jest.mock('@components/ReportPDFDownloadModal', () => () => null); +jest.mock('@components/KYCWall', () => ({ + __esModule: true, + default: ({children}: {children: (triggerKYCFlow: jest.Mock, buttonRef: React.RefObject) => React.ReactNode}) => children(jest.fn(), {current: null}), +})); +jest.mock('@components/LockedAccountModalProvider', () => ({ + useLockedAccountState: () => ({isAccountLocked: false}), + useLockedAccountActions: () => ({showLockedAccountModal: jest.fn()}), +})); +jest.mock('@components/DelegateNoAccessModalProvider', () => ({ + useDelegateNoAccessState: () => ({isDelegateAccessRestricted: false}), + useDelegateNoAccessActions: () => ({showDelegateNoAccessModal: jest.fn()}), +})); +jest.mock('@hooks/useThemeStyles', () => ({__esModule: true, default: () => ({flexRow: {}, alignItemsCenter: {}, gap3: {}})})); +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string, params?: {count?: number}) => (params?.count === undefined ? key : `${key}:${params.count}`)}), +})); +jest.mock('@hooks/useNetwork', () => ({__esModule: true, default: () => ({isOffline: false})})); +jest.mock('@hooks/useResponsiveLayout', () => ({ + __esModule: true, + default: () => ({shouldUseNarrowLayout: false, isSmallScreenWidth: false}), +})); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({__esModule: true, default: () => ({accountID: 1})})); +jest.mock('@hooks/useOnyx', () => ({__esModule: true, default: () => [undefined]})); +jest.mock('@hooks/usePolicy', () => ({__esModule: true, default: () => undefined})); +jest.mock('@hooks/useSortedActiveAdminPolicies', () => ({__esModule: true, default: () => []})); +jest.mock('@hooks/useSearchBulkActions', () => ({ + __esModule: true, + default: () => ({ + headerButtonsOptions: [], + selectedPolicyIDs: [], + selectedTransactionReportIDs: [], + selectedReportIDs: [], + businessBankAccountOptions: [], + emptyReportsCount: 0, + isDuplicateOptionVisible: false, + isDuplicateReportOptionVisible: false, + allTransactions: {}, + allReports: {}, + searchData: {}, + }), +})); +jest.mock('@components/Search/SearchContext', () => ({ + useSearchSelectionContext: () => ({ + selectedTransactions: {tx_1: {isSelected: true}}, + excludedTransactions: mockExcludedTransactions, + selectedReports: [], + areAllMatchingItemsSelected: true, + }), + useSearchResultsContext: () => ({ + currentSearchResults: {search: {count: mockSearchCount, isLoading: mockSearchIsLoading}}, + }), +})); +jest.mock('@libs/ReportUtils', () => ({...jest.requireActual('@libs/ReportUtils'), isExpenseReport: () => false})); +jest.mock('@libs/shouldPopoverUseScrollView', () => ({__esModule: true, default: () => false})); + +const queryJSON = { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + hash: 1, +} as SearchQueryJSON; + +function getButtonProps(): {customText: string; isLoading: boolean} { + const props = mockButtonWithDropdownMenu.mock.calls.at(-1)?.at(0); + if (!props || typeof props !== 'object' || !('customText' in props) || !('isLoading' in props)) { + throw new Error('ButtonWithDropdownMenu was not rendered'); + } + return {customText: props.customText as string, isLoading: props.isLoading as boolean}; +} + +describe('SearchBulkActionsButton all-matching label', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockExcludedTransactions = {}; + mockSearchCount = undefined; + mockSearchIsLoading = false; + }); + + it('shows the all-matching label without loading while totals are requested', () => { + mockSearchIsLoading = true; + + render(); + + expect(getButtonProps()).toEqual({customText: 'search.exportAll.allMatchingItemsSelected', isLoading: false}); + }); + + it('keeps the all-matching label when the server count arrives and there are no exclusions', () => { + mockSearchCount = 172; + + render(); + + expect(getButtonProps()).toEqual({customText: 'search.exportAll.allMatchingItemsSelected', isLoading: false}); + }); + + it('shows the exact count after an item is excluded', () => { + mockSearchCount = 172; + mockExcludedTransactions = {tx_2: {isSelected: true} as SelectedTransactions[string]}; + + render(); + + expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:171', isLoading: false}); + }); + + it('loads only when an exclusion exists before the count arrives', () => { + mockSearchIsLoading = true; + mockExcludedTransactions = {tx_2: {isSelected: true} as SelectedTransactions[string]}; + + render(); + + expect(getButtonProps()).toEqual({customText: 'search.exportAll.allMatchingItemsSelected', isLoading: true}); + }); +}); diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx new file mode 100644 index 000000000000..8f864fd9cefd --- /dev/null +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -0,0 +1,102 @@ +import {render} from '@testing-library/react-native'; + +import SearchSelectionFooter from '@components/Search/SearchSelectionFooter'; +import type {SelectedTransactions} from '@components/Search/types'; + +import CONST from '@src/CONST'; +import type {SearchResults} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; + +const mockSearchPageFooter = jest.fn(() => null); +let mockExcludedTransactions: SelectedTransactions = {}; +let mockSearchType: SearchResults['search']['type'] = CONST.SEARCH.DATA_TYPES.EXPENSE; + +jest.mock('@hooks/useSearchShouldCalculateTotals', () => ({ + __esModule: true, + default: () => true, +})); + +jest.mock('@components/Search/SearchPageFooter', () => ({ + __esModule: true, + default: (props: unknown) => mockSearchPageFooter(props), +})); + +jest.mock('@components/Search/SearchContext', () => ({ + useSearchSelectionContext: () => ({ + selectedTransactions: {}, + excludedTransactions: mockExcludedTransactions, + areAllMatchingItemsSelected: true, + }), + useSearchResultsContext: () => ({currentSearchResults: {data: {}}}), + useSearchQueryContext: () => ({ + currentSearchKey: 'expenses', + currentSearchQueryJSON: {type: mockSearchType, hash: 1}, + }), +})); + +function makeTransaction(reportID: string, amount = 100): SelectedTransactions[string] { + return { + isSelected: true, + canReject: false, + canHold: false, + canSplit: false, + hasBeenSplit: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + isFromOneTransactionReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + reportID, + policyID: 'policy_1', + amount, + currency: 'USD', + }; +} + +function makeSearchResults(count: number, total: number): OnyxEntry { + return { + search: { + type: mockSearchType, + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + offset: 0, + hasMoreResults: true, + hasResults: true, + isLoading: false, + count, + total, + currency: 'USD', + }, + data: {}, + } as OnyxEntry; +} + +describe('SearchSelectionFooter all-matching exclusions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSearchType = CONST.SEARCH.DATA_TYPES.EXPENSE; + mockExcludedTransactions = {}; + }); + + it('subtracts excluded expenses from the server count and total', () => { + mockExcludedTransactions = {tx_1: makeTransaction('report_1')}; + + render(); + + expect(mockSearchPageFooter).toHaveBeenLastCalledWith(expect.objectContaining({count: 171, total: 35900, currency: 'USD'})); + }); + + it('counts multiple excluded transactions from one expense report as one excluded report', () => { + mockSearchType = CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + mockExcludedTransactions = { + tx_1: makeTransaction('report_1'), + tx_2: makeTransaction('report_1'), + }; + + render(); + + expect(mockSearchPageFooter).toHaveBeenLastCalledWith(expect.objectContaining({count: 9, total: 35800, currency: 'USD'})); + }); +}); diff --git a/tests/unit/Search/SearchSelectionProviderTest.tsx b/tests/unit/Search/SearchSelectionProviderTest.tsx new file mode 100644 index 000000000000..0deec12518c0 --- /dev/null +++ b/tests/unit/Search/SearchSelectionProviderTest.tsx @@ -0,0 +1,172 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; +import {SearchQueryContext} from '@components/Search/SearchContextDefinitions'; +import {SearchSelectionProvider} from '@components/Search/SearchSelectionProvider'; +import type {SearchQueryContextValue, SelectedTransactions} from '@components/Search/types'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +const queryContextValue: SearchQueryContextValue = { + currentSearchHash: 1, + currentSimilarSearchHash: 1, + currentSearchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + currentSearchQueryJSON: undefined, + suggestedSearches: {} as SearchQueryContextValue['suggestedSearches'], + shouldResetSearchQuery: false, +}; + +function buildSelected(...keys: string[]): SelectedTransactions { + return Object.fromEntries( + keys.map((key) => [ + key, + { + isSelected: true, + canReject: false, + canHold: false, + canSplit: false, + hasBeenSplit: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + isFromOneTransactionReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + reportID: 'report_1', + policyID: 'policy_1', + amount: 100, + currency: 'USD', + }, + ]), + ); +} + +function wrapper({children}: {children: React.ReactNode}) { + return ( + + {children} + + ); +} + +function renderSelection() { + return renderHook( + () => ({ + state: useSearchSelectionContext(), + actions: useSearchSelectionActions(), + }), + {wrapper}, + ); +} + +function seedAllMatchingSelection(result: ReturnType['result']) { + act(() => { + result.current.actions.selectAllMatchingItems(true); + result.current.actions.setSelectedTransactions(buildSelected('tx_1', 'tx_2')); + }); +} + +function removeTransaction(selectedTransactions: SelectedTransactions, transactionID: string): SelectedTransactions { + const nextSelection = {...selectedTransactions}; + delete nextSelection[transactionID]; + return nextSelection; +} + +describe('SearchSelectionProvider all-matching exclusions', () => { + it('keeps all-matching active and records a row exclusion', () => { + const {result} = renderSelection(); + seedAllMatchingSelection(result); + + act(() => { + result.current.actions.applySelection((selectedTransactions) => removeTransaction(selectedTransactions, 'tx_1'), { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: true, + }); + }); + + expect(result.current.state.areAllMatchingItemsSelected).toBe(true); + expect(result.current.state.hasSelectedTransactions).toBe(true); + expect(Object.keys(result.current.state.selectedTransactions)).toEqual(['tx_2']); + expect(Object.keys(result.current.state.excludedTransactions)).toEqual(['tx_1']); + }); + + it('removes an exclusion when the row is rechecked', () => { + const {result} = renderSelection(); + seedAllMatchingSelection(result); + const tx1 = result.current.state.selectedTransactions.tx_1; + if (!tx1) { + throw new Error('Expected tx_1 to be selected'); + } + + act(() => { + result.current.actions.applySelection((selectedTransactions) => removeTransaction(selectedTransactions, 'tx_1'), { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: true, + }); + }); + act(() => { + result.current.actions.applySelection((selectedTransactions) => ({...selectedTransactions, tx_1: tx1}), { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: true, + }); + }); + + expect(result.current.state.areAllMatchingItemsSelected).toBe(true); + expect(Object.keys(result.current.state.excludedTransactions)).toEqual([]); + expect(Object.keys(result.current.state.selectedTransactions)).toEqual(['tx_2', 'tx_1']); + }); + + it('exits all-matching and clears exclusions when the header deselects all', () => { + const {result} = renderSelection(); + seedAllMatchingSelection(result); + + act(() => { + result.current.actions.applySelection((selectedTransactions) => removeTransaction(selectedTransactions, 'tx_1'), { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: true, + }); + }); + act(() => { + result.current.actions.applySelection(() => ({}), {totalSelectableItemsCount: 2}); + }); + + expect(result.current.state.areAllMatchingItemsSelected).toBe(false); + expect(result.current.state.selectedTransactions).toEqual({}); + expect(result.current.state.excludedTransactions).toEqual({}); + }); + + it('clears exclusions when selection is cleared or a new all-matching session starts', () => { + const {result} = renderSelection(); + seedAllMatchingSelection(result); + + act(() => { + result.current.actions.applySelection((selectedTransactions) => removeTransaction(selectedTransactions, 'tx_1'), { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: true, + }); + }); + act(() => result.current.actions.selectAllMatchingItems(true)); + expect(result.current.state.excludedTransactions).toEqual({}); + + act(() => result.current.actions.clearSelectedTransactions()); + expect(result.current.state.areAllMatchingItemsSelected).toBe(false); + expect(result.current.state.excludedTransactions).toEqual({}); + }); + + it('retains ordinary page-selection behavior', () => { + const {result} = renderSelection(); + act(() => result.current.actions.setSelectedTransactions(buildSelected('tx_1', 'tx_2'))); + + act(() => { + result.current.actions.applySelection((selectedTransactions) => removeTransaction(selectedTransactions, 'tx_1'), { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: true, + }); + }); + + expect(result.current.state.areAllMatchingItemsSelected).toBe(false); + expect(Object.keys(result.current.state.selectedTransactions)).toEqual(['tx_2']); + expect(result.current.state.excludedTransactions).toEqual({}); + }); +}); diff --git a/tests/unit/Search/useRowSelectionTest.tsx b/tests/unit/Search/useRowSelectionTest.tsx index 27b9d068f996..320e14c6118c 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, @@ -63,13 +64,15 @@ function renderWithSelection(hook: () => T, selectionValue: SearchSelectionCo function renderRowSelection({ keyForList, selectedTransactions, + excludedTransactions = {}, areAllMatchingItemsSelected, }: { keyForList: string | undefined; selectedTransactions: SelectedTransactions; + excludedTransactions?: SelectedTransactions; areAllMatchingItemsSelected: boolean; }): {isSelected: boolean} { - return renderWithSelection(() => useRowSelection(keyForList), {...baseSelectionContext, areAllMatchingItemsSelected, selectedTransactions}); + return renderWithSelection(() => useRowSelection(keyForList), {...baseSelectionContext, areAllMatchingItemsSelected, selectedTransactions, excludedTransactions}); } function renderSelectionCounts(selectedTransactions: SelectedTransactions): {selected: number} { @@ -86,6 +89,17 @@ describe('useRowSelection', () => { expect(renderRowSelection({keyForList: 'tx_not_in_map', selectedTransactions: {}, areAllMatchingItemsSelected: true}).isSelected).toBe(true); }); + it('does not mark an excluded row selected when areAllMatchingItemsSelected is true', () => { + expect( + renderRowSelection({ + keyForList: 'tx_1', + selectedTransactions: {}, + excludedTransactions: buildSelected('tx_1'), + areAllMatchingItemsSelected: true, + }).isSelected, + ).toBe(false); + }); + it('returns not-selected when keyForList is undefined', () => { expect(renderRowSelection({keyForList: undefined, selectedTransactions: buildSelected('tx_1'), areAllMatchingItemsSelected: false}).isSelected).toBe(false); }); 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/SearchActionsTest.ts b/tests/unit/SearchActionsTest.ts index 4d63c9ceb59e..f7cf2f57d5cd 100644 --- a/tests/unit/SearchActionsTest.ts +++ b/tests/unit/SearchActionsTest.ts @@ -1,6 +1,9 @@ -import {queueExportSearchItemsToCSV, queueExportSearchWithTemplate} from '@libs/actions/Search'; +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; + +import {exportSearchItemsToCSV, queueExportSearchItemsToCSV, queueExportSearchWithTemplate} from '@libs/actions/Search'; import {write} from '@libs/API'; import {WRITE_COMMANDS} from '@libs/API/types'; +import fileDownload from '@libs/fileDownload'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -9,12 +12,14 @@ import type {AnyOnyxUpdate} from '@src/types/onyx/Request'; const EXPENSE_STATUS_ALL = CONST.SEARCH.STATUS.EXPENSE.ALL; jest.mock('@libs/API'); +jest.mock('@libs/fileDownload'); jest.mock('@libs/Network/enhanceParameters', () => ({ __esModule: true, default: (_: string, params: Record) => params, })); const mockWrite = jest.mocked(write); +const mockFileDownload = jest.mocked(fileDownload); function getWriteOptions(): {optimisticData: AnyOnyxUpdate[]; failureData: AnyOnyxUpdate[]} { const options = mockWrite.mock.calls.at(-1)?.at(2); @@ -64,6 +69,62 @@ describe('queueExportSearchItemsToCSV', () => { expect(failureUpdate).toBeDefined(); expect(failureUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.FAILED, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); }); + + it('includes excluded transaction IDs in the queued CSV payload', () => { + queueExportSearchItemsToCSV({ + query: EXPENSE_STATUS_ALL, + jsonQuery: '{}', + reportIDList: [], + transactionIDList: ['tx1'], + excludedTransactionIDList: ['tx2'], + isBasicExport: true, + exportColumnLabels: '{}', + exportName: 'Basic export', + }); + + expect(mockWrite).toHaveBeenCalledWith(WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_ITEMS_TO_CSV, expect.objectContaining({excludedTransactionIDList: ['tx2']}), expect.any(Object)); + }); + + it('does not add an exclusion field when there are no exclusions', () => { + queueExportSearchItemsToCSV({ + query: EXPENSE_STATUS_ALL, + jsonQuery: '{}', + reportIDList: [], + transactionIDList: ['tx1'], + isBasicExport: true, + exportColumnLabels: '{}', + exportName: 'Basic export', + }); + + expect(mockWrite.mock.calls.at(-1)?.at(1)).not.toHaveProperty('excludedTransactionIDList'); + }); +}); + +describe('exportSearchItemsToCSV', () => { + beforeEach(() => jest.clearAllMocks()); + + it('includes excluded transaction IDs in the direct CSV form payload', () => { + const appendSpy = jest.spyOn(FormData.prototype, 'append'); + + exportSearchItemsToCSV( + { + query: EXPENSE_STATUS_ALL, + jsonQuery: '{}', + reportIDList: [], + transactionIDList: ['tx1'], + excludedTransactionIDList: ['tx2'], + isBasicExport: true, + exportColumnLabels: '{}', + exportName: 'Basic export', + }, + jest.fn(), + jest.fn((key: string) => key) as unknown as LocalizedTranslate, + ); + + expect(appendSpy).toHaveBeenCalledWith('excludedTransactionIDList', 'tx2'); + expect(mockFileDownload).toHaveBeenCalled(); + appendSpy.mockRestore(); + }); }); describe('queueExportSearchWithTemplate', () => { diff --git a/tests/unit/hooks/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts index 7daeed8ff4ed..d3632898d0f4 100644 --- a/tests/unit/hooks/useSearchBulkActionsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsTest.ts @@ -153,12 +153,14 @@ jest.mock('@hooks/usePaymentContext', () => ({ const mockClearSelectedTransactions = jest.fn(); const mockSelectAllMatchingItems = jest.fn(); let mockSelectedTransactions: SelectedTransactions = {}; +let mockExcludedTransactions: SelectedTransactions = {}; let mockSelectedReports: SelectedReports[] = []; let mockAreAllMatchingItemsSelected = false; jest.mock('@components/Search/SearchContext', () => ({ useSearchSelectionContext: () => ({ selectedTransactions: mockSelectedTransactions, + excludedTransactions: mockExcludedTransactions, selectedReports: mockSelectedReports, areAllMatchingItemsSelected: mockAreAllMatchingItemsSelected, }), @@ -230,6 +232,7 @@ describe('useSearchBulkActions - CSV export flow', () => { mockAreAllMatchingItemsSelected = false; await Onyx.clear(); mockSelectedTransactions = {}; + mockExcludedTransactions = {}; mockSelectedReports = []; await Onyx.merge(ONYXKEYS.SESSION, {accountID: CURRENT_USER_ACCOUNT_ID, email: 'test@example.com'}); @@ -242,6 +245,7 @@ describe('useSearchBulkActions - CSV export flow', () => { it('handleBasicExport with select-all tracks the export', async () => { mockAreAllMatchingItemsSelected = true; mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + mockExcludedTransactions = {tx2: makeSelectedTransaction()}; const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); @@ -259,6 +263,7 @@ describe('useSearchBulkActions - CSV export flow', () => { }); expect(mockQueueExportSearchItemsToCSV).toHaveBeenCalled(); + expect(mockQueueExportSearchItemsToCSV).toHaveBeenCalledWith(expect.objectContaining({excludedTransactionIDList: ['tx2']})); expect(result.current.exportDownloadStatusModal).not.toBeNull(); }); diff --git a/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts b/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts index 4ec2528a44a3..108eace944e1 100644 --- a/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts +++ b/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts @@ -1,6 +1,6 @@ import {renderHook} from '@testing-library/react-native'; -import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; +import useSearchShouldCalculateTotals, {getSearchRequestOffsetForMissingAllMatchingCount} from '@hooks/useSearchShouldCalculateTotals'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -83,9 +83,23 @@ describe('useSearchShouldCalculateTotals', () => { expect(result.current).toBe(true); }); - it('returns true when all matching items are selected even when the hook is disabled (select-all bypasses the offset gate)', () => { + it('returns false for an all-matching selection when the caller has already obtained totals for pagination', () => { const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, 123, false, true)); - expect(result.current).toBe(true); + expect(result.current).toBe(false); + }); +}); + +describe('getSearchRequestOffsetForMissingAllMatchingCount', () => { + it('uses the server-confirmed offset when the local offset advanced without loading another page', () => { + expect(getSearchRequestOffsetForMissingAllMatchingCount(50, 0, true)).toBe(0); + }); + + it('keeps the offset when the page was genuinely loaded', () => { + expect(getSearchRequestOffsetForMissingAllMatchingCount(50, 50, true)).toBe(50); + }); + + it('keeps the pagination offset after the matching count is available', () => { + expect(getSearchRequestOffsetForMissingAllMatchingCount(100, 50, false)).toBe(100); }); }); 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, From dc68c95a5935b75e4ef3c718399caaf1e8768b83 Mon Sep 17 00:00:00 2001 From: emkhalid Date: Sat, 11 Jul 2026 23:57:40 +0430 Subject: [PATCH 02/16] fix: correct search selection test mock typings --- tests/unit/Search/SearchBulkActionsButtonTest.tsx | 2 +- tests/unit/Search/SearchSelectionFooterTest.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/Search/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx index cdd9f20a56ed..3006315751ae 100644 --- a/tests/unit/Search/SearchBulkActionsButtonTest.tsx +++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx @@ -7,7 +7,7 @@ import CONST from '@src/CONST'; import React from 'react'; -const mockButtonWithDropdownMenu = jest.fn(() => null); +const mockButtonWithDropdownMenu = jest.fn((_props: unknown) => null); let mockExcludedTransactions: SelectedTransactions = {}; let mockSearchCount: number | undefined; let mockSearchIsLoading = false; diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index 8f864fd9cefd..d8443d294ee4 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -10,7 +10,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import React from 'react'; -const mockSearchPageFooter = jest.fn(() => null); +const mockSearchPageFooter = jest.fn((_props: unknown) => null); let mockExcludedTransactions: SelectedTransactions = {}; let mockSearchType: SearchResults['search']['type'] = CONST.SEARCH.DATA_TYPES.EXPENSE; From bd2483e75ad003f41eaaa4ec548230ef6b7fd944 Mon Sep 17 00:00:00 2001 From: emkhalid Date: Mon, 13 Jul 2026 00:58:35 +0430 Subject: [PATCH 03/16] fix: resolve CI errors --- .../Search/SearchBulkActionsButton.tsx | 5 +- .../Search/SearchSelectionFooter.tsx | 16 +++--- .../Search/SearchSelectionProvider.tsx | 4 +- .../Search/SearchWriteActionsProvider.tsx | 4 +- src/components/Search/index.tsx | 3 +- src/hooks/useSearchBulkActions.ts | 3 +- .../Search/SearchBulkActionsButtonTest.tsx | 52 ++++++++++++++----- .../unit/Search/SearchSelectionFooterTest.tsx | 13 +++-- .../Search/SearchSelectionProviderTest.tsx | 18 +++++-- tests/unit/SearchActionsTest.ts | 5 +- 10 files changed, 83 insertions(+), 40 deletions(-) diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index 330ad18683b1..ae5c419ffaac 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -26,12 +26,13 @@ import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; import {isUserValidatedSelector} from '@selectors/Account'; import React, {useContext, useMemo, useRef} from 'react'; import {View} from 'react-native'; -import type {BulkPaySelectionData, SearchQueryJSON} from './types'; +import type {BulkPaySelectionData, SearchQueryJSON, SelectedTransactions} from './types'; import BulkDuplicateHandler from './BulkDuplicateHandler'; import BulkDuplicateReportHandler from './BulkDuplicateReportHandler'; @@ -48,7 +49,7 @@ 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, excludedTransactions = {}, selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {selectedTransactions, excludedTransactions = getEmptyObject(), selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext(); const {currentSearchResults} = useSearchResultsContext(); const kycWallRef = useContext(KYCWallContext); const {isAccountLocked} = useLockedAccountState(); diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index a47d6386e4e6..6bff5b7b410c 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -4,11 +4,14 @@ import {isGroupEntry} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import type {SearchResults} from '@src/types/onyx'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; import React from 'react'; +import type {SelectedTransactions} from './types'; + import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionContext} from './SearchContext'; import SearchPageFooter from './SearchPageFooter'; @@ -20,7 +23,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, excludedTransactions = {}, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {selectedTransactions, excludedTransactions = getEmptyObject(), areAllMatchingItemsSelected} = useSearchSelectionContext(); const {currentSearchResults} = useSearchResultsContext(); const {currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true, areAllMatchingItemsSelected); @@ -62,11 +65,12 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { transactions.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0); const excludedCount = getTransactionCount(excludedTransactionsKeys, excludedTransactions); const count = shouldUseClientTotal ? getTransactionCount(selectedTransactionsKeys, selectedTransactions) : Math.max((metadata?.count ?? 0) - excludedCount, 0); - const total = shouldUseClientTotal - ? getTransactionTotal(selectedTransactionItems) - : metadata?.total === undefined - ? undefined - : metadata.total - getTransactionTotal(Object.values(excludedTransactions)); + let total: number | undefined; + if (shouldUseClientTotal) { + total = getTransactionTotal(selectedTransactionItems); + } else if (metadata?.total !== undefined) { + total = metadata.total - getTransactionTotal(Object.values(excludedTransactions)); + } return ( (), areAllMatchingItemsSelected} = useSearchSelectionContext(); if (!keyForList) { return {isSelected: false}; } diff --git a/src/components/Search/SearchWriteActionsProvider.tsx b/src/components/Search/SearchWriteActionsProvider.tsx index 55b7d3ffd3ff..de944959270f 100644 --- a/src/components/Search/SearchWriteActionsProvider.tsx +++ b/src/components/Search/SearchWriteActionsProvider.tsx @@ -13,7 +13,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OutstandingReportsByPolicyIDDerivedValue, Report, ReportNameValuePairs, SearchResults, Transaction} from '@src/types/onyx'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {getEmptyObject, isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -127,7 +127,7 @@ function useReconcileSelectionWithData({ reportNameValuePairs, outstandingReportsByPolicyID, }: ReconcileSelectionParams) { - const {selectedTransactions, excludedTransactions = {}, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {selectedTransactions, excludedTransactions = getEmptyObject(), areAllMatchingItemsSelected} = useSearchSelectionContext(); const {applySelection} = useSearchSelectionActions(); useEffect(() => { diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 5bd147ea194e..2a1beef70bff 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -189,7 +189,8 @@ function Search({ if (searchRequestOffset === offset) { return; } - setOffset(searchRequestOffset); + const timeoutID = setTimeout(() => setOffset(searchRequestOffset), 0); + return () => clearTimeout(timeoutID); }, [offset, searchRequestOffset]); const previousReportActions = usePrevious(reportActions); diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index f0861eb635bc..57614f75a746 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -94,6 +94,7 @@ import {doesPersonalDetailExistSelector} from '@src/selectors/PersonalDetails'; import type {BillingGraceEndPeriod, Policy, Report, ReportAction, 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 {getEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -357,7 +358,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {isProduction} = useEnvironment(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); - const {selectedTransactions, excludedTransactions = {}, selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {selectedTransactions, excludedTransactions = getEmptyObject(), selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext(); const {currentSearchResults} = useSearchResultsContext(); const {currentSearchKey} = useSearchQueryContext(); const {clearSelectedTransactions, selectAllMatchingItems} = useSearchSelectionActions(); diff --git a/tests/unit/Search/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx index 3006315751ae..33f53f83a601 100644 --- a/tests/unit/Search/SearchBulkActionsButtonTest.tsx +++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx @@ -1,20 +1,27 @@ import {render} from '@testing-library/react-native'; import SearchBulkActionsButton from '@components/Search/SearchBulkActionsButton'; -import type {SearchQueryJSON, SelectedTransactions} from '@components/Search/types'; +import type {SelectedTransactions} from '@components/Search/types'; + +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; import React from 'react'; -const mockButtonWithDropdownMenu = jest.fn((_props: unknown) => null); +type MockButtonProps = { + customText: string; + isLoading: boolean; +}; + +const mockButtonWithDropdownMenu = jest.fn(() => null); let mockExcludedTransactions: SelectedTransactions = {}; let mockSearchCount: number | undefined; let mockSearchIsLoading = false; jest.mock('@components/ButtonWithDropdownMenu', () => ({ __esModule: true, - default: (props: unknown) => mockButtonWithDropdownMenu(props), + default: (props: MockButtonProps) => mockButtonWithDropdownMenu(props), })); jest.mock('@components/DecisionModal', () => () => null); jest.mock('@components/HoldOrRejectEducationalModal', () => () => null); @@ -64,7 +71,7 @@ jest.mock('@hooks/useSearchBulkActions', () => ({ })); jest.mock('@components/Search/SearchContext', () => ({ useSearchSelectionContext: () => ({ - selectedTransactions: {tx_1: {isSelected: true}}, + selectedTransactions: {tx1: {isSelected: true}}, excludedTransactions: mockExcludedTransactions, selectedReports: [], areAllMatchingItemsSelected: true, @@ -73,20 +80,39 @@ jest.mock('@components/Search/SearchContext', () => ({ currentSearchResults: {search: {count: mockSearchCount, isLoading: mockSearchIsLoading}}, }), })); -jest.mock('@libs/ReportUtils', () => ({...jest.requireActual('@libs/ReportUtils'), isExpenseReport: () => false})); +jest.mock('@libs/ReportUtils', () => ({...jest.requireActual('@libs/ReportUtils'), isExpenseReport: () => false})); jest.mock('@libs/shouldPopoverUseScrollView', () => ({__esModule: true, default: () => false})); -const queryJSON = { - type: CONST.SEARCH.DATA_TYPES.EXPENSE, - hash: 1, -} as SearchQueryJSON; +const queryJSON = buildSearchQueryJSON('type:expense'); +if (!queryJSON) { + throw new Error('Expected the expense query to be valid'); +} + +function makeTransaction(): SelectedTransactions[string] { + return { + isSelected: true, + canReject: false, + canHold: false, + canSplit: false, + hasBeenSplit: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + isFromOneTransactionReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + reportID: 'report1', + policyID: 'policy1', + amount: 100, + currency: 'USD', + }; +} function getButtonProps(): {customText: string; isLoading: boolean} { const props = mockButtonWithDropdownMenu.mock.calls.at(-1)?.at(0); - if (!props || typeof props !== 'object' || !('customText' in props) || !('isLoading' in props)) { + if (!props) { throw new Error('ButtonWithDropdownMenu was not rendered'); } - return {customText: props.customText as string, isLoading: props.isLoading as boolean}; + return {customText: props.customText, isLoading: props.isLoading}; } describe('SearchBulkActionsButton all-matching label', () => { @@ -115,7 +141,7 @@ describe('SearchBulkActionsButton all-matching label', () => { it('shows the exact count after an item is excluded', () => { mockSearchCount = 172; - mockExcludedTransactions = {tx_2: {isSelected: true} as SelectedTransactions[string]}; + mockExcludedTransactions = {tx2: makeTransaction()}; render(); @@ -124,7 +150,7 @@ describe('SearchBulkActionsButton all-matching label', () => { it('loads only when an exclusion exists before the count arrives', () => { mockSearchIsLoading = true; - mockExcludedTransactions = {tx_2: {isSelected: true} as SelectedTransactions[string]}; + mockExcludedTransactions = {tx2: makeTransaction()}; render(); diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index d8443d294ee4..672690971705 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -1,5 +1,6 @@ import {render} from '@testing-library/react-native'; +import type SearchPageFooter from '@components/Search/SearchPageFooter'; import SearchSelectionFooter from '@components/Search/SearchSelectionFooter'; import type {SelectedTransactions} from '@components/Search/types'; @@ -10,7 +11,9 @@ import type {OnyxEntry} from 'react-native-onyx'; import React from 'react'; -const mockSearchPageFooter = jest.fn((_props: unknown) => null); +type MockSearchPageFooterProps = React.ComponentProps; + +const mockSearchPageFooter = jest.fn(() => null); let mockExcludedTransactions: SelectedTransactions = {}; let mockSearchType: SearchResults['search']['type'] = CONST.SEARCH.DATA_TYPES.EXPENSE; @@ -21,7 +24,7 @@ jest.mock('@hooks/useSearchShouldCalculateTotals', () => ({ jest.mock('@components/Search/SearchPageFooter', () => ({ __esModule: true, - default: (props: unknown) => mockSearchPageFooter(props), + default: (props: MockSearchPageFooterProps) => mockSearchPageFooter(props), })); jest.mock('@components/Search/SearchContext', () => ({ @@ -81,7 +84,7 @@ describe('SearchSelectionFooter all-matching exclusions', () => { }); it('subtracts excluded expenses from the server count and total', () => { - mockExcludedTransactions = {tx_1: makeTransaction('report_1')}; + mockExcludedTransactions = {tx1: makeTransaction('report1')}; render(); @@ -91,8 +94,8 @@ describe('SearchSelectionFooter all-matching exclusions', () => { it('counts multiple excluded transactions from one expense report as one excluded report', () => { mockSearchType = CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; mockExcludedTransactions = { - tx_1: makeTransaction('report_1'), - tx_2: makeTransaction('report_1'), + tx1: makeTransaction('report1'), + tx2: makeTransaction('report1'), }; render(); diff --git a/tests/unit/Search/SearchSelectionProviderTest.tsx b/tests/unit/Search/SearchSelectionProviderTest.tsx index 0deec12518c0..7ed2504bf51c 100644 --- a/tests/unit/Search/SearchSelectionProviderTest.tsx +++ b/tests/unit/Search/SearchSelectionProviderTest.tsx @@ -6,6 +6,7 @@ import {SearchSelectionProvider} from '@components/Search/SearchSelectionProvide import type {SearchQueryContextValue, SelectedTransactions} from '@components/Search/types'; import CONST from '@src/CONST'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; import React from 'react'; @@ -14,7 +15,7 @@ const queryContextValue: SearchQueryContextValue = { currentSimilarSearchHash: 1, currentSearchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, currentSearchQueryJSON: undefined, - suggestedSearches: {} as SearchQueryContextValue['suggestedSearches'], + suggestedSearches: getEmptyObject(), shouldResetSearchQuery: false, }; @@ -106,10 +107,17 @@ describe('SearchSelectionProvider all-matching exclusions', () => { }); }); act(() => { - result.current.actions.applySelection((selectedTransactions) => ({...selectedTransactions, tx_1: tx1}), { - totalSelectableItemsCount: 2, - shouldPreserveAllMatchingSelection: true, - }); + result.current.actions.applySelection( + (selectedTransactions) => { + const nextSelection = {...selectedTransactions}; + nextSelection.tx_1 = tx1; + return nextSelection; + }, + { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: true, + }, + ); }); expect(result.current.state.areAllMatchingItemsSelected).toBe(true); diff --git a/tests/unit/SearchActionsTest.ts b/tests/unit/SearchActionsTest.ts index f7cf2f57d5cd..e0d07ca9aab5 100644 --- a/tests/unit/SearchActionsTest.ts +++ b/tests/unit/SearchActionsTest.ts @@ -1,9 +1,8 @@ -import type {LocalizedTranslate} from '@components/LocaleContextProvider'; - import {exportSearchItemsToCSV, queueExportSearchItemsToCSV, queueExportSearchWithTemplate} from '@libs/actions/Search'; import {write} from '@libs/API'; import {WRITE_COMMANDS} from '@libs/API/types'; import fileDownload from '@libs/fileDownload'; +import {translateLocal} from '@libs/Localize'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -118,7 +117,7 @@ describe('exportSearchItemsToCSV', () => { exportName: 'Basic export', }, jest.fn(), - jest.fn((key: string) => key) as unknown as LocalizedTranslate, + translateLocal, ); expect(appendSpy).toHaveBeenCalledWith('excludedTransactionIDList', 'tx2'); From b24c47aaed770059fc77117a11cb94fc1704afff Mon Sep 17 00:00:00 2001 From: emkhalid Date: Mon, 13 Jul 2026 20:12:02 +0430 Subject: [PATCH 04/16] fix: preserve all-matching bulk actions --- .../SearchPageHeader/SearchActionsBarWide.tsx | 7 +++---- src/hooks/useSearchBulkActions.ts | 2 +- .../unit/Search/SearchBulkActionsButtonTest.tsx | 2 +- .../unit/Search/SearchSelectionProviderTest.tsx | 17 +++++++++++++++++ tests/unit/SearchActionsTest.ts | 7 +++++-- tests/unit/hooks/useSearchBulkActionsTest.ts | 12 ++++++++++++ 6 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx b/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx index 399fd47c856a..81220ef09a38 100644 --- a/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx +++ b/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx @@ -1,5 +1,5 @@ import SearchBulkActionsButton from '@components/Search/SearchBulkActionsButton'; -import {useSelectionCounts} from '@components/Search/SearchSelectionProvider'; +import {useSearchSelectionContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -26,12 +26,11 @@ type SearchActionsBarWideProps = { function SearchActionsBarWide({queryJSON, searchResults, onSort}: SearchActionsBarWideProps) { const styles = useThemeStyles(); - const {selected} = useSelectionCounts(); - const hasSelectedItems = selected > 0; + const {hasSelectedTransactions} = useSearchSelectionContext(); return ( - {hasSelectedItems ? ( + {hasSelectedTransactions ? ( diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 57614f75a746..352980cd05ad 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1462,7 +1462,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }, [selectedReports, currentSearchResults?.data, isTrackIntentUser, policies, selectedTransactions]); const headerButtonsOptions = useMemo(() => { - if (selectedTransactionsKeys.length === 0 || status == null || !hash) { + if ((!areAllMatchingItemsSelected && selectedTransactionsKeys.length === 0) || status == null || !hash) { return CONST.EMPTY_ARRAY as unknown as Array>; } diff --git a/tests/unit/Search/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx index 33f53f83a601..9f963ad794e1 100644 --- a/tests/unit/Search/SearchBulkActionsButtonTest.tsx +++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx @@ -80,7 +80,7 @@ jest.mock('@components/Search/SearchContext', () => ({ currentSearchResults: {search: {count: mockSearchCount, isLoading: mockSearchIsLoading}}, }), })); -jest.mock('@libs/ReportUtils', () => ({...jest.requireActual('@libs/ReportUtils'), isExpenseReport: () => false})); +jest.mock('@libs/ReportUtils', () => ({isExpenseReport: () => false})); jest.mock('@libs/shouldPopoverUseScrollView', () => ({__esModule: true, default: () => false})); const queryJSON = buildSearchQueryJSON('type:expense'); diff --git a/tests/unit/Search/SearchSelectionProviderTest.tsx b/tests/unit/Search/SearchSelectionProviderTest.tsx index 7ed2504bf51c..9c318e45f466 100644 --- a/tests/unit/Search/SearchSelectionProviderTest.tsx +++ b/tests/unit/Search/SearchSelectionProviderTest.tsx @@ -92,6 +92,23 @@ describe('SearchSelectionProvider all-matching exclusions', () => { expect(Object.keys(result.current.state.excludedTransactions)).toEqual(['tx_1']); }); + it('keeps a semantic selection when every loaded row is excluded', () => { + const {result} = renderSelection(); + seedAllMatchingSelection(result); + + act(() => { + result.current.actions.applySelection(() => ({}), { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: true, + }); + }); + + expect(result.current.state.selectedTransactions).toEqual({}); + expect(Object.keys(result.current.state.excludedTransactions)).toEqual(['tx_1', 'tx_2']); + expect(result.current.state.areAllMatchingItemsSelected).toBe(true); + expect(result.current.state.hasSelectedTransactions).toBe(true); + }); + it('removes an exclusion when the row is rechecked', () => { const {result} = renderSelection(); seedAllMatchingSelection(result); diff --git a/tests/unit/SearchActionsTest.ts b/tests/unit/SearchActionsTest.ts index e0d07ca9aab5..28444ad7413d 100644 --- a/tests/unit/SearchActionsTest.ts +++ b/tests/unit/SearchActionsTest.ts @@ -1,14 +1,17 @@ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; + import {exportSearchItemsToCSV, queueExportSearchItemsToCSV, queueExportSearchWithTemplate} from '@libs/actions/Search'; import {write} from '@libs/API'; import {WRITE_COMMANDS} from '@libs/API/types'; import fileDownload from '@libs/fileDownload'; -import {translateLocal} from '@libs/Localize'; +import {translate} from '@libs/Localize'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {AnyOnyxUpdate} from '@src/types/onyx/Request'; const EXPENSE_STATUS_ALL = CONST.SEARCH.STATUS.EXPENSE.ALL; +const translateForTest: LocalizedTranslate = (path, ...parameters) => translate(CONST.LOCALES.EN, path, ...parameters); jest.mock('@libs/API'); jest.mock('@libs/fileDownload'); @@ -117,7 +120,7 @@ describe('exportSearchItemsToCSV', () => { exportName: 'Basic export', }, jest.fn(), - translateLocal, + translateForTest, ); expect(appendSpy).toHaveBeenCalledWith('excludedTransactionIDList', 'tx2'); diff --git a/tests/unit/hooks/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts index d3632898d0f4..ffd4f4075d7f 100644 --- a/tests/unit/hooks/useSearchBulkActionsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsTest.ts @@ -267,6 +267,18 @@ describe('useSearchBulkActions - CSV export flow', () => { expect(result.current.exportDownloadStatusModal).not.toBeNull(); }); + it('keeps export available when every loaded transaction is excluded from an all-matching selection', async () => { + mockAreAllMatchingItemsSelected = true; + mockSelectedTransactions = {}; + mockExcludedTransactions = {tx1: makeSelectedTransaction()}; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.some((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT)).toBe(true); + }); + }); + it('handleBasicExport with manual selection does not track any export', async () => { mockAreAllMatchingItemsSelected = false; mockSelectedTransactions = {tx1: makeSelectedTransaction()}; From f0b00c16ff3e66a85c54d316ce8e8722e5613f5e Mon Sep 17 00:00:00 2001 From: emkhalid Date: Mon, 13 Jul 2026 20:39:09 +0430 Subject: [PATCH 05/16] fix: resolve search selection CI failures --- tests/unit/Search/SearchBulkActionsButtonTest.tsx | 8 +++++++- tests/unit/Search/SearchSelectionFooterTest.tsx | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/unit/Search/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx index 9f963ad794e1..b0e1af9035f3 100644 --- a/tests/unit/Search/SearchBulkActionsButtonTest.tsx +++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx @@ -80,7 +80,13 @@ jest.mock('@components/Search/SearchContext', () => ({ currentSearchResults: {search: {count: mockSearchCount, isLoading: mockSearchIsLoading}}, }), })); -jest.mock('@libs/ReportUtils', () => ({isExpenseReport: () => false})); +jest.mock('@libs/ReportUtils', () => { + const reportUtils: unknown = jest.requireActual('@libs/ReportUtils'); + if (!reportUtils || typeof reportUtils !== 'object') { + throw new Error('Expected ReportUtils to export an object'); + } + return {...reportUtils, isExpenseReport: () => false}; +}); jest.mock('@libs/shouldPopoverUseScrollView', () => ({__esModule: true, default: () => false})); const queryJSON = buildSearchQueryJSON('type:expense'); diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index 672690971705..8d200a19f5bc 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -62,8 +62,8 @@ function makeTransaction(reportID: string, amount = 100): SelectedTransactions[s function makeSearchResults(count: number, total: number): OnyxEntry { return { search: { + hash: 1, type: mockSearchType, - status: CONST.SEARCH.STATUS.EXPENSE.ALL, offset: 0, hasMoreResults: true, hasResults: true, @@ -73,7 +73,7 @@ function makeSearchResults(count: number, total: number): OnyxEntry; + }; } describe('SearchSelectionFooter all-matching exclusions', () => { From 64d00aeccbb1645df43bdac0429f91b3776ab0b8 Mon Sep 17 00:00:00 2001 From: emkhalid Date: Fri, 17 Jul 2026 23:25:48 +0430 Subject: [PATCH 06/16] Add context for deferred search offset update --- 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 3bd1bd63d084..55c3d45dc622 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -190,6 +190,7 @@ function Search({ if (searchRequestOffset === offset) { return; } + // Defer so this effect does not synchronously chain another render from setState (eslint react-hooks/set-state-in-effect). const timeoutID = setTimeout(() => setOffset(searchRequestOffset), 0); return () => clearTimeout(timeoutID); }, [offset, searchRequestOffset]); From 3753e221c07604288a212dc2982f3aa2393f99f4 Mon Sep 17 00:00:00 2001 From: emkhalid Date: Sun, 19 Jul 2026 03:32:19 +0430 Subject: [PATCH 07/16] Fix unloaded grouped row selection flicker --- .../Search/SearchList/ListItem/TransactionGroupListItem.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 58da076f9de9..cadff6eeb7a4 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -306,7 +306,8 @@ function TransactionGroupListItemImpl({ }; const onPress = (event?: ModifiedMouseEvent) => { - if (isExpenseReportType || transactions.length === 0) { + const isEmptyGroupWithoutTransactionsQuery = transactions.length === 0 && !groupItem.transactionsQueryJSON; + if (isExpenseReportType || isEmptyGroupWithoutTransactionsQuery) { onSelectRow(item, transactionPreviewData, event); } if (!isExpenseReportType) { From 46a3bdaaaa6d8b9034adcb105c1674e59294061d Mon Sep 17 00:00:00 2001 From: emkhalid Date: Sun, 19 Jul 2026 12:35:03 +0430 Subject: [PATCH 08/16] Fix totals request race during pagination --- src/libs/actions/Search.ts | 36 +++++-- .../Search/searchTotalsLoadingDataTest.ts | 95 ++++++++++++++++++- 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index b5f9a6eaf6c4..4afeffd8973f 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -883,10 +883,15 @@ function openBulkChangeApproverPage(reportIDList: OpenBulkChangeApproverPagePara write(WRITE_COMMANDS.OPEN_BULK_CHANGE_APPROVER_PAGE, {reportIDList}, {optimisticData, successData}); } -// Tracks in-flight search requests by hash+offset to prevent duplicate API calls -// when both page-level (useSearchPageSetup) and Search-internal (handleSearch) effects -// fire for the same query. Cleared when the request completes. -const inFlightSearchRequests = new Set(); +type InFlightSearchRequest = { + shouldCalculateTotals: boolean; + pendingTotalsRequest?: () => Promise | undefined; +}; + +// Tracks in-flight search requests by hash+offset to prevent duplicate API calls when both page-level +// and Search-internal effects fire for the same query. A totals request is not equivalent to the +// non-totals request already in flight, so preserve one such request to run immediately afterward. +const inFlightSearchRequests = new Map(); let shouldPreventSearchAPI = false; function handlePreventSearchAPI(hash: number | undefined) { @@ -937,16 +942,32 @@ function search({ * optimistic write data. */ skipWaitForWrites?: boolean; -}) { +}): Promise | undefined { if (isLoading || shouldPreventSearchAPI) { return; } const dedupeKey = `${queryJSON.hash}_${offset ?? 0}`; - if (inFlightSearchRequests.has(dedupeKey)) { + const inFlightRequest = inFlightSearchRequests.get(dedupeKey); + if (inFlightRequest) { + if (shouldCalculateTotals && !inFlightRequest.shouldCalculateTotals) { + inFlightRequest.pendingTotalsRequest = () => + search({ + queryJSON, + searchKey, + offset, + shouldCalculateTotals: true, + prevReportsLength, + isOffline, + isLoading: false, + shouldUpdateLastSearchParams, + skipWaitForWrites, + }); + } return; } - inFlightSearchRequests.add(dedupeKey); + const inFlightRequestState: InFlightSearchRequest = {shouldCalculateTotals}; + inFlightSearchRequests.set(dedupeKey, inFlightRequestState); const {optimisticData, successData, finallyData, failureData} = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, isOffline, true, shouldCalculateTotals); const {flatFilters, limit, ...queryJSONWithoutFlatFilters} = queryJSON; @@ -1023,6 +1044,7 @@ function search({ }) .finally(() => { inFlightSearchRequests.delete(dedupeKey); + return inFlightRequestState.pendingTotalsRequest?.(); }); if (skipWaitForWrites) { diff --git a/tests/unit/Search/searchTotalsLoadingDataTest.ts b/tests/unit/Search/searchTotalsLoadingDataTest.ts index 3bb2877c8852..1327b781ad29 100644 --- a/tests/unit/Search/searchTotalsLoadingDataTest.ts +++ b/tests/unit/Search/searchTotalsLoadingDataTest.ts @@ -39,12 +39,18 @@ type SearchRequestData = { }>; }; +type SearchResponse = { + onyxData: Array<{value: {search: {offset: number; hasMoreResults: boolean}; data: Record}}>; + jsonCode: number; +}; + function getMakeRequestWithSideEffectsMock() { return makeRequestWithSideEffects as unknown as { mock: { - calls: Array<[unknown, unknown, SearchRequestData?]>; + calls: Array<[unknown, {jsonQuery?: string}, SearchRequestData?]>; }; - mockResolvedValue: (value: {onyxData: Array<{value: {search: {offset: number; hasMoreResults: boolean}; data: Record}}>; jsonCode: number}) => void; + mockResolvedValue: (value: SearchResponse) => void; + mockImplementationOnce: (implementation: () => Promise) => void; }; } @@ -134,4 +140,89 @@ describe('search loading totals handling', () => { expect(loadingSearchData?.total).toBeUndefined(); expect(loadingSearchData?.currency).toBeUndefined(); }); + + it('queues a totals request when the same non-totals search is already in flight', async () => { + const queryJSON = getQueryJSON(); + const response: SearchResponse = { + onyxData: [{value: {search: {offset: 50, hasMoreResults: true}, data: {}}}], + jsonCode: CONST.JSON_CODE.SUCCESS, + }; + let resolveFirstRequest: (value: SearchResponse) => void = () => {}; + const firstRequestPromise = new Promise((resolve) => { + resolveFirstRequest = resolve; + }); + const makeRequestWithSideEffectsMock = getMakeRequestWithSideEffectsMock(); + makeRequestWithSideEffectsMock.mockImplementationOnce(() => firstRequestPromise); + + const firstSearch = search({ + queryJSON, + searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + offset: 50, + shouldCalculateTotals: false, + isLoading: false, + }); + search({ + queryJSON, + searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + offset: 50, + shouldCalculateTotals: true, + isLoading: false, + }); + search({ + queryJSON, + searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + offset: 50, + shouldCalculateTotals: true, + isLoading: false, + }); + + await Promise.resolve(); + expect(makeRequestWithSideEffectsMock.mock.calls).toHaveLength(1); + + resolveFirstRequest(response); + await firstSearch; + await Promise.resolve(); + + expect(makeRequestWithSideEffectsMock.mock.calls).toHaveLength(2); + const [, queuedRequestParameters] = makeRequestWithSideEffectsMock.mock.calls.at(-1) ?? []; + const queuedQuery: unknown = JSON.parse(queuedRequestParameters?.jsonQuery ?? '{}'); + expect(queuedQuery).toEqual(expect.objectContaining({shouldCalculateTotals: true})); + }); + + it('does not queue another request when the in-flight search already calculates totals', async () => { + const queryJSON = getQueryJSON(); + const response: SearchResponse = { + onyxData: [{value: {search: {offset: 50, hasMoreResults: true}, data: {}}}], + jsonCode: CONST.JSON_CODE.SUCCESS, + }; + let resolveFirstRequest: (value: SearchResponse) => void = () => {}; + const firstRequestPromise = new Promise((resolve) => { + resolveFirstRequest = resolve; + }); + const makeRequestWithSideEffectsMock = getMakeRequestWithSideEffectsMock(); + makeRequestWithSideEffectsMock.mockImplementationOnce(() => firstRequestPromise); + + const firstSearch = search({ + queryJSON, + searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + offset: 50, + shouldCalculateTotals: true, + isLoading: false, + }); + search({ + queryJSON, + searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + offset: 50, + shouldCalculateTotals: true, + isLoading: false, + }); + + await Promise.resolve(); + expect(makeRequestWithSideEffectsMock.mock.calls).toHaveLength(1); + + resolveFirstRequest(response); + await firstSearch; + + expect(makeRequestWithSideEffectsMock.mock.calls).toHaveLength(1); + }); }); From 9e54de19d2539317f094324b6bd92b1bc7fb9cd3 Mon Sep 17 00:00:00 2001 From: emkhalid Date: Mon, 20 Jul 2026 22:26:37 +0430 Subject: [PATCH 09/16] Restore Reports behavior and scope exclusions to Expenses --- .../Search/SearchBulkActionsButton.tsx | 18 +++++--- .../ListItem/ExpenseReportListItem.tsx | 2 +- .../ListItem/TransactionGroupListItem.tsx | 2 +- .../SearchPageHeader/SearchActionsBarWide.tsx | 6 ++- .../Search/SearchSelectionFooter.tsx | 13 ++---- .../Search/SearchSelectionProvider.tsx | 12 +++-- .../Search/SearchWriteActionsProvider.tsx | 4 +- src/components/Search/index.tsx | 10 +++-- src/hooks/useSearchBulkActions.ts | 11 +++-- src/libs/actions/Search.ts | 2 +- .../Search/SearchBulkActionsButtonTest.tsx | 22 ++++++++- .../unit/Search/SearchSelectionFooterTest.tsx | 4 +- .../Search/SearchSelectionProviderTest.tsx | 45 ++++++++++++++++++- .../Search/searchTotalsLoadingDataTest.ts | 44 ++++++++++++++++-- tests/unit/hooks/useSearchBulkActionsTest.ts | 40 +++++++++++++++++ 15 files changed, 192 insertions(+), 43 deletions(-) diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index 59a3fb38d1f8..5e39b008c525 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -100,6 +100,7 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { const pendingPaymentAdditionalDataRef = useRef(undefined); const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); + const isExpenseType = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE; const isExpenseReportType = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const popoverUseScrollView = shouldPopoverUseScrollView(headerButtonsOptions); @@ -131,15 +132,20 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { const allMatchingItemsCount = currentSearchResults?.search?.count; const selectedAllMatchingItemsCount = typeof allMatchingItemsCount === 'number' ? Math.max(allMatchingItemsCount - excludedItemsCount, 0) : undefined; - const hasExcludedItems = Object.keys(excludedTransactions).length > 0; + const hasExcludedItems = isExpenseType && Object.keys(excludedTransactions).length > 0; const isAllMatchingItemsCountLoading = - areAllMatchingItemsSelected && hasExcludedItems && typeof allMatchingItemsCount !== 'number' && !isOffline && !!currentSearchResults?.search?.isLoading; + areAllMatchingItemsSelected && (!isExpenseType || hasExcludedItems) && typeof allMatchingItemsCount !== 'number' && !isOffline && !!currentSearchResults?.search?.isLoading; let selectionButtonText: string; if (areAllMatchingItemsSelected) { - selectionButtonText = - !hasExcludedItems || selectedAllMatchingItemsCount === undefined - ? translate('search.exportAll.allMatchingItemsSelected') - : translate('workspace.common.selected', {count: selectedAllMatchingItemsCount}); + if (!isExpenseType) { + selectionButtonText = + typeof allMatchingItemsCount !== 'number' ? translate('search.exportAll.allMatchingItemsSelected') : translate('workspace.common.selected', {count: allMatchingItemsCount}); + } else { + selectionButtonText = + !hasExcludedItems || selectedAllMatchingItemsCount === undefined + ? translate('search.exportAll.allMatchingItemsSelected') + : translate('workspace.common.selected', {count: selectedAllMatchingItemsCount}); + } } else { selectionButtonText = translate('workspace.common.selected', {count: selectedItemsCount}); } diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx index e1d3727ef63d..0146df7ac43a 100644 --- a/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx +++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx @@ -108,7 +108,7 @@ function ExpenseReportListItemInner({ const transactionsWithoutPendingDelete = (reportItem.transactions ?? []).filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const areAllReportTransactionsSelected = transactionsWithoutPendingDelete.length > 0 && transactionsWithoutPendingDelete.every((transaction) => selectedTransactions[transaction.keyForList]?.isSelected); - const isSelected = transactionsWithoutPendingDelete.length > 0 ? areAllReportTransactionsSelected : liveRowSelected; + const isSelected = liveRowSelected || areAllReportTransactionsSelected; const {translate} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const {isLargeScreenWidth} = useResponsiveLayout(); diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index cadff6eeb7a4..368349a7e75b 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -228,7 +228,7 @@ function TransactionGroupListItemImpl({ const StyleUtils = useStyleUtils(); const {isSelected: liveRowSelected} = useRowSelection(item?.keyForList); - const isItemSelected = isSelectAllChecked || (transactionsWithoutPendingDelete.length === 0 && liveRowSelected); + const isItemSelected = isSelectAllChecked || (liveRowSelected && (isExpenseReportType || transactionsWithoutPendingDelete.length === 0)); const animatedHighlightStyle = useAnimatedHighlightStyle({ shouldHighlight: item?.shouldAnimateInHighlight ?? false, diff --git a/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx b/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx index 81220ef09a38..da53a25662a9 100644 --- a/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx +++ b/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx @@ -1,9 +1,11 @@ import SearchBulkActionsButton from '@components/Search/SearchBulkActionsButton'; import {useSearchSelectionContext} from '@components/Search/SearchContext'; +import {useSelectionCounts} from '@components/Search/SearchSelectionProvider'; import type {SearchQueryJSON} from '@components/Search/types'; import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; import type {SearchResults} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -27,10 +29,12 @@ type SearchActionsBarWideProps = { function SearchActionsBarWide({queryJSON, searchResults, onSort}: SearchActionsBarWideProps) { const styles = useThemeStyles(); const {hasSelectedTransactions} = useSearchSelectionContext(); + const {selected} = useSelectionCounts(); + const shouldShowBulkActions = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE ? hasSelectedTransactions : selected > 0; return ( - {hasSelectedTransactions ? ( + {shouldShowBulkActions ? ( diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 6bff5b7b410c..a8749f1a6e4e 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -31,7 +31,7 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { const metadata = searchResults?.search; const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); const excludedTransactionsKeys = Object.keys(excludedTransactions); - const isExpenseReportType = currentSearchQueryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + const isExpenseType = currentSearchQueryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE; const shouldShowFooter = (!areAllMatchingItemsSelected && selectedTransactionsKeys.length > 0) || (shouldAllowFooterTotals && !!metadata?.count); if (!shouldShowFooter) { @@ -42,13 +42,6 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { const selectedTransactionItems = Object.values(selectedTransactions); const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.groupCurrency ?? selectedTransactionItems.at(0)?.currency; const getTransactionCount = (transactionKeys: string[], transactions: typeof selectedTransactions) => { - if (isExpenseReportType) { - return new Set( - Object.values(transactions) - .map((transaction) => transaction.reportID) - .filter((reportID): reportID is string => !!reportID), - ).size; - } return transactionKeys.reduce((acc, key) => { if (isGroupEntry(key)) { const group = currentSearchResults?.data?.[key]; @@ -63,13 +56,13 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { }; const getTransactionTotal = (transactions: typeof selectedTransactionItems) => transactions.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0); - const excludedCount = getTransactionCount(excludedTransactionsKeys, excludedTransactions); + const excludedCount = isExpenseType ? getTransactionCount(excludedTransactionsKeys, excludedTransactions) : 0; const count = shouldUseClientTotal ? getTransactionCount(selectedTransactionsKeys, selectedTransactions) : Math.max((metadata?.count ?? 0) - excludedCount, 0); let total: number | undefined; if (shouldUseClientTotal) { total = getTransactionTotal(selectedTransactionItems); } else if (metadata?.total !== undefined) { - total = metadata.total - getTransactionTotal(Object.values(excludedTransactions)); + total = isExpenseType ? metadata.total - getTransactionTotal(Object.values(excludedTransactions)) : metadata.total; } return ( diff --git a/src/components/Search/SearchSelectionProvider.tsx b/src/components/Search/SearchSelectionProvider.tsx index 4da3bb3f2d8a..8407df290c93 100644 --- a/src/components/Search/SearchSelectionProvider.tsx +++ b/src/components/Search/SearchSelectionProvider.tsx @@ -1,3 +1,4 @@ +import CONST from '@src/CONST'; import {getEmptyObject, isEmptyObject} from '@src/types/utils/EmptyObject'; import React, {useEffect, useRef, useState} from 'react'; @@ -34,7 +35,8 @@ const defaultSelectionState: SelectionState = { // Owns selection state + pure setters only; the write actions (toggle/toggleAll) live in SearchWriteActionsProvider. function SearchSelectionProvider({children}: SearchSelectionProviderProps) { - const {currentSearchHash} = useSearchQueryContext(); + const {currentSearchHash, currentSearchQueryJSON} = useSearchQueryContext(); + const isExpenseSearch = currentSearchQueryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE; const areTransactionsEmpty = useRef(true); const [selectionState, setSelectionState] = useState(defaultSelectionState); @@ -80,8 +82,8 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { // Read-modify-write the selection atomically. The updater receives the previous map so write actions never // need to close over (and re-render on) selection state. `totalSelectableItemsCount` unchecks select-all when // the new selection no longer covers every item; omitting it (e.g. during data reconcile) leaves select-all - // untouched, which is what the former `isRefreshingSelection` flag protected. Row toggles preserve an - // all-matching selection and record their removed entries as explicit exclusions. + // untouched, which is what the former `isRefreshingSelection` flag protected. Expense row toggles preserve + // an all-matching selection and record their removed entries as explicit exclusions. const applySelection: SearchSelectionActionsValue['applySelection'] = (updater, options) => { setSelectionState((prevState) => { const selectedTransactions = updater(prevState.selectedTransactions); @@ -228,7 +230,9 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { }; const hasSelectedTransactions = - selectionState.areAllMatchingItemsSelected || selectionState.selectedTransactionIDs.length > 0 || Object.values(selectionState.selectedTransactions).some((t) => t.isSelected); + (isExpenseSearch && selectionState.areAllMatchingItemsSelected) || + selectionState.selectedTransactionIDs.length > 0 || + Object.values(selectionState.selectedTransactions).some((t) => t.isSelected); const selectionValue: SearchSelectionContextValue = { ...selectionState, diff --git a/src/components/Search/SearchWriteActionsProvider.tsx b/src/components/Search/SearchWriteActionsProvider.tsx index de944959270f..d389df266214 100644 --- a/src/components/Search/SearchWriteActionsProvider.tsx +++ b/src/components/Search/SearchWriteActionsProvider.tsx @@ -397,7 +397,7 @@ function SearchWriteActionsProvider({ return updatedTransactions; }, - {totalSelectableItemsCount, shouldPreserveAllMatchingSelection: true}, + {totalSelectableItemsCount, shouldPreserveAllMatchingSelection: type === CONST.SEARCH.DATA_TYPES.EXPENSE}, ); return; } @@ -461,7 +461,7 @@ function SearchWriteActionsProvider({ ), }; }, - {totalSelectableItemsCount, shouldPreserveAllMatchingSelection: true}, + {totalSelectableItemsCount, shouldPreserveAllMatchingSelection: type === CONST.SEARCH.DATA_TYPES.EXPENSE}, ); }; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 55c3d45dc622..64752fcc4871 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -181,8 +181,10 @@ function Search({ const [, cardFeedsResult] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); const searchDataType = useMemo(() => (shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type), [shouldUseLiveData, searchResults?.search?.type]); - const isAllMatchingItemsCountMissing = areAllMatchingItemsSelected && typeof searchResults?.search?.count !== 'number'; - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, offset === 0 || isAllMatchingItemsCountMissing, areAllMatchingItemsSelected); + const isExpenseAllMatchingSelection = type === CONST.SEARCH.DATA_TYPES.EXPENSE && areAllMatchingItemsSelected; + const isAllMatchingItemsCountMissing = isExpenseAllMatchingSelection && typeof searchResults?.search?.count !== 'number'; + const shouldCalculateExpenseTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, offset === 0 || isAllMatchingItemsCountMissing, isExpenseAllMatchingSelection); + const shouldCalculateTotals = (areAllMatchingItemsSelected && !isExpenseAllMatchingSelection) || shouldCalculateExpenseTotals; const previousShouldCalculateTotals = usePrevious(shouldCalculateTotals); const searchRequestOffset = getSearchRequestOffsetForMissingAllMatchingCount(offset, searchResults?.search?.offset, isAllMatchingItemsCountMissing); @@ -416,7 +418,7 @@ function Search({ // back to false. Do not immediately repeat that same page request without totals; the next real // pagination offset change will trigger the appropriate request. const didJustFinishAllMatchingTotalsRequest = - areAllMatchingItemsSelected && + isExpenseAllMatchingSelection && previousShouldCalculateTotals === true && !shouldCalculateTotals && typeof searchResults?.search?.count === 'number' && @@ -425,7 +427,7 @@ function Search({ return; } const didEnableAllMatchingTotalsWithExistingCount = - areAllMatchingItemsSelected && previousShouldCalculateTotals === false && shouldCalculateTotals && typeof searchResults?.search?.count === 'number'; + isExpenseAllMatchingSelection && previousShouldCalculateTotals === false && shouldCalculateTotals && typeof searchResults?.search?.count === 'number'; if (didEnableAllMatchingTotalsWithExistingCount) { return; } diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index e03d76a99b94..9e43fde167b5 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -559,8 +559,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); const {hash} = queryJSON ?? {}; + const isExpenseType = queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE; const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); - const excludedTransactionIDs = Object.keys(excludedTransactions); + const excludedTransactionIDs = isExpenseType ? Object.keys(excludedTransactions) : []; const firstTransactionID = selectedTransactionsKeys.at(0); const firstTransaction = (firstTransactionID ? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${firstTransactionID}`] : undefined) ?? @@ -726,7 +727,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const exportName = translate(isBasicExport ? 'export.basicExport' : 'export.currentView'); if (areAllMatchingItemsSelected) { - if (!hash) { + if ((!isExpenseType && selectedTransactionsKeys.length === 0) || !hash) { return; } const reportIDList = selectedReports?.map((report) => report?.reportID).filter((reportID) => reportID !== undefined) ?? []; @@ -735,7 +736,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { jsonQuery: exportParameters.jsonQuery, reportIDList, transactionIDList: selectedTransactionsKeys, - excludedTransactionIDList: excludedTransactionIDs, + ...(isExpenseType ? {excludedTransactionIDList: excludedTransactionIDs} : {}), isBasicExport: exportParameters.isBasicExport, exportColumnLabels: exportParameters.exportColumnLabels, exportName, @@ -778,6 +779,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { selectedTransactionReportIDs, selectedTransactions, selectedTransactionsKeys, + isExpenseType, excludedTransactionIDs, translate, clearSelectedTransactions, @@ -1458,7 +1460,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }, [selectedReports, currentSearchResults?.data, isTrackIntentUser, policies, selectedTransactions]); const headerButtonsOptions = useMemo(() => { - if ((!areAllMatchingItemsSelected && selectedTransactionsKeys.length === 0) || !hash) { + if ((selectedTransactionsKeys.length === 0 && !(isExpenseType && areAllMatchingItemsSelected)) || !hash) { return CONST.EMPTY_ARRAY as unknown as Array>; } @@ -2170,6 +2172,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { expensifyIcons, translate, areAllMatchingItemsSelected, + isExpenseType, isOffline, selectedReports, lastPaymentMethods, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 4afeffd8973f..b1125bf7945f 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -950,7 +950,7 @@ function search({ const dedupeKey = `${queryJSON.hash}_${offset ?? 0}`; const inFlightRequest = inFlightSearchRequests.get(dedupeKey); if (inFlightRequest) { - if (shouldCalculateTotals && !inFlightRequest.shouldCalculateTotals) { + if (queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE && shouldCalculateTotals && !inFlightRequest.shouldCalculateTotals) { inFlightRequest.pendingTotalsRequest = () => search({ queryJSON, diff --git a/tests/unit/Search/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx index b0e1af9035f3..010541c7c773 100644 --- a/tests/unit/Search/SearchBulkActionsButtonTest.tsx +++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx @@ -90,8 +90,9 @@ jest.mock('@libs/ReportUtils', () => { jest.mock('@libs/shouldPopoverUseScrollView', () => ({__esModule: true, default: () => false})); const queryJSON = buildSearchQueryJSON('type:expense'); -if (!queryJSON) { - throw new Error('Expected the expense query to be valid'); +const reportQueryJSON = buildSearchQueryJSON('type:expense-report'); +if (!queryJSON || !reportQueryJSON) { + throw new Error('Expected the search queries to be valid'); } function makeTransaction(): SelectedTransactions[string] { @@ -162,4 +163,21 @@ describe('SearchBulkActionsButton all-matching label', () => { expect(getButtonProps()).toEqual({customText: 'search.exportAll.allMatchingItemsSelected', isLoading: true}); }); + + it('retains the expense-report loading behavior while the server count is missing', () => { + mockSearchIsLoading = true; + + render(); + + expect(getButtonProps()).toEqual({customText: 'search.exportAll.allMatchingItemsSelected', isLoading: true}); + }); + + it('uses the unmodified server count for expense reports', () => { + mockSearchCount = 320; + mockExcludedTransactions = {tx2: makeTransaction()}; + + render(); + + expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:320', isLoading: false}); + }); }); diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index 8d200a19f5bc..5e4687bb23d1 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -91,7 +91,7 @@ describe('SearchSelectionFooter all-matching exclusions', () => { expect(mockSearchPageFooter).toHaveBeenLastCalledWith(expect.objectContaining({count: 171, total: 35900, currency: 'USD'})); }); - it('counts multiple excluded transactions from one expense report as one excluded report', () => { + it('keeps the expense-report server count and total unchanged', () => { mockSearchType = CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; mockExcludedTransactions = { tx1: makeTransaction('report1'), @@ -100,6 +100,6 @@ describe('SearchSelectionFooter all-matching exclusions', () => { render(); - expect(mockSearchPageFooter).toHaveBeenLastCalledWith(expect.objectContaining({count: 9, total: 35800, currency: 'USD'})); + expect(mockSearchPageFooter).toHaveBeenLastCalledWith(expect.objectContaining({count: 10, total: 36000, currency: 'USD'})); }); }); diff --git a/tests/unit/Search/SearchSelectionProviderTest.tsx b/tests/unit/Search/SearchSelectionProviderTest.tsx index 9c318e45f466..12ae57b124fd 100644 --- a/tests/unit/Search/SearchSelectionProviderTest.tsx +++ b/tests/unit/Search/SearchSelectionProviderTest.tsx @@ -5,16 +5,26 @@ import {SearchQueryContext} from '@components/Search/SearchContextDefinitions'; import {SearchSelectionProvider} from '@components/Search/SearchSelectionProvider'; import type {SearchQueryContextValue, SelectedTransactions} from '@components/Search/types'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; + import CONST from '@src/CONST'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; import React from 'react'; +const expenseQueryJSON = buildSearchQueryJSON('type:expense'); +const expenseReportQueryJSON = buildSearchQueryJSON('type:expense-report'); +if (!expenseQueryJSON || !expenseReportQueryJSON) { + throw new Error('Expected the search queries to be valid'); +} + +let mockCurrentSearchQueryJSON = expenseQueryJSON; + const queryContextValue: SearchQueryContextValue = { currentSearchHash: 1, currentSimilarSearchHash: 1, currentSearchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, - currentSearchQueryJSON: undefined, + currentSearchQueryJSON: expenseQueryJSON, suggestedSearches: getEmptyObject(), shouldResetSearchQuery: false, }; @@ -45,7 +55,7 @@ function buildSelected(...keys: string[]): SelectedTransactions { function wrapper({children}: {children: React.ReactNode}) { return ( - + {children} ); @@ -75,6 +85,10 @@ function removeTransaction(selectedTransactions: SelectedTransactions, transacti } describe('SearchSelectionProvider all-matching exclusions', () => { + beforeEach(() => { + mockCurrentSearchQueryJSON = expenseQueryJSON; + }); + it('keeps all-matching active and records a row exclusion', () => { const {result} = renderSelection(); seedAllMatchingSelection(result); @@ -194,4 +208,31 @@ describe('SearchSelectionProvider all-matching exclusions', () => { expect(Object.keys(result.current.state.selectedTransactions)).toEqual(['tx_2']); expect(result.current.state.excludedTransactions).toEqual({}); }); + + it('keeps the original expense-report behavior when a report is deselected', () => { + mockCurrentSearchQueryJSON = expenseReportQueryJSON; + const {result} = renderSelection(); + seedAllMatchingSelection(result); + + act(() => { + result.current.actions.applySelection((selectedTransactions) => removeTransaction(selectedTransactions, 'tx_1'), { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: false, + }); + }); + + expect(result.current.state.areAllMatchingItemsSelected).toBe(false); + expect(Object.keys(result.current.state.selectedTransactions)).toEqual(['tx_2']); + expect(result.current.state.excludedTransactions).toEqual({}); + }); + + it('does not treat an empty expense-report all-matching state as a loaded selection', () => { + mockCurrentSearchQueryJSON = expenseReportQueryJSON; + const {result} = renderSelection(); + + act(() => result.current.actions.selectAllMatchingItems(true)); + + expect(result.current.state.areAllMatchingItemsSelected).toBe(true); + expect(result.current.state.hasSelectedTransactions).toBe(false); + }); }); diff --git a/tests/unit/Search/searchTotalsLoadingDataTest.ts b/tests/unit/Search/searchTotalsLoadingDataTest.ts index 1327b781ad29..511c7e739dfb 100644 --- a/tests/unit/Search/searchTotalsLoadingDataTest.ts +++ b/tests/unit/Search/searchTotalsLoadingDataTest.ts @@ -13,8 +13,8 @@ jest.mock('@libs/API', () => ({ read: jest.fn(), })); -function getQueryJSON() { - const queryJSON = buildSearchQueryJSON(''); +function getQueryJSON(query = '') { + const queryJSON = buildSearchQueryJSON(query); if (!queryJSON) { throw new Error('Query JSON should be defined for test setup'); } @@ -142,7 +142,7 @@ describe('search loading totals handling', () => { }); it('queues a totals request when the same non-totals search is already in flight', async () => { - const queryJSON = getQueryJSON(); + const queryJSON = getQueryJSON('type:expense'); const response: SearchResponse = { onyxData: [{value: {search: {offset: 50, hasMoreResults: true}, data: {}}}], jsonCode: CONST.JSON_CODE.SUCCESS, @@ -189,6 +189,44 @@ describe('search loading totals handling', () => { expect(queuedQuery).toEqual(expect.objectContaining({shouldCalculateTotals: true})); }); + it('keeps the original expense-report deduplication when a totals request collides with an in-flight request', async () => { + const queryJSON = getQueryJSON('type:expense-report'); + const response: SearchResponse = { + onyxData: [{value: {search: {offset: 50, hasMoreResults: true}, data: {}}}], + jsonCode: CONST.JSON_CODE.SUCCESS, + }; + let resolveFirstRequest: (value: SearchResponse) => void = () => {}; + const firstRequestPromise = new Promise((resolve) => { + resolveFirstRequest = resolve; + }); + const makeRequestWithSideEffectsMock = getMakeRequestWithSideEffectsMock(); + makeRequestWithSideEffectsMock.mockImplementationOnce(() => firstRequestPromise); + + const firstSearch = search({ + queryJSON, + searchKey: CONST.SEARCH.SEARCH_KEYS.REPORTS, + offset: 50, + shouldCalculateTotals: false, + isLoading: false, + }); + search({ + queryJSON, + searchKey: CONST.SEARCH.SEARCH_KEYS.REPORTS, + offset: 50, + shouldCalculateTotals: true, + isLoading: false, + }); + + await Promise.resolve(); + expect(makeRequestWithSideEffectsMock.mock.calls).toHaveLength(1); + + resolveFirstRequest(response); + await firstSearch; + await Promise.resolve(); + + expect(makeRequestWithSideEffectsMock.mock.calls).toHaveLength(1); + }); + it('does not queue another request when the in-flight search already calculates totals', async () => { const queryJSON = getQueryJSON(); const response: SearchResponse = { diff --git a/tests/unit/hooks/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts index 0b2b2b5052da..b371681ef1d2 100644 --- a/tests/unit/hooks/useSearchBulkActionsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsTest.ts @@ -200,6 +200,13 @@ const baseQueryJSON: SearchQueryJSON = { filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: 'type', right: 'expense'}, }; +const expenseReportQueryJSON: SearchQueryJSON = { + ...baseQueryJSON, + inputQuery: 'type:expense-report status:all', + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: 'type', right: 'expense-report'}, +}; + function makeSelectedTransaction(overrides: Partial = {}): SelectedTransactions[string] { return { isSelected: true, @@ -278,6 +285,39 @@ describe('useSearchBulkActions - CSV export flow', () => { }); }); + it('keeps the original expense-report export guard when no loaded transaction is selected', async () => { + mockAreAllMatchingItemsSelected = true; + mockSelectedTransactions = {}; + mockExcludedTransactions = {tx1: makeSelectedTransaction()}; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON})); + + expect(result.current.headerButtonsOptions).toEqual([]); + }); + + it('does not send exclusions for an expense-report export', async () => { + mockAreAllMatchingItemsSelected = true; + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + mockExcludedTransactions = {tx2: makeSelectedTransaction()}; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0); + }); + + const exportOption = result.current.headerButtonsOptions.find((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT); + const onSelected = exportOption?.subMenuItems?.find((item) => item.text === 'export.basicExport')?.onSelected ?? exportOption?.onSelected; + + await act(async () => { + onSelected?.(); + }); + + const exportPayload = mockQueueExportSearchItemsToCSV.mock.calls.at(-1)?.at(0); + expect(exportPayload).toBeDefined(); + expect(exportPayload).not.toHaveProperty('excludedTransactionIDList'); + }); + it('handleBasicExport with manual selection does not track any export', async () => { mockAreAllMatchingItemsSelected = false; mockSelectedTransactions = {tx1: makeSelectedTransaction()}; From 0baeb372ff816bfb18ce0ce5803d20aeef041818 Mon Sep 17 00:00:00 2001 From: emkhalid Date: Mon, 20 Jul 2026 22:50:29 +0430 Subject: [PATCH 10/16] Restore production bulk selection loading behavior --- src/components/Search/SearchBulkActionsButton.tsx | 15 +++------------ tests/unit/Search/SearchBulkActionsButtonTest.tsx | 10 +++++----- 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index 5e39b008c525..aab9ce0f2b68 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -132,20 +132,11 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { const allMatchingItemsCount = currentSearchResults?.search?.count; const selectedAllMatchingItemsCount = typeof allMatchingItemsCount === 'number' ? Math.max(allMatchingItemsCount - excludedItemsCount, 0) : undefined; - const hasExcludedItems = isExpenseType && Object.keys(excludedTransactions).length > 0; - const isAllMatchingItemsCountLoading = - areAllMatchingItemsSelected && (!isExpenseType || hasExcludedItems) && typeof allMatchingItemsCount !== 'number' && !isOffline && !!currentSearchResults?.search?.isLoading; + const isAllMatchingItemsCountLoading = areAllMatchingItemsSelected && typeof allMatchingItemsCount !== 'number' && !isOffline && !!currentSearchResults?.search?.isLoading; let selectionButtonText: string; if (areAllMatchingItemsSelected) { - if (!isExpenseType) { - selectionButtonText = - typeof allMatchingItemsCount !== 'number' ? translate('search.exportAll.allMatchingItemsSelected') : translate('workspace.common.selected', {count: allMatchingItemsCount}); - } else { - selectionButtonText = - !hasExcludedItems || selectedAllMatchingItemsCount === undefined - ? translate('search.exportAll.allMatchingItemsSelected') - : translate('workspace.common.selected', {count: selectedAllMatchingItemsCount}); - } + const count = isExpenseType ? selectedAllMatchingItemsCount : allMatchingItemsCount; + selectionButtonText = typeof count !== 'number' ? translate('search.exportAll.allMatchingItemsSelected') : translate('workspace.common.selected', {count}); } else { selectionButtonText = translate('workspace.common.selected', {count: selectedItemsCount}); } diff --git a/tests/unit/Search/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx index 010541c7c773..accfb9524d8c 100644 --- a/tests/unit/Search/SearchBulkActionsButtonTest.tsx +++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx @@ -130,20 +130,20 @@ describe('SearchBulkActionsButton all-matching label', () => { mockSearchIsLoading = false; }); - it('shows the all-matching label without loading while totals are requested', () => { + it('keeps the production loading state while totals are requested', () => { mockSearchIsLoading = true; render(); - expect(getButtonProps()).toEqual({customText: 'search.exportAll.allMatchingItemsSelected', isLoading: false}); + expect(getButtonProps()).toEqual({customText: 'search.exportAll.allMatchingItemsSelected', isLoading: true}); }); - it('keeps the all-matching label when the server count arrives and there are no exclusions', () => { + it('shows the production numeric label when the server count arrives', () => { mockSearchCount = 172; render(); - expect(getButtonProps()).toEqual({customText: 'search.exportAll.allMatchingItemsSelected', isLoading: false}); + expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:172', isLoading: false}); }); it('shows the exact count after an item is excluded', () => { @@ -155,7 +155,7 @@ describe('SearchBulkActionsButton all-matching label', () => { expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:171', isLoading: false}); }); - it('loads only when an exclusion exists before the count arrives', () => { + it('keeps loading when an exclusion exists before the count arrives', () => { mockSearchIsLoading = true; mockExcludedTransactions = {tx2: makeTransaction()}; From 1d941008fcbdc6faeb863b82c2f47cd193f5ad12 Mon Sep 17 00:00:00 2001 From: emkhalid Date: Fri, 24 Jul 2026 07:14:31 +0430 Subject: [PATCH 11/16] Fix all-matching expense selection label --- .../Search/SearchBulkActionsButton.tsx | 9 ++++++++- .../unit/Search/SearchBulkActionsButtonTest.tsx | 17 ++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index 0e0d6bf98e7d..ff77c14b97cc 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -131,7 +131,14 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { }, [excludedTransactions, selectedTransactions, isExpenseReportType, searchData]); const allMatchingItemsCount = currentSearchResults?.search?.count; - const selectedAllMatchingItemsCount = typeof allMatchingItemsCount === 'number' ? Math.max(allMatchingItemsCount - excludedItemsCount, 0) : undefined; + const selectedAllMatchingItemsCount = + excludedItemsCount > 0 + ? typeof allMatchingItemsCount === 'number' + ? Math.max(allMatchingItemsCount - excludedItemsCount, 0) + : isExpenseType && isOffline + ? selectedItemsCount + : undefined + : undefined; const isAllMatchingItemsCountLoading = areAllMatchingItemsSelected && typeof allMatchingItemsCount !== 'number' && !isOffline && !!currentSearchResults?.search?.isLoading; let selectionButtonText: string; if (areAllMatchingItemsSelected) { diff --git a/tests/unit/Search/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx index accfb9524d8c..7302a5c6b955 100644 --- a/tests/unit/Search/SearchBulkActionsButtonTest.tsx +++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx @@ -18,6 +18,7 @@ const mockButtonWithDropdownMenu = jest.fn(() => null); let mockExcludedTransactions: SelectedTransactions = {}; let mockSearchCount: number | undefined; let mockSearchIsLoading = false; +let mockIsOffline = false; jest.mock('@components/ButtonWithDropdownMenu', () => ({ __esModule: true, @@ -44,7 +45,7 @@ jest.mock('@hooks/useLocalize', () => ({ __esModule: true, default: () => ({translate: (key: string, params?: {count?: number}) => (params?.count === undefined ? key : `${key}:${params.count}`)}), })); -jest.mock('@hooks/useNetwork', () => ({__esModule: true, default: () => ({isOffline: false})})); +jest.mock('@hooks/useNetwork', () => ({__esModule: true, default: () => ({isOffline: mockIsOffline})})); jest.mock('@hooks/useResponsiveLayout', () => ({ __esModule: true, default: () => ({shouldUseNarrowLayout: false, isSmallScreenWidth: false}), @@ -128,6 +129,7 @@ describe('SearchBulkActionsButton all-matching label', () => { mockExcludedTransactions = {}; mockSearchCount = undefined; mockSearchIsLoading = false; + mockIsOffline = false; }); it('keeps the production loading state while totals are requested', () => { @@ -138,12 +140,12 @@ describe('SearchBulkActionsButton all-matching label', () => { expect(getButtonProps()).toEqual({customText: 'search.exportAll.allMatchingItemsSelected', isLoading: true}); }); - it('shows the production numeric label when the server count arrives', () => { + it('keeps the all-matching label when the server count arrives and there are no exclusions', () => { mockSearchCount = 172; render(); - expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:172', isLoading: false}); + expect(getButtonProps()).toEqual({customText: 'search.exportAll.allMatchingItemsSelected', isLoading: false}); }); it('shows the exact count after an item is excluded', () => { @@ -164,6 +166,15 @@ describe('SearchBulkActionsButton all-matching label', () => { expect(getButtonProps()).toEqual({customText: 'search.exportAll.allMatchingItemsSelected', isLoading: true}); }); + it('shows the loaded selected count when an expense is excluded offline before the server count is available', () => { + mockIsOffline = true; + mockExcludedTransactions = {tx2: makeTransaction()}; + + render(); + + expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:1', isLoading: false}); + }); + it('retains the expense-report loading behavior while the server count is missing', () => { mockSearchIsLoading = true; From 6a96e370a51d61b32559aa8320990cc2d73cbf9f Mon Sep 17 00:00:00 2001 From: emkhalid Date: Fri, 24 Jul 2026 07:30:51 +0430 Subject: [PATCH 12/16] Fix search selection lint and type errors --- .../Search/SearchBulkActionsButton.tsx | 16 ++++++++-------- src/hooks/useSearchBulkActions.ts | 5 ++--- tests/unit/Search/SearchSelectionFooterTest.tsx | 2 ++ tests/unit/SearchActionsTest.ts | 16 +++++++++------- 4 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index ff77c14b97cc..6b895e68379a 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -131,14 +131,14 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { }, [excludedTransactions, selectedTransactions, isExpenseReportType, searchData]); const allMatchingItemsCount = currentSearchResults?.search?.count; - const selectedAllMatchingItemsCount = - excludedItemsCount > 0 - ? typeof allMatchingItemsCount === 'number' - ? Math.max(allMatchingItemsCount - excludedItemsCount, 0) - : isExpenseType && isOffline - ? selectedItemsCount - : undefined - : undefined; + let selectedAllMatchingItemsCount: number | undefined; + if (excludedItemsCount > 0) { + if (typeof allMatchingItemsCount === 'number') { + selectedAllMatchingItemsCount = Math.max(allMatchingItemsCount - excludedItemsCount, 0); + } else if (isExpenseType && isOffline) { + selectedAllMatchingItemsCount = selectedItemsCount; + } + } const isAllMatchingItemsCountLoading = areAllMatchingItemsSelected && typeof allMatchingItemsCount !== 'number' && !isOffline && !!currentSearchResults?.search?.isLoading; let selectionButtonText: string; if (areAllMatchingItemsSelected) { diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 2e0426a06b8c..c02c93c2e38e 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -563,7 +563,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {hash} = queryJSON ?? {}; const isExpenseType = queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE; const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); - const excludedTransactionIDs = isExpenseType ? Object.keys(excludedTransactions) : []; const firstTransactionID = selectedTransactionsKeys.at(0); const firstTransaction = (firstTransactionID ? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${firstTransactionID}`] : undefined) ?? @@ -738,7 +737,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { jsonQuery: exportParameters.jsonQuery, reportIDList, transactionIDList: selectedTransactionsKeys, - ...(isExpenseType ? {excludedTransactionIDList: excludedTransactionIDs} : {}), + ...(isExpenseType ? {excludedTransactionIDList: Object.keys(excludedTransactions)} : {}), isBasicExport: exportParameters.isBasicExport, exportColumnLabels: exportParameters.exportColumnLabels, exportName, @@ -782,7 +781,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { selectedTransactions, selectedTransactionsKeys, isExpenseType, - excludedTransactionIDs, + excludedTransactions, translate, clearSelectedTransactions, hash, diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx index 5e4687bb23d1..1335931feda4 100644 --- a/tests/unit/Search/SearchSelectionFooterTest.tsx +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -65,6 +65,8 @@ function makeSearchResults(count: number, total: number): OnyxEntry { }); describe('getExportTemplates', () => { - const translate = translateLocal; + const translateForTemplates = translateLocal; const localeCompare = (first: string, second: string) => first.localeCompare(second); const makeTemplate = (name: string): ExportTemplate => ({name, templateName: name, type: '', policyID: undefined, description: ''}); @@ -195,17 +195,17 @@ describe('getExportTemplates', () => { banana: makeTemplate('Banana layout'), }; - const {customTemplates, defaultTemplates} = getExportTemplates(integrationsExportTemplates, csvExportLayouts, translate, localeCompare); + const {customTemplates, defaultTemplates} = getExportTemplates(integrationsExportTemplates, csvExportLayouts, translateForTemplates, localeCompare); // Custom group (custom integrations + in-app templates) is sorted alphabetically expect(customTemplates.map((template) => template.name)).toEqual(['Apple integration', 'Banana layout', 'Mango layout', 'Zebra integration']); // Default group (expense/report level) is sorted alphabetically - expect(defaultTemplates.map((template) => template.name)).toEqual([translate('export.expenseLevelExport'), translate('export.reportLevelExport')]); + expect(defaultTemplates.map((template) => template.name)).toEqual([translateForTemplates('export.expenseLevelExport'), translateForTemplates('export.reportLevelExport')]); }); it('excludes the report level export template when includeReportLevelExport is false', () => { - const {defaultTemplates} = getExportTemplates([], {}, translate, localeCompare, undefined, false); + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, undefined, false); const templateNames = defaultTemplates.map((template) => template.templateName); expect(templateNames).toContain(CONST.REPORT.EXPORT_OPTIONS.EXPENSE_LEVEL_EXPORT); @@ -213,17 +213,19 @@ describe('getExportTemplates', () => { }); it('excludes the basic export template by default', () => { - const {defaultTemplates} = getExportTemplates([], {}, translate, localeCompare); + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare); const templateNames = defaultTemplates.map((template) => template.templateName); expect(templateNames).not.toContain(CONST.REPORT.EXPORT_OPTIONS.DOWNLOAD_CSV); }); it('includes the basic export template in the default group (sorted alphabetically) when includeBasicExport is true', () => { - const {defaultTemplates} = getExportTemplates([], {}, translate, localeCompare, undefined, true, true); + const {defaultTemplates} = getExportTemplates([], {}, translateForTemplates, localeCompare, undefined, true, true); const names = defaultTemplates.map((template) => template.name); // Basic export is sorted alphabetically alongside the other default templates, not pinned to the bottom - expect(names).toEqual([translate('export.expenseLevelExport'), translate('export.reportLevelExport'), translate('export.basicExport')].sort(localeCompare)); + expect(names).toEqual( + [translateForTemplates('export.expenseLevelExport'), translateForTemplates('export.reportLevelExport'), translateForTemplates('export.basicExport')].sort(localeCompare), + ); }); }); From 40e494bdda0a55c4a8aec329feadde760187cdef Mon Sep 17 00:00:00 2001 From: emkhalid Date: Mon, 27 Jul 2026 20:18:00 +0430 Subject: [PATCH 13/16] Hide unsupported template exports with exclusions --- src/hooks/useSearchBulkActions.ts | 12 ++++- tests/unit/hooks/useSearchBulkActionsTest.ts | 54 +++++++++++++++++--- 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index a9eb33dd17e8..3ecdeb8b94bb 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1495,6 +1495,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { includeReportLevelExport, !isGroupedSearch, ); + const shouldHideTemplateExports = isExpenseType && areAllMatchingItemsSelected && Object.keys(excludedTransactions).length > 0; + const availableCustomTemplates = shouldHideTemplateExports + ? customTemplates.filter((template) => template.templateName === CONST.REPORT.EXPORT_OPTIONS.DOWNLOAD_CSV) + : customTemplates; + const availableDefaultTemplates = shouldHideTemplateExports + ? defaultTemplates.filter((template) => template.templateName === CONST.REPORT.EXPORT_OPTIONS.DOWNLOAD_CSV) + : defaultTemplates; const exportOptions: PopoverMenuItem[] = []; @@ -1650,10 +1657,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }; // Add each group's templates separately so the icon and the group-boundary divider come from the group itself, not from an index into a combined list. - for (const [index, template] of customTemplates.entries()) { + for (const [index, template] of availableCustomTemplates.entries()) { exportOptions.push(buildExportOption(template, false, index === 0)); } - for (const [index, template] of defaultTemplates.entries()) { + for (const [index, template] of availableDefaultTemplates.entries()) { exportOptions.push(buildExportOption(template, true, index === 0)); } } else if (!isGroupedSearch) { @@ -2204,6 +2211,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { selectedTransactionsKeys, hash, selectedTransactions, + excludedTransactions, queryJSON?.type, expensifyIcons, translate, diff --git a/tests/unit/hooks/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts index 85f372afdeec..fab0f46c6dd4 100644 --- a/tests/unit/hooks/useSearchBulkActionsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsTest.ts @@ -4,7 +4,7 @@ import type {SearchQueryJSON, SelectedReports, SelectedTransactions} from '@comp import useSearchBulkActions from '@hooks/useSearchBulkActions'; -import {queueExportSearchItemsToCSV, queueExportSearchWithTemplate} from '@libs/actions/Search'; +import {getExportTemplates, queueExportSearchItemsToCSV, queueExportSearchWithTemplate} from '@libs/actions/Search'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -13,6 +13,7 @@ import Onyx from 'react-native-onyx'; const mockQueueExportSearchItemsToCSV = jest.mocked(queueExportSearchItemsToCSV); const mockQueueExportSearchWithTemplate = jest.mocked(queueExportSearchWithTemplate); +const mockGetExportTemplates = jest.mocked(getExportTemplates); jest.mock('@libs/actions/Export', () => ({ clearExportDownload: jest.fn(), @@ -240,6 +241,7 @@ describe('useSearchBulkActions - CSV export flow', () => { mockSelectedTransactions = {}; mockExcludedTransactions = {}; mockSelectedReports = []; + mockGetExportTemplates.mockReturnValue({customTemplates: [], defaultTemplates: []}); await Onyx.merge(ONYXKEYS.SESSION, {accountID: CURRENT_USER_ACCOUNT_ID, email: 'test@example.com'}); }); @@ -335,6 +337,10 @@ describe('useSearchBulkActions - CSV export flow', () => { it('beginExportWithTemplate tracks the export', async () => { mockAreAllMatchingItemsSelected = true; mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + mockGetExportTemplates.mockReturnValue({ + customTemplates: [{name: 'Custom template', templateName: 'custom-template', type: 'csv', policyID: undefined, description: ''}], + defaultTemplates: [], + }); const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); @@ -345,13 +351,45 @@ describe('useSearchBulkActions - CSV export flow', () => { const exportOption = result.current.headerButtonsOptions.find((o) => o.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT); const templateSubItem = exportOption?.subMenuItems?.find((item) => item.text !== 'export.basicExport' && item.text !== 'export.currentView'); - if (templateSubItem?.onSelected) { - act(() => { - templateSubItem.onSelected?.(); - }); + expect(templateSubItem).toBeDefined(); + act(() => { + templateSubItem?.onSelected?.(); + }); + + expect(mockQueueExportSearchWithTemplate).toHaveBeenCalled(); + expect(result.current.exportDownloadStatusModal).not.toBeNull(); + }); + + it('hides template exports when an all-matching expense selection has exclusions', async () => { + mockAreAllMatchingItemsSelected = true; + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + mockExcludedTransactions = {tx2: makeSelectedTransaction()}; + mockGetExportTemplates.mockReturnValue({ + customTemplates: [{name: 'Custom template', templateName: 'custom-template', type: 'csv', policyID: undefined, description: ''}], + defaultTemplates: [ + {name: 'Default template', templateName: 'default-template', type: 'csv', policyID: undefined, description: ''}, + { + name: 'export.basicExport', + templateName: CONST.REPORT.EXPORT_OPTIONS.DOWNLOAD_CSV, + type: 'csv', + policyID: undefined, + description: '', + }, + ], + }); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0); + }); + + const exportOption = result.current.headerButtonsOptions.find((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT); + const exportItems = exportOption?.subMenuItems ?? []; - expect(mockQueueExportSearchWithTemplate).toHaveBeenCalled(); - expect(result.current.exportDownloadStatusModal).not.toBeNull(); - } + expect(exportItems.some((item) => item.text === 'Custom template')).toBe(false); + expect(exportItems.some((item) => item.text === 'Default template')).toBe(false); + expect(exportItems.some((item) => item.text === 'export.currentView')).toBe(true); + expect(exportItems.some((item) => item.text === 'export.basicExport')).toBe(true); }); }); From c966a5529bec813075c6cda89391a482e2197fc7 Mon Sep 17 00:00:00 2001 From: emkhalid Date: Mon, 27 Jul 2026 21:06:27 +0430 Subject: [PATCH 14/16] Clear all-matching selection when every expense is excluded --- .../Search/SearchSelectionProvider.tsx | 6 +++++- .../Search/SearchWriteActionsProvider.tsx | 12 +++++++++-- src/components/Search/types.ts | 8 +++++++- .../Search/SearchSelectionProviderTest.tsx | 20 ++++++++++++++++++- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/components/Search/SearchSelectionProvider.tsx b/src/components/Search/SearchSelectionProvider.tsx index 8407df290c93..16be7decd199 100644 --- a/src/components/Search/SearchSelectionProvider.tsx +++ b/src/components/Search/SearchSelectionProvider.tsx @@ -96,7 +96,11 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { totalSelectableItemsCount && totalSelectableItemsCount !== Object.keys(selectedTransactions).length ? false : prevState.areAllMatchingItemsSelected; let excludedTransactions = prevState.excludedTransactions; - if (prevState.areAllMatchingItemsSelected && options?.shouldPreserveAllMatchingSelection) { + const shouldClearAllMatchingSelection = options?.shouldClearAllMatchingSelectionWhenEmpty && isEmptyObject(selectedTransactions); + if (shouldClearAllMatchingSelection) { + areAllMatchingItemsSelected = false; + } + if (prevState.areAllMatchingItemsSelected && options?.shouldPreserveAllMatchingSelection && !shouldClearAllMatchingSelection) { areAllMatchingItemsSelected = true; excludedTransactions = {...prevState.excludedTransactions}; for (const [key, transaction] of Object.entries(prevState.selectedTransactions)) { diff --git a/src/components/Search/SearchWriteActionsProvider.tsx b/src/components/Search/SearchWriteActionsProvider.tsx index d389df266214..70f94d84ed16 100644 --- a/src/components/Search/SearchWriteActionsProvider.tsx +++ b/src/components/Search/SearchWriteActionsProvider.tsx @@ -397,7 +397,11 @@ function SearchWriteActionsProvider({ return updatedTransactions; }, - {totalSelectableItemsCount, shouldPreserveAllMatchingSelection: type === CONST.SEARCH.DATA_TYPES.EXPENSE}, + { + totalSelectableItemsCount, + shouldPreserveAllMatchingSelection: type === CONST.SEARCH.DATA_TYPES.EXPENSE, + shouldClearAllMatchingSelectionWhenEmpty: searchResults?.search?.hasMoreResults === false, + }, ); return; } @@ -461,7 +465,11 @@ function SearchWriteActionsProvider({ ), }; }, - {totalSelectableItemsCount, shouldPreserveAllMatchingSelection: type === CONST.SEARCH.DATA_TYPES.EXPENSE}, + { + totalSelectableItemsCount, + shouldPreserveAllMatchingSelection: type === CONST.SEARCH.DATA_TYPES.EXPENSE, + shouldClearAllMatchingSelectionWhenEmpty: searchResults?.search?.hasMoreResults === false, + }, ); }; diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 6a91e6a2070a..ecde78fe3631 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -237,10 +237,16 @@ 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. * `shouldPreserveAllMatchingSelection` keeps that mode active for row toggles and records removed rows as exclusions. + * `shouldClearAllMatchingSelectionWhenEmpty` exits that mode when no selected rows or additional results remain. */ applySelection: ( updater: (previousSelectedTransactions: SelectedTransactions) => SelectedTransactions, - options?: {data?: SearchData; totalSelectableItemsCount?: number; shouldPreserveAllMatchingSelection?: boolean}, + options?: { + data?: SearchData; + totalSelectableItemsCount?: number; + shouldPreserveAllMatchingSelection?: boolean; + shouldClearAllMatchingSelectionWhenEmpty?: boolean; + }, ) => void; setSelectedReports: (reports: SelectedReports[]) => void; setCurrentSelectedTransactionReportID: (reportID: string | undefined) => void; diff --git a/tests/unit/Search/SearchSelectionProviderTest.tsx b/tests/unit/Search/SearchSelectionProviderTest.tsx index 12ae57b124fd..75f735e8d947 100644 --- a/tests/unit/Search/SearchSelectionProviderTest.tsx +++ b/tests/unit/Search/SearchSelectionProviderTest.tsx @@ -106,7 +106,7 @@ describe('SearchSelectionProvider all-matching exclusions', () => { expect(Object.keys(result.current.state.excludedTransactions)).toEqual(['tx_1']); }); - it('keeps a semantic selection when every loaded row is excluded', () => { + it('keeps a semantic selection when every loaded row is excluded and more results exist', () => { const {result} = renderSelection(); seedAllMatchingSelection(result); @@ -123,6 +123,24 @@ describe('SearchSelectionProvider all-matching exclusions', () => { expect(result.current.state.hasSelectedTransactions).toBe(true); }); + it('clears all-matching selection when every result is excluded', () => { + const {result} = renderSelection(); + seedAllMatchingSelection(result); + + act(() => { + result.current.actions.applySelection(() => ({}), { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: true, + shouldClearAllMatchingSelectionWhenEmpty: true, + }); + }); + + expect(result.current.state.selectedTransactions).toEqual({}); + expect(result.current.state.excludedTransactions).toEqual({}); + expect(result.current.state.areAllMatchingItemsSelected).toBe(false); + expect(result.current.state.hasSelectedTransactions).toBe(false); + }); + it('removes an exclusion when the row is rechecked', () => { const {result} = renderSelection(); seedAllMatchingSelection(result); From b25c225853e216e14574aad8c066c9519138470f Mon Sep 17 00:00:00 2001 From: emkhalid Date: Tue, 28 Jul 2026 06:20:33 +0430 Subject: [PATCH 15/16] Fix all-matching totals refresh after reconnect --- src/components/Search/index.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index a4cf91112fa9..61275d5e9fdc 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -389,7 +389,9 @@ function Search({ const isMigratedModalDisplayed = focusedRoute?.name === NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR || focusedRoute?.name === SCREENS.MIGRATED_USER_WELCOME_MODAL.DYNAMIC_ROOT; const comingBackOnlineWithNoResults = prevIsOffline && !isOffline && isEmptyObject(searchResults?.data); - if (!comingBackOnlineWithNoResults && ((!isFocused && !isMigratedModalDisplayed) || isOffline)) { + const comingBackOnlineWithMissingAllMatchingTotals = prevIsOffline && !isOffline && isExpenseAllMatchingSelection && isAllMatchingItemsCountMissing; + const shouldRefreshOnReconnect = comingBackOnlineWithNoResults || comingBackOnlineWithMissingAllMatchingTotals; + if (!shouldRefreshOnReconnect && ((!isFocused && !isMigratedModalDisplayed) || isOffline)) { return; } @@ -409,7 +411,7 @@ function Search({ return; } - if (hasErrors && !comingBackOnlineWithNoResults) { + if (hasErrors && !shouldRefreshOnReconnect) { return; } From 6bf8b01c46aa5fb3c3cbefbb92d75174044ae53c Mon Sep 17 00:00:00 2001 From: emkhalid Date: Tue, 28 Jul 2026 15:16:20 +0430 Subject: [PATCH 16/16] Clear empty all-matching selection while offline --- src/components/Search/SearchWriteActionsProvider.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/Search/SearchWriteActionsProvider.tsx b/src/components/Search/SearchWriteActionsProvider.tsx index 70f94d84ed16..47be299e378d 100644 --- a/src/components/Search/SearchWriteActionsProvider.tsx +++ b/src/components/Search/SearchWriteActionsProvider.tsx @@ -1,5 +1,6 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useEnvironment from '@hooks/useEnvironment'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSelfDMReport from '@hooks/useSelfDMReport'; @@ -349,6 +350,7 @@ function SearchWriteActionsProvider({ }: SearchWriteActionsProviderProps) { const isFocused = useIsFocused(); const {isProduction} = useEnvironment(); + const {isOffline} = useNetwork(); const {accountID, email, login} = useCurrentUserPersonalDetails(); const selfDMReport = useSelfDMReport(); const [reportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); @@ -400,7 +402,7 @@ function SearchWriteActionsProvider({ { totalSelectableItemsCount, shouldPreserveAllMatchingSelection: type === CONST.SEARCH.DATA_TYPES.EXPENSE, - shouldClearAllMatchingSelectionWhenEmpty: searchResults?.search?.hasMoreResults === false, + shouldClearAllMatchingSelectionWhenEmpty: isOffline || searchResults?.search?.hasMoreResults === false, }, ); return; @@ -468,7 +470,7 @@ function SearchWriteActionsProvider({ { totalSelectableItemsCount, shouldPreserveAllMatchingSelection: type === CONST.SEARCH.DATA_TYPES.EXPENSE, - shouldClearAllMatchingSelectionWhenEmpty: searchResults?.search?.hasMoreResults === false, + shouldClearAllMatchingSelectionWhenEmpty: isOffline || searchResults?.search?.hasMoreResults === false, }, ); };