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, };