diff --git a/src/CONST.ts b/src/CONST.ts
index 8e38812ccdcf..6362e2795acb 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -5800,6 +5800,11 @@ const CONST = {
IN: 'in',
},
EMPTY_VALUE: 'none',
+ SEARCH_ROUTER_ITEM_TYPE: {
+ CONTEXTUAL_SUGGESTION: 'contextualSuggestion',
+ AUTOCOMPLETE_SUGGESTION: 'autocompleteSuggestion',
+ SEARCH: 'searchItem',
+ },
},
REFERRER: {
diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx
index 65d86005207c..00e07b0406b9 100644
--- a/src/components/Search/SearchPageHeader.tsx
+++ b/src/components/Search/SearchPageHeader.tsx
@@ -73,9 +73,8 @@ function HeaderWrapper({icon, children, text, value, isCannedQuery, onSubmit, se
{}}
+ updateSearch={setValue}
autoFocus={false}
isFullWidth
wrapperStyle={[styles.searchRouterInputResults, styles.br2]}
diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx
index d2cb25c5a5f9..83d7d5d89b20 100644
--- a/src/components/Search/SearchRouter/SearchRouter.tsx
+++ b/src/components/Search/SearchRouter/SearchRouter.tsx
@@ -1,21 +1,31 @@
import {useNavigationState} from '@react-navigation/native';
-import debounce from 'lodash/debounce';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import {usePersonalDetails} from '@components/OnyxProvider';
import {useOptionsList} from '@components/OptionListContextProvider';
-import type {SearchQueryJSON} from '@components/Search/types';
+import type {AutocompleteRange, SearchQueryJSON} from '@components/Search/types';
import type {SelectionListHandle} from '@components/SelectionList/types';
+import useActiveWorkspaceFromNavigationState from '@hooks/useActiveWorkspaceFromNavigationState';
import useDebouncedState from '@hooks/useDebouncedState';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useLocalize from '@hooks/useLocalize';
+import usePolicy from '@hooks/usePolicy';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
-import Log from '@libs/Log';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import {getAllTaxRates} from '@libs/PolicyUtils';
import type {OptionData} from '@libs/ReportUtils';
+import {
+ getAutocompleteCategories,
+ getAutocompleteRecentCategories,
+ getAutocompleteRecentTags,
+ getAutocompleteTags,
+ getAutocompleteTaxList,
+ parseForAutocomplete,
+} from '@libs/SearchAutocompleteUtils';
import * as SearchQueryUtils from '@libs/SearchQueryUtils';
import Navigation from '@navigation/Navigation';
import variables from '@styles/variables';
@@ -26,8 +36,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import SearchRouterInput from './SearchRouterInput';
import SearchRouterList from './SearchRouterList';
-
-const SEARCH_DEBOUNCE_DELAY = 150;
+import type {ItemWithQuery} from './SearchRouterList';
type SearchRouterProps = {
onRouterClose: () => void;
@@ -39,18 +48,51 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
const [betas] = useOnyx(ONYXKEYS.BETAS);
const [recentSearches] = useOnyx(ONYXKEYS.RECENT_SEARCHES);
const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false});
+ const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]);
const {shouldUseNarrowLayout} = useResponsiveLayout();
const listRef = useRef(null);
- const taxRates = getAllTaxRates();
- const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST);
-
const [textInputValue, debouncedInputValue, setTextInputValue] = useDebouncedState('', 500);
- const [userSearchQuery, setUserSearchQuery] = useState(undefined);
const contextualReportID = useNavigationState, string | undefined>((state) => {
return state?.routes.at(-1)?.params?.reportID;
});
+
+ const activeWorkspaceID = useActiveWorkspaceFromNavigationState();
+ const policy = usePolicy(activeWorkspaceID);
+ const typeAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES);
+ const statusAutocompleteList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP});
+ const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE);
+ const allTaxRates = getAllTaxRates();
+ const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(allTaxRates, policy), [policy, allTaxRates]);
+ const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST);
+ const cardAutocompleteList = Object.values(cardList ?? {}).map((card) => card.bank);
+ const personalDetailsForParticipants = usePersonalDetails();
+ const participantsAutocompleteList = Object.values(personalDetailsForParticipants)
+ .filter((details) => details && details?.login)
+ // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
+ .map((details) => details?.login as string);
+
+ const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
+ const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES);
+ const categoryAutocompleteList = useMemo(() => {
+ return getAutocompleteCategories(allPolicyCategories, activeWorkspaceID);
+ }, [activeWorkspaceID, allPolicyCategories]);
+ const recentCategoriesAutocompleteList = useMemo(() => {
+ return getAutocompleteRecentCategories(allRecentCategories, activeWorkspaceID);
+ }, [activeWorkspaceID, allRecentCategories]);
+
+ const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST);
+ const currencyAutocompleteList = Object.keys(currencyList ?? {});
+ const [recentCurrencyAutocompleteList] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES);
+
+ const [allPoliciesTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
+ const [allRecentTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS);
+ const tagAutocompleteList = useMemo(() => {
+ return getAutocompleteTags(allPoliciesTags, activeWorkspaceID);
+ }, [activeWorkspaceID, allPoliciesTags]);
+ const recentTagsAutocompleteList = getAutocompleteRecentTags(allRecentTags, activeWorkspaceID);
+
const sortedRecentSearches = useMemo(() => {
return Object.values(recentSearches ?? {}).sort((a, b) => b.timestamp.localeCompare(a.timestamp));
}, [recentSearches]);
@@ -101,45 +143,173 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
const contextualReportData = contextualReportID ? searchOptions.recentReports?.find((option) => option.reportID === contextualReportID) : undefined;
- const clearUserQuery = () => {
- setTextInputValue('');
- setUserSearchQuery(undefined);
- };
-
- const onSearchChange = useMemo(
- // eslint-disable-next-line react-compiler/react-compiler
- () =>
- debounce((userQuery: string) => {
- if (!userQuery) {
- clearUserQuery();
- listRef.current?.updateAndScrollToFocusedIndex(-1);
+ const updateAutocomplete = useCallback(
+ (autocompleteValue: string, ranges: AutocompleteRange[], autocompleteType?: ValueOf) => {
+ const alreadyAutocompletedKeys: string[] = [];
+ ranges.forEach((range) => {
+ if (!autocompleteType || range.key !== autocompleteType) {
return;
}
- listRef.current?.updateAndScrollToFocusedIndex(0);
- const queryJSON = SearchQueryUtils.buildSearchQueryJSON(userQuery);
-
- if (queryJSON) {
- setUserSearchQuery(queryJSON);
- } else {
- Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} user query failed to parse`, userQuery, false);
+ alreadyAutocompletedKeys.push(range.value.toLowerCase());
+ });
+ switch (autocompleteType) {
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG: {
+ const autocompleteList = autocompleteValue ? tagAutocompleteList : recentTagsAutocompleteList ?? [];
+ const filteredTags = autocompleteList
+ .filter((tag) => tag.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tag))
+ .sort()
+ .slice(0, 10);
+ setAutocompleteSuggestions(
+ filteredTags.map((tagName) => ({
+ text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG}:${tagName}`,
+ query: `${SearchQueryUtils.sanitizeSearchValue(tagName)}`,
+ })),
+ );
+ return;
+ }
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY: {
+ const autocompleteList = autocompleteValue ? categoryAutocompleteList : recentCategoriesAutocompleteList;
+ const filteredCategories = autocompleteList
+ .filter((category) => {
+ return category.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(category.toLowerCase());
+ })
+ .sort()
+ .slice(0, 10);
+ setAutocompleteSuggestions(
+ filteredCategories.map((categoryName) => ({
+ text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY}:${categoryName}`,
+ query: `${SearchQueryUtils.sanitizeSearchValue(categoryName)}`,
+ })),
+ );
+ return;
+ }
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY: {
+ const autocompleteList = autocompleteValue ? currencyAutocompleteList : recentCurrencyAutocompleteList ?? [];
+ const filteredCurrencies = autocompleteList
+ .filter((currency) => currency.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(currency.toLowerCase()))
+ .sort()
+ .slice(0, 10);
+ setAutocompleteSuggestions(
+ filteredCurrencies.map((currencyName) => ({
+ text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY}:${currencyName}`,
+ query: `${currencyName}`,
+ })),
+ );
+ return;
+ }
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE: {
+ const filteredTaxRates = taxAutocompleteList
+ .filter((tax) => tax.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tax.toLowerCase()))
+ .sort()
+ .slice(0, 10);
+ setAutocompleteSuggestions(
+ filteredTaxRates.map((tax) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE}:${tax}`, query: `${SearchQueryUtils.sanitizeSearchValue(tax)}`})),
+ );
+ return;
+ }
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: {
+ const filteredParticipants = participantsAutocompleteList
+ .filter((participant) => participant.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.toLowerCase()))
+ .sort()
+ .slice(0, 10);
+ setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM}:${participant}`, query: `${participant}`})));
+ return;
+ }
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: {
+ const filteredParticipants = participantsAutocompleteList
+ .filter((participant) => participant.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.toLowerCase()))
+ .sort()
+ .slice(0, 10);
+ setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${participant}`, query: `${participant}`})));
+ return;
}
- }, SEARCH_DEBOUNCE_DELAY),
- // eslint-disable-next-line react-compiler/react-compiler
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [],
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN: {
+ const filteredChats = searchOptions.recentReports
+ .filter((chat) => chat.text?.toLowerCase()?.includes(autocompleteValue.toLowerCase()))
+ .sort((chatA, chatB) => (chatA > chatB ? 1 : -1))
+ .slice(0, 10);
+ setAutocompleteSuggestions(filteredChats.map((chat) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${chat.text}`, query: `${chat.reportID}`})));
+ return;
+ }
+ case CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE: {
+ const filteredTypes = typeAutocompleteList
+ .filter((type) => type.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(type.toLowerCase()))
+ .sort();
+ setAutocompleteSuggestions(filteredTypes.map((type) => ({text: `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${type}`, query: `${type}`})));
+ return;
+ }
+ case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: {
+ const filteredStatuses = statusAutocompleteList
+ .filter((status) => status.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(status))
+ .sort()
+ .slice(0, 10);
+ setAutocompleteSuggestions(filteredStatuses.map((status) => ({text: `${CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS}:${status}`, query: `${status}`})));
+ return;
+ }
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE: {
+ const filteredExpenseTypes = expenseTypes
+ .filter((expenseType) => expenseType.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(expenseType))
+ .sort();
+ setAutocompleteSuggestions(
+ filteredExpenseTypes.map((expenseType) => ({
+ text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE}:${expenseType}`,
+ query: `${expenseType}`,
+ })),
+ );
+ return;
+ }
+ case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID: {
+ const filteredCards = cardAutocompleteList
+ .filter((card) => card.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(card.toLowerCase()))
+ .sort()
+ .slice(0, 10);
+ setAutocompleteSuggestions(
+ filteredCards.map((card) => ({
+ text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID}:${card}`,
+ query: `${card}`,
+ })),
+ );
+ return;
+ }
+ default: {
+ setAutocompleteSuggestions(undefined);
+ }
+ }
+ },
+ [
+ tagAutocompleteList,
+ recentTagsAutocompleteList,
+ categoryAutocompleteList,
+ recentCategoriesAutocompleteList,
+ currencyAutocompleteList,
+ recentCurrencyAutocompleteList,
+ taxAutocompleteList,
+ participantsAutocompleteList,
+ searchOptions.recentReports,
+ typeAutocompleteList,
+ statusAutocompleteList,
+ expenseTypes,
+ cardAutocompleteList,
+ ],
);
- const updateUserSearchQuery = (newSearchQuery: string) => {
- setTextInputValue(newSearchQuery);
- onSearchChange(newSearchQuery);
- };
-
- const closeAndClearRouter = useCallback(() => {
- onRouterClose();
- clearUserQuery();
- // eslint-disable-next-line react-compiler/react-compiler
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [onRouterClose]);
+ const onSearchChange = useCallback(
+ (userQuery: string) => {
+ let newUserQuery = userQuery;
+ if (autocompleteSuggestions && userQuery.endsWith(',')) {
+ newUserQuery = `${userQuery.slice(0, userQuery.length - 1).trim()},`;
+ }
+ setTextInputValue(newUserQuery);
+ const autocompleteParsedQuery = parseForAutocomplete(newUserQuery);
+ updateAutocomplete(autocompleteParsedQuery?.autocomplete?.value ?? '', autocompleteParsedQuery?.ranges ?? [], autocompleteParsedQuery?.autocomplete?.key);
+ if (newUserQuery) {
+ listRef.current?.updateAndScrollToFocusedIndex(0);
+ } else {
+ listRef.current?.updateAndScrollToFocusedIndex(-1);
+ }
+ },
+ [autocompleteSuggestions, setTextInputValue, updateAutocomplete],
+ );
const onSearchSubmit = useCallback(
(query: SearchQueryJSON | undefined) => {
@@ -147,18 +317,16 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
return;
}
onRouterClose();
- const standardizedQuery = SearchQueryUtils.standardizeQueryJSON(query, cardList, taxRates);
+ const standardizedQuery = SearchQueryUtils.standardizeQueryJSON(query, cardList, allTaxRates);
const queryString = SearchQueryUtils.buildSearchQueryString(standardizedQuery);
Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: queryString}));
- clearUserQuery();
+ setTextInputValue('');
},
- // eslint-disable-next-line react-compiler/react-compiler
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [onRouterClose],
+ [allTaxRates, cardList, onRouterClose, setTextInputValue],
);
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => {
- closeAndClearRouter();
+ onRouterClose();
});
const modalWidth = shouldUseNarrowLayout ? styles.w100 : {width: variables.searchRouterPopoverWidth};
@@ -176,7 +344,6 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
)}
{
@@ -190,13 +357,15 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
isSearchingForReports={isSearchingForReports}
/>
diff --git a/src/components/Search/SearchRouter/SearchRouterInput.tsx b/src/components/Search/SearchRouter/SearchRouterInput.tsx
index 811c34b72a6e..6bc782f3d44a 100644
--- a/src/components/Search/SearchRouter/SearchRouterInput.tsx
+++ b/src/components/Search/SearchRouter/SearchRouterInput.tsx
@@ -16,9 +16,6 @@ type SearchRouterInputProps = {
/** Value of TextInput */
value: string;
- /** Setter to TextInput value */
- setValue: (searchTerm: string) => void;
-
/** Callback to update search in SearchRouter */
updateSearch: (searchTerm: string) => void;
@@ -58,7 +55,6 @@ type SearchRouterInputProps = {
function SearchRouterInput({
value,
- setValue,
updateSearch,
onSubmit = () => {},
routerListRef,
@@ -78,11 +74,6 @@ function SearchRouterInput({
const {isOffline} = useNetwork();
const offlineMessage: string = isOffline && shouldShowOfflineMessage ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : '';
- const onChangeText = (text: string) => {
- setValue(text);
- updateSearch(text);
- };
-
const inputWidth = isFullWidth ? styles.w100 : {width: variables.popoverWidth};
return (
@@ -92,7 +83,7 @@ function SearchRouterInput({
void;
+
+ /** Callback to update text input value */
+ setTextInputValue: (text: string) => void;
/** Recent searches */
recentSearches: Array | undefined;
@@ -37,17 +46,17 @@ type SearchRouterListProps = {
/** Recent reports */
recentReports: OptionData[];
+ /** Autocomplete items */
+ autocompleteItems: ItemWithQuery[] | undefined;
+
/** Callback to submit query when selecting a list item */
onSearchSubmit: (query: SearchQueryJSON | undefined) => void;
/** Context present when opening SearchRouter from a report, invoice or workspace page */
reportForContextualSearch?: OptionData;
- /** Callback to update search query when selecting contextual suggestion */
- updateUserSearchQuery: (newSearchQuery: string) => void;
-
/** Callback to close and clear SearchRouter */
- closeAndClearRouter: () => void;
+ closeRouter: () => void;
};
const setPerformanceTimersEnd = () => {
@@ -91,7 +100,7 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList
}
function SearchRouterList(
- {currentQuery, reportForContextualSearch, recentSearches, recentReports, onSearchSubmit, updateUserSearchQuery, closeAndClearRouter}: SearchRouterListProps,
+ {textInputValue, updateSearchValue, setTextInputValue, reportForContextualSearch, recentSearches, autocompleteItems, recentReports, onSearchSubmit, closeRouter}: SearchRouterListProps,
ref: ForwardedRef,
) {
const styles = useThemeStyles();
@@ -104,21 +113,22 @@ function SearchRouterList(
const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST);
const sections: Array> = [];
- if (currentQuery?.inputQuery) {
+ if (textInputValue) {
sections.push({
data: [
{
- text: currentQuery?.inputQuery,
+ text: textInputValue,
singleIcon: Expensicons.MagnifyingGlass,
- query: currentQuery?.inputQuery,
+ query: textInputValue,
itemStyle: styles.activeComponentBG,
keyForList: 'findItem',
+ searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH,
},
],
});
}
- if (reportForContextualSearch && !currentQuery?.inputQuery) {
+ if (reportForContextualSearch && !textInputValue) {
sections.push({
data: [
{
@@ -127,12 +137,26 @@ function SearchRouterList(
query: getContextualSearchQuery(reportForContextualSearch.reportID),
itemStyle: styles.activeComponentBG,
keyForList: 'contextualSearch',
- isContextualSearchItem: true,
+ searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION,
},
],
});
}
+ const autocompleteData = autocompleteItems?.map(({text, query}) => {
+ return {
+ text,
+ singleIcon: Expensicons.MagnifyingGlass,
+ query,
+ keyForList: query,
+ searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION,
+ };
+ });
+
+ if (autocompleteData && autocompleteData.length > 0) {
+ sections.push({title: translate('search.suggestions'), data: autocompleteData});
+ }
+
const recentSearchesData = recentSearches?.map(({query, timestamp}) => {
const searchQueryJSON = SearchQueryUtils.buildSearchQueryJSON(query);
return {
@@ -140,10 +164,11 @@ function SearchRouterList(
singleIcon: Expensicons.History,
query,
keyForList: timestamp,
+ searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH,
};
});
- if (!currentQuery?.inputQuery && recentSearchesData && recentSearchesData.length > 0) {
+ if (!textInputValue && recentSearchesData && recentSearchesData.length > 0) {
sections.push({title: translate('search.recentSearches'), data: recentSearchesData});
}
@@ -153,30 +178,51 @@ function SearchRouterList(
const onSelectRow = useCallback(
(item: OptionData | SearchQueryItem) => {
if (isSearchQueryItem(item)) {
- if (item.isContextualSearchItem) {
- // Handle selection of "Contextual search suggestion"
- updateUserSearchQuery(`${item?.query} ${currentQuery?.inputQuery ?? ''}`);
+ if (!item?.query) {
return;
}
-
- // Handle selection of "Recent search"
- if (!item?.query) {
+ if (item?.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) {
+ updateSearchValue(`${item?.query} `);
return;
}
+ if (item?.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) {
+ const trimmedUserSearchQuery = trimSearchQueryForAutocomplete(textInputValue);
+ updateSearchValue(`${trimmedUserSearchQuery}${item?.query} `);
+ return;
+ }
+
onSearchSubmit(SearchQueryUtils.buildSearchQueryJSON(item?.query));
}
// Handle selection of "Recent chat"
- closeAndClearRouter();
+ closeRouter();
if ('reportID' in item && item?.reportID) {
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item?.reportID));
} else if ('login' in item) {
Report.navigateToAndOpenReport(item.login ? [item.login] : [], false);
}
},
- [closeAndClearRouter, onSearchSubmit, currentQuery, updateUserSearchQuery],
+ [closeRouter, textInputValue, onSearchSubmit, updateSearchValue],
);
+ const onArrowFocus = useCallback(
+ (focusedItem: OptionData | SearchQueryItem) => {
+ if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !textInputValue) {
+ return;
+ }
+ const trimmedUserSearchQuery = trimSearchQueryForAutocomplete(textInputValue);
+ setTextInputValue(`${trimmedUserSearchQuery}${focusedItem?.query} `);
+ },
+ [setTextInputValue, textInputValue],
+ );
+
+ const getItemHeight = useCallback((item: OptionData | SearchQueryItem) => {
+ if (isSearchQueryItem(item)) {
+ return 44;
+ }
+ return 64;
+ }, []);
+
return (
sections={sections}
@@ -185,11 +231,13 @@ function SearchRouterList(
containerStyle={[styles.mh100]}
sectionListStyle={[shouldUseNarrowLayout ? styles.ph5 : styles.ph2, styles.pb2]}
listItemWrapperStyle={[styles.pr3, styles.pl3]}
+ getItemHeight={getItemHeight}
onLayout={setPerformanceTimersEnd}
ref={ref}
showScrollIndicator={!shouldUseNarrowLayout}
sectionTitleStyles={styles.mhn2}
shouldSingleExecuteRowSelect
+ onArrowFocus={onArrowFocus}
/>
);
}
diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts
index 5db88ae47e8d..d5be896c1c50 100644
--- a/src/components/Search/types.ts
+++ b/src/components/Search/types.ts
@@ -80,6 +80,18 @@ type SearchQueryJSON = {
flatFilters: QueryFilters;
} & SearchQueryAST;
+type AutocompleteRange = {
+ key: ValueOf;
+ length: number;
+ start: number;
+ value: string;
+};
+
+type SearchAutocompleteResult = {
+ autocomplete: AutocompleteRange | null;
+ ranges: AutocompleteRange[];
+};
+
export type {
SelectedTransactionInfo,
SelectedTransactions,
@@ -98,4 +110,6 @@ export type {
InvoiceSearchStatus,
TripSearchStatus,
ChatSearchStatus,
+ SearchAutocompleteResult,
+ AutocompleteRange,
};
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index 57423992e43e..3e1b3a3c2d70 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -97,6 +97,7 @@ function BaseSelectionList(
updateCellsBatchingPeriod = 50,
removeClippedSubviews = true,
shouldDelayFocus = true,
+ onArrowFocus = () => {},
shouldUpdateFocusedIndex = false,
onLongPressRow,
shouldShowTextInput = !!textInputLabel || !!textInputIconLeft,
@@ -281,6 +282,10 @@ function BaseSelectionList(
disabledIndexes: disabledArrowKeyIndexes,
isActive: isFocused,
onFocusedIndexChange: (index: number) => {
+ const focusedItem = flattenedSections.allOptions.at(index);
+ if (focusedItem) {
+ onArrowFocus(focusedItem);
+ }
scrollToIndex(index, true);
},
isFocused,
diff --git a/src/components/SelectionList/Search/SearchQueryListItem.tsx b/src/components/SelectionList/Search/SearchQueryListItem.tsx
index 369f527cdeba..3c9cc4c0cd8b 100644
--- a/src/components/SelectionList/Search/SearchQueryListItem.tsx
+++ b/src/components/SelectionList/Search/SearchQueryListItem.tsx
@@ -1,17 +1,19 @@
import React from 'react';
import {View} from 'react-native';
+import type {ValueOf} from 'type-fest';
import Icon from '@components/Icon';
import BaseListItem from '@components/SelectionList/BaseListItem';
import type {ListItem} from '@components/SelectionList/types';
import TextWithTooltip from '@components/TextWithTooltip';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
+import type CONST from '@src/CONST';
import type IconAsset from '@src/types/utils/IconAsset';
type SearchQueryItem = ListItem & {
singleIcon?: IconAsset;
query?: string;
- isContextualSearchItem?: boolean;
+ searchItemType?: ValueOf;
};
type SearchQueryListItemProps = {
diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts
index b2e175418813..8fb50456182c 100644
--- a/src/components/SelectionList/types.ts
+++ b/src/components/SelectionList/types.ts
@@ -515,6 +515,9 @@ type BaseSelectionListProps = Partial & {
/** Whether focus event should be delayed */
shouldDelayFocus?: boolean;
+ /** Callback to fire when the text input changes */
+ onArrowFocus?: (focusedItem: TItem) => void;
+
/** Whether to show the loading indicator for new options */
isLoadingNewOptions?: boolean;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 6f9a30a40d9e..b446a0bbcbc8 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -4352,6 +4352,7 @@ const translations = {
recentChats: 'Recent chats',
searchIn: 'Search in',
searchPlaceholder: 'Search for something',
+ suggestions: 'Suggestions',
},
genericErrorPage: {
title: 'Uh-oh, something went wrong!',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 20d33c706ced..fdbd83c01048 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -4399,6 +4399,7 @@ const translations = {
recentChats: 'Chats recientes',
searchIn: 'Buscar en',
searchPlaceholder: 'Busca algo',
+ suggestions: 'Sugerencias',
},
genericErrorPage: {
title: '¡Oh-oh, algo salió mal!',
diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts
new file mode 100644
index 000000000000..f33e2a82d445
--- /dev/null
+++ b/src/libs/SearchAutocompleteUtils.ts
@@ -0,0 +1,86 @@
+import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
+import type {SearchAutocompleteResult} from '@components/Search/types';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Policy, PolicyCategories, PolicyTagLists, RecentlyUsedCategories, RecentlyUsedTags} from '@src/types/onyx';
+import {getTagNamesFromTagsLists} from './PolicyUtils';
+import * as autocompleteParser from './SearchParser/autocompleteParser';
+
+function parseForAutocomplete(text: string) {
+ try {
+ const parsedAutocomplete = autocompleteParser.parse(text) as SearchAutocompleteResult;
+ return parsedAutocomplete;
+ } catch (e) {
+ console.error(`Error when parsing autocopmlete query"`, e);
+ }
+}
+
+function getAutocompleteTags(allPoliciesTagsLists: OnyxCollection, policyID?: string) {
+ const singlePolicyTagsList: PolicyTagLists | undefined = allPoliciesTagsLists?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`];
+ if (!singlePolicyTagsList) {
+ const uniqueTagNames = new Set();
+ const tagListsUnpacked = Object.values(allPoliciesTagsLists ?? {}).filter((item) => !!item) as PolicyTagLists[];
+ tagListsUnpacked
+ .map(getTagNamesFromTagsLists)
+ .flat()
+ .forEach((tag) => uniqueTagNames.add(tag));
+ return Array.from(uniqueTagNames);
+ }
+ return getTagNamesFromTagsLists(singlePolicyTagsList);
+}
+
+function getAutocompleteRecentTags(allRecentTags: OnyxCollection, policyID?: string) {
+ const singlePolicyRecentTags: RecentlyUsedTags | undefined = allRecentTags?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`];
+ if (!singlePolicyRecentTags) {
+ const uniqueTagNames = new Set();
+ Object.values(allRecentTags ?? {})
+ .map((recentTag) => Object.values(recentTag ?? {}))
+ .flat(2)
+ .forEach((tag) => uniqueTagNames.add(tag));
+ return Array.from(uniqueTagNames);
+ }
+ return Object.values(singlePolicyRecentTags ?? {}).flat(2);
+}
+
+function getAutocompleteCategories(allPolicyCategories: OnyxCollection, policyID?: string) {
+ const singlePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`];
+ if (!singlePolicyCategories) {
+ const uniqueCategoryNames = new Set();
+ Object.values(allPolicyCategories ?? {}).map((policyCategories) => Object.values(policyCategories ?? {}).forEach((category) => uniqueCategoryNames.add(category.name)));
+ return Array.from(uniqueCategoryNames);
+ }
+ return Object.values(singlePolicyCategories ?? {}).map((category) => category.name);
+}
+
+function getAutocompleteRecentCategories(allRecentCategories: OnyxCollection, policyID?: string) {
+ const singlePolicyRecentCategories = allRecentCategories?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`];
+ if (!singlePolicyRecentCategories) {
+ const uniqueCategoryNames = new Set();
+ Object.values(allRecentCategories ?? {}).map((policyCategories) => Object.values(policyCategories ?? {}).forEach((category) => uniqueCategoryNames.add(category)));
+ return Array.from(uniqueCategoryNames);
+ }
+ return Object.values(singlePolicyRecentCategories ?? {}).map((category) => category);
+}
+
+function getAutocompleteTaxList(allTaxRates: Record, policy?: OnyxEntry) {
+ if (policy) {
+ return Object.keys(policy?.taxRates?.taxes ?? {}).map((taxRateName) => taxRateName);
+ }
+ return Object.keys(allTaxRates).map((taxRateName) => taxRateName);
+}
+
+function trimSearchQueryForAutocomplete(searchQuery: string) {
+ const lastColonIndex = searchQuery.lastIndexOf(':');
+ const lastCommaIndex = searchQuery.lastIndexOf(',');
+ const trimmedUserSearchQuery = lastColonIndex > lastCommaIndex ? searchQuery.slice(0, lastColonIndex + 1) : searchQuery.slice(0, lastCommaIndex + 1);
+ return trimmedUserSearchQuery;
+}
+
+export {
+ parseForAutocomplete,
+ getAutocompleteTags,
+ getAutocompleteRecentTags,
+ getAutocompleteCategories,
+ getAutocompleteRecentCategories,
+ getAutocompleteTaxList,
+ trimSearchQueryForAutocomplete,
+};
diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts
index 51db9fd56ea6..c84e42704fb9 100644
--- a/src/libs/SearchQueryUtils.ts
+++ b/src/libs/SearchQueryUtils.ts
@@ -642,4 +642,5 @@ export {
buildCannedSearchQuery,
isCannedSearchQuery,
standardizeQueryJSON,
+ sanitizeSearchValue,
};