Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fc4c238
fix: preserve all matching selection after deselecting an expense
emkhalid Jul 11, 2026
dc68c95
fix: correct search selection test mock typings
emkhalid Jul 11, 2026
bd2483e
fix: resolve CI errors
emkhalid Jul 12, 2026
b24c47a
fix: preserve all-matching bulk actions
emkhalid Jul 13, 2026
93d0337
Merge branch 'main' into fix/preserve-all-matching-selection
emkhalid Jul 13, 2026
f0b00c1
fix: resolve search selection CI failures
emkhalid Jul 13, 2026
a7280e1
Merge branch 'main' into fix/preserve-all-matching-selection
emkhalid Jul 15, 2026
7fbe97c
Merge branch 'main' into fix/preserve-all-matching-selection
emkhalid Jul 15, 2026
88f353e
Merge branch 'main' into fix/preserve-all-matching-selection
emkhalid Jul 16, 2026
61fb693
Merge branch 'main' into fix/preserve-all-matching-selection
emkhalid Jul 17, 2026
64d00ae
Add context for deferred search offset update
emkhalid Jul 17, 2026
3753e22
Fix unloaded grouped row selection flicker
emkhalid Jul 18, 2026
46a3bda
Fix totals request race during pagination
emkhalid Jul 19, 2026
9e54de1
Restore Reports behavior and scope exclusions to Expenses
emkhalid Jul 20, 2026
0baeb37
Restore production bulk selection loading behavior
emkhalid Jul 20, 2026
aa2ce27
Merge branch 'main' into fix/preserve-all-matching-selection
emkhalid Jul 24, 2026
1d94100
Fix all-matching expense selection label
emkhalid Jul 24, 2026
6a96e37
Fix search selection lint and type errors
emkhalid Jul 24, 2026
1ca1014
Merge branch 'main' into fix/preserve-all-matching-selection
emkhalid Jul 27, 2026
40e494b
Hide unsupported template exports with exclusions
emkhalid Jul 27, 2026
c966a55
Clear all-matching selection when every expense is excluded
emkhalid Jul 27, 2026
b25c225
Fix all-matching totals refresh after reconnect
emkhalid Jul 28, 2026
6bf8b01
Clear empty all-matching selection while offline
emkhalid Jul 28, 2026
eba15ff
Merge branch 'main' into fix/preserve-all-matching-selection
emkhalid Jul 29, 2026
33578e2
Merge branch 'main' into fix/preserve-all-matching-selection
emkhalid Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 37 additions & 24 deletions src/components/Search/SearchBulkActionsButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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, selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext();
const {selectedTransactions, excludedTransactions = getEmptyObject<SelectedTransactions>(), selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext();
const {currentSearchResults} = useSearchResultsContext();
const kycWallRef = useContext(KYCWallContext);
const {isAccountLocked} = useLockedAccountState();
Expand Down Expand Up @@ -99,38 +100,50 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) {
const pendingPaymentAdditionalDataRef = useRef<BulkPaySelectionData | undefined>(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);
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;
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) {
selectionButtonText =
typeof allMatchingItemsCount !== 'number' ? translate('search.exportAll.allMatchingItemsSelected') : translate('workspace.common.selected', {count: allMatchingItemsCount});
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});
}
Expand Down
1 change: 1 addition & 0 deletions src/components/Search/SearchContextDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const defaultSearchResultsActions: SearchResultsActionsValue = {
const defaultSearchSelectionContext: SearchSelectionContextValue = {
currentSelectedTransactionReportID: undefined,
selectedTransactions: {},
excludedTransactions: {},
selectedTransactionIDs: [],
selectedReports: [],
shouldTurnOffSelectionMode: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ function TransactionGroupListItemImpl({

const StyleUtils = useStyleUtils();
const {isSelected: liveRowSelected} = useRowSelection(item?.keyForList);
const isItemSelected = isSelectAllChecked || liveRowSelected;
const isItemSelected = isSelectAllChecked || (liveRowSelected && (isExpenseReportType || transactionsWithoutPendingDelete.length === 0));

const animatedHighlightStyle = useAnimatedHighlightStyle({
shouldHighlight: item?.shouldAnimateInHighlight ?? false,
Expand Down Expand Up @@ -309,7 +309,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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -26,12 +28,13 @@ type SearchActionsBarWideProps = {

function SearchActionsBarWide({queryJSON, searchResults, onSort}: SearchActionsBarWideProps) {
const styles = useThemeStyles();
const {hasSelectedTransactions} = useSearchSelectionContext();
const {selected} = useSelectionCounts();
const hasSelectedItems = selected > 0;
const shouldShowBulkActions = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE ? hasSelectedTransactions : selected > 0;

return (
<View style={[styles.searchActionsBarContainer]}>
{hasSelectedItems ? (
{shouldShowBulkActions ? (
<View style={styles.searchBulkActionsButton}>
<SearchBulkActionsButton queryJSON={queryJSON} />
</View>
Expand Down
44 changes: 29 additions & 15 deletions src/components/Search/SearchSelectionFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -20,13 +23,15 @@ type SearchSelectionFooterProps = {
// Self-subscribing footer leaf. Owns the `selectedTransactions` read so a checkbox press re-renders only this
// footer — not SearchPage and the <Search> list it contains.
function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) {
const {selectedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext();
const {selectedTransactions, excludedTransactions = getEmptyObject<SelectedTransactions>(), 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 isExpenseType = currentSearchQueryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE;
const shouldShowFooter = (!areAllMatchingItemsSelected && selectedTransactionsKeys.length > 0) || (shouldAllowFooterTotals && !!metadata?.count);

if (!shouldShowFooter) {
Expand All @@ -36,20 +41,29 @@ 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) => {
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 = 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 = isExpenseType ? metadata.total - getTransactionTotal(Object.values(excludedTransactions)) : metadata.total;
}

return (
<SearchPageFooter
Expand Down
Loading
Loading