diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 5665909185c4..a330be3d5ff6 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -340,7 +340,10 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) { } const inputQueryJSON = SearchQueryUtils.buildSearchQueryJSON(inputValue); if (inputQueryJSON) { - const standardizedQuery = SearchQueryUtils.standardizeQueryJSON(inputQueryJSON, cardList, taxRates); + // 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})); diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 83d7d5d89b20..e65b12deb64b 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -1,4 +1,5 @@ import {useNavigationState} from '@react-navigation/native'; +import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; @@ -6,15 +7,16 @@ import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {usePersonalDetails} from '@components/OnyxProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; -import type {AutocompleteRange, SearchQueryJSON} from '@components/Search/types'; +import type {SearchAutocompleteQueryRange, SearchQueryString} from '@components/Search/types'; import type {SelectionListHandle} from '@components/SelectionList/types'; -import useActiveWorkspaceFromNavigationState from '@hooks/useActiveWorkspaceFromNavigationState'; +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 {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -34,9 +36,13 @@ 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 {getQueryWithSubstitutions} from './getQueryWithSubstitutions'; +import type {SubstitutionMap} from './getQueryWithSubstitutions'; +import {getUpdatedSubstitutionsMap} from './getUpdatedSubstitutionsMap'; import SearchRouterInput from './SearchRouterInput'; import SearchRouterList from './SearchRouterList'; -import type {ItemWithQuery} from './SearchRouterList'; +import type {AutocompleteItemData} from './SearchRouterList'; type SearchRouterProps = { onRouterClose: () => void; @@ -48,7 +54,8 @@ 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 [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]); + const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); const {shouldUseNarrowLayout} = useResponsiveLayout(); const listRef = useRef(null); @@ -58,41 +65,6 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { 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]); @@ -137,14 +109,52 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { return reports.slice(0, 10); }, [debouncedInputValue, filteredOptions, searchOptions]); - useEffect(() => { - Report.searchInServer(debouncedInputValue.trim()); - }, [debouncedInputValue]); - 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: AutocompleteRange[], autocompleteType?: ValueOf) => { + (autocompleteValue: string, ranges: SearchAutocompleteQueryRange[], autocompleteType?: ValueOf) => { const alreadyAutocompletedKeys: string[] = []; ranges.forEach((range) => { if (!autocompleteType || range.key !== autocompleteType) { @@ -152,6 +162,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } alreadyAutocompletedKeys.push(range.value.toLowerCase()); }); + + let filteredAutocompleteSuggestions: AutocompleteItemData[] | undefined; switch (autocompleteType) { case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG: { const autocompleteList = autocompleteValue ? tagAutocompleteList : recentTagsAutocompleteList ?? []; @@ -159,13 +171,12 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { .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; + + 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; @@ -175,13 +186,12 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { }) .sort() .slice(0, 10); - setAutocompleteSuggestions( - filteredCategories.map((categoryName) => ({ - text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY}:${categoryName}`, - query: `${SearchQueryUtils.sanitizeSearchValue(categoryName)}`, - })), - ); - return; + + 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 ?? []; @@ -189,92 +199,110 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { .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; + + 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.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tax.toLowerCase())) + .filter((tax) => tax.taxRateName.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tax.taxRateName.toLowerCase())) .sort() .slice(0, 10); - setAutocompleteSuggestions( - filteredTaxRates.map((tax) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE}:${tax}`, query: `${SearchQueryUtils.sanitizeSearchValue(tax)}`})), - ); - return; + 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.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.toLowerCase())) + .filter( + (participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase()), + ) .sort() .slice(0, 10); - setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM}:${participant}`, query: `${participant}`}))); - return; + 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.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.toLowerCase())) + .filter( + (participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase()), + ) .sort() .slice(0, 10); - setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${participant}`, query: `${participant}`}))); - return; + 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); - setAutocompleteSuggestions(filteredChats.map((chat) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${chat.text}`, query: `${chat.reportID}`}))); - return; + 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(); - setAutocompleteSuggestions(filteredTypes.map((type) => ({text: `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${type}`, query: `${type}`}))); - return; + 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); - setAutocompleteSuggestions(filteredStatuses.map((status) => ({text: `${CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS}:${status}`, query: `${status}`}))); - return; + 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(); - setAutocompleteSuggestions( - filteredExpenseTypes.map((expenseType) => ({ - text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE}:${expenseType}`, - query: `${expenseType}`, - })), - ); - return; + + 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.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(card.toLowerCase())) + .filter((card) => card.bank.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(card.bank.toLowerCase())) .sort() .slice(0, 10); - setAutocompleteSuggestions( - filteredCards.map((card) => ({ - text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID}:${card}`, - query: `${card}`, - })), - ); - return; + + filteredAutocompleteSuggestions = filteredCards.map((card) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, + text: CardUtils.getCardDescription(card.cardID), + autocompleteID: card.cardID.toString(), + })); + break; } default: { - setAutocompleteSuggestions(undefined); + filteredAutocompleteSuggestions = undefined; } } + setAutocompleteSuggestions(filteredAutocompleteSuggestions); }, [ tagAutocompleteList, @@ -293,6 +321,10 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { ], ); + useEffect(() => { + Report.searchInServer(debouncedInputValue.trim()); + }, [debouncedInputValue]); + const onSearchChange = useCallback( (userQuery: string) => { let newUserQuery = userQuery; @@ -302,29 +334,44 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { setTextInputValue(newUserQuery); const autocompleteParsedQuery = parseForAutocomplete(newUserQuery); updateAutocomplete(autocompleteParsedQuery?.autocomplete?.value ?? '', autocompleteParsedQuery?.ranges ?? [], autocompleteParsedQuery?.autocomplete?.key); + + const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions); + setAutocompleteSubstitutions(updatedSubstitutionsMap); + if (newUserQuery) { listRef.current?.updateAndScrollToFocusedIndex(0); } else { listRef.current?.updateAndScrollToFocusedIndex(-1); } }, - [autocompleteSuggestions, setTextInputValue, updateAutocomplete], + [autocompleteSubstitutions, autocompleteSuggestions, setTextInputValue, updateAutocomplete], ); const onSearchSubmit = useCallback( - (query: SearchQueryJSON | undefined) => { - if (!query) { + (queryString: SearchQueryString) => { + const cleanedQueryString = getQueryWithSubstitutions(queryString, autocompleteSubstitutions); + const queryJSON = SearchQueryUtils.buildSearchQueryJSON(cleanedQueryString); + if (!queryJSON) { return; } + onRouterClose(); - const standardizedQuery = SearchQueryUtils.standardizeQueryJSON(query, cardList, allTaxRates); - const queryString = SearchQueryUtils.buildSearchQueryString(standardizedQuery); - Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: queryString})); + + const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(queryJSON, SearchQueryUtils.getUpdatedAmountValue); + const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery); + Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); + setTextInputValue(''); }, - [allTaxRates, cardList, onRouterClose, setTextInputValue], + [autocompleteSubstitutions, onRouterClose, setTextInputValue], ); + const onAutocompleteSuggestionClick = (autocompleteKey: string, autocompleteID: string) => { + const substitutions = {...autocompleteSubstitutions, [autocompleteKey]: autocompleteID}; + + setAutocompleteSubstitutions(substitutions); + }; + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => { onRouterClose(); }); @@ -347,7 +394,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { isFullWidth={shouldUseNarrowLayout} updateSearch={onSearchChange} onSubmit={() => { - onSearchSubmit(SearchQueryUtils.buildSearchQueryJSON(textInputValue)); + onSearchSubmit(textInputValue); }} routerListRef={listRef} shouldShowOfflineMessage @@ -363,9 +410,10 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { reportForContextualSearch={contextualReportData} recentSearches={sortedRecentSearches?.slice(0, 5)} recentReports={recentReports} - autocompleteItems={autocompleteSuggestions} + autocompleteSuggestions={autocompleteSuggestions} onSearchSubmit={onSearchSubmit} closeRouter={onRouterClose} + onAutocompleteSuggestionClick={onAutocompleteSuggestionClick} ref={listRef} /> diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index c3799ce5579e..cc854ff926c3 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -3,7 +3,7 @@ import type {ForwardedRef} from 'react'; import {useOnyx} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; import {usePersonalDetails} from '@components/OnyxProvider'; -import type {SearchQueryJSON} from '@components/Search/types'; +import type {SearchFilterKey, SearchQueryString} from '@components/Search/types'; import SelectionList from '@components/SelectionList'; import SearchQueryListItem from '@components/SelectionList/Search/SearchQueryListItem'; import type {SearchQueryItem, SearchQueryListItemProps} from '@components/SelectionList/Search/SearchQueryListItem'; @@ -16,20 +16,26 @@ import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; -import {trimSearchQueryForAutocomplete} from '@libs/SearchAutocompleteUtils'; +import {getQueryWithoutAutocompletedPart} from '@libs/SearchAutocompleteUtils'; import * as SearchQueryUtils from '@libs/SearchQueryUtils'; import * as Report from '@userActions/Report'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import {getSubstitutionMapKey} from './getQueryWithSubstitutions'; -type ItemWithQuery = { +type SearchQueryItemData = { query: string; - id?: string; text?: string; }; +type AutocompleteItemData = { + filterKey: SearchFilterKey; + text: string; + autocompleteID?: string; +}; + type SearchRouterListProps = { /** value of TextInput */ textInputValue: string; @@ -41,20 +47,23 @@ type SearchRouterListProps = { setTextInputValue: (text: string) => void; /** Recent searches */ - recentSearches: Array | undefined; + recentSearches: Array | undefined; /** Recent reports */ recentReports: OptionData[]; /** Autocomplete items */ - autocompleteItems: ItemWithQuery[] | undefined; + autocompleteSuggestions: AutocompleteItemData[] | undefined; /** Callback to submit query when selecting a list item */ - onSearchSubmit: (query: SearchQueryJSON | undefined) => void; + onSearchSubmit: (query: SearchQueryString) => 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 close and clear SearchRouter */ closeRouter: () => void; }; @@ -64,21 +73,25 @@ const setPerformanceTimersEnd = () => { Performance.markEnd(CONST.TIMING.SEARCH_ROUTER_RENDER); }; -function getContextualSearchQuery(reportID: string) { - return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${CONST.SEARCH.DATA_TYPES.CHAT} in:${reportID}`; +function getContextualSearchQuery(reportName: string) { + return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${CONST.SEARCH.DATA_TYPES.CHAT} ${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${SearchQueryUtils.sanitizeSearchValue(reportName)}`; } function isSearchQueryItem(item: OptionData | SearchQueryItem): item is SearchQueryItem { - if ('singleIcon' in item && item.singleIcon && 'query' in item && item.query) { - return true; - } - return false; + return 'searchItemType' in item; } function isSearchQueryListItem(listItem: UserListItemProps | SearchQueryListItemProps): listItem is SearchQueryListItemProps { return isSearchQueryItem(listItem.item); } +function getItemHeight(item: OptionData | SearchQueryItem) { + if (isSearchQueryItem(item)) { + return 44; + } + return 64; +} + function SearchRouterItem(props: UserListItemProps | SearchQueryListItemProps) { const styles = useThemeStyles(); @@ -100,7 +113,18 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList } function SearchRouterList( - {textInputValue, updateSearchValue, setTextInputValue, reportForContextualSearch, recentSearches, autocompleteItems, recentReports, onSearchSubmit, closeRouter}: SearchRouterListProps, + { + textInputValue, + updateSearchValue, + setTextInputValue, + reportForContextualSearch, + recentSearches, + autocompleteSuggestions, + recentReports, + onSearchSubmit, + onAutocompleteSuggestionClick, + closeRouter, + }: SearchRouterListProps, ref: ForwardedRef, ) { const styles = useThemeStyles(); @@ -119,7 +143,7 @@ function SearchRouterList( { text: textInputValue, singleIcon: Expensicons.MagnifyingGlass, - query: textInputValue, + searchQuery: textInputValue, itemStyle: styles.activeComponentBG, keyForList: 'findItem', searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, @@ -129,12 +153,14 @@ function SearchRouterList( } if (reportForContextualSearch && !textInputValue) { + const reportQueryValue = reportForContextualSearch.text ?? reportForContextualSearch.alternateText ?? reportForContextualSearch.reportID; sections.push({ data: [ { text: `${translate('search.searchIn')} ${reportForContextualSearch.text ?? reportForContextualSearch.alternateText}`, singleIcon: Expensicons.MagnifyingGlass, - query: getContextualSearchQuery(reportForContextualSearch.reportID), + searchQuery: reportQueryValue, + autocompleteID: reportForContextualSearch.reportID, itemStyle: styles.activeComponentBG, keyForList: 'contextualSearch', searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION, @@ -143,12 +169,13 @@ function SearchRouterList( }); } - const autocompleteData = autocompleteItems?.map(({text, query}) => { + const autocompleteData = autocompleteSuggestions?.map(({filterKey, text, autocompleteID}) => { return { - text, + text: getSubstitutionMapKey(filterKey, text), singleIcon: Expensicons.MagnifyingGlass, - query, - keyForList: query, + 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, }; }); @@ -162,7 +189,7 @@ function SearchRouterList( return { text: searchQueryJSON ? SearchQueryUtils.buildUserReadableQueryString(searchQueryJSON, personalDetails, cardList, reports, taxRates) : query, singleIcon: Expensicons.History, - query, + searchQuery: query, keyForList: timestamp, searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, }; @@ -178,20 +205,30 @@ function SearchRouterList( const onSelectRow = useCallback( (item: OptionData | SearchQueryItem) => { if (isSearchQueryItem(item)) { - if (!item?.query) { + if (!item.searchQuery) { return; } - if (item?.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { - updateSearchValue(`${item?.query} `); + if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { + const searchQuery = getContextualSearchQuery(item.searchQuery); + updateSearchValue(`${searchQuery} `); + + if (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 = trimSearchQueryForAutocomplete(textInputValue); - updateSearchValue(`${trimmedUserSearchQuery}${item?.query} `); + 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(SearchQueryUtils.buildSearchQueryJSON(item?.query)); + onSearchSubmit(item.searchQuery); } // Handle selection of "Recent chat" @@ -202,27 +239,25 @@ function SearchRouterList( Report.navigateToAndOpenReport(item.login ? [item.login] : [], false); } }, - [closeRouter, textInputValue, onSearchSubmit, updateSearchValue], + [closeRouter, textInputValue, onSearchSubmit, updateSearchValue, onAutocompleteSuggestionClick], ); const onArrowFocus = useCallback( (focusedItem: OptionData | SearchQueryItem) => { - if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !textInputValue) { + if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !focusedItem.searchQuery) { return; } - const trimmedUserSearchQuery = trimSearchQueryForAutocomplete(textInputValue); - setTextInputValue(`${trimmedUserSearchQuery}${focusedItem?.query} `); + + const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue); + setTextInputValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(focusedItem.searchQuery)} `); + + if (focusedItem.autocompleteID && focusedItem.text) { + onAutocompleteSuggestionClick(focusedItem.text, focusedItem.autocompleteID); + } }, - [setTextInputValue, textInputValue], + [setTextInputValue, textInputValue, onAutocompleteSuggestionClick], ); - const getItemHeight = useCallback((item: OptionData | SearchQueryItem) => { - if (isSearchQueryItem(item)) { - return 44; - } - return 64; - }, []); - return ( sections={sections} @@ -244,4 +279,4 @@ function SearchRouterList( export default forwardRef(SearchRouterList); export {SearchRouterItem}; -export type {ItemWithQuery}; +export type {AutocompleteItemData}; diff --git a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts new file mode 100644 index 000000000000..117745fee480 --- /dev/null +++ b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts @@ -0,0 +1,50 @@ +import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types'; +import * as parser from '@libs/SearchParser/autocompleteParser'; + +type SubstitutionMap = Record; + +const getSubstitutionMapKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`; + +/** + * Given a plaintext query and a SubstitutionMap object, this function will return a transformed query where: + * - any autocomplete mention in the original query will be substituted with an id taken from `substitutions` object + * - anything that does not match will stay as is + * + * Ex: + * query: `A from:@johndoe A` + * substitutions: { + * from:@johndoe: 9876 + * } + * return: `A from:9876 A` + */ +function getQueryWithSubstitutions(changedQuery: string, substitutions: SubstitutionMap) { + const parsed = parser.parse(changedQuery) as {ranges: SearchAutocompleteQueryRange[]}; + + const searchAutocompleteQueryRanges = parsed.ranges; + + if (searchAutocompleteQueryRanges.length === 0) { + return changedQuery; + } + + let resultQuery = changedQuery; + let lengthDiff = 0; + + for (const range of searchAutocompleteQueryRanges) { + const itemKey = getSubstitutionMapKey(range.key, range.value); + const substitutionEntry = substitutions[itemKey]; + + if (substitutionEntry) { + const substitutionStart = range.start + lengthDiff; + const substitutionEnd = range.start + range.length; + + // generate new query but substituting "user-typed" value with the entity id/email from substitutions + resultQuery = resultQuery.slice(0, substitutionStart) + substitutionEntry + changedQuery.slice(substitutionEnd); + lengthDiff = lengthDiff + substitutionEntry.length - range.length; + } + } + + return resultQuery; +} + +export {getQueryWithSubstitutions, getSubstitutionMapKey}; +export type {SubstitutionMap}; diff --git a/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts new file mode 100644 index 000000000000..ee7bf3850259 --- /dev/null +++ b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts @@ -0,0 +1,43 @@ +import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types'; +import * as parser from '@libs/SearchParser/autocompleteParser'; +import type {SubstitutionMap} from './getQueryWithSubstitutions'; + +const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`; + +/** + * Given a plaintext query and a SubstitutionMap object, + * this function will remove any substitution keys that do not appear in the query and return an updated object + * + * Ex: + * query: `Test from:John1` + * substitutions: { + * from:SomeOtherJohn: 12345 + * } + * return: {} + */ +function getUpdatedSubstitutionsMap(query: string, substitutions: SubstitutionMap): SubstitutionMap { + const parsedQuery = parser.parse(query) as {ranges: SearchAutocompleteQueryRange[]}; + + const searchAutocompleteQueryRanges = parsedQuery.ranges; + + if (searchAutocompleteQueryRanges.length === 0) { + return {}; + } + + const autocompleteQueryKeys = searchAutocompleteQueryRanges.map((range) => getSubstitutionsKey(range.key, range.value)); + + // Build a new substitutions map consisting of only the keys from old map, that appear in query + const updatedSubstitutionMap = autocompleteQueryKeys.reduce((map, key) => { + if (substitutions[key]) { + // eslint-disable-next-line no-param-reassign + map[key] = substitutions[key]; + } + + return map; + }, {} as SubstitutionMap); + + return updatedSubstitutionMap; +} + +// eslint-disable-next-line import/prefer-default-export +export {getUpdatedSubstitutionsMap}; diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 2fb034131c86..3e5c158660f1 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -1,4 +1,4 @@ -import type {ValueOf} from 'react-native-gesture-handler/lib/typescript/typeUtils'; +import type {ValueOf} from 'type-fest'; import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import type CONST from '@src/CONST'; import type {SearchDataTypes, SearchReport} from '@src/types/onyx/SearchResults'; @@ -56,10 +56,14 @@ type QueryFilter = { value: string | number; }; -type AdvancedFiltersKeys = ValueOf; +type SearchFilterKey = + | ValueOf + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID; type QueryFilters = Array<{ - key: AdvancedFiltersKeys; + key: SearchFilterKey; filters: QueryFilter[]; }>; @@ -82,18 +86,18 @@ type SearchQueryJSON = { flatFilters: QueryFilters; } & SearchQueryAST; -type AutocompleteRange = { - key: ValueOf; +type SearchAutocompleteResult = { + autocomplete: SearchAutocompleteQueryRange | null; + ranges: SearchAutocompleteQueryRange[]; +}; + +type SearchAutocompleteQueryRange = { + key: SearchFilterKey; length: number; start: number; value: string; }; -type SearchAutocompleteResult = { - autocomplete: AutocompleteRange | null; - ranges: AutocompleteRange[]; -}; - export type { SelectedTransactionInfo, SelectedTransactions, @@ -107,11 +111,11 @@ export type { ASTNode, QueryFilter, QueryFilters, - AdvancedFiltersKeys, + SearchFilterKey, ExpenseSearchStatus, InvoiceSearchStatus, TripSearchStatus, ChatSearchStatus, SearchAutocompleteResult, - AutocompleteRange, + SearchAutocompleteQueryRange, }; diff --git a/src/components/SelectionList/Search/SearchQueryListItem.tsx b/src/components/SelectionList/Search/SearchQueryListItem.tsx index bba574fa3ac7..77637eed39df 100644 --- a/src/components/SelectionList/Search/SearchQueryListItem.tsx +++ b/src/components/SelectionList/Search/SearchQueryListItem.tsx @@ -12,7 +12,8 @@ import type IconAsset from '@src/types/utils/IconAsset'; type SearchQueryItem = ListItem & { singleIcon?: IconAsset; - query?: string; + searchQuery?: string; + autocompleteID?: string; searchItemType?: ValueOf; }; diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index f33e2a82d445..fd427b7480c6 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -5,6 +5,10 @@ import type {Policy, PolicyCategories, PolicyTagLists, RecentlyUsedCategories, R import {getTagNamesFromTagsLists} from './PolicyUtils'; import * as autocompleteParser from './SearchParser/autocompleteParser'; +/** + * Parses given query using the autocomplete parser. + * This is a smaller and simpler version of search parser used for autocomplete displaying logic. + */ function parseForAutocomplete(text: string) { try { const parsedAutocomplete = autocompleteParser.parse(text) as SearchAutocompleteResult; @@ -14,6 +18,9 @@ function parseForAutocomplete(text: string) { } } +/** + * Returns data for computing the `Tag` filter autocomplete list. + */ function getAutocompleteTags(allPoliciesTagsLists: OnyxCollection, policyID?: string) { const singlePolicyTagsList: PolicyTagLists | undefined = allPoliciesTagsLists?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]; if (!singlePolicyTagsList) { @@ -28,6 +35,9 @@ function getAutocompleteTags(allPoliciesTagsLists: OnyxCollection, policyID?: string) { const singlePolicyRecentTags: RecentlyUsedTags | undefined = allRecentTags?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`]; if (!singlePolicyRecentTags) { @@ -41,6 +51,9 @@ function getAutocompleteRecentTags(allRecentTags: OnyxCollection, policyID?: string) { const singlePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; if (!singlePolicyCategories) { @@ -51,6 +64,9 @@ function getAutocompleteCategories(allPolicyCategories: OnyxCollection category.name); } +/** + * Returns data for computing the recent categories autocomplete list. + */ function getAutocompleteRecentCategories(allRecentCategories: OnyxCollection, policyID?: string) { const singlePolicyRecentCategories = allRecentCategories?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`]; if (!singlePolicyRecentCategories) { @@ -61,18 +77,43 @@ function getAutocompleteRecentCategories(allRecentCategories: OnyxCollection category); } -function getAutocompleteTaxList(allTaxRates: Record, policy?: OnyxEntry) { +/** + * Returns data for computing the `Tax` filter autocomplete list + * + * Please note: taxes are stored in a quite convoluted and non-obvious way, and there can be multiple taxes with the same id + * because tax ids are generated based on a tax name, so they look like this: `id_My_Tax` and are not numeric. + * That is why this function may seem a bit complex. + */ +function getAutocompleteTaxList(taxRates: Record, policy?: OnyxEntry) { if (policy) { - return Object.keys(policy?.taxRates?.taxes ?? {}).map((taxRateName) => taxRateName); + const policyTaxes = policy?.taxRates?.taxes ?? {}; + + return Object.keys(policyTaxes).map((taxID) => ({ + taxRateName: policyTaxes[taxID].name, + taxRateIds: [taxID], + })); } - return Object.keys(allTaxRates).map((taxRateName) => taxRateName); + + return Object.keys(taxRates).map((taxName) => ({ + taxRateName: taxName, + taxRateIds: taxRates[taxName].map((id) => taxRates[id] ?? id).flat(), + })); } -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; +/** + * Given a query string, this function parses it with the autocomplete parser + * and returns only the part of the string before autocomplete. + * + * Ex: "test from:john@doe" -> "test from:" + */ +function getQueryWithoutAutocompletedPart(searchQuery: string) { + const parsedQuery = parseForAutocomplete(searchQuery); + if (!parsedQuery?.autocomplete) { + return searchQuery; + } + + const sliceEnd = parsedQuery.autocomplete.start; + return searchQuery.slice(0, sliceEnd); } export { @@ -82,5 +123,5 @@ export { getAutocompleteCategories, getAutocompleteRecentCategories, getAutocompleteTaxList, - trimSearchQueryForAutocomplete, + getQueryWithoutAutocompletedPart, }; diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index be57ff8a67a5..bd114b56e099 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -186,12 +186,13 @@ function peg$parse(input, options) { var peg$c8 = "expenseType"; var peg$c9 = "type"; var peg$c10 = "status"; - var peg$c11 = "!="; - var peg$c12 = ">="; - var peg$c13 = ">"; - var peg$c14 = "<="; - var peg$c15 = "<"; - var peg$c16 = "\""; + var peg$c11 = "cardID"; + var peg$c12 = "!="; + var peg$c13 = ">="; + var peg$c14 = ">"; + var peg$c15 = "<="; + var peg$c16 = "<"; + var peg$c17 = "\""; var peg$r0 = /^[:=]/; var peg$r1 = /^[^ ,"\t\n\r]/; @@ -211,21 +212,22 @@ function peg$parse(input, options) { var peg$e9 = peg$literalExpectation("expenseType", false); var peg$e10 = peg$literalExpectation("type", false); var peg$e11 = peg$literalExpectation("status", false); - var peg$e12 = peg$otherExpectation("operator"); - var peg$e13 = peg$classExpectation([":", "="], false, false); - var peg$e14 = peg$literalExpectation("!=", false); - var peg$e15 = peg$literalExpectation(">=", false); - var peg$e16 = peg$literalExpectation(">", false); - var peg$e17 = peg$literalExpectation("<=", false); - var peg$e18 = peg$literalExpectation("<", false); - var peg$e19 = peg$otherExpectation("quote"); - var peg$e20 = peg$classExpectation([" ", ",", "\"", "\t", "\n", "\r"], true, false); - var peg$e21 = peg$literalExpectation("\"", false); - var peg$e22 = peg$classExpectation(["\"", "\r", "\n"], true, false); - var peg$e23 = peg$classExpectation([" ", ",", "\t", "\n", "\r"], true, false); - var peg$e24 = peg$otherExpectation("word"); - var peg$e25 = peg$otherExpectation("whitespace"); - var peg$e26 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); + var peg$e12 = peg$literalExpectation("cardID", false); + var peg$e13 = peg$otherExpectation("operator"); + var peg$e14 = peg$classExpectation([":", "="], false, false); + var peg$e15 = peg$literalExpectation("!=", false); + var peg$e16 = peg$literalExpectation(">=", false); + var peg$e17 = peg$literalExpectation(">", false); + var peg$e18 = peg$literalExpectation("<=", false); + var peg$e19 = peg$literalExpectation("<", false); + var peg$e20 = peg$otherExpectation("quote"); + var peg$e21 = peg$classExpectation([" ", ",", "\"", "\t", "\n", "\r"], true, false); + var peg$e22 = peg$literalExpectation("\"", false); + var peg$e23 = peg$classExpectation(["\"", "\r", "\n"], true, false); + var peg$e24 = peg$classExpectation([" ", ",", "\t", "\n", "\r"], true, false); + var peg$e25 = peg$otherExpectation("word"); + var peg$e26 = peg$otherExpectation("whitespace"); + var peg$e27 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); var peg$f0 = function(ranges) { return { autocomplete, ranges }; }; var peg$f1 = function(filters) { return filters.filter(Boolean).flat(); }; @@ -644,6 +646,15 @@ function peg$parse(input, options) { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e11); } } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 6) === peg$c11) { + s1 = peg$c11; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e12); } + } + } } } } @@ -740,7 +751,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e13); } + if (peg$silentFails === 0) { peg$fail(peg$e14); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -749,12 +760,12 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c11) { - s1 = peg$c11; + if (input.substr(peg$currPos, 2) === peg$c12) { + s1 = peg$c12; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e14); } + if (peg$silentFails === 0) { peg$fail(peg$e15); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -763,12 +774,12 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c12) { - s1 = peg$c12; + if (input.substr(peg$currPos, 2) === peg$c13) { + s1 = peg$c13; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e15); } + if (peg$silentFails === 0) { peg$fail(peg$e16); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -778,11 +789,11 @@ function peg$parse(input, options) { if (s0 === peg$FAILED) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 62) { - s1 = peg$c13; + s1 = peg$c14; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e16); } + if (peg$silentFails === 0) { peg$fail(peg$e17); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -791,12 +802,12 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c14) { - s1 = peg$c14; + if (input.substr(peg$currPos, 2) === peg$c15) { + s1 = peg$c15; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e17); } + if (peg$silentFails === 0) { peg$fail(peg$e18); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -806,11 +817,11 @@ function peg$parse(input, options) { if (s0 === peg$FAILED) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 60) { - s1 = peg$c15; + s1 = peg$c16; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e18); } + if (peg$silentFails === 0) { peg$fail(peg$e19); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -825,7 +836,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e12); } + if (peg$silentFails === 0) { peg$fail(peg$e13); } } return s0; @@ -842,7 +853,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e20); } + if (peg$silentFails === 0) { peg$fail(peg$e21); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -851,15 +862,15 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e20); } + if (peg$silentFails === 0) { peg$fail(peg$e21); } } } if (input.charCodeAt(peg$currPos) === 34) { - s2 = peg$c16; + s2 = peg$c17; peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e21); } + if (peg$silentFails === 0) { peg$fail(peg$e22); } } if (s2 !== peg$FAILED) { s3 = []; @@ -868,7 +879,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e22); } + if (peg$silentFails === 0) { peg$fail(peg$e23); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -877,15 +888,15 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e22); } + if (peg$silentFails === 0) { peg$fail(peg$e23); } } } if (input.charCodeAt(peg$currPos) === 34) { - s4 = peg$c16; + s4 = peg$c17; peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e21); } + if (peg$silentFails === 0) { peg$fail(peg$e22); } } if (s4 !== peg$FAILED) { s5 = []; @@ -894,7 +905,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } + if (peg$silentFails === 0) { peg$fail(peg$e24); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -903,7 +914,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } + if (peg$silentFails === 0) { peg$fail(peg$e24); } } } peg$savedPos = s0; @@ -919,7 +930,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e19); } + if (peg$silentFails === 0) { peg$fail(peg$e20); } } return s0; @@ -936,7 +947,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } + if (peg$silentFails === 0) { peg$fail(peg$e24); } } if (s2 !== peg$FAILED) { while (s2 !== peg$FAILED) { @@ -946,7 +957,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } + if (peg$silentFails === 0) { peg$fail(peg$e24); } } } } else { @@ -960,7 +971,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e24); } + if (peg$silentFails === 0) { peg$fail(peg$e25); } } return s0; @@ -988,7 +999,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e26); } + if (peg$silentFails === 0) { peg$fail(peg$e27); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -997,12 +1008,12 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e26); } + if (peg$silentFails === 0) { peg$fail(peg$e27); } } } peg$silentFails--; s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e25); } + if (peg$silentFails === 0) { peg$fail(peg$e26); } return s0; } diff --git a/src/libs/SearchParser/autocompleteParser.peggy b/src/libs/SearchParser/autocompleteParser.peggy index 89d89fd07cd4..e2a8bed9a9cc 100644 --- a/src/libs/SearchParser/autocompleteParser.peggy +++ b/src/libs/SearchParser/autocompleteParser.peggy @@ -61,6 +61,7 @@ autocompleteKey "key" / "expenseType" / "type" / "status" + / "cardID" ) identifier diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 62d00f8091ed..5e2a6d737984 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -125,11 +125,11 @@ function getFilters(queryJSON: SearchQueryJSON) { return; } - if (typeof node?.left === 'object' && node.left) { + if (typeof node.left === 'object' && node.left) { traverse(node.left); } - if (typeof node?.right === 'object' && node.right && !Array.isArray(node.right)) { + if (typeof node.right === 'object' && node.right && !Array.isArray(node.right)) { traverse(node.right); } @@ -148,7 +148,7 @@ function getFilters(queryJSON: SearchQueryJSON) { node.right.forEach((element) => { filterArray.push({ operator: node.operator, - value: element as string | number, + value: element, }); }); } @@ -163,52 +163,66 @@ function getFilters(queryJSON: SearchQueryJSON) { } /** - * @private * 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 findIDFromDisplayValue(filterName: ValueOf, filter: string | string[], cardList: OnyxTypes.CardList, taxRates: Record) { - 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; +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); } - 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; + 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(); } - const banks = filter; - return banks - .map( - (bank) => + 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()) ?? bank, - ) - .flat(); - } - if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { - if (typeof filter === 'string') { - const backendAmount = CurrencyUtils.convertToBackendAmount(Number(filter)); - return Number.isNaN(backendAmount) ? filter : backendAmount.toString(); + .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.map((amount) => { - const backendAmount = CurrencyUtils.convertToBackendAmount(Number(amount)); - return Number.isNaN(backendAmount) ? amount : backendAmount.toString(); - }); + + return filter; + }; +} + +/** + * Returns an updated amount value for query filters, correctly formatted to "backend" amount + */ +function getUpdatedAmountValue(filterName: ValueOf, filter: string | string[]) { + if (filterName !== CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { + return filter; } - return filter; + + if (typeof filter === 'string') { + const backendAmount = CurrencyUtils.convertToBackendAmount(Number(filter)); + return Number.isNaN(backendAmount) ? filter : backendAmount.toString(); + } + return filter.map((amount) => { + const backendAmount = CurrencyUtils.convertToBackendAmount(Number(amount)); + return Number.isNaN(backendAmount) ? amount : backendAmount.toString(); + }); } /** @@ -561,7 +575,9 @@ function buildUserReadableQueryString( }) .flat(); - displayQueryFilters = taxRateNames.map((taxRate) => ({ + const uniqueTaxRateNames = [...new Set(taxRateNames)]; + + displayQueryFilters = uniqueTaxRateNames.map((taxRate) => ({ operator: queryFilter.at(0)?.operator ?? CONST.SEARCH.SYNTAX_OPERATORS.AND, value: taxRate, })); @@ -610,23 +626,23 @@ function isCannedSearchQuery(queryJSON: SearchQueryJSON) { /** * Given a search query, this function will standardize the query by replacing display values with their corresponding IDs. */ -function standardizeQueryJSON(queryJSON: SearchQueryJSON, cardList: OnyxTypes.CardList, taxRates: Record) { +function traverseAndUpdatedQuery(queryJSON: SearchQueryJSON, computeNodeValue: (left: ValueOf, right: string | string[]) => string | string[]) { const standardQuery = cloneDeep(queryJSON); const filters = standardQuery.filters; const traverse = (node: ASTNode) => { if (!node.operator) { return; } - if (typeof node.left === 'object' && node.left) { + if (typeof node.left === 'object') { traverse(node.left); } - if (typeof node.right === 'object' && node.right && !Array.isArray(node.right)) { + if (typeof node.right === 'object' && !Array.isArray(node.right)) { traverse(node.right); } - if (typeof node.left !== 'object') { + if (typeof node.left !== 'object' && (Array.isArray(node.right) || typeof node.right === 'string')) { // eslint-disable-next-line no-param-reassign - node.right = findIDFromDisplayValue(node.left, node.right as string | string[], cardList, taxRates); + node.right = computeNodeValue(node.left, node.right); } }; @@ -647,6 +663,8 @@ export { getPolicyIDFromSearchQuery, buildCannedSearchQuery, isCannedSearchQuery, - standardizeQueryJSON, + traverseAndUpdatedQuery, + getFindIDFromDisplayValue, + getUpdatedAmountValue, sanitizeSearchValue, }; diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index ce4daabc983a..58fd159b5bed 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -9,7 +9,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {usePersonalDetails} from '@components/OnyxProvider'; import ScrollView from '@components/ScrollView'; -import type {AdvancedFiltersKeys} from '@components/Search/types'; +import type {SearchFilterKey} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -150,8 +150,8 @@ const sortOptionsWithEmptyValue = (a: string, b: string) => { return localeCompare(a, b); }; -function getFilterDisplayTitle(filters: Partial, fieldName: AdvancedFiltersKeys, translate: LocaleContextProps['translate']) { - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE) { +function getFilterDisplayTitle(filters: Partial, filterKey: SearchFilterKey, translate: LocaleContextProps['translate']) { + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE) { // the value of date filter is a combination of dateBefore + dateAfter values const {dateAfter, dateBefore} = filters; let dateValue = ''; @@ -168,7 +168,7 @@ function getFilterDisplayTitle(filters: Partial, fiel return dateValue; } - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { const {lessThan, greaterThan} = filters; if (lessThan && greaterThan) { return translate('search.filters.amount.between', { @@ -186,32 +186,32 @@ function getFilterDisplayTitle(filters: Partial, fiel return; } - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY && filters[fieldName]) { - const filterArray = filters[fieldName] ?? []; + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY && filters[filterKey]) { + const filterArray = filters[filterKey] ?? []; return filterArray.sort(localeCompare).join(', '); } - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY && filters[fieldName]) { - const filterArray = filters[fieldName] ?? []; + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY && filters[filterKey]) { + const filterArray = filters[filterKey] ?? []; return filterArray .sort(sortOptionsWithEmptyValue) .map((value) => (value === CONST.SEARCH.EMPTY_VALUE ? translate('search.noCategory') : value)) .join(', '); } - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG && filters[fieldName]) { - const filterArray = filters[fieldName] ?? []; + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG && filters[filterKey]) { + const filterArray = filters[filterKey] ?? []; return filterArray .sort(sortOptionsWithEmptyValue) .map((value) => (value === CONST.SEARCH.EMPTY_VALUE ? translate('search.noTag') : value)) .join(', '); } - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION) { - return filters[fieldName]; + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION) { + return filters[filterKey]; } - const filterValue = filters[fieldName]; + const filterValue = filters[filterKey]; return Array.isArray(filterValue) ? filterValue.join(', ') : filterValue; } diff --git a/tests/unit/Search/getQueryWithSubstitutionsTest.ts b/tests/unit/Search/getQueryWithSubstitutionsTest.ts new file mode 100644 index 000000000000..8ca2eec31256 --- /dev/null +++ b/tests/unit/Search/getQueryWithSubstitutionsTest.ts @@ -0,0 +1,92 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// we need "dirty" object key names in these tests +import {getQueryWithSubstitutions} from '@src/components/Search/SearchRouter/getQueryWithSubstitutions'; + +describe('getQueryWithSubstitutions should compute and return correct new query', () => { + test('when both queries contain no substitutions', () => { + // given this previous query: "foo" + const userTypedQuery = 'foo bar'; + const substitutionsMock = {}; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo bar'); + }); + + test('when query has a substitution and plain text was added after it', () => { + // given this previous query: "foo from:@mateusz" + const userTypedQuery = 'foo from:Mat test'; + const substitutionsMock = { + 'from:Mat': '@mateusz', + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo from:@mateusz test'); + }); + + test('when query has a substitution and plain text was added after before it', () => { + // given this previous query: "foo from:@mateusz1" + const userTypedQuery = 'foo bar from:Mat1'; + const substitutionsMock = { + 'from:Mat1': '@mateusz1', + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo bar from:@mateusz1'); + }); + + test('when query has a substitution and then it was removed', () => { + // given this previous query: "foo from:@mateusz" + const userTypedQuery = 'foo from:Ma'; + const substitutionsMock = { + 'from:Mat': '@mateusz', + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo from:Ma'); + }); + + test('when query has a substitution and then it was changed', () => { + // given this previous query: "foo from:@mateusz1" + const userTypedQuery = 'foo from:Maat1'; + const substitutionsMock = { + 'from:Mat1': '@mateusz1', + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo from:Maat1'); + }); + + test('when query has multiple substitutions and one was changed on the last position', () => { + // given this previous query: "foo in:123,456 from:@jakub" + // oldHumanReadableQ = 'foo in:admin,admins from:Jakub' + const userTypedQuery = 'foo in:admin,admins from:Jakub2'; + const substitutionsMock = { + 'in:admin': '123', + 'in:admins': '456', + 'from:Jakub': '@jakub', + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo in:123,456 from:Jakub2'); + }); + + test('when query has multiple substitutions and one was changed in the middle', () => { + // given this previous query: "foo in:aabbccdd123,zxcv123 from:@jakub" + const userTypedQuery = 'foo in:wave2,waveControl from:zzzz'; + + const substM = { + 'in:wave': 'aabbccdd123', + 'in:waveControl': 'zxcv123', + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substM); + + expect(result).toBe('foo in:wave2,zxcv123 from:zzzz'); + }); +}); diff --git a/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts b/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts new file mode 100644 index 000000000000..43829af9f873 --- /dev/null +++ b/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// we need "dirty" object key names in these tests +import {getUpdatedSubstitutionsMap} from '@src/components/Search/SearchRouter/getUpdatedSubstitutionsMap'; + +describe('getUpdatedSubstitutionsMap should return updated and cleaned substitutions map', () => { + test('when there were no substitutions', () => { + const userTypedQuery = 'foo bar'; + const substitutionsMock = {}; + + const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock); + + expect(result).toStrictEqual({}); + }); + + test('when query has a substitution and it did not change', () => { + const userTypedQuery = 'foo from:Mat'; + const substitutionsMock = { + 'from:Mat': '@mateusz', + }; + + const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock); + + expect(result).toStrictEqual({ + 'from:Mat': '@mateusz', + }); + }); + + test('when query has a substitution and it changed', () => { + const userTypedQuery = 'foo from:Johnny'; + const substitutionsMock = { + 'from:Steven': '@steven', + }; + + const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock); + + expect(result).toStrictEqual({}); + }); + + test('when query has multiple substitutions and some changed but some stayed', () => { + const userTypedQuery = 'from:Johnny to:Steven category:Fruitzzzz'; + const substitutionsMock = { + 'from:Johnny': '@johnny', + 'to:Steven': '@steven', + 'from:OldName': '@oldName', + 'category:Fruit': '123456', + }; + + const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock); + + expect(result).toStrictEqual({ + 'from:Johnny': '@johnny', + 'to:Steven': '@steven', + }); + }); +});