From cd8b3830f00e9fee1c3cf58b868dfacaeec53d11 Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Sat, 18 Jul 2026 14:35:59 +0430 Subject: [PATCH 01/12] Add Spend navigation suggestions to Search Router --- .../ListItem/SearchQueryListItem.tsx | 1 + .../Search/SearchRouter/SearchRouter.tsx | 2 +- .../SearchRouter/useNavigationSuggestions.tsx | 115 ++++++++++++++- tests/unit/SearchAutocompleteListTest.tsx | 9 +- tests/unit/SearchRouterNavigationTest.ts | 134 +++++++++++++++++- 5 files changed, 251 insertions(+), 10 deletions(-) diff --git a/src/components/Search/SearchList/ListItem/SearchQueryListItem.tsx b/src/components/Search/SearchList/ListItem/SearchQueryListItem.tsx index 62c5fa978162..447f3875249e 100644 --- a/src/components/Search/SearchList/ListItem/SearchQueryListItem.tsx +++ b/src/components/Search/SearchList/ListItem/SearchQueryListItem.tsx @@ -65,6 +65,7 @@ function SearchQueryListItem({item, isFocused, showTooltip, onSelectRow, onFocus showTooltip={showTooltip} shouldDisableHoverStyle={shouldDisableHoverStyle} shouldHighlightSelectedItem + rightHandSideComponent={item.rightElement} > <> {!!item.singleIcon && ( diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 3cd37413210a..490216980e12 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -155,7 +155,6 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla // The input text that was last used for autocomplete; needed for the SearchAutocompleteList when browsing list via arrow keys const [autocompleteQueryValue, setAutocompleteQueryValue] = useState(initialQuery); const [selection, setSelection] = useState({start: initialQuery.length, end: initialQuery.length}); - const navigationSuggestions = useNavigationSuggestions(textInputValue); useEffect(() => { clearPendingRouterState(); @@ -164,6 +163,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla const textInputRef = useRef(null); const {contextualReportID, isSearchRouterScreen} = useRootNavigationState(getContextualReportData); + const navigationSuggestions = useNavigationSuggestions(textInputValue, !!isSearchRouterDisplayed || isSearchRouterScreen); const contextualReport = useReportOrReportDraft(contextualReportID); const [contextualReportNVP] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${contextualReportID}`, { diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index f6ef5c1f18e9..ab8585c74a0b 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -1,27 +1,63 @@ /** - * Builds the top-level navigation suggestions shown in the Search Router. + * Builds the top-level and Spend navigation suggestions shown in the Search Router. */ +import Icon from '@components/Icon'; import getSearchTabRoute from '@components/Navigation/NavigationTabBar/getSearchTabRoute'; +import {useSearchSelectionActions} from '@components/Search/SearchContext'; import type {SearchQueryItem} from '@components/Search/SearchList/ListItem/SearchQueryListItem'; +import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {setSearchContext} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; +import type {SearchTypeMenuItem, SearchTypeMenuSection} from '@libs/SearchUIUtils'; import navigationRef from '@navigation/navigationRef'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type IconAsset from '@src/types/utils/IconAsset'; +import type {ReactNode} from 'react'; + +import React from 'react'; +import {View} from 'react-native'; + import type {NavigationSuggestionSourceItem} from './SearchRouterHelpers'; import {buildNavigationSuggestions, getGoToText} from './SearchRouterHelpers'; type TopLevelNavigationIcons = Record<'Home' | 'Inbox' | 'ReceiptMultiple' | 'Building' | 'Gear', IconAsset>; +type SpendNavigationIcons = Record; + +type RightSideContextProps = { + label: string; + icon: IconAsset; +}; + +function RightSideContext({label, icon}: RightSideContextProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + + return ( + + + {label} + + ); +} type BuildTopLevelNavigationItemsParams = { labels: { @@ -36,6 +72,15 @@ type BuildTopLevelNavigationItemsParams = { getDestinationText: (destination: string) => string; }; +type BuildSpendNavigationItemsParams = { + sections: SearchTypeMenuSection[]; + icons: SpendNavigationIcons; + rightElement: ReactNode; + getItemText: (item: SearchTypeMenuItem) => string; + getDestinationText: (destination: string) => string; + onSelect: (searchQuery: string) => void; +}; + // Tab buttons own stateful navigation behavior and do not expose reusable descriptors, so Search Router keeps deterministic destination actions here. function buildTopLevelNavigationItems({labels, icons, getSpendRoute, getDestinationText}: BuildTopLevelNavigationItemsParams): NavigationSuggestionSourceItem[] { return [ @@ -77,10 +122,56 @@ function buildTopLevelNavigationItems({labels, icons, getSpendRoute, getDestinat ]; } -function useNavigationSuggestions(query: string): SearchQueryItem[] { +function buildSpendNavigationItems({sections, icons, rightElement, getItemText, getDestinationText, onSelect}: BuildSpendNavigationItemsParams): NavigationSuggestionSourceItem[] { + return sections + .filter((section) => section.translationPath !== 'search.savedSearchesMenuItemTitle') + .flatMap((section) => + section.menuItems.map((item) => { + const itemText = getItemText(item); + return { + text: getDestinationText(itemText), + singleIcon: icons[item.icon], + action: () => onSelect(item.searchQuery), + keyForList: `spend_${item.key}`, + rightElement, + matchTerms: [itemText], + }; + }), + ); +} + +function navigateToSpendSearch(searchQuery: string, clearSelectedTransactions: () => void) { + clearSelectedTransactions(); + setSearchContext(false); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); +} + +function useNavigationSuggestions(query: string, shouldWatchForApprovals = true): SearchQueryItem[] { const {translate, localeCompare} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['Home', 'Inbox', 'ReceiptMultiple', 'Building', 'Gear']); + const icons = useMemoizedLazyExpensifyIcons([ + 'Home', + 'Inbox', + 'ReceiptMultiple', + 'Building', + 'Gear', + 'Basket', + 'CalendarSolid', + 'Receipt', + 'MoneyBag', + 'CreditCard', + 'MoneyHourglass', + 'CreditCardHourglass', + 'Bank', + 'User', + 'Folder', + 'Document', + 'Pencil', + 'ThumbsUp', + 'CheckCircle', + ]); const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); + const {clearSelectedTransactions} = useSearchSelectionActions(); + const {typeMenuSections} = useSearchTypeMenuSections(undefined, shouldWatchForApprovals); const topLevelItems = buildTopLevelNavigationItems({ labels: { @@ -95,8 +186,22 @@ function useNavigationSuggestions(query: string): SearchQueryItem[] { getDestinationText: (destination) => getGoToText(translate, destination), }); - return buildNavigationSuggestions(query, [topLevelItems], localeCompare); + const spendItems = buildSpendNavigationItems({ + sections: typeMenuSections, + icons, + rightElement: ( + + ), + getItemText: (item) => translate(item.translationPath), + getDestinationText: (destination) => getGoToText(translate, destination), + onSelect: (searchQuery) => navigateToSpendSearch(searchQuery, clearSelectedTransactions), + }); + + return buildNavigationSuggestions(query, [topLevelItems, spendItems], localeCompare); } export default useNavigationSuggestions; -export {buildTopLevelNavigationItems}; +export {buildTopLevelNavigationItems, buildSpendNavigationItems, navigateToSpendSearch}; diff --git a/tests/unit/SearchAutocompleteListTest.tsx b/tests/unit/SearchAutocompleteListTest.tsx index b68e03593507..6a9ac88cd742 100644 --- a/tests/unit/SearchAutocompleteListTest.tsx +++ b/tests/unit/SearchAutocompleteListTest.tsx @@ -4,6 +4,7 @@ import {LocaleContextProvider} from '@components/LocaleContextProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import type {SearchQueryItem} from '@components/Search/SearchList/ListItem/SearchQueryListItem'; import SearchRouter from '@components/Search/SearchRouter/SearchRouter'; +import Text from '@components/Text'; import type {PrivateIsArchivedMap} from '@hooks/usePrivateIsArchivedMap'; @@ -206,10 +207,11 @@ describe('SearchAutocompleteList', () => { const navigationAction = jest.fn(); mockUseNavigationSuggestions.mockReturnValue([ { - text: 'Go to Inbox', - keyForList: 'topLevelInbox', + text: 'Go to Reports', + keyForList: 'spend_reports', searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.NAVIGATE, action: navigationAction, + rightElement: Spend, }, ]); @@ -223,7 +225,8 @@ describe('SearchAutocompleteList', () => { render(); await flushAllUpdates(); - fireEvent.press(await screen.findByText('Go to Inbox')); + expect(await screen.findByText('Spend')).toBeTruthy(); + fireEvent.press(await screen.findByText('Go to Reports')); await waitFor(() => { expect(mockOnClose).toHaveBeenCalledWith(navigationAction); diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index 1d3f0e968c34..430c5203e00e 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -6,13 +6,20 @@ import { sortNavigationSuggestionItems, stripNavigationIntentPrefix, } from '@components/Search/SearchRouter/SearchRouterHelpers'; -import {buildTopLevelNavigationItems} from '@components/Search/SearchRouter/useNavigationSuggestions'; +import {buildSpendNavigationItems, buildTopLevelNavigationItems, navigateToSpendSearch} from '@components/Search/SearchRouter/useNavigationSuggestions'; +import {setSearchContext} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; +import type {SearchTypeMenuItem, SearchTypeMenuSection} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type IconAsset from '@src/types/utils/IconAsset'; +jest.mock('@libs/actions/Search', () => ({ + setSearchContext: jest.fn(), +})); + jest.mock('@libs/Navigation/Navigation', () => ({ __esModule: true, default: { @@ -22,6 +29,41 @@ jest.mock('@libs/Navigation/Navigation', () => ({ const localeCompare = (firstValue: string, secondValue: string) => firstValue.localeCompare(secondValue); const mockIcon: IconAsset = () => null; +const spendIcons = { + Basket: mockIcon, + CalendarSolid: mockIcon, + Receipt: mockIcon, + MoneyBag: mockIcon, + CreditCard: mockIcon, + MoneyHourglass: mockIcon, + CreditCardHourglass: mockIcon, + Bank: mockIcon, + User: mockIcon, + Folder: mockIcon, + Document: mockIcon, + Pencil: mockIcon, + ThumbsUp: mockIcon, + CheckCircle: mockIcon, +}; + +function createSpendMenuItem( + key: SearchTypeMenuItem['key'], + translationPath: SearchTypeMenuItem['translationPath'], + icon: SearchTypeMenuItem['icon'], + searchQuery: string, +): SearchTypeMenuItem { + return { + key, + translationPath, + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + icon, + searchQuery, + searchQueryJSON: undefined, + hash: 1, + similarSearchHash: 1, + recentSearchHash: 1, + }; +} describe('Search Router navigation query helpers', () => { it.each([ @@ -180,3 +222,93 @@ describe('top-level Search Router navigation source', () => { expect(Navigation.navigate).toHaveBeenNthCalledWith(5, ROUTES.SETTINGS); }); }); + +describe('Spend Search Router navigation source', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('reuses Spend menu labels, icons, queries, and excludes saved searches', () => { + const reportsQuery = 'type:expense-report'; + const expensesQuery = 'type:expense'; + const sections: SearchTypeMenuSection[] = [ + { + translationPath: 'search.tabs.expenseReports', + menuItems: [ + createSpendMenuItem(CONST.SEARCH.SEARCH_KEYS.REPORTS, 'search.tabs.reports', 'Document', reportsQuery), + createSpendMenuItem(CONST.SEARCH.SEARCH_KEYS.EXPENSES, 'search.tabs.expenses', 'Receipt', expensesQuery), + ], + }, + { + translationPath: 'search.savedSearchesMenuItemTitle', + menuItems: [createSpendMenuItem(`${CONST.SEARCH.SAVED_SEARCH_PREFIX}1`, 'search.tabs.expenses', 'Receipt', 'saved-search-query')], + }, + ]; + const rightElement = 'Spend'; + const onSelect = jest.fn(); + const labels = new Map([ + ['search.tabs.reports', 'Reports'], + ['search.tabs.expenses', 'Expenses'], + ]); + + const items = buildSpendNavigationItems({ + sections, + icons: spendIcons, + rightElement, + getItemText: (item) => labels.get(item.translationPath) ?? item.translationPath, + getDestinationText: (destination) => `Go to ${destination}`, + onSelect, + }); + + expect(items.map((item) => item.text)).toEqual(['Go to Reports', 'Go to Expenses']); + expect(items.map((item) => item.keyForList)).toEqual(['spend_reports', 'spend_expenses']); + expect(items.map((item) => item.singleIcon)).toEqual([mockIcon, mockIcon]); + expect(items.map((item) => item.rightElement)).toEqual([rightElement, rightElement]); + expect(items.map((item) => item.matchTerms)).toEqual([['Reports'], ['Expenses']]); + + items.at(0)?.action?.(); + expect(onSelect).toHaveBeenCalledWith(reportsQuery); + }); + + it('does not use the right-side Spend context as a matching term', () => { + const items = buildSpendNavigationItems({ + sections: [ + { + translationPath: 'search.tabs.expenseReports', + menuItems: [createSpendMenuItem(CONST.SEARCH.SEARCH_KEYS.REPORTS, 'search.tabs.reports', 'Document', 'type:expense-report')], + }, + ], + icons: spendIcons, + rightElement: 'Spend', + getItemText: () => 'Reports', + getDestinationText: (destination) => `Go to ${destination}`, + onSelect: jest.fn(), + }); + + expect(buildNavigationSuggestions('spend', [items], localeCompare)).toEqual([]); + expect(buildNavigationSuggestions('reports', [items], localeCompare).map((item) => item.keyForList)).toEqual(['spend_reports']); + }); + + it('keeps top-level priority and alphabetizes Spend results', () => { + const topLevelItems = [{text: 'Go to Spend', keyForList: 'topLevelSpend', matchTerms: ['Spend']}]; + const spendItems = [ + {text: 'Go to Reports', keyForList: 'spend_reports', matchTerms: ['Reports']}, + {text: 'Go to Expenses', keyForList: 'spend_expenses', matchTerms: ['Expenses']}, + ]; + + expect(buildNavigationSuggestions('go', [topLevelItems, spendItems], localeCompare).map((item) => item.keyForList)).toEqual(['topLevelSpend', 'spend_expenses', 'spend_reports']); + }); + + it('clears selected transactions and stale search context before opening a canned Spend search', () => { + const clearSelectedTransactions = jest.fn(); + const searchQuery = 'type:expense sortBy:date sortOrder:desc'; + + navigateToSpendSearch(searchQuery, clearSelectedTransactions); + + expect(clearSelectedTransactions).toHaveBeenCalledTimes(1); + expect(setSearchContext).toHaveBeenCalledWith(false); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); + expect(clearSelectedTransactions.mock.invocationCallOrder.at(0)).toBeLessThan(jest.mocked(setSearchContext).mock.invocationCallOrder.at(0) ?? 0); + expect(jest.mocked(setSearchContext).mock.invocationCallOrder.at(0)).toBeLessThan(jest.mocked(Navigation.navigate).mock.invocationCallOrder.at(0) ?? 0); + }); +}); From 60bb4a5135e37a1e30dbd4b9b4cd582729026762 Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Wed, 22 Jul 2026 15:02:53 +0430 Subject: [PATCH 02/12] Document Spend navigation context props --- .../Search/SearchRouter/useNavigationSuggestions.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index ab8585c74a0b..f99d73f906ee 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -39,7 +39,10 @@ type TopLevelNavigationIcons = Record<'Home' | 'Inbox' | 'ReceiptMultiple' | 'Bu type SpendNavigationIcons = Record; type RightSideContextProps = { + /** Text label shown to the right of the suggestion row */ label: string; + + /** Icon rendered alongside the label */ icon: IconAsset; }; From 8dbcb2631a9a9a97ccb9ed26ab4d3a6079b2244d Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Wed, 22 Jul 2026 15:25:39 +0430 Subject: [PATCH 03/12] Reuse canned Spend search navigation --- .../Search/SearchRouter/useNavigationSuggestions.tsx | 12 +++--------- src/libs/SearchNavigationUtils.ts | 12 ++++++++++++ src/pages/Search/SearchTypeMenuWide.tsx | 10 ++-------- tests/unit/SearchRouterNavigationTest.ts | 5 +++-- 4 files changed, 20 insertions(+), 19 deletions(-) create mode 100644 src/libs/SearchNavigationUtils.ts diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index f99d73f906ee..475665234615 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -14,8 +14,8 @@ import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {setSearchContext} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; +import {navigateToCannedSpendSearch} from '@libs/SearchNavigationUtils'; import type {SearchTypeMenuItem, SearchTypeMenuSection} from '@libs/SearchUIUtils'; import navigationRef from '@navigation/navigationRef'; @@ -143,12 +143,6 @@ function buildSpendNavigationItems({sections, icons, rightElement, getItemText, ); } -function navigateToSpendSearch(searchQuery: string, clearSelectedTransactions: () => void) { - clearSelectedTransactions(); - setSearchContext(false); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); -} - function useNavigationSuggestions(query: string, shouldWatchForApprovals = true): SearchQueryItem[] { const {translate, localeCompare} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons([ @@ -200,11 +194,11 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) ), getItemText: (item) => translate(item.translationPath), getDestinationText: (destination) => getGoToText(translate, destination), - onSelect: (searchQuery) => navigateToSpendSearch(searchQuery, clearSelectedTransactions), + onSelect: (searchQuery) => navigateToCannedSpendSearch(searchQuery, clearSelectedTransactions), }); return buildNavigationSuggestions(query, [topLevelItems, spendItems], localeCompare); } export default useNavigationSuggestions; -export {buildTopLevelNavigationItems, buildSpendNavigationItems, navigateToSpendSearch}; +export {buildTopLevelNavigationItems, buildSpendNavigationItems}; diff --git a/src/libs/SearchNavigationUtils.ts b/src/libs/SearchNavigationUtils.ts new file mode 100644 index 000000000000..941997e67ec2 --- /dev/null +++ b/src/libs/SearchNavigationUtils.ts @@ -0,0 +1,12 @@ +import {setSearchContext} from '@libs/actions/Search'; +import Navigation from '@libs/Navigation/Navigation'; + +import ROUTES from '@src/ROUTES'; + +function navigateToCannedSpendSearch(searchQuery: string, clearSelectedTransactions: () => void) { + clearSelectedTransactions(); + setSearchContext(false); + Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); +} + +export {navigateToCannedSpendSearch}; diff --git a/src/pages/Search/SearchTypeMenuWide.tsx b/src/pages/Search/SearchTypeMenuWide.tsx index a8a1b666a91f..3802fb7670bb 100644 --- a/src/pages/Search/SearchTypeMenuWide.tsx +++ b/src/pages/Search/SearchTypeMenuWide.tsx @@ -14,13 +14,11 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useTodoCounts from '@hooks/useTodoCounts'; import type {TodoCounts} from '@hooks/useTodoCounts'; -import {setSearchContext} from '@libs/actions/Search'; -import Navigation from '@libs/Navigation/Navigation'; +import {navigateToCannedSpendSearch} from '@libs/SearchNavigationUtils'; import {getItemBadgeText, getSectionBadgeText} from '@libs/SearchUIUtils'; import type {SearchTypeMenuSection} from '@libs/SearchUIUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; // eslint-disable-next-line no-restricted-imports @@ -151,11 +149,7 @@ function SearchTypeMenuWide({queryJSON}: SearchTypeMenuProps) { saveScrollOffset(route, e.nativeEvent.contentOffset.y); }; - const handleTypeMenuItemPress = singleExecution((searchQuery: string) => { - clearSelectedTransactions(); - setSearchContext(false); - Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); - }); + const handleTypeMenuItemPress = singleExecution((searchQuery: string) => navigateToCannedSpendSearch(searchQuery, clearSelectedTransactions)); useLayoutEffect(() => { const scrollOffset = getScrollOffset(route); diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index 430c5203e00e..edcc8ef26446 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -6,10 +6,11 @@ import { sortNavigationSuggestionItems, stripNavigationIntentPrefix, } from '@components/Search/SearchRouter/SearchRouterHelpers'; -import {buildSpendNavigationItems, buildTopLevelNavigationItems, navigateToSpendSearch} from '@components/Search/SearchRouter/useNavigationSuggestions'; +import {buildSpendNavigationItems, buildTopLevelNavigationItems} from '@components/Search/SearchRouter/useNavigationSuggestions'; import {setSearchContext} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; +import {navigateToCannedSpendSearch} from '@libs/SearchNavigationUtils'; import type {SearchTypeMenuItem, SearchTypeMenuSection} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -303,7 +304,7 @@ describe('Spend Search Router navigation source', () => { const clearSelectedTransactions = jest.fn(); const searchQuery = 'type:expense sortBy:date sortOrder:desc'; - navigateToSpendSearch(searchQuery, clearSelectedTransactions); + navigateToCannedSpendSearch(searchQuery, clearSelectedTransactions); expect(clearSelectedTransactions).toHaveBeenCalledTimes(1); expect(setSearchContext).toHaveBeenCalledWith(false); From 4ab0b8f41299cfa0a8407038081c8e70be346378 Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Wed, 22 Jul 2026 15:45:58 +0430 Subject: [PATCH 04/12] Clarify Search Router approval watching --- src/components/Search/SearchRouter/SearchRouter.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 490216980e12..859d9d98dc78 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -163,6 +163,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla const textInputRef = useRef(null); const {contextualReportID, isSearchRouterScreen} = useRootNavigationState(getContextualReportData); + // Only watch for reports awaiting approval while the router is active as a popover or full-screen page. const navigationSuggestions = useNavigationSuggestions(textInputValue, !!isSearchRouterDisplayed || isSearchRouterScreen); const contextualReport = useReportOrReportDraft(contextualReportID); From 9f9624447e24e7425c32b5f8a21694575b2e7360 Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Wed, 22 Jul 2026 16:10:01 +0430 Subject: [PATCH 05/12] Test Spend navigation hook composition --- tests/unit/SearchRouterNavigationTest.ts | 102 ++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index edcc8ef26446..4641d16f6a7b 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -1,3 +1,5 @@ +import {renderHook} from '@testing-library/react-native'; + import { buildNavigationSuggestions, isNavigationIntentOnlyQuery, @@ -6,7 +8,7 @@ import { sortNavigationSuggestionItems, stripNavigationIntentPrefix, } from '@components/Search/SearchRouter/SearchRouterHelpers'; -import {buildSpendNavigationItems, buildTopLevelNavigationItems} from '@components/Search/SearchRouter/useNavigationSuggestions'; +import useNavigationSuggestions, {buildSpendNavigationItems, buildTopLevelNavigationItems} from '@components/Search/SearchRouter/useNavigationSuggestions'; import {setSearchContext} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; @@ -17,6 +19,53 @@ import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type IconAsset from '@src/types/utils/IconAsset'; +import {isValidElement} from 'react'; + +const mockUseSearchTypeMenuSections = jest.fn(); +const mockUseMemoizedLazyExpensifyIcons = jest.fn(); +const mockClearSelectedTransactions = jest.fn(); + +jest.mock('@components/Search/SearchContext', () => ({ + useSearchSelectionActions: () => ({clearSelectedTransactions: mockClearSelectedTransactions}), +})); + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyExpensifyIcons: () => mockUseMemoizedLazyExpensifyIcons(), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({ + localeCompare: (firstValue: string, secondValue: string) => firstValue.localeCompare(secondValue), + translate: (key: string, params?: {destination?: string}) => { + if (key === 'search.goTo') { + return `Go to ${params?.destination}`; + } + + const translations: Record = { + 'common.home': 'Home', + 'common.inbox': 'Inbox', + 'common.spend': 'Spend', + 'common.workspacesTabTitle': 'Workspaces', + 'initialSettingsPage.account': 'Account', + 'search.tabs.reports': 'Reports', + 'search.tabs.expenses': 'Expenses', + }; + return translations[key] ?? key; + }, + }), +})); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: () => [undefined], +})); + +jest.mock('@hooks/useSearchTypeMenuSections', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockUseSearchTypeMenuSections(...args), +})); + jest.mock('@libs/actions/Search', () => ({ setSearchContext: jest.fn(), })); @@ -312,4 +361,55 @@ describe('Spend Search Router navigation source', () => { expect(clearSelectedTransactions.mock.invocationCallOrder.at(0)).toBeLessThan(jest.mocked(setSearchContext).mock.invocationCallOrder.at(0) ?? 0); expect(jest.mocked(setSearchContext).mock.invocationCallOrder.at(0)).toBeLessThan(jest.mocked(Navigation.navigate).mock.invocationCallOrder.at(0) ?? 0); }); + + it('composes Spend suggestions from the menu hook with icons, context, exclusions, and approval gating', () => { + const reportsIcon: IconAsset = () => null; + const spendContextIcon: IconAsset = () => null; + mockUseMemoizedLazyExpensifyIcons.mockReturnValue({ + ...spendIcons, + Home: mockIcon, + Inbox: mockIcon, + ReceiptMultiple: spendContextIcon, + Building: mockIcon, + Gear: mockIcon, + Document: reportsIcon, + }); + mockUseSearchTypeMenuSections.mockReturnValue({ + typeMenuSections: [ + { + translationPath: 'search.tabs.expenseReports', + menuItems: [createSpendMenuItem(CONST.SEARCH.SEARCH_KEYS.REPORTS, 'search.tabs.reports', 'Document', 'type:expense-report')], + }, + { + translationPath: 'search.savedSearchesMenuItemTitle', + menuItems: [createSpendMenuItem(`${CONST.SEARCH.SAVED_SEARCH_PREFIX}1`, 'search.tabs.reports', 'Receipt', 'saved-search-query')], + }, + ], + activeItemIndex: -1, + activeKey: undefined, + }); + + const {result, rerender} = renderHook(({shouldWatchForApprovals}) => useNavigationSuggestions('reports', shouldWatchForApprovals), { + initialProps: {shouldWatchForApprovals: false}, + }); + + expect(mockUseSearchTypeMenuSections).toHaveBeenLastCalledWith(undefined, false); + expect(result.current).toHaveLength(1); + expect(result.current.at(0)).toMatchObject({ + text: 'Go to Reports', + keyForList: 'spend_reports', + singleIcon: reportsIcon, + }); + expect(result.current.some((item) => item.keyForList === `spend_${CONST.SEARCH.SAVED_SEARCH_PREFIX}1`)).toBe(false); + + const rightElement = result.current.at(0)?.rightElement; + expect(isValidElement<{label: string; icon: IconAsset}>(rightElement)).toBe(true); + if (!isValidElement<{label: string; icon: IconAsset}>(rightElement)) { + throw new Error('Expected Spend navigation context to be a React element'); + } + expect(rightElement.props).toMatchObject({label: 'Spend', icon: spendContextIcon}); + + rerender({shouldWatchForApprovals: true}); + expect(mockUseSearchTypeMenuSections).toHaveBeenLastCalledWith(undefined, true); + }); }); From a2c6c6a94e8efc9033ff872a3393c5faeefb2ecd Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Wed, 22 Jul 2026 16:19:05 +0430 Subject: [PATCH 06/12] Fix Spend navigation lint issues --- .../SearchRouter/useNavigationSuggestions.tsx | 2 +- src/libs/SearchNavigationUtils.ts | 8 ++--- src/pages/Search/SearchTypeMenuWide.tsx | 2 +- tests/unit/SearchRouterNavigationTest.ts | 34 +++++++++++-------- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index 475665234615..d85dd9bea3d4 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -15,7 +15,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; -import {navigateToCannedSpendSearch} from '@libs/SearchNavigationUtils'; +import navigateToCannedSpendSearch from '@libs/SearchNavigationUtils'; import type {SearchTypeMenuItem, SearchTypeMenuSection} from '@libs/SearchUIUtils'; import navigationRef from '@navigation/navigationRef'; diff --git a/src/libs/SearchNavigationUtils.ts b/src/libs/SearchNavigationUtils.ts index 941997e67ec2..925faf6c3590 100644 --- a/src/libs/SearchNavigationUtils.ts +++ b/src/libs/SearchNavigationUtils.ts @@ -1,12 +1,12 @@ -import {setSearchContext} from '@libs/actions/Search'; -import Navigation from '@libs/Navigation/Navigation'; - import ROUTES from '@src/ROUTES'; +import {setSearchContext} from './actions/Search'; +import Navigation from './Navigation/Navigation'; + function navigateToCannedSpendSearch(searchQuery: string, clearSelectedTransactions: () => void) { clearSelectedTransactions(); setSearchContext(false); Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: searchQuery})); } -export {navigateToCannedSpendSearch}; +export default navigateToCannedSpendSearch; diff --git a/src/pages/Search/SearchTypeMenuWide.tsx b/src/pages/Search/SearchTypeMenuWide.tsx index 3802fb7670bb..95b7bbb3da2c 100644 --- a/src/pages/Search/SearchTypeMenuWide.tsx +++ b/src/pages/Search/SearchTypeMenuWide.tsx @@ -14,7 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useTodoCounts from '@hooks/useTodoCounts'; import type {TodoCounts} from '@hooks/useTodoCounts'; -import {navigateToCannedSpendSearch} from '@libs/SearchNavigationUtils'; +import navigateToCannedSpendSearch from '@libs/SearchNavigationUtils'; import {getItemBadgeText, getSectionBadgeText} from '@libs/SearchUIUtils'; import type {SearchTypeMenuSection} from '@libs/SearchUIUtils'; diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index 4641d16f6a7b..3d10ae27cacf 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -12,7 +12,7 @@ import useNavigationSuggestions, {buildSpendNavigationItems, buildTopLevelNaviga import {setSearchContext} from '@libs/actions/Search'; import Navigation from '@libs/Navigation/Navigation'; -import {navigateToCannedSpendSearch} from '@libs/SearchNavigationUtils'; +import navigateToCannedSpendSearch from '@libs/SearchNavigationUtils'; import type {SearchTypeMenuItem, SearchTypeMenuSection} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; @@ -21,8 +21,14 @@ import type IconAsset from '@src/types/utils/IconAsset'; import {isValidElement} from 'react'; -const mockUseSearchTypeMenuSections = jest.fn(); -const mockUseMemoizedLazyExpensifyIcons = jest.fn(); +type MockSearchTypeMenuSectionsResult = { + typeMenuSections: SearchTypeMenuSection[]; + activeItemIndex: number; + activeKey: string | undefined; +}; + +const mockUseSearchTypeMenuSections = jest.fn(); +const mockUseMemoizedLazyExpensifyIcons = jest.fn, []>(); const mockClearSelectedTransactions = jest.fn(); jest.mock('@components/Search/SearchContext', () => ({ @@ -42,16 +48,16 @@ jest.mock('@hooks/useLocalize', () => ({ return `Go to ${params?.destination}`; } - const translations: Record = { - 'common.home': 'Home', - 'common.inbox': 'Inbox', - 'common.spend': 'Spend', - 'common.workspacesTabTitle': 'Workspaces', - 'initialSettingsPage.account': 'Account', - 'search.tabs.reports': 'Reports', - 'search.tabs.expenses': 'Expenses', - }; - return translations[key] ?? key; + const translations = new Map([ + ['common.home', 'Home'], + ['common.inbox', 'Inbox'], + ['common.spend', 'Spend'], + ['common.workspacesTabTitle', 'Workspaces'], + ['initialSettingsPage.account', 'Account'], + ['search.tabs.reports', 'Reports'], + ['search.tabs.expenses', 'Expenses'], + ]); + return translations.get(key) ?? key; }, }), })); @@ -63,7 +69,7 @@ jest.mock('@hooks/useOnyx', () => ({ jest.mock('@hooks/useSearchTypeMenuSections', () => ({ __esModule: true, - default: (...args: unknown[]) => mockUseSearchTypeMenuSections(...args), + default: (queryParams: unknown, isScreenFocused: boolean) => mockUseSearchTypeMenuSections(queryParams, isScreenFocused), })); jest.mock('@libs/actions/Search', () => ({ From 9af9639f0f5412aa6769bedf265b9ed8ec543bd3 Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Fri, 24 Jul 2026 10:33:12 +0430 Subject: [PATCH 07/12] Test Search Router approval watch state --- tests/unit/SearchAutocompleteListTest.tsx | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/unit/SearchAutocompleteListTest.tsx b/tests/unit/SearchAutocompleteListTest.tsx index 6a9ac88cd742..9c3805025f3a 100644 --- a/tests/unit/SearchAutocompleteListTest.tsx +++ b/tests/unit/SearchAutocompleteListTest.tsx @@ -89,10 +89,10 @@ jest.mock('@hooks/useFilteredOptions', () => ({ default: (...args: unknown[]) => mockUseFilteredOptions(...args), })); -const mockUseNavigationSuggestions = jest.fn(() => []); +const mockUseNavigationSuggestions = jest.fn(() => []); jest.mock('@components/Search/SearchRouter/useNavigationSuggestions', () => ({ __esModule: true, - default: () => mockUseNavigationSuggestions(), + default: (query: string, shouldWatchForApprovals?: boolean) => mockUseNavigationSuggestions(query, shouldWatchForApprovals), })); jest.mock('@react-navigation/native', () => { @@ -154,10 +154,13 @@ const fakeRecentReports = [ {reportID: '103', keyForList: '103', text: 'Charlie Report', alternateText: 'charlie alt', lastMessageText: 'hey'}, ]; -function SearchRouterWrapper() { +function SearchRouterWrapper({isSearchRouterDisplayed}: {isSearchRouterDisplayed?: boolean}) { return ( - + ); } @@ -203,6 +206,15 @@ describe('SearchAutocompleteList', () => { mockUseNavigationSuggestions.mockReturnValue([]); }); + it.each([ + ['displayed', true, true], + ['hidden', undefined, false], + ] as const)('should pass the correct approval-watch state when the router is %s', (_state, isSearchRouterDisplayed, shouldWatchForApprovals) => { + render(); + + expect(mockUseNavigationSuggestions).toHaveBeenCalledWith(expect.any(String), shouldWatchForApprovals); + }); + it('should display and select navigation suggestion rows', async () => { const navigationAction = jest.fn(); mockUseNavigationSuggestions.mockReturnValue([ From 70ddc5857ba1baf56ef062d70c920135c9cd4cb4 Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Fri, 24 Jul 2026 10:36:56 +0430 Subject: [PATCH 08/12] Clarify Spend suggestion label component name --- .../Search/SearchRouter/useNavigationSuggestions.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index d85dd9bea3d4..250ae0faa0aa 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -38,7 +38,7 @@ import {buildNavigationSuggestions, getGoToText} from './SearchRouterHelpers'; type TopLevelNavigationIcons = Record<'Home' | 'Inbox' | 'ReceiptMultiple' | 'Building' | 'Gear', IconAsset>; type SpendNavigationIcons = Record; -type RightSideContextProps = { +type RightSideLabelProps = { /** Text label shown to the right of the suggestion row */ label: string; @@ -46,7 +46,7 @@ type RightSideContextProps = { icon: IconAsset; }; -function RightSideContext({label, icon}: RightSideContextProps) { +function RightSideLabel({label, icon}: RightSideLabelProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -187,7 +187,7 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) sections: typeMenuSections, icons, rightElement: ( - From 50b32b7baa0cda921e0b385bda2b485521384a05 Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Fri, 24 Jul 2026 10:39:57 +0430 Subject: [PATCH 09/12] Document saved search navigation exclusion --- .../Search/SearchRouter/useNavigationSuggestions.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index 250ae0faa0aa..7be55f0239b4 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -38,6 +38,9 @@ import {buildNavigationSuggestions, getGoToText} from './SearchRouterHelpers'; type TopLevelNavigationIcons = Record<'Home' | 'Inbox' | 'ReceiptMultiple' | 'Building' | 'Gear', IconAsset>; type SpendNavigationIcons = Record; +// Saved searches are user-defined searches, not canned destinations, so they are excluded from go-to navigation suggestions. +const SAVED_SEARCHES_SECTION_PATH = 'search.savedSearchesMenuItemTitle'; + type RightSideLabelProps = { /** Text label shown to the right of the suggestion row */ label: string; @@ -127,7 +130,7 @@ function buildTopLevelNavigationItems({labels, icons, getSpendRoute, getDestinat function buildSpendNavigationItems({sections, icons, rightElement, getItemText, getDestinationText, onSelect}: BuildSpendNavigationItemsParams): NavigationSuggestionSourceItem[] { return sections - .filter((section) => section.translationPath !== 'search.savedSearchesMenuItemTitle') + .filter((section) => section.translationPath !== SAVED_SEARCHES_SECTION_PATH) .flatMap((section) => section.menuItems.map((item) => { const itemText = getItemText(item); From 10bc5e3aa2907594b7dfa7756f0f444b430e7c4d Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Fri, 24 Jul 2026 10:45:06 +0430 Subject: [PATCH 10/12] Reuse shared icon label for Spend suggestions --- .../SearchRouter/useNavigationSuggestions.tsx | 37 +++---------------- tests/unit/SearchRouterNavigationTest.ts | 2 +- 2 files changed, 7 insertions(+), 32 deletions(-) diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index 7be55f0239b4..9c070bbac258 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -1,17 +1,15 @@ /** * Builds the top-level and Spend navigation suggestions shown in the Search Router. */ -import Icon from '@components/Icon'; import getSearchTabRoute from '@components/Navigation/NavigationTabBar/getSearchTabRoute'; import {useSearchSelectionActions} from '@components/Search/SearchContext'; import type {SearchQueryItem} from '@components/Search/SearchList/ListItem/SearchQueryListItem'; -import Text from '@components/Text'; +import TextWithIconCell from '@components/Search/SearchList/ListItem/TextWithIconCell'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useSearchTypeMenuSections from '@hooks/useSearchTypeMenuSections'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; @@ -20,7 +18,6 @@ import type {SearchTypeMenuItem, SearchTypeMenuSection} from '@libs/SearchUIUtil import navigationRef from '@navigation/navigationRef'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; @@ -29,7 +26,6 @@ import type IconAsset from '@src/types/utils/IconAsset'; import type {ReactNode} from 'react'; import React from 'react'; -import {View} from 'react-native'; import type {NavigationSuggestionSourceItem} from './SearchRouterHelpers'; @@ -41,30 +37,6 @@ type SpendNavigationIcons = Record; // Saved searches are user-defined searches, not canned destinations, so they are excluded from go-to navigation suggestions. const SAVED_SEARCHES_SECTION_PATH = 'search.savedSearchesMenuItemTitle'; -type RightSideLabelProps = { - /** Text label shown to the right of the suggestion row */ - label: string; - - /** Icon rendered alongside the label */ - icon: IconAsset; -}; - -function RightSideLabel({label, icon}: RightSideLabelProps) { - const styles = useThemeStyles(); - const theme = useTheme(); - - return ( - - - {label} - - ); -} - type BuildTopLevelNavigationItemsParams = { labels: { home: string; @@ -148,6 +120,7 @@ function buildSpendNavigationItems({sections, icons, rightElement, getItemText, function useNavigationSuggestions(query: string, shouldWatchForApprovals = true): SearchQueryItem[] { const {translate, localeCompare} = useLocalize(); + const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons([ 'Home', 'Inbox', @@ -190,9 +163,11 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) sections: typeMenuSections, icons, rightElement: ( - ), getItemText: (item) => translate(item.translationPath), diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index 3d10ae27cacf..c449df7e5625 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -413,7 +413,7 @@ describe('Spend Search Router navigation source', () => { if (!isValidElement<{label: string; icon: IconAsset}>(rightElement)) { throw new Error('Expected Spend navigation context to be a React element'); } - expect(rightElement.props).toMatchObject({label: 'Spend', icon: spendContextIcon}); + expect(rightElement.props).toMatchObject({text: 'Spend', icon: spendContextIcon, showTooltip: false}); rerender({shouldWatchForApprovals: true}); expect(mockUseSearchTypeMenuSections).toHaveBeenLastCalledWith(undefined, true); From 418ace8361d1e8fbaf80eea7ee8cc405cb55bc56 Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Fri, 24 Jul 2026 10:55:27 +0430 Subject: [PATCH 11/12] Centralize Spend menu icon names --- .../SearchRouter/useNavigationSuggestions.tsx | 25 ++---------- src/libs/SearchUIUtils.ts | 38 ++++++++++--------- src/pages/Search/SearchTypeMenuNarrow.tsx | 23 +---------- src/pages/Search/SearchTypeMenuWide.tsx | 19 +--------- 4 files changed, 28 insertions(+), 77 deletions(-) diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index 9c070bbac258..3e9dfbf72396 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -14,6 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import navigateToCannedSpendSearch from '@libs/SearchNavigationUtils'; +import {SEARCH_TYPE_MENU_ICON_NAMES} from '@libs/SearchUIUtils'; import type {SearchTypeMenuItem, SearchTypeMenuSection} from '@libs/SearchUIUtils'; import navigationRef from '@navigation/navigationRef'; @@ -34,6 +35,8 @@ import {buildNavigationSuggestions, getGoToText} from './SearchRouterHelpers'; type TopLevelNavigationIcons = Record<'Home' | 'Inbox' | 'ReceiptMultiple' | 'Building' | 'Gear', IconAsset>; type SpendNavigationIcons = Record; +const SEARCH_ROUTER_ICON_NAMES = ['Home', 'Inbox', 'ReceiptMultiple', 'Building', 'Gear', ...SEARCH_TYPE_MENU_ICON_NAMES] as const; + // Saved searches are user-defined searches, not canned destinations, so they are excluded from go-to navigation suggestions. const SAVED_SEARCHES_SECTION_PATH = 'search.savedSearchesMenuItemTitle'; @@ -121,27 +124,7 @@ function buildSpendNavigationItems({sections, icons, rightElement, getItemText, function useNavigationSuggestions(query: string, shouldWatchForApprovals = true): SearchQueryItem[] { const {translate, localeCompare} = useLocalize(); const styles = useThemeStyles(); - const icons = useMemoizedLazyExpensifyIcons([ - 'Home', - 'Inbox', - 'ReceiptMultiple', - 'Building', - 'Gear', - 'Basket', - 'CalendarSolid', - 'Receipt', - 'MoneyBag', - 'CreditCard', - 'MoneyHourglass', - 'CreditCardHourglass', - 'Bank', - 'User', - 'Folder', - 'Document', - 'Pencil', - 'ThumbsUp', - 'CheckCircle', - ]); + const icons = useMemoizedLazyExpensifyIcons(SEARCH_ROUTER_ICON_NAMES); const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); const {clearSelectedTransactions} = useSearchSelectionActions(); const {typeMenuSections} = useSearchTypeMenuSections(undefined, shouldWatchForApprovals); diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 431a4a62f00c..3b11280d325b 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -90,7 +90,7 @@ import arraysEqual from '@src/utils/arraysEqual'; import type {TextStyle, ViewStyle} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; +import type {TupleToUnion, ValueOf} from 'type-fest'; /* eslint-disable max-lines */ // TODO: Remove this disable once SearchUIUtils is refactored (see dedicated refactor issue) @@ -538,27 +538,28 @@ type SearchTypeMenuSection = { menuItems: SearchTypeMenuItem[]; }; +const SEARCH_TYPE_MENU_ICON_NAMES = [ + 'Receipt', + 'MoneyBag', + 'CreditCard', + 'MoneyHourglass', + 'CreditCardHourglass', + 'Bank', + 'User', + 'Folder', + 'Basket', + 'CalendarSolid', + 'Document', + 'Pencil', + 'ThumbsUp', + 'CheckCircle', +] as const satisfies readonly ExpensifyIconName[]; + type SearchTypeMenuItem = { key: SearchKey; translationPath: TranslationPaths; type: SearchDataTypes; - icon: Extract< - ExpensifyIconName, - | 'Receipt' - | 'MoneyBag' - | 'CreditCard' - | 'MoneyHourglass' - | 'CreditCardHourglass' - | 'Bank' - | 'User' - | 'Folder' - | 'Basket' - | 'CalendarSolid' - | 'Document' - | 'Pencil' - | 'ThumbsUp' - | 'CheckCircle' - >; + icon: TupleToUnion; searchQuery: string; searchQueryJSON: SearchQueryJSON | undefined; hash: number; @@ -6594,5 +6595,6 @@ export { splitGroupsIntoPairs, isEligibleForStatus, SKIPPED_SEARCH_FILTERS, + SEARCH_TYPE_MENU_ICON_NAMES, }; export type {SavedSearchMenuItem, SearchTypeMenuSection, SearchTypeMenuItem, SearchDateModifier, SearchDateModifierLower, SearchKey, GroupBySection, SearchFilter}; diff --git a/src/pages/Search/SearchTypeMenuNarrow.tsx b/src/pages/Search/SearchTypeMenuNarrow.tsx index 61910d27e2f9..1d5746becad1 100644 --- a/src/pages/Search/SearchTypeMenuNarrow.tsx +++ b/src/pages/Search/SearchTypeMenuNarrow.tsx @@ -22,7 +22,7 @@ import useTodoCounts from '@hooks/useTodoCounts'; import {setSearchContext} from '@libs/actions/Search'; import {mergeCardListWithWorkspaceFeeds} from '@libs/CardUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; -import {getItemBadgeText, getOverflowMenu} from '@libs/SearchUIUtils'; +import {getItemBadgeText, getOverflowMenu, SEARCH_TYPE_MENU_ICON_NAMES} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -131,26 +131,7 @@ function SearchTypeMenuNarrow({queryJSON, onTabPress}: SearchTypeMenuNarrowProps const {copiedHash, handleShare} = useShareSavedSearch(); - const expensifyIcons = useMemoizedLazyExpensifyIcons([ - 'Receipt', - 'MoneyBag', - 'CreditCard', - 'MoneyHourglass', - 'CreditCardHourglass', - 'Bank', - 'User', - 'Folder', - 'Basket', - 'CalendarSolid', - 'Bookmark', - 'Pencil', - 'Trashcan', - 'LinkCopy', - 'Checkmark', - 'Document', - 'ThumbsUp', - 'CheckCircle', - ]); + const expensifyIcons = useMemoizedLazyExpensifyIcons([...SEARCH_TYPE_MENU_ICON_NAMES, 'Bookmark', 'Trashcan', 'LinkCopy', 'Checkmark']); const queryMap = new Map(); const tabItems: TabSelectorBaseItem[] = []; diff --git a/src/pages/Search/SearchTypeMenuWide.tsx b/src/pages/Search/SearchTypeMenuWide.tsx index 95b7bbb3da2c..2a07ca064744 100644 --- a/src/pages/Search/SearchTypeMenuWide.tsx +++ b/src/pages/Search/SearchTypeMenuWide.tsx @@ -15,7 +15,7 @@ import useTodoCounts from '@hooks/useTodoCounts'; import type {TodoCounts} from '@hooks/useTodoCounts'; import navigateToCannedSpendSearch from '@libs/SearchNavigationUtils'; -import {getItemBadgeText, getSectionBadgeText} from '@libs/SearchUIUtils'; +import {getItemBadgeText, getSectionBadgeText, SEARCH_TYPE_MENU_ICON_NAMES} from '@libs/SearchUIUtils'; import type {SearchTypeMenuSection} from '@libs/SearchUIUtils'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -50,22 +50,7 @@ type SectionParams = { function Section({section, hash, activeItemIndex, sectionStartIndex, reportCounts, areAllSectionsExpanded, onItemPress, onCollapsed}: SectionParams) { const {translate} = useLocalize(); - const expensifyIcons = useMemoizedLazyExpensifyIcons([ - 'Basket', - 'CalendarSolid', - 'Receipt', - 'MoneyBag', - 'CreditCard', - 'MoneyHourglass', - 'CreditCardHourglass', - 'Bank', - 'User', - 'Folder', - 'Document', - 'Pencil', - 'ThumbsUp', - 'CheckCircle', - ]); + const expensifyIcons = useMemoizedLazyExpensifyIcons(SEARCH_TYPE_MENU_ICON_NAMES); const [isExpanded, setIsExpanded] = useState(true); From bcd1eca8fb2d0df819cdc19b736eac3292e41890 Mon Sep 17 00:00:00 2001 From: nabi-ebrahimi Date: Fri, 24 Jul 2026 15:21:56 +0430 Subject: [PATCH 12/12] Normalize whitespace in navigation queries --- src/components/Search/SearchRouter/SearchRouterHelpers.ts | 2 +- tests/unit/SearchRouterNavigationTest.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/Search/SearchRouter/SearchRouterHelpers.ts b/src/components/Search/SearchRouter/SearchRouterHelpers.ts index 8ad3dcb86008..2b6328cdf768 100644 --- a/src/components/Search/SearchRouter/SearchRouterHelpers.ts +++ b/src/components/Search/SearchRouter/SearchRouterHelpers.ts @@ -18,7 +18,7 @@ const GO_TO_PREFIX = /^go\s+to\s+/i; const GO_PREFIX = /^go\s+/i; function stripNavigationIntentPrefix(query: string) { - const trimmedQuery = query.trim(); + const trimmedQuery = query.trim().replaceAll(/\s+/g, ' '); if (GO_TO_PREFIX.test(trimmedQuery)) { return trimmedQuery.replace(GO_TO_PREFIX, '').trim(); } diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index c449df7e5625..6d0d58b6b0bc 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -129,6 +129,7 @@ describe('Search Router navigation query helpers', () => { ['go to inbox', 'inbox'], ['Go To Inbox', 'Inbox'], [' go to inbox ', 'inbox'], + ['ready to pay', 'ready to pay'], ['inbox', 'inbox'], ])('normalizes navigation intent in "%s"', (query, expectedQuery) => { expect(stripNavigationIntentPrefix(query)).toBe(expectedQuery); @@ -197,6 +198,12 @@ describe('Search Router navigation query helpers', () => { expect(buildNavigationSuggestions(query, source, localeCompare).map((item) => item.keyForList)).toEqual(['inbox']); }); + it.each(['ready to pay', 'ready to pay', 'go to ready to pay'])('matches a multi-word destination for "%s"', (query) => { + const source = [[{text: 'Go to Ready to pay', keyForList: 'readyToPay', matchTerms: ['Ready to pay']}]]; + + expect(buildNavigationSuggestions(query, source, localeCompare).map((item) => item.keyForList)).toEqual(['readyToPay']); + }); + it('shows navigation rows for bare go intents and caps the result', () => { const source = [Array.from({length: 12}, (_, index) => ({text: `Go to Item ${index}`, keyForList: `item-${index}`}))];