diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index d7b679f21891..e7d2e7c39042 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useMemo, useState} from 'react'; import {InteractionManager, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -6,13 +6,8 @@ import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import ConfirmModal from '@components/ConfirmModal'; import DecisionModal from '@components/DecisionModal'; -import Header from '@components/Header'; -import type HeaderWithBackButtonProps from '@components/HeaderWithBackButton/types'; -import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; -import * as Illustrations from '@components/Icon/Illustrations'; import {usePersonalDetails} from '@components/OnyxProvider'; -import Text from '@components/Text'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -20,101 +15,24 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as SearchActions from '@libs/actions/Search'; -import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; import * as SearchQueryUtils from '@libs/SearchQueryUtils'; import SearchSelectedNarrow from '@pages/Search/SearchSelectedNarrow'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; -import type IconAsset from '@src/types/utils/IconAsset'; import {useSearchContext} from './SearchContext'; -import SearchButton from './SearchRouter/SearchButton'; -import SearchRouterInput from './SearchRouter/SearchRouterInput'; +import SearchPageHeaderInput from './SearchPageHeaderInput'; import type {SearchQueryJSON} from './types'; -type HeaderWrapperProps = Pick & { - text: string; - value: string; - isCannedQuery: boolean; - onSubmit: () => void; - setValue: (input: string) => void; -}; - -function HeaderWrapper({icon, children, text, value, isCannedQuery, onSubmit, setValue}: HeaderWrapperProps) { - const styles = useThemeStyles(); - // If the icon is present, the header bar should be taller and use different font. - const isCentralPaneSettings = !!icon; - - return ( - - {isCannedQuery ? ( - - {!!icon && ( - - )} -
{text}} /> - {children} - - ) : ( - - - - )} - - ); -} - -type SearchPageHeaderProps = { - queryJSON: SearchQueryJSON; - hash: number; -}; +type SearchPageHeaderProps = {queryJSON: SearchQueryJSON}; type SearchHeaderOptionValue = DeepValueOf | undefined; -type HeaderContent = { - icon: IconAsset; - titleText: TranslationPaths; -}; - -function getHeaderContent(type: SearchDataTypes): HeaderContent { - switch (type) { - case CONST.SEARCH.DATA_TYPES.INVOICE: - return {icon: Illustrations.EnvelopeReceipt, titleText: 'workspace.common.invoices'}; - case CONST.SEARCH.DATA_TYPES.TRIP: - return {icon: Illustrations.Luggage, titleText: 'travel.trips'}; - case CONST.SEARCH.DATA_TYPES.CHAT: - return {icon: Illustrations.CommentBubblesBlue, titleText: 'common.chats'}; - case CONST.SEARCH.DATA_TYPES.EXPENSE: - default: - return {icon: Illustrations.MoneyReceipts, titleText: 'common.expenses'}; - } -} - -function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) { +function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); @@ -136,19 +54,10 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) { const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); - const {status, type} = queryJSON; - const isCannedQuery = SearchQueryUtils.isCannedSearchQuery(queryJSON); - const headerText = isCannedQuery ? translate(getHeaderContent(type).titleText) : SearchQueryUtils.buildUserReadableQueryString(queryJSON, personalDetails, cardList, reports, taxRates); - const [inputValue, setInputValue] = useState(headerText); - - useEffect(() => { - setInputValue(headerText); - }, [headerText]); + const {status, hash} = queryJSON; const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); - const headerIcon = getHeaderContent(type).icon; - const handleDeleteExpenses = () => { if (selectedTransactionsKeys.length === 0) { return; @@ -182,7 +91,7 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) { return; } - const reportIDList = selectedReports?.filter((report) => !!report) ?? []; + const reportIDList = selectedReports.filter((report): report is string => !!report) ?? []; SearchActions.exportSearchItemsToCSV( {query: status, jsonQuery: JSON.stringify(queryJSON), reportIDList, transactionIDList: selectedTransactionsKeys, policyIDs: [activeWorkspaceID ?? '']}, () => { @@ -327,41 +236,18 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) { return null; } - const onPress = () => { + const onFiltersButtonPress = () => { const filterFormValues = SearchQueryUtils.buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTagsLists, currencyList, personalDetails, cardList, reports, taxRates); SearchActions.updateAdvancedFilters(filterFormValues); Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS); }; - const onSubmit = () => { - if (!inputValue) { - return; - } - const inputQueryJSON = SearchQueryUtils.buildSearchQueryJSON(inputValue); - if (inputQueryJSON) { - // Todo traverse the tree to update all the display values into id values; this is only temporary until autocomplete code from SearchRouter is implement here - // After https://github.com/Expensify/App/pull/51633 is merged, autocomplete functionality will be included into this component, and `getFindIDFromDisplayValue` can be removed - const computeNodeValueFn = SearchQueryUtils.getFindIDFromDisplayValue(cardList, taxRates); - const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(inputQueryJSON, computeNodeValueFn); - const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery); - SearchActions.clearAllFilters(); - Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); - } else { - Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} user query failed to parse`, inputValue, false); - } - }; + const isCannedQuery = SearchQueryUtils.isCannedSearchQuery(queryJSON); return ( <> - + {headerButtonsOptions.length > 0 ? ( null} @@ -377,11 +263,10 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) { innerStyles={!isCannedQuery && [styles.searchRouterInputResults, styles.borderNone]} text={translate('search.filtersHeader')} icon={Expensicons.Filters} - onPress={onPress} + onPress={onFiltersButtonPress} /> )} - {isCannedQuery && } - + getAllTaxRates(), []); + + const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); + // The actual input text that the user sees + const [textInputValue, setTextInputValue] = useState(' '); // initial empty space to avoid quick flash of placeholder text + // The input text that was last used for autocomplete; needed for the SearchRouterList when browsing list via arrow keys + const [autocompleteQueryValue, setAutocompleteQueryValue] = useState(textInputValue); + + const [isAutocompleteListVisible, setIsAutocompleteListVisible] = useState(false); + const listRef = useRef(null); + + const {type, inputQuery: originalInputQuery} = queryJSON; + const isCannedQuery = SearchQueryUtils.isCannedSearchQuery(queryJSON); + const headerText = isCannedQuery ? translate(getHeaderContent(type).titleText) : ''; + const queryText = SearchQueryUtils.buildUserReadableQueryString(queryJSON, personalDetails, reports, taxRates); + + useEffect(() => { + setTextInputValue(queryText); + }, [queryText]); + + useEffect(() => { + const substitutionsMap = buildSubstitutionsMap(originalInputQuery, personalDetails, reports, taxRates); + setAutocompleteSubstitutions(substitutionsMap); + }, [originalInputQuery, personalDetails, reports, taxRates]); + + const onSearchQueryChange = useCallback( + (userQuery: string) => { + const updatedUserQuery = SearchAutocompleteUtils.getAutocompleteQueryWithComma(textInputValue, userQuery); + setTextInputValue(updatedUserQuery); + setAutocompleteQueryValue(updatedUserQuery); + + const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions); + setAutocompleteSubstitutions(updatedSubstitutionsMap); + + if (updatedUserQuery || textInputValue.length > 0) { + listRef.current?.updateAndScrollToFocusedIndex(0); + } else { + listRef.current?.updateAndScrollToFocusedIndex(-1); + } + }, + [autocompleteSubstitutions, setTextInputValue, textInputValue], + ); + + const submitSearch = useCallback( + (queryString: SearchQueryString) => { + if (!queryString) { + return; + } + + const cleanedQueryString = getQueryWithSubstitutions(queryString, autocompleteSubstitutions); + const userQueryJSON = SearchQueryUtils.buildSearchQueryJSON(cleanedQueryString); + + if (!userQueryJSON) { + Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} user query failed to parse`, {}, false); + return; + } + + const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(userQueryJSON, SearchQueryUtils.getUpdatedAmountValue); + const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery); + + Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); + + if (query !== originalInputQuery) { + SearchActions.clearAllFilters(); + setTextInputValue(''); + setAutocompleteQueryValue(''); + setIsAutocompleteListVisible(false); + } + }, + [autocompleteSubstitutions, originalInputQuery], + ); + + const onListItemPress = (item: OptionData | SearchQueryItem) => { + if (!isSearchQueryItem(item)) { + return; + } + + if (!item.searchQuery) { + return; + } + + if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { + const trimmedUserSearchQuery = SearchAutocompleteUtils.getQueryWithoutAutocompletedPart(textInputValue); + onSearchQueryChange(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `); + + if (item.text && item.autocompleteID) { + const substitutions = {...autocompleteSubstitutions, [item.text]: item.autocompleteID}; + + setAutocompleteSubstitutions(substitutions); + } + } else if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH) { + submitSearch(item.searchQuery); + } + }; + + const onListItemFocus = (focusedItem: SearchQueryItem) => { + if (!focusedItem.searchQuery) { + return; + } + + const trimmedUserSearchQuery = SearchAutocompleteUtils.getQueryWithoutAutocompletedPart(textInputValue); + setTextInputValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(focusedItem.searchQuery)} `); + + if (focusedItem.autocompleteID && focusedItem.text) { + const substitutions = {...autocompleteSubstitutions, [focusedItem.text]: focusedItem.autocompleteID}; + + setAutocompleteSubstitutions(substitutions); + } + }; + + const hideAutocompleteList = () => setIsAutocompleteListVisible(false); + const showAutocompleteList = () => setIsAutocompleteListVisible(true); + + if (isCannedQuery) { + const headerIcon = getHeaderContent(type).icon; + + return ( + + + +
{headerText}} /> + + {children} + + + + + ); + } + + const searchQueryItem = textInputValue + ? { + text: textInputValue, + singleIcon: Expensicons.MagnifyingGlass, + searchQuery: textInputValue, + itemStyle: styles.activeComponentBG, + keyForList: 'findItem', + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, + } + : undefined; + + const isHeaderInputActive = isAutocompleteListVisible; + + // we need `- BORDER_WIDTH` to achieve the effect that the input will not "jump" + const popoverHorizontalPosition = 12 - BORDER_WIDTH; + const autocompleteInputStyle = isHeaderInputActive + ? [ + styles.border, + styles.borderRadiusComponentLarge, + styles.pAbsolute, + styles.pt2, + {top: 8 - BORDER_WIDTH, left: popoverHorizontalPosition, right: popoverHorizontalPosition}, + {boxShadow: variables.popoverMenuShadow}, + ] + : [styles.pt4]; + const inputWrapperStyle = isHeaderInputActive ? styles.ph2 : null; + + return ( + + + { + submitSearch(textInputValue); + }} + autoFocus={false} + onFocus={showAutocompleteList} + onBlur={hideAutocompleteList} + wrapperStyle={[styles.searchRouterInputResults, styles.br2]} + wrapperFocusedStyle={styles.searchRouterInputResultsFocused} + outerWrapperStyle={inputWrapperStyle} + rightComponent={children} + routerListRef={listRef} + /> + + + + + + ); +} + +SearchPageHeaderInput.displayName = 'SearchPageHeaderInput'; + +export default SearchPageHeaderInput; diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 87cb065d5c88..8b98858405c2 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -1,50 +1,70 @@ import {useNavigationState} from '@react-navigation/native'; -import {Str} from 'expensify-common'; -import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {TextInputProps} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; import {usePersonalDetails} from '@components/OnyxProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; -import type {SearchAutocompleteQueryRange, SearchQueryString} from '@components/Search/types'; +import type {SearchQueryString} from '@components/Search/types'; +import {isSearchQueryItem} from '@components/SelectionList/Search/SearchQueryListItem'; +import type {SearchQueryItem} from '@components/SelectionList/Search/SearchQueryListItem'; import type {SelectionListHandle} from '@components/SelectionList/types'; -import useActiveWorkspace from '@hooks/useActiveWorkspace'; 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 * as CardUtils from '@libs/CardUtils'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import type {SearchOption} 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 SearchAutocompleteUtils from '@libs/SearchAutocompleteUtils'; import * as SearchQueryUtils from '@libs/SearchQueryUtils'; import Navigation from '@navigation/Navigation'; import variables from '@styles/variables'; -import * as Report from '@userActions/Report'; +import * as ReportUserActions from '@userActions/Report'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type PersonalDetails from '@src/types/onyx/PersonalDetails'; +import type Report from '@src/types/onyx/Report'; import {getQueryWithSubstitutions} from './getQueryWithSubstitutions'; import type {SubstitutionMap} from './getQueryWithSubstitutions'; import {getUpdatedSubstitutionsMap} from './getUpdatedSubstitutionsMap'; import SearchRouterInput from './SearchRouterInput'; import SearchRouterList from './SearchRouterList'; -import type {AutocompleteItemData} from './SearchRouterList'; + +function getContextualSearchAutocompleteKey(item: SearchQueryItem) { + if (item.roomType === CONST.SEARCH.DATA_TYPES.INVOICE) { + return `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${item.searchQuery}`; + } + if (item.roomType === CONST.SEARCH.DATA_TYPES.CHAT) { + return `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${item.searchQuery}`; + } +} + +function getContextualSearchQuery(item: SearchQueryItem) { + const baseQuery = `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${item.roomType}`; + let additionalQuery = ''; + + switch (item.roomType) { + case CONST.SEARCH.DATA_TYPES.EXPENSE: + case CONST.SEARCH.DATA_TYPES.INVOICE: + additionalQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID}:${item.policyID}`; + if (item.roomType === CONST.SEARCH.DATA_TYPES.INVOICE && item.autocompleteID) { + additionalQuery += ` ${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${SearchQueryUtils.sanitizeSearchValue(item.searchQuery ?? '')}`; + } + break; + case CONST.SEARCH.DATA_TYPES.CHAT: + default: + additionalQuery = ` ${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${SearchQueryUtils.sanitizeSearchValue(item.searchQuery ?? '')}`; + break; + } + return baseQuery + additionalQuery; +} type SearchRouterProps = { onRouterClose: () => void; @@ -57,13 +77,20 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: 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 [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); + const personalDetails = usePersonalDetails(); + const [reports = {}] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const taxRates = getAllTaxRates(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const listRef = useRef(null); + // The actual input text that the user sees const [textInputValue, debouncedInputValue, setTextInputValue] = useDebouncedState('', 500); + // The input text that was last used for autocomplete; needed for the SearchRouterList when browsing list via arrow keys + const [autocompleteQueryValue, setAutocompleteQueryValue] = useState(textInputValue); + const contextualReportID = useNavigationState, string | undefined>((state) => { return state?.routes.at(-1)?.params?.reportID; }); @@ -105,256 +132,105 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) return searchOptions.recentReports.slice(0, 10); } - const reports: OptionData[] = [...filteredOptions.recentReports, ...filteredOptions.personalDetails]; + const reportOptions: OptionData[] = [...filteredOptions.recentReports, ...filteredOptions.personalDetails]; if (filteredOptions.userToInvite) { - reports.push(filteredOptions.userToInvite); + reportOptions.push(filteredOptions.userToInvite); } - return reports.slice(0, 10); + return reportOptions.slice(0, 10); }, [debouncedInputValue, filteredOptions, searchOptions]); - const contextualReportData = contextualReportID ? searchOptions.recentReports?.find((option) => option.reportID === contextualReportID) : undefined; - - const {activeWorkspaceID} = useActiveWorkspace(); - 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 [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); - const cardAutocompleteList = Object.values(cardList); - const personalDetailsForParticipants = usePersonalDetails(); - - const participantsAutocompleteList = useMemo( - () => - Object.values(personalDetailsForParticipants) - .filter((details): details is NonNullable => !!(details && details?.login)) - .map((details) => ({ - name: details.displayName ?? Str.removeSMSDomain(details.login ?? ''), - accountID: details?.accountID.toString(), - })), - [personalDetailsForParticipants], - ); - const allTaxRates = getAllTaxRates(); - const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(allTaxRates, policy), [policy, allTaxRates]); - 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 updateAutocomplete = useCallback( - (autocompleteValue: string, ranges: SearchAutocompleteQueryRange[], autocompleteType?: ValueOf) => { - const alreadyAutocompletedKeys: string[] = []; - ranges.forEach((range) => { - if (!autocompleteType || range.key !== autocompleteType) { - return; - } - alreadyAutocompletedKeys.push(range.value.toLowerCase()); - }); - - let filteredAutocompleteSuggestions: AutocompleteItemData[] | undefined; - 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); - - filteredAutocompleteSuggestions = filteredTags.map((tagName) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, - text: tagName, - })); - break; - } - 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); - - filteredAutocompleteSuggestions = filteredCategories.map((categoryName) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, - text: categoryName, - })); - break; - } - 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); - - filteredAutocompleteSuggestions = filteredCurrencies.map((currencyName) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY, - text: currencyName, - })); - break; - } - case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE: { - const filteredTaxRates = taxAutocompleteList - .filter((tax) => tax.taxRateName.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tax.taxRateName.toLowerCase())) - .sort() - .slice(0, 10); - filteredAutocompleteSuggestions = filteredTaxRates.map((tax) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, - text: tax.taxRateName, - autocompleteID: tax.taxRateIds.join(','), - })); - - break; - } - case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: { - const filteredParticipants = participantsAutocompleteList - .filter( - (participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase()), - ) - .sort() - .slice(0, 10); - filteredAutocompleteSuggestions = filteredParticipants.map((participant) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, - text: participant.name, - autocompleteID: participant.accountID, - })); - break; - } - case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: { - const filteredParticipants = participantsAutocompleteList - .filter( - (participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase()), - ) - .sort() - .slice(0, 10); - filteredAutocompleteSuggestions = filteredParticipants.map((participant) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, - text: participant.name, - autocompleteID: participant.accountID, - })); - break; - } - 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); - filteredAutocompleteSuggestions = filteredChats.map((chat) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.IN, - text: chat.text ?? '', - autocompleteID: chat.reportID, - })); - break; - } - case CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE: { - const filteredTypes = typeAutocompleteList - .filter((type) => type.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(type.toLowerCase())) - .sort(); - filteredAutocompleteSuggestions = filteredTypes.map((type) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE, text: type})); - break; - } - case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: { - const filteredStatuses = statusAutocompleteList - .filter((status) => status.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(status)) - .sort() - .slice(0, 10); - filteredAutocompleteSuggestions = filteredStatuses.map((status) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS, text: status})); - break; - } - case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE: { - const filteredExpenseTypes = expenseTypes - .filter((expenseType) => expenseType.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(expenseType)) - .sort(); - - filteredAutocompleteSuggestions = filteredExpenseTypes.map((expenseType) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE, - text: expenseType, - })); - break; - } - case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID: { - const filteredCards = cardAutocompleteList - .filter((card) => card.bank.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(card.bank.toLowerCase())) - .sort() - .slice(0, 10); - - filteredAutocompleteSuggestions = filteredCards.map((card) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, - text: CardUtils.getCardDescription(card.cardID), - autocompleteID: card.cardID.toString(), - })); - break; - } - default: { - filteredAutocompleteSuggestions = undefined; - } - } - setAutocompleteSuggestions(filteredAutocompleteSuggestions); - }, - [ - tagAutocompleteList, - recentTagsAutocompleteList, - categoryAutocompleteList, - recentCategoriesAutocompleteList, - currencyAutocompleteList, - recentCurrencyAutocompleteList, - taxAutocompleteList, - participantsAutocompleteList, - searchOptions.recentReports, - typeAutocompleteList, - statusAutocompleteList, - expenseTypes, - cardAutocompleteList, - ], - ); + const reportForContextualSearch = contextualReportID ? searchOptions.recentReports?.find((option) => option.reportID === contextualReportID) : undefined; - const prevUserQueryRef = useRef(null); useEffect(() => { - Report.searchInServer(debouncedInputValue.trim()); + ReportUserActions.searchInServer(debouncedInputValue.trim()); }, [debouncedInputValue]); - const onSearchChange = useCallback( - (userQuery: string) => { - let newUserQuery = userQuery; - if (autocompleteSuggestions && userQuery.endsWith(',')) { - newUserQuery = `${userQuery.slice(0, userQuery.length - 1).trim()},`; + const sections = []; + + if (reportForContextualSearch && !textInputValue) { + const reportQueryValue = reportForContextualSearch.text ?? reportForContextualSearch.alternateText ?? reportForContextualSearch.reportID; + + let roomType: ValueOf = CONST.SEARCH.DATA_TYPES.CHAT; + let autocompleteID = reportForContextualSearch.reportID; + if (reportForContextualSearch.isInvoiceRoom) { + roomType = CONST.SEARCH.DATA_TYPES.INVOICE; + const report = reportForContextualSearch as SearchOption; + if (report.item && report.item?.invoiceReceiver && report.item.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) { + autocompleteID = report.item.invoiceReceiver.accountID.toString(); + } else { + autocompleteID = ''; } - setTextInputValue(newUserQuery); - const autocompleteParsedQuery = parseForAutocomplete(newUserQuery); - updateAutocomplete(autocompleteParsedQuery?.autocomplete?.value ?? '', autocompleteParsedQuery?.ranges ?? [], autocompleteParsedQuery?.autocomplete?.key); + } + if (reportForContextualSearch.isPolicyExpenseChat) { + roomType = CONST.SEARCH.DATA_TYPES.EXPENSE; + autocompleteID = reportForContextualSearch.policyID ?? ''; + } + + sections.push({ + data: [ + { + text: `${translate('search.searchIn')} ${reportForContextualSearch.text ?? reportForContextualSearch.alternateText}`, + singleIcon: Expensicons.MagnifyingGlass, + searchQuery: reportQueryValue, + autocompleteID, + itemStyle: styles.activeComponentBG, + keyForList: 'contextualSearch', + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION, + roomType, + policyID: reportForContextualSearch.policyID, + }, + ], + }); + } + + const recentSearchesData = sortedRecentSearches?.slice(0, 5).map(({query, timestamp}) => { + const searchQueryJSON = SearchQueryUtils.buildSearchQueryJSON(query); + return { + text: searchQueryJSON ? SearchQueryUtils.buildUserReadableQueryString(searchQueryJSON, personalDetails, reports, taxRates) : query, + singleIcon: Expensicons.History, + searchQuery: query, + keyForList: timestamp, + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, + }; + }); + + if (!textInputValue && recentSearchesData && recentSearchesData.length > 0) { + sections.push({title: translate('search.recentSearches'), data: recentSearchesData}); + } + + const styledRecentReports = recentReports.map((item) => ({...item, pressableStyle: styles.br2, wrapperStyle: [styles.pr3, styles.pl3]})); + sections.push({title: translate('search.recentChats'), data: styledRecentReports}); + + const searchQueryItem = textInputValue + ? { + text: textInputValue, + singleIcon: Expensicons.MagnifyingGlass, + searchQuery: textInputValue, + itemStyle: styles.activeComponentBG, + keyForList: 'findItem', + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, + } + : undefined; + + const onSearchQueryChange = useCallback( + (userQuery: string) => { + const updatedUserQuery = SearchAutocompleteUtils.getAutocompleteQueryWithComma(textInputValue, userQuery); + setTextInputValue(updatedUserQuery); + setAutocompleteQueryValue(updatedUserQuery); const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions); setAutocompleteSubstitutions(updatedSubstitutionsMap); - if (newUserQuery || !isEmpty(prevUserQueryRef.current)) { + if (updatedUserQuery || textInputValue.length > 0) { listRef.current?.updateAndScrollToFocusedIndex(0); } else { listRef.current?.updateAndScrollToFocusedIndex(-1); } - - // Store the previous newUserQuery - prevUserQueryRef.current = newUserQuery; }, - [autocompleteSubstitutions, autocompleteSuggestions, setTextInputValue, updateAutocomplete], + [autocompleteSubstitutions, setTextInputValue, textInputValue], ); - const onSearchSubmit = useCallback( + const submitSearch = useCallback( (queryString: SearchQueryString) => { const cleanedQueryString = getQueryWithSubstitutions(queryString, autocompleteSubstitutions); const queryJSON = SearchQueryUtils.buildSearchQueryJSON(cleanedQueryString); @@ -369,14 +245,63 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); setTextInputValue(''); + setAutocompleteQueryValue(''); }, [autocompleteSubstitutions, onRouterClose, setTextInputValue], ); - const onAutocompleteSuggestionClick = (autocompleteKey: string, autocompleteID: string) => { - const substitutions = {...autocompleteSubstitutions, [autocompleteKey]: autocompleteID}; + const onListItemPress = (item: OptionData | SearchQueryItem) => { + if (isSearchQueryItem(item)) { + if (!item.searchQuery) { + return; + } + + if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { + const searchQuery = getContextualSearchQuery(item); + onSearchQueryChange(`${searchQuery} `); + + const autocompleteKey = getContextualSearchAutocompleteKey(item); + if (autocompleteKey && item.autocompleteID) { + const substitutions = {...autocompleteSubstitutions, [autocompleteKey]: item.autocompleteID}; + + setAutocompleteSubstitutions(substitutions); + } + } else if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { + const trimmedUserSearchQuery = SearchAutocompleteUtils.getQueryWithoutAutocompletedPart(textInputValue); + onSearchQueryChange(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `); + + if (item.text && item.autocompleteID) { + const substitutions = {...autocompleteSubstitutions, [item.text]: item.autocompleteID}; - setAutocompleteSubstitutions(substitutions); + setAutocompleteSubstitutions(substitutions); + } + } else { + submitSearch(item.searchQuery); + } + } else { + onRouterClose(); + + if (item?.reportID) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item?.reportID)); + } else if ('login' in item) { + ReportUserActions.navigateToAndOpenReport(item.login ? [item.login] : [], false); + } + } + }; + + const onListItemFocus = (focusedItem: SearchQueryItem) => { + if (!focusedItem.searchQuery) { + return; + } + + const trimmedUserSearchQuery = SearchAutocompleteUtils.getQueryWithoutAutocompletedPart(textInputValue); + setTextInputValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(focusedItem.searchQuery)} `); + + if (focusedItem.autocompleteID && focusedItem.text) { + const substitutions = {...autocompleteSubstitutions, [focusedItem.text]: focusedItem.autocompleteID}; + + setAutocompleteSubstitutions(substitutions); + } }; useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => { @@ -399,9 +324,9 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) { - onSearchSubmit(textInputValue); + submitSearch(textInputValue); }} caretHidden={shouldHideInputCaret} routerListRef={listRef} @@ -412,16 +337,11 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) isSearchingForReports={isSearchingForReports} /> diff --git a/src/components/Search/SearchRouter/SearchRouterInput.tsx b/src/components/Search/SearchRouter/SearchRouterInput.tsx index 6b99588a21df..3bd9146cac84 100644 --- a/src/components/Search/SearchRouter/SearchRouterInput.tsx +++ b/src/components/Search/SearchRouter/SearchRouterInput.tsx @@ -17,7 +17,7 @@ type SearchRouterInputProps = { value: string; /** Callback to update search in SearchRouter */ - updateSearch: (searchTerm: string) => void; + onSearchQueryChange: (searchTerm: string) => void; /** Callback invoked when the user submits the input */ onSubmit?: () => void; @@ -34,6 +34,12 @@ type SearchRouterInputProps = { /** Whether the offline message should be shown */ shouldShowOfflineMessage?: boolean; + /** Callback to call when the input gets focus */ + onFocus?: () => void; + + /** Callback to call when the input gets blur */ + onBlur?: () => void; + /** Any additional styles to apply */ wrapperStyle?: StyleProp; @@ -52,13 +58,15 @@ type SearchRouterInputProps = { function SearchRouterInput({ value, - updateSearch, + onSearchQueryChange, onSubmit = () => {}, routerListRef, isFullWidth, disabled = false, shouldShowOfflineMessage = false, autoFocus = true, + onFocus, + onBlur, caretHidden = false, wrapperStyle, wrapperFocusedStyle, @@ -81,7 +89,7 @@ function SearchRouterInput({ { setIsFocused(true); routerListRef?.current?.updateExternalTextInputFocus(true); + onFocus?.(); }} onBlur={() => { setIsFocused(false); routerListRef?.current?.updateExternalTextInputFocus(false); + onBlur?.(); }} isLoading={!!isSearchingForReports} /> diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index b16894f65f51..f626a3b34daf 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -1,38 +1,40 @@ -import React, {forwardRef, useCallback} from 'react'; +import {Str} from 'expensify-common'; +import React, {forwardRef, useMemo} from 'react'; import type {ForwardedRef} from 'react'; import {useOnyx} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; import * as Expensicons from '@components/Icon/Expensicons'; -import {usePersonalDetails} from '@components/OnyxProvider'; -import type {SearchFilterKey, SearchQueryString} from '@components/Search/types'; +import {useOptionsList} from '@components/OptionListContextProvider'; +import type {SearchFilterKey} from '@components/Search/types'; import SelectionList from '@components/SelectionList'; -import SearchQueryListItem from '@components/SelectionList/Search/SearchQueryListItem'; +import SearchQueryListItem, {isSearchQueryItem} from '@components/SelectionList/Search/SearchQueryListItem'; import type {SearchQueryItem, SearchQueryListItemProps} from '@components/SelectionList/Search/SearchQueryListItem'; import type {SectionListDataType, SelectionListHandle, UserListItemProps} from '@components/SelectionList/types'; import UserListItem from '@components/SelectionList/UserListItem'; +import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import Navigation from '@libs/Navigation/Navigation'; +import * as CardUtils from '@libs/CardUtils'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; import type {SearchOption} from '@libs/OptionsListUtils'; import Performance from '@libs/Performance'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; -import {getQueryWithoutAutocompletedPart} from '@libs/SearchAutocompleteUtils'; -import * as SearchQueryUtils from '@libs/SearchQueryUtils'; -import * as ReportUserActions from '@userActions/Report'; +import { + getAutocompleteCategories, + getAutocompleteRecentCategories, + getAutocompleteRecentTags, + getAutocompleteTags, + getAutocompleteTaxList, + parseForAutocomplete, +} from '@libs/SearchAutocompleteUtils'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type Report from '@src/types/onyx/Report'; +import type PersonalDetails from '@src/types/onyx/PersonalDetails'; import {getSubstitutionMapKey} from './getQueryWithSubstitutions'; -type SearchQueryItemData = { - query: string; - text?: string; -}; - type AutocompleteItemData = { filterKey: SearchFilterKey; text: string; @@ -40,35 +42,30 @@ type AutocompleteItemData = { }; type SearchRouterListProps = { - /** value of TextInput */ - textInputValue: string; - - /** Callback to update text input value along with autocomplete suggestions */ - updateSearchValue: (newValue: string) => void; - - /** Callback to update text input value */ - setTextInputValue: (text: string) => void; + /** Value of TextInput */ + autocompleteQueryValue: string; - /** Recent searches */ - recentSearches: Array | undefined; + /** An optional item to always display on the top of the router list */ + searchQueryItem?: SearchQueryItem; - /** Recent reports */ - recentReports: OptionData[]; + /** Any extra sections that should be displayed in the router list */ + additionalSections?: Array>; - /** Autocomplete items */ - autocompleteSuggestions: AutocompleteItemData[] | undefined; + shouldPreventDefault?: boolean; - /** Callback to submit query when selecting a list item */ - onSearchSubmit: (query: SearchQueryString) => void; + /** Callback to call when an item is clicked/selected */ + onListItemPress: (item: OptionData | SearchQueryItem) => void; - /** Context present when opening SearchRouter from a report, invoice or workspace page */ - reportForContextualSearch?: OptionData; - - /** Callback to run when user clicks a suggestion item that contains autocomplete data */ - onAutocompleteSuggestionClick: (autocompleteKey: string, autocompleteID: string) => void; + /** Callback to call when an item is focused via arrow buttons */ + onListItemFocus: (item: SearchQueryItem) => void; +}; - /** Callback to close and clear SearchRouter */ - closeRouter: () => void; +const defaultListOptions = { + userToInvite: null, + recentReports: [], + personalDetails: [], + currentUserOption: null, + categoryOptions: [], }; const setPerformanceTimersEnd = () => { @@ -76,30 +73,6 @@ const setPerformanceTimersEnd = () => { Performance.markEnd(CONST.TIMING.OPEN_SEARCH); }; -function getContextualSearchQuery(item: SearchQueryItem) { - const baseQuery = `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${item.roomType}`; - let additionalQuery = ''; - - switch (item.roomType) { - case CONST.SEARCH.DATA_TYPES.EXPENSE: - case CONST.SEARCH.DATA_TYPES.INVOICE: - additionalQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID}:${item.policyID}`; - if (item.roomType === CONST.SEARCH.DATA_TYPES.INVOICE && item.autocompleteID) { - additionalQuery += ` ${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${SearchQueryUtils.sanitizeSearchValue(item.searchQuery ?? '')}`; - } - break; - case CONST.SEARCH.DATA_TYPES.CHAT: - default: - additionalQuery = ` ${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${SearchQueryUtils.sanitizeSearchValue(item.searchQuery ?? '')}`; - break; - } - return baseQuery + additionalQuery; -} - -function isSearchQueryItem(item: OptionData | SearchQueryItem): item is SearchQueryItem { - return 'searchItemType' in item; -} - function isSearchQueryListItem(listItem: UserListItemProps | SearchQueryListItemProps): listItem is SearchQueryListItemProps { return isSearchQueryItem(listItem.item); } @@ -131,192 +104,297 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList ); } +// Todo rename to SearchAutocompleteList once it's used in both Router and SearchPage function SearchRouterList( - { - textInputValue, - updateSearchValue, - setTextInputValue, - reportForContextualSearch, - recentSearches, - autocompleteSuggestions, - recentReports, - onSearchSubmit, - onAutocompleteSuggestionClick, - closeRouter, - }: SearchRouterListProps, + {autocompleteQueryValue, searchQueryItem, additionalSections, shouldPreventDefault = true, onListItemFocus, onListItemPress}: SearchRouterListProps, ref: ForwardedRef, ) { const styles = useThemeStyles(); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const personalDetails = usePersonalDetails(); - const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); - const taxRates = getAllTaxRates(); - const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); - const sections: Array> = []; - - if (textInputValue) { - sections.push({ - data: [ - { - text: textInputValue, - singleIcon: Expensicons.MagnifyingGlass, - searchQuery: textInputValue, - itemStyle: styles.activeComponentBG, - keyForList: 'findItem', - searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, - }, - ], - }); - } + const {activeWorkspaceID} = useActiveWorkspace(); + const policy = usePolicy(activeWorkspaceID); + const [betas] = useOnyx(ONYXKEYS.BETAS); - if (reportForContextualSearch && !textInputValue) { - const reportQueryValue = reportForContextualSearch.text ?? reportForContextualSearch.alternateText ?? reportForContextualSearch.reportID; - let roomType: ValueOf = CONST.SEARCH.DATA_TYPES.CHAT; - let autocompleteID = reportForContextualSearch.reportID; - if (reportForContextualSearch.isInvoiceRoom) { - roomType = CONST.SEARCH.DATA_TYPES.INVOICE; - const report = reportForContextualSearch as SearchOption; - if (report.item && report.item?.invoiceReceiver && report.item.invoiceReceiver?.type === CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL) { - autocompleteID = report.item.invoiceReceiver.accountID.toString(); - } else { - autocompleteID = ''; - } + const {options, areOptionsInitialized} = useOptionsList(); + const searchOptions = useMemo(() => { + if (!areOptionsInitialized) { + return defaultListOptions; } - if (reportForContextualSearch.isPolicyExpenseChat) { - roomType = CONST.SEARCH.DATA_TYPES.EXPENSE; - autocompleteID = reportForContextualSearch.policyID ?? ''; + return OptionsListUtils.getSearchOptions(options, '', betas ?? []); + }, [areOptionsInitialized, betas, options]); + + 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 [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const cardAutocompleteList = Object.values(cardList); + + const participantsAutocompleteList = useMemo(() => { + if (!areOptionsInitialized) { + return []; } - sections.push({ - data: [ - { - text: `${translate('search.searchIn')} ${reportForContextualSearch.text ?? reportForContextualSearch.alternateText}`, - singleIcon: Expensicons.MagnifyingGlass, - searchQuery: reportQueryValue, - autocompleteID, - itemStyle: styles.activeComponentBG, - keyForList: 'contextualSearch', - searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION, - roomType, - policyID: reportForContextualSearch.policyID, - }, - ], + + const filteredOptions = OptionsListUtils.getFilteredOptions({ + reports: options.reports, + personalDetails: options.personalDetails, + excludeLogins: CONST.EXPENSIFY_EMAILS, + maxRecentReportsToShow: 0, + includeSelfDM: true, }); - } - const autocompleteData = autocompleteSuggestions?.map(({filterKey, text, autocompleteID}) => { - return { - text: getSubstitutionMapKey(filterKey, text), - singleIcon: Expensicons.MagnifyingGlass, - searchQuery: text, - autocompleteID, - keyForList: autocompleteID ?? text, // in case we have a unique identifier then use it because text might not be unique - searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION, - }; - }); - - if (autocompleteData && autocompleteData.length > 0) { - sections.push({title: translate('search.suggestions'), data: autocompleteData}); - } + // This cast is needed as something is incorrect in types OptionsListUtils.getOptions around l1490 and includeRecentReports types + const personalDetailsFromOptions = filteredOptions.personalDetails.map((option) => (option as SearchOption).item); + const autocompleteOptions = Object.values(personalDetailsFromOptions) + .filter((details): details is NonNullable => !!(details && details?.login)) + .map((details) => { + return { + name: details.displayName ?? Str.removeSMSDomain(details.login ?? ''), + accountID: details.accountID.toString(), + }; + }); + const currentUser = filteredOptions.currentUserOption ? (filteredOptions.currentUserOption as SearchOption).item : undefined; + if (currentUser) { + autocompleteOptions.push({ + name: currentUser.displayName ?? Str.removeSMSDomain(currentUser.login ?? ''), + accountID: currentUser.accountID?.toString() ?? '-1', + }); + } - const recentSearchesData = recentSearches?.map(({query, timestamp}) => { - const searchQueryJSON = SearchQueryUtils.buildSearchQueryJSON(query); - return { - text: searchQueryJSON ? SearchQueryUtils.buildUserReadableQueryString(searchQueryJSON, personalDetails, cardList, reports, taxRates) : query, - singleIcon: Expensicons.History, - searchQuery: query, - keyForList: timestamp, - searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, - }; - }); - - if (!textInputValue && recentSearchesData && recentSearchesData.length > 0) { - sections.push({title: translate('search.recentSearches'), data: recentSearchesData}); - } + return autocompleteOptions; + }, [areOptionsInitialized, options.personalDetails, options.reports]); - const styledRecentReports = recentReports.map((item) => ({...item, pressableStyle: styles.br2, wrapperStyle: [styles.pr3, styles.pl3]})); - sections.push({title: translate('search.recentChats'), data: styledRecentReports}); - - const onSelectRow = useCallback( - (item: OptionData | SearchQueryItem) => { - if (isSearchQueryItem(item)) { - if (!item.searchQuery) { - return; - } - if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { - const searchQuery = getContextualSearchQuery(item); - updateSearchValue(`${searchQuery} `); - - if (item.roomType === CONST.SEARCH.DATA_TYPES.INVOICE && item.autocompleteID) { - const autocompleteKey = `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${item.searchQuery}`; - onAutocompleteSuggestionClick(autocompleteKey, item.autocompleteID); - } - if (item.roomType === CONST.SEARCH.DATA_TYPES.CHAT && item.autocompleteID) { - const autocompleteKey = `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${item.searchQuery}`; - onAutocompleteSuggestionClick(autocompleteKey, item.autocompleteID); - } - return; - } - if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { - const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue); - updateSearchValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `); - - if (item.autocompleteID && item.text) { - onAutocompleteSuggestionClick(item.text, item.autocompleteID); - } - return; - } - - onSearchSubmit(item.searchQuery); + const taxRates = getAllTaxRates(); + const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(taxRates, policy), [policy, taxRates]); + + 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 autocompleteSuggestions = useMemo(() => { + const autocompleteParsedQuery = parseForAutocomplete(autocompleteQueryValue); + const {autocomplete, ranges = []} = autocompleteParsedQuery ?? {}; + const autocompleteKey = autocomplete?.key; + const autocompleteValue = autocomplete?.value ?? ''; + + const alreadyAutocompletedKeys = ranges + .filter((range) => { + return autocompleteKey && range.key === autocompleteKey; + }) + .map((range) => range.value.toLowerCase()); + + switch (autocompleteKey) { + 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); + + return filteredTags.map((tagName) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, + text: tagName, + })); + } + case CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY: { + const autocompleteList = autocompleteValue ? categoryAutocompleteList : recentCategoriesAutocompleteList; + const filteredCategories = autocompleteList + .filter((category) => category.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(category.toLowerCase())) + .sort() + .slice(0, 10); + + return filteredCategories.map((categoryName) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, + text: categoryName, + })); + } + 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); + + return filteredCurrencies.map((currencyName) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY, + text: currencyName, + })); + } + case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE: { + const filteredTaxRates = taxAutocompleteList + .filter((tax) => tax.taxRateName.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tax.taxRateName.toLowerCase())) + .sort() + .slice(0, 10); + + return filteredTaxRates.map((tax) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, + text: tax.taxRateName, + autocompleteID: tax.taxRateIds.join(','), + })); + } + case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: { + const filteredParticipants = participantsAutocompleteList + .filter((participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase())) + .slice(0, 10); + + return filteredParticipants.map((participant) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, + text: participant.name, + autocompleteID: participant.accountID, + })); + } + case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: { + const filteredParticipants = participantsAutocompleteList + .filter((participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase())) + .slice(0, 10); + + return filteredParticipants.map((participant) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, + text: participant.name, + autocompleteID: participant.accountID, + })); + } + case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN: { + const filteredChats = searchOptions.recentReports + .filter((chat) => chat.text?.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(chat.text.toLowerCase())) + .slice(0, 10); + + return filteredChats.map((chat) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.IN, + text: chat.text ?? '', + autocompleteID: chat.reportID, + })); } + case CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE: { + const filteredTypes = typeAutocompleteList + .filter((type) => type.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(type.toLowerCase())) + .sort(); - // Handle selection of "Recent chat" - closeRouter(); - if ('reportID' in item && item?.reportID) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item?.reportID)); - } else if ('login' in item) { - ReportUserActions.navigateToAndOpenReport(item.login ? [item.login] : [], false); + return filteredTypes.map((type) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE, text: type})); } - }, - [closeRouter, textInputValue, onSearchSubmit, updateSearchValue, onAutocompleteSuggestionClick], - ); + case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: { + const filteredStatuses = statusAutocompleteList + .filter((status) => status.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(status)) + .sort() + .slice(0, 10); - const onArrowFocus = useCallback( - (focusedItem: OptionData | SearchQueryItem) => { - if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !focusedItem.searchQuery) { - return; + return filteredStatuses.map((status) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS, text: status})); + } + case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE: { + const filteredExpenseTypes = expenseTypes + .filter((expenseType) => expenseType.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(expenseType)) + .sort(); + + return filteredExpenseTypes.map((expenseType) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE, + text: expenseType, + })); + } + case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID: { + const filteredCards = cardAutocompleteList + .filter( + (card) => + card.bank.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(CardUtils.getCardDescription(card.cardID).toLowerCase()), + ) + .sort() + .slice(0, 10); + + return filteredCards.map((card) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, + text: CardUtils.getCardDescription(card.cardID), + autocompleteID: card.cardID.toString(), + })); + } + default: { + return []; } + } + }, [ + autocompleteQueryValue, + tagAutocompleteList, + recentTagsAutocompleteList, + categoryAutocompleteList, + recentCategoriesAutocompleteList, + currencyAutocompleteList, + recentCurrencyAutocompleteList, + taxAutocompleteList, + participantsAutocompleteList, + searchOptions.recentReports, + typeAutocompleteList, + statusAutocompleteList, + expenseTypes, + cardAutocompleteList, + ]); - const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue); - setTextInputValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(focusedItem.searchQuery)} `); + const sections: Array> = []; - if (focusedItem.autocompleteID && focusedItem.text) { - onAutocompleteSuggestionClick(focusedItem.text, focusedItem.autocompleteID); - } - }, - [setTextInputValue, textInputValue, onAutocompleteSuggestionClick], - ); + if (searchQueryItem) { + sections.push({data: [searchQueryItem]}); + } + + if (autocompleteSuggestions.length > 0) { + const autocompleteData = autocompleteSuggestions.map(({filterKey, text, autocompleteID}) => { + return { + text: getSubstitutionMapKey(filterKey, text), + singleIcon: Expensicons.MagnifyingGlass, + searchQuery: text, + autocompleteID, + keyForList: autocompleteID ?? text, // in case we have a unique identifier then use it because text might not be unique + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION, + }; + }); + + sections.push({title: translate('search.suggestions'), data: autocompleteData}); + } + + if (additionalSections) { + sections.push(...additionalSections); + } + + const onArrowFocus = (focusedItem: OptionData | SearchQueryItem) => { + if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION) { + return; + } + + onListItemFocus(focusedItem); + }; return ( sections={sections} - onSelectRow={onSelectRow} + onSelectRow={onListItemPress} ListItem={SearchRouterItem} containerStyle={[styles.mh100]} sectionListStyle={[shouldUseNarrowLayout ? styles.ph5 : styles.ph2, styles.pb2]} listItemWrapperStyle={[styles.pr0, styles.pl0]} getItemHeight={getItemHeight} onLayout={setPerformanceTimersEnd} - ref={ref} showScrollIndicator={!shouldUseNarrowLayout} sectionTitleStyles={styles.mhn2} shouldSingleExecuteRowSelect onArrowFocus={onArrowFocus} + shouldPreventDefault={shouldPreventDefault} + ref={ref} /> ); } export default forwardRef(SearchRouterList); export {SearchRouterItem}; -export type {AutocompleteItemData}; diff --git a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts new file mode 100644 index 000000000000..a7185f126e55 --- /dev/null +++ b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts @@ -0,0 +1,81 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types'; +import * as parser from '@libs/SearchParser/autocompleteParser'; +import * as SearchQueryUtils from '@libs/SearchQueryUtils'; +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {SubstitutionMap} from './getQueryWithSubstitutions'; + +const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`; + +/** + * Given a plaintext query and specific entities data, + * this function will build the substitutions map from scratch for this query + * + * Ex: + * query: `Test from:12345 to:9876` + * personalDetails: { + * 12345: JohnDoe + * 98765: SomeoneElse + * } + * + * return: { + * from:JohnDoe: 12345, + * to:SomeoneElse: 98765, + * } + */ +function buildSubstitutionsMap( + query: string, + personalDetails: OnyxTypes.PersonalDetailsList, + reports: OnyxCollection, + allTaxRates: Record, +): SubstitutionMap { + const parsedQuery = parser.parse(query) as {ranges: SearchAutocompleteQueryRange[]}; + + const searchAutocompleteQueryRanges = parsedQuery.ranges; + + if (searchAutocompleteQueryRanges.length === 0) { + return {}; + } + + const substitutionsMap = searchAutocompleteQueryRanges.reduce((map, range) => { + const {key: filterKey, value: filterValue} = range; + + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) { + const taxRateID = filterValue; + const taxRates = Object.entries(allTaxRates) + .filter(([, IDs]) => IDs.includes(taxRateID)) + .map(([name]) => name); + + const taxRateNames = taxRates.length > 0 ? taxRates : [taxRateID]; + const uniqueTaxRateNames = [...new Set(taxRateNames)]; + uniqueTaxRateNames.forEach((taxRateName) => { + const substitutionKey = getSubstitutionsKey(filterKey, taxRateName); + + // eslint-disable-next-line no-param-reassign + map[substitutionKey] = taxRateID; + }); + } else if ( + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO || + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.IN || + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID + ) { + const displayValue = SearchQueryUtils.getFilterDisplayValue(filterKey, filterValue, personalDetails, reports); + + // If displayValue === filterValue, then it means there is nothing to substitute, so we don't add any key to map + if (displayValue !== filterValue) { + const substitutionKey = getSubstitutionsKey(filterKey, displayValue); + // eslint-disable-next-line no-param-reassign + map[substitutionKey] = filterValue; + } + } + + return map; + }, {} as SubstitutionMap); + + return substitutionsMap; +} + +// eslint-disable-next-line import/prefer-default-export +export {buildSubstitutionsMap}; diff --git a/src/components/SelectionList/Search/SearchQueryListItem.tsx b/src/components/SelectionList/Search/SearchQueryListItem.tsx index 0dad7796556c..b5631e03c3d7 100644 --- a/src/components/SelectionList/Search/SearchQueryListItem.tsx +++ b/src/components/SelectionList/Search/SearchQueryListItem.tsx @@ -7,15 +7,16 @@ import type {ListItem} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {OptionData} from '@libs/ReportUtils'; import type CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; type SearchQueryItem = ListItem & { singleIcon?: IconAsset; + searchItemType?: ValueOf; searchQuery?: string; autocompleteID?: string; roomType?: ValueOf; - searchItemType?: ValueOf; }; type SearchQueryListItemProps = { @@ -27,6 +28,10 @@ type SearchQueryListItemProps = { shouldSyncFocus?: boolean; }; +function isSearchQueryItem(item: OptionData | SearchQueryItem): item is SearchQueryItem { + return 'searchItemType' in item; +} + function SearchQueryListItem({item, isFocused, showTooltip, onSelectRow, onFocus, shouldSyncFocus}: SearchQueryListItemProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -82,4 +87,5 @@ function SearchQueryListItem({item, isFocused, showTooltip, onSelectRow, onFocus SearchQueryListItem.displayName = 'SearchQueryListItem'; export default SearchQueryListItem; +export {isSearchQueryItem}; export type {SearchQueryItem, SearchQueryListItemProps}; diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index fd427b7480c6..fe6988033dd9 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -116,6 +116,21 @@ function getQueryWithoutAutocompletedPart(searchQuery: string) { return searchQuery.slice(0, sliceEnd); } +/** + * Returns updated search query string with special case of comma after autocomplete handled. + * If prev query value had autocomplete, and the last thing user typed is a comma + * then we allow to continue autocompleting the next value by omitting the whitespace + */ +function getAutocompleteQueryWithComma(prevQuery: string, newQuery: string) { + const prevParsedQuery = parseForAutocomplete(prevQuery); + + if (prevParsedQuery?.autocomplete && newQuery.endsWith(',')) { + return `${newQuery.slice(0, newQuery.length - 1).trim()},`; + } + + return newQuery; +} + export { parseForAutocomplete, getAutocompleteTags, @@ -124,4 +139,5 @@ export { getAutocompleteRecentCategories, getAutocompleteTaxList, getQueryWithoutAutocompletedPart, + getAutocompleteQueryWithComma, }; diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 9645cc76a037..5ce63e772bff 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -8,10 +8,10 @@ import type {SearchAdvancedFiltersForm} from '@src/types/form'; import FILTER_KEYS from '@src/types/form/SearchAdvancedFiltersForm'; import type * as OnyxTypes from '@src/types/onyx'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; +import * as CardUtils from './CardUtils'; import * as CurrencyUtils from './CurrencyUtils'; import localeCompare from './LocaleCompare'; import {validateAmount} from './MoneyRequestUtils'; -import * as PersonalDetailsUtils from './PersonalDetailsUtils'; import {getTagNamesFromTagsLists} from './PolicyUtils'; import * as ReportUtils from './ReportUtils'; import * as searchParser from './SearchParser/searchParser'; @@ -162,51 +162,6 @@ function getFilters(queryJSON: SearchQueryJSON) { return filters; } -/** - * Given a filter name and its value, this function returns the corresponding ID found in Onyx data. - * Returns a function that can be used as a computeNodeValue callback for traversing the filters tree - */ -function getFindIDFromDisplayValue(cardList: OnyxTypes.CardList, taxRates: Record) { - return (filterName: ValueOf, filter: string | string[]) => { - if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) { - if (typeof filter === 'string') { - const email = filter; - return PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? filter; - } - const emails = filter; - return emails.map((email) => PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? email); - } - if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) { - const names = Array.isArray(filter) ? filter : ([filter] as string[]); - return names.map((name) => taxRates[name] ?? name).flat(); - } - if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) { - if (typeof filter === 'string') { - const bank = filter; - const ids = - Object.values(cardList) - .filter((card) => card.bank === bank) - .map((card) => card.cardID.toString()) ?? filter; - return ids.length > 0 ? ids : bank; - } - const banks = filter; - return banks - .map( - (bank) => - Object.values(cardList) - .filter((card) => card.bank === bank) - .map((card) => card.cardID.toString()) ?? bank, - ) - .flat(); - } - if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { - return getUpdatedAmountValue(filterName, filter); - } - - return filter; - }; -} - /** * Returns an updated amount value for query filters, correctly formatted to "backend" amount */ @@ -531,26 +486,29 @@ function getPolicyIDFromSearchQuery(queryJSON: SearchQueryJSON) { } /** - * @private - * Returns the human-readable "pretty" value for a filter. + * Returns the human-readable "pretty" string for a specified filter value. */ -function getDisplayValue(filterName: string, filter: string, personalDetails: OnyxTypes.PersonalDetailsList, cardList: OnyxTypes.CardList, reports: OnyxCollection) { +function getFilterDisplayValue(filterName: string, filterValue: string, personalDetails: OnyxTypes.PersonalDetailsList, reports: OnyxCollection) { if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) { // login can be an empty string // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - return personalDetails?.[filter]?.login || filter; + return personalDetails?.[filterValue]?.displayName || filterValue; } if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) { - return cardList[filter]?.bank || filter; + const cardID = parseInt(filterValue, 10); + if (Number.isNaN(cardID)) { + return filterValue; + } + return CardUtils.getCardDescription(cardID) || filterValue; } if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.IN) { - return ReportUtils.getReportName(reports?.[`${ONYXKEYS.COLLECTION.REPORT}${filter}`]) || filter; + return ReportUtils.getReportName(reports?.[`${ONYXKEYS.COLLECTION.REPORT}${filterValue}`]) || filterValue; } if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { - const frontendAmount = CurrencyUtils.convertToFrontendAmountAsInteger(Number(filter)); - return Number.isNaN(frontendAmount) ? filter : frontendAmount.toString(); + const frontendAmount = CurrencyUtils.convertToFrontendAmountAsInteger(Number(filterValue)); + return Number.isNaN(frontendAmount) ? filterValue : frontendAmount.toString(); } - return filter; + return filterValue; } /** @@ -562,12 +520,11 @@ function getDisplayValue(filterName: string, filter: string, personalDetails: On function buildUserReadableQueryString( queryJSON: SearchQueryJSON, PersonalDetails: OnyxTypes.PersonalDetailsList, - cardList: OnyxTypes.CardList, reports: OnyxCollection, - TaxRates: Record, + taxRates: Record, ) { const {type, status} = queryJSON; - const filters = queryJSON.flatFilters ?? {}; + const filters = queryJSON.flatFilters; let title = `type:${type} status:${Array.isArray(status) ? status.join(',') : status}`; @@ -580,10 +537,10 @@ function buildUserReadableQueryString( const taxRateIDs = queryFilter.map((filter) => filter.value.toString()); const taxRateNames = taxRateIDs .map((id) => { - const taxRate = Object.entries(TaxRates) + const taxRate = Object.entries(taxRates) .filter(([, IDs]) => IDs.includes(id)) .map(([name]) => name); - return taxRate?.length > 0 ? taxRate : id; + return taxRate.length > 0 ? taxRate : id; }) .flat(); @@ -596,7 +553,7 @@ function buildUserReadableQueryString( } else { displayQueryFilters = queryFilter.map((filter) => ({ operator: filter.operator, - value: getDisplayValue(key, filter.value.toString(), PersonalDetails, cardList, reports), + value: getFilterDisplayValue(key, filter.value.toString(), PersonalDetails, reports), })); } title += buildFilterValuesString(key, displayQueryFilters); @@ -672,13 +629,13 @@ export { buildSearchQueryJSON, buildSearchQueryString, buildUserReadableQueryString, + getFilterDisplayValue, buildQueryStringFromFilterFormValues, buildFilterFormValuesFromQuery, getPolicyIDFromSearchQuery, buildCannedSearchQuery, isCannedSearchQuery, traverseAndUpdatedQuery, - getFindIDFromDisplayValue, getUpdatedAmountValue, sanitizeSearchValue, }; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 4c68d84a0520..33cc1cb37755 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -43,10 +43,7 @@ function SearchPage({route}: SearchPageProps) { > {!!queryJSON && ( <> - + diff --git a/src/pages/Search/SearchSelectedNarrow.tsx b/src/pages/Search/SearchSelectedNarrow.tsx index 536d96c23ed0..f1eb3110febb 100644 --- a/src/pages/Search/SearchSelectedNarrow.tsx +++ b/src/pages/Search/SearchSelectedNarrow.tsx @@ -56,7 +56,6 @@ function SearchSelectedNarrow({options, itemsLength}: SearchSelectedNarrowProps) isDisabled={options.length === 0} shouldShowRightIcon={options.length !== 0} /> - - {!!queryJSON && ( - - )} + {!!queryJSON && } ); } diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index 6d74ccb46e21..263f346d01ab 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -69,7 +69,6 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) { const personalDetails = usePersonalDetails(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const taxRates = getAllTaxRates(); - const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); const {isOffline} = useNetwork(); const typeMenuItems: SearchTypeMenuItem[] = [ @@ -123,7 +122,7 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) { let title = item.name; if (title === item.query) { const jsonQuery = SearchQueryUtils.buildSearchQueryJSON(item.query) ?? ({} as SearchQueryJSON); - title = SearchQueryUtils.buildUserReadableQueryString(jsonQuery, personalDetails, cardList, reports, taxRates); + title = SearchQueryUtils.buildUserReadableQueryString(jsonQuery, personalDetails, reports, taxRates); } const baseMenuItem: SavedSearchMenuItem = { @@ -229,7 +228,7 @@ function SearchTypeMenu({queryJSON, searchName}: SearchTypeMenuProps) { const activeItemIndex = isCannedQuery ? typeMenuItems.findIndex((item) => item.type === type) : -1; if (shouldUseNarrowLayout) { - const title = searchName ?? (isCannedQuery ? undefined : SearchQueryUtils.buildUserReadableQueryString(queryJSON, personalDetails, cardList, reports, taxRates)); + const title = searchName ?? (isCannedQuery ? undefined : SearchQueryUtils.buildUserReadableQueryString(queryJSON, personalDetails, reports, taxRates)); return ( width: '100%', }, + searchResultsHeaderBar: { + display: 'flex', + height: variables.contentHeaderDesktopHeight, + zIndex: variables.popoverzIndex, + position: 'relative', + paddingHorizontal: 20, + }, + headerBarDesktopHeight: { height: variables.contentHeaderDesktopHeight, }, diff --git a/tests/perf-test/SearchRouter.perf-test.tsx b/tests/perf-test/SearchRouter.perf-test.tsx index 8ae16c3b8a1c..0784813127be 100644 --- a/tests/perf-test/SearchRouter.perf-test.tsx +++ b/tests/perf-test/SearchRouter.perf-test.tsx @@ -133,7 +133,7 @@ function SearchRouterInputWrapper() { setValue(searchTerm)} + onSearchQueryChange={(searchTerm) => setValue(searchTerm)} isFullWidth={false} /> diff --git a/tests/unit/Search/buildSubstitutionsMapTest.ts b/tests/unit/Search/buildSubstitutionsMapTest.ts new file mode 100644 index 000000000000..03f4f13645df --- /dev/null +++ b/tests/unit/Search/buildSubstitutionsMapTest.ts @@ -0,0 +1,87 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// we need "dirty" object key names in these tests +import type {OnyxCollection} from 'react-native-onyx'; +import {buildSubstitutionsMap} from '@src/components/Search/SearchRouter/buildSubstitutionsMap'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; + +jest.mock('@libs/CardUtils', () => { + return { + getCardDescription(cardID: number) { + return cardID; + }, + }; +}); + +jest.mock('@libs/ReportUtils', () => { + return { + parseReportRouteParams: jest.fn(() => ({})), + // The `getReportName` method is quite complex, and we don't need to test it, we just want to test the logic around generating subsitutionsMap + getReportName(report: OnyxTypes.Report) { + return report.reportName; + }, + }; +}); + +const personalDetailsMock = { + 12345: { + accountID: 12345, + firstName: 'John', + displayName: 'John Doe', + login: 'johndoe@example.com', + }, + 78901: { + accountID: 78901, + firstName: 'Jane', + displayName: 'Jane Doe', + login: 'janedoe@example.com', + }, +} as OnyxTypes.PersonalDetailsList; + +const reportsMock = { + [`${ONYXKEYS.COLLECTION.REPORT}rep123`]: { + reportID: 'rep123', + reportName: 'Report 1', + }, + [`${ONYXKEYS.COLLECTION.REPORT}rep456`]: { + reportID: 'rep456', + reportName: 'Report 2', + }, +} as OnyxCollection; + +const taxRatesMock = { + TAX_1: ['id_TAX_1'], +} as Record; + +describe('buildSubstitutionsMap should return correct substitutions map', () => { + test('when there were no substitutions', () => { + const userQuery = 'foo bar'; + + const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock); + + expect(result).toStrictEqual({}); + }); + test('when query has a single substitution', () => { + const userQuery = 'foo from:12345'; + + const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock); + + expect(result).toStrictEqual({ + 'from:John Doe': '12345', + }); + }); + + test('when query has multiple substitutions of different types', () => { + const userQuery = 'from:78901,12345 to:nonExistingGuy@mail.com cardID:11223344 in:rep123 taxRate:id_TAX_1'; + + const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock); + + expect(result).toStrictEqual({ + 'from:Jane Doe': '78901', + 'from:John Doe': '12345', + 'in:Report 1': 'rep123', + 'cardID:11223344': '11223344', + 'taxRate:TAX_1': 'id_TAX_1', + }); + }); +});