From 54050e737218012231b6ea6eb0ed7d52f274aa63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 13 Jan 2026 15:51:14 +0100 Subject: [PATCH 1/9] reapply old changes --- src/ROUTES.ts | 2 +- src/SCREENS.ts | 1 + .../SearchRouter/SearchRouterContext.tsx | 8 +++--- .../SearchRouterModal/index.native.tsx | 8 ++++++ .../index.tsx} | 4 +-- .../SearchRouterPage/index.native.tsx | 28 +++++++++++++++++++ .../SearchRouter/SearchRouterPage/index.tsx | 11 ++++++++ .../toggleSearch/index.native.tsx | 12 ++++++++ .../SearchRouter/toggleSearch/index.tsx | 9 ++++++ .../ModalStackNavigators/index.tsx | 5 ++++ .../Navigators/RightModalNavigator.tsx | 4 +++ src/libs/Navigation/linkingConfig/config.ts | 4 +++ src/libs/Navigation/types.ts | 1 + 13 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 src/components/Search/SearchRouter/SearchRouterModal/index.native.tsx rename src/components/Search/SearchRouter/{SearchRouterModal.tsx => SearchRouterModal/index.tsx} (94%) create mode 100644 src/components/Search/SearchRouter/SearchRouterPage/index.native.tsx create mode 100644 src/components/Search/SearchRouter/SearchRouterPage/index.tsx create mode 100644 src/components/Search/SearchRouter/toggleSearch/index.native.tsx create mode 100644 src/components/Search/SearchRouter/toggleSearch/index.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 88889c618d084..0a9eda2a31551 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -58,7 +58,7 @@ const ROUTES = { // eslint-disable-next-line no-restricted-syntax -- Legacy route generation WORKSPACES_LIST: {route: 'workspaces', getRoute: (backTo?: string) => getUrlWithBackToParam('workspaces', backTo)}, - + SEARCH_ROUTER: 'search-router', SEARCH_ROOT: { route: 'search', getRoute: ({query, rawQuery, name}: {query: SearchQueryString; rawQuery?: SearchQueryString; name?: string}) => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index c224566c44f21..9aa03f3f0f748 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -222,6 +222,7 @@ const SCREENS = { RIGHT_MODAL: { SETTINGS: 'Settings', TWO_FACTOR_AUTH: 'TwoFactorAuth', + SEARCH_ROUTER: 'Search_Router', NEW_CHAT: 'NewChat', DETAILS: 'Details', PROFILE: 'Profile', diff --git a/src/components/Search/SearchRouter/SearchRouterContext.tsx b/src/components/Search/SearchRouter/SearchRouterContext.tsx index cc97cf3d97568..2ae0d27962b1f 100644 --- a/src/components/Search/SearchRouter/SearchRouterContext.tsx +++ b/src/components/Search/SearchRouter/SearchRouterContext.tsx @@ -8,6 +8,7 @@ import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import SCREENS from '@src/SCREENS'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import {closeSearch, openSearch} from './toggleSearch'; type SearchRouterContext = { isSearchRouterDisplayed: boolean; @@ -40,7 +41,6 @@ function SearchRouterContextProvider({children}: ChildrenProps) { const [isSearchRouterDisplayed, setIsSearchRouterDisplayed] = useState(false); const searchRouterDisplayedRef = useRef(false); const searchPageInputRef = useRef(undefined); - useEffect(() => { if (!canListenPopState) { return; @@ -76,7 +76,7 @@ function SearchRouterContextProvider({children}: ChildrenProps) { } close( () => { - setIsSearchRouterDisplayed(true); + openSearch(setIsSearchRouterDisplayed); searchRouterDisplayedRef.current = true; }, false, @@ -84,7 +84,7 @@ function SearchRouterContextProvider({children}: ChildrenProps) { ); }; const closeSearchRouter = () => { - setIsSearchRouterDisplayed(false); + closeSearch(setIsSearchRouterDisplayed); searchRouterDisplayedRef.current = false; if (isBrowserWithHistory) { const state = window.history.state as HistoryState | null; @@ -143,7 +143,7 @@ function SearchRouterContextProvider({children}: ChildrenProps) { registerSearchPageInput, unregisterSearchPageInput, }; - }, [isSearchRouterDisplayed, setIsSearchRouterDisplayed]); + }, [isSearchRouterDisplayed]); return {children}; } diff --git a/src/components/Search/SearchRouter/SearchRouterModal/index.native.tsx b/src/components/Search/SearchRouter/SearchRouterModal/index.native.tsx new file mode 100644 index 0000000000000..2654b32fcd25b --- /dev/null +++ b/src/components/Search/SearchRouter/SearchRouterModal/index.native.tsx @@ -0,0 +1,8 @@ +/** + * On native devices SearchRouter is served from SearchRouterPage, on web from SearchRouterModal. + */ +function SearchRouterModal() { + return null; +} + +export default SearchRouterModal; diff --git a/src/components/Search/SearchRouter/SearchRouterModal.tsx b/src/components/Search/SearchRouter/SearchRouterModal/index.tsx similarity index 94% rename from src/components/Search/SearchRouter/SearchRouterModal.tsx rename to src/components/Search/SearchRouter/SearchRouterModal/index.tsx index efa0ea5667f6e..16a7799d4005c 100644 --- a/src/components/Search/SearchRouter/SearchRouterModal.tsx +++ b/src/components/Search/SearchRouter/SearchRouterModal/index.tsx @@ -3,12 +3,12 @@ import {Dimensions} from 'react-native'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; import Modal from '@components/Modal'; import ScreenWrapperContainer from '@components/ScreenWrapper/ScreenWrapperContainer'; +import SearchRouter from '@components/Search/SearchRouter/SearchRouter'; +import {useSearchRouterContext} from '@components/Search/SearchRouter/SearchRouterContext'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useViewportOffsetTop from '@hooks/useViewportOffsetTop'; import {isMobileIOS} from '@libs/Browser'; import CONST from '@src/CONST'; -import SearchRouter from './SearchRouter'; -import {useSearchRouterContext} from './SearchRouterContext'; const isMobileWebIOS = isMobileIOS(); diff --git a/src/components/Search/SearchRouter/SearchRouterPage/index.native.tsx b/src/components/Search/SearchRouter/SearchRouterPage/index.native.tsx new file mode 100644 index 0000000000000..7818f9e1f294f --- /dev/null +++ b/src/components/Search/SearchRouter/SearchRouterPage/index.native.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SearchRouter from '@components/Search/SearchRouter/SearchRouter'; +import {useSearchRouterContext} from '@components/Search/SearchRouter/SearchRouterContext'; + +function SearchRouterPage() { + const {isSearchRouterDisplayed, closeSearchRouter} = useSearchRouterContext(); + + return ( + + + + ); +} + +SearchRouterPage.displayName = 'SearchRouterPage'; + +export default SearchRouterPage; diff --git a/src/components/Search/SearchRouter/SearchRouterPage/index.tsx b/src/components/Search/SearchRouter/SearchRouterPage/index.tsx new file mode 100644 index 0000000000000..ca82a5518ac6e --- /dev/null +++ b/src/components/Search/SearchRouter/SearchRouterPage/index.tsx @@ -0,0 +1,11 @@ +import Navigation from '@libs/Navigation/Navigation'; +import ROUTES from '@src/ROUTES'; + +/** + * On native devices SearchRouter is served from SearchRouterPage, on web from SearchRouterModal. + */ +function SearchRouterPage() { + return Navigation.navigate(ROUTES.HOME); +} + +export default SearchRouterPage; diff --git a/src/components/Search/SearchRouter/toggleSearch/index.native.tsx b/src/components/Search/SearchRouter/toggleSearch/index.native.tsx new file mode 100644 index 0000000000000..0b3883baaf0e1 --- /dev/null +++ b/src/components/Search/SearchRouter/toggleSearch/index.native.tsx @@ -0,0 +1,12 @@ +import Navigation from '@libs/Navigation/Navigation'; +import ROUTES from '@src/ROUTES'; + +function openSearch() { + return Navigation.navigate(ROUTES.SEARCH_ROUTER); +} + +function closeSearch() { + return Navigation.dismissModal(); +} + +export {openSearch, closeSearch}; diff --git a/src/components/Search/SearchRouter/toggleSearch/index.tsx b/src/components/Search/SearchRouter/toggleSearch/index.tsx new file mode 100644 index 0000000000000..766f94d826001 --- /dev/null +++ b/src/components/Search/SearchRouter/toggleSearch/index.tsx @@ -0,0 +1,9 @@ +function openSearch(setSearchState: React.Dispatch>) { + return setSearchState(true); +} + +function closeSearch(setSearchState: React.Dispatch>) { + return setSearchState(false); +} + +export {openSearch, closeSearch}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 20061d6c54946..e5beee5028d81 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -819,6 +819,10 @@ const TwoFactorAuthenticatorStackNavigator = createModalStackNavigator require('../../../../pages/settings/Security/TwoFactorAuth/SuccessPage').default, }); +const SearchRouterModalStackNavigator = createModalStackNavigator({ + [SCREENS.RIGHT_MODAL.SEARCH_ROUTER]: () => require('../../../../components/Search/SearchRouter/SearchRouterPage').default, +}); + const EnablePaymentsStackNavigator = createModalStackNavigator({ [SCREENS.ENABLE_PAYMENTS_ROOT]: () => require('../../../../pages/EnablePayments/EnablePaymentsPage').default, }); @@ -1025,4 +1029,5 @@ export { WorkspaceConfirmationModalStackNavigator, WorkspaceDuplicateModalStackNavigator, WorkspacesDomainModalStackNavigator, + SearchRouterModalStackNavigator, }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index 52d83ca526f64..42c2d9988c2df 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -184,6 +184,10 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { screenListeners={screenListeners} id={NAVIGATORS.RIGHT_MODAL_NAVIGATOR} > + ['config'] = { }, [NAVIGATORS.RIGHT_MODAL_NAVIGATOR]: { screens: { + [SCREENS.RIGHT_MODAL.SEARCH_ROUTER]: { + path: ROUTES.SEARCH_ROUTER, + exact: true, + }, [SCREENS.RIGHT_MODAL.SETTINGS]: { screens: { [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 34702a3f643de..d68414065fbda 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2201,6 +2201,7 @@ type WorkspacesDomainModalNavigatorParamList = { type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.SETTINGS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.TWO_FACTOR_AUTH]: NavigatorScreenParams; + [SCREENS.RIGHT_MODAL.SEARCH_ROUTER]: undefined; [SCREENS.RIGHT_MODAL.NEW_CHAT]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.DETAILS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.PROFILE]: NavigatorScreenParams; From 044b4835f576f1d0f465924131f6ce65cc0ebe8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Wed, 14 Jan 2026 01:22:39 +0100 Subject: [PATCH 2/9] working POC of searchin report with routing based search router --- .../Search/SearchRouter/SearchRouter.tsx | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 8f547047cb0e8..6ebfd5043928f 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -121,17 +121,29 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); const textInputRef = useRef(null); - const contextualReportID = useRootNavigationState((state) => { + const {contextualReportID, isSearchRouterScreen} = useRootNavigationState((state) => { // Safe handling when navigation is not yet initialized if (!state) { - return undefined; + return {contextualReportID: undefined, isSearchRouterScreen: false}; } - const focusedRoute = findFocusedRoute(state); - if (focusedRoute?.name === SCREENS.REPORT) { + let maybeReportRoute = focusedRoute; + // eslint-disable-next-line @typescript-eslint/no-shadow + const isSearchRouterScreen = focusedRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_ROUTER; + if (focusedRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_ROUTER) { + const stateWithoutLastRoute = { + ...state, + routes: state.routes.slice(0, -1), + index: state.index !== 0 ? state.index - 1 : 0, + }; + maybeReportRoute = findFocusedRoute(stateWithoutLastRoute); + } + + if (maybeReportRoute?.name === SCREENS.REPORT) { // We're guaranteed that the type of params is of SCREENS.REPORT - return (focusedRoute.params as ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]).reportID; + return {contextualReportID: (maybeReportRoute.params as ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]).reportID, isSearchRouterScreen}; } + return {contextualReportID: undefined, isSearchRouterScreen}; }); const getAdditionalSections: GetAdditionalSectionsCallback = useCallback( @@ -145,11 +157,10 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla return undefined; } - if (!isSearchRouterDisplayed) { + if (!isSearchRouterDisplayed && !isSearchRouterScreen) { return undefined; } let reportForContextualSearch = recentReports.find((option) => option.reportID === contextualReportID); - if (!reportForContextualSearch) { const report = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${contextualReportID}`]; if (!report) { From 105e4d6e2a4f44aed4d71ee49a4d4be7b43ae347 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Mon, 19 Jan 2026 22:56:13 +0100 Subject: [PATCH 3/9] extract util and add tests --- .../Search/SearchRouter/SearchRouter.tsx | 69 +------ .../SearchRouterPage/index.native.tsx | 2 - .../Search/SearchRouter/SearchRouterUtils.ts | 90 +++++++++ tests/unit/SearchRouterUtilsTest.ts | 175 ++++++++++++++++++ 4 files changed, 269 insertions(+), 67 deletions(-) create mode 100644 src/components/Search/SearchRouter/SearchRouterUtils.ts create mode 100644 tests/unit/SearchRouterUtilsTest.ts diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index a869de6430016..3c2abbcfa00dd 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -1,9 +1,7 @@ -import {findFocusedRoute} from '@react-navigation/native'; import {deepEqual} from 'fast-equals'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {TextInputProps} from 'react-native'; import {InteractionManager, View} from 'react-native'; -import type {OnyxCollection} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; @@ -37,57 +35,21 @@ import {getReportAction} from '@libs/ReportActionsUtils'; import {getReportOrDraftReport} from '@libs/ReportUtils'; import type {OptionData} from '@libs/ReportUtils'; import {getAutocompleteQueryWithComma, getQueryWithoutAutocompletedPart} from '@libs/SearchAutocompleteUtils'; -import {getPolicyNameWithFallback, getQueryWithUpdatedValues, sanitizeSearchValue} from '@libs/SearchQueryUtils'; +import {getQueryWithUpdatedValues, sanitizeSearchValue} from '@libs/SearchQueryUtils'; import StringUtils from '@libs/StringUtils'; import Navigation from '@navigation/Navigation'; -import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import variables from '@styles/variables'; import {navigateToAndOpenReport, searchInServer} from '@userActions/Report'; import {setSearchContext} from '@userActions/Search'; import CONST, {CONTINUATION_DETECTION_SEARCH_FILTER_KEYS} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import SCREENS from '@src/SCREENS'; -import type * as OnyxTypes from '@src/types/onyx'; import type Report from '@src/types/onyx/Report'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {SubstitutionMap} from './getQueryWithSubstitutions'; import {getQueryWithSubstitutions} from './getQueryWithSubstitutions'; import {getUpdatedSubstitutionsMap} from './getUpdatedSubstitutionsMap'; - -function getContextualSearchAutocompleteKey(item: SearchQueryItem, policies: OnyxCollection, reports?: OnyxCollection) { - if (item.roomType === CONST.SEARCH.DATA_TYPES.INVOICE) { - return `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${item.searchQuery}`; - } - if (item.roomType === CONST.SEARCH.DATA_TYPES.CHAT) { - return `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${item.searchQuery}`; - } - if (item.roomType === CONST.SEARCH.DATA_TYPES.EXPENSE) { - return `${CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID}:${item.policyID ? getPolicyNameWithFallback(item.policyID, policies, reports) : ''}`; - } -} - -function getContextualSearchQuery(item: SearchQueryItem, policies: OnyxCollection, reports?: OnyxCollection) { - const baseQuery = `${CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.TYPE}:${item.roomType}`; - let additionalQuery = ''; - - switch (item.roomType) { - case CONST.SEARCH.DATA_TYPES.EXPENSE: - additionalQuery += ` ${CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.POLICY_ID}:${sanitizeSearchValue(item.policyID ? getPolicyNameWithFallback(item.policyID, policies, reports) : '')}`; - break; - case CONST.SEARCH.DATA_TYPES.INVOICE: - additionalQuery += ` ${CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.POLICY_ID}:${item.policyID}`; - if (item.autocompleteID) { - additionalQuery += ` ${CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.TO}:${sanitizeSearchValue(item.searchQuery ?? '')}`; - } - break; - case CONST.SEARCH.DATA_TYPES.CHAT: - default: - additionalQuery = ` ${CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.IN}:${sanitizeSearchValue(item.searchQuery ?? '')}`; - break; - } - return baseQuery + additionalQuery; -} +import {getContextualReportData, getContextualSearchAutocompleteKey, getContextualSearchQuery} from './SearchRouterUtils'; type SearchRouterProps = { onRouterClose: () => void; @@ -124,30 +86,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); const textInputRef = useRef(null); - const {contextualReportID, isSearchRouterScreen} = useRootNavigationState((state) => { - // Safe handling when navigation is not yet initialized - if (!state) { - return {contextualReportID: undefined, isSearchRouterScreen: false}; - } - const focusedRoute = findFocusedRoute(state); - let maybeReportRoute = focusedRoute; - // eslint-disable-next-line @typescript-eslint/no-shadow - const isSearchRouterScreen = focusedRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_ROUTER; - if (focusedRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_ROUTER) { - const stateWithoutLastRoute = { - ...state, - routes: state.routes.slice(0, -1), - index: state.index !== 0 ? state.index - 1 : 0, - }; - maybeReportRoute = findFocusedRoute(stateWithoutLastRoute); - } - - if (maybeReportRoute?.name === SCREENS.REPORT || focusedRoute?.name === SCREENS.RIGHT_MODAL.EXPENSE_REPORT) { - // We're guaranteed that the type of params is of SCREENS.REPORT - return {contextualReportID: (maybeReportRoute.params as ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]).reportID, isSearchRouterScreen}; - } - return {contextualReportID: undefined, isSearchRouterScreen}; - }); + const {contextualReportID, isSearchRouterScreen} = useRootNavigationState(getContextualReportData); const getAdditionalSections: GetAdditionalSectionsCallback = useCallback( ({recentReports}) => { @@ -224,7 +163,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla }, ]; }, - [contextualReportID, textInputValue, isSearchRouterDisplayed, translate, expensifyIcons.MagnifyingGlass, styles.activeComponentBG, reports, personalDetails], + [contextualReportID, textInputValue, isSearchRouterDisplayed, isSearchRouterScreen, translate, expensifyIcons.MagnifyingGlass, styles.activeComponentBG, reports, personalDetails], ); const searchQueryItem = textInputValue diff --git a/src/components/Search/SearchRouter/SearchRouterPage/index.native.tsx b/src/components/Search/SearchRouter/SearchRouterPage/index.native.tsx index 7818f9e1f294f..fa87dac8a421f 100644 --- a/src/components/Search/SearchRouter/SearchRouterPage/index.native.tsx +++ b/src/components/Search/SearchRouter/SearchRouterPage/index.native.tsx @@ -23,6 +23,4 @@ function SearchRouterPage() { ); } -SearchRouterPage.displayName = 'SearchRouterPage'; - export default SearchRouterPage; diff --git a/src/components/Search/SearchRouter/SearchRouterUtils.ts b/src/components/Search/SearchRouter/SearchRouterUtils.ts new file mode 100644 index 0000000000000..95570985955e1 --- /dev/null +++ b/src/components/Search/SearchRouter/SearchRouterUtils.ts @@ -0,0 +1,90 @@ +import {findFocusedRoute} from '@react-navigation/native'; +import type {OnyxCollection} from 'react-native-onyx'; +import type {SearchQueryItem} from '@components/SelectionListWithSections/Search/SearchQueryListItem'; +import type useRootNavigationState from '@hooks/useRootNavigationState'; +import {getPolicyNameWithFallback, sanitizeSearchValue} from '@libs/SearchQueryUtils'; +import type {ReportsSplitNavigatorParamList} from '@navigation/types'; +import CONST from '@src/CONST'; +import SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; + +type ContextualReportData = { + contextualReportID: string | undefined; + isSearchRouterScreen: boolean; +}; + +/** + * Extracts contextual report data from the navigation state. + * + * This function determines: + * 1. Whether the current screen is the SearchRouter modal + * 2. The reportID of the contextual report (if any) that was focused before opening the SearchRouter + * + * When the SearchRouter is open, it looks at the previous route in the stack to find the underlying + * report context. Otherwise it looks @ current screen for report context. + * This allows the search to provide contextual suggestions based on the report + * the user was viewing when they opened the search. + * + * @param state - The root navigation state from useRootNavigationState hook + * @returns Object containing contextualReportID (the report ID if on a report screen) and isSearchRouterScreen (whether SearchRouter is focused) + */ +function getContextualReportData(state: Parameters[0]>[0]): ContextualReportData { + // Safe handling when navigation is not yet initialized + if (!state) { + return {contextualReportID: undefined, isSearchRouterScreen: false}; + } + const focusedRoute = findFocusedRoute(state); + let maybeReportRoute = focusedRoute; + const isSearchRouterScreen = focusedRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_ROUTER; + if (focusedRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_ROUTER) { + const stateWithoutLastRoute = { + ...state, + routes: state.routes.slice(0, -1), + index: state.index !== 0 ? state.index - 1 : 0, + }; + maybeReportRoute = findFocusedRoute(stateWithoutLastRoute); + } + + if (maybeReportRoute?.name === SCREENS.REPORT || maybeReportRoute?.name === SCREENS.RIGHT_MODAL.EXPENSE_REPORT) { + // We're guaranteed that the type of params is of SCREENS.REPORT + return {contextualReportID: (maybeReportRoute?.params as ReportsSplitNavigatorParamList[typeof SCREENS.REPORT]).reportID, isSearchRouterScreen}; + } + return {contextualReportID: undefined, isSearchRouterScreen}; +} + +function getContextualSearchAutocompleteKey(item: SearchQueryItem, policies: OnyxCollection, reports?: OnyxCollection) { + if (item.roomType === CONST.SEARCH.DATA_TYPES.INVOICE) { + return `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${item.searchQuery}`; + } + if (item.roomType === CONST.SEARCH.DATA_TYPES.CHAT) { + return `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${item.searchQuery}`; + } + if (item.roomType === CONST.SEARCH.DATA_TYPES.EXPENSE) { + return `${CONST.SEARCH.SYNTAX_FILTER_KEYS.POLICY_ID}:${item.policyID ? getPolicyNameWithFallback(item.policyID, policies, reports) : ''}`; + } +} + +function getContextualSearchQuery(item: SearchQueryItem, policies: OnyxCollection, reports?: OnyxCollection) { + const baseQuery = `${CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.TYPE}:${item.roomType}`; + let additionalQuery = ''; + + switch (item.roomType) { + case CONST.SEARCH.DATA_TYPES.EXPENSE: + additionalQuery += ` ${CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.POLICY_ID}:${sanitizeSearchValue(item.policyID ? getPolicyNameWithFallback(item.policyID, policies, reports) : '')}`; + break; + case CONST.SEARCH.DATA_TYPES.INVOICE: + additionalQuery += ` ${CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.POLICY_ID}:${item.policyID}`; + if (item.autocompleteID) { + additionalQuery += ` ${CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.TO}:${sanitizeSearchValue(item.searchQuery ?? '')}`; + } + break; + case CONST.SEARCH.DATA_TYPES.CHAT: + default: + additionalQuery = ` ${CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS.IN}:${sanitizeSearchValue(item.searchQuery ?? '')}`; + break; + } + return baseQuery + additionalQuery; +} + +export {getContextualReportData, getContextualSearchAutocompleteKey, getContextualSearchQuery}; +export type {ContextualReportData}; diff --git a/tests/unit/SearchRouterUtilsTest.ts b/tests/unit/SearchRouterUtilsTest.ts new file mode 100644 index 0000000000000..0cad24158b0f0 --- /dev/null +++ b/tests/unit/SearchRouterUtilsTest.ts @@ -0,0 +1,175 @@ +import type {NavigationState} from '@react-navigation/native'; +import {getContextualReportData} from '@components/Search/SearchRouter/SearchRouterUtils'; +import SCREENS from '@src/SCREENS'; + +// Helper to create minimal navigation state for testing +// The function only uses index, routes, and nested state properties +function createMockState(partialState: {index: number; routes: Array<{name: string; params?: Record; state?: unknown}>}): NavigationState { + return partialState as NavigationState; +} + +describe('SearchRouterUtils', () => { + describe('getContextualReportData', () => { + it('returns undefined contextualReportID and false isSearchRouterScreen when state is undefined', () => { + const result = getContextualReportData(undefined); + + expect(result).toEqual({ + contextualReportID: undefined, + isSearchRouterScreen: false, + }); + }); + + it('returns reportID when focused on a Report screen', () => { + const state = createMockState({ + index: 0, + routes: [ + { + name: SCREENS.REPORT, + params: {reportID: '12345'}, + }, + ], + }); + + const result = getContextualReportData(state); + + expect(result).toEqual({ + contextualReportID: '12345', + isSearchRouterScreen: false, + }); + }); + + it('returns reportID when focused on ExpenseReport screen', () => { + const state = createMockState({ + index: 0, + routes: [ + { + name: SCREENS.RIGHT_MODAL.EXPENSE_REPORT, + params: {reportID: '67890'}, + }, + ], + }); + + const result = getContextualReportData(state); + + expect(result).toEqual({ + contextualReportID: '67890', + isSearchRouterScreen: false, + }); + }); + + it('returns isSearchRouterScreen true and extracts reportID from previous route when SearchRouter is open over a Report', () => { + const state = createMockState({ + index: 1, + routes: [ + { + name: SCREENS.REPORT, + params: {reportID: '11111'}, + }, + { + name: SCREENS.RIGHT_MODAL.SEARCH_ROUTER, + params: {}, + }, + ], + }); + + const result = getContextualReportData(state); + + expect(result).toEqual({ + contextualReportID: '11111', + isSearchRouterScreen: true, + }); + }); + + it('returns undefined contextualReportID when SearchRouter is open but no report underneath', () => { + const state = createMockState({ + index: 1, + routes: [ + { + name: SCREENS.HOME, + params: {}, + }, + { + name: SCREENS.RIGHT_MODAL.SEARCH_ROUTER, + params: {}, + }, + ], + }); + + const result = getContextualReportData(state); + + expect(result).toEqual({ + contextualReportID: undefined, + isSearchRouterScreen: true, + }); + }); + + it('returns undefined contextualReportID when on a non-report screen', () => { + const state = createMockState({ + index: 0, + routes: [ + { + name: SCREENS.HOME, + params: {}, + }, + ], + }); + + const result = getContextualReportData(state); + + expect(result).toEqual({ + contextualReportID: undefined, + isSearchRouterScreen: false, + }); + }); + + it('handles nested navigation state with Report screen', () => { + const state = createMockState({ + index: 0, + routes: [ + { + name: 'RootNavigator', + state: { + index: 0, + routes: [ + { + name: SCREENS.REPORT, + params: {reportID: '55555'}, + }, + ], + }, + }, + ], + }); + + const result = getContextualReportData(state); + + expect(result).toEqual({ + contextualReportID: '55555', + isSearchRouterScreen: false, + }); + }); + + it('extracts reportID from ExpenseReport when SearchRouter is open over it', () => { + const state = createMockState({ + index: 1, + routes: [ + { + name: SCREENS.RIGHT_MODAL.EXPENSE_REPORT, + params: {reportID: '99999'}, + }, + { + name: SCREENS.RIGHT_MODAL.SEARCH_ROUTER, + params: {}, + }, + ], + }); + + const result = getContextualReportData(state); + + expect(result).toEqual({ + contextualReportID: '99999', + isSearchRouterScreen: true, + }); + }); + }); +}); From cabb8449e846634a9137ec410efc840638e7a654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Mon, 19 Jan 2026 23:15:55 +0100 Subject: [PATCH 4/9] fix prettier --- src/components/Search/SearchRouter/SearchRouterUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouterUtils.ts b/src/components/Search/SearchRouter/SearchRouterUtils.ts index 95570985955e1..fbdd16ac20392 100644 --- a/src/components/Search/SearchRouter/SearchRouterUtils.ts +++ b/src/components/Search/SearchRouter/SearchRouterUtils.ts @@ -21,9 +21,9 @@ type ContextualReportData = { * 2. The reportID of the contextual report (if any) that was focused before opening the SearchRouter * * When the SearchRouter is open, it looks at the previous route in the stack to find the underlying - * report context. Otherwise it looks @ current screen for report context. + * report context. Otherwise it looks @ current screen for report context. * This allows the search to provide contextual suggestions based on the report - * the user was viewing when they opened the search. + * the user was viewing when they opened the search. * * @param state - The root navigation state from useRootNavigationState hook * @returns Object containing contextualReportID (the report ID if on a report screen) and isSearchRouterScreen (whether SearchRouter is focused) From 5ca853e27edb865c6fe99500e16db1cbeae59243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Mon, 19 Jan 2026 23:56:37 +0100 Subject: [PATCH 5/9] fix perf test --- tests/perf-test/SearchRouter.perf-test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/perf-test/SearchRouter.perf-test.tsx b/tests/perf-test/SearchRouter.perf-test.tsx index 10f4f21c435f9..c07c8be9c77b4 100644 --- a/tests/perf-test/SearchRouter.perf-test.tsx +++ b/tests/perf-test/SearchRouter.perf-test.tsx @@ -43,7 +43,7 @@ jest.mock('@src/libs/Navigation/Navigation', () => ({ isDisplayedInModal: jest.fn(() => false), })); -jest.mock('@src/hooks/useRootNavigationState'); +jest.mock('@src/hooks/useRootNavigationState', () => ({__esModule: true, default: () => ({contextualReportID: undefined, isSearchRouterScreen: false})})); jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); From ed3153f6f236a2011942ad4c5411496c292bf97b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 20 Jan 2026 00:03:45 +0100 Subject: [PATCH 6/9] fix lint --- tests/perf-test/SearchRouter.perf-test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/perf-test/SearchRouter.perf-test.tsx b/tests/perf-test/SearchRouter.perf-test.tsx index c07c8be9c77b4..6883c91737ea2 100644 --- a/tests/perf-test/SearchRouter.perf-test.tsx +++ b/tests/perf-test/SearchRouter.perf-test.tsx @@ -43,7 +43,11 @@ jest.mock('@src/libs/Navigation/Navigation', () => ({ isDisplayedInModal: jest.fn(() => false), })); -jest.mock('@src/hooks/useRootNavigationState', () => ({__esModule: true, default: () => ({contextualReportID: undefined, isSearchRouterScreen: false})})); +jest.mock('@src/hooks/useRootNavigationState', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: () => ({contextualReportID: undefined, isSearchRouterScreen: false}), +})); jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); From fb7a93c5370734c8f83980d3b7bb54ad9b38a5eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 20 Jan 2026 11:06:43 +0100 Subject: [PATCH 7/9] fix type --- src/components/Search/SearchRouter/SearchRouterUtils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouterUtils.ts b/src/components/Search/SearchRouter/SearchRouterUtils.ts index fbdd16ac20392..fe75a7caf34e2 100644 --- a/src/components/Search/SearchRouter/SearchRouterUtils.ts +++ b/src/components/Search/SearchRouter/SearchRouterUtils.ts @@ -1,7 +1,7 @@ import {findFocusedRoute} from '@react-navigation/native'; +import type {NavigationState} from '@react-navigation/routers'; import type {OnyxCollection} from 'react-native-onyx'; import type {SearchQueryItem} from '@components/SelectionListWithSections/Search/SearchQueryListItem'; -import type useRootNavigationState from '@hooks/useRootNavigationState'; import {getPolicyNameWithFallback, sanitizeSearchValue} from '@libs/SearchQueryUtils'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; import CONST from '@src/CONST'; @@ -28,7 +28,7 @@ type ContextualReportData = { * @param state - The root navigation state from useRootNavigationState hook * @returns Object containing contextualReportID (the report ID if on a report screen) and isSearchRouterScreen (whether SearchRouter is focused) */ -function getContextualReportData(state: Parameters[0]>[0]): ContextualReportData { +function getContextualReportData(state: NavigationState | undefined): ContextualReportData { // Safe handling when navigation is not yet initialized if (!state) { return {contextualReportID: undefined, isSearchRouterScreen: false}; From f8ebcf315291ee29e59c5596c9d607879002580c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Tue, 20 Jan 2026 16:18:20 +0100 Subject: [PATCH 8/9] fix duplicated var --- src/components/Search/SearchRouter/SearchRouterUtils.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouterUtils.ts b/src/components/Search/SearchRouter/SearchRouterUtils.ts index fe75a7caf34e2..ec4a8fe2157f6 100644 --- a/src/components/Search/SearchRouter/SearchRouterUtils.ts +++ b/src/components/Search/SearchRouter/SearchRouterUtils.ts @@ -33,10 +33,9 @@ function getContextualReportData(state: NavigationState | undefined): Contextual if (!state) { return {contextualReportID: undefined, isSearchRouterScreen: false}; } - const focusedRoute = findFocusedRoute(state); - let maybeReportRoute = focusedRoute; - const isSearchRouterScreen = focusedRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_ROUTER; - if (focusedRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_ROUTER) { + let maybeReportRoute = findFocusedRoute(state); + const isSearchRouterScreen = maybeReportRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_ROUTER; + if (isSearchRouterScreen) { const stateWithoutLastRoute = { ...state, routes: state.routes.slice(0, -1), From c5a089b38c8270d8f21f370f25e022323abb8785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Musia=C5=82?= Date: Mon, 26 Jan 2026 16:01:06 -0800 Subject: [PATCH 9/9] remove old imports --- src/components/Search/SearchRouter/SearchRouter.tsx | 13 ++++++++++++- .../Search/SearchRouter/SearchRouterModal/index.tsx | 4 +--- .../SearchRouter/SearchRouterPage/index.native.tsx | 5 +++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 78e151f5361a8..023666896c5c9 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -166,7 +166,18 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla }, ]; }, - [contextualReportID, textInputValue, isSearchRouterDisplayed, isSearchRouterScreen, translate, expensifyIcons.MagnifyingGlass, styles.activeComponentBG, reports, personalDetails, currentUserAccountID], + [ + contextualReportID, + textInputValue, + isSearchRouterDisplayed, + isSearchRouterScreen, + translate, + expensifyIcons.MagnifyingGlass, + styles.activeComponentBG, + reports, + personalDetails, + currentUserAccountID, + ], ); const searchQueryItem = textInputValue diff --git a/src/components/Search/SearchRouter/SearchRouterModal/index.tsx b/src/components/Search/SearchRouter/SearchRouterModal/index.tsx index 07bee9f6d5809..bde79df92d104 100644 --- a/src/components/Search/SearchRouter/SearchRouterModal/index.tsx +++ b/src/components/Search/SearchRouter/SearchRouterModal/index.tsx @@ -4,13 +4,11 @@ import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; import Modal from '@components/Modal'; import ScreenWrapperContainer from '@components/ScreenWrapper/ScreenWrapperContainer'; import SearchRouter from '@components/Search/SearchRouter/SearchRouter'; -import {useSearchRouterContext} from '@components/Search/SearchRouter/SearchRouterContext'; +import {useSearchRouterActions, useSearchRouterState} from '@components/Search/SearchRouter/SearchRouterContext'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useViewportOffsetTop from '@hooks/useViewportOffsetTop'; import {isMobileIOS} from '@libs/Browser'; import CONST from '@src/CONST'; -import SearchRouter from './SearchRouter'; -import {useSearchRouterActions, useSearchRouterState} from './SearchRouterContext'; const isMobileWebIOS = isMobileIOS(); diff --git a/src/components/Search/SearchRouter/SearchRouterPage/index.native.tsx b/src/components/Search/SearchRouter/SearchRouterPage/index.native.tsx index fa87dac8a421f..4be5dc0269e8e 100644 --- a/src/components/Search/SearchRouter/SearchRouterPage/index.native.tsx +++ b/src/components/Search/SearchRouter/SearchRouterPage/index.native.tsx @@ -1,10 +1,11 @@ import React from 'react'; import ScreenWrapper from '@components/ScreenWrapper'; import SearchRouter from '@components/Search/SearchRouter/SearchRouter'; -import {useSearchRouterContext} from '@components/Search/SearchRouter/SearchRouterContext'; +import {useSearchRouterActions, useSearchRouterState} from '@components/Search/SearchRouter/SearchRouterContext'; function SearchRouterPage() { - const {isSearchRouterDisplayed, closeSearchRouter} = useSearchRouterContext(); + const {closeSearchRouter} = useSearchRouterActions(); + const {isSearchRouterDisplayed} = useSearchRouterState(); return (