From 433ec49e2abdaa7c3180c4fff81ac642c038bbc9 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Mon, 14 Oct 2024 11:12:58 +0200 Subject: [PATCH 01/21] autocomplete POC --- .eslintignore | 1 + package.json | 1 + src/CONST.ts | 5 + .../Search/SearchRouter/SearchRouter.tsx | 199 +++- .../Search/SearchRouter/SearchRouterList.tsx | 65 +- src/components/Search/types.ts | 10 + .../Search/SearchQueryListItem.tsx | 3 + .../AutocompleteParser/autocompleteParser.js | 995 ++++++++++++++++++ .../autocompleteParser.peggy | 104 ++ src/libs/Permissions.ts | 2 +- src/libs/SearchAutocompleteUtils.ts | 14 + 11 files changed, 1379 insertions(+), 20 deletions(-) create mode 100644 src/libs/AutocompleteParser/autocompleteParser.js create mode 100644 src/libs/AutocompleteParser/autocompleteParser.peggy create mode 100644 src/libs/SearchAutocompleteUtils.ts diff --git a/.eslintignore b/.eslintignore index 162cc816ea80..49d445ef74a2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -12,4 +12,5 @@ docs/assets/** web/gtm.js **/.expo/** src/libs/SearchParser/searchParser.js +src/libs/AutocompleteParser/autocompleteParser.js help/_scripts/** diff --git a/package.json b/package.json index e691ae9075ed..de51f75dc645 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "react-compiler-healthcheck": "react-compiler-healthcheck --verbose", "react-compiler-healthcheck-test": "react-compiler-healthcheck --verbose &> react-compiler-output.txt", "generate-search-parser": "peggy --format es -o src/libs/SearchParser/searchParser.js src/libs/SearchParser/searchParser.peggy ", + "generate-autocomplete-parser": "peggy --format es -o src/libs/AutocompleteParser/autocompleteParser.js src/libs/AutocompleteParser/autocompleteParser.peggy ", "web:prod": "http-server ./dist --cors" }, "dependencies": { diff --git a/src/CONST.ts b/src/CONST.ts index 7131fab28bdb..9cfe20e11d8e 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5609,6 +5609,11 @@ const CONST = { KEYWORD: 'keyword', IN: 'in', }, + SEARCH_ROUTER_ITEM_TYPE: { + CONTEXTUAL_SUGGESTION: 'contextualSuggestion', + AUTOCOMPLETE_SUGGESTION: 'autocompleteSuggestion', + SEARCH: 'seearchItem', + }, }, REFERRER: { diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 8f5ad55bc0c9..d610fccb950f 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -3,18 +3,25 @@ import debounce from 'lodash/debounce'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import {usePersonalDetails} from '@components/OnyxProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; import type {SearchQueryJSON} from '@components/Search/types'; import type {SelectionListHandle} from '@components/SelectionList/types'; +import useActiveWorkspaceFromNavigationState from '@hooks/useActiveWorkspaceFromNavigationState'; import useDebouncedState from '@hooks/useDebouncedState'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import Log from '@libs/Log'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import {getAllTaxRates, getTagNamesFromTagsLists} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; +import {parseForAutocomplete} from '@libs/SearchAutocompleteUtils'; import * as SearchUtils from '@libs/SearchUtils'; import Navigation from '@navigation/Navigation'; import variables from '@styles/variables'; @@ -22,18 +29,74 @@ import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Policy, PolicyCategories, PolicyTagLists} from '@src/types/onyx'; import {useSearchRouterContext} from './SearchRouterContext'; import SearchRouterInput from './SearchRouterInput'; import SearchRouterList from './SearchRouterList'; +import type {ItemWithQuery} from './SearchRouterList'; const SEARCH_DEBOUNCE_DELAY = 150; +function getAutoCompleteTagsList(allPoliciesTagsLists: OnyxCollection, policyID?: string) { + const singlePolicyTagsList: PolicyTagLists | undefined = allPoliciesTagsLists?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]; + if (!singlePolicyTagsList) { + const uniqueTagNames = new Set(); + const tagListsUnpacked = Object.values(allPoliciesTagsLists ?? {}).filter((item) => !!item) as PolicyTagLists[]; + tagListsUnpacked + .map((policyTagLists) => { + return getTagNamesFromTagsLists(policyTagLists); + }) + .flat() + .forEach((tag) => uniqueTagNames.add(tag)); + return Array.from(uniqueTagNames); + } + return getTagNamesFromTagsLists(singlePolicyTagsList); +} + +function getAutocompleteStatusesList(type?: ValueOf) { + switch (type) { + case CONST.SEARCH.DATA_TYPES.INVOICE: { + return Object.values(CONST.SEARCH.STATUS.INVOICE); + } + case CONST.SEARCH.DATA_TYPES.CHAT: { + return Object.values(CONST.SEARCH.STATUS.CHAT); + } + case CONST.SEARCH.DATA_TYPES.EXPENSE: { + return Object.values(CONST.SEARCH.STATUS.EXPENSE); + } + case CONST.SEARCH.DATA_TYPES.TRIP: { + return Object.values(CONST.SEARCH.STATUS.TRIP); + } + default: + return Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP}); + } +} + +function getAutocompleteCategoriesList(allPolicyCategories: OnyxCollection, policyID?: string) { + const singlePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; + const categoryList = singlePolicyCategories + ? Object.values(singlePolicyCategories) + : Object.values(allPolicyCategories ?? {}) + .map((policyCategories) => Object.values(policyCategories ?? {})) + .flat(); + const filteredCategoryList = categoryList.filter((category) => !!category); + return filteredCategoryList.map((category) => category.name); +} + +function getAutocompleteTaxList(allTaxRates: Record, policy?: OnyxEntry) { + if (policy) { + return Object.keys(policy?.taxRates?.taxes ?? {}).map((taxRateName) => taxRateName); + } + return Object.keys(allTaxRates).map((taxRateName) => taxRateName); +} + function SearchRouter() { const styles = useThemeStyles(); const {translate} = useLocalize(); 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 {isSmallScreenWidth} = useResponsiveLayout(); const {isSearchRouterDisplayed, closeSearchRouter} = useSearchRouterContext(); @@ -44,6 +107,28 @@ function SearchRouter() { const contextualReportID = useNavigationState, string | undefined>((state) => { return state?.routes.at(-1)?.params?.reportID; }); + + const activeWorkspaceID = useActiveWorkspaceFromNavigationState(); + const policy = usePolicy(activeWorkspaceID); + const typesAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES); + const statusesAutocompleteList = useMemo(() => getAutocompleteStatusesList(userSearchQuery?.type), [userSearchQuery?.type]); + const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE); + const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); + const categoryAutocompleteList = useMemo(() => getAutocompleteCategoriesList(allPolicyCategories, activeWorkspaceID), [allPolicyCategories, activeWorkspaceID]); + const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST); + const currencyAutocompleteList = Object.keys(currencyList ?? {}); + const [allPoliciesTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); + const tagAutocompleteList = useMemo(() => getAutoCompleteTagsList(allPoliciesTagsLists, activeWorkspaceID), [allPoliciesTagsLists, activeWorkspaceID]); + const allTaxRates = getAllTaxRates(); + const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(allTaxRates, policy), [policy, allTaxRates]); + const [cardList] = useOnyx(ONYXKEYS.CARD_LIST); + const cardsAutocompleteList = Object.values(cardList ?? {}).map((card) => card.bank); + const personalDetails = usePersonalDetails(); + const participantsAutocompleteList = Object.values(personalDetails) + .filter((details) => details && details?.login) + // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style + .map((details) => details?.login as string); + const sortedRecentSearches = useMemo(() => { return Object.values(recentSearches ?? {}).sort((a, b) => b.timestamp.localeCompare(a.timestamp)); }, [recentSearches]); @@ -103,6 +188,104 @@ function SearchRouter() { setUserSearchQuery(undefined); }; + const updateAutocomplete = useCallback( + (autocompleteValue: string, autocompleteType?: ValueOf) => { + switch (autocompleteType) { + case 'in': { + return; + } + case 'tag': { + const filteredTags = tagAutocompleteList.filter((tag) => tag?.includes(autocompleteValue)); + setAutocompleteSuggestions( + filteredTags.map((tagName) => ({ + text: `tag:${tagName}`, + query: `${tagName}`, + })), + ); + return; + } + case 'category': { + const filteredCategories = categoryAutocompleteList.filter((category) => category?.includes(autocompleteValue)); + setAutocompleteSuggestions( + filteredCategories.map((categoryName) => ({ + text: `currency:${categoryName}`, + query: `${categoryName}`, + })), + ); + return; + } + case 'currency': { + const filteredCurrencies = currencyAutocompleteList.filter((currency) => currency?.includes(autocompleteValue)); + setAutocompleteSuggestions( + filteredCurrencies.map((currencyName) => ({ + text: `currency:${currencyName}`, + query: `${currencyName}`, + })), + ); + return; + } + case 'taxRate': { + const filteredTaxRates = taxAutocompleteList.filter((tax) => tax.includes(autocompleteValue)); + setAutocompleteSuggestions(filteredTaxRates.map((tax) => ({text: `type:${tax}`, query: `${tax}`}))); + return; + } + case 'from': { + const filteredParticipants = participantsAutocompleteList.filter((participant) => participant.includes(autocompleteValue)); + setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `from:${participant}`, query: `${participant}`}))); + return; + } + case 'to': { + const filteredParticipants = participantsAutocompleteList.filter((participant) => participant.includes(autocompleteValue)); + setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `to:${participant}`, query: `${participant}`}))); + return; + } + case 'type': { + const filteredTypes = typesAutocompleteList.filter((type) => type.includes(autocompleteValue)); + setAutocompleteSuggestions(filteredTypes.map((type) => ({text: `type:${type}`, query: `${type}`}))); + return; + } + case 'status': { + const filteredStatuses = statusesAutocompleteList.filter((status) => status.includes(autocompleteValue)); + setAutocompleteSuggestions(filteredStatuses.map((status) => ({text: `status:${status}`, query: `${status}`}))); + return; + } + case 'expenseType': { + const filteredExpenseTypes = expenseTypes.filter((expenseType) => expenseType.includes(autocompleteValue)); + setAutocompleteSuggestions( + filteredExpenseTypes.map((expenseType) => ({ + text: `expenseType:${expenseType}`, + query: `${expenseType}`, + })), + ); + return; + } + case 'cardID': { + const filteredCards = cardsAutocompleteList.filter((card) => card.includes(autocompleteValue)); + setAutocompleteSuggestions( + filteredCards.map((card) => ({ + text: `expenseType:${card}`, + query: `${card}`, + })), + ); + return; + } + default: + setAutocompleteSuggestions(undefined); + } + }, + [ + tagAutocompleteList, + categoryAutocompleteList, + currencyAutocompleteList, + taxAutocompleteList, + participantsAutocompleteList, + typesAutocompleteList, + statusesAutocompleteList, + expenseTypes, + cardsAutocompleteList, + ], + ); + const onSearchChange = useMemo( // eslint-disable-next-line react-compiler/react-compiler () => @@ -112,6 +295,9 @@ function SearchRouter() { listRef.current?.updateAndScrollToFocusedIndex(-1); return; } + const autocompleteParsedQuery = parseForAutocomplete(userQuery); + updateAutocomplete(autocompleteParsedQuery?.autocomplete?.value ?? '', autocompleteParsedQuery?.autocomplete?.key); + listRef.current?.updateAndScrollToFocusedIndex(0); const queryJSON = SearchUtils.buildSearchQueryJSON(userQuery); @@ -123,12 +309,12 @@ function SearchRouter() { }, SEARCH_DEBOUNCE_DELAY), // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps - [], + [updateAutocomplete], ); - const updateUserSearchQuery = (newSearchQuery: string) => { - setTextInputValue(newSearchQuery); - onSearchChange(newSearchQuery); + const updateSearchInputValue = (newValue: string) => { + setTextInputValue(newValue); + onSearchChange(newValue); }; const closeAndClearRouter = useCallback(() => { @@ -179,12 +365,13 @@ function SearchRouter() { isSearchingForReports={isSearchingForReports} /> diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 7d86ce1150d5..276abb0d88ad 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -17,16 +17,18 @@ import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; import * as SearchUtils from '@libs/SearchUtils'; import * as Report from '@userActions/Report'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; type ItemWithQuery = { query: string; + text?: string; }; type SearchRouterListProps = { - /** currentQuery value computed coming from parsed TextInput value */ - currentQuery: SearchQueryJSON | undefined; + /** Value of TextInput */ + textInputValue: string; /** Recent searches */ recentSearches: ItemWithQuery[] | undefined; @@ -34,6 +36,9 @@ type SearchRouterListProps = { /** Recent reports */ recentReports: OptionData[]; + /** Autocomplete items */ + autocompleteItems: ItemWithQuery[] | undefined; + /** Callback to submit query when selecting a list item */ onSearchSubmit: (query: SearchQueryJSON | undefined) => void; @@ -41,7 +46,7 @@ type SearchRouterListProps = { reportForContextualSearch?: OptionData; /** Callback to update search query when selecting contextual suggestion */ - updateUserSearchQuery: (newSearchQuery: string) => void; + updateSearchInputValue: (newSearchQuery: string) => void; /** Callback to close and clear SearchRouter */ closeAndClearRouter: () => void; @@ -80,7 +85,16 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList } function SearchRouterList( - {currentQuery, reportForContextualSearch, recentSearches, recentReports, onSearchSubmit, updateUserSearchQuery, closeAndClearRouter}: SearchRouterListProps, + { + textInputValue, + reportForContextualSearch, + recentSearches, + recentReports, + autocompleteItems, + onSearchSubmit, + updateSearchInputValue: updateUserSearchQuery, + closeAndClearRouter, + }: SearchRouterListProps, ref: ForwardedRef, ) { const styles = useThemeStyles(); @@ -94,21 +108,22 @@ function SearchRouterList( const contextualQuery = `in:${reportForContextualSearch?.reportID}`; const sections: Array> = []; - if (currentQuery?.inputQuery) { + if (textInputValue) { sections.push({ data: [ { - text: currentQuery?.inputQuery, + text: textInputValue, singleIcon: Expensicons.MagnifyingGlass, - query: currentQuery?.inputQuery, + query: textInputValue, itemStyle: styles.activeComponentBG, keyForList: 'findItem', + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, }, ], }); } - if (reportForContextualSearch && !currentQuery?.inputQuery?.includes(contextualQuery)) { + if (reportForContextualSearch && !textInputValue.includes(contextualQuery)) { sections.push({ data: [ { @@ -117,12 +132,26 @@ function SearchRouterList( query: SearchUtils.getContextualSuggestionQuery(reportForContextualSearch.reportID), itemStyle: styles.activeComponentBG, keyForList: 'contextualSearch', - isContextualSearchItem: true, + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION, }, ], }); } + const autocompleteData = autocompleteItems?.map(({text, query}) => { + return { + text, + singleIcon: Expensicons.MagnifyingGlass, + query, + keyForList: query, + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION, + }; + }); + + if (autocompleteData && autocompleteData.length > 0) { + sections.push({title: 'Autocomplete', data: autocompleteData}); + } + const recentSearchesData = recentSearches?.map(({query}) => { const searchQueryJSON = SearchUtils.buildSearchQueryJSON(query); return { @@ -130,10 +159,11 @@ function SearchRouterList( singleIcon: Expensicons.History, query, keyForList: query, + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, }; }); - if (!currentQuery?.inputQuery && recentSearchesData && recentSearchesData.length > 0) { + if (!textInputValue && recentSearchesData && recentSearchesData.length > 0) { sections.push({title: translate('search.recentSearches'), data: recentSearchesData}); } @@ -143,9 +173,18 @@ function SearchRouterList( const onSelectRow = useCallback( (item: OptionData | SearchQueryItem) => { if (isSearchQueryItem(item)) { - if (item.isContextualSearchItem) { + if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { // Handle selection of "Contextual search suggestion" - updateUserSearchQuery(`${item?.query} ${currentQuery?.inputQuery ?? ''}`); + updateUserSearchQuery(`${item?.query} ${textInputValue ?? ''}`); + return; + } + + if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION) { + // Handle selection of "Autocomplete suggestion" + const lastColonIndex = textInputValue.lastIndexOf(':'); + const lastComaIndex = textInputValue.lastIndexOf(','); + const trimmedTextInputValue = lastColonIndex > lastComaIndex ? textInputValue.slice(0, lastColonIndex + 1) : textInputValue.slice(0, lastComaIndex + 1); + updateUserSearchQuery(`${trimmedTextInputValue}${item?.query}`); return; } @@ -164,7 +203,7 @@ function SearchRouterList( Report.navigateToAndOpenReport(item?.login ? [item.login] : []); } }, - [closeAndClearRouter, onSearchSubmit, currentQuery, updateUserSearchQuery], + [closeAndClearRouter, onSearchSubmit, textInputValue, updateUserSearchQuery], ); return ( diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 3d35190bf1a4..152607af0ede 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -77,6 +77,15 @@ type SearchQueryJSON = { flatFilters: QueryFilters; } & SearchQueryAST; +type SearchAutocompleteResult = { + autocomplete: { + key: ValueOf; + length: number; + start: number; + value: string; + }; +}; + export type { SelectedTransactionInfo, SelectedTransactions, @@ -95,4 +104,5 @@ export type { InvoiceSearchStatus, TripSearchStatus, ChatSearchStatus, + SearchAutocompleteResult, }; diff --git a/src/components/SelectionList/Search/SearchQueryListItem.tsx b/src/components/SelectionList/Search/SearchQueryListItem.tsx index 369f527cdeba..cf1b75d95f17 100644 --- a/src/components/SelectionList/Search/SearchQueryListItem.tsx +++ b/src/components/SelectionList/Search/SearchQueryListItem.tsx @@ -1,17 +1,20 @@ import React from 'react'; import {View} from 'react-native'; +import type {ValueOf} from 'type-fest'; import Icon from '@components/Icon'; import BaseListItem from '@components/SelectionList/BaseListItem'; import type {ListItem} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import type CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; type SearchQueryItem = ListItem & { singleIcon?: IconAsset; query?: string; isContextualSearchItem?: boolean; + searchItemType: ValueOf; }; type SearchQueryListItemProps = { diff --git a/src/libs/AutocompleteParser/autocompleteParser.js b/src/libs/AutocompleteParser/autocompleteParser.js new file mode 100644 index 000000000000..be65d1699c59 --- /dev/null +++ b/src/libs/AutocompleteParser/autocompleteParser.js @@ -0,0 +1,995 @@ +// @generated by Peggy 4.0.3. +// +// https://peggyjs.org/ + + +function peg$subclass(child, parent) { + function C() { this.constructor = child; } + C.prototype = parent.prototype; + child.prototype = new C(); +} + +function peg$SyntaxError(message, expected, found, location) { + var self = Error.call(this, message); + // istanbul ignore next Check is a necessary evil to support older environments + if (Object.setPrototypeOf) { + Object.setPrototypeOf(self, peg$SyntaxError.prototype); + } + self.expected = expected; + self.found = found; + self.location = location; + self.name = "SyntaxError"; + return self; +} + +peg$subclass(peg$SyntaxError, Error); + +function peg$padEnd(str, targetLength, padString) { + padString = padString || " "; + if (str.length > targetLength) { return str; } + targetLength -= str.length; + padString += padString.repeat(targetLength); + return str + padString.slice(0, targetLength); +} + +peg$SyntaxError.prototype.format = function(sources) { + var str = "Error: " + this.message; + if (this.location) { + var src = null; + var k; + for (k = 0; k < sources.length; k++) { + if (sources[k].source === this.location.source) { + src = sources[k].text.split(/\r\n|\n|\r/g); + break; + } + } + var s = this.location.start; + var offset_s = (this.location.source && (typeof this.location.source.offset === "function")) + ? this.location.source.offset(s) + : s; + var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; + if (src) { + var e = this.location.end; + var filler = peg$padEnd("", offset_s.line.toString().length, ' '); + var line = src[s.line - 1]; + var last = s.line === e.line ? e.column : line.length + 1; + var hatLen = (last - s.column) || 1; + str += "\n --> " + loc + "\n" + + filler + " |\n" + + offset_s.line + " | " + line + "\n" + + filler + " | " + peg$padEnd("", s.column - 1, ' ') + + peg$padEnd("", hatLen, "^"); + } else { + str += "\n at " + loc; + } + } + return str; +}; + +peg$SyntaxError.buildMessage = function(expected, found) { + var DESCRIBE_EXPECTATION_FNS = { + literal: function(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, + + class: function(expectation) { + var escapedParts = expectation.parts.map(function(part) { + return Array.isArray(part) + ? classEscape(part[0]) + "-" + classEscape(part[1]) + : classEscape(part); + }); + + return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]"; + }, + + any: function() { + return "any character"; + }, + + end: function() { + return "end of input"; + }, + + other: function(expectation) { + return expectation.description; + } + }; + + function hex(ch) { + return ch.charCodeAt(0).toString(16).toUpperCase(); + } + + function literalEscape(s) { + return s + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); + } + + function classEscape(s) { + return s + .replace(/\\/g, "\\\\") + .replace(/\]/g, "\\]") + .replace(/\^/g, "\\^") + .replace(/-/g, "\\-") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); + } + + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); + } + + function describeExpected(expected) { + var descriptions = expected.map(describeExpectation); + var i, j; + + descriptions.sort(); + + if (descriptions.length > 0) { + for (i = 1, j = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } + } + descriptions.length = j; + } + + switch (descriptions.length) { + case 1: + return descriptions[0]; + + case 2: + return descriptions[0] + " or " + descriptions[1]; + + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; + } + } + + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + } + + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; +}; + +function peg$parse(input, options) { + options = options !== undefined ? options : {}; + + var peg$FAILED = {}; + var peg$source = options.grammarSource; + + var peg$startRuleFunctions = { query: peg$parsequery }; + var peg$startRuleFunction = peg$parsequery; + + var peg$c0 = "!="; + var peg$c1 = ">="; + var peg$c2 = ">"; + var peg$c3 = "<="; + var peg$c4 = "<"; + var peg$c5 = "in"; + var peg$c6 = "currency"; + var peg$c7 = "tag"; + var peg$c8 = "category"; + var peg$c9 = "to"; + var peg$c10 = "taxRate"; + var peg$c11 = "from"; + var peg$c12 = "expenseType"; + var peg$c13 = "type"; + var peg$c14 = "status"; + var peg$c15 = "\""; + + var peg$r0 = /^[:=]/; + var peg$r1 = /^[^"\r\n]/; + var peg$r2 = /^[A-Za-z0-9_@.\/#&+\-\\',;:%]/; + var peg$r3 = /^[ \t\r\n]/; + + var peg$e0 = peg$otherExpectation("operator"); + var peg$e1 = peg$classExpectation([":", "="], false, false); + var peg$e2 = peg$literalExpectation("!=", false); + var peg$e3 = peg$literalExpectation(">=", false); + var peg$e4 = peg$literalExpectation(">", false); + var peg$e5 = peg$literalExpectation("<=", false); + var peg$e6 = peg$literalExpectation("<", false); + var peg$e7 = peg$otherExpectation("key"); + var peg$e8 = peg$literalExpectation("in", false); + var peg$e9 = peg$literalExpectation("currency", false); + var peg$e10 = peg$literalExpectation("tag", false); + var peg$e11 = peg$literalExpectation("category", false); + var peg$e12 = peg$literalExpectation("to", false); + var peg$e13 = peg$literalExpectation("taxRate", false); + var peg$e14 = peg$literalExpectation("from", false); + var peg$e15 = peg$literalExpectation("expenseType", false); + var peg$e16 = peg$literalExpectation("type", false); + var peg$e17 = peg$literalExpectation("status", false); + var peg$e18 = peg$otherExpectation("quote"); + var peg$e19 = peg$literalExpectation("\"", false); + var peg$e20 = peg$classExpectation(["\"", "\r", "\n"], true, false); + var peg$e21 = peg$otherExpectation("word"); + var peg$e22 = peg$classExpectation([["A", "Z"], ["a", "z"], ["0", "9"], "_", "@", ".", "/", "#", "&", "+", "-", "\\", "'", ",", ";", ":", "%"], false, false); + var peg$e23 = peg$otherExpectation("whitespace"); + var peg$e24 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); + + var peg$f0 = function(filters) { return applyAutocomplete(filters); }; + var peg$f1 = function(head, tail) { + const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter( + Boolean + ); + return allFilters.flat(); + }; + var peg$f2 = function(key, op, value) { + if (!value) { + updateAutocomplete({ + key, + value: null, + start: location().end.offset, + length: 0, + }); + return; + } else { + updateAutocomplete({ + key, + ...value[value.length - 1], + }); + } + + return value.map(({ start, length }) => ({ + key, + start, + length, + })); + }; + var peg$f3 = function(value) { updateAutocomplete(null); }; + var peg$f4 = function() { return "eq"; }; + var peg$f5 = function() { return "neq"; }; + var peg$f6 = function() { return "gte"; }; + var peg$f7 = function() { return "gt"; }; + var peg$f8 = function() { return "lte"; }; + var peg$f9 = function() { return "lt"; }; + var peg$f10 = function(parts) { + const ends = location(); + const value = parts.flat(); + let count = ends.start.offset; + const result = []; + value.forEach((filter) => { + result.push({ + value: filter, + start: count, + length: filter.length, + }); + count += filter.length + 1; + }); + return result; + }; + var peg$f11 = function(chars) { return chars.join(""); }; + var peg$f12 = function(chars) { + return chars.join("").trim().split(","); + }; + var peg$f13 = function() { return "and"; }; + var peg$currPos = options.peg$currPos | 0; + var peg$savedPos = peg$currPos; + var peg$posDetailsCache = [{ line: 1, column: 1 }]; + var peg$maxFailPos = peg$currPos; + var peg$maxFailExpected = options.peg$maxFailExpected || []; + var peg$silentFails = options.peg$silentFails | 0; + + var peg$result; + + if (options.startRule) { + if (!(options.startRule in peg$startRuleFunctions)) { + throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); + } + + peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; + } + + function text() { + return input.substring(peg$savedPos, peg$currPos); + } + + function offset() { + return peg$savedPos; + } + + function range() { + return { + source: peg$source, + start: peg$savedPos, + end: peg$currPos + }; + } + + function location() { + return peg$computeLocation(peg$savedPos, peg$currPos); + } + + function expected(description, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildStructuredError( + [peg$otherExpectation(description)], + input.substring(peg$savedPos, peg$currPos), + location + ); + } + + function error(message, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildSimpleError(message, location); + } + + function peg$literalExpectation(text, ignoreCase) { + return { type: "literal", text: text, ignoreCase: ignoreCase }; + } + + function peg$classExpectation(parts, inverted, ignoreCase) { + return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; + } + + function peg$anyExpectation() { + return { type: "any" }; + } + + function peg$endExpectation() { + return { type: "end" }; + } + + function peg$otherExpectation(description) { + return { type: "other", description: description }; + } + + function peg$computePosDetails(pos) { + var details = peg$posDetailsCache[pos]; + var p; + + if (details) { + return details; + } else { + if (pos >= peg$posDetailsCache.length) { + p = peg$posDetailsCache.length - 1; + } else { + p = pos; + while (!peg$posDetailsCache[--p]) {} + } + + details = peg$posDetailsCache[p]; + details = { + line: details.line, + column: details.column + }; + + while (p < pos) { + if (input.charCodeAt(p) === 10) { + details.line++; + details.column = 1; + } else { + details.column++; + } + + p++; + } + + peg$posDetailsCache[pos] = details; + + return details; + } + } + + function peg$computeLocation(startPos, endPos, offset) { + var startPosDetails = peg$computePosDetails(startPos); + var endPosDetails = peg$computePosDetails(endPos); + + var res = { + source: peg$source, + start: { + offset: startPos, + line: startPosDetails.line, + column: startPosDetails.column + }, + end: { + offset: endPos, + line: endPosDetails.line, + column: endPosDetails.column + } + }; + if (offset && peg$source && (typeof peg$source.offset === "function")) { + res.start = peg$source.offset(res.start); + res.end = peg$source.offset(res.end); + } + return res; + } + + function peg$fail(expected) { + if (peg$currPos < peg$maxFailPos) { return; } + + if (peg$currPos > peg$maxFailPos) { + peg$maxFailPos = peg$currPos; + peg$maxFailExpected = []; + } + + peg$maxFailExpected.push(expected); + } + + function peg$buildSimpleError(message, location) { + return new peg$SyntaxError(message, null, null, location); + } + + function peg$buildStructuredError(expected, found, location) { + return new peg$SyntaxError( + peg$SyntaxError.buildMessage(expected, found), + expected, + found, + location + ); + } + + function peg$parsequery() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = peg$parsefilterList(); + if (s2 === peg$FAILED) { + s2 = null; + } + s3 = peg$parse_(); + peg$savedPos = s0; + s0 = peg$f0(s2); + + return s0; + } + + function peg$parsefilterList() { + var s0, s1, s2, s3, s4, s5; + + s0 = peg$currPos; + s1 = peg$parsefilter(); + if (s1 !== peg$FAILED) { + s2 = []; + s3 = peg$currPos; + s4 = peg$parselogicalAnd(); + s5 = peg$parsefilter(); + if (s5 !== peg$FAILED) { + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = peg$currPos; + s4 = peg$parselogicalAnd(); + s5 = peg$parsefilter(); + if (s5 !== peg$FAILED) { + s4 = [s4, s5]; + s3 = s4; + } else { + peg$currPos = s3; + s3 = peg$FAILED; + } + } + peg$savedPos = s0; + s0 = peg$f1(s1, s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsefilter() { + var s0, s1; + + s0 = peg$currPos; + s1 = peg$parsedefaultFilter(); + if (s1 === peg$FAILED) { + s1 = peg$parsefreeTextFilter(); + } + if (s1 !== peg$FAILED) { + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsedefaultFilter() { + var s0, s1, s2, s3, s4, s5, s6; + + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = peg$parsekey(); + if (s2 !== peg$FAILED) { + s3 = peg$parse_(); + s4 = peg$parseoperator(); + if (s4 !== peg$FAILED) { + s5 = peg$parse_(); + s6 = peg$parseidentifier(); + if (s6 === peg$FAILED) { + s6 = null; + } + peg$savedPos = s0; + s0 = peg$f2(s2, s4, s6); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parsefreeTextFilter() { + var s0, s1, s2, s3; + + s0 = peg$currPos; + s1 = peg$parse_(); + s2 = peg$parseidentifier(); + if (s2 !== peg$FAILED) { + s3 = peg$parse_(); + peg$savedPos = s0; + s0 = peg$f3(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + + return s0; + } + + function peg$parseoperator() { + var s0, s1; + + peg$silentFails++; + s0 = peg$currPos; + s1 = input.charAt(peg$currPos); + if (peg$r0.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e1); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f4(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c0) { + s1 = peg$c0; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e2); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f5(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c1) { + s1 = peg$c1; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e3); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f6(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 62) { + s1 = peg$c2; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e4); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f7(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c3) { + s1 = peg$c3; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e5); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f8(); + } + s0 = s1; + if (s0 === peg$FAILED) { + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 60) { + s1 = peg$c4; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e6); } + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f9(); + } + s0 = s1; + } + } + } + } + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + + return s0; + } + + function peg$parsekey() { + var s0, s1; + + peg$silentFails++; + s0 = peg$currPos; + if (input.substr(peg$currPos, 2) === peg$c5) { + s1 = peg$c5; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e8); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 8) === peg$c6) { + s1 = peg$c6; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e9); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 3) === peg$c7) { + s1 = peg$c7; + peg$currPos += 3; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e10); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 8) === peg$c8) { + s1 = peg$c8; + peg$currPos += 8; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e11); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 2) === peg$c9) { + s1 = peg$c9; + peg$currPos += 2; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e12); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 7) === peg$c10) { + s1 = peg$c10; + peg$currPos += 7; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e13); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 4) === peg$c11) { + s1 = peg$c11; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e14); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 11) === peg$c12) { + s1 = peg$c12; + peg$currPos += 11; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e15); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 4) === peg$c13) { + s1 = peg$c13; + peg$currPos += 4; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e16); } + } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 6) === peg$c14) { + s1 = peg$c14; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e17); } + } + } + } + } + } + } + } + } + } + } + if (s1 !== peg$FAILED) { + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e7); } + } + + return s0; + } + + function peg$parseidentifier() { + var s0, s1, s2; + + s0 = peg$currPos; + s1 = []; + s2 = peg$parsequotedString(); + if (s2 === peg$FAILED) { + s2 = peg$parsealphanumeric(); + } + if (s2 !== peg$FAILED) { + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = peg$parsequotedString(); + if (s2 === peg$FAILED) { + s2 = peg$parsealphanumeric(); + } + } + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f10(s1); + } + s0 = s1; + + return s0; + } + + function peg$parsequotedString() { + var s0, s1, s2, s3; + + peg$silentFails++; + s0 = peg$currPos; + if (input.charCodeAt(peg$currPos) === 34) { + s1 = peg$c15; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e19); } + } + if (s1 !== peg$FAILED) { + s2 = []; + s3 = input.charAt(peg$currPos); + if (peg$r1.test(s3)) { + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e20); } + } + while (s3 !== peg$FAILED) { + s2.push(s3); + s3 = input.charAt(peg$currPos); + if (peg$r1.test(s3)) { + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e20); } + } + } + if (input.charCodeAt(peg$currPos) === 34) { + s3 = peg$c15; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e19); } + } + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f11(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e18); } + } + + return s0; + } + + function peg$parsealphanumeric() { + var s0, s1, s2; + + peg$silentFails++; + s0 = peg$currPos; + s1 = []; + s2 = input.charAt(peg$currPos); + if (peg$r2.test(s2)) { + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e22); } + } + if (s2 !== peg$FAILED) { + while (s2 !== peg$FAILED) { + s1.push(s2); + s2 = input.charAt(peg$currPos); + if (peg$r2.test(s2)) { + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e22); } + } + } + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + peg$savedPos = s0; + s1 = peg$f12(s1); + } + s0 = s1; + peg$silentFails--; + if (s0 === peg$FAILED) { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e21); } + } + + return s0; + } + + function peg$parselogicalAnd() { + var s0, s1; + + s0 = peg$currPos; + s1 = peg$parse_(); + peg$savedPos = s0; + s1 = peg$f13(); + s0 = s1; + + return s0; + } + + function peg$parse_() { + var s0, s1; + + peg$silentFails++; + s0 = []; + s1 = input.charAt(peg$currPos); + if (peg$r3.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e24); } + } + while (s1 !== peg$FAILED) { + s0.push(s1); + s1 = input.charAt(peg$currPos); + if (peg$r3.test(s1)) { + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e24); } + } + } + peg$silentFails--; + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e23); } + + return s0; + } + + + const defaults = { + autocomplete: null, + }; + + function applyAutocomplete(ranges) { + return { + ...defaults, + ranges, + }; + } + + function updateAutocomplete(value) { + defaults.autocomplete = value; + } + + peg$result = peg$startRuleFunction(); + + if (options.peg$library) { + return /** @type {any} */ ({ + peg$result, + peg$currPos, + peg$FAILED, + peg$maxFailExpected, + peg$maxFailPos + }); + } + if (peg$result !== peg$FAILED && peg$currPos === input.length) { + return peg$result; + } else { + if (peg$result !== peg$FAILED && peg$currPos < input.length) { + peg$fail(peg$endExpectation()); + } + + throw peg$buildStructuredError( + peg$maxFailExpected, + peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, + peg$maxFailPos < input.length + ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) + : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) + ); + } +} + +const peg$allowedStartRules = [ + "query" +]; + +export { + peg$allowedStartRules as StartRules, + peg$SyntaxError as SyntaxError, + peg$parse as parse +}; diff --git a/src/libs/AutocompleteParser/autocompleteParser.peggy b/src/libs/AutocompleteParser/autocompleteParser.peggy new file mode 100644 index 000000000000..c313cd2ff6c1 --- /dev/null +++ b/src/libs/AutocompleteParser/autocompleteParser.peggy @@ -0,0 +1,104 @@ +{ + const defaults = { + autocomplete: null, + }; + + function applyAutocomplete(ranges) { + return { + ...defaults, + ranges, + }; + } + + function updateAutocomplete(value) { + defaults.autocomplete = value; + } +} + +query = _ filters:filterList? _ { return applyAutocomplete(filters); } + +filterList + = head:filter tail:(logicalAnd filter)* { + const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter( + Boolean + ); + return allFilters.flat(); + } + +filter = @(defaultFilter / freeTextFilter) + +defaultFilter + = _ key:key _ op:operator _ value:identifier? { + if (!value) { + updateAutocomplete({ + key, + value: null, + start: location().end.offset, + length: 0, + }); + return; + } else { + updateAutocomplete({ + key, + ...value[value.length - 1], + }); + } + + return value.map(({ start, length }) => ({ + key, + start, + length, + })); + } + +freeTextFilter = _ value:identifier _ { updateAutocomplete(null); } + +operator "operator" + = (":" / "=") { return "eq"; } + / "!=" { return "neq"; } + / ">=" { return "gte"; } + / ">" { return "gt"; } + / "<=" { return "lte"; } + / "<" { return "lt"; } + +key "key" + = @( + "in" + / "currency" + / "tag" + / "category" + / "to" + / "taxRate" + / "from" + / "expenseType" + / "type" + / "status" + ) + +identifier + = parts:(quotedString / alphanumeric)+ { + const ends = location(); + const value = parts.flat(); + let count = ends.start.offset; + const result = []; + value.forEach((filter) => { + result.push({ + value: filter, + start: count, + length: filter.length, + }); + count += filter.length + 1; + }); + return result; + } + +quotedString "quote" = "\"" chars:[^"\r\n]* "\"" { return chars.join(""); } + +alphanumeric "word" + = chars:[A-Za-z0-9_@./#&+\-\\',;:%]+ { + return chars.join("").trim().split(","); + } + +logicalAnd = _ { return "and"; } + +_ "whitespace" = [ \t\r\n]* \ No newline at end of file diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 9fd94dcb86b8..15bbc360e54f 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -70,7 +70,7 @@ function canUseNewDotQBD(betas: OnyxEntry): boolean { * After everything is implemented this function can be removed, as we will always use SearchRouter in the App. */ function canUseNewSearchRouter() { - return Environment.isDevelopment(); + return true; } /** diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts new file mode 100644 index 000000000000..cb6445a15689 --- /dev/null +++ b/src/libs/SearchAutocompleteUtils.ts @@ -0,0 +1,14 @@ +import type {SearchAutocompleteResult} from '@components/Search/types'; +import * as autocompleteParser from './AutocompleteParser/autocompleteParser'; + +function parseForAutocomplete(text: string) { + try { + const parsedAutocomplete = autocompleteParser.parse(text) as SearchAutocompleteResult; + return parsedAutocomplete; + } catch (e) { + console.error(`Error when parsing autocopmlete}"`, e); + } +} + +// eslint-disable-next-line import/prefer-default-export +export {parseForAutocomplete}; From 2b63c0ed3418668ae09d88a823764a8e693ee2d2 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Tue, 22 Oct 2024 13:22:48 +0200 Subject: [PATCH 02/21] remove previously added parser --- .../AutocompleteParser/autocompleteParser.js | 995 ------------------ .../autocompleteParser.peggy | 104 -- src/libs/SearchAutocompleteUtils.ts | 2 +- 3 files changed, 1 insertion(+), 1100 deletions(-) delete mode 100644 src/libs/AutocompleteParser/autocompleteParser.js delete mode 100644 src/libs/AutocompleteParser/autocompleteParser.peggy diff --git a/src/libs/AutocompleteParser/autocompleteParser.js b/src/libs/AutocompleteParser/autocompleteParser.js deleted file mode 100644 index be65d1699c59..000000000000 --- a/src/libs/AutocompleteParser/autocompleteParser.js +++ /dev/null @@ -1,995 +0,0 @@ -// @generated by Peggy 4.0.3. -// -// https://peggyjs.org/ - - -function peg$subclass(child, parent) { - function C() { this.constructor = child; } - C.prototype = parent.prototype; - child.prototype = new C(); -} - -function peg$SyntaxError(message, expected, found, location) { - var self = Error.call(this, message); - // istanbul ignore next Check is a necessary evil to support older environments - if (Object.setPrototypeOf) { - Object.setPrototypeOf(self, peg$SyntaxError.prototype); - } - self.expected = expected; - self.found = found; - self.location = location; - self.name = "SyntaxError"; - return self; -} - -peg$subclass(peg$SyntaxError, Error); - -function peg$padEnd(str, targetLength, padString) { - padString = padString || " "; - if (str.length > targetLength) { return str; } - targetLength -= str.length; - padString += padString.repeat(targetLength); - return str + padString.slice(0, targetLength); -} - -peg$SyntaxError.prototype.format = function(sources) { - var str = "Error: " + this.message; - if (this.location) { - var src = null; - var k; - for (k = 0; k < sources.length; k++) { - if (sources[k].source === this.location.source) { - src = sources[k].text.split(/\r\n|\n|\r/g); - break; - } - } - var s = this.location.start; - var offset_s = (this.location.source && (typeof this.location.source.offset === "function")) - ? this.location.source.offset(s) - : s; - var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; - if (src) { - var e = this.location.end; - var filler = peg$padEnd("", offset_s.line.toString().length, ' '); - var line = src[s.line - 1]; - var last = s.line === e.line ? e.column : line.length + 1; - var hatLen = (last - s.column) || 1; - str += "\n --> " + loc + "\n" - + filler + " |\n" - + offset_s.line + " | " + line + "\n" - + filler + " | " + peg$padEnd("", s.column - 1, ' ') - + peg$padEnd("", hatLen, "^"); - } else { - str += "\n at " + loc; - } - } - return str; -}; - -peg$SyntaxError.buildMessage = function(expected, found) { - var DESCRIBE_EXPECTATION_FNS = { - literal: function(expectation) { - return "\"" + literalEscape(expectation.text) + "\""; - }, - - class: function(expectation) { - var escapedParts = expectation.parts.map(function(part) { - return Array.isArray(part) - ? classEscape(part[0]) + "-" + classEscape(part[1]) - : classEscape(part); - }); - - return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]"; - }, - - any: function() { - return "any character"; - }, - - end: function() { - return "end of input"; - }, - - other: function(expectation) { - return expectation.description; - } - }; - - function hex(ch) { - return ch.charCodeAt(0).toString(16).toUpperCase(); - } - - function literalEscape(s) { - return s - .replace(/\\/g, "\\\\") - .replace(/"/g, "\\\"") - .replace(/\0/g, "\\0") - .replace(/\t/g, "\\t") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); - } - - function classEscape(s) { - return s - .replace(/\\/g, "\\\\") - .replace(/\]/g, "\\]") - .replace(/\^/g, "\\^") - .replace(/-/g, "\\-") - .replace(/\0/g, "\\0") - .replace(/\t/g, "\\t") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) - .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); - } - - function describeExpectation(expectation) { - return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); - } - - function describeExpected(expected) { - var descriptions = expected.map(describeExpectation); - var i, j; - - descriptions.sort(); - - if (descriptions.length > 0) { - for (i = 1, j = 1; i < descriptions.length; i++) { - if (descriptions[i - 1] !== descriptions[i]) { - descriptions[j] = descriptions[i]; - j++; - } - } - descriptions.length = j; - } - - switch (descriptions.length) { - case 1: - return descriptions[0]; - - case 2: - return descriptions[0] + " or " + descriptions[1]; - - default: - return descriptions.slice(0, -1).join(", ") - + ", or " - + descriptions[descriptions.length - 1]; - } - } - - function describeFound(found) { - return found ? "\"" + literalEscape(found) + "\"" : "end of input"; - } - - return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; -}; - -function peg$parse(input, options) { - options = options !== undefined ? options : {}; - - var peg$FAILED = {}; - var peg$source = options.grammarSource; - - var peg$startRuleFunctions = { query: peg$parsequery }; - var peg$startRuleFunction = peg$parsequery; - - var peg$c0 = "!="; - var peg$c1 = ">="; - var peg$c2 = ">"; - var peg$c3 = "<="; - var peg$c4 = "<"; - var peg$c5 = "in"; - var peg$c6 = "currency"; - var peg$c7 = "tag"; - var peg$c8 = "category"; - var peg$c9 = "to"; - var peg$c10 = "taxRate"; - var peg$c11 = "from"; - var peg$c12 = "expenseType"; - var peg$c13 = "type"; - var peg$c14 = "status"; - var peg$c15 = "\""; - - var peg$r0 = /^[:=]/; - var peg$r1 = /^[^"\r\n]/; - var peg$r2 = /^[A-Za-z0-9_@.\/#&+\-\\',;:%]/; - var peg$r3 = /^[ \t\r\n]/; - - var peg$e0 = peg$otherExpectation("operator"); - var peg$e1 = peg$classExpectation([":", "="], false, false); - var peg$e2 = peg$literalExpectation("!=", false); - var peg$e3 = peg$literalExpectation(">=", false); - var peg$e4 = peg$literalExpectation(">", false); - var peg$e5 = peg$literalExpectation("<=", false); - var peg$e6 = peg$literalExpectation("<", false); - var peg$e7 = peg$otherExpectation("key"); - var peg$e8 = peg$literalExpectation("in", false); - var peg$e9 = peg$literalExpectation("currency", false); - var peg$e10 = peg$literalExpectation("tag", false); - var peg$e11 = peg$literalExpectation("category", false); - var peg$e12 = peg$literalExpectation("to", false); - var peg$e13 = peg$literalExpectation("taxRate", false); - var peg$e14 = peg$literalExpectation("from", false); - var peg$e15 = peg$literalExpectation("expenseType", false); - var peg$e16 = peg$literalExpectation("type", false); - var peg$e17 = peg$literalExpectation("status", false); - var peg$e18 = peg$otherExpectation("quote"); - var peg$e19 = peg$literalExpectation("\"", false); - var peg$e20 = peg$classExpectation(["\"", "\r", "\n"], true, false); - var peg$e21 = peg$otherExpectation("word"); - var peg$e22 = peg$classExpectation([["A", "Z"], ["a", "z"], ["0", "9"], "_", "@", ".", "/", "#", "&", "+", "-", "\\", "'", ",", ";", ":", "%"], false, false); - var peg$e23 = peg$otherExpectation("whitespace"); - var peg$e24 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); - - var peg$f0 = function(filters) { return applyAutocomplete(filters); }; - var peg$f1 = function(head, tail) { - const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter( - Boolean - ); - return allFilters.flat(); - }; - var peg$f2 = function(key, op, value) { - if (!value) { - updateAutocomplete({ - key, - value: null, - start: location().end.offset, - length: 0, - }); - return; - } else { - updateAutocomplete({ - key, - ...value[value.length - 1], - }); - } - - return value.map(({ start, length }) => ({ - key, - start, - length, - })); - }; - var peg$f3 = function(value) { updateAutocomplete(null); }; - var peg$f4 = function() { return "eq"; }; - var peg$f5 = function() { return "neq"; }; - var peg$f6 = function() { return "gte"; }; - var peg$f7 = function() { return "gt"; }; - var peg$f8 = function() { return "lte"; }; - var peg$f9 = function() { return "lt"; }; - var peg$f10 = function(parts) { - const ends = location(); - const value = parts.flat(); - let count = ends.start.offset; - const result = []; - value.forEach((filter) => { - result.push({ - value: filter, - start: count, - length: filter.length, - }); - count += filter.length + 1; - }); - return result; - }; - var peg$f11 = function(chars) { return chars.join(""); }; - var peg$f12 = function(chars) { - return chars.join("").trim().split(","); - }; - var peg$f13 = function() { return "and"; }; - var peg$currPos = options.peg$currPos | 0; - var peg$savedPos = peg$currPos; - var peg$posDetailsCache = [{ line: 1, column: 1 }]; - var peg$maxFailPos = peg$currPos; - var peg$maxFailExpected = options.peg$maxFailExpected || []; - var peg$silentFails = options.peg$silentFails | 0; - - var peg$result; - - if (options.startRule) { - if (!(options.startRule in peg$startRuleFunctions)) { - throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); - } - - peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; - } - - function text() { - return input.substring(peg$savedPos, peg$currPos); - } - - function offset() { - return peg$savedPos; - } - - function range() { - return { - source: peg$source, - start: peg$savedPos, - end: peg$currPos - }; - } - - function location() { - return peg$computeLocation(peg$savedPos, peg$currPos); - } - - function expected(description, location) { - location = location !== undefined - ? location - : peg$computeLocation(peg$savedPos, peg$currPos); - - throw peg$buildStructuredError( - [peg$otherExpectation(description)], - input.substring(peg$savedPos, peg$currPos), - location - ); - } - - function error(message, location) { - location = location !== undefined - ? location - : peg$computeLocation(peg$savedPos, peg$currPos); - - throw peg$buildSimpleError(message, location); - } - - function peg$literalExpectation(text, ignoreCase) { - return { type: "literal", text: text, ignoreCase: ignoreCase }; - } - - function peg$classExpectation(parts, inverted, ignoreCase) { - return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; - } - - function peg$anyExpectation() { - return { type: "any" }; - } - - function peg$endExpectation() { - return { type: "end" }; - } - - function peg$otherExpectation(description) { - return { type: "other", description: description }; - } - - function peg$computePosDetails(pos) { - var details = peg$posDetailsCache[pos]; - var p; - - if (details) { - return details; - } else { - if (pos >= peg$posDetailsCache.length) { - p = peg$posDetailsCache.length - 1; - } else { - p = pos; - while (!peg$posDetailsCache[--p]) {} - } - - details = peg$posDetailsCache[p]; - details = { - line: details.line, - column: details.column - }; - - while (p < pos) { - if (input.charCodeAt(p) === 10) { - details.line++; - details.column = 1; - } else { - details.column++; - } - - p++; - } - - peg$posDetailsCache[pos] = details; - - return details; - } - } - - function peg$computeLocation(startPos, endPos, offset) { - var startPosDetails = peg$computePosDetails(startPos); - var endPosDetails = peg$computePosDetails(endPos); - - var res = { - source: peg$source, - start: { - offset: startPos, - line: startPosDetails.line, - column: startPosDetails.column - }, - end: { - offset: endPos, - line: endPosDetails.line, - column: endPosDetails.column - } - }; - if (offset && peg$source && (typeof peg$source.offset === "function")) { - res.start = peg$source.offset(res.start); - res.end = peg$source.offset(res.end); - } - return res; - } - - function peg$fail(expected) { - if (peg$currPos < peg$maxFailPos) { return; } - - if (peg$currPos > peg$maxFailPos) { - peg$maxFailPos = peg$currPos; - peg$maxFailExpected = []; - } - - peg$maxFailExpected.push(expected); - } - - function peg$buildSimpleError(message, location) { - return new peg$SyntaxError(message, null, null, location); - } - - function peg$buildStructuredError(expected, found, location) { - return new peg$SyntaxError( - peg$SyntaxError.buildMessage(expected, found), - expected, - found, - location - ); - } - - function peg$parsequery() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = peg$parse_(); - s2 = peg$parsefilterList(); - if (s2 === peg$FAILED) { - s2 = null; - } - s3 = peg$parse_(); - peg$savedPos = s0; - s0 = peg$f0(s2); - - return s0; - } - - function peg$parsefilterList() { - var s0, s1, s2, s3, s4, s5; - - s0 = peg$currPos; - s1 = peg$parsefilter(); - if (s1 !== peg$FAILED) { - s2 = []; - s3 = peg$currPos; - s4 = peg$parselogicalAnd(); - s5 = peg$parsefilter(); - if (s5 !== peg$FAILED) { - s4 = [s4, s5]; - s3 = s4; - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$currPos; - s4 = peg$parselogicalAnd(); - s5 = peg$parsefilter(); - if (s5 !== peg$FAILED) { - s4 = [s4, s5]; - s3 = s4; - } else { - peg$currPos = s3; - s3 = peg$FAILED; - } - } - peg$savedPos = s0; - s0 = peg$f1(s1, s2); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsefilter() { - var s0, s1; - - s0 = peg$currPos; - s1 = peg$parsedefaultFilter(); - if (s1 === peg$FAILED) { - s1 = peg$parsefreeTextFilter(); - } - if (s1 !== peg$FAILED) { - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsedefaultFilter() { - var s0, s1, s2, s3, s4, s5, s6; - - s0 = peg$currPos; - s1 = peg$parse_(); - s2 = peg$parsekey(); - if (s2 !== peg$FAILED) { - s3 = peg$parse_(); - s4 = peg$parseoperator(); - if (s4 !== peg$FAILED) { - s5 = peg$parse_(); - s6 = peg$parseidentifier(); - if (s6 === peg$FAILED) { - s6 = null; - } - peg$savedPos = s0; - s0 = peg$f2(s2, s4, s6); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parsefreeTextFilter() { - var s0, s1, s2, s3; - - s0 = peg$currPos; - s1 = peg$parse_(); - s2 = peg$parseidentifier(); - if (s2 !== peg$FAILED) { - s3 = peg$parse_(); - peg$savedPos = s0; - s0 = peg$f3(s2); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - - return s0; - } - - function peg$parseoperator() { - var s0, s1; - - peg$silentFails++; - s0 = peg$currPos; - s1 = input.charAt(peg$currPos); - if (peg$r0.test(s1)) { - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e1); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f4(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c0) { - s1 = peg$c0; - peg$currPos += 2; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e2); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f5(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c1) { - s1 = peg$c1; - peg$currPos += 2; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e3); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f6(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 62) { - s1 = peg$c2; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e4); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f7(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c3) { - s1 = peg$c3; - peg$currPos += 2; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e5); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f8(); - } - s0 = s1; - if (s0 === peg$FAILED) { - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 60) { - s1 = peg$c4; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e6); } - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f9(); - } - s0 = s1; - } - } - } - } - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e0); } - } - - return s0; - } - - function peg$parsekey() { - var s0, s1; - - peg$silentFails++; - s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c5) { - s1 = peg$c5; - peg$currPos += 2; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e8); } - } - if (s1 === peg$FAILED) { - if (input.substr(peg$currPos, 8) === peg$c6) { - s1 = peg$c6; - peg$currPos += 8; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e9); } - } - if (s1 === peg$FAILED) { - if (input.substr(peg$currPos, 3) === peg$c7) { - s1 = peg$c7; - peg$currPos += 3; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e10); } - } - if (s1 === peg$FAILED) { - if (input.substr(peg$currPos, 8) === peg$c8) { - s1 = peg$c8; - peg$currPos += 8; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e11); } - } - if (s1 === peg$FAILED) { - if (input.substr(peg$currPos, 2) === peg$c9) { - s1 = peg$c9; - peg$currPos += 2; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e12); } - } - if (s1 === peg$FAILED) { - if (input.substr(peg$currPos, 7) === peg$c10) { - s1 = peg$c10; - peg$currPos += 7; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e13); } - } - if (s1 === peg$FAILED) { - if (input.substr(peg$currPos, 4) === peg$c11) { - s1 = peg$c11; - peg$currPos += 4; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e14); } - } - if (s1 === peg$FAILED) { - if (input.substr(peg$currPos, 11) === peg$c12) { - s1 = peg$c12; - peg$currPos += 11; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e15); } - } - if (s1 === peg$FAILED) { - if (input.substr(peg$currPos, 4) === peg$c13) { - s1 = peg$c13; - peg$currPos += 4; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e16); } - } - if (s1 === peg$FAILED) { - if (input.substr(peg$currPos, 6) === peg$c14) { - s1 = peg$c14; - peg$currPos += 6; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e17); } - } - } - } - } - } - } - } - } - } - } - if (s1 !== peg$FAILED) { - s0 = s1; - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e7); } - } - - return s0; - } - - function peg$parseidentifier() { - var s0, s1, s2; - - s0 = peg$currPos; - s1 = []; - s2 = peg$parsequotedString(); - if (s2 === peg$FAILED) { - s2 = peg$parsealphanumeric(); - } - if (s2 !== peg$FAILED) { - while (s2 !== peg$FAILED) { - s1.push(s2); - s2 = peg$parsequotedString(); - if (s2 === peg$FAILED) { - s2 = peg$parsealphanumeric(); - } - } - } else { - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f10(s1); - } - s0 = s1; - - return s0; - } - - function peg$parsequotedString() { - var s0, s1, s2, s3; - - peg$silentFails++; - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 34) { - s1 = peg$c15; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e19); } - } - if (s1 !== peg$FAILED) { - s2 = []; - s3 = input.charAt(peg$currPos); - if (peg$r1.test(s3)) { - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e20); } - } - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = input.charAt(peg$currPos); - if (peg$r1.test(s3)) { - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e20); } - } - } - if (input.charCodeAt(peg$currPos) === 34) { - s3 = peg$c15; - peg$currPos++; - } else { - s3 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e19); } - } - if (s3 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f11(s2); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e18); } - } - - return s0; - } - - function peg$parsealphanumeric() { - var s0, s1, s2; - - peg$silentFails++; - s0 = peg$currPos; - s1 = []; - s2 = input.charAt(peg$currPos); - if (peg$r2.test(s2)) { - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e22); } - } - if (s2 !== peg$FAILED) { - while (s2 !== peg$FAILED) { - s1.push(s2); - s2 = input.charAt(peg$currPos); - if (peg$r2.test(s2)) { - peg$currPos++; - } else { - s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e22); } - } - } - } else { - s1 = peg$FAILED; - } - if (s1 !== peg$FAILED) { - peg$savedPos = s0; - s1 = peg$f12(s1); - } - s0 = s1; - peg$silentFails--; - if (s0 === peg$FAILED) { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e21); } - } - - return s0; - } - - function peg$parselogicalAnd() { - var s0, s1; - - s0 = peg$currPos; - s1 = peg$parse_(); - peg$savedPos = s0; - s1 = peg$f13(); - s0 = s1; - - return s0; - } - - function peg$parse_() { - var s0, s1; - - peg$silentFails++; - s0 = []; - s1 = input.charAt(peg$currPos); - if (peg$r3.test(s1)) { - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e24); } - } - while (s1 !== peg$FAILED) { - s0.push(s1); - s1 = input.charAt(peg$currPos); - if (peg$r3.test(s1)) { - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e24); } - } - } - peg$silentFails--; - s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } - - return s0; - } - - - const defaults = { - autocomplete: null, - }; - - function applyAutocomplete(ranges) { - return { - ...defaults, - ranges, - }; - } - - function updateAutocomplete(value) { - defaults.autocomplete = value; - } - - peg$result = peg$startRuleFunction(); - - if (options.peg$library) { - return /** @type {any} */ ({ - peg$result, - peg$currPos, - peg$FAILED, - peg$maxFailExpected, - peg$maxFailPos - }); - } - if (peg$result !== peg$FAILED && peg$currPos === input.length) { - return peg$result; - } else { - if (peg$result !== peg$FAILED && peg$currPos < input.length) { - peg$fail(peg$endExpectation()); - } - - throw peg$buildStructuredError( - peg$maxFailExpected, - peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, - peg$maxFailPos < input.length - ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) - : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) - ); - } -} - -const peg$allowedStartRules = [ - "query" -]; - -export { - peg$allowedStartRules as StartRules, - peg$SyntaxError as SyntaxError, - peg$parse as parse -}; diff --git a/src/libs/AutocompleteParser/autocompleteParser.peggy b/src/libs/AutocompleteParser/autocompleteParser.peggy deleted file mode 100644 index c313cd2ff6c1..000000000000 --- a/src/libs/AutocompleteParser/autocompleteParser.peggy +++ /dev/null @@ -1,104 +0,0 @@ -{ - const defaults = { - autocomplete: null, - }; - - function applyAutocomplete(ranges) { - return { - ...defaults, - ranges, - }; - } - - function updateAutocomplete(value) { - defaults.autocomplete = value; - } -} - -query = _ filters:filterList? _ { return applyAutocomplete(filters); } - -filterList - = head:filter tail:(logicalAnd filter)* { - const allFilters = [head, ...tail.map(([_, filter]) => filter)].filter( - Boolean - ); - return allFilters.flat(); - } - -filter = @(defaultFilter / freeTextFilter) - -defaultFilter - = _ key:key _ op:operator _ value:identifier? { - if (!value) { - updateAutocomplete({ - key, - value: null, - start: location().end.offset, - length: 0, - }); - return; - } else { - updateAutocomplete({ - key, - ...value[value.length - 1], - }); - } - - return value.map(({ start, length }) => ({ - key, - start, - length, - })); - } - -freeTextFilter = _ value:identifier _ { updateAutocomplete(null); } - -operator "operator" - = (":" / "=") { return "eq"; } - / "!=" { return "neq"; } - / ">=" { return "gte"; } - / ">" { return "gt"; } - / "<=" { return "lte"; } - / "<" { return "lt"; } - -key "key" - = @( - "in" - / "currency" - / "tag" - / "category" - / "to" - / "taxRate" - / "from" - / "expenseType" - / "type" - / "status" - ) - -identifier - = parts:(quotedString / alphanumeric)+ { - const ends = location(); - const value = parts.flat(); - let count = ends.start.offset; - const result = []; - value.forEach((filter) => { - result.push({ - value: filter, - start: count, - length: filter.length, - }); - count += filter.length + 1; - }); - return result; - } - -quotedString "quote" = "\"" chars:[^"\r\n]* "\"" { return chars.join(""); } - -alphanumeric "word" - = chars:[A-Za-z0-9_@./#&+\-\\',;:%]+ { - return chars.join("").trim().split(","); - } - -logicalAnd = _ { return "and"; } - -_ "whitespace" = [ \t\r\n]* \ No newline at end of file diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index cb6445a15689..3a279a2ab4ea 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -1,5 +1,5 @@ import type {SearchAutocompleteResult} from '@components/Search/types'; -import * as autocompleteParser from './AutocompleteParser/autocompleteParser'; +import * as autocompleteParser from './SearchParser/autocompleteParser'; function parseForAutocomplete(text: string) { try { From 3f748b5d90c3dd934c65f997a742898bfac57da7 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Tue, 22 Oct 2024 15:07:49 +0200 Subject: [PATCH 03/21] remove autocomplete list behaviour --- .../Search/SearchRouter/SearchRouter.tsx | 54 +++++-------------- .../Search/SearchRouter/SearchRouterList.tsx | 45 +++++----------- .../Search/SearchQueryListItem.tsx | 3 -- 3 files changed, 26 insertions(+), 76 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index f5ef2384f52f..15595e6e1658 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -56,34 +56,14 @@ function getAutoCompleteTagsList(allPoliciesTagsLists: OnyxCollection) { - switch (type) { - case CONST.SEARCH.DATA_TYPES.INVOICE: { - return Object.values(CONST.SEARCH.STATUS.INVOICE); - } - case CONST.SEARCH.DATA_TYPES.CHAT: { - return Object.values(CONST.SEARCH.STATUS.CHAT); - } - case CONST.SEARCH.DATA_TYPES.EXPENSE: { - return Object.values(CONST.SEARCH.STATUS.EXPENSE); - } - case CONST.SEARCH.DATA_TYPES.TRIP: { - return Object.values(CONST.SEARCH.STATUS.TRIP); - } - default: - return Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP}); - } -} - function getAutocompleteCategoriesList(allPolicyCategories: OnyxCollection, policyID?: string) { const singlePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; - const categoryList = singlePolicyCategories - ? Object.values(singlePolicyCategories) - : Object.values(allPolicyCategories ?? {}) - .map((policyCategories) => Object.values(policyCategories ?? {})) - .flat(); - const filteredCategoryList = categoryList.filter((category) => !!category); - return filteredCategoryList.map((category) => category.name); + if (!singlePolicyCategories) { + const uniqueCategoryNames = new Set(); + Object.values(allPolicyCategories ?? {}).map((policyCategories) => Object.values(policyCategories ?? {}).forEach((category) => uniqueCategoryNames.add(category.name))); + return Array.from(uniqueCategoryNames); + } + return Object.values(singlePolicyCategories ?? {}).map((category) => category.name); } function getAutocompleteTaxList(allTaxRates: Record, policy?: OnyxEntry) { @@ -93,11 +73,6 @@ function getAutocompleteTaxList(allTaxRates: Record, policy?: return Object.keys(allTaxRates).map((taxRateName) => taxRateName); } - -type SearchRouterProps = { - onRouterClose: () => void; -}; - function SearchRouter({onRouterClose}: SearchRouterProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -120,7 +95,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const activeWorkspaceID = useActiveWorkspaceFromNavigationState(); const policy = usePolicy(activeWorkspaceID); const typesAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES); - const statusesAutocompleteList = useMemo(() => getAutocompleteStatusesList(userSearchQuery?.type), [userSearchQuery?.type]); + const statusesAutocompleteList = 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 [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const categoryAutocompleteList = useMemo(() => getAutocompleteCategoriesList(allPolicyCategories, activeWorkspaceID), [allPolicyCategories, activeWorkspaceID]); @@ -196,9 +171,6 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const updateAutocomplete = useCallback( (autocompleteValue: string, autocompleteType?: ValueOf) => { switch (autocompleteType) { - case 'in': { - return; - } case 'tag': { const filteredTags = tagAutocompleteList.filter((tag) => tag?.includes(autocompleteValue)); setAutocompleteSuggestions( @@ -213,7 +185,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const filteredCategories = categoryAutocompleteList.filter((category) => category?.includes(autocompleteValue)); setAutocompleteSuggestions( filteredCategories.map((categoryName) => ({ - text: `currency:${categoryName}`, + text: `category:${categoryName}`, query: `${categoryName}`, })), ); @@ -317,9 +289,9 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { [updateAutocomplete], ); - const updateSearchInputValue = (newValue: string) => { - setTextInputValue(newValue); - onSearchChange(newValue); + const updateUserSearchQuery = (newSearchQuery: string) => { + setTextInputValue(newSearchQuery); + onSearchChange(newSearchQuery); }; const closeAndClearRouter = useCallback(() => { @@ -378,13 +350,13 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { isSearchingForReports={isSearchingForReports} /> diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 2ab4157bf986..e0be11fe3ec8 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -29,8 +29,8 @@ type ItemWithQuery = { }; type SearchRouterListProps = { - /** Value of TextInput */ - textInputValue: string; + /** currentQuery value computed coming from parsed TextInput value */ + currentQuery: SearchQueryJSON | undefined; /** Recent searches */ recentSearches: Array | undefined; @@ -48,7 +48,7 @@ type SearchRouterListProps = { reportForContextualSearch?: OptionData; /** Callback to update search query when selecting contextual suggestion */ - updateSearchInputValue: (newSearchQuery: string) => void; + updateUserSearchQuery: (newSearchQuery: string) => void; /** Callback to close and clear SearchRouter */ closeAndClearRouter: () => void; @@ -91,16 +91,7 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList } function SearchRouterList( - { - textInputValue, - reportForContextualSearch, - recentSearches, - recentReports, - autocompleteItems, - onSearchSubmit, - updateSearchInputValue: updateUserSearchQuery, - closeAndClearRouter, - }: SearchRouterListProps, + {currentQuery, reportForContextualSearch, recentSearches, autocompleteItems, recentReports, onSearchSubmit, updateUserSearchQuery, closeAndClearRouter}: SearchRouterListProps, ref: ForwardedRef, ) { const styles = useThemeStyles(); @@ -113,22 +104,21 @@ function SearchRouterList( const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); const sections: Array> = []; - if (textInputValue) { + if (currentQuery?.inputQuery) { sections.push({ data: [ { - text: textInputValue, + text: currentQuery?.inputQuery, singleIcon: Expensicons.MagnifyingGlass, - query: textInputValue, + query: currentQuery?.inputQuery, itemStyle: styles.activeComponentBG, keyForList: 'findItem', - searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, }, ], }); } - if (reportForContextualSearch && !textInputValue) { + if (reportForContextualSearch && !currentQuery?.inputQuery) { sections.push({ data: [ { @@ -137,7 +127,7 @@ function SearchRouterList( query: SearchUtils.getContextualSuggestionQuery(reportForContextualSearch.reportID), itemStyle: styles.activeComponentBG, keyForList: 'contextualSearch', - searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION, + isContextualSearchItem: true, }, ], }); @@ -167,7 +157,7 @@ function SearchRouterList( }; }); - if (!textInputValue && recentSearchesData && recentSearchesData.length > 0) { + if (!currentQuery?.inputQuery && recentSearchesData && recentSearchesData.length > 0) { sections.push({title: translate('search.recentSearches'), data: recentSearchesData}); } @@ -177,18 +167,9 @@ function SearchRouterList( const onSelectRow = useCallback( (item: OptionData | SearchQueryItem) => { if (isSearchQueryItem(item)) { - if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { + if (item.isContextualSearchItem) { // Handle selection of "Contextual search suggestion" - updateUserSearchQuery(`${item?.query} ${textInputValue ?? ''}`); - return; - } - - if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION) { - // Handle selection of "Autocomplete suggestion" - const lastColonIndex = textInputValue.lastIndexOf(':'); - const lastComaIndex = textInputValue.lastIndexOf(','); - const trimmedTextInputValue = lastColonIndex > lastComaIndex ? textInputValue.slice(0, lastColonIndex + 1) : textInputValue.slice(0, lastComaIndex + 1); - updateUserSearchQuery(`${trimmedTextInputValue}${item?.query}`); + updateUserSearchQuery(`${item?.query} ${currentQuery?.inputQuery ?? ''}`); return; } @@ -207,7 +188,7 @@ function SearchRouterList( Report.navigateToAndOpenReport(item.login ? [item.login] : [], false); } }, - [closeAndClearRouter, onSearchSubmit, textInputValue, updateUserSearchQuery], + [closeAndClearRouter, onSearchSubmit, currentQuery, updateUserSearchQuery], ); return ( diff --git a/src/components/SelectionList/Search/SearchQueryListItem.tsx b/src/components/SelectionList/Search/SearchQueryListItem.tsx index cf1b75d95f17..369f527cdeba 100644 --- a/src/components/SelectionList/Search/SearchQueryListItem.tsx +++ b/src/components/SelectionList/Search/SearchQueryListItem.tsx @@ -1,20 +1,17 @@ import React from 'react'; import {View} from 'react-native'; -import type {ValueOf} from 'type-fest'; import Icon from '@components/Icon'; import BaseListItem from '@components/SelectionList/BaseListItem'; import type {ListItem} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import type CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; type SearchQueryItem = ListItem & { singleIcon?: IconAsset; query?: string; isContextualSearchItem?: boolean; - searchItemType: ValueOf; }; type SearchQueryListItemProps = { From c58aa27c6f958ae99c8b3ecd50a2eeecf01c9bab Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Tue, 22 Oct 2024 15:11:58 +0200 Subject: [PATCH 04/21] remove unused CONST values --- src/CONST.ts | 5 ----- src/components/Search/SearchRouter/SearchRouterList.tsx | 1 - 2 files changed, 6 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index f9014d495678..440f942e1244 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5730,11 +5730,6 @@ const CONST = { IN: 'in', }, EMPTY_VALUE: 'none', - SEARCH_ROUTER_ITEM_TYPE: { - CONTEXTUAL_SUGGESTION: 'contextualSuggestion', - AUTOCOMPLETE_SUGGESTION: 'autocompleteSuggestion', - SEARCH: 'seearchItem', - }, }, REFERRER: { diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index e0be11fe3ec8..70072ad4da01 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -139,7 +139,6 @@ function SearchRouterList( singleIcon: Expensicons.MagnifyingGlass, query, keyForList: query, - searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION, }; }); From 157c3e1f1d116d94867c9ecd9be142bc5e2ae8d5 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Wed, 23 Oct 2024 13:35:19 +0200 Subject: [PATCH 05/21] move autocomplete functions to autocompleteUtils --- .../Search/SearchRouter/SearchRouter.tsx | 39 +------------------ src/libs/SearchAutocompleteUtils.ts | 39 ++++++++++++++++++- 2 files changed, 40 insertions(+), 38 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index b3af8e575400..30817fcf64c7 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -3,7 +3,6 @@ import debounce from 'lodash/debounce'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {usePersonalDetails} from '@components/OnyxProvider'; @@ -19,9 +18,9 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import Log from '@libs/Log'; import * as OptionsListUtils from '@libs/OptionsListUtils'; -import {getAllTaxRates, getTagNamesFromTagsLists} from '@libs/PolicyUtils'; +import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; -import {parseForAutocomplete} from '@libs/SearchAutocompleteUtils'; +import {getAutocompleteCategoriesList, getAutoCompleteTagsList, getAutocompleteTaxList, parseForAutocomplete} from '@libs/SearchAutocompleteUtils'; import * as SearchQueryUtils from '@libs/SearchQueryUtils'; import Navigation from '@navigation/Navigation'; import variables from '@styles/variables'; @@ -30,7 +29,6 @@ import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Policy, PolicyCategories, PolicyTagLists} from '@src/types/onyx'; import SearchRouterInput from './SearchRouterInput'; import SearchRouterList from './SearchRouterList'; import type {ItemWithQuery} from './SearchRouterList'; @@ -40,39 +38,6 @@ type SearchRouterProps = { onRouterClose: () => void; }; -function getAutoCompleteTagsList(allPoliciesTagsLists: OnyxCollection, policyID?: string) { - const singlePolicyTagsList: PolicyTagLists | undefined = allPoliciesTagsLists?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]; - if (!singlePolicyTagsList) { - const uniqueTagNames = new Set(); - const tagListsUnpacked = Object.values(allPoliciesTagsLists ?? {}).filter((item) => !!item) as PolicyTagLists[]; - tagListsUnpacked - .map((policyTagLists) => { - return getTagNamesFromTagsLists(policyTagLists); - }) - .flat() - .forEach((tag) => uniqueTagNames.add(tag)); - return Array.from(uniqueTagNames); - } - return getTagNamesFromTagsLists(singlePolicyTagsList); -} - -function getAutocompleteCategoriesList(allPolicyCategories: OnyxCollection, policyID?: string) { - const singlePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; - if (!singlePolicyCategories) { - const uniqueCategoryNames = new Set(); - Object.values(allPolicyCategories ?? {}).map((policyCategories) => Object.values(policyCategories ?? {}).forEach((category) => uniqueCategoryNames.add(category.name))); - return Array.from(uniqueCategoryNames); - } - return Object.values(singlePolicyCategories ?? {}).map((category) => category.name); -} - -function getAutocompleteTaxList(allTaxRates: Record, policy?: OnyxEntry) { - if (policy) { - return Object.keys(policy?.taxRates?.taxes ?? {}).map((taxRateName) => taxRateName); - } - return Object.keys(allTaxRates).map((taxRateName) => taxRateName); -} - function SearchRouter({onRouterClose}: SearchRouterProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index 3a279a2ab4ea..488401c17316 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -1,4 +1,8 @@ +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {SearchAutocompleteResult} from '@components/Search/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Policy, PolicyCategories, PolicyTagLists} from '@src/types/onyx'; +import {getTagNamesFromTagsLists} from './PolicyUtils'; import * as autocompleteParser from './SearchParser/autocompleteParser'; function parseForAutocomplete(text: string) { @@ -10,5 +14,38 @@ function parseForAutocomplete(text: string) { } } +function getAutoCompleteTagsList(allPoliciesTagsLists: OnyxCollection, policyID?: string) { + const singlePolicyTagsList: PolicyTagLists | undefined = allPoliciesTagsLists?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]; + if (!singlePolicyTagsList) { + const uniqueTagNames = new Set(); + const tagListsUnpacked = Object.values(allPoliciesTagsLists ?? {}).filter((item) => !!item) as PolicyTagLists[]; + tagListsUnpacked + .map((policyTagLists) => { + return getTagNamesFromTagsLists(policyTagLists); + }) + .flat() + .forEach((tag) => uniqueTagNames.add(tag)); + return Array.from(uniqueTagNames); + } + return getTagNamesFromTagsLists(singlePolicyTagsList); +} + +function getAutocompleteCategoriesList(allPolicyCategories: OnyxCollection, policyID?: string) { + const singlePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; + if (!singlePolicyCategories) { + const uniqueCategoryNames = new Set(); + Object.values(allPolicyCategories ?? {}).map((policyCategories) => Object.values(policyCategories ?? {}).forEach((category) => uniqueCategoryNames.add(category.name))); + return Array.from(uniqueCategoryNames); + } + return Object.values(singlePolicyCategories ?? {}).map((category) => category.name); +} + +function getAutocompleteTaxList(allTaxRates: Record, policy?: OnyxEntry) { + if (policy) { + return Object.keys(policy?.taxRates?.taxes ?? {}).map((taxRateName) => taxRateName); + } + return Object.keys(allTaxRates).map((taxRateName) => taxRateName); +} + // eslint-disable-next-line import/prefer-default-export -export {parseForAutocomplete}; +export {parseForAutocomplete, getAutoCompleteTagsList, getAutocompleteCategoriesList, getAutocompleteTaxList}; From ffd299566cdd9e79816319f7b5a95645d115d215 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Wed, 23 Oct 2024 15:16:50 +0200 Subject: [PATCH 06/21] autosuggest recent categories and currencies when textinput is empty --- .../Search/SearchRouter/SearchRouter.tsx | 25 +++++++++++++------ src/libs/SearchAutocompleteUtils.ts | 18 ++++++++++--- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 30817fcf64c7..2a0b069c7d7f 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -20,7 +20,7 @@ import Log from '@libs/Log'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; -import {getAutocompleteCategoriesList, getAutoCompleteTagsList, getAutocompleteTaxList, parseForAutocomplete} from '@libs/SearchAutocompleteUtils'; +import {getAutocompleteCategories, getAutocompleteRecentCategories, getAutoCompleteTags, getAutocompleteTaxList, parseForAutocomplete} from '@libs/SearchAutocompleteUtils'; import * as SearchQueryUtils from '@libs/SearchQueryUtils'; import Navigation from '@navigation/Navigation'; import variables from '@styles/variables'; @@ -62,12 +62,6 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const typesAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES); const statusesAutocompleteList = 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 [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); - const categoryAutocompleteList = useMemo(() => getAutocompleteCategoriesList(allPolicyCategories, activeWorkspaceID), [allPolicyCategories, activeWorkspaceID]); - const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST); - const currencyAutocompleteList = Object.keys(currencyList ?? {}); - const [allPoliciesTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); - const tagAutocompleteList = useMemo(() => getAutoCompleteTagsList(allPoliciesTagsLists, activeWorkspaceID), [allPoliciesTagsLists, activeWorkspaceID]); const allTaxRates = getAllTaxRates(); const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(allTaxRates, policy), [policy, allTaxRates]); const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); @@ -78,6 +72,23 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style .map((details) => details?.login as string); + const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); + const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES); + const categoryAutocompleteList = useMemo(() => { + if (textInputValue) { + return getAutocompleteCategories(allPolicyCategories, activeWorkspaceID); + } + return getAutocompleteRecentCategories(allRecentCategories, activeWorkspaceID); + }, [textInputValue, allRecentCategories, activeWorkspaceID, allPolicyCategories]); + + const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST); + const [recentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); + const currencyAutocompleteList = useMemo(() => (textInputValue ? Object.keys(currencyList ?? {}) : recentlyUsedCurrencies ?? []), []); + + const [allPoliciesTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); + const [allRecentTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS); + const tagAutocompleteList = useMemo(() => getAutoCompleteTags(allPoliciesTagsLists, activeWorkspaceID), [allPoliciesTagsLists, activeWorkspaceID]); + const sortedRecentSearches = useMemo(() => { return Object.values(recentSearches ?? {}).sort((a, b) => b.timestamp.localeCompare(a.timestamp)); }, [recentSearches]); diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index 488401c17316..f33c4b55e52a 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -1,7 +1,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {SearchAutocompleteResult} from '@components/Search/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, PolicyCategories, PolicyTagLists} from '@src/types/onyx'; +import type {Policy, PolicyCategories, PolicyTagLists, RecentlyUsedCategories, RecentlyUsedTags} from '@src/types/onyx'; import {getTagNamesFromTagsLists} from './PolicyUtils'; import * as autocompleteParser from './SearchParser/autocompleteParser'; @@ -14,7 +14,7 @@ function parseForAutocomplete(text: string) { } } -function getAutoCompleteTagsList(allPoliciesTagsLists: OnyxCollection, policyID?: string) { +function getAutoCompleteTags(allPoliciesTagsLists: OnyxCollection, policyID?: string) { const singlePolicyTagsList: PolicyTagLists | undefined = allPoliciesTagsLists?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]; if (!singlePolicyTagsList) { const uniqueTagNames = new Set(); @@ -30,7 +30,7 @@ function getAutoCompleteTagsList(allPoliciesTagsLists: OnyxCollection, policyID?: string) { +function getAutocompleteCategories(allPolicyCategories: OnyxCollection, policyID?: string) { const singlePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; if (!singlePolicyCategories) { const uniqueCategoryNames = new Set(); @@ -40,6 +40,16 @@ function getAutocompleteCategoriesList(allPolicyCategories: OnyxCollection category.name); } +function getAutocompleteRecentCategories(allRecentCategories: OnyxCollection, policyID?: string) { + const singlePolicyRecentCategories = allRecentCategories?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`]; + if (!singlePolicyRecentCategories) { + const uniqueCategoryNames = new Set(); + Object.values(allRecentCategories ?? {}).map((policyCategories) => Object.values(policyCategories ?? {}).forEach((category) => uniqueCategoryNames.add(category))); + return Array.from(uniqueCategoryNames); + } + return Object.values(singlePolicyRecentCategories ?? {}).map((category) => category); +} + function getAutocompleteTaxList(allTaxRates: Record, policy?: OnyxEntry) { if (policy) { return Object.keys(policy?.taxRates?.taxes ?? {}).map((taxRateName) => taxRateName); @@ -48,4 +58,4 @@ function getAutocompleteTaxList(allTaxRates: Record, policy?: } // eslint-disable-next-line import/prefer-default-export -export {parseForAutocomplete, getAutoCompleteTagsList, getAutocompleteCategoriesList, getAutocompleteTaxList}; +export {parseForAutocomplete, getAutoCompleteTags, getAutocompleteCategories, getAutocompleteRecentCategories, getAutocompleteTaxList}; From fb4359bb717b7ad52e19c6dff89f8183a389652b Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Wed, 23 Oct 2024 15:21:14 +0200 Subject: [PATCH 07/21] fix pr comments --- src/libs/SearchAutocompleteUtils.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index f33c4b55e52a..d8b8499fe4a6 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -1,7 +1,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {SearchAutocompleteResult} from '@components/Search/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, PolicyCategories, PolicyTagLists, RecentlyUsedCategories, RecentlyUsedTags} from '@src/types/onyx'; +import type {Policy, PolicyCategories, PolicyTagLists, RecentlyUsedCategories} from '@src/types/onyx'; import {getTagNamesFromTagsLists} from './PolicyUtils'; import * as autocompleteParser from './SearchParser/autocompleteParser'; @@ -10,7 +10,7 @@ function parseForAutocomplete(text: string) { const parsedAutocomplete = autocompleteParser.parse(text) as SearchAutocompleteResult; return parsedAutocomplete; } catch (e) { - console.error(`Error when parsing autocopmlete}"`, e); + console.error(`Error when parsing autocopmlete query}"`, e); } } @@ -20,9 +20,7 @@ function getAutoCompleteTags(allPoliciesTagsLists: OnyxCollection(); const tagListsUnpacked = Object.values(allPoliciesTagsLists ?? {}).filter((item) => !!item) as PolicyTagLists[]; tagListsUnpacked - .map((policyTagLists) => { - return getTagNamesFromTagsLists(policyTagLists); - }) + .map(getTagNamesFromTagsLists) .flat() .forEach((tag) => uniqueTagNames.add(tag)); return Array.from(uniqueTagNames); @@ -57,5 +55,4 @@ function getAutocompleteTaxList(allTaxRates: Record, policy?: return Object.keys(allTaxRates).map((taxRateName) => taxRateName); } -// eslint-disable-next-line import/prefer-default-export export {parseForAutocomplete, getAutoCompleteTags, getAutocompleteCategories, getAutocompleteRecentCategories, getAutocompleteTaxList}; From 00d61c6b9a83ad6dc689a97cb32a008803b8cdfd Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Wed, 23 Oct 2024 16:39:42 +0200 Subject: [PATCH 08/21] autosuggest tags --- .../Search/SearchRouter/SearchRouter.tsx | 42 +++++++++++++------ src/libs/SearchAutocompleteUtils.ts | 19 +++++++-- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 2a0b069c7d7f..b516bb7a55ce 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -20,7 +20,14 @@ import Log from '@libs/Log'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; -import {getAutocompleteCategories, getAutocompleteRecentCategories, getAutoCompleteTags, getAutocompleteTaxList, parseForAutocomplete} from '@libs/SearchAutocompleteUtils'; +import { + getAutocompleteCategories, + getAutocompleteRecentCategories, + getAutocompleteRecentTags, + getAutocompleteTags, + getAutocompleteTaxList, + parseForAutocomplete, +} from '@libs/SearchAutocompleteUtils'; import * as SearchQueryUtils from '@libs/SearchQueryUtils'; import Navigation from '@navigation/Navigation'; import variables from '@styles/variables'; @@ -75,19 +82,22 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES); const categoryAutocompleteList = useMemo(() => { - if (textInputValue) { - return getAutocompleteCategories(allPolicyCategories, activeWorkspaceID); - } + return getAutocompleteCategories(allPolicyCategories, activeWorkspaceID); + }, [activeWorkspaceID, allPolicyCategories]); + const recentCategoriesAutocompleteList = useMemo(() => { return getAutocompleteRecentCategories(allRecentCategories, activeWorkspaceID); - }, [textInputValue, allRecentCategories, activeWorkspaceID, allPolicyCategories]); + }, [activeWorkspaceID, allRecentCategories]); const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST); - const [recentlyUsedCurrencies] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); - const currencyAutocompleteList = useMemo(() => (textInputValue ? Object.keys(currencyList ?? {}) : recentlyUsedCurrencies ?? []), []); + const currencyAutocompleteList = Object.keys(currencyList ?? {}); + const [recentCurrencyAutocompleteList] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); - const [allPoliciesTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); - const [allRecentTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS); - const tagAutocompleteList = useMemo(() => getAutoCompleteTags(allPoliciesTagsLists, activeWorkspaceID), [allPoliciesTagsLists, activeWorkspaceID]); + const [allPoliciesTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); + const [allRecentTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS); + const tagAutocompleteList = useMemo(() => { + return getAutocompleteTags(allPoliciesTags, activeWorkspaceID); + }, [activeWorkspaceID, allPoliciesTags]); + const recentTagsAutocompleteList = getAutocompleteRecentTags(allRecentTags, activeWorkspaceID); const sortedRecentSearches = useMemo(() => { return Object.values(recentSearches ?? {}).sort((a, b) => b.timestamp.localeCompare(a.timestamp)); @@ -148,7 +158,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { (autocompleteValue: string, autocompleteType?: ValueOf) => { switch (autocompleteType) { case 'tag': { - const filteredTags = tagAutocompleteList.filter((tag) => tag?.includes(autocompleteValue)); + const autocompleteList = autocompleteValue ? tagAutocompleteList : recentTagsAutocompleteList ?? []; + const filteredTags = autocompleteList.filter((tag) => tag?.includes(autocompleteValue)); setAutocompleteSuggestions( filteredTags.map((tagName) => ({ text: `tag:${tagName}`, @@ -158,7 +169,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { return; } case 'category': { - const filteredCategories = categoryAutocompleteList.filter((category) => category?.includes(autocompleteValue)); + const autocompleteList = autocompleteValue ? categoryAutocompleteList : recentCategoriesAutocompleteList; + const filteredCategories = autocompleteList.filter((category) => category?.includes(autocompleteValue)); setAutocompleteSuggestions( filteredCategories.map((categoryName) => ({ text: `category:${categoryName}`, @@ -168,7 +180,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { return; } case 'currency': { - const filteredCurrencies = currencyAutocompleteList.filter((currency) => currency?.includes(autocompleteValue)); + const autocompleteList = autocompleteValue ? currencyAutocompleteList : recentCurrencyAutocompleteList ?? []; + const filteredCurrencies = autocompleteList.filter((currency) => currency?.includes(autocompleteValue)); setAutocompleteSuggestions( filteredCurrencies.map((currencyName) => ({ text: `currency:${currencyName}`, @@ -228,8 +241,11 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { }, [ tagAutocompleteList, + recentTagsAutocompleteList, categoryAutocompleteList, + recentCategoriesAutocompleteList, currencyAutocompleteList, + recentCurrencyAutocompleteList, taxAutocompleteList, participantsAutocompleteList, typesAutocompleteList, diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index d8b8499fe4a6..374944154157 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -1,7 +1,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {SearchAutocompleteResult} from '@components/Search/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, PolicyCategories, PolicyTagLists, RecentlyUsedCategories} from '@src/types/onyx'; +import type {Policy, PolicyCategories, PolicyTagLists, RecentlyUsedCategories, RecentlyUsedTags} from '@src/types/onyx'; import {getTagNamesFromTagsLists} from './PolicyUtils'; import * as autocompleteParser from './SearchParser/autocompleteParser'; @@ -14,7 +14,7 @@ function parseForAutocomplete(text: string) { } } -function getAutoCompleteTags(allPoliciesTagsLists: OnyxCollection, policyID?: string) { +function getAutocompleteTags(allPoliciesTagsLists: OnyxCollection, policyID?: string) { const singlePolicyTagsList: PolicyTagLists | undefined = allPoliciesTagsLists?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]; if (!singlePolicyTagsList) { const uniqueTagNames = new Set(); @@ -28,6 +28,19 @@ function getAutoCompleteTags(allPoliciesTagsLists: OnyxCollection, policyID?: string) { + const singlePolicyRecentTags: RecentlyUsedTags | undefined = allRecentTags?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`]; + if (!singlePolicyRecentTags) { + const uniqueTagNames = new Set(); + Object.values(allRecentTags ?? {}) + .map((testVar) => Object.values(testVar ?? {})) + .flat(2) + .forEach((tag) => uniqueTagNames.add(tag)); + return Array.from(uniqueTagNames); + } + return Object.values(singlePolicyRecentTags ?? {}).flat(2); +} + function getAutocompleteCategories(allPolicyCategories: OnyxCollection, policyID?: string) { const singlePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; if (!singlePolicyCategories) { @@ -55,4 +68,4 @@ function getAutocompleteTaxList(allTaxRates: Record, policy?: return Object.keys(allTaxRates).map((taxRateName) => taxRateName); } -export {parseForAutocomplete, getAutoCompleteTags, getAutocompleteCategories, getAutocompleteRecentCategories, getAutocompleteTaxList}; +export {parseForAutocomplete, getAutocompleteTags, getAutocompleteRecentTags, getAutocompleteCategories, getAutocompleteRecentCategories, getAutocompleteTaxList}; From dc8f9935e20135e812ad06ed6be63a4de702c8d5 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Thu, 24 Oct 2024 13:47:50 +0200 Subject: [PATCH 09/21] put autocomplete suggestions into input --- src/CONST.ts | 5 ++ .../Search/SearchRouter/SearchRouter.tsx | 57 ++++++------------- .../Search/SearchRouter/SearchRouterInput.tsx | 5 -- .../Search/SearchRouter/SearchRouterList.tsx | 54 ++++++++++++------ .../Search/SearchQueryListItem.tsx | 3 + 5 files changed, 61 insertions(+), 63 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index 440f942e1244..4c2958df4e0d 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5730,6 +5730,11 @@ const CONST = { IN: 'in', }, EMPTY_VALUE: 'none', + SEARCH_ROUTER_ITEM_TYPE: { + CONTEXTUAL_SUGGESTION: 'contextualSuggestion', + AUTOCOMPLETE_SUGGESTION: 'autocompleteSuggestion', + SEARCH: 'searchItem', + }, }, REFERRER: { diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index b516bb7a55ce..c1c4bb94add7 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -1,5 +1,4 @@ import {useNavigationState} from '@react-navigation/native'; -import debounce from 'lodash/debounce'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; @@ -16,7 +15,6 @@ import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import Log from '@libs/Log'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -40,7 +38,6 @@ import SearchRouterInput from './SearchRouterInput'; import SearchRouterList from './SearchRouterList'; import type {ItemWithQuery} from './SearchRouterList'; -const SEARCH_DEBOUNCE_DELAY = 150; type SearchRouterProps = { onRouterClose: () => void; }; @@ -59,7 +56,6 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const taxRates = getAllTaxRates(); const [textInputValue, debouncedInputValue, setTextInputValue] = useDebouncedState('', 500); - const [userSearchQuery, setUserSearchQuery] = useState(undefined); const contextualReportID = useNavigationState, string | undefined>((state) => { return state?.routes.at(-1)?.params?.reportID; }); @@ -149,11 +145,6 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const contextualReportData = contextualReportID ? searchOptions.recentReports?.find((option) => option.reportID === contextualReportID) : undefined; - const clearUserQuery = () => { - setTextInputValue(''); - setUserSearchQuery(undefined); - }; - const updateAutocomplete = useCallback( (autocompleteValue: string, autocompleteType?: ValueOf) => { switch (autocompleteType) { @@ -255,40 +246,27 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { ], ); - const onSearchChange = useMemo( - // eslint-disable-next-line react-compiler/react-compiler - () => - debounce((userQuery: string) => { - if (!userQuery) { - clearUserQuery(); - listRef.current?.updateAndScrollToFocusedIndex(-1); - return; - } - const autocompleteParsedQuery = parseForAutocomplete(userQuery); - updateAutocomplete(autocompleteParsedQuery?.autocomplete?.value ?? '', autocompleteParsedQuery?.autocomplete?.key); - - listRef.current?.updateAndScrollToFocusedIndex(0); - const queryJSON = SearchQueryUtils.buildSearchQueryJSON(userQuery); - - if (queryJSON) { - setUserSearchQuery(queryJSON); - } else { - Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} user query failed to parse`, userQuery, false); - } - }, SEARCH_DEBOUNCE_DELAY), + const onSearchChange = useCallback( + (userQuery: string) => { + if (!userQuery) { + setTextInputValue(''); + listRef.current?.updateAndScrollToFocusedIndex(-1); + return; + } + setTextInputValue(userQuery); + const autocompleteParsedQuery = parseForAutocomplete(userQuery); + console.log('%%%%%\n', 'autocompleteParsedQuery', autocompleteParsedQuery); + updateAutocomplete(autocompleteParsedQuery?.autocomplete?.value ?? '', autocompleteParsedQuery?.autocomplete?.key); + listRef.current?.updateAndScrollToFocusedIndex(0); + }, // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps [updateAutocomplete], ); - const updateUserSearchQuery = (newSearchQuery: string) => { - setTextInputValue(newSearchQuery); - onSearchChange(newSearchQuery); - }; - const closeAndClearRouter = useCallback(() => { onRouterClose(); - clearUserQuery(); + setTextInputValue(''); // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps }, [onRouterClose]); @@ -302,7 +280,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const standardizedQuery = SearchQueryUtils.standardizeQueryJSON(query, cardList, taxRates); const queryString = SearchQueryUtils.buildSearchQueryString(standardizedQuery); Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: queryString})); - clearUserQuery(); + setTextInputValue(''); }, // eslint-disable-next-line react-compiler/react-compiler // eslint-disable-next-line react-hooks/exhaustive-deps @@ -328,7 +306,6 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { )} { @@ -342,13 +319,13 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { isSearchingForReports={isSearchingForReports} /> diff --git a/src/components/Search/SearchRouter/SearchRouterInput.tsx b/src/components/Search/SearchRouter/SearchRouterInput.tsx index 811c34b72a6e..c8f6c390656a 100644 --- a/src/components/Search/SearchRouter/SearchRouterInput.tsx +++ b/src/components/Search/SearchRouter/SearchRouterInput.tsx @@ -16,9 +16,6 @@ type SearchRouterInputProps = { /** Value of TextInput */ value: string; - /** Setter to TextInput value */ - setValue: (searchTerm: string) => void; - /** Callback to update search in SearchRouter */ updateSearch: (searchTerm: string) => void; @@ -58,7 +55,6 @@ type SearchRouterInputProps = { function SearchRouterInput({ value, - setValue, updateSearch, onSubmit = () => {}, routerListRef, @@ -79,7 +75,6 @@ function SearchRouterInput({ const offlineMessage: string = isOffline && shouldShowOfflineMessage ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; const onChangeText = (text: string) => { - setValue(text); updateSearch(text); }; diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index e686725ec6e0..81e61515bd03 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -29,8 +29,11 @@ type ItemWithQuery = { }; type SearchRouterListProps = { - /** currentQuery value computed coming from parsed TextInput value */ - currentQuery: SearchQueryJSON | undefined; + /** value of TextInput */ + textInputValue: string; + + /** Callback to update text input when selecting contextual or autocomplete suggestion */ + setTextInputValue: (newSearchQuery: string) => void; /** Recent searches */ recentSearches: Array | undefined; @@ -47,9 +50,6 @@ type SearchRouterListProps = { /** Context present when opening SearchRouter from a report, invoice or workspace page */ reportForContextualSearch?: OptionData; - /** Callback to update search query when selecting contextual suggestion */ - updateUserSearchQuery: (newSearchQuery: string) => void; - /** Callback to close and clear SearchRouter */ closeAndClearRouter: () => void; }; @@ -95,7 +95,16 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList } function SearchRouterList( - {currentQuery, reportForContextualSearch, recentSearches, autocompleteItems, recentReports, onSearchSubmit, updateUserSearchQuery, closeAndClearRouter}: SearchRouterListProps, + { + textInputValue, + setTextInputValue: updateTextInputValue, + reportForContextualSearch, + recentSearches, + autocompleteItems, + recentReports, + onSearchSubmit, + closeAndClearRouter, + }: SearchRouterListProps, ref: ForwardedRef, ) { const styles = useThemeStyles(); @@ -108,21 +117,22 @@ function SearchRouterList( const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); const sections: Array> = []; - if (currentQuery?.inputQuery) { + if (textInputValue) { sections.push({ data: [ { - text: currentQuery?.inputQuery, + text: textInputValue, singleIcon: Expensicons.MagnifyingGlass, - query: currentQuery?.inputQuery, + query: textInputValue, itemStyle: styles.activeComponentBG, keyForList: 'findItem', + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, }, ], }); } - if (reportForContextualSearch && !currentQuery?.inputQuery) { + if (reportForContextualSearch && !textInputValue) { sections.push({ data: [ { @@ -132,6 +142,7 @@ function SearchRouterList( itemStyle: styles.activeComponentBG, keyForList: 'contextualSearch', isContextualSearchItem: true, + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION, }, ], }); @@ -143,6 +154,7 @@ function SearchRouterList( singleIcon: Expensicons.MagnifyingGlass, query, keyForList: query, + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION, }; }); @@ -157,10 +169,11 @@ function SearchRouterList( singleIcon: Expensicons.History, query, keyForList: timestamp, + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, }; }); - if (!currentQuery?.inputQuery && recentSearchesData && recentSearchesData.length > 0) { + if (!textInputValue && recentSearchesData && recentSearchesData.length > 0) { sections.push({title: translate('search.recentSearches'), data: recentSearchesData}); } @@ -170,16 +183,21 @@ function SearchRouterList( const onSelectRow = useCallback( (item: OptionData | SearchQueryItem) => { if (isSearchQueryItem(item)) { - if (item.isContextualSearchItem) { - // Handle selection of "Contextual search suggestion" - updateUserSearchQuery(`${item?.query} ${currentQuery?.inputQuery ?? ''}`); + if (!item?.query) { return; } - - // Handle selection of "Recent search" - if (!item?.query) { + if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { + updateTextInputValue(`${item?.query} `); + return; + } + if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { + const lastColonIndex = textInputValue.lastIndexOf(':'); + const lastComaIndex = textInputValue.lastIndexOf(','); + const trimmedUserSearchQuery = lastColonIndex > lastComaIndex ? textInputValue.slice(0, lastColonIndex + 1) : textInputValue.slice(0, lastComaIndex + 1); + updateTextInputValue(`${trimmedUserSearchQuery}${item?.query} `); return; } + onSearchSubmit(SearchQueryUtils.buildSearchQueryJSON(item?.query)); } @@ -191,7 +209,7 @@ function SearchRouterList( Report.navigateToAndOpenReport(item.login ? [item.login] : [], false); } }, - [closeAndClearRouter, onSearchSubmit, currentQuery, updateUserSearchQuery], + [closeAndClearRouter, textInputValue, onSearchSubmit, updateTextInputValue], ); return ( diff --git a/src/components/SelectionList/Search/SearchQueryListItem.tsx b/src/components/SelectionList/Search/SearchQueryListItem.tsx index 369f527cdeba..cf1b75d95f17 100644 --- a/src/components/SelectionList/Search/SearchQueryListItem.tsx +++ b/src/components/SelectionList/Search/SearchQueryListItem.tsx @@ -1,17 +1,20 @@ import React from 'react'; import {View} from 'react-native'; +import type {ValueOf} from 'type-fest'; import Icon from '@components/Icon'; import BaseListItem from '@components/SelectionList/BaseListItem'; import type {ListItem} from '@components/SelectionList/types'; import TextWithTooltip from '@components/TextWithTooltip'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import type CONST from '@src/CONST'; import type IconAsset from '@src/types/utils/IconAsset'; type SearchQueryItem = ListItem & { singleIcon?: IconAsset; query?: string; isContextualSearchItem?: boolean; + searchItemType: ValueOf; }; type SearchQueryListItemProps = { From 8cf4690ba2ce7047b4d8d7ef5f9aa1b984b21cee Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Thu, 24 Oct 2024 14:32:02 +0200 Subject: [PATCH 10/21] ignore already autocompleted values --- .../Search/SearchRouter/SearchRouter.tsx | 34 ++++++++++++------- src/components/Search/types.ts | 16 +++++---- src/libs/SearchParser/autocompleteParser.js | 3 +- .../SearchParser/autocompleteParser.peggy | 3 +- 4 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index c1c4bb94add7..aa69195891df 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -6,7 +6,7 @@ import type {ValueOf} from 'type-fest'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import {usePersonalDetails} from '@components/OnyxProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; -import type {SearchQueryJSON} from '@components/Search/types'; +import type {AutocompleteRange, SearchQueryJSON} from '@components/Search/types'; import type {SelectionListHandle} from '@components/SelectionList/types'; import useActiveWorkspaceFromNavigationState from '@hooks/useActiveWorkspaceFromNavigationState'; import useDebouncedState from '@hooks/useDebouncedState'; @@ -146,11 +146,18 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const contextualReportData = contextualReportID ? searchOptions.recentReports?.find((option) => option.reportID === contextualReportID) : undefined; const updateAutocomplete = useCallback( - (autocompleteValue: string, autocompleteType?: ValueOf) => { + (autocompleteValue: string, ranges: AutocompleteRange[], autocompleteType: ValueOf) => { + const alreadyAutocompletedKeys: string[] = []; + ranges.forEach((range) => { + if (range.key !== autocompleteType) { + return; + } + alreadyAutocompletedKeys.push(range.value); + }); switch (autocompleteType) { case 'tag': { const autocompleteList = autocompleteValue ? tagAutocompleteList : recentTagsAutocompleteList ?? []; - const filteredTags = autocompleteList.filter((tag) => tag?.includes(autocompleteValue)); + const filteredTags = autocompleteList.filter((tag) => tag?.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(tag)); setAutocompleteSuggestions( filteredTags.map((tagName) => ({ text: `tag:${tagName}`, @@ -161,7 +168,9 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } case 'category': { const autocompleteList = autocompleteValue ? categoryAutocompleteList : recentCategoriesAutocompleteList; - const filteredCategories = autocompleteList.filter((category) => category?.includes(autocompleteValue)); + const filteredCategories = autocompleteList.filter((category) => { + return category?.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(category); + }); setAutocompleteSuggestions( filteredCategories.map((categoryName) => ({ text: `category:${categoryName}`, @@ -172,7 +181,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } case 'currency': { const autocompleteList = autocompleteValue ? currencyAutocompleteList : recentCurrencyAutocompleteList ?? []; - const filteredCurrencies = autocompleteList.filter((currency) => currency?.includes(autocompleteValue)); + const filteredCurrencies = autocompleteList.filter((currency) => currency?.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(currency)); setAutocompleteSuggestions( filteredCurrencies.map((currencyName) => ({ text: `currency:${currencyName}`, @@ -182,7 +191,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { return; } case 'taxRate': { - const filteredTaxRates = taxAutocompleteList.filter((tax) => tax.includes(autocompleteValue)); + const filteredTaxRates = taxAutocompleteList.filter((tax) => tax.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(tax)); setAutocompleteSuggestions(filteredTaxRates.map((tax) => ({text: `type:${tax}`, query: `${tax}`}))); return; } @@ -197,17 +206,17 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { return; } case 'type': { - const filteredTypes = typesAutocompleteList.filter((type) => type.includes(autocompleteValue)); + const filteredTypes = typesAutocompleteList.filter((type) => type.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(type)); setAutocompleteSuggestions(filteredTypes.map((type) => ({text: `type:${type}`, query: `${type}`}))); return; } case 'status': { - const filteredStatuses = statusesAutocompleteList.filter((status) => status.includes(autocompleteValue)); + const filteredStatuses = statusesAutocompleteList.filter((status) => status.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(status)); setAutocompleteSuggestions(filteredStatuses.map((status) => ({text: `status:${status}`, query: `${status}`}))); return; } case 'expenseType': { - const filteredExpenseTypes = expenseTypes.filter((expenseType) => expenseType.includes(autocompleteValue)); + const filteredExpenseTypes = expenseTypes.filter((expenseType) => expenseType.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(expenseType)); setAutocompleteSuggestions( filteredExpenseTypes.map((expenseType) => ({ text: `expenseType:${expenseType}`, @@ -217,7 +226,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { return; } case 'cardID': { - const filteredCards = cardsAutocompleteList.filter((card) => card.includes(autocompleteValue)); + const filteredCards = cardsAutocompleteList.filter((card) => card.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(card)); setAutocompleteSuggestions( filteredCards.map((card) => ({ text: `expenseType:${card}`, @@ -255,8 +264,9 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } setTextInputValue(userQuery); const autocompleteParsedQuery = parseForAutocomplete(userQuery); - console.log('%%%%%\n', 'autocompleteParsedQuery', autocompleteParsedQuery); - updateAutocomplete(autocompleteParsedQuery?.autocomplete?.value ?? '', autocompleteParsedQuery?.autocomplete?.key); + if (autocompleteParsedQuery?.autocomplete) { + updateAutocomplete(autocompleteParsedQuery.autocomplete.value ?? '', autocompleteParsedQuery.ranges, autocompleteParsedQuery.autocomplete.key); + } listRef.current?.updateAndScrollToFocusedIndex(0); }, // eslint-disable-next-line react-compiler/react-compiler diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 9b755653c0cf..ecc83ce4b7b5 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -79,13 +79,16 @@ type SearchQueryJSON = { flatFilters: QueryFilters; } & SearchQueryAST; +type AutocompleteRange = { + key: ValueOf; + length: number; + start: number; + value: string; +}; + type SearchAutocompleteResult = { - autocomplete: { - key: ValueOf; - length: number; - start: number; - value: string; - }; + autocomplete: AutocompleteRange; + ranges: AutocompleteRange[]; }; export type { @@ -107,4 +110,5 @@ export type { TripSearchStatus, ChatSearchStatus, SearchAutocompleteResult, + AutocompleteRange, }; diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index a9c8870e2f79..00b79574b81b 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -241,8 +241,9 @@ function peg$parse(input, options) { ...value[value.length - 1], }; - return value.map(({ start, length }) => ({ + return value.map(({ start, value, length }) => ({ key, + value, start, length, })); diff --git a/src/libs/SearchParser/autocompleteParser.peggy b/src/libs/SearchParser/autocompleteParser.peggy index 003d35485d69..597c7d7a921e 100644 --- a/src/libs/SearchParser/autocompleteParser.peggy +++ b/src/libs/SearchParser/autocompleteParser.peggy @@ -39,8 +39,9 @@ defaultFilter ...value[value.length - 1], }; - return value.map(({ start, length }) => ({ + return value.map(({ start, value, length }) => ({ key, + value, start, length, })); From 3354af20912eaf346a92b43bffd945ac6cf86b49 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Thu, 24 Oct 2024 15:12:31 +0200 Subject: [PATCH 11/21] autocomplete "in:" key --- src/components/Search/SearchRouter/SearchRouter.tsx | 6 ++++++ src/components/Search/SearchRouter/SearchRouterList.tsx | 5 +++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index aa69195891df..a5d459cb1aae 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -205,6 +205,11 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `to:${participant}`, query: `${participant}`}))); return; } + case 'in': { + const filteredChats = searchOptions.recentReports.filter((chat) => chat.text?.includes(autocompleteValue)); + setAutocompleteSuggestions(filteredChats.map((chat) => ({text: `in:${chat.text}`, query: `${chat.reportID}`}))); + return; + } case 'type': { const filteredTypes = typesAutocompleteList.filter((type) => type.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(type)); setAutocompleteSuggestions(filteredTypes.map((type) => ({text: `type:${type}`, query: `${type}`}))); @@ -248,6 +253,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { recentCurrencyAutocompleteList, taxAutocompleteList, participantsAutocompleteList, + recentReports, typesAutocompleteList, statusesAutocompleteList, expenseTypes, diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 81e61515bd03..236c895f9efe 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -25,6 +25,7 @@ import ROUTES from '@src/ROUTES'; type ItemWithQuery = { query: string; + id?: string; text?: string; }; @@ -192,8 +193,8 @@ function SearchRouterList( } if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { const lastColonIndex = textInputValue.lastIndexOf(':'); - const lastComaIndex = textInputValue.lastIndexOf(','); - const trimmedUserSearchQuery = lastColonIndex > lastComaIndex ? textInputValue.slice(0, lastColonIndex + 1) : textInputValue.slice(0, lastComaIndex + 1); + const lastCommaIndex = textInputValue.lastIndexOf(','); + const trimmedUserSearchQuery = lastColonIndex > lastCommaIndex ? textInputValue.slice(0, lastColonIndex + 1) : textInputValue.slice(0, lastCommaIndex + 1); updateTextInputValue(`${trimmedUserSearchQuery}${item?.query} `); return; } From 3ec1802b546b675ba32789467260ea34476453e9 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Thu, 24 Oct 2024 15:20:25 +0200 Subject: [PATCH 12/21] fix SearchRouterInput bug --- src/components/Search/SearchPageHeader.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 65d86005207c..00e07b0406b9 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -73,9 +73,8 @@ function HeaderWrapper({icon, children, text, value, isCannedQuery, onSubmit, se {}} + updateSearch={setValue} autoFocus={false} isFullWidth wrapperStyle={[styles.searchRouterInputResults, styles.br2]} From 94c259fba61612f74afefbbedf24649f23a566d9 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Thu, 24 Oct 2024 16:51:39 +0200 Subject: [PATCH 13/21] fix PR comments --- .../Search/SearchRouter/SearchRouter.tsx | 107 ++++++++---------- .../Search/SearchRouter/SearchRouterInput.tsx | 6 +- .../Search/SearchRouter/SearchRouterList.tsx | 11 +- src/components/Search/types.ts | 2 +- .../Search/SearchQueryListItem.tsx | 1 - src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/libs/SearchParser/autocompleteParser.js | 9 +- .../SearchParser/autocompleteParser.peggy | 3 +- src/libs/SearchParser/baseRules.peggy | 4 +- 10 files changed, 62 insertions(+), 83 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index a5d459cb1aae..23d3b03efbb8 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -53,8 +53,6 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const {shouldUseNarrowLayout} = useResponsiveLayout(); const listRef = useRef(null); - const taxRates = getAllTaxRates(); - const [textInputValue, debouncedInputValue, setTextInputValue] = useDebouncedState('', 500); const contextualReportID = useNavigationState, string | undefined>((state) => { return state?.routes.at(-1)?.params?.reportID; @@ -62,13 +60,13 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const activeWorkspaceID = useActiveWorkspaceFromNavigationState(); const policy = usePolicy(activeWorkspaceID); - const typesAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES); - const statusesAutocompleteList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP}); + const typeAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES); + const statusAutocompleteList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP}); const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE); const allTaxRates = getAllTaxRates(); const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(allTaxRates, policy), [policy, allTaxRates]); const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); - const cardsAutocompleteList = Object.values(cardList ?? {}).map((card) => card.bank); + const cardAutocompleteList = Object.values(cardList ?? {}).map((card) => card.bank); const personalDetails = usePersonalDetails(); const participantsAutocompleteList = Object.values(personalDetails) .filter((details) => details && details?.login) @@ -146,102 +144,103 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const contextualReportData = contextualReportID ? searchOptions.recentReports?.find((option) => option.reportID === contextualReportID) : undefined; const updateAutocomplete = useCallback( - (autocompleteValue: string, ranges: AutocompleteRange[], autocompleteType: ValueOf) => { + (autocompleteValue: string, ranges: AutocompleteRange[], autocompleteType?: ValueOf) => { const alreadyAutocompletedKeys: string[] = []; ranges.forEach((range) => { - if (range.key !== autocompleteType) { + if (!autocompleteType || range.key !== autocompleteType) { return; } alreadyAutocompletedKeys.push(range.value); }); switch (autocompleteType) { - case 'tag': { + case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG: { const autocompleteList = autocompleteValue ? tagAutocompleteList : recentTagsAutocompleteList ?? []; const filteredTags = autocompleteList.filter((tag) => tag?.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(tag)); setAutocompleteSuggestions( filteredTags.map((tagName) => ({ - text: `tag:${tagName}`, + text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG}:${tagName}`, query: `${tagName}`, })), ); return; } - case 'category': { + case CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY: { const autocompleteList = autocompleteValue ? categoryAutocompleteList : recentCategoriesAutocompleteList; const filteredCategories = autocompleteList.filter((category) => { return category?.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(category); }); setAutocompleteSuggestions( filteredCategories.map((categoryName) => ({ - text: `category:${categoryName}`, + text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY}:${categoryName}`, query: `${categoryName}`, })), ); return; } - case 'currency': { + case CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY: { const autocompleteList = autocompleteValue ? currencyAutocompleteList : recentCurrencyAutocompleteList ?? []; const filteredCurrencies = autocompleteList.filter((currency) => currency?.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(currency)); setAutocompleteSuggestions( filteredCurrencies.map((currencyName) => ({ - text: `currency:${currencyName}`, + text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY}:${currencyName}`, query: `${currencyName}`, })), ); return; } - case 'taxRate': { + case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE: { const filteredTaxRates = taxAutocompleteList.filter((tax) => tax.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(tax)); - setAutocompleteSuggestions(filteredTaxRates.map((tax) => ({text: `type:${tax}`, query: `${tax}`}))); + setAutocompleteSuggestions(filteredTaxRates.map((tax) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE}:${tax}`, query: `${tax}`}))); return; } - case 'from': { + case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: { const filteredParticipants = participantsAutocompleteList.filter((participant) => participant.includes(autocompleteValue)); - setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `from:${participant}`, query: `${participant}`}))); + setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM}:${participant}`, query: `${participant}`}))); return; } - case 'to': { + case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: { const filteredParticipants = participantsAutocompleteList.filter((participant) => participant.includes(autocompleteValue)); - setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `to:${participant}`, query: `${participant}`}))); + setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${participant}`, query: `${participant}`}))); return; } - case 'in': { + case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN: { const filteredChats = searchOptions.recentReports.filter((chat) => chat.text?.includes(autocompleteValue)); - setAutocompleteSuggestions(filteredChats.map((chat) => ({text: `in:${chat.text}`, query: `${chat.reportID}`}))); + setAutocompleteSuggestions(filteredChats.map((chat) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${chat.text}`, query: `${chat.reportID}`}))); return; } - case 'type': { - const filteredTypes = typesAutocompleteList.filter((type) => type.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(type)); - setAutocompleteSuggestions(filteredTypes.map((type) => ({text: `type:${type}`, query: `${type}`}))); + case CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE: { + const filteredTypes = typeAutocompleteList.filter((type) => type.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(type)); + setAutocompleteSuggestions(filteredTypes.map((type) => ({text: `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${type}`, query: `${type}`}))); return; } - case 'status': { - const filteredStatuses = statusesAutocompleteList.filter((status) => status.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(status)); - setAutocompleteSuggestions(filteredStatuses.map((status) => ({text: `status:${status}`, query: `${status}`}))); + case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: { + const filteredStatuses = statusAutocompleteList.filter((status) => status.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(status)); + setAutocompleteSuggestions(filteredStatuses.map((status) => ({text: `${CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS}:${status}`, query: `${status}`}))); return; } - case 'expenseType': { + case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE: { const filteredExpenseTypes = expenseTypes.filter((expenseType) => expenseType.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(expenseType)); setAutocompleteSuggestions( filteredExpenseTypes.map((expenseType) => ({ - text: `expenseType:${expenseType}`, + text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE}:${expenseType}`, query: `${expenseType}`, })), ); return; } - case 'cardID': { - const filteredCards = cardsAutocompleteList.filter((card) => card.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(card)); + case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID: { + const filteredCards = cardAutocompleteList.filter((card) => card.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(card)); setAutocompleteSuggestions( filteredCards.map((card) => ({ - text: `expenseType:${card}`, + text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID}:${card}`, query: `${card}`, })), ); return; } - default: + default: { setAutocompleteSuggestions(undefined); + } } }, [ @@ -253,58 +252,44 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { recentCurrencyAutocompleteList, taxAutocompleteList, participantsAutocompleteList, - recentReports, - typesAutocompleteList, - statusesAutocompleteList, + searchOptions.recentReports, + typeAutocompleteList, + statusAutocompleteList, expenseTypes, - cardsAutocompleteList, + cardAutocompleteList, ], ); const onSearchChange = useCallback( (userQuery: string) => { - if (!userQuery) { - setTextInputValue(''); - listRef.current?.updateAndScrollToFocusedIndex(-1); - return; - } setTextInputValue(userQuery); const autocompleteParsedQuery = parseForAutocomplete(userQuery); - if (autocompleteParsedQuery?.autocomplete) { - updateAutocomplete(autocompleteParsedQuery.autocomplete.value ?? '', autocompleteParsedQuery.ranges, autocompleteParsedQuery.autocomplete.key); + updateAutocomplete(autocompleteParsedQuery?.autocomplete?.value ?? '', autocompleteParsedQuery?.ranges ?? [], autocompleteParsedQuery?.autocomplete?.key); + if (userQuery) { + listRef.current?.updateAndScrollToFocusedIndex(0); + } else { + listRef.current?.updateAndScrollToFocusedIndex(-1); } - listRef.current?.updateAndScrollToFocusedIndex(0); }, - // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/exhaustive-deps - [updateAutocomplete], + [setTextInputValue, updateAutocomplete], ); - const closeAndClearRouter = useCallback(() => { - onRouterClose(); - setTextInputValue(''); - // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [onRouterClose]); - const onSearchSubmit = useCallback( (query: SearchQueryJSON | undefined) => { if (!query) { return; } onRouterClose(); - const standardizedQuery = SearchQueryUtils.standardizeQueryJSON(query, cardList, taxRates); + const standardizedQuery = SearchQueryUtils.standardizeQueryJSON(query, cardList, allTaxRates); const queryString = SearchQueryUtils.buildSearchQueryString(standardizedQuery); Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: queryString})); setTextInputValue(''); }, - // eslint-disable-next-line react-compiler/react-compiler - // eslint-disable-next-line react-hooks/exhaustive-deps - [onRouterClose], + [allTaxRates, cardList, onRouterClose, setTextInputValue], ); useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => { - closeAndClearRouter(); + onRouterClose(); }); const modalWidth = shouldUseNarrowLayout ? styles.w100 : {width: variables.searchRouterPopoverWidth}; @@ -342,7 +327,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { recentReports={recentReports} autocompleteItems={autocompleteSuggestions} onSearchSubmit={onSearchSubmit} - closeAndClearRouter={closeAndClearRouter} + closeRouter={onRouterClose} ref={listRef} /> diff --git a/src/components/Search/SearchRouter/SearchRouterInput.tsx b/src/components/Search/SearchRouter/SearchRouterInput.tsx index c8f6c390656a..6bc782f3d44a 100644 --- a/src/components/Search/SearchRouter/SearchRouterInput.tsx +++ b/src/components/Search/SearchRouter/SearchRouterInput.tsx @@ -74,10 +74,6 @@ function SearchRouterInput({ const {isOffline} = useNetwork(); const offlineMessage: string = isOffline && shouldShowOfflineMessage ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; - const onChangeText = (text: string) => { - updateSearch(text); - }; - const inputWidth = isFullWidth ? styles.w100 : {width: variables.popoverWidth}; return ( @@ -87,7 +83,7 @@ function SearchRouterInput({ void; + closeRouter: () => void; }; const setPerformanceTimersEnd = () => { @@ -104,7 +104,7 @@ function SearchRouterList( autocompleteItems, recentReports, onSearchSubmit, - closeAndClearRouter, + closeRouter, }: SearchRouterListProps, ref: ForwardedRef, ) { @@ -142,7 +142,6 @@ function SearchRouterList( query: getContextualSearchQuery(reportForContextualSearch.reportID), itemStyle: styles.activeComponentBG, keyForList: 'contextualSearch', - isContextualSearchItem: true, searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION, }, ], @@ -160,7 +159,7 @@ function SearchRouterList( }); if (autocompleteData && autocompleteData.length > 0) { - sections.push({title: 'Autocomplete', data: autocompleteData}); + sections.push({title: translate('search.autocomplete'), data: autocompleteData}); } const recentSearchesData = recentSearches?.map(({query, timestamp}) => { @@ -203,14 +202,14 @@ function SearchRouterList( } // Handle selection of "Recent chat" - closeAndClearRouter(); + closeRouter(); if ('reportID' in item && item?.reportID) { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(item?.reportID)); } else if ('login' in item) { Report.navigateToAndOpenReport(item.login ? [item.login] : [], false); } }, - [closeAndClearRouter, textInputValue, onSearchSubmit, updateTextInputValue], + [closeRouter, textInputValue, onSearchSubmit, updateTextInputValue], ); return ( diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index ecc83ce4b7b5..89da0053dbe8 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -87,7 +87,7 @@ type AutocompleteRange = { }; type SearchAutocompleteResult = { - autocomplete: AutocompleteRange; + autocomplete: AutocompleteRange | null; ranges: AutocompleteRange[]; }; diff --git a/src/components/SelectionList/Search/SearchQueryListItem.tsx b/src/components/SelectionList/Search/SearchQueryListItem.tsx index cf1b75d95f17..e4e496474a5b 100644 --- a/src/components/SelectionList/Search/SearchQueryListItem.tsx +++ b/src/components/SelectionList/Search/SearchQueryListItem.tsx @@ -13,7 +13,6 @@ import type IconAsset from '@src/types/utils/IconAsset'; type SearchQueryItem = ListItem & { singleIcon?: IconAsset; query?: string; - isContextualSearchItem?: boolean; searchItemType: ValueOf; }; diff --git a/src/languages/en.ts b/src/languages/en.ts index 8b9569dc1267..0485b7ed17e9 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4356,6 +4356,7 @@ const translations = { recentChats: 'Recent chats', searchIn: 'Search in', searchPlaceholder: 'Search for something', + autocomplete: 'Autocomplete', }, genericErrorPage: { title: 'Uh-oh, something went wrong!', diff --git a/src/languages/es.ts b/src/languages/es.ts index b7f66ef2bec0..fb33fc7f40aa 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4404,6 +4404,7 @@ const translations = { recentChats: 'Chats recientes', searchIn: 'Buscar en', searchPlaceholder: 'Busca algo', + autocomplete: 'Autocompletar', }, genericErrorPage: { title: '¡Oh-oh, algo salió mal!', diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index 00b79574b81b..3ac52b591458 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -194,7 +194,7 @@ function peg$parse(input, options) { var peg$r0 = /^[:=]/; var peg$r1 = /^[^"\r\n]/; - var peg$r2 = /^[A-Za-z0-9_@.\/#&+\-\\',;%]/; + var peg$r2 = /^[^ "\t\n\r]/; var peg$r3 = /^[ \t\r\n]/; var peg$e0 = peg$otherExpectation("key"); @@ -219,7 +219,7 @@ function peg$parse(input, options) { var peg$e19 = peg$literalExpectation("\"", false); var peg$e20 = peg$classExpectation(["\"", "\r", "\n"], true, false); var peg$e21 = peg$otherExpectation("word"); - var peg$e22 = peg$classExpectation([["A", "Z"], ["a", "z"], ["0", "9"], "_", "@", ".", "/", "#", "&", "+", "-", "\\", "'", ",", ";", "%"], false, false); + var peg$e22 = peg$classExpectation([" ", "\"", "\t", "\n", "\r"], true, false); var peg$e23 = peg$otherExpectation("whitespace"); var peg$e24 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); @@ -240,8 +240,7 @@ function peg$parse(input, options) { key, ...value[value.length - 1], }; - - return value.map(({ start, value, length }) => ({ + return value.filter((filter) => filter.length > 0).map(({ start, value, length }) => ({ key, value, start, @@ -272,7 +271,7 @@ function peg$parse(input, options) { var peg$f10 = function() { return "lt"; }; var peg$f11 = function(chars) { return chars.join(""); }; var peg$f12 = function(chars) { - return chars.join("").trim().split(",").filter(Boolean); + return chars.join("").trim().split(","); }; var peg$f13 = function() { return "and"; }; var peg$currPos = options.peg$currPos | 0; diff --git a/src/libs/SearchParser/autocompleteParser.peggy b/src/libs/SearchParser/autocompleteParser.peggy index 597c7d7a921e..78b237d5611b 100644 --- a/src/libs/SearchParser/autocompleteParser.peggy +++ b/src/libs/SearchParser/autocompleteParser.peggy @@ -38,8 +38,7 @@ defaultFilter key, ...value[value.length - 1], }; - - return value.map(({ start, value, length }) => ({ + return value.filter((filter) => filter.length > 0).map(({ start, value, length }) => ({ key, value, start, diff --git a/src/libs/SearchParser/baseRules.peggy b/src/libs/SearchParser/baseRules.peggy index 62948fdb573b..bd55a734d44c 100644 --- a/src/libs/SearchParser/baseRules.peggy +++ b/src/libs/SearchParser/baseRules.peggy @@ -19,8 +19,8 @@ operator "operator" quotedString "quote" = "\"" chars:[^"\r\n]* "\"" { return chars.join(""); } alphanumeric "word" - = chars:[A-Za-z0-9_@./#&+\-\\',;%]+ { - return chars.join("").trim().split(",").filter(Boolean); + = chars:[^ "\t\n\r]+ { + return chars.join("").trim().split(","); } logicalAnd = _ { return "and"; } From 4002093493931849f45c3620eb87a8b0685d9d1e Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Fri, 25 Oct 2024 12:13:21 +0200 Subject: [PATCH 14/21] add arrowSelectionAutocomplete logic --- .../Search/SearchRouter/SearchRouter.tsx | 3 +- .../Search/SearchRouter/SearchRouterList.tsx | 32 +++++++++++++++++-- .../SelectionList/BaseSelectionList.tsx | 5 +++ src/components/SelectionList/types.ts | 3 ++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 23d3b03efbb8..78a13a0f40f2 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -321,7 +321,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { /> void; + /** Callback to update text input value along with autocomplete suggestions */ + updateSearchValue: (newValue: string) => void; + + /** Callback to update text input value */ + setTextInputValue: (text: string) => void; /** Recent searches */ recentSearches: Array | undefined; @@ -98,7 +101,8 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList function SearchRouterList( { textInputValue, - setTextInputValue: updateTextInputValue, + updateSearchValue: updateTextInputValue, + setTextInputValue, reportForContextualSearch, recentSearches, autocompleteItems, @@ -212,6 +216,26 @@ function SearchRouterList( [closeRouter, textInputValue, onSearchSubmit, updateTextInputValue], ); + const onArrowFocus = useCallback( + (focusedItem: OptionData | SearchQueryItem) => { + if (!isSearchQueryItem(focusedItem) || focusedItem.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !textInputValue) { + return; + } + const lastColonIndex = textInputValue.lastIndexOf(':'); + const lastCommaIndex = textInputValue.lastIndexOf(','); + const trimmedUserSearchQuery = lastColonIndex > lastCommaIndex ? textInputValue.slice(0, lastColonIndex + 1) : textInputValue.slice(0, lastCommaIndex + 1); + setTextInputValue(`${trimmedUserSearchQuery}${focusedItem?.query} `); + }, + [setTextInputValue, textInputValue], + ); + + const getItemHeight = useCallback((item: OptionData | SearchQueryItem) => { + if (isSearchQueryItem(item)) { + return 44; + } + return 64; + }, []); + return ( sections={sections} @@ -220,11 +244,13 @@ function SearchRouterList( containerStyle={[styles.mh100]} sectionListStyle={[shouldUseNarrowLayout ? styles.ph5 : styles.ph2, styles.pb2]} listItemWrapperStyle={[styles.pr3, styles.pl3]} + getItemHeight={getItemHeight} onLayout={setPerformanceTimersEnd} ref={ref} showScrollIndicator={!shouldUseNarrowLayout} sectionTitleStyles={styles.mhn2} shouldSingleExecuteRowSelect + onArrowFocus={onArrowFocus} /> ); } diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 57423992e43e..3e1b3a3c2d70 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -97,6 +97,7 @@ function BaseSelectionList( updateCellsBatchingPeriod = 50, removeClippedSubviews = true, shouldDelayFocus = true, + onArrowFocus = () => {}, shouldUpdateFocusedIndex = false, onLongPressRow, shouldShowTextInput = !!textInputLabel || !!textInputIconLeft, @@ -281,6 +282,10 @@ function BaseSelectionList( disabledIndexes: disabledArrowKeyIndexes, isActive: isFocused, onFocusedIndexChange: (index: number) => { + const focusedItem = flattenedSections.allOptions.at(index); + if (focusedItem) { + onArrowFocus(focusedItem); + } scrollToIndex(index, true); }, isFocused, diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 13a90d13d465..5eecd2cde2ef 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -511,6 +511,9 @@ type BaseSelectionListProps = Partial & { /** Whether focus event should be delayed */ shouldDelayFocus?: boolean; + /** Callback to fire when the text input changes */ + onArrowFocus?: (focusedItem: TItem) => void; + /** Whether to show the loading indicator for new options */ isLoadingNewOptions?: boolean; From 0dc345c17ed2101e3f08afd79429908e08316480 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Fri, 25 Oct 2024 12:39:20 +0200 Subject: [PATCH 15/21] sort autocomplete values --- .../Search/SearchRouter/SearchRouter.tsx | 53 ++++++++++++++----- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 78a13a0f40f2..22c454382c67 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -155,7 +155,10 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { switch (autocompleteType) { case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG: { const autocompleteList = autocompleteValue ? tagAutocompleteList : recentTagsAutocompleteList ?? []; - const filteredTags = autocompleteList.filter((tag) => tag?.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(tag)); + const filteredTags = autocompleteList + .filter((tag) => tag?.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(tag)) + .sort() + .slice(0, 10); setAutocompleteSuggestions( filteredTags.map((tagName) => ({ text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG}:${tagName}`, @@ -166,9 +169,12 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } case CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY: { const autocompleteList = autocompleteValue ? categoryAutocompleteList : recentCategoriesAutocompleteList; - const filteredCategories = autocompleteList.filter((category) => { - return category?.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(category); - }); + const filteredCategories = autocompleteList + .filter((category) => { + return category?.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(category); + }) + .sort() + .slice(0, 10); setAutocompleteSuggestions( filteredCategories.map((categoryName) => ({ text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY}:${categoryName}`, @@ -179,7 +185,10 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } case CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY: { const autocompleteList = autocompleteValue ? currencyAutocompleteList : recentCurrencyAutocompleteList ?? []; - const filteredCurrencies = autocompleteList.filter((currency) => currency?.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(currency)); + const filteredCurrencies = autocompleteList + .filter((currency) => currency?.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(currency)) + .sort() + .slice(0, 10); setAutocompleteSuggestions( filteredCurrencies.map((currencyName) => ({ text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY}:${currencyName}`, @@ -189,37 +198,52 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { return; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE: { - const filteredTaxRates = taxAutocompleteList.filter((tax) => tax.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(tax)); + const filteredTaxRates = taxAutocompleteList + .filter((tax) => tax.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(tax)) + .sort() + .slice(0, 10); setAutocompleteSuggestions(filteredTaxRates.map((tax) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE}:${tax}`, query: `${tax}`}))); return; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: { - const filteredParticipants = participantsAutocompleteList.filter((participant) => participant.includes(autocompleteValue)); + const filteredParticipants = participantsAutocompleteList + .filter((participant) => participant.includes(autocompleteValue)) + .sort() + .slice(0, 10); setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM}:${participant}`, query: `${participant}`}))); return; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: { - const filteredParticipants = participantsAutocompleteList.filter((participant) => participant.includes(autocompleteValue)); + const filteredParticipants = participantsAutocompleteList + .filter((participant) => participant.includes(autocompleteValue)) + .sort() + .slice(0, 10); setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${participant}`, query: `${participant}`}))); return; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN: { - const filteredChats = searchOptions.recentReports.filter((chat) => chat.text?.includes(autocompleteValue)); + const filteredChats = searchOptions.recentReports + .filter((chat) => chat.text?.includes(autocompleteValue)) + .sort((chatA, chatB) => (chatA > chatB ? 1 : -1)) + .slice(0, 10); setAutocompleteSuggestions(filteredChats.map((chat) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${chat.text}`, query: `${chat.reportID}`}))); return; } case CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE: { - const filteredTypes = typeAutocompleteList.filter((type) => type.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(type)); + const filteredTypes = typeAutocompleteList.filter((type) => type.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(type)).sort(); setAutocompleteSuggestions(filteredTypes.map((type) => ({text: `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${type}`, query: `${type}`}))); return; } case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: { - const filteredStatuses = statusAutocompleteList.filter((status) => status.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(status)); + const filteredStatuses = statusAutocompleteList + .filter((status) => status.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(status)) + .sort() + .slice(0, 10); setAutocompleteSuggestions(filteredStatuses.map((status) => ({text: `${CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS}:${status}`, query: `${status}`}))); return; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE: { - const filteredExpenseTypes = expenseTypes.filter((expenseType) => expenseType.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(expenseType)); + const filteredExpenseTypes = expenseTypes.filter((expenseType) => expenseType.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(expenseType)).sort(); setAutocompleteSuggestions( filteredExpenseTypes.map((expenseType) => ({ text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE}:${expenseType}`, @@ -229,7 +253,10 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { return; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID: { - const filteredCards = cardAutocompleteList.filter((card) => card.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(card)); + const filteredCards = cardAutocompleteList + .filter((card) => card.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(card)) + .sort() + .slice(0, 10); setAutocompleteSuggestions( filteredCards.map((card) => ({ text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID}:${card}`, From 11ee9da136ec9d0e607e4ec713744c5050253d3a Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Fri, 25 Oct 2024 12:52:08 +0200 Subject: [PATCH 16/21] clean up code --- .../Search/SearchRouter/SearchRouterList.tsx | 27 +++++-------------- src/libs/SearchAutocompleteUtils.ts | 17 +++++++++++- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 53835ddf45b8..e746baa1325d 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -16,6 +16,7 @@ import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; +import {trimSearchQueryForAutocomplete} from '@libs/SearchAutocompleteUtils'; import * as SearchQueryUtils from '@libs/SearchQueryUtils'; import * as Report from '@userActions/Report'; import Timing from '@userActions/Timing'; @@ -99,17 +100,7 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList } function SearchRouterList( - { - textInputValue, - updateSearchValue: updateTextInputValue, - setTextInputValue, - reportForContextualSearch, - recentSearches, - autocompleteItems, - recentReports, - onSearchSubmit, - closeRouter, - }: SearchRouterListProps, + {textInputValue, updateSearchValue, setTextInputValue, reportForContextualSearch, recentSearches, autocompleteItems, recentReports, onSearchSubmit, closeRouter}: SearchRouterListProps, ref: ForwardedRef, ) { const styles = useThemeStyles(); @@ -191,14 +182,12 @@ function SearchRouterList( return; } if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { - updateTextInputValue(`${item?.query} `); + updateSearchValue(`${item?.query} `); return; } if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { - const lastColonIndex = textInputValue.lastIndexOf(':'); - const lastCommaIndex = textInputValue.lastIndexOf(','); - const trimmedUserSearchQuery = lastColonIndex > lastCommaIndex ? textInputValue.slice(0, lastColonIndex + 1) : textInputValue.slice(0, lastCommaIndex + 1); - updateTextInputValue(`${trimmedUserSearchQuery}${item?.query} `); + const trimmedUserSearchQuery = trimSearchQueryForAutocomplete(textInputValue); + updateSearchValue(`${trimmedUserSearchQuery}${item?.query} `); return; } @@ -213,7 +202,7 @@ function SearchRouterList( Report.navigateToAndOpenReport(item.login ? [item.login] : [], false); } }, - [closeRouter, textInputValue, onSearchSubmit, updateTextInputValue], + [closeRouter, textInputValue, onSearchSubmit, updateSearchValue], ); const onArrowFocus = useCallback( @@ -221,9 +210,7 @@ function SearchRouterList( if (!isSearchQueryItem(focusedItem) || focusedItem.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !textInputValue) { return; } - const lastColonIndex = textInputValue.lastIndexOf(':'); - const lastCommaIndex = textInputValue.lastIndexOf(','); - const trimmedUserSearchQuery = lastColonIndex > lastCommaIndex ? textInputValue.slice(0, lastColonIndex + 1) : textInputValue.slice(0, lastCommaIndex + 1); + const trimmedUserSearchQuery = trimSearchQueryForAutocomplete(textInputValue); setTextInputValue(`${trimmedUserSearchQuery}${focusedItem?.query} `); }, [setTextInputValue, textInputValue], diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index 374944154157..1293457f0fc4 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -68,4 +68,19 @@ function getAutocompleteTaxList(allTaxRates: Record, policy?: return Object.keys(allTaxRates).map((taxRateName) => taxRateName); } -export {parseForAutocomplete, getAutocompleteTags, getAutocompleteRecentTags, getAutocompleteCategories, getAutocompleteRecentCategories, getAutocompleteTaxList}; +function trimSearchQueryForAutocomplete(searchQuery: string) { + const lastColonIndex = searchQuery.lastIndexOf(':'); + const lastCommaIndex = searchQuery.lastIndexOf(','); + const trimmedUserSearchQuery = lastColonIndex > lastCommaIndex ? searchQuery.slice(0, lastColonIndex + 1) : searchQuery.slice(0, lastCommaIndex + 1); + return trimmedUserSearchQuery; +} + +export { + parseForAutocomplete, + getAutocompleteTags, + getAutocompleteRecentTags, + getAutocompleteCategories, + getAutocompleteRecentCategories, + getAutocompleteTaxList, + trimSearchQueryForAutocomplete, +}; From 0d6f2cea8b505b767828bbc56f2be34d62d1e845 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Fri, 25 Oct 2024 13:33:03 +0200 Subject: [PATCH 17/21] fix type errors --- src/components/Search/SearchRouter/SearchRouterList.tsx | 6 +++--- src/components/SelectionList/Search/SearchQueryListItem.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index e746baa1325d..c538ce4ad61d 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -181,11 +181,11 @@ function SearchRouterList( if (!item?.query) { return; } - if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { + if (item?.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { updateSearchValue(`${item?.query} `); return; } - if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { + if (item?.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { const trimmedUserSearchQuery = trimSearchQueryForAutocomplete(textInputValue); updateSearchValue(`${trimmedUserSearchQuery}${item?.query} `); return; @@ -207,7 +207,7 @@ function SearchRouterList( const onArrowFocus = useCallback( (focusedItem: OptionData | SearchQueryItem) => { - if (!isSearchQueryItem(focusedItem) || focusedItem.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !textInputValue) { + if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !textInputValue) { return; } const trimmedUserSearchQuery = trimSearchQueryForAutocomplete(textInputValue); diff --git a/src/components/SelectionList/Search/SearchQueryListItem.tsx b/src/components/SelectionList/Search/SearchQueryListItem.tsx index e4e496474a5b..3c9cc4c0cd8b 100644 --- a/src/components/SelectionList/Search/SearchQueryListItem.tsx +++ b/src/components/SelectionList/Search/SearchQueryListItem.tsx @@ -13,7 +13,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; type SearchQueryItem = ListItem & { singleIcon?: IconAsset; query?: string; - searchItemType: ValueOf; + searchItemType?: ValueOf; }; type SearchQueryListItemProps = { From c1e1bc51fb44d94132a809ccf4e9e522799ce1fb Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Mon, 28 Oct 2024 11:07:49 +0100 Subject: [PATCH 18/21] make autocomplete suggestions insensitive --- .../Search/SearchRouter/SearchRouter.tsx | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 455d4daef99a..d24ff5f5c388 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -204,13 +204,13 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { if (!autocompleteType || range.key !== autocompleteType) { return; } - alreadyAutocompletedKeys.push(range.value); + alreadyAutocompletedKeys.push(range.value.toLowerCase()); }); switch (autocompleteType) { case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG: { const autocompleteList = autocompleteValue ? tagAutocompleteList : recentTagsAutocompleteList ?? []; const filteredTags = autocompleteList - .filter((tag) => tag?.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(tag)) + .filter((tag) => tag.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tag)) .sort() .slice(0, 10); setAutocompleteSuggestions( @@ -225,7 +225,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const autocompleteList = autocompleteValue ? categoryAutocompleteList : recentCategoriesAutocompleteList; const filteredCategories = autocompleteList .filter((category) => { - return category?.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(category); + return category.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(category.toLowerCase()); }) .sort() .slice(0, 10); @@ -240,7 +240,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { case CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY: { const autocompleteList = autocompleteValue ? currencyAutocompleteList : recentCurrencyAutocompleteList ?? []; const filteredCurrencies = autocompleteList - .filter((currency) => currency?.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(currency)) + .filter((currency) => currency.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(currency.toLowerCase())) .sort() .slice(0, 10); setAutocompleteSuggestions( @@ -253,7 +253,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE: { const filteredTaxRates = taxAutocompleteList - .filter((tax) => tax.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(tax)) + .filter((tax) => tax.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tax.toLowerCase())) .sort() .slice(0, 10); setAutocompleteSuggestions(filteredTaxRates.map((tax) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE}:${tax}`, query: `${tax}`}))); @@ -261,7 +261,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: { const filteredParticipants = participantsAutocompleteList - .filter((participant) => participant.includes(autocompleteValue)) + .filter((participant) => participant.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.toLowerCase())) .sort() .slice(0, 10); setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM}:${participant}`, query: `${participant}`}))); @@ -269,7 +269,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: { const filteredParticipants = participantsAutocompleteList - .filter((participant) => participant.includes(autocompleteValue)) + .filter((participant) => participant.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.toLowerCase())) .sort() .slice(0, 10); setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${participant}`, query: `${participant}`}))); @@ -277,27 +277,31 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN: { const filteredChats = searchOptions.recentReports - .filter((chat) => chat.text?.includes(autocompleteValue)) + .filter((chat) => chat.text?.toLowerCase()?.includes(autocompleteValue.toLowerCase())) .sort((chatA, chatB) => (chatA > chatB ? 1 : -1)) .slice(0, 10); setAutocompleteSuggestions(filteredChats.map((chat) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${chat.text}`, query: `${chat.reportID}`}))); return; } case CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE: { - const filteredTypes = typeAutocompleteList.filter((type) => type.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(type)).sort(); + const filteredTypes = typeAutocompleteList + .filter((type) => type.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(type.toLowerCase())) + .sort(); setAutocompleteSuggestions(filteredTypes.map((type) => ({text: `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${type}`, query: `${type}`}))); return; } case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: { const filteredStatuses = statusAutocompleteList - .filter((status) => status.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(status)) + .filter((status) => status.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(status)) .sort() .slice(0, 10); setAutocompleteSuggestions(filteredStatuses.map((status) => ({text: `${CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS}:${status}`, query: `${status}`}))); return; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE: { - const filteredExpenseTypes = expenseTypes.filter((expenseType) => expenseType.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(expenseType)).sort(); + const filteredExpenseTypes = expenseTypes + .filter((expenseType) => expenseType.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(expenseType)) + .sort(); setAutocompleteSuggestions( filteredExpenseTypes.map((expenseType) => ({ text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE}:${expenseType}`, @@ -308,7 +312,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID: { const filteredCards = cardAutocompleteList - .filter((card) => card.includes(autocompleteValue) && !alreadyAutocompletedKeys.includes(card)) + .filter((card) => card.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(card.toLowerCase())) .sort() .slice(0, 10); setAutocompleteSuggestions( From d7b51bb80c9d31b9bd1cf4ae6b3ac00b503e2155 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Mon, 28 Oct 2024 11:18:38 +0100 Subject: [PATCH 19/21] sanitize search query in search router --- src/components/Search/SearchRouter/SearchRouter.tsx | 8 +++++--- src/libs/SearchQueryUtils.ts | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index d24ff5f5c388..41a7654b7075 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -216,7 +216,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { setAutocompleteSuggestions( filteredTags.map((tagName) => ({ text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG}:${tagName}`, - query: `${tagName}`, + query: `${SearchQueryUtils.sanitizeSearchValue(tagName)}`, })), ); return; @@ -232,7 +232,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { setAutocompleteSuggestions( filteredCategories.map((categoryName) => ({ text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY}:${categoryName}`, - query: `${categoryName}`, + query: `${SearchQueryUtils.sanitizeSearchValue(categoryName)}`, })), ); return; @@ -256,7 +256,9 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { .filter((tax) => tax.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tax.toLowerCase())) .sort() .slice(0, 10); - setAutocompleteSuggestions(filteredTaxRates.map((tax) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE}:${tax}`, query: `${tax}`}))); + setAutocompleteSuggestions( + filteredTaxRates.map((tax) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE}:${tax}`, query: `${SearchQueryUtils.sanitizeSearchValue(tax)}`})), + ); return; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: { diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 51db9fd56ea6..c84e42704fb9 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -642,4 +642,5 @@ export { buildCannedSearchQuery, isCannedSearchQuery, standardizeQueryJSON, + sanitizeSearchValue, }; From cf4eb14db3522f8ec187832651f239b0c0c02b3f Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Tue, 29 Oct 2024 09:18:38 +0100 Subject: [PATCH 20/21] fix PR comments --- src/components/Search/SearchRouter/SearchRouterList.tsx | 2 +- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/libs/SearchAutocompleteUtils.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index c538ce4ad61d..c3799ce5579e 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -154,7 +154,7 @@ function SearchRouterList( }); if (autocompleteData && autocompleteData.length > 0) { - sections.push({title: translate('search.autocomplete'), data: autocompleteData}); + sections.push({title: translate('search.suggestions'), data: autocompleteData}); } const recentSearchesData = recentSearches?.map(({query, timestamp}) => { diff --git a/src/languages/en.ts b/src/languages/en.ts index 4bd7795ef660..2a073d465ead 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4346,7 +4346,7 @@ const translations = { recentChats: 'Recent chats', searchIn: 'Search in', searchPlaceholder: 'Search for something', - autocomplete: 'Autocomplete', + suggestions: 'Suggestions', }, genericErrorPage: { title: 'Uh-oh, something went wrong!', diff --git a/src/languages/es.ts b/src/languages/es.ts index 90ab8d661be3..a5000303156b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4393,7 +4393,7 @@ const translations = { recentChats: 'Chats recientes', searchIn: 'Buscar en', searchPlaceholder: 'Busca algo', - autocomplete: 'Autocompletar', + suggestions: 'Sugerencias', }, genericErrorPage: { title: '¡Oh-oh, algo salió mal!', diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index 1293457f0fc4..f33e2a82d445 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -10,7 +10,7 @@ function parseForAutocomplete(text: string) { const parsedAutocomplete = autocompleteParser.parse(text) as SearchAutocompleteResult; return parsedAutocomplete; } catch (e) { - console.error(`Error when parsing autocopmlete query}"`, e); + console.error(`Error when parsing autocopmlete query"`, e); } } @@ -33,7 +33,7 @@ function getAutocompleteRecentTags(allRecentTags: OnyxCollection(); Object.values(allRecentTags ?? {}) - .map((testVar) => Object.values(testVar ?? {})) + .map((recentTag) => Object.values(recentTag ?? {})) .flat(2) .forEach((tag) => uniqueTagNames.add(tag)); return Array.from(uniqueTagNames); From f66dfeec72a74dc8c914544e38f1c190538859a8 Mon Sep 17 00:00:00 2001 From: Jakub Szymczak Date: Tue, 29 Oct 2024 13:13:17 +0100 Subject: [PATCH 21/21] trim space to allow for autocomplete sugggestion after comma --- src/components/Search/SearchRouter/SearchRouter.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 9c159428d75c..83d7d5d89b20 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -295,16 +295,20 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const onSearchChange = useCallback( (userQuery: string) => { - setTextInputValue(userQuery); - const autocompleteParsedQuery = parseForAutocomplete(userQuery); + let newUserQuery = userQuery; + if (autocompleteSuggestions && userQuery.endsWith(',')) { + newUserQuery = `${userQuery.slice(0, userQuery.length - 1).trim()},`; + } + setTextInputValue(newUserQuery); + const autocompleteParsedQuery = parseForAutocomplete(newUserQuery); updateAutocomplete(autocompleteParsedQuery?.autocomplete?.value ?? '', autocompleteParsedQuery?.ranges ?? [], autocompleteParsedQuery?.autocomplete?.key); - if (userQuery) { + if (newUserQuery) { listRef.current?.updateAndScrollToFocusedIndex(0); } else { listRef.current?.updateAndScrollToFocusedIndex(-1); } }, - [setTextInputValue, updateAutocomplete], + [autocompleteSuggestions, setTextInputValue, updateAutocomplete], ); const onSearchSubmit = useCallback(