From 3bc3cebed62bc787d30ad8091dd4a395e8de10d4 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Wed, 13 Nov 2024 11:49:35 +0100 Subject: [PATCH 1/9] Move autocomplete list generation to RouterList --- src/components/Search/SearchPageHeader.tsx | 2 +- .../Search/SearchRouter/SearchRouter.tsx | 331 ++++------------ .../Search/SearchRouter/SearchRouterInput.tsx | 6 +- .../Search/SearchRouter/SearchRouterList.tsx | 364 ++++++++++++------ tests/perf-test/SearchRouter.perf-test.tsx | 2 +- 5 files changed, 333 insertions(+), 372 deletions(-) diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 1c3370cd72d5..e33654cbb9f3 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -74,7 +74,7 @@ function HeaderWrapper({icon, children, text, value, isCannedQuery, onSubmit, se void; @@ -57,13 +43,21 @@ 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 [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + 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 [autocompleteInputValue, setAutocompleteInputValue] = useState(textInputValue); + const contextualReportID = useNavigationState, string | undefined>((state) => { return state?.routes.at(-1)?.params?.reportID; }); @@ -105,253 +99,89 @@ 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 prevUserQueryRef = useRef(null); useEffect(() => { Report.searchInServer(debouncedInputValue.trim()); }, [debouncedInputValue]); - const onSearchChange = useCallback( + const sections = []; + + if (contextualReportData && !textInputValue) { + const reportQueryValue = contextualReportData.text ?? contextualReportData.alternateText ?? contextualReportData.reportID; + sections.push({ + data: [ + { + text: `${translate('search.searchIn')} ${contextualReportData.text ?? contextualReportData.alternateText}`, + singleIcon: Expensicons.MagnifyingGlass, + searchQuery: reportQueryValue, + autocompleteID: contextualReportData.reportID, + itemStyle: styles.activeComponentBG, + keyForList: 'contextualSearch', + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION, + }, + ], + }); + } + + const recentSearchesData = sortedRecentSearches?.slice(0, 5).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}); + } + + 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) => { - let newUserQuery = userQuery; - if (autocompleteSuggestions && userQuery.endsWith(',')) { - newUserQuery = `${userQuery.slice(0, userQuery.length - 1).trim()},`; + const prevParsedQuery = parseForAutocomplete(textInputValue); + + let updatedUserQuery = userQuery; + // If the prev value was query with autocomplete, and the current query ends with a comma, then we allow to continue autocompleting the next value + if (prevParsedQuery?.autocomplete && userQuery.endsWith(',')) { + updatedUserQuery = `${userQuery.slice(0, userQuery.length - 1).trim()},`; } - setTextInputValue(newUserQuery); - const autocompleteParsedQuery = parseForAutocomplete(newUserQuery); - updateAutocomplete(autocompleteParsedQuery?.autocomplete?.value ?? '', autocompleteParsedQuery?.ranges ?? [], autocompleteParsedQuery?.autocomplete?.key); + setTextInputValue(updatedUserQuery); + setAutocompleteInputValue(updatedUserQuery); const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions); setAutocompleteSubstitutions(updatedSubstitutionsMap); - if (newUserQuery || !isEmpty(prevUserQueryRef.current)) { + if (updatedUserQuery) { 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( @@ -369,6 +199,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); setTextInputValue(''); + setAutocompleteInputValue(''); }, [autocompleteSubstitutions, onRouterClose, setTextInputValue], ); @@ -399,7 +230,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) { onSearchSubmit(textInputValue); }} @@ -412,13 +243,11 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) isSearchingForReports={isSearchingForReports} /> void; + onSearchQueryChange: (searchTerm: string) => void; /** Callback invoked when the user submits the input */ onSubmit?: () => void; @@ -52,7 +52,7 @@ type SearchRouterInputProps = { function SearchRouterInput({ value, - updateSearch, + onSearchQueryChange, onSubmit = () => {}, routerListRef, isFullWidth, @@ -81,7 +81,7 @@ function SearchRouterInput({ >; + /** Callback to update text input value along with autocomplete suggestions */ - updateSearchValue: (newValue: string) => void; + onSearchQueryChange: (newValue: string) => void; /** Callback to update text input value */ setTextInputValue: (text: string) => void; - /** Recent searches */ - recentSearches: Array | undefined; - - /** Recent reports */ - recentReports: OptionData[]; - - /** Autocomplete items */ - autocompleteSuggestions: AutocompleteItemData[] | undefined; - /** Callback to submit query when selecting a list item */ 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; @@ -132,112 +133,243 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList } function SearchRouterList( - { - textInputValue, - updateSearchValue, - setTextInputValue, - reportForContextualSearch, - recentSearches, - autocompleteSuggestions, - recentReports, - onSearchSubmit, - onAutocompleteSuggestionClick, - closeRouter, - }: SearchRouterListProps, + {textInputValue, searchQueryItem, additionalSections, onSearchQueryChange, setTextInputValue, onSearchSubmit, onAutocompleteSuggestionClick, closeRouter}: 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 {activeWorkspaceID} = useActiveWorkspace(); + const policy = usePolicy(activeWorkspaceID); + const [betas] = useOnyx(ONYXKEYS.BETAS); + + const {options, areOptionsInitialized} = useOptionsList(); + const searchOptions = useMemo(() => { + if (!areOptionsInitialized) { + return {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null, categoryOptions: [], tagOptions: [], taxRatesOptions: []}; + } + 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 sections: Array> = []; + 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 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(textInputValue); + 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())) + .sort() + .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())) + .sort() + .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())) + .sort((chatA, chatB) => (chatA > chatB ? 1 : -1)) + .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(); - 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, - }, - ], - }); - } + return filteredTypes.map((type) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE, text: type})); + } + case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: { + const filteredStatuses = statusAutocompleteList + .filter((status) => status.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(status)) + .sort() + .slice(0, 10); - 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 = ''; + 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(card.bank.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 []; } } - 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, - }, - ], - }); + }, [ + textInputValue, + tagAutocompleteList, + recentTagsAutocompleteList, + categoryAutocompleteList, + recentCategoriesAutocompleteList, + currencyAutocompleteList, + recentCurrencyAutocompleteList, + taxAutocompleteList, + participantsAutocompleteList, + searchOptions.recentReports, + typeAutocompleteList, + statusAutocompleteList, + expenseTypes, + cardAutocompleteList, + ]); + + const sections: Array> = []; + + if (searchQueryItem) { + sections.push({data: [searchQueryItem]}); } - 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) { + 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}); } - 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}); + if (additionalSections) { + sections.push(...additionalSections); } - 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)) { @@ -246,7 +378,7 @@ function SearchRouterList( } if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { const searchQuery = getContextualSearchQuery(item); - updateSearchValue(`${searchQuery} `); + onSearchQueryChange(`${searchQuery} `); if (item.roomType === CONST.SEARCH.DATA_TYPES.INVOICE && item.autocompleteID) { const autocompleteKey = `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${item.searchQuery}`; @@ -260,7 +392,7 @@ function SearchRouterList( } if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue); - updateSearchValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `); + onSearchQueryChange(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `); if (item.autocompleteID && item.text) { onAutocompleteSuggestionClick(item.text, item.autocompleteID); @@ -279,7 +411,7 @@ function SearchRouterList( ReportUserActions.navigateToAndOpenReport(item.login ? [item.login] : [], false); } }, - [closeRouter, textInputValue, onSearchSubmit, updateSearchValue, onAutocompleteSuggestionClick], + [closeRouter, textInputValue, onSearchSubmit, onSearchQueryChange, onAutocompleteSuggestionClick], ); const onArrowFocus = useCallback( @@ -289,6 +421,7 @@ function SearchRouterList( } const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue); + setTextInputValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(focusedItem.searchQuery)} `); if (focusedItem.autocompleteID && focusedItem.text) { @@ -319,4 +452,3 @@ function SearchRouterList( export default forwardRef(SearchRouterList); export {SearchRouterItem}; -export type {AutocompleteItemData}; 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} /> From cf0c23a35bd7712d828a8c2160b8811d7c864e2c Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Fri, 15 Nov 2024 13:09:32 +0100 Subject: [PATCH 2/9] Add autocomplete list to SearchPageHeader and refactor it --- src/components/Search/SearchPageHeader.tsx | 137 +--------- .../Search/SearchPageHeaderInput.tsx | 239 ++++++++++++++++++ .../Search/SearchRouter/SearchRouter.tsx | 37 ++- .../Search/SearchRouter/SearchRouterInput.tsx | 10 + .../Search/SearchRouter/SearchRouterList.tsx | 6 + .../SearchRouter/buildSubstitutionsMap.ts | 41 +++ src/libs/SearchQueryUtils.ts | 1 + src/pages/Search/SearchPage.tsx | 5 +- src/pages/Search/SearchSelectedNarrow.tsx | 1 - .../Search/SearchSelectionModeHeader.tsx | 7 +- src/styles/index.ts | 9 + 11 files changed, 349 insertions(+), 144 deletions(-) create mode 100644 src/components/Search/SearchPageHeaderInput.tsx create mode 100644 src/components/Search/SearchRouter/buildSubstitutionsMap.ts diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index e33654cbb9f3..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) as string[]) ?? []; + 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 && } - + ({}); + // The actual input text that the user sees + const [textInputValue, setTextInputValue] = useState(''); + // The input text that was last used for autocomplete; needed for the SearchRouterLiteList when browsing list via arrow keys + const [autocompleteInputValue, setAutocompleteInputValue] = useState(textInputValue); + + const [displayAutocompleteList, setDisplayAutocompleteList] = useState(true); + const listRef = useRef(null); + + const {type} = queryJSON; + const isCannedQuery = SearchQueryUtils.isCannedSearchQuery(queryJSON); + const headerText = isCannedQuery ? translate(getHeaderContent(type).titleText) : ''; + const queryText = SearchQueryUtils.buildUserReadableQueryString(queryJSON, personalDetails, cardList, reports, taxRates); + + useEffect(() => { + // Todo handle setting a new text + // const foobar = buildSubstitutionsMap(queryText); + setTextInputValue(queryText); + }, [queryText]); + + const onSearchQueryChange = useCallback( + (userQuery: string) => { + const prevParsedQuery = parseForAutocomplete(textInputValue); + + let updatedUserQuery = userQuery; + // If the prev value was query with autocomplete, and the current query ends with a comma, then we allow to continue autocompleting the next value + if (prevParsedQuery?.autocomplete && userQuery.endsWith(',')) { + updatedUserQuery = `${userQuery.slice(0, userQuery.length - 1).trim()},`; + } + setTextInputValue(updatedUserQuery); + setAutocompleteInputValue(updatedUserQuery); + + const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions); + setAutocompleteSubstitutions(updatedSubstitutionsMap); + + if (updatedUserQuery) { + listRef.current?.updateAndScrollToFocusedIndex(0); + } else { + listRef.current?.updateAndScrollToFocusedIndex(-1); + } + }, + [autocompleteSubstitutions, setTextInputValue, textInputValue], + ); + + const onSearchSubmit = useCallback( + (queryString: SearchQueryString) => { + const cleanedQueryString = getQueryWithSubstitutions(queryString, autocompleteSubstitutions); + const inputQueryJSON = SearchQueryUtils.buildSearchQueryJSON(cleanedQueryString); + if (!inputQueryJSON) { + return; + } + + const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(inputQueryJSON, SearchQueryUtils.getUpdatedAmountValue); + const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery); + Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); + + SearchActions.clearAllFilters(); + setTextInputValue(''); + setAutocompleteInputValue(''); + setDisplayAutocompleteList(false); + + // Todo Old + // const inputQueryJSON = SearchQueryUtils.buildSearchQueryJSON(''); + // 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); + // } else { + // Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} user query failed to parse`, {}, false); + // } + }, + [autocompleteSubstitutions], + ); + + const onAutocompleteSuggestionClick = (autocompleteKey: string, autocompleteID: string) => { + const substitutions = {...autocompleteSubstitutions, [autocompleteKey]: autocompleteID}; + + setAutocompleteSubstitutions(substitutions); + }; + + const hideAutocompleteList = () => setDisplayAutocompleteList(false); + const showAutocompleteList = () => setDisplayAutocompleteList(true); + + if (isCannedQuery) { + const headerIcon = getHeaderContent(type).icon; + + return ( + + + +
{headerText}} /> + + {children} + + + + + ); + } + + const autocompleteParsedQuery = parseForAutocomplete(autocompleteInputValue); + const isListVisible = !!autocompleteParsedQuery?.autocomplete && displayAutocompleteList; + + const searchQueryItem = textInputValue + ? { + text: textInputValue, + singleIcon: Expensicons.MagnifyingGlass, + searchQuery: textInputValue, + itemStyle: styles.activeComponentBG, + keyForList: 'findItem', + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, + } + : undefined; + + return ( + + { + onSearchSubmit(textInputValue); + }} + autoFocus={false} + onFocus={showAutocompleteList} + onBlur={hideAutocompleteList} + wrapperStyle={[styles.searchRouterInputResults, styles.br2]} + wrapperFocusedStyle={styles.searchRouterInputResultsFocused} + 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 33aecc87d20d..df8cc54fe327 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -3,6 +3,7 @@ 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'; @@ -15,17 +16,19 @@ import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import type {SearchOption} from '@libs/OptionsListUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; import {parseForAutocomplete} 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 Report from '@src/types/onyx/Report'; import {getQueryWithSubstitutions} from './getQueryWithSubstitutions'; import type {SubstitutionMap} from './getQueryWithSubstitutions'; import {getUpdatedSubstitutionsMap} from './getUpdatedSubstitutionsMap'; @@ -106,26 +109,46 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) return reportOptions.slice(0, 10); }, [debouncedInputValue, filteredOptions, searchOptions]); - const contextualReportData = contextualReportID ? searchOptions.recentReports?.find((option) => option.reportID === contextualReportID) : undefined; + const reportForContextualSearch = contextualReportID ? searchOptions.recentReports?.find((option) => option.reportID === contextualReportID) : undefined; useEffect(() => { - Report.searchInServer(debouncedInputValue.trim()); + ReportUserActions.searchInServer(debouncedInputValue.trim()); }, [debouncedInputValue]); const sections = []; - if (contextualReportData && !textInputValue) { - const reportQueryValue = contextualReportData.text ?? contextualReportData.alternateText ?? contextualReportData.reportID; + 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; + // Todo understand why this typecasting is needed here + 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 = ''; + } + } + if (reportForContextualSearch.isPolicyExpenseChat) { + roomType = CONST.SEARCH.DATA_TYPES.EXPENSE; + autocompleteID = reportForContextualSearch.policyID ?? ''; + } + sections.push({ data: [ { - text: `${translate('search.searchIn')} ${contextualReportData.text ?? contextualReportData.alternateText}`, + text: `${translate('search.searchIn')} ${reportForContextualSearch.text ?? reportForContextualSearch.alternateText}`, singleIcon: Expensicons.MagnifyingGlass, searchQuery: reportQueryValue, - autocompleteID: contextualReportData.reportID, + autocompleteID, itemStyle: styles.activeComponentBG, keyForList: 'contextualSearch', searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION, + roomType, + policyID: reportForContextualSearch.policyID, }, ], }); diff --git a/src/components/Search/SearchRouter/SearchRouterInput.tsx b/src/components/Search/SearchRouter/SearchRouterInput.tsx index 38f2d78b43a0..3bd9146cac84 100644 --- a/src/components/Search/SearchRouter/SearchRouterInput.tsx +++ b/src/components/Search/SearchRouter/SearchRouterInput.tsx @@ -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; @@ -59,6 +65,8 @@ function SearchRouterInput({ disabled = false, shouldShowOfflineMessage = false, autoFocus = true, + onFocus, + onBlur, caretHidden = false, wrapperStyle, wrapperFocusedStyle, @@ -101,10 +109,12 @@ function SearchRouterInput({ onFocus={() => { 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 631cc6f4b044..7062110e266c 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -132,6 +132,7 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList ); } +// Todo rename to SearchAutocompleteList once it's used in both Router and SearchPage function SearchRouterList( {textInputValue, searchQueryItem, additionalSections, onSearchQueryChange, setTextInputValue, onSearchSubmit, onAutocompleteSuggestionClick, closeRouter}: SearchRouterListProps, ref: ForwardedRef, @@ -392,6 +393,10 @@ function SearchRouterList( } if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue); + // Fixme + // even though onSearchQueryChange cleans the autocompleteMap internally, + // the `onAutocompleteSuggestionClick` still carries old autocompleteMap in its closure + // as a result the autocompleteMap will never get cleaned onSearchQueryChange(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `); if (item.autocompleteID && item.text) { @@ -403,6 +408,7 @@ function SearchRouterList( onSearchSubmit(item.searchQuery); } + // Todo cleanup this onSelectRow, move reportID to Router // Handle selection of "Recent chat" closeRouter(); if ('reportID' in item && item?.reportID) { diff --git a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts new file mode 100644 index 000000000000..3c4351121975 --- /dev/null +++ b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts @@ -0,0 +1,41 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types'; +import * as parser from '@libs/SearchParser/autocompleteParser'; +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 data + * this function will build from scratch + * + * Ex: + * query: `Test from:John1` + * substitutions: { + * from:SomeOtherJohn: 12345 + * } + * return: {} + */ +function buildSubstitutionsMap(query: string, personalDetails: OnyxTypes.PersonalDetailsList, cardList: OnyxTypes.CardList, reports: OnyxCollection): 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) => { + // + return map; + }, {} as SubstitutionMap); + + return updatedSubstitutionMap; +} + +// eslint-disable-next-line import/prefer-default-export +export {buildSubstitutionsMap}; diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 6c2e570d58f9..b3a3ba4f5768 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -533,6 +533,7 @@ function getPolicyIDFromSearchQuery(queryJSON: SearchQueryJSON) { /** * @private * Returns the human-readable "pretty" value for a filter. + * Fixme use this for generating map? */ function getDisplayValue(filterName: string, filter: string, personalDetails: OnyxTypes.PersonalDetailsList, cardList: OnyxTypes.CardList, reports: OnyxCollection) { if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) { 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/styles/index.ts b/src/styles/index.ts index 154f8240b0c5..14ea26813a58 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2568,6 +2568,15 @@ const styles = (theme: ThemeColors) => width: '100%', }, + searchResultsHeaderBar: { + display: 'flex', + justifyContent: 'center', + height: variables.contentHeaderHeight, + zIndex: variables.popoverzIndex, + position: 'relative', + paddingHorizontal: 20, + }, + headerBarDesktopHeight: { height: variables.contentHeaderDesktopHeight, }, From 633e031c870026f85a71b0a1d4213ed7b03c5709 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Wed, 20 Nov 2024 10:04:16 +0100 Subject: [PATCH 3/9] Handle autocomplete substitutions in SearchPageHeader --- .../Search/SearchPageHeaderInput.tsx | 142 +++++++++++------- .../Search/SearchRouter/SearchRouter.tsx | 115 +++++++++++--- .../Search/SearchRouter/SearchRouterList.tsx | 131 +++------------- .../SearchRouter/buildSubstitutionsMap.ts | 48 +++++- .../Search/SearchQueryListItem.tsx | 8 +- src/libs/SearchAutocompleteUtils.ts | 16 ++ src/libs/SearchQueryUtils.ts | 82 +++------- .../unit/Search/buildSubstitutionsMapTest.ts | 98 ++++++++++++ 8 files changed, 378 insertions(+), 262 deletions(-) create mode 100644 tests/unit/Search/buildSubstitutionsMapTest.ts diff --git a/src/components/Search/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeaderInput.tsx index 1b97ba4f40b2..cc59cec2d502 100644 --- a/src/components/Search/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeaderInput.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Header from '@components/Header'; @@ -6,7 +6,8 @@ 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 {buildSubstitutionsMap} from '@components/Search/SearchRouter/buildSubstitutionsMap'; +import {isSearchQueryItem} from '@components/SelectionList/Search/SearchQueryListItem'; +import type {SearchQueryItem} from '@components/SelectionList/Search/SearchQueryListItem'; import type {SelectionListHandle} from '@components/SelectionList/types'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -15,7 +16,8 @@ import * as SearchActions from '@libs/actions/Search'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {getAllTaxRates} from '@libs/PolicyUtils'; -import {parseForAutocomplete} from '@libs/SearchAutocompleteUtils'; +import type {OptionData} from '@libs/ReportUtils'; +import * as SearchAutocompleteUtils from '@libs/SearchAutocompleteUtils'; import * as SearchQueryUtils from '@libs/SearchQueryUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -24,6 +26,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type IconAsset from '@src/types/utils/IconAsset'; +import {buildSubstitutionsMap} from './SearchRouter/buildSubstitutionsMap'; import {getQueryWithSubstitutions} from './SearchRouter/getQueryWithSubstitutions'; import type {SubstitutionMap} from './SearchRouter/getQueryWithSubstitutions'; import {getUpdatedSubstitutionsMap} from './SearchRouter/getUpdatedSubstitutionsMap'; @@ -61,40 +64,37 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps const styles = useThemeStyles(); const personalDetails = usePersonalDetails(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); - const taxRates = getAllTaxRates(); - const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const taxRates = useMemo(() => getAllTaxRates(), []); + const [cardList = CONST.EMPTY_OBJECT] = useOnyx(ONYXKEYS.CARD_LIST); const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); // The actual input text that the user sees const [textInputValue, setTextInputValue] = useState(''); - // The input text that was last used for autocomplete; needed for the SearchRouterLiteList when browsing list via arrow keys - const [autocompleteInputValue, setAutocompleteInputValue] = useState(textInputValue); + // 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 [displayAutocompleteList, setDisplayAutocompleteList] = useState(true); + const [isAutocompleteListVisible, setIsAutocompleteListVisible] = useState(false); const listRef = useRef(null); - const {type} = queryJSON; + const {type, inputQuery: originalInputQuery} = queryJSON; const isCannedQuery = SearchQueryUtils.isCannedSearchQuery(queryJSON); const headerText = isCannedQuery ? translate(getHeaderContent(type).titleText) : ''; const queryText = SearchQueryUtils.buildUserReadableQueryString(queryJSON, personalDetails, cardList, reports, taxRates); useEffect(() => { - // Todo handle setting a new text - // const foobar = buildSubstitutionsMap(queryText); setTextInputValue(queryText); }, [queryText]); + useEffect(() => { + const substitutionsMap = buildSubstitutionsMap(originalInputQuery, personalDetails, cardList, reports, taxRates); + setAutocompleteSubstitutions(substitutionsMap); + }, [originalInputQuery, cardList, personalDetails, reports, taxRates]); + const onSearchQueryChange = useCallback( (userQuery: string) => { - const prevParsedQuery = parseForAutocomplete(textInputValue); - - let updatedUserQuery = userQuery; - // If the prev value was query with autocomplete, and the current query ends with a comma, then we allow to continue autocompleting the next value - if (prevParsedQuery?.autocomplete && userQuery.endsWith(',')) { - updatedUserQuery = `${userQuery.slice(0, userQuery.length - 1).trim()},`; - } + const updatedUserQuery = SearchAutocompleteUtils.getAutocompleteQueryWithComma(textInputValue, userQuery); setTextInputValue(updatedUserQuery); - setAutocompleteInputValue(updatedUserQuery); + setAutocompleteQueryValue(updatedUserQuery); const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions); setAutocompleteSubstitutions(updatedSubstitutionsMap); @@ -108,46 +108,75 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps [autocompleteSubstitutions, setTextInputValue, textInputValue], ); - const onSearchSubmit = useCallback( + const submitSearch = useCallback( (queryString: SearchQueryString) => { + if (!queryString) { + return; + } + const cleanedQueryString = getQueryWithSubstitutions(queryString, autocompleteSubstitutions); - const inputQueryJSON = SearchQueryUtils.buildSearchQueryJSON(cleanedQueryString); - if (!inputQueryJSON) { + const userQueryJSON = SearchQueryUtils.buildSearchQueryJSON(cleanedQueryString); + + if (!userQueryJSON) { + Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} user query failed to parse`, {}, false); return; } - const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(inputQueryJSON, SearchQueryUtils.getUpdatedAmountValue); + const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(userQueryJSON, SearchQueryUtils.getUpdatedAmountValue); const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery); + Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); - SearchActions.clearAllFilters(); - setTextInputValue(''); - setAutocompleteInputValue(''); - setDisplayAutocompleteList(false); - - // Todo Old - // const inputQueryJSON = SearchQueryUtils.buildSearchQueryJSON(''); - // 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); - // } else { - // Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} user query failed to parse`, {}, false); - // } + if (query !== originalInputQuery) { + SearchActions.clearAllFilters(); + setTextInputValue(''); + setAutocompleteQueryValue(''); + setIsAutocompleteListVisible(false); + } }, - [autocompleteSubstitutions], + [autocompleteSubstitutions, originalInputQuery], ); - const onAutocompleteSuggestionClick = (autocompleteKey: string, autocompleteID: string) => { - const substitutions = {...autocompleteSubstitutions, [autocompleteKey]: autocompleteID}; + const onListItemClick = (item: OptionData | SearchQueryItem) => { + if (!isSearchQueryItem(item)) { + return; + } + + if (!item.searchQuery) { + return; + } - setAutocompleteSubstitutions(substitutions); + 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 = () => setDisplayAutocompleteList(false); - const showAutocompleteList = () => setDisplayAutocompleteList(true); + const hideAutocompleteList = () => setIsAutocompleteListVisible(false); + const showAutocompleteList = () => setIsAutocompleteListVisible(true); if (isCannedQuery) { const headerIcon = getHeaderContent(type).icon; @@ -174,9 +203,6 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps ); } - const autocompleteParsedQuery = parseForAutocomplete(autocompleteInputValue); - const isListVisible = !!autocompleteParsedQuery?.autocomplete && displayAutocompleteList; - const searchQueryItem = textInputValue ? { text: textInputValue, @@ -188,6 +214,9 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps } : undefined; + const listTopPosition = variables.contentHeaderHeight; + const shouldShowAutocompleteList = isAutocompleteListVisible && !!textInputValue; + return ( { - onSearchSubmit(textInputValue); + submitSearch(textInputValue); }} autoFocus={false} onFocus={showAutocompleteList} @@ -214,19 +243,16 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps styles.pAbsolute, styles.appBG, styles.pt2, - !isListVisible && styles.dNone, - isListVisible && styles.border, - {top: variables.contentHeaderHeight - 9, left: 20, right: 20}, + {top: listTopPosition, left: 20, right: 20}, + !shouldShowAutocompleteList && styles.dNone, + shouldShowAutocompleteList && styles.border, ]} > diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index df8cc54fe327..61accc441138 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -9,6 +9,8 @@ import * as Expensicons from '@components/Icon/Expensicons'; import {usePersonalDetails} from '@components/OnyxProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; 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 useDebouncedState from '@hooks/useDebouncedState'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; @@ -19,7 +21,7 @@ import * as OptionsListUtils from '@libs/OptionsListUtils'; import type {SearchOption} from '@libs/OptionsListUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; -import {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'; @@ -35,6 +37,35 @@ import {getUpdatedSubstitutionsMap} from './getUpdatedSubstitutionsMap'; import SearchRouterInput from './SearchRouterInput'; import SearchRouterList 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; shouldHideInputCaret?: TextInputProps['caretHidden']; @@ -59,7 +90,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) // 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 [autocompleteInputValue, setAutocompleteInputValue] = useState(textInputValue); + const [autocompleteQueryValue, setAutocompleteQueryValue] = useState(textInputValue); const contextualReportID = useNavigationState, string | undefined>((state) => { return state?.routes.at(-1)?.params?.reportID; @@ -124,7 +155,6 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) let autocompleteID = reportForContextualSearch.reportID; if (reportForContextualSearch.isInvoiceRoom) { roomType = CONST.SEARCH.DATA_TYPES.INVOICE; - // Todo understand why this typecasting is needed here 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(); @@ -185,15 +215,9 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) const onSearchQueryChange = useCallback( (userQuery: string) => { - const prevParsedQuery = parseForAutocomplete(textInputValue); - - let updatedUserQuery = userQuery; - // If the prev value was query with autocomplete, and the current query ends with a comma, then we allow to continue autocompleting the next value - if (prevParsedQuery?.autocomplete && userQuery.endsWith(',')) { - updatedUserQuery = `${userQuery.slice(0, userQuery.length - 1).trim()},`; - } + const updatedUserQuery = SearchAutocompleteUtils.getAutocompleteQueryWithComma(textInputValue, userQuery); setTextInputValue(updatedUserQuery); - setAutocompleteInputValue(updatedUserQuery); + setAutocompleteQueryValue(updatedUserQuery); const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions); setAutocompleteSubstitutions(updatedSubstitutionsMap); @@ -207,7 +231,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) [autocompleteSubstitutions, setTextInputValue, textInputValue], ); - const onSearchSubmit = useCallback( + const submitSearch = useCallback( (queryString: SearchQueryString) => { const cleanedQueryString = getQueryWithSubstitutions(queryString, autocompleteSubstitutions); const queryJSON = SearchQueryUtils.buildSearchQueryJSON(cleanedQueryString); @@ -222,15 +246,63 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); setTextInputValue(''); - setAutocompleteInputValue(''); + 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)} `); - setAutocompleteSubstitutions(substitutions); + if (item.text && item.autocompleteID) { + const substitutions = {...autocompleteSubstitutions, [item.text]: item.autocompleteID}; + + 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, () => { @@ -255,7 +327,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) isFullWidth={shouldUseNarrowLayout} onSearchQueryChange={onSearchQueryChange} onSubmit={() => { - onSearchSubmit(textInputValue); + submitSearch(textInputValue); }} caretHidden={shouldHideInputCaret} routerListRef={listRef} @@ -266,14 +338,11 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) isSearchingForReports={isSearchingForReports} /> diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 7062110e266c..26e0aa257efa 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -7,7 +7,7 @@ import {usePersonalDetails} from '@components/OnyxProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; import type {SearchFilterKey, SearchQueryString} 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'; @@ -17,7 +17,6 @@ import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CardUtils from '@libs/CardUtils'; -import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import Performance from '@libs/Performance'; import {getAllTaxRates} from '@libs/PolicyUtils'; @@ -28,15 +27,11 @@ import { getAutocompleteRecentTags, getAutocompleteTags, getAutocompleteTaxList, - getQueryWithoutAutocompletedPart, parseForAutocomplete, } from '@libs/SearchAutocompleteUtils'; -import * as SearchQueryUtils from '@libs/SearchQueryUtils'; -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 {getSubstitutionMapKey} from './getQueryWithSubstitutions'; @@ -48,7 +43,7 @@ type AutocompleteItemData = { type SearchRouterListProps = { /** Value of TextInput */ - textInputValue: string; + autocompleteQueryValue: string; /** An optional item to always display on the top of the router list */ searchQueryItem?: SearchQueryItem; @@ -56,20 +51,13 @@ type SearchRouterListProps = { /** Any extra sections that should be displayed in the router list */ additionalSections?: Array>; - /** Callback to update text input value along with autocomplete suggestions */ - onSearchQueryChange: (newValue: string) => void; + shouldPreventDefault?: boolean; - /** Callback to update text input value */ - setTextInputValue: (text: string) => void; + /** Callback to call when an item is clicked/selected */ + onListItemPress: (item: OptionData | SearchQueryItem) => void; - /** Callback to submit query when selecting a list item */ - onSearchSubmit: (query: SearchQueryString) => void; - - /** 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; + /** Callback to call when an item is focused via arrow buttons */ + onListItemFocus: (item: SearchQueryItem) => void; }; const setPerformanceTimersEnd = () => { @@ -77,30 +65,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); } @@ -134,7 +98,7 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList // Todo rename to SearchAutocompleteList once it's used in both Router and SearchPage function SearchRouterList( - {textInputValue, searchQueryItem, additionalSections, onSearchQueryChange, setTextInputValue, onSearchSubmit, onAutocompleteSuggestionClick, closeRouter}: SearchRouterListProps, + {autocompleteQueryValue, searchQueryItem, additionalSections, shouldPreventDefault = true, onListItemFocus, onListItemPress}: SearchRouterListProps, ref: ForwardedRef, ) { const styles = useThemeStyles(); @@ -192,7 +156,7 @@ function SearchRouterList( const recentTagsAutocompleteList = getAutocompleteRecentTags(allRecentTags, activeWorkspaceID); const autocompleteSuggestions = useMemo(() => { - const autocompleteParsedQuery = parseForAutocomplete(textInputValue); + const autocompleteParsedQuery = parseForAutocomplete(autocompleteQueryValue); const {autocomplete, ranges = []} = autocompleteParsedQuery ?? {}; const autocompleteKey = autocomplete?.key; const autocompleteValue = autocomplete?.value ?? ''; @@ -330,7 +294,7 @@ function SearchRouterList( } } }, [ - textInputValue, + autocompleteQueryValue, tagAutocompleteList, recentTagsAutocompleteList, categoryAutocompleteList, @@ -371,87 +335,30 @@ function SearchRouterList( sections.push(...additionalSections); } - 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); - onSearchQueryChange(`${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); - // Fixme - // even though onSearchQueryChange cleans the autocompleteMap internally, - // the `onAutocompleteSuggestionClick` still carries old autocompleteMap in its closure - // as a result the autocompleteMap will never get cleaned - onSearchQueryChange(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `); - - if (item.autocompleteID && item.text) { - onAutocompleteSuggestionClick(item.text, item.autocompleteID); - } - return; - } - - onSearchSubmit(item.searchQuery); - } - - // Todo cleanup this onSelectRow, move reportID to Router - // 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); - } - }, - [closeRouter, textInputValue, onSearchSubmit, onSearchQueryChange, onAutocompleteSuggestionClick], - ); - - const onArrowFocus = useCallback( - (focusedItem: OptionData | SearchQueryItem) => { - if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !focusedItem.searchQuery) { - return; - } - - const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue); - - setTextInputValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(focusedItem.searchQuery)} `); + const onArrowFocus = (focusedItem: OptionData | SearchQueryItem) => { + if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION) { + return; + } - if (focusedItem.autocompleteID && focusedItem.text) { - onAutocompleteSuggestionClick(focusedItem.text, focusedItem.autocompleteID); - } - }, - [setTextInputValue, textInputValue, onAutocompleteSuggestionClick], - ); + 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} /> ); } diff --git a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts index 3c4351121975..ae79e02df17c 100644 --- a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts +++ b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts @@ -1,6 +1,8 @@ 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'; @@ -17,7 +19,13 @@ const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${fi * } * return: {} */ -function buildSubstitutionsMap(query: string, personalDetails: OnyxTypes.PersonalDetailsList, cardList: OnyxTypes.CardList, reports: OnyxCollection): SubstitutionMap { +function buildSubstitutionsMap( + query: string, + personalDetails: OnyxTypes.PersonalDetailsList, + cardList: OnyxTypes.CardList, + reports: OnyxCollection, + allTaxRates: Record, +): SubstitutionMap { const parsedQuery = parser.parse(query) as {ranges: SearchAutocompleteQueryRange[]}; const searchAutocompleteQueryRanges = parsedQuery.ranges; @@ -26,15 +34,43 @@ function buildSubstitutionsMap(query: string, personalDetails: OnyxTypes.Persona return {}; } - const autocompleteQueryKeys = searchAutocompleteQueryRanges.map((range) => getSubstitutionsKey(range.key, range.value)); + 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, cardList, 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; + } + } - // Build a new substitutions map consisting of only the keys from old map, that appear in query - const updatedSubstitutionMap = autocompleteQueryKeys.reduce((map, key) => { - // return map; }, {} as SubstitutionMap); - return updatedSubstitutionMap; + return substitutionsMap; } // eslint-disable-next-line import/prefer-default-export 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 b3a3ba4f5768..0aeb40903023 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -11,7 +11,6 @@ import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; 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 +161,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,27 +485,31 @@ function getPolicyIDFromSearchQuery(queryJSON: SearchQueryJSON) { } /** - * @private - * Returns the human-readable "pretty" value for a filter. - * Fixme use this for generating map? + * 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, + cardList: OnyxTypes.CardList, + 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; + return cardList[filterValue]?.bank || 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; } /** @@ -565,10 +523,10 @@ function buildUserReadableQueryString( 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}`; @@ -581,10 +539,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(); @@ -597,7 +555,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, cardList, reports), })); } title += buildFilterValuesString(key, displayQueryFilters); @@ -673,13 +631,13 @@ export { buildSearchQueryJSON, buildSearchQueryString, buildUserReadableQueryString, + getFilterDisplayValue, buildQueryStringFromFilterFormValues, buildFilterFormValuesFromQuery, getPolicyIDFromSearchQuery, buildCannedSearchQuery, isCannedSearchQuery, traverseAndUpdatedQuery, - getFindIDFromDisplayValue, getUpdatedAmountValue, sanitizeSearchValue, }; diff --git a/tests/unit/Search/buildSubstitutionsMapTest.ts b/tests/unit/Search/buildSubstitutionsMapTest.ts new file mode 100644 index 000000000000..83832aea232d --- /dev/null +++ b/tests/unit/Search/buildSubstitutionsMapTest.ts @@ -0,0 +1,98 @@ +/* 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/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.displayName; + }, + }; +}); + +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 cardListMock = { + card1: { + cardID: 1111, + state: 3, + bank: 'Best Bank', + domainName: '', + lastUpdated: '', + fraud: 'none', + }, + card2: { + cardID: 2222, + state: 3, + bank: 'Great Bank', + domainName: '', + lastUpdated: '', + fraud: 'none', + }, +} as OnyxTypes.CardList; + +const reportsMock = { + [`${ONYXKEYS.COLLECTION.REPORT}rep123`]: { + reportID: 'rep123', + displayName: 'Report 1', + }, + [`${ONYXKEYS.COLLECTION.REPORT}rep456`]: { + reportID: 'rep456', + displayName: '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, cardListMock, reportsMock, taxRatesMock); + + expect(result).toStrictEqual({}); + }); + test('when query has a single substitution', () => { + const userQuery = 'foo from:12345'; + + const result = buildSubstitutionsMap(userQuery, personalDetailsMock, cardListMock, 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:card1 in:rep123 taxRate:id_TAX_1'; + + const result = buildSubstitutionsMap(userQuery, personalDetailsMock, cardListMock, reportsMock, taxRatesMock); + + expect(result).toStrictEqual({ + 'from:Jane Doe': '78901', + 'from:John Doe': '12345', + 'in:Report 1': 'rep123', + 'cardID:Best Bank': 'card1', + 'taxRate:TAX_1': 'id_TAX_1', + }); + }); +}); From 29ee84fd41a26817caaa3303063cbc9554b5358d Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Wed, 20 Nov 2024 12:58:25 +0100 Subject: [PATCH 4/9] Improve sorting of autocomplete options --- .../Search/SearchPageHeaderInput.tsx | 4 +- .../Search/SearchRouter/SearchRouter.tsx | 2 +- .../Search/SearchRouter/SearchRouterList.tsx | 71 ++++++++++++++----- src/styles/index.ts | 2 +- .../unit/Search/buildSubstitutionsMapTest.ts | 6 +- 5 files changed, 59 insertions(+), 26 deletions(-) diff --git a/src/components/Search/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeaderInput.tsx index cc59cec2d502..599c830531ea 100644 --- a/src/components/Search/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeaderInput.tsx @@ -99,7 +99,7 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions); setAutocompleteSubstitutions(updatedSubstitutionsMap); - if (updatedUserQuery) { + if (updatedUserQuery || textInputValue.length > 0) { listRef.current?.updateAndScrollToFocusedIndex(0); } else { listRef.current?.updateAndScrollToFocusedIndex(-1); @@ -214,7 +214,7 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps } : undefined; - const listTopPosition = variables.contentHeaderHeight; + const listTopPosition = variables.contentHeaderDesktopHeight; const shouldShowAutocompleteList = isAutocompleteListVisible && !!textInputValue; return ( diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 61accc441138..9de6b1c2998d 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -222,7 +222,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions); setAutocompleteSubstitutions(updatedSubstitutionsMap); - if (updatedUserQuery) { + if (updatedUserQuery || textInputValue.length > 0) { listRef.current?.updateAndScrollToFocusedIndex(0); } else { listRef.current?.updateAndScrollToFocusedIndex(-1); diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 26e0aa257efa..135eb83390d6 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -1,11 +1,10 @@ import {Str} from 'expensify-common'; -import React, {forwardRef, useCallback, useMemo} from 'react'; +import React, {forwardRef, useMemo} from 'react'; import type {ForwardedRef} from 'react'; import {useOnyx} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; -import {usePersonalDetails} from '@components/OnyxProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; -import type {SearchFilterKey, SearchQueryString} from '@components/Search/types'; +import type {SearchFilterKey} from '@components/Search/types'; import SelectionList from '@components/SelectionList'; import SearchQueryListItem, {isSearchQueryItem} from '@components/SelectionList/Search/SearchQueryListItem'; import type {SearchQueryItem, SearchQueryListItemProps} from '@components/SelectionList/Search/SearchQueryListItem'; @@ -18,6 +17,7 @@ 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 Performance from '@libs/Performance'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -60,6 +60,14 @@ type SearchRouterListProps = { onListItemFocus: (item: SearchQueryItem) => void; }; +const defaultListOptions = { + userToInvite: null, + recentReports: [], + personalDetails: [], + currentUserOption: null, + categoryOptions: [], +}; + const setPerformanceTimersEnd = () => { Timing.end(CONST.TIMING.OPEN_SEARCH); Performance.markEnd(CONST.TIMING.OPEN_SEARCH); @@ -112,7 +120,7 @@ function SearchRouterList( const {options, areOptionsInitialized} = useOptionsList(); const searchOptions = useMemo(() => { if (!areOptionsInitialized) { - return {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null, categoryOptions: [], tagOptions: [], taxRatesOptions: []}; + return defaultListOptions; } return OptionsListUtils.getSearchOptions(options, '', betas ?? []); }, [areOptionsInitialized, betas, options]); @@ -123,19 +131,44 @@ function SearchRouterList( 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) => ({ + + const participantsAutocompleteList = useMemo(() => { + if (!areOptionsInitialized) { + return []; + } + + const filteredOptions = OptionsListUtils.getFilteredOptions({ + reports: options.reports, + personalDetails: options.personalDetails, + excludeLogins: CONST.EXPENSIFY_EMAILS, + maxRecentReportsToShow: 0, + includeSelfDM: true, + }); + + // 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(), - })), - [personalDetailsForParticipants], - ); + accountID: details.accountID.toString(), + }; + }); + const currentUser = (filteredOptions.currentUserOption as SearchOption).item; + if (currentUser) { + autocompleteOptions.push({ + name: currentUser.displayName ?? Str.removeSMSDomain(currentUser.login ?? ''), + accountID: currentUser.accountID?.toString() ?? '-1', + }); + } + + return autocompleteOptions; + }, [areOptionsInitialized, options.personalDetails, options.reports]); + 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(() => { @@ -219,7 +252,6 @@ function SearchRouterList( 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); return filteredParticipants.map((participant) => ({ @@ -231,7 +263,6 @@ function SearchRouterList( 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); return filteredParticipants.map((participant) => ({ @@ -242,8 +273,7 @@ function SearchRouterList( } 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)) + .filter((chat) => chat.text?.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(chat.text.toLowerCase())) .slice(0, 10); return filteredChats.map((chat) => ({ @@ -279,7 +309,10 @@ function SearchRouterList( } case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID: { const filteredCards = cardAutocompleteList - .filter((card) => card.bank.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(card.bank.toLowerCase())) + .filter( + (card) => + card.bank.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(CardUtils.getCardDescription(card.cardID).toLowerCase()), + ) .sort() .slice(0, 10); diff --git a/src/styles/index.ts b/src/styles/index.ts index 14ea26813a58..ccea5dd26b3d 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -2571,7 +2571,7 @@ const styles = (theme: ThemeColors) => searchResultsHeaderBar: { display: 'flex', justifyContent: 'center', - height: variables.contentHeaderHeight, + height: variables.contentHeaderDesktopHeight, zIndex: variables.popoverzIndex, position: 'relative', paddingHorizontal: 20, diff --git a/tests/unit/Search/buildSubstitutionsMapTest.ts b/tests/unit/Search/buildSubstitutionsMapTest.ts index 83832aea232d..2478c8a8de84 100644 --- a/tests/unit/Search/buildSubstitutionsMapTest.ts +++ b/tests/unit/Search/buildSubstitutionsMapTest.ts @@ -10,7 +10,7 @@ jest.mock('@libs/ReportUtils', () => { 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.displayName; + return report.reportName; }, }; }); @@ -52,11 +52,11 @@ const cardListMock = { const reportsMock = { [`${ONYXKEYS.COLLECTION.REPORT}rep123`]: { reportID: 'rep123', - displayName: 'Report 1', + reportName: 'Report 1', }, [`${ONYXKEYS.COLLECTION.REPORT}rep456`]: { reportID: 'rep456', - displayName: 'Report 2', + reportName: 'Report 2', }, } as OnyxCollection; From 4a0c82c4474d5cefd8c0747405500646138328c8 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Thu, 21 Nov 2024 16:28:17 +0100 Subject: [PATCH 5/9] Improve UI and add small tweaks --- .../Search/SearchPageHeaderInput.tsx | 66 +++++++++---------- .../SearchRouter/buildSubstitutionsMap.ts | 17 +++-- 2 files changed, 42 insertions(+), 41 deletions(-) diff --git a/src/components/Search/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeaderInput.tsx index 599c830531ea..eb2c7aa2601d 100644 --- a/src/components/Search/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeaderInput.tsx @@ -137,7 +137,7 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps [autocompleteSubstitutions, originalInputQuery], ); - const onListItemClick = (item: OptionData | SearchQueryItem) => { + const onListItemPress = (item: OptionData | SearchQueryItem) => { if (!isSearchQueryItem(item)) { return; } @@ -214,47 +214,43 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps } : undefined; - const listTopPosition = variables.contentHeaderDesktopHeight; const shouldShowAutocompleteList = isAutocompleteListVisible && !!textInputValue; + const topPosition = (variables.contentHeaderDesktopHeight - variables.componentSizeLarge) / 2; + const activeStyles = shouldShowAutocompleteList ? [styles.appBG, styles.border, styles.pAbsolute, {top: topPosition, left: 20, right: 20}] : []; + const inputWrapperActiveStyle = shouldShowAutocompleteList ? styles.ph2 : null; + return ( - { - submitSearch(textInputValue); - }} - autoFocus={false} - onFocus={showAutocompleteList} - onBlur={hideAutocompleteList} - wrapperStyle={[styles.searchRouterInputResults, styles.br2]} - wrapperFocusedStyle={styles.searchRouterInputResultsFocused} - rightComponent={children} - routerListRef={listRef} - /> - - + { + submitSearch(textInputValue); + }} + autoFocus={false} + onFocus={showAutocompleteList} + onBlur={hideAutocompleteList} + wrapperStyle={[styles.searchRouterInputResults, styles.br2]} + wrapperFocusedStyle={styles.searchRouterInputResultsFocused} + outerWrapperStyle={inputWrapperActiveStyle} + rightComponent={children} + routerListRef={listRef} /> + + + ); diff --git a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts index ae79e02df17c..b58a3c0118d2 100644 --- a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts +++ b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts @@ -9,15 +9,20 @@ import type {SubstitutionMap} from './getQueryWithSubstitutions'; const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`; /** - * Given a plaintext query and data - * this function will build from scratch + * Given a plaintext query and specific entities data, + * this function will build the substitutions map from scratch for this query * * Ex: - * query: `Test from:John1` - * substitutions: { - * from:SomeOtherJohn: 12345 + * query: `Test from:12345 to:9876` + * personalDetails: { + * 12345: JohnDoe + * 98765: SomeoneElse + * } + * + * return: { + * from:JohnDoe: 12345, + * to:SomeoneElse: 98765, * } - * return: {} */ function buildSubstitutionsMap( query: string, From d724961aa671986fdb35e08fa8f202fdb08416c5 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Fri, 22 Nov 2024 12:40:21 +0100 Subject: [PATCH 6/9] Improve UI and styling for search results autocomplete input --- .../Search/SearchPageHeaderInput.tsx | 24 ++++++++++++------- src/libs/SearchQueryUtils.ts | 7 +++++- src/styles/index.ts | 1 - 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/components/Search/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeaderInput.tsx index eb2c7aa2601d..4982f11dca07 100644 --- a/src/components/Search/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeaderInput.tsx @@ -35,6 +35,9 @@ import SearchRouterInput from './SearchRouter/SearchRouterInput'; import SearchRouterList from './SearchRouter/SearchRouterList'; import type {SearchQueryJSON, SearchQueryString} from './types'; +// When counting absolute positioning, we need to account for borders +const BORDER_WIDTH = 1; + type SearchPageHeaderInputProps = { queryJSON: SearchQueryJSON; children: React.ReactNode; @@ -69,7 +72,7 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); // The actual input text that the user sees - const [textInputValue, setTextInputValue] = useState(''); + 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); @@ -214,18 +217,21 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps } : undefined; - const shouldShowAutocompleteList = isAutocompleteListVisible && !!textInputValue; + const isHeaderInputActive = isAutocompleteListVisible && !!textInputValue; - const topPosition = (variables.contentHeaderDesktopHeight - variables.componentSizeLarge) / 2; - const activeStyles = shouldShowAutocompleteList ? [styles.appBG, styles.border, styles.pAbsolute, {top: topPosition, left: 20, right: 20}] : []; - const inputWrapperActiveStyle = shouldShowAutocompleteList ? styles.ph2 : null; + // 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.pAbsolute, styles.pt2, {top: 8 - BORDER_WIDTH, left: popoverHorizontalPosition, right: popoverHorizontalPosition}, {boxShadow: variables.popoverMenuShadow}] + : [styles.pt4]; + const inputWrapperStyle = isHeaderInputActive ? styles.ph2 : null; return ( - + - + searchResultsHeaderBar: { display: 'flex', - justifyContent: 'center', height: variables.contentHeaderDesktopHeight, zIndex: variables.popoverzIndex, position: 'relative', From 9d8c40e003518762d5e950804b67c772c46b0454 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Fri, 22 Nov 2024 14:23:09 +0100 Subject: [PATCH 7/9] Fix test after removing cards param --- .../Search/SearchPageHeaderInput.tsx | 7 ++-- .../Search/SearchRouter/SearchRouter.tsx | 3 +- .../SearchRouter/buildSubstitutionsMap.ts | 3 +- src/libs/SearchQueryUtils.ts | 13 ++----- src/pages/Search/SearchTypeMenu.tsx | 5 +-- .../unit/Search/buildSubstitutionsMapTest.ts | 37 +++++++------------ 6 files changed, 23 insertions(+), 45 deletions(-) diff --git a/src/components/Search/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeaderInput.tsx index 4982f11dca07..485b071afa4f 100644 --- a/src/components/Search/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeaderInput.tsx @@ -68,7 +68,6 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps const personalDetails = usePersonalDetails(); const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); const taxRates = useMemo(() => getAllTaxRates(), []); - const [cardList = CONST.EMPTY_OBJECT] = useOnyx(ONYXKEYS.CARD_LIST); const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); // The actual input text that the user sees @@ -82,16 +81,16 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps const {type, inputQuery: originalInputQuery} = queryJSON; const isCannedQuery = SearchQueryUtils.isCannedSearchQuery(queryJSON); const headerText = isCannedQuery ? translate(getHeaderContent(type).titleText) : ''; - const queryText = SearchQueryUtils.buildUserReadableQueryString(queryJSON, personalDetails, cardList, reports, taxRates); + const queryText = SearchQueryUtils.buildUserReadableQueryString(queryJSON, personalDetails, reports, taxRates); useEffect(() => { setTextInputValue(queryText); }, [queryText]); useEffect(() => { - const substitutionsMap = buildSubstitutionsMap(originalInputQuery, personalDetails, cardList, reports, taxRates); + const substitutionsMap = buildSubstitutionsMap(originalInputQuery, personalDetails, reports, taxRates); setAutocompleteSubstitutions(substitutionsMap); - }, [originalInputQuery, cardList, personalDetails, reports, taxRates]); + }, [originalInputQuery, personalDetails, reports, taxRates]); const onSearchQueryChange = useCallback( (userQuery: string) => { diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 9de6b1c2998d..6edda1bd4494 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -81,7 +81,6 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) const personalDetails = usePersonalDetails(); const [reports = {}] = useOnyx(ONYXKEYS.COLLECTION.REPORT); - const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); const taxRates = getAllTaxRates(); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -187,7 +186,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps) const recentSearchesData = sortedRecentSearches?.slice(0, 5).map(({query, timestamp}) => { const searchQueryJSON = SearchQueryUtils.buildSearchQueryJSON(query); return { - text: searchQueryJSON ? SearchQueryUtils.buildUserReadableQueryString(searchQueryJSON, personalDetails, cardList, reports, taxRates) : query, + text: searchQueryJSON ? SearchQueryUtils.buildUserReadableQueryString(searchQueryJSON, personalDetails, reports, taxRates) : query, singleIcon: Expensicons.History, searchQuery: query, keyForList: timestamp, diff --git a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts index b58a3c0118d2..a7185f126e55 100644 --- a/src/components/Search/SearchRouter/buildSubstitutionsMap.ts +++ b/src/components/Search/SearchRouter/buildSubstitutionsMap.ts @@ -27,7 +27,6 @@ const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${fi function buildSubstitutionsMap( query: string, personalDetails: OnyxTypes.PersonalDetailsList, - cardList: OnyxTypes.CardList, reports: OnyxCollection, allTaxRates: Record, ): SubstitutionMap { @@ -62,7 +61,7 @@ function buildSubstitutionsMap( filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.IN || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID ) { - const displayValue = SearchQueryUtils.getFilterDisplayValue(filterKey, filterValue, personalDetails, cardList, reports); + 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) { diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index c45e412fe1be..b036eb5de6ab 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -488,20 +488,14 @@ function getPolicyIDFromSearchQuery(queryJSON: SearchQueryJSON) { /** * Returns the human-readable "pretty" string for a specified filter value. */ -function getFilterDisplayValue( - filterName: string, - filterValue: 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?.[filterValue]?.displayName || filterValue; } if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) { - const cardID = parseInt(filterName, 10); + const cardID = parseInt(filterValue, 10); if (Number.isNaN(cardID)) { return filterValue; } @@ -526,7 +520,6 @@ function getFilterDisplayValue( function buildUserReadableQueryString( queryJSON: SearchQueryJSON, PersonalDetails: OnyxTypes.PersonalDetailsList, - cardList: OnyxTypes.CardList, reports: OnyxCollection, taxRates: Record, ) { @@ -560,7 +553,7 @@ function buildUserReadableQueryString( } else { displayQueryFilters = queryFilter.map((filter) => ({ operator: filter.operator, - value: getFilterDisplayValue(key, filter.value.toString(), PersonalDetails, cardList, reports), + value: getFilterDisplayValue(key, filter.value.toString(), PersonalDetails, reports), })); } title += buildFilterValuesString(key, displayQueryFilters); 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 ( { + return { + getCardDescription(cardID: number) { + return cardID; + }, + }; +}); + jest.mock('@libs/ReportUtils', () => { return { parseReportRouteParams: jest.fn(() => ({})), @@ -30,25 +38,6 @@ const personalDetailsMock = { }, } as OnyxTypes.PersonalDetailsList; -const cardListMock = { - card1: { - cardID: 1111, - state: 3, - bank: 'Best Bank', - domainName: '', - lastUpdated: '', - fraud: 'none', - }, - card2: { - cardID: 2222, - state: 3, - bank: 'Great Bank', - domainName: '', - lastUpdated: '', - fraud: 'none', - }, -} as OnyxTypes.CardList; - const reportsMock = { [`${ONYXKEYS.COLLECTION.REPORT}rep123`]: { reportID: 'rep123', @@ -68,14 +57,14 @@ describe('buildSubstitutionsMap should return correct substitutions map', () => test('when there were no substitutions', () => { const userQuery = 'foo bar'; - const result = buildSubstitutionsMap(userQuery, personalDetailsMock, cardListMock, reportsMock, taxRatesMock); + 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, cardListMock, reportsMock, taxRatesMock); + const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock); expect(result).toStrictEqual({ 'from:John Doe': '12345', @@ -83,15 +72,15 @@ describe('buildSubstitutionsMap should return correct substitutions map', () => }); test('when query has multiple substitutions of different types', () => { - const userQuery = 'from:78901,12345 to:nonExistingGuy@mail.com cardID:card1 in:rep123 taxRate:id_TAX_1'; + const userQuery = 'from:78901,12345 to:nonExistingGuy@mail.com cardID:11223344 in:rep123 taxRate:id_TAX_1'; - const result = buildSubstitutionsMap(userQuery, personalDetailsMock, cardListMock, reportsMock, taxRatesMock); + const result = buildSubstitutionsMap(userQuery, personalDetailsMock, reportsMock, taxRatesMock); expect(result).toStrictEqual({ 'from:Jane Doe': '78901', 'from:John Doe': '12345', 'in:Report 1': 'rep123', - 'cardID:Best Bank': 'card1', + 'cardID:11223344': '11223344', 'taxRate:TAX_1': 'id_TAX_1', }); }); From cad04e08e3cb3b243965ad7d54cd69e7b166a3d6 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Fri, 22 Nov 2024 15:11:38 +0100 Subject: [PATCH 8/9] Small UI tweaks --- src/components/Search/SearchPageHeaderInput.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/Search/SearchPageHeaderInput.tsx b/src/components/Search/SearchPageHeaderInput.tsx index 485b071afa4f..0c10cadacda5 100644 --- a/src/components/Search/SearchPageHeaderInput.tsx +++ b/src/components/Search/SearchPageHeaderInput.tsx @@ -216,12 +216,19 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps } : undefined; - const isHeaderInputActive = isAutocompleteListVisible && !!textInputValue; + 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.pAbsolute, styles.pt2, {top: 8 - BORDER_WIDTH, left: popoverHorizontalPosition, right: popoverHorizontalPosition}, {boxShadow: variables.popoverMenuShadow}] + ? [ + 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; From 30f4067aa83f1333ac45ab858fa0b224792c027e Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Mon, 25 Nov 2024 09:57:01 +0100 Subject: [PATCH 9/9] Fix SearchRouterList perf test --- src/components/Search/SearchRouter/SearchRouterList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 135eb83390d6..f626a3b34daf 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -155,7 +155,7 @@ function SearchRouterList( accountID: details.accountID.toString(), }; }); - const currentUser = (filteredOptions.currentUserOption as SearchOption).item; + const currentUser = filteredOptions.currentUserOption ? (filteredOptions.currentUserOption as SearchOption).item : undefined; if (currentUser) { autocompleteOptions.push({ name: currentUser.displayName ?? Str.removeSMSDomain(currentUser.login ?? ''),