From 4294524cde58c4c6e683cd2c4b2944db855792c0 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 23 Feb 2026 17:05:24 -0700 Subject: [PATCH 1/9] Add SearchSelectionContext and wire provider in AuthScreens Co-authored-by: Cursor --- .../Search/SearchSelectionContext.tsx | 194 ++++++++++++++++++ src/components/Search/types.ts | 22 ++ .../Navigation/AppNavigator/AuthScreens.tsx | 2 + 3 files changed, 218 insertions(+) create mode 100644 src/components/Search/SearchSelectionContext.tsx diff --git a/src/components/Search/SearchSelectionContext.tsx b/src/components/Search/SearchSelectionContext.tsx new file mode 100644 index 0000000000000..75ca9c5e7158a --- /dev/null +++ b/src/components/Search/SearchSelectionContext.tsx @@ -0,0 +1,194 @@ +import React, {useCallback, useContext, useMemo, useRef, useState} from 'react'; +import {isMoneyRequestReport} from '@libs/ReportUtils'; +import {isTransactionListItemType, isTransactionReportGroupListItemType} from '@libs/SearchUIUtils'; +import {hasValidModifiedAmount} from '@libs/TransactionUtils'; +import CONST from '@src/CONST'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type {SearchSelectionContextProps, SelectedTransactions} from './types'; +import {useSearchContext} from './SearchContext'; + +const defaultSearchSelectionContext: SearchSelectionContextProps = { + selectedTransactions: {}, + selectedTransactionIDs: [], + selectedReports: [], + shouldTurnOffSelectionMode: false, + setSelectedTransactions: () => {}, + clearSelectedTransactions: () => {}, + removeTransaction: () => {}, + showSelectAllMatchingItems: false, + shouldShowSelectAllMatchingItems: () => {}, + areAllMatchingItemsSelected: false, + selectAllMatchingItems: () => {}, +}; + +const SearchSelectionContext = React.createContext(defaultSearchSelectionContext); + +function SearchSelectionContextProvider({children}: ChildrenProps) { + const {currentSearchHash} = useSearchContext(); + const [showSelectAllMatchingItems, shouldShowSelectAllMatchingItems] = useState(false); + const [areAllMatchingItemsSelected, selectAllMatchingItems] = useState(false); + const [selectionData, setSelectionData] = useState({ + selectedTransactions: {} as SelectedTransactions, + selectedTransactionIDs: [] as string[], + selectedReports: [] as SearchSelectionContextProps['selectedReports'], + shouldTurnOffSelectionMode: false, + }); + const areTransactionsEmpty = useRef(true); + const selectionDataRef = useRef(selectionData); + selectionDataRef.current = selectionData; + + const setSelectedTransactions: SearchSelectionContextProps['setSelectedTransactions'] = useCallback((selectedTransactions, data = []) => { + if (selectedTransactions instanceof Array) { + if (!selectedTransactions.length && areTransactionsEmpty.current) { + areTransactionsEmpty.current = true; + return; + } + areTransactionsEmpty.current = false; + return setSelectionData((prevState) => ({ + ...prevState, + selectedTransactionIDs: selectedTransactions, + })); + } + + let selectedReports: SearchSelectionContextProps['selectedReports'] = []; + + if (data.length && data.every(isTransactionReportGroupListItemType)) { + selectedReports = data + .filter((item) => { + if (!isMoneyRequestReport(item)) { + return false; + } + if (item.transactions.length === 0) { + return !!item.keyForList && selectedTransactions[item.keyForList]?.isSelected; + } + return item.transactions.every(({keyForList}) => selectedTransactions[keyForList]?.isSelected); + }) + .map(({reportID, action = CONST.SEARCH.ACTION_TYPES.VIEW, total = CONST.DEFAULT_NUMBER_ID, policyID, allActions = [action], currency, chatReportID}) => ({ + reportID, + action, + total, + policyID, + allActions, + currency, + chatReportID, + })); + } else if (data.length && data.every(isTransactionListItemType)) { + selectedReports = data + .filter(({keyForList}) => !!keyForList && selectedTransactions[keyForList]?.isSelected) + .map((item) => { + const total = hasValidModifiedAmount(item) ? Number(item.modifiedAmount) : (item.amount ?? CONST.DEFAULT_NUMBER_ID); + const action = item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW; + + return { + reportID: item.reportID, + action, + total, + policyID: item.policyID, + allActions: item.allActions ?? [action], + currency: item.currency, + chatReportID: item.report?.chatReportID, + }; + }); + } + + setSelectionData((prevState) => ({ + ...prevState, + selectedTransactions, + shouldTurnOffSelectionMode: false, + selectedReports, + })); + }, []); + + const clearSelectedTransactions: SearchSelectionContextProps['clearSelectedTransactions'] = useCallback( + (searchHashOrClearIDsFlag, shouldTurnOffSelectionMode = false) => { + if (typeof searchHashOrClearIDsFlag === 'boolean') { + setSelectedTransactions([]); + return; + } + + const data = selectionDataRef.current; + + if (searchHashOrClearIDsFlag === currentSearchHash) { + return; + } + + if (data.selectedReports.length === 0 && isEmptyObject(data.selectedTransactions) && !data.shouldTurnOffSelectionMode) { + return; + } + setSelectionData((prevState) => ({ + ...prevState, + shouldTurnOffSelectionMode, + selectedTransactions: {}, + selectedReports: [], + })); + + shouldShowSelectAllMatchingItems(false); + selectAllMatchingItems(false); + }, + [currentSearchHash, setSelectedTransactions], + ); + + const removeTransaction: SearchSelectionContextProps['removeTransaction'] = useCallback( + (transactionID) => { + if (!transactionID) { + return; + } + const selectedTransactionIDs = selectionData.selectedTransactionIDs; + + if (!isEmptyObject(selectionData.selectedTransactions)) { + const newSelectedTransactions = Object.entries(selectionData.selectedTransactions).reduce((acc, [key, value]) => { + if (key === transactionID) { + return acc; + } + acc[key] = value; + return acc; + }, {} as SelectedTransactions); + + setSelectionData((prevState) => ({ + ...prevState, + selectedTransactions: newSelectedTransactions, + })); + } + + if (selectedTransactionIDs.length > 0) { + setSelectionData((prevState) => ({ + ...prevState, + selectedTransactionIDs: selectedTransactionIDs.filter((ID) => transactionID !== ID), + })); + } + }, + [selectionData.selectedTransactionIDs, selectionData.selectedTransactions], + ); + + const value = useMemo( + () => ({ + ...selectionData, + setSelectedTransactions, + clearSelectedTransactions, + removeTransaction, + showSelectAllMatchingItems, + shouldShowSelectAllMatchingItems, + areAllMatchingItemsSelected, + selectAllMatchingItems, + }), + [ + selectionData, + setSelectedTransactions, + clearSelectedTransactions, + removeTransaction, + showSelectAllMatchingItems, + shouldShowSelectAllMatchingItems, + areAllMatchingItemsSelected, + selectAllMatchingItems, + ], + ); + + return {children}; +} + +function useSearchSelectionContext() { + return useContext(SearchSelectionContext); +} + +export {SearchSelectionContextProvider, useSearchSelectionContext, SearchSelectionContext}; diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index e671df62f8d50..f8e7bc5c4dfd7 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -164,6 +164,27 @@ type SearchContextData = { shouldResetSearchQuery: boolean; }; +/** Selection-only context (no search results). Used by SearchSelectionBar, list components, IOU/RHP. */ +type SearchSelectionContextProps = { + selectedTransactions: SelectedTransactions; + selectedTransactionIDs: string[]; + selectedReports: SelectedReports[]; + shouldTurnOffSelectionMode: boolean; + setSelectedTransactions: { + (selectedTransactionIDs: string[], unused?: undefined): void; + (selectedTransactions: SelectedTransactions, data: TransactionListItemType[] | TransactionGroupListItemType[] | ReportActionListItemType[] | TaskListItemType[]): void; + }; + clearSelectedTransactions: { + (hash?: number, shouldTurnOffSelectionMode?: boolean): void; + (clearIDs: true, unused?: undefined): void; + }; + removeTransaction: (transactionID: string | undefined) => void; + showSelectAllMatchingItems: boolean; + shouldShowSelectAllMatchingItems: (shouldShow: boolean) => void; + areAllMatchingItemsSelected: boolean; + selectAllMatchingItems: (on: boolean) => void; +}; + type SearchContextProps = SearchContextData & { currentSearchResults: SearchResults | undefined; /** Whether we're on a main to-do search and should use live Onyx data instead of snapshots */ @@ -336,6 +357,7 @@ type GroupedItem = | TransactionQuarterGroupListItemType; export type { + SearchSelectionContextProps, SelectedTransactionInfo, SelectedTransactions, SearchColumnType, diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 76c7fd09ca025..4fe3bc51160ba 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -15,6 +15,7 @@ import OpenAppFailureModal from '@components/OpenAppFailureModal'; import OptionsListContextProvider from '@components/OptionListContextProvider'; import PriorityModeController from '@components/PriorityModeController'; import {SearchContextProvider} from '@components/Search/SearchContext'; +import {SearchSelectionContextProvider} from '@components/Search/SearchSelectionContext'; import {useSearchRouterActions} from '@components/Search/SearchRouter/SearchRouterContext'; import SearchRouterModal from '@components/Search/SearchRouter/SearchRouterModal'; import SupportalPermissionDeniedModalProvider from '@components/SupportalPermissionDeniedModalProvider'; @@ -511,6 +512,7 @@ function AuthScreens() { OptionsListContextProvider, SidebarOrderedReportsContextProvider, SearchContextProvider, + SearchSelectionContextProvider, LockedAccountModalProvider, DelegateNoAccessModalProvider, SupportalPermissionDeniedModalProvider, From 1b10330268feff3d85920f95d1c5cf5e82d776d1 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 23 Feb 2026 17:15:55 -0700 Subject: [PATCH 2/9] Migrate SearchRejectReasonPage and SearchHoldReasonPage to SearchSelectionContext Co-authored-by: Cursor --- src/pages/Search/SearchHoldReasonPage.tsx | 28 +++++++++++++++----- src/pages/Search/SearchRejectReasonPage.tsx | 29 +++++++++++++++------ 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/pages/Search/SearchHoldReasonPage.tsx b/src/pages/Search/SearchHoldReasonPage.tsx index a8e14844a9b55..f3839aaf96bd1 100644 --- a/src/pages/Search/SearchHoldReasonPage.tsx +++ b/src/pages/Search/SearchHoldReasonPage.tsx @@ -2,6 +2,7 @@ import React, {useCallback, useEffect} from 'react'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import useAncestors from '@hooks/useAncestors'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -24,7 +25,8 @@ type SearchHoldReasonPageProps = function SearchHoldReasonPage({route}: SearchHoldReasonPageProps) { const {translate} = useLocalize(); const {backTo = '', reportID} = route.params ?? {}; - const context = useSearchContext(); + const {currentSearchHash} = useSearchContext(); + const {selectedTransactionIDs, selectedTransactions, clearSelectedTransactions} = useSearchSelectionContext(); const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const ancestors = useAncestors(report); @@ -40,16 +42,28 @@ function SearchHoldReasonPage({route}: SearchHoldReasonPageProps) { } if (route.name === SCREENS.SEARCH.MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS) { - putTransactionsOnHold(context.selectedTransactionIDs, comment, reportID, ancestors); - context.clearSelectedTransactions(true); + putTransactionsOnHold(selectedTransactionIDs, comment, reportID, ancestors); + clearSelectedTransactions(true); } else { - holdMoneyRequestOnSearch(context.currentSearchHash, Object.keys(context.selectedTransactions), comment, allTransactions, allReportActions); - context.clearSelectedTransactions(); + holdMoneyRequestOnSearch(currentSearchHash, Object.keys(selectedTransactions), comment, allTransactions, allReportActions); + clearSelectedTransactions(); } Navigation.goBack(); }, - [route.name, context, reportID, allTransactions, allReportActions, ancestors, isDelegateAccessRestricted, showDelegateNoAccessModal], + [ + route.name, + currentSearchHash, + selectedTransactionIDs, + selectedTransactions, + clearSelectedTransactions, + reportID, + allTransactions, + allReportActions, + ancestors, + isDelegateAccessRestricted, + showDelegateNoAccessModal, + ], ); const validate = useCallback( @@ -70,7 +84,7 @@ function SearchHoldReasonPage({route}: SearchHoldReasonPageProps) { clearErrorFields(ONYXKEYS.FORMS.MONEY_REQUEST_HOLD_FORM); }, []); - const expenseCount = route.name === SCREENS.SEARCH.MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS ? context.selectedTransactionIDs.length : Object.keys(context.selectedTransactions).length; + const expenseCount = route.name === SCREENS.SEARCH.MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS ? selectedTransactionIDs.length : Object.keys(selectedTransactions).length; return ( ; function SearchRejectReasonPage({route}: SearchRejectReasonPageProps) { - const context = useSearchContext(); + const {currentSearchHash} = useSearchContext(); + const {selectedTransactionIDs, selectedTransactions, clearSelectedTransactions} = useSearchSelectionContext(); const {reportID} = route.params ?? {}; const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); @@ -33,13 +35,13 @@ function SearchRejectReasonPage({route}: SearchRejectReasonPageProps) { // When coming from the report view, selectedTransactions is empty, build it from selectedTransactionIDs const selectedTransactionsForReject = useMemo(() => { if (route.name === SCREENS.SEARCH.MONEY_REQUEST_REPORT_REJECT_TRANSACTIONS && reportID) { - return context.selectedTransactionIDs.reduce>((acc, transactionID) => { + return selectedTransactionIDs.reduce>((acc, transactionID) => { acc[transactionID] = {reportID}; return acc; }, {}); } - return context.selectedTransactions; - }, [route.name, reportID, context.selectedTransactionIDs, context.selectedTransactions]); + return selectedTransactions; + }, [route.name, reportID, selectedTransactionIDs, selectedTransactions]); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); @@ -50,18 +52,29 @@ function SearchRejectReasonPage({route}: SearchRejectReasonPageProps) { return; } - const urlToNavigateBack = rejectMoneyRequestsOnSearch(context.currentSearchHash, selectedTransactionsForReject, comment, allPolicies, allReports, currentUserAccountID, betas); + const urlToNavigateBack = rejectMoneyRequestsOnSearch(currentSearchHash, selectedTransactionsForReject, comment, allPolicies, allReports, currentUserAccountID, betas); if (route.name === SCREENS.SEARCH.MONEY_REQUEST_REPORT_REJECT_TRANSACTIONS) { - context.clearSelectedTransactions(true); + clearSelectedTransactions(true); } else { - context.clearSelectedTransactions(); + clearSelectedTransactions(); } Navigation.dismissToSuperWideRHP(); if (urlToNavigateBack) { Navigation.isNavigationReady().then(() => Navigation.goBack(urlToNavigateBack as Route)); } }, - [context, allPolicies, allReports, route.name, selectedTransactionsForReject, isDelegateAccessRestricted, currentUserAccountID, showDelegateNoAccessModal, betas], + [ + currentSearchHash, + clearSelectedTransactions, + allPolicies, + allReports, + route.name, + selectedTransactionsForReject, + isDelegateAccessRestricted, + currentUserAccountID, + showDelegateNoAccessModal, + betas, + ], ); const validate = useCallback( From 002d4f2e31d8c3f059a5729e91351937c5fd2fff Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 23 Feb 2026 17:15:58 -0700 Subject: [PATCH 3/9] Remove selection logic from SearchContext; add clearSelectedTransactions to Split flow Co-authored-by: Cursor --- src/components/Search/SearchContext.tsx | 170 +----------------------- src/components/Search/types.ts | 19 --- src/libs/actions/IOU/Split.ts | 8 +- src/pages/iou/SplitExpensePage.tsx | 3 + 4 files changed, 11 insertions(+), 189 deletions(-) diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx index 968a6febe7eeb..0d7a4dc4584b5 100644 --- a/src/components/Search/SearchContext.tsx +++ b/src/components/Search/SearchContext.tsx @@ -1,20 +1,17 @@ -import React, {useCallback, useContext, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useContext, useMemo, useState} from 'react'; // We need direct access to useOnyx from react-native-onyx to avoid circular dependencies in SearchContext // eslint-disable-next-line no-restricted-imports import {useOnyx} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useTodos from '@hooks/useTodos'; -import {isMoneyRequestReport} from '@libs/ReportUtils'; -import {getSuggestedSearches, isTodoSearch, isTransactionListItemType, isTransactionReportGroupListItemType} from '@libs/SearchUIUtils'; +import {getSuggestedSearches, isTodoSearch} from '@libs/SearchUIUtils'; import type {SearchKey} from '@libs/SearchUIUtils'; -import {hasValidModifiedAmount} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchResults} from '@src/types/onyx'; import type {SearchResultsInfo} from '@src/types/onyx/SearchResults'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {SearchContextData, SearchContextProps, SearchQueryJSON, SelectedTransactions} from './types'; +import type {SearchContextData, SearchContextProps, SearchQueryJSON} from './types'; // Default search info when building from live data // Used for to-do searches where we build SearchResults from live Onyx data instead of API snapshots @@ -35,47 +32,29 @@ const defaultSearchContextData: SearchContextData = { currentSearchKey: undefined, currentSearchQueryJSON: undefined, currentSearchResults: undefined, - selectedTransactions: {}, - selectedTransactionIDs: [], - selectedReports: [], isOnSearch: false, - shouldTurnOffSelectionMode: false, shouldResetSearchQuery: false, }; const defaultSearchContext: SearchContextProps = { ...defaultSearchContextData, lastSearchType: undefined, - areAllMatchingItemsSelected: false, - showSelectAllMatchingItems: false, shouldShowFiltersBarLoading: false, currentSearchResults: undefined, shouldUseLiveData: false, setLastSearchType: () => {}, setCurrentSearchHashAndKey: () => {}, setCurrentSearchQueryJSON: () => {}, - setSelectedTransactions: () => {}, - removeTransaction: () => {}, - clearSelectedTransactions: () => {}, setShouldShowFiltersBarLoading: () => {}, - shouldShowSelectAllMatchingItems: () => {}, - selectAllMatchingItems: () => {}, setShouldResetSearchQuery: () => {}, }; const SearchContext = React.createContext(defaultSearchContext); function SearchContextProvider({children}: ChildrenProps) { - const [showSelectAllMatchingItems, shouldShowSelectAllMatchingItems] = useState(false); - const [areAllMatchingItemsSelected, selectAllMatchingItems] = useState(false); const [shouldShowFiltersBarLoading, setShouldShowFiltersBarLoading] = useState(false); const [lastSearchType, setLastSearchType] = useState(undefined); const [searchContextData, setSearchContextData] = useState(defaultSearchContextData); - const areTransactionsEmpty = useRef(true); - - // Use a ref to access searchContextData in callbacks without causing callback reference changes - const searchContextDataRef = useRef(searchContextData); - searchContextDataRef.current = searchContextData; const [snapshotSearchResults] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${searchContextData.currentSearchHash}`); const todoSearchResultsData = useTodos(); @@ -136,131 +115,6 @@ function SearchContextProvider({children}: ChildrenProps) { }); }, []); - const setSelectedTransactions: SearchContextProps['setSelectedTransactions'] = useCallback((selectedTransactions, data = []) => { - if (selectedTransactions instanceof Array) { - if (!selectedTransactions.length && areTransactionsEmpty.current) { - areTransactionsEmpty.current = true; - return; - } - areTransactionsEmpty.current = false; - return setSearchContextData((prevState) => ({ - ...prevState, - selectedTransactionIDs: selectedTransactions, - })); - } - - // When selecting transactions, we also need to manage the reports to which these transactions belong. This is done to ensure proper exporting to CSV. - let selectedReports: SearchContextProps['selectedReports'] = []; - - if (data.length && data.every(isTransactionReportGroupListItemType)) { - selectedReports = data - .filter((item) => { - if (!isMoneyRequestReport(item)) { - return false; - } - if (item.transactions.length === 0) { - return !!item.keyForList && selectedTransactions[item.keyForList]?.isSelected; - } - return item.transactions.every(({keyForList}) => selectedTransactions[keyForList]?.isSelected); - }) - .map(({reportID, action = CONST.SEARCH.ACTION_TYPES.VIEW, total = CONST.DEFAULT_NUMBER_ID, policyID, allActions = [action], currency, chatReportID}) => ({ - reportID, - action, - total, - policyID, - allActions, - currency, - chatReportID, - })); - } else if (data.length && data.every(isTransactionListItemType)) { - selectedReports = data - .filter(({keyForList}) => !!keyForList && selectedTransactions[keyForList]?.isSelected) - .map((item) => { - const total = hasValidModifiedAmount(item) ? Number(item.modifiedAmount) : (item.amount ?? CONST.DEFAULT_NUMBER_ID); - const action = item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW; - - return { - reportID: item.reportID, - action, - total, - policyID: item.policyID, - allActions: item.allActions ?? [action], - currency: item.currency, - chatReportID: item.report?.chatReportID, - }; - }); - } - - setSearchContextData((prevState) => ({ - ...prevState, - selectedTransactions, - shouldTurnOffSelectionMode: false, - selectedReports, - })); - }, []); - - const clearSelectedTransactions: SearchContextProps['clearSelectedTransactions'] = useCallback( - (searchHashOrClearIDsFlag, shouldTurnOffSelectionMode = false) => { - if (typeof searchHashOrClearIDsFlag === 'boolean') { - setSelectedTransactions([]); - return; - } - - const data = searchContextDataRef.current; - - if (searchHashOrClearIDsFlag === data.currentSearchHash) { - return; - } - - if (data.selectedReports.length === 0 && isEmptyObject(data.selectedTransactions) && !data.shouldTurnOffSelectionMode) { - return; - } - setSearchContextData((prevState) => ({ - ...prevState, - shouldTurnOffSelectionMode, - selectedTransactions: {}, - selectedReports: [], - })); - - // Unselect all transactions and hide the "select all matching items" option - shouldShowSelectAllMatchingItems(false); - selectAllMatchingItems(false); - }, - [setSelectedTransactions], - ); - - const removeTransaction: SearchContextProps['removeTransaction'] = useCallback( - (transactionID) => { - if (!transactionID) { - return; - } - const selectedTransactionIDs = searchContextData.selectedTransactionIDs; - - if (!isEmptyObject(searchContextData.selectedTransactions)) { - const newSelectedTransactions = Object.entries(searchContextData.selectedTransactions).reduce((acc, [key, value]) => { - if (key === transactionID) { - return acc; - } - acc[key] = value; - return acc; - }, {} as SelectedTransactions); - - setSearchContextData((prevState) => ({ - ...prevState, - selectedTransactions: newSelectedTransactions, - })); - } - - if (selectedTransactionIDs.length > 0) { - setSearchContextData((prevState) => ({ - ...prevState, - selectedTransactionIDs: selectedTransactionIDs.filter((ID) => transactionID !== ID), - })); - } - }, - [searchContextData.selectedTransactionIDs, searchContextData.selectedTransactions], - ); - const setShouldResetSearchQuery = useCallback((shouldReset: boolean) => { setSearchContextData((prevState) => ({ ...prevState, @@ -273,35 +127,22 @@ function SearchContextProvider({children}: ChildrenProps) { ...searchContextData, currentSearchResults, shouldUseLiveData, - removeTransaction, setCurrentSearchHashAndKey, setCurrentSearchQueryJSON, - setSelectedTransactions, - clearSelectedTransactions, shouldShowFiltersBarLoading, setShouldShowFiltersBarLoading, lastSearchType, setLastSearchType, - showSelectAllMatchingItems, - shouldShowSelectAllMatchingItems, - areAllMatchingItemsSelected, - selectAllMatchingItems, setShouldResetSearchQuery, }), [ searchContextData, currentSearchResults, shouldUseLiveData, - removeTransaction, setCurrentSearchHashAndKey, setCurrentSearchQueryJSON, - setSelectedTransactions, - clearSelectedTransactions, shouldShowFiltersBarLoading, lastSearchType, - shouldShowSelectAllMatchingItems, - showSelectAllMatchingItems, - areAllMatchingItemsSelected, setShouldResetSearchQuery, ], ); @@ -309,11 +150,6 @@ function SearchContextProvider({children}: ChildrenProps) { return {children}; } -/** - * Note: `selectedTransactionIDs` and `selectedTransactions` are two separate properties. - * Setting or clearing one of them does not influence the other. - * IDs should be used if transaction details are not required. - */ function useSearchContext() { return useContext(SearchContext); } diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index f8e7bc5c4dfd7..08b89f251cd08 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -156,11 +156,7 @@ type SearchContextData = { currentSearchKey: SearchKey | undefined; currentSearchQueryJSON: SearchQueryJSON | undefined; currentSearchResults: SearchResults | undefined; - selectedTransactions: SelectedTransactions; - selectedTransactionIDs: string[]; - selectedReports: SelectedReports[]; isOnSearch: boolean; - shouldTurnOffSelectionMode: boolean; shouldResetSearchQuery: boolean; }; @@ -191,25 +187,10 @@ type SearchContextProps = SearchContextData & { shouldUseLiveData: boolean; setCurrentSearchHashAndKey: (hash: number, key: SearchKey | undefined) => void; setCurrentSearchQueryJSON: (searchQueryJSON: SearchQueryJSON | undefined) => void; - /** If you want to set `selectedTransactionIDs`, pass an array as the first argument, object/record otherwise */ - setSelectedTransactions: { - (selectedTransactionIDs: string[], unused?: undefined): void; - (selectedTransactions: SelectedTransactions, data: TransactionListItemType[] | TransactionGroupListItemType[] | ReportActionListItemType[] | TaskListItemType[]): void; - }; - /** If you want to clear `selectedTransactionIDs`, pass `true` as the first argument */ - clearSelectedTransactions: { - (hash?: number, shouldTurnOffSelectionMode?: boolean): void; - (clearIDs: true, unused?: undefined): void; - }; - removeTransaction: (transactionID: string | undefined) => void; shouldShowFiltersBarLoading: boolean; setShouldShowFiltersBarLoading: (shouldShow: boolean) => void; setLastSearchType: (type: string | undefined) => void; lastSearchType: string | undefined; - showSelectAllMatchingItems: boolean; - shouldShowSelectAllMatchingItems: (shouldShow: boolean) => void; - areAllMatchingItemsSelected: boolean; - selectAllMatchingItems: (on: boolean) => void; setShouldResetSearchQuery: (shouldReset: boolean) => void; }; diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index a64d510efdf6f..aaf5461c2919a 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -3,7 +3,7 @@ import {InteractionManager} from 'react-native'; import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import type {SearchContextProps} from '@components/Search/types'; +import type {SearchContextProps, SearchSelectionContextProps} from '@components/Search/types'; import * as API from '@libs/API'; import type {CompleteSplitBillParams, RevertSplitTransactionParams, SplitBillParams, SplitTransactionParams, SplitTransactionSplitsParam, StartSplitBillParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; @@ -106,6 +106,8 @@ type UpdateSplitTransactionsParams = { splitExpensesTotal?: number; }; searchContext?: Partial; + /** Called to clear search selection after saving (from SearchSelectionContext). */ + clearSelectedTransactions?: SearchSelectionContextProps['clearSelectedTransactions']; policyCategories: OnyxTypes.PolicyCategories | undefined; policy: OnyxTypes.Policy | undefined; policyRecentlyUsedCategories: OnyxTypes.RecentlyUsedCategories | undefined; @@ -1645,9 +1647,9 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac const lastRoute = searchFullScreenRoutes?.state?.routes?.at(-1); const isUserOnSearchPage = isSearchTopmostFullScreenRoute() && lastRoute?.name === SCREENS.SEARCH.ROOT; if (isUserOnSearchPage) { - params?.searchContext?.clearSelectedTransactions?.(undefined, true); + params?.clearSelectedTransactions?.(undefined, true); } else { - params?.searchContext?.clearSelectedTransactions?.(true); + params?.clearSelectedTransactions?.(true); } if (isSearchPageTopmostFullScreenRoute || !transactionReport?.parentReportID) { diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 51cfb324d0c67..69d3f8fe3345e 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -11,6 +11,7 @@ import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScreenWrapper from '@components/ScreenWrapper'; import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import type {SplitListItemType} from '@components/SelectionList/ListItem/types'; import TabSelector from '@components/TabSelector/TabSelector'; import useAllTransactions from '@hooks/useAllTransactions'; @@ -72,6 +73,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const [errorMessage, setErrorMessage] = React.useState(''); const searchContext = useSearchContext(); + const {clearSelectedTransactions} = useSearchSelectionContext(); const {getCurrencySymbol} = useCurrencyListActions(); @@ -248,6 +250,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { splitExpensesTotal: draftTransaction?.comment?.splitExpensesTotal ?? 0, }, searchContext, + clearSelectedTransactions, policyCategories, policy: expenseReportPolicy, policyRecentlyUsedCategories, From 7958288480566cea71b98cc58047f46aa5ceb45f Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 23 Feb 2026 17:16:03 -0700 Subject: [PATCH 4/9] Migrate selection consumers from SearchContext to SearchSelectionContext Co-authored-by: Cursor --- src/components/MoneyReportHeader.tsx | 4 +++- src/components/MoneyRequestHeader.tsx | 4 +++- .../MoneyRequestReportActionsList.tsx | 4 ++-- .../MoneyRequestReportTransactionList.tsx | 4 ++-- .../Search/SearchPageHeader/SearchFiltersBar.tsx | 4 +++- .../Search/SearchPageHeader/SearchPageHeader.tsx | 4 ++-- src/components/Search/index.tsx | 15 +++++++++------ .../Search/TransactionGroupListItem.tsx | 4 ++-- src/hooks/useFilterSelectedTransactions.ts | 4 ++-- src/hooks/useSearchTypeMenu.tsx | 4 ++-- src/hooks/useSelectedTransactionsActions.ts | 4 +++- src/pages/NewReportWorkspaceSelectionPage.tsx | 4 ++-- src/pages/ReportDetailsPage.tsx | 3 ++- src/pages/Search/SearchPage.tsx | 8 +++----- src/pages/Search/SearchPageNarrow.tsx | 4 ++-- .../Search/SearchTransactionsChangeReport.tsx | 4 ++-- src/pages/Search/SearchTypeMenu.tsx | 4 ++-- src/pages/iou/RejectReasonPage.tsx | 4 ++-- .../iou/request/step/IOURequestEditReport.tsx | 4 ++-- .../iou/request/step/IOURequestStepReport.tsx | 4 ++-- 20 files changed, 52 insertions(+), 42 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index d5bf29943fb4d..cb4a2445bbd89 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -164,6 +164,7 @@ import type {PopoverMenuItem} from './PopoverMenu'; import {PressableWithFeedback} from './Pressable'; import ProcessMoneyReportHoldMenu from './ProcessMoneyReportHoldMenu'; import {useSearchContext} from './Search/SearchContext'; +import {useSearchSelectionContext} from './Search/SearchSelectionContext'; import AnimatedSettlementButton from './SettlementButton/AnimatedSettlementButton'; import Text from './Text'; @@ -427,7 +428,8 @@ function MoneyReportHeader({ typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK > | null>(null); - const {selectedTransactionIDs, removeTransaction, clearSelectedTransactions, currentSearchQueryJSON, currentSearchKey, currentSearchHash, currentSearchResults} = useSearchContext(); + const {currentSearchQueryJSON, currentSearchKey, currentSearchHash, currentSearchResults} = useSearchContext(); + const {selectedTransactionIDs, removeTransaction, clearSelectedTransactions} = useSearchSelectionContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); const [network] = useOnyx(ONYXKEYS.NETWORK); diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index 805c6b04d6559..5b43a58c793ed 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -87,6 +87,7 @@ import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar'; import MoneyRequestReportTransactionsNavigation from './MoneyRequestReportView/MoneyRequestReportTransactionsNavigation'; import {usePersonalDetails} from './OnyxListItemProvider'; import {useSearchContext} from './Search/SearchContext'; +import {useSearchSelectionContext} from './Search/SearchSelectionContext'; import {useWideRHPState} from './WideRHPContextProvider'; type MoneyRequestHeaderProps = { @@ -149,7 +150,8 @@ function MoneyRequestHeader({report, parentReportAction, policy, onBackButtonPre const isOnHold = isOnHoldTransactionUtils(transaction); const isDuplicate = isDuplicateTransactionUtils(transaction, email ?? '', accountID, report, policy, transactionViolations); const reportID = report?.reportID; - const {removeTransaction, currentSearchHash} = useSearchContext(); + const {currentSearchHash} = useSearchContext(); + const {removeTransaction} = useSearchSelectionContext(); const {isExpenseSplit} = getOriginalTransactionWithSplitInfo(transaction, originalTransaction); const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index d3694c553ed33..55f112b6af4a4 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -17,7 +17,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {PressableWithFeedback} from '@components/Pressable'; import ScrollView from '@components/ScrollView'; -import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import Text from '@components/Text'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -196,7 +196,7 @@ function MoneyRequestReportActionsList({ const [enableScrollToEnd, setEnableScrollToEnd] = useState(false); const [lastActionEventId, setLastActionEventId] = useState(''); - const {selectedTransactionIDs, setSelectedTransactions, clearSelectedTransactions} = useSearchContext(); + const {selectedTransactionIDs, setSelectedTransactions, clearSelectedTransactions} = useSearchSelectionContext(); useFilterSelectedTransactions(transactions); diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx index ebfc919e2ead3..1f233504857ad 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx @@ -8,7 +8,7 @@ import Checkbox from '@components/Checkbox'; import MenuItem from '@components/MenuItem'; import Modal from '@components/Modal'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import type {SortOrder} from '@components/Search/types'; import Text from '@components/Text'; import {useWideRHPActions} from '@components/WideRHPContextProvider'; @@ -207,7 +207,7 @@ function MoneyRequestReportTransactionList({ return hasPendingDeletionTransaction || transactions.some(getTransactionPendingAction); }, [hasPendingDeletionTransaction, transactions]); - const {selectedTransactionIDs, setSelectedTransactions, clearSelectedTransactions} = useSearchContext(); + const {selectedTransactionIDs, setSelectedTransactions, clearSelectedTransactions} = useSearchSelectionContext(); useHandleSelectionMode(selectedTransactionIDs); const isMobileSelectionModeEnabled = useMobileSelectionMode(); diff --git a/src/components/Search/SearchPageHeader/SearchFiltersBar.tsx b/src/components/Search/SearchPageHeader/SearchFiltersBar.tsx index 5c77e785e5b4d..663fa8fd3fab0 100644 --- a/src/components/Search/SearchPageHeader/SearchFiltersBar.tsx +++ b/src/components/Search/SearchPageHeader/SearchFiltersBar.tsx @@ -21,6 +21,7 @@ import MultiSelectPopup from '@components/Search/FilterDropdowns/MultiSelectPopu import SingleSelectPopup from '@components/Search/FilterDropdowns/SingleSelectPopup'; import UserSelectPopup from '@components/Search/FilterDropdowns/UserSelectPopup'; import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import type {BankAccountMenuItem, SearchDateFilterKeys, SearchQueryJSON, SingularSearchStatus} from '@components/Search/types'; import SearchFiltersSkeleton from '@components/Skeletons/SearchFiltersSkeleton'; import useAdvancedSearchFilters from '@hooks/useAdvancedSearchFilters'; @@ -161,7 +162,8 @@ function SearchFiltersBar({ const personalDetails = usePersonalDetails(); const filterFormValues = useFilterFormValues(queryJSON); const {shouldUseNarrowLayout, isLargeScreenWidth} = useResponsiveLayout(); - const {selectedTransactions, selectAllMatchingItems, areAllMatchingItemsSelected, showSelectAllMatchingItems, shouldShowFiltersBarLoading, currentSearchResults} = useSearchContext(); + const {shouldShowFiltersBarLoading, currentSearchResults} = useSearchContext(); + const {selectedTransactions, selectAllMatchingItems, areAllMatchingItemsSelected, showSelectAllMatchingItems} = useSearchSelectionContext(); const {currencyList} = useCurrencyListState(); const {getCurrencySymbol} = useCurrencyListActions(); diff --git a/src/components/Search/SearchPageHeader/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader/SearchPageHeader.tsx index 0af1525aaefbb..9a25d0d3ad263 100644 --- a/src/components/Search/SearchPageHeader/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader/SearchPageHeader.tsx @@ -1,7 +1,7 @@ import React, {useMemo} from 'react'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import type {PaymentMethodType} from '@components/KYCWall/types'; -import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import type {BankAccountMenuItem, SearchQueryJSON} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -44,7 +44,7 @@ function SearchPageHeader({ latestBankItems, }: SearchPageHeaderProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {selectedTransactions} = useSearchContext(); + const {selectedTransactions} = useSearchSelectionContext(); const {translate} = useLocalize(); const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 0dc8d7a81d111..0ad1afa5ec120 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -82,6 +82,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import arraysEqual from '@src/utils/arraysEqual'; import SearchChartView from './SearchChartView'; import {useSearchContext} from './SearchContext'; +import {useSearchSelectionContext} from './SearchSelectionContext'; import SearchList from './SearchList'; import {SearchScopeProvider} from './SearchScopeProvider'; import type {SearchColumnType, SearchParams, SearchQueryJSON, SelectedTransactionInfo, SelectedTransactions, SortOrder} from './types'; @@ -231,19 +232,21 @@ function Search({ currentSearchHash, setCurrentSearchHashAndKey, setCurrentSearchQueryJSON, + setShouldShowFiltersBarLoading, + lastSearchType, + shouldResetSearchQuery, + setShouldResetSearchQuery, + shouldUseLiveData, + } = useSearchContext(); + const { setSelectedTransactions, selectedTransactions, clearSelectedTransactions, shouldTurnOffSelectionMode, - setShouldShowFiltersBarLoading, - lastSearchType, shouldShowSelectAllMatchingItems, areAllMatchingItemsSelected, selectAllMatchingItems, - shouldResetSearchQuery, - setShouldResetSearchQuery, - shouldUseLiveData, - } = useSearchContext(); + } = useSearchSelectionContext(); const [offset, setOffset] = useState(0); const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); diff --git a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx index b21a3df8a3167..1adb7f335bb87 100644 --- a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx @@ -8,7 +8,7 @@ import AnimatedCollapsible from '@components/AnimatedCollapsible'; import {getButtonRole} from '@components/Button/utils'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {PressableWithFeedback} from '@components/Pressable'; -import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import type {SearchGroupBy} from '@components/Search/types'; import type { ListItem, @@ -83,7 +83,7 @@ function TransactionGroupListItem({ const theme = useTheme(); const styles = useThemeStyles(); const {translate, formatPhoneNumber} = useLocalize(); - const {selectedTransactions} = useSearchContext(); + const {selectedTransactions} = useSearchSelectionContext(); const {isLargeScreenWidth} = useResponsiveLayout(); const currentUserDetails = useCurrentUserPersonalDetails(); diff --git a/src/hooks/useFilterSelectedTransactions.ts b/src/hooks/useFilterSelectedTransactions.ts index b180dcfd5e71a..f2ab5d064b871 100644 --- a/src/hooks/useFilterSelectedTransactions.ts +++ b/src/hooks/useFilterSelectedTransactions.ts @@ -1,5 +1,5 @@ import {useEffect, useMemo} from 'react'; -import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import type {Transaction} from '@src/types/onyx'; /** @@ -9,7 +9,7 @@ import type {Transaction} from '@src/types/onyx'; * @param transactions - The current list of transactions */ function useFilterSelectedTransactions(transactions: Transaction[]) { - const {selectedTransactionIDs, setSelectedTransactions} = useSearchContext(); + const {selectedTransactionIDs, setSelectedTransactions} = useSearchSelectionContext(); const transactionIDs = useMemo(() => transactions.map((transaction) => transaction.transactionID), [transactions]); const filteredSelectedTransactionIDs = useMemo(() => selectedTransactionIDs.filter((id) => transactionIDs.includes(id)), [selectedTransactionIDs, transactionIDs]); diff --git a/src/hooks/useSearchTypeMenu.tsx b/src/hooks/useSearchTypeMenu.tsx index dc26046d64354..caa88428bc04e 100644 --- a/src/hooks/useSearchTypeMenu.tsx +++ b/src/hooks/useSearchTypeMenu.tsx @@ -3,7 +3,7 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react'; import type {OnyxCollection} from 'react-native-onyx'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import type {PopoverMenuItem} from '@components/PopoverMenu'; -import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import type {SearchQueryJSON} from '@components/Search/types'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; import {setSearchContext} from '@libs/actions/Search'; @@ -41,7 +41,7 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { const {windowHeight} = useWindowDimensions(); const {translate} = useLocalize(); const {typeMenuSections, shouldShowSuggestedSearchSkeleton} = useSearchTypeMenuSections(); - const {clearSelectedTransactions} = useSearchContext(); + const {clearSelectedTransactions} = useSearchSelectionContext(); const {showDeleteModal} = useDeleteSavedSearch(); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const personalDetails = usePersonalDetails(); diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index a8f657a11ba68..e0d758776d1be 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -4,6 +4,7 @@ import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import {unholdRequest} from '@libs/actions/IOU/Hold'; import {initSplitExpense} from '@libs/actions/IOU/Split'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; @@ -75,7 +76,8 @@ function useSelectedTransactionsActions({ const {isOffline} = useNetworkWithOfflineStatus(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); - const {selectedTransactionIDs, clearSelectedTransactions, currentSearchHash, selectedTransactions: selectedTransactionsMeta} = useSearchContext(); + const {currentSearchHash} = useSearchContext(); + const {selectedTransactionIDs, clearSelectedTransactions, selectedTransactions: selectedTransactionsMeta} = useSearchSelectionContext(); const allTransactions = useAllTransactions(); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); diff --git a/src/pages/NewReportWorkspaceSelectionPage.tsx b/src/pages/NewReportWorkspaceSelectionPage.tsx index d8cd8d9a5d3e7..46906fd589fb2 100644 --- a/src/pages/NewReportWorkspaceSelectionPage.tsx +++ b/src/pages/NewReportWorkspaceSelectionPage.tsx @@ -4,7 +4,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; -import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import SelectionList from '@components/SelectionList'; import UserListItem from '@components/SelectionList/ListItem/UserListItem'; import type {ListItem} from '@components/SelectionList/types'; @@ -53,7 +53,7 @@ function NewReportWorkspaceSelectionPage({route}: NewReportWorkspaceSelectionPag const {isMovingExpenses, backTo} = route.params ?? {}; const {isOffline} = useNetwork(); const icons = useMemoizedLazyExpensifyIcons(['FallbackWorkspaceAvatar']); - const {selectedTransactions, selectedTransactionIDs, clearSelectedTransactions} = useSearchContext(); + const {selectedTransactions, selectedTransactionIDs, clearSelectedTransactions} = useSearchSelectionContext(); const styles = useThemeStyles(); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const {translate, localeCompare} = useLocalize(); diff --git a/src/pages/ReportDetailsPage.tsx b/src/pages/ReportDetailsPage.tsx index 22500592497fa..df4bc5d7af8f3 100644 --- a/src/pages/ReportDetailsPage.tsx +++ b/src/pages/ReportDetailsPage.tsx @@ -21,6 +21,7 @@ import RoomHeaderAvatars from '@components/RoomHeaderAvatars'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import {SUPER_WIDE_RIGHT_MODALS} from '@components/WideRHPContextProvider/WIDE_RIGHT_MODALS'; import useActivePolicy from '@hooks/useActivePolicy'; import useAncestors from '@hooks/useAncestors'; @@ -177,7 +178,7 @@ function ReportDetailsPage({policy, report, route, reportMetadata}: ReportDetail const {reportActions} = usePaginatedReportActions(report.reportID); const [reportActionsForOriginalReportID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`); - const {removeTransaction} = useSearchContext(); + const {removeTransaction} = useSearchSelectionContext(); const transactionThreadReportID = useMemo(() => getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline), [reportActions, isOffline, report, chatReport]); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 375b46cf1ade5..f8350d400efd6 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -16,6 +16,7 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import type {SearchHeaderOptionValue} from '@components/Search/SearchPageHeader/SearchPageHeader'; import type {PaymentData, SearchParams} from '@components/Search/types'; import {usePlaybackActionsContext} from '@components/VideoPlayerContexts/PlaybackContext'; @@ -103,17 +104,14 @@ function SearchPage({route}: SearchPageProps) { const {isOffline} = useNetwork(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); + const {lastSearchType, setLastSearchType, currentSearchKey, currentSearchResults} = useSearchContext(); const { selectedTransactions, clearSelectedTransactions, selectedReports, - lastSearchType, - setLastSearchType, areAllMatchingItemsSelected, selectAllMatchingItems, - currentSearchKey, - currentSearchResults, - } = useSearchContext(); + } = useSearchSelectionContext(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const isMobileSelectionModeEnabled = useMobileSelectionMode(clearSelectedTransactions); const allTransactions = useAllTransactions(); diff --git a/src/pages/Search/SearchPageNarrow.tsx b/src/pages/Search/SearchPageNarrow.tsx index 94ecdfa219e83..badf9c55c7324 100644 --- a/src/pages/Search/SearchPageNarrow.tsx +++ b/src/pages/Search/SearchPageNarrow.tsx @@ -14,7 +14,7 @@ import TopBar from '@components/Navigation/TopBar'; import ScreenWrapper from '@components/ScreenWrapper'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import Search from '@components/Search'; -import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import SearchPageFooter from '@components/Search/SearchPageFooter'; import SearchFiltersBar from '@components/Search/SearchPageHeader/SearchFiltersBar'; import SearchPageHeader from '@components/Search/SearchPageHeader/SearchPageHeader'; @@ -79,7 +79,7 @@ function SearchPageNarrow({ const {windowHeight} = useWindowDimensions(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {clearSelectedTransactions} = useSearchContext(); + const {clearSelectedTransactions} = useSearchSelectionContext(); const [searchRouterListVisible, setSearchRouterListVisible] = useState(false); const {isOffline} = useNetwork(); // Controls the visibility of the educational tooltip based on user scrolling. diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 60dc115a6fca3..db17d83d58efd 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -2,7 +2,7 @@ import React, {useMemo} from 'react'; import {InteractionManager} from 'react-native'; import type {OnyxCollection} from 'react-native-onyx'; import {usePersonalDetails, useSession} from '@components/OnyxListItemProvider'; -import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import type {ListItem} from '@components/SelectionListWithSections/types'; import useConditionalCreateEmptyReportConfirmation from '@hooks/useConditionalCreateEmptyReportConfirmation'; import useHasPerDiemTransactions from '@hooks/useHasPerDiemTransactions'; @@ -27,7 +27,7 @@ type TransactionGroupListItem = ListItem & { }; function SearchTransactionsChangeReport() { - const {selectedTransactions, clearSelectedTransactions} = useSearchContext(); + const {selectedTransactions, clearSelectedTransactions} = useSearchSelectionContext(); const selectedTransactionsKeys = useMemo(() => Object.keys(selectedTransactions), [selectedTransactions]); const transactions = useMemo( () => diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index 3c4572d2e18a7..058aab236ad25 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -11,7 +11,7 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {useProductTrainingContext} from '@components/ProductTrainingContext'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import ScrollView from '@components/ScrollView'; -import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import type {SearchQueryJSON} from '@components/Search/types'; import Text from '@components/Text'; import useDeleteSavedSearch from '@hooks/useDeleteSavedSearch'; @@ -84,7 +84,7 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { const feedKeysWithCards = useFeedKeysWithAssignedCards(); const taxRates = getAllTaxRates(allPolicies); const [currentUserAccountID = -1] = useOnyx(ONYXKEYS.SESSION, {selector: accountIDSelector}); - const {clearSelectedTransactions} = useSearchContext(); + const {clearSelectedTransactions} = useSearchSelectionContext(); const [reportCounts = CONST.EMPTY_TODOS_REPORT_COUNTS] = useOnyx(ONYXKEYS.DERIVED.TODOS, {selector: todosReportCountsSelector}); const flattenedMenuItems = useMemo(() => typeMenuSections.flatMap((section) => section.menuItems), [typeMenuSections]); diff --git a/src/pages/iou/RejectReasonPage.tsx b/src/pages/iou/RejectReasonPage.tsx index 7a9ecff61849f..bac9d30179460 100644 --- a/src/pages/iou/RejectReasonPage.tsx +++ b/src/pages/iou/RejectReasonPage.tsx @@ -2,7 +2,7 @@ import {getReportPolicyID} from '@selectors/Report'; import React, {useCallback, useEffect} from 'react'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; -import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import {useWideRHPState} from '@components/WideRHPContextProvider'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; @@ -29,7 +29,7 @@ function RejectReasonPage({route}: RejectReasonPageProps) { const {translate} = useLocalize(); const {transactionID, reportID, backTo} = route.params; - const {removeTransaction} = useSearchContext(); + const {removeTransaction} = useSearchSelectionContext(); const [reportPolicyID] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(reportID)}`, {selector: getReportPolicyID}); const policy = usePolicy(reportPolicyID); const {superWideRHPRouteKeys} = useWideRHPState(); diff --git a/src/pages/iou/request/step/IOURequestEditReport.tsx b/src/pages/iou/request/step/IOURequestEditReport.tsx index 5d42dad30f910..cb13e36f4344b 100644 --- a/src/pages/iou/request/step/IOURequestEditReport.tsx +++ b/src/pages/iou/request/step/IOURequestEditReport.tsx @@ -1,7 +1,7 @@ import React, {useMemo} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {usePersonalDetails, useSession} from '@components/OnyxListItemProvider'; -import {useSearchContext} from '@components/Search/SearchContext'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; import type {ListItem} from '@components/SelectionListWithSections/types'; import useConditionalCreateEmptyReportConfirmation from '@hooks/useConditionalCreateEmptyReportConfirmation'; import useHasPerDiemTransactions from '@hooks/useHasPerDiemTransactions'; @@ -34,7 +34,7 @@ type IOURequestEditReportProps = WithWritableReportOrNotFoundProps Date: Mon, 23 Feb 2026 17:23:10 -0700 Subject: [PATCH 5/9] Add useSearchFooter hook; SearchPageFooter uses it, remove footerData from SearchPage and layouts Co-authored-by: Cursor --- src/components/Search/SearchPageFooter.tsx | 14 +++--- src/hooks/useSearchFooter.ts | 56 ++++++++++++++++++++++ src/pages/Search/SearchPage.tsx | 38 +-------------- src/pages/Search/SearchPageNarrow.tsx | 16 +------ src/pages/Search/SearchPageWide.tsx | 24 +--------- 5 files changed, 67 insertions(+), 81 deletions(-) create mode 100644 src/hooks/useSearchFooter.ts diff --git a/src/components/Search/SearchPageFooter.tsx b/src/components/Search/SearchPageFooter.tsx index 95ba35a3214dd..9ecb9216ca0a6 100644 --- a/src/components/Search/SearchPageFooter.tsx +++ b/src/components/Search/SearchPageFooter.tsx @@ -7,15 +7,11 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useSearchFooter from '@hooks/useSearchFooter'; import {convertToDisplayString} from '@libs/CurrencyUtils'; -type SearchPageFooterProps = { - count: number | undefined; - total: number | undefined; - currency: string | undefined; -}; - -function SearchPageFooter({count, total, currency}: SearchPageFooterProps) { +function SearchPageFooter() { + const {count, total, currency, shouldShow} = useSearchFooter(); const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -26,6 +22,10 @@ function SearchPageFooter({count, total, currency}: SearchPageFooterProps) { const valueTextStyle = useMemo(() => (isOffline ? [styles.textLabelSupporting, styles.labelStrong] : [styles.labelStrong]), [isOffline, styles]); + if (!shouldShow) { + return null; + } + return ( Object.keys(selectedTransactions), [selectedTransactions]); + const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchHash, true); + const metadata = currentSearchResults?.search; + + return useMemo(() => { + if (!shouldAllowFooterTotals && selectedTransactionsKeys.length === 0) { + return { + count: undefined, + total: undefined, + currency: undefined, + shouldShow: false, + }; + } + + const shouldUseClientTotal = selectedTransactionsKeys.length > 0 || !metadata?.count || (selectedTransactionsKeys.length > 0 && !areAllMatchingItemsSelected); + const selectedTransactionItems = Object.values(selectedTransactions); + const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.groupCurrency; + const numberOfExpense = shouldUseClientTotal + ? selectedTransactionsKeys.reduce((count, key) => { + const item = selectedTransactions[key]; + if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) { + return count; + } + return count + 1; + }, 0) + : metadata?.count; + const total = shouldUseClientTotal ? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? 0), 0) : metadata?.total; + + const shouldShow = selectedTransactionsKeys.length > 0 || (shouldAllowFooterTotals && !!metadata?.count); + + return { + count: numberOfExpense, + total, + currency, + shouldShow, + }; + }, [areAllMatchingItemsSelected, metadata?.count, metadata?.currency, metadata?.total, selectedTransactions, selectedTransactionsKeys, shouldAllowFooterTotals]); +} + +export default useSearchFooter; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index f8350d400efd6..1375029631984 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -35,7 +35,6 @@ import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useReceiptScanDrop from '@hooks/useReceiptScanDrop'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -105,13 +104,7 @@ function SearchPage({route}: SearchPageProps) { const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const {lastSearchType, setLastSearchType, currentSearchKey, currentSearchResults} = useSearchContext(); - const { - selectedTransactions, - clearSelectedTransactions, - selectedReports, - areAllMatchingItemsSelected, - selectAllMatchingItems, - } = useSearchSelectionContext(); + const {selectedTransactions, clearSelectedTransactions, selectedReports, areAllMatchingItemsSelected, selectAllMatchingItems} = useSearchSelectionContext(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const isMobileSelectionModeEnabled = useMobileSelectionMode(clearSelectedTransactions); const allTransactions = useAllTransactions(); @@ -1122,8 +1115,6 @@ function SearchPage({route}: SearchPageProps) { const {resetVideoPlayerData} = usePlaybackActionsContext(); const metadata = searchResults?.search; - const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, queryJSON?.hash, true); - const shouldShowFooter = selectedTransactionsKeys.length > 0 || (shouldAllowFooterTotals && !!metadata?.count); // Handles video player cleanup: // 1. On mount: Resets player if navigating from report screen @@ -1164,29 +1155,6 @@ function SearchPage({route}: SearchPageProps) { } }, []); - const footerData = useMemo(() => { - if (!shouldAllowFooterTotals && selectedTransactionsKeys.length === 0) { - return {count: undefined, total: undefined, currency: undefined}; - } - - const shouldUseClientTotal = selectedTransactionsKeys.length > 0 || !metadata?.count || (selectedTransactionsKeys.length > 0 && !areAllMatchingItemsSelected); - const selectedTransactionItems = Object.values(selectedTransactions); - const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.groupCurrency; - const numberOfExpense = shouldUseClientTotal - ? selectedTransactionsKeys.reduce((count, key) => { - const item = selectedTransactions[key]; - // Skip empty reports (where key is the reportID itself, not a transactionID) - if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) { - return count; - } - return count + 1; - }, 0) - : metadata?.count; - const total = shouldUseClientTotal ? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? 0), 0) : metadata?.total; - - return {count: numberOfExpense, total, currency}; - }, [areAllMatchingItemsSelected, metadata?.count, metadata?.currency, metadata?.total, selectedTransactions, selectedTransactionsKeys, shouldAllowFooterTotals]); - const onSortPressedCallback = useCallback(() => { setIsSorting(true); }, []); @@ -1243,12 +1211,10 @@ function SearchPage({route}: SearchPageProps) { headerButtonsOptions={headerButtonsOptions} searchResults={searchResults} isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} - footerData={footerData} currentSelectedPolicyID={selectedPolicyIDs?.at(0)} currentSelectedReportID={selectedTransactionReportIDs?.at(0) ?? selectedReportIDs?.at(0)} confirmPayment={stableOnBulkPaySelected} latestBankItems={latestBankItems} - shouldShowFooter={shouldShowFooter} /> )} diff --git a/src/pages/Search/SearchPageNarrow.tsx b/src/pages/Search/SearchPageNarrow.tsx index badf9c55c7324..f637dc087ce29 100644 --- a/src/pages/Search/SearchPageNarrow.tsx +++ b/src/pages/Search/SearchPageNarrow.tsx @@ -49,16 +49,10 @@ type SearchPageNarrowProps = { headerButtonsOptions: Array>; searchResults?: SearchResults; isMobileSelectionModeEnabled: boolean; - footerData: { - count: number | undefined; - total: number | undefined; - currency: string | undefined; - }; currentSelectedPolicyID?: string | undefined; currentSelectedReportID?: string | undefined; confirmPayment?: (paymentType: PaymentMethodType | undefined) => void; latestBankItems?: BankAccountMenuItem[] | undefined; - shouldShowFooter: boolean; }; function SearchPageNarrow({ @@ -67,12 +61,10 @@ function SearchPageNarrow({ searchResults, isMobileSelectionModeEnabled, metadata, - footerData, currentSelectedPolicyID, currentSelectedReportID, latestBankItems, confirmPayment, - shouldShowFooter, }: SearchPageNarrowProps) { const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -274,13 +266,7 @@ function SearchPageNarrow({ /> )} - {shouldShowFooter && !searchRouterListVisible && ( - - )} + {!searchRouterListVisible && } ); diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index 6d6296bf0ed6e..86e0599ceb967 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -30,11 +30,6 @@ type SearchPageWideProps = { searchRequestResponseStatusCode: number | null; isMobileSelectionModeEnabled: boolean; headerButtonsOptions: Array>; - footerData: { - count: number | undefined; - total: number | undefined; - currency: string | undefined; - }; selectedPolicyIDs: Array; selectedTransactionReportIDs: string[]; selectedReportIDs: string[]; @@ -46,7 +41,6 @@ type SearchPageWideProps = { initScanRequest: (e: DragEvent) => void; PDFValidationComponent: React.ReactNode; ErrorModal: React.ReactNode; - shouldShowFooter: boolean; }; function SearchPageWide({ @@ -55,7 +49,6 @@ function SearchPageWide({ searchRequestResponseStatusCode, isMobileSelectionModeEnabled, headerButtonsOptions, - footerData, selectedPolicyIDs, selectedTransactionReportIDs, selectedReportIDs, @@ -67,19 +60,12 @@ function SearchPageWide({ initScanRequest, PDFValidationComponent, ErrorModal, - shouldShowFooter, }: SearchPageWideProps) { const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); - const offlineIndicatorStyle = useMemo(() => { - if (shouldShowFooter) { - return [styles.mtAuto, styles.pAbsolute, styles.h10, styles.b0]; - } - - return [styles.mtAuto]; - }, [shouldShowFooter, styles]); + const offlineIndicatorStyle = useMemo(() => [styles.mtAuto], [styles]); const expensifyIcons = useMemoizedLazyExpensifyIcons(['SmartScan']); const handleOnBackButtonPress = () => Navigation.goBack(ROUTES.SEARCH_ROOT.getRoute({query: buildCannedSearchQuery()})); @@ -126,13 +112,7 @@ function SearchPageWide({ onSortPressedCallback={onSortPressedCallback} searchRequestResponseStatusCode={searchRequestResponseStatusCode} /> - {shouldShowFooter && ( - - )} + Date: Mon, 23 Feb 2026 17:27:21 -0700 Subject: [PATCH 6/9] Add SearchSelectionBar; show it instead of SearchFiltersBar when selection is active Co-authored-by: Cursor --- .../SearchPageHeader/SearchSelectionBar.tsx | 135 ++++++++++++++++++ src/pages/Search/SearchPageNarrow.tsx | 25 +++- src/pages/Search/SearchPageWide.tsx | 30 ++-- 3 files changed, 174 insertions(+), 16 deletions(-) create mode 100644 src/components/Search/SearchPageHeader/SearchSelectionBar.tsx diff --git a/src/components/Search/SearchPageHeader/SearchSelectionBar.tsx b/src/components/Search/SearchPageHeader/SearchSelectionBar.tsx new file mode 100644 index 0000000000000..38a7b78471657 --- /dev/null +++ b/src/components/Search/SearchPageHeader/SearchSelectionBar.tsx @@ -0,0 +1,135 @@ +import React, {useContext} from 'react'; +import {View} from 'react-native'; +import Button from '@components/Button'; +import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; +import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; +import KYCWall from '@components/KYCWall'; +import {KYCWallContext} from '@components/KYCWall/KYCWallContext'; +import type {PaymentMethodType} from '@components/KYCWall/types'; +import {LockedAccountContext} from '@components/LockedAccountModalProvider'; +import {useSearchSelectionContext} from '@components/Search/SearchSelectionContext'; +import type {BankAccountMenuItem, SearchQueryJSON} from '@components/Search/types'; +import {handleBulkPayItemSelected} from '@libs/actions/Search'; +import Navigation from '@libs/Navigation/Navigation'; +import {isExpenseReport} from '@libs/ReportUtils'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useSortedActiveAdminPolicies from '@hooks/useSortedActiveAdminPolicies'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {isUserValidatedSelector} from '@selectors/Account'; +import useOnyx from '@hooks/useOnyx'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {SearchHeaderOptionValue} from './SearchPageHeader'; + +type SearchSelectionBarProps = { + queryJSON?: SearchQueryJSON; + headerButtonsOptions: Array>; + currentSelectedPolicyID?: string | undefined; + currentSelectedReportID?: string | undefined; + confirmPayment?: (paymentMethod?: PaymentMethodType) => void; + latestBankItems?: BankAccountMenuItem[] | undefined; +}; + +function SearchSelectionBar({queryJSON, headerButtonsOptions, currentSelectedPolicyID, currentSelectedReportID, confirmPayment, latestBankItems}: SearchSelectionBarProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {selectedTransactions, selectAllMatchingItems, areAllMatchingItemsSelected, showSelectAllMatchingItems} = useSearchSelectionContext(); + const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); + const currentPolicy = usePolicy(currentSelectedPolicyID); + const kycWallRef = useContext(KYCWallContext); + const {isDelegateAccessRestricted} = useDelegateNoAccessState(); + const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); + const {isAccountLocked, showLockedAccountModal} = useContext(LockedAccountContext); + const activeAdminPolicies = useSortedActiveAdminPolicies(); + const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isUserValidatedSelector}); + const [selectedIOUReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${currentSelectedReportID}`); + const isCurrentSelectedExpenseReport = isExpenseReport(currentSelectedReportID); + + const isExpenseReportType = queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + const selectedItemsCount = (() => { + if (!selectedTransactions) { + return 0; + } + if (isExpenseReportType) { + const reportIDs = new Set( + Object.values(selectedTransactions) + .map((transaction) => transaction?.reportID) + .filter((reportID): reportID is string => !!reportID), + ); + return reportIDs.size; + } + return selectedTransactionsKeys.length; + })(); + + const selectionButtonText = areAllMatchingItemsSelected ? translate('search.exportAll.allMatchingItemsSelected') : translate('workspace.common.selected', {count: selectedItemsCount}); + + return ( + + confirmPayment?.(paymentType)} + > + {(triggerKYCFlow, buttonRef) => ( + + null} + shouldAlwaysShowDropdownMenu + buttonSize={CONST.DROPDOWN_BUTTON_SIZE.SMALL} + customText={selectionButtonText} + options={headerButtonsOptions} + onSubItemSelected={(subItem) => + handleBulkPayItemSelected({ + item: subItem, + triggerKYCFlow, + isAccountLocked, + showLockedAccountModal, + policy: currentPolicy, + latestBankItems, + activeAdminPolicies, + isUserValidated, + isDelegateAccessRestricted, + showDelegateNoAccessModal, + confirmPayment, + }) + } + isSplitButton={false} + buttonRef={buttonRef} + anchorAlignment={{ + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, + }} + sentryLabel={CONST.SENTRY_LABEL.SEARCH.BULK_ACTIONS_DROPDOWN} + /> + {!areAllMatchingItemsSelected && showSelectAllMatchingItems && ( +