Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ function SearchQueryListItem({item, isFocused, showTooltip, onSelectRow, onFocus
showTooltip={showTooltip}
shouldDisableHoverStyle={shouldDisableHoverStyle}
shouldHighlightSelectedItem
rightHandSideComponent={item.rightElement}
Comment thread
nabi-ebrahimi marked this conversation as resolved.
>
<>
{!!item.singleIcon && (
Expand Down
3 changes: 2 additions & 1 deletion src/components/Search/SearchRouter/SearchRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -164,6 +163,8 @@ function SearchRouter({onRouterClose, shouldHideInputCaret, isSearchRouterDispla
const textInputRef = useRef<AnimatedTextInputRef>(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);
Comment thread
nabi-ebrahimi marked this conversation as resolved.
Comment thread
nabi-ebrahimi marked this conversation as resolved.
Comment thread
nabi-ebrahimi marked this conversation as resolved.

const contextualReport = useReportOrReportDraft(contextualReportID);
const [contextualReportNVP] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${contextualReportID}`, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
73 changes: 68 additions & 5 deletions src/components/Search/SearchRouter/useNavigationSuggestions.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<SearchTypeMenuItem['icon'], IconAsset>;

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: {
Expand All @@ -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 [
Expand Down Expand Up @@ -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);
Comment thread
nabi-ebrahimi marked this conversation as resolved.

const topLevelItems = buildTopLevelNavigationItems({
labels: {
Expand All @@ -95,8 +142,24 @@ function useNavigationSuggestions(query: string): SearchQueryItem[] {
getDestinationText: (destination) => getGoToText(translate, destination),
});

return buildNavigationSuggestions(query, [topLevelItems], localeCompare);
const spendItems = buildSpendNavigationItems({
Comment thread
nabi-ebrahimi marked this conversation as resolved.
sections: typeMenuSections,
icons,
rightElement: (
Comment thread
nabi-ebrahimi marked this conversation as resolved.
<TextWithIconCell
text={translate('common.spend')}
icon={icons.ReceiptMultiple}
showTooltip={false}
textStyle={styles.textLabelSupporting}
/>
),
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};
12 changes: 12 additions & 0 deletions src/libs/SearchNavigationUtils.ts
Original file line number Diff line number Diff line change
@@ -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;
38 changes: 20 additions & 18 deletions src/libs/SearchUIUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<typeof SEARCH_TYPE_MENU_ICON_NAMES>;
searchQuery: string;
searchQueryJSON: SearchQueryJSON | undefined;
hash: number;
Expand Down Expand Up @@ -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};
23 changes: 2 additions & 21 deletions src/pages/Search/SearchTypeMenuNarrow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, {query: string; name?: string}>();
const tabItems: TabSelectorBaseItem[] = [];
Expand Down
29 changes: 4 additions & 25 deletions src/pages/Search/SearchTypeMenuWide.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
29 changes: 22 additions & 7 deletions tests/unit/SearchAutocompleteListTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -88,10 +89,10 @@ jest.mock('@hooks/useFilteredOptions', () => ({
default: (...args: unknown[]) => mockUseFilteredOptions(...args),
}));

const mockUseNavigationSuggestions = jest.fn<SearchQueryItem[], []>(() => []);
const mockUseNavigationSuggestions = jest.fn<SearchQueryItem[], [query: string, shouldWatchForApprovals?: boolean]>(() => []);
jest.mock('@components/Search/SearchRouter/useNavigationSuggestions', () => ({
__esModule: true,
default: () => mockUseNavigationSuggestions(),
default: (query: string, shouldWatchForApprovals?: boolean) => mockUseNavigationSuggestions(query, shouldWatchForApprovals),
}));

jest.mock('@react-navigation/native', () => {
Expand Down Expand Up @@ -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 (
<ComposeProviders components={[OnyxListItemProvider, LocaleContextProvider]}>
<SearchRouter onRouterClose={mockOnClose} />
<SearchRouter
onRouterClose={mockOnClose}
isSearchRouterDisplayed={isSearchRouterDisplayed}
/>
</ComposeProviders>
);
}
Expand Down Expand Up @@ -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(<SearchRouterWrapper isSearchRouterDisplayed={isSearchRouterDisplayed} />);

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',
Comment thread
nabi-ebrahimi marked this conversation as resolved.
keyForList: 'spend_reports',
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.NAVIGATE,
action: navigationAction,
rightElement: <Text>Spend</Text>,
},
]);

Expand All @@ -223,7 +237,8 @@ describe('SearchAutocompleteList', () => {
render(<SearchRouterWrapper />);
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);
Expand Down
Loading
Loading