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 143fe8b71793..05072cf3e8ea 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,8 @@ 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); const [contextualReportNVP] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${contextualReportID}`, { 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/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index f6ef5c1f18e9..3e9dfbf72396 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -1,14 +1,21 @@ /** - * 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 getSearchTabRoute from '@components/Navigation/NavigationTabBar/getSearchTabRoute'; +import {useSearchSelectionActions} from '@components/Search/SearchContext'; import type {SearchQueryItem} from '@components/Search/SearchList/ListItem/SearchQueryListItem'; +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 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'; @@ -17,11 +24,21 @@ 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 type {NavigationSuggestionSourceItem} from './SearchRouterHelpers'; 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'; type BuildTopLevelNavigationItemsParams = { labels: { @@ -36,6 +53,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 +103,31 @@ 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 !== SAVED_SEARCHES_SECTION_PATH) + .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 useNavigationSuggestions(query: string, shouldWatchForApprovals = true): SearchQueryItem[] { const {translate, localeCompare} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['Home', 'Inbox', 'ReceiptMultiple', 'Building', 'Gear']); + const styles = useThemeStyles(); + const icons = useMemoizedLazyExpensifyIcons(SEARCH_ROUTER_ICON_NAMES); const [lastSearchParams] = useOnyx(ONYXKEYS.REPORT_NAVIGATION_LAST_SEARCH_QUERY); + const {clearSelectedTransactions} = useSearchSelectionActions(); + const {typeMenuSections} = useSearchTypeMenuSections(undefined, shouldWatchForApprovals); const topLevelItems = buildTopLevelNavigationItems({ labels: { @@ -95,8 +142,24 @@ 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) => navigateToCannedSpendSearch(searchQuery, clearSelectedTransactions), + }); + + return buildNavigationSuggestions(query, [topLevelItems, spendItems], localeCompare); } export default useNavigationSuggestions; -export {buildTopLevelNavigationItems}; +export {buildTopLevelNavigationItems, buildSpendNavigationItems}; diff --git a/src/libs/SearchNavigationUtils.ts b/src/libs/SearchNavigationUtils.ts new file mode 100644 index 000000000000..925faf6c3590 --- /dev/null +++ b/src/libs/SearchNavigationUtils.ts @@ -0,0 +1,12 @@ +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 default navigateToCannedSpendSearch; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index aeeb5417bb6f..daca675a9f62 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) @@ -539,27 +539,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; @@ -6625,5 +6626,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 d0dd4c2e6191..326a44bb0a59 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 {getItemBadgeText, getSectionBadgeText} from '@libs/SearchUIUtils'; +import navigateToCannedSpendSearch from '@libs/SearchNavigationUtils'; +import {getItemBadgeText, getSectionBadgeText, SEARCH_TYPE_MENU_ICON_NAMES} 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 @@ -50,22 +48,7 @@ type SectionParams = { function Section({section, hash, activeItemIndex, sectionStartIndex, reportCounts, onItemPress}: 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); @@ -128,11 +111,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/SearchAutocompleteListTest.tsx b/tests/unit/SearchAutocompleteListTest.tsx index b68e03593507..9c3805025f3a 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'; @@ -88,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', () => { @@ -153,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 ( - + ); } @@ -202,14 +206,24 @@ 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([ { - 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 +237,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..6d0d58b6b0bc 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,13 +8,74 @@ import { sortNavigationSuggestionItems, stripNavigationIntentPrefix, } from '@components/Search/SearchRouter/SearchRouterHelpers'; -import {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'; +import navigateToCannedSpendSearch from '@libs/SearchNavigationUtils'; +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'; +import {isValidElement} from 'react'; + +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', () => ({ + 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 = 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; + }, + }), +})); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: () => [undefined], +})); + +jest.mock('@hooks/useSearchTypeMenuSections', () => ({ + __esModule: true, + default: (queryParams: unknown, isScreenFocused: boolean) => mockUseSearchTypeMenuSections(queryParams, isScreenFocused), +})); + +jest.mock('@libs/actions/Search', () => ({ + setSearchContext: jest.fn(), +})); + jest.mock('@libs/Navigation/Navigation', () => ({ __esModule: true, default: { @@ -22,6 +85,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([ @@ -31,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); @@ -99,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}`}))]; @@ -180,3 +285,144 @@ 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'; + + navigateToCannedSpendSearch(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); + }); + + 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({text: 'Spend', icon: spendContextIcon, showTooltip: false}); + + rerender({shouldWatchForApprovals: true}); + expect(mockUseSearchTypeMenuSections).toHaveBeenLastCalledWith(undefined, true); + }); +});