From b93c8c8d7972d6a08b0a85b9e7103d6b12656474 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Wed, 23 Oct 2024 16:30:32 +0200 Subject: [PATCH 1/8] Add function for generating new query with autocomplete values --- .../SearchRouter/getQueryWithSubstitutions.ts | 39 +++++++ src/components/Search/types.ts | 8 ++ .../Search/getQueryWithSubstitutionsTest.ts | 110 ++++++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 src/components/Search/SearchRouter/getQueryWithSubstitutions.ts create mode 100644 tests/unit/Search/getQueryWithSubstitutionsTest.ts diff --git a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts new file mode 100644 index 000000000000..999464ce4b34 --- /dev/null +++ b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts @@ -0,0 +1,39 @@ +import type {SearchAutocompleteQueryRange} from '@components/Search/types'; +import * as parser from '@libs/SearchParser/autocompleteParser'; + +type SubstitutionEntry = {value: string}; +type SubstitutionMap = Record; + +const getSubstitutionsKey = (filterName: string, value: string) => `${filterName}:${value}`; + +function getQueryWithSubstitutions(changedQuery: string, substitutions: SubstitutionMap) { + const parsed = parser.parse(changedQuery) as {ranges: SearchAutocompleteQueryRange[]}; + + const searchAutocompleteQueryRanges = parsed.ranges; + + if (searchAutocompleteQueryRanges.length === 0) { + return changedQuery; + } + + let resultQuery = changedQuery; + let lengthDiff = 0; + + for (const range of searchAutocompleteQueryRanges) { + const itemKey = getSubstitutionsKey(range.key, range.value); + const substitutionEntry = substitutions[itemKey]; + + if (substitutionEntry) { + const substitutionStart = range.start + lengthDiff; + const substitutionEnd = range.start + range.length; + + // generate new query but substituting "user-typed" value with the entity id/email from substitutions + resultQuery = resultQuery.slice(0, substitutionStart) + substitutionEntry.value + changedQuery.slice(substitutionEnd); + lengthDiff = lengthDiff + substitutionEntry.value.length - range.length; + } + } + + return resultQuery; +} + +// eslint-disable-next-line import/prefer-default-export +export {getQueryWithSubstitutions}; diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index d5be896c1c50..a85dd0853d93 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -92,6 +92,13 @@ type SearchAutocompleteResult = { ranges: AutocompleteRange[]; }; +type SearchAutocompleteQueryRange = { + key: ValueOf; + value: string; + start: number; + length: number; +}; + export type { SelectedTransactionInfo, SelectedTransactions, @@ -112,4 +119,5 @@ export type { ChatSearchStatus, SearchAutocompleteResult, AutocompleteRange, + SearchAutocompleteQueryRange, }; diff --git a/tests/unit/Search/getQueryWithSubstitutionsTest.ts b/tests/unit/Search/getQueryWithSubstitutionsTest.ts new file mode 100644 index 000000000000..98918423eef8 --- /dev/null +++ b/tests/unit/Search/getQueryWithSubstitutionsTest.ts @@ -0,0 +1,110 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// we need "dirty" object key names in these tests +import {getQueryWithSubstitutions} from '@src/components/Search/SearchRouter/getQueryWithSubstitutions'; + +describe('getQueryWithSubstitutions should compute and return correct new query', () => { + test('when both queries contain no substitutions', () => { + // given this previous query: "foo" + const userTypedQuery = 'foo bar'; + const substitutionsMock = {}; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo bar'); + }); + + test('when query has a substitution and plain text was added after it', () => { + // given this previous query: "foo from:@mateusz" + const userTypedQuery = 'foo from:Mat test'; + const substitutionsMock = { + 'from:Mat': { + value: '@mateusz', + }, + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo from:@mateusz test'); + }); + + test('when query has a substitution and plain text was added after before it', () => { + // given this previous query: "foo from:@mateusz1" + const userTypedQuery = 'foo bar from:Mat1'; + const substitutionsMock = { + 'from:Mat1': { + value: '@mateusz1', + }, + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo bar from:@mateusz1'); + }); + + test('when query has a substitution and then it was removed', () => { + // given this previous query: "foo from:@mateusz" + const userTypedQuery = 'foo from:Ma'; + const substitutionsMock = { + 'from:Mat': { + value: '@mateusz', + }, + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo from:Ma'); + }); + + test('when query has a substitution and then it was changed', () => { + // given this previous query: "foo from:@mateusz1" + const userTypedQuery = 'foo from:Maat1'; + const substitutionsMock = { + 'from:Mat1': { + value: '@mateusz1', + }, + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo from:Maat1'); + }); + + test('when query has multiple substitutions and one was changed on the last position', () => { + // given this previous query: "foo in:123,456 from:@jakub" + // oldHumanReadableQ = 'foo in:admin,admins from:Jakub' + const userTypedQuery = 'foo in:admin,admins from:Jakub2'; + const substitutionsMock = { + 'in:admin': { + value: '123', + }, + 'in:admins': { + value: '456', + }, + 'from:Jakub': { + value: '@jakub', + }, + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); + + expect(result).toBe('foo in:123,456 from:Jakub2'); + }); + + test('when query has multiple substitutions and one was changed in the middle', () => { + // given this previous query: "foo in:aabbccdd123,zxcv123 from:@jakub" + const userTypedQuery = 'foo in:wave2,waveControl from:zzzz'; + + const substM = { + 'in:wave': { + value: 'aabbccdd123', + }, + 'in:waveControl': { + value: 'zxcv123', + }, + }; + + const result = getQueryWithSubstitutions(userTypedQuery, substM); + + expect(result).toBe('foo in:wave2,zxcv123 from:zzzz'); + }); +}); From 12987cd942db5aaf457c1e1a32196ed63812652d Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Fri, 25 Oct 2024 09:01:53 +0200 Subject: [PATCH 2/8] Add autocomplete working for from/to and chat rooms --- src/CONST.ts | 10 +-- .../Search/SearchRouter/SearchRouter.tsx | 86 ++++++++++++------ .../Search/SearchRouter/SearchRouterList.tsx | 87 ++++++++++++------- .../SearchRouter/getQueryWithSubstitutions.ts | 10 ++- .../getUpdatedSubstitutionsMap.ts | 32 +++++++ src/components/Search/types.ts | 1 + .../Search/SearchQueryListItem.tsx | 3 +- src/libs/SearchAutocompleteUtils.ts | 21 +++-- .../Search/getUpdatedSubstitutionsMapTest.ts | 73 ++++++++++++++++ 9 files changed, 251 insertions(+), 72 deletions(-) create mode 100644 src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts create mode 100644 tests/unit/Search/getUpdatedSubstitutionsMapTest.ts diff --git a/src/CONST.ts b/src/CONST.ts index 09f89459f329..325754682013 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5807,15 +5807,15 @@ const CONST = { CURRENCY: 'currency', MERCHANT: 'merchant', DESCRIPTION: 'description', - FROM: 'from', - TO: 'to', + FROM: 'from', // Fixme substitute with accountID + TO: 'to', // Fixme substitute with accountID CATEGORY: 'category', TAG: 'tag', - TAX_RATE: 'taxRate', - CARD_ID: 'cardID', + TAX_RATE: 'taxRate', // Fixme substitute with tax id? + CARD_ID: 'cardID', // Fixme substitue bank id? REPORT_ID: 'reportID', KEYWORD: 'keyword', - IN: 'in', + IN: 'in', // Fixme substitute with reportID }, EMPTY_VALUE: 'none', SEARCH_ROUTER_ITEM_TYPE: { diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 83d7d5d89b20..b3a5fd3a1a4f 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -34,9 +34,13 @@ import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type PersonalDetails from '@src/types/onyx/PersonalDetails'; +import {getQueryWithSubstitutions} from './getQueryWithSubstitutions'; +import type {SubstitutionMap} from './getQueryWithSubstitutions'; +import {getUpdatedSubstitutionsMap} from './getUpdatedSubstitutionsMap'; import SearchRouterInput from './SearchRouterInput'; import SearchRouterList from './SearchRouterList'; -import type {ItemWithQuery} from './SearchRouterList'; +import type {AutocompleteItemData} from './SearchRouterList'; type SearchRouterProps = { onRouterClose: () => void; @@ -48,16 +52,21 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const [betas] = useOnyx(ONYXKEYS.BETAS); const [recentSearches] = useOnyx(ONYXKEYS.RECENT_SEARCHES); const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); - const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]); + const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]); const {shouldUseNarrowLayout} = useResponsiveLayout(); const listRef = useRef(null); + const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); const [textInputValue, debouncedInputValue, setTextInputValue] = useDebouncedState('', 500); const contextualReportID = useNavigationState, string | undefined>((state) => { return state?.routes.at(-1)?.params?.reportID; }); + const cleanQuery = useMemo(() => { + return getQueryWithSubstitutions(textInputValue, autocompleteSubstitutions); + }, [autocompleteSubstitutions, textInputValue]); + const activeWorkspaceID = useActiveWorkspaceFromNavigationState(); const policy = usePolicy(activeWorkspaceID); const typeAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES); @@ -69,9 +78,11 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const cardAutocompleteList = Object.values(cardList ?? {}).map((card) => card.bank); const personalDetailsForParticipants = usePersonalDetails(); const participantsAutocompleteList = Object.values(personalDetailsForParticipants) - .filter((details) => details && details?.login) - // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - .map((details) => details?.login as string); + .filter((details): details is NonNullable => !!(details && details?.login)) + .map((details) => ({ + name: details.login ?? '', + accountID: details?.accountID, + })); const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES); @@ -161,8 +172,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { .slice(0, 10); setAutocompleteSuggestions( filteredTags.map((tagName) => ({ - text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG}:${tagName}`, - query: `${SearchQueryUtils.sanitizeSearchValue(tagName)}`, + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, + text: `${SearchQueryUtils.sanitizeSearchValue(tagName)}`, })), ); return; @@ -177,8 +188,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { .slice(0, 10); setAutocompleteSuggestions( filteredCategories.map((categoryName) => ({ - text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY}:${categoryName}`, - query: `${SearchQueryUtils.sanitizeSearchValue(categoryName)}`, + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, + text: `${SearchQueryUtils.sanitizeSearchValue(categoryName)}`, })), ); return; @@ -191,8 +202,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { .slice(0, 10); setAutocompleteSuggestions( filteredCurrencies.map((currencyName) => ({ - text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY}:${currencyName}`, - query: `${currencyName}`, + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY, + text: currencyName, })), ); return; @@ -202,25 +213,27 @@ 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: `${SearchQueryUtils.sanitizeSearchValue(tax)}`})), - ); + setAutocompleteSuggestions(filteredTaxRates.map((tax) => ({filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, text: SearchQueryUtils.sanitizeSearchValue(tax)}))); return; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: { const filteredParticipants = participantsAutocompleteList - .filter((participant) => participant.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.toLowerCase())) + .filter((participant) => participant.name.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase())) .sort() .slice(0, 10); - setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM}:${participant}`, query: `${participant}`}))); + setAutocompleteSuggestions( + filteredParticipants.map((participant) => ({filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, text: participant.name, autocompleteID: `${participant.accountID}`})), + ); return; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: { const filteredParticipants = participantsAutocompleteList - .filter((participant) => participant.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.toLowerCase())) + .filter((participant) => participant.name.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase())) .sort() .slice(0, 10); - setAutocompleteSuggestions(filteredParticipants.map((participant) => ({text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.TO}:${participant}`, query: `${participant}`}))); + setAutocompleteSuggestions( + filteredParticipants.map((participant) => ({filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, text: participant.name, autocompleteID: `${participant.accountID}`})), + ); return; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN: { @@ -228,14 +241,21 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { .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}`}))); + setAutocompleteSuggestions( + filteredChats.map((chat) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.IN, + // text: SearchQueryUtils.sanitizeSearchValue(chat.text ?? ''), + text: chat.text ?? '', + autocompleteID: chat.reportID, + })), + ); return; } case CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE: { const filteredTypes = typeAutocompleteList .filter((type) => type.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(type.toLowerCase())) .sort(); - setAutocompleteSuggestions(filteredTypes.map((type) => ({text: `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${type}`, query: `${type}`}))); + setAutocompleteSuggestions(filteredTypes.map((type) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE, text: type}))); return; } case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: { @@ -243,7 +263,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { .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}`}))); + setAutocompleteSuggestions(filteredStatuses.map((status) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS, text: status}))); return; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE: { @@ -252,8 +272,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { .sort(); setAutocompleteSuggestions( filteredExpenseTypes.map((expenseType) => ({ - text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE}:${expenseType}`, - query: `${expenseType}`, + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE, + text: expenseType, })), ); return; @@ -265,8 +285,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { .slice(0, 10); setAutocompleteSuggestions( filteredCards.map((card) => ({ - text: `${CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID}:${card}`, - query: `${card}`, + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, + text: card, })), ); return; @@ -302,13 +322,17 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { setTextInputValue(newUserQuery); const autocompleteParsedQuery = parseForAutocomplete(newUserQuery); updateAutocomplete(autocompleteParsedQuery?.autocomplete?.value ?? '', autocompleteParsedQuery?.ranges ?? [], autocompleteParsedQuery?.autocomplete?.key); + + const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions); + setAutocompleteSubstitutions(updatedSubstitutionsMap); + if (newUserQuery) { listRef.current?.updateAndScrollToFocusedIndex(0); } else { listRef.current?.updateAndScrollToFocusedIndex(-1); } }, - [autocompleteSuggestions, setTextInputValue, updateAutocomplete], + [autocompleteSubstitutions,autocompleteSuggestions, setTextInputValue, updateAutocomplete], ); const onSearchSubmit = useCallback( @@ -325,12 +349,21 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { [allTaxRates, cardList, onRouterClose, setTextInputValue], ); + const updateSubstitutionsMap = (key: string, value: string) => { + const substitutions = {...autocompleteSubstitutions, [key]: {value}}; + console.log('updateSubstitutionsMap', substitutions); + + setAutocompleteSubstitutions(substitutions); + }; + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => { onRouterClose(); }); const modalWidth = shouldUseNarrowLayout ? styles.w100 : {width: variables.searchRouterPopoverWidth}; + console.log('[ROUTER]', {user: textInputValue, cleanQuery, autocompleteSubstitutions}); + return ( diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index c3799ce5579e..c517626cb235 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -16,20 +16,26 @@ import Navigation from '@libs/Navigation/Navigation'; import Performance from '@libs/Performance'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; -import {trimSearchQueryForAutocomplete} from '@libs/SearchAutocompleteUtils'; +import {getQueryWithoutAutocompletedPart} from '@libs/SearchAutocompleteUtils'; import * as SearchQueryUtils from '@libs/SearchQueryUtils'; import * as Report from '@userActions/Report'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import {getSubstitutionMapKey} from './getQueryWithSubstitutions'; -type ItemWithQuery = { +type SearchQueryItemData = { query: string; - id?: string; text?: string; }; +type AutocompleteItemData = { + filterKey: string; + text: string; + autocompleteID?: string; +}; + type SearchRouterListProps = { /** value of TextInput */ textInputValue: string; @@ -41,13 +47,13 @@ type SearchRouterListProps = { setTextInputValue: (text: string) => void; /** Recent searches */ - recentSearches: Array | undefined; + recentSearches: Array | undefined; /** Recent reports */ recentReports: OptionData[]; /** Autocomplete items */ - autocompleteItems: ItemWithQuery[] | undefined; + autocompleteItems: AutocompleteItemData[] | undefined; /** Callback to submit query when selecting a list item */ onSearchSubmit: (query: SearchQueryJSON | undefined) => void; @@ -57,6 +63,9 @@ type SearchRouterListProps = { /** Callback to close and clear SearchRouter */ closeRouter: () => void; + + /** Callback WIP */ + onAutocompleteSuggestionClick: (id: string, value: string) => void; }; const setPerformanceTimersEnd = () => { @@ -69,10 +78,7 @@ function getContextualSearchQuery(reportID: string) { } function isSearchQueryItem(item: OptionData | SearchQueryItem): item is SearchQueryItem { - if ('singleIcon' in item && item.singleIcon && 'query' in item && item.query) { - return true; - } - return false; + return 'searchItemType' in item; } function isSearchQueryListItem(listItem: UserListItemProps | SearchQueryListItemProps): listItem is SearchQueryListItemProps { @@ -100,7 +106,18 @@ function SearchRouterItem(props: UserListItemProps | SearchQueryList } function SearchRouterList( - {textInputValue, updateSearchValue, setTextInputValue, reportForContextualSearch, recentSearches, autocompleteItems, recentReports, onSearchSubmit, closeRouter}: SearchRouterListProps, + { + textInputValue, + updateSearchValue, + setTextInputValue, + reportForContextualSearch, + recentSearches, + autocompleteItems, + recentReports, + onSearchSubmit, + onAutocompleteSuggestionClick, + closeRouter, + }: SearchRouterListProps, ref: ForwardedRef, ) { const styles = useThemeStyles(); @@ -119,7 +136,7 @@ function SearchRouterList( { text: textInputValue, singleIcon: Expensicons.MagnifyingGlass, - query: textInputValue, + searchQuery: textInputValue, itemStyle: styles.activeComponentBG, keyForList: 'findItem', searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, @@ -134,7 +151,7 @@ function SearchRouterList( { text: `${translate('search.searchIn')} ${reportForContextualSearch.text ?? reportForContextualSearch.alternateText}`, singleIcon: Expensicons.MagnifyingGlass, - query: getContextualSearchQuery(reportForContextualSearch.reportID), + searchQuery: getContextualSearchQuery(reportForContextualSearch.reportID), itemStyle: styles.activeComponentBG, keyForList: 'contextualSearch', searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION, @@ -143,12 +160,13 @@ function SearchRouterList( }); } - const autocompleteData = autocompleteItems?.map(({text, query}) => { + const autocompleteData = autocompleteItems?.map(({filterKey, text, autocompleteID}) => { return { - text, + text: getSubstitutionMapKey(filterKey, text), singleIcon: Expensicons.MagnifyingGlass, - query, - keyForList: query, + searchQuery: text, + autocompleteID, + keyForList: autocompleteID ?? text, // in case we have a unique identifier then use it because text might not be unique searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION, }; }); @@ -162,7 +180,7 @@ function SearchRouterList( return { text: searchQueryJSON ? SearchQueryUtils.buildUserReadableQueryString(searchQueryJSON, personalDetails, cardList, reports, taxRates) : query, singleIcon: Expensicons.History, - query, + searchQuery: query, keyForList: timestamp, searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH, }; @@ -178,20 +196,24 @@ function SearchRouterList( const onSelectRow = useCallback( (item: OptionData | SearchQueryItem) => { if (isSearchQueryItem(item)) { - if (!item?.query) { + if (!item.searchQuery) { return; } - if (item?.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { - updateSearchValue(`${item?.query} `); + if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { + updateSearchValue(`${item.searchQuery} `); return; } - if (item?.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { - const trimmedUserSearchQuery = trimSearchQueryForAutocomplete(textInputValue); - updateSearchValue(`${trimmedUserSearchQuery}${item?.query} `); + if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { + const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue); + updateSearchValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `); + + if (item.autocompleteID && item.text) { + onAutocompleteSuggestionClick(item.text, item.autocompleteID); + } return; } - onSearchSubmit(SearchQueryUtils.buildSearchQueryJSON(item?.query)); + onSearchSubmit(SearchQueryUtils.buildSearchQueryJSON(item.searchQuery)); } // Handle selection of "Recent chat" @@ -202,18 +224,23 @@ function SearchRouterList( Report.navigateToAndOpenReport(item.login ? [item.login] : [], false); } }, - [closeRouter, textInputValue, onSearchSubmit, updateSearchValue], + [closeRouter, textInputValue, onSearchSubmit, updateSearchValue, onAutocompleteSuggestionClick], ); const onArrowFocus = useCallback( (focusedItem: OptionData | SearchQueryItem) => { - if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !textInputValue) { + if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !focusedItem.searchQuery) { return; } - const trimmedUserSearchQuery = trimSearchQueryForAutocomplete(textInputValue); - setTextInputValue(`${trimmedUserSearchQuery}${focusedItem?.query} `); + + const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue); + setTextInputValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(focusedItem.searchQuery)} `); + + if (focusedItem.autocompleteID && focusedItem.text) { + onAutocompleteSuggestionClick(focusedItem.text, focusedItem.autocompleteID); + } }, - [setTextInputValue, textInputValue], + [setTextInputValue, textInputValue, onAutocompleteSuggestionClick], ); const getItemHeight = useCallback((item: OptionData | SearchQueryItem) => { @@ -244,4 +271,4 @@ function SearchRouterList( export default forwardRef(SearchRouterList); export {SearchRouterItem}; -export type {ItemWithQuery}; +export type {AutocompleteItemData}; diff --git a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts index 999464ce4b34..ac770e4a6886 100644 --- a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts +++ b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts @@ -4,9 +4,10 @@ import * as parser from '@libs/SearchParser/autocompleteParser'; type SubstitutionEntry = {value: string}; type SubstitutionMap = Record; -const getSubstitutionsKey = (filterName: string, value: string) => `${filterName}:${value}`; +const getSubstitutionMapKey = (filterName: string, value: string) => `${filterName}:${value}`; function getQueryWithSubstitutions(changedQuery: string, substitutions: SubstitutionMap) { + console.log('getQueryWithSubstitutions', changedQuery, substitutions); const parsed = parser.parse(changedQuery) as {ranges: SearchAutocompleteQueryRange[]}; const searchAutocompleteQueryRanges = parsed.ranges; @@ -15,11 +16,12 @@ function getQueryWithSubstitutions(changedQuery: string, substitutions: Substitu return changedQuery; } + debugger; let resultQuery = changedQuery; let lengthDiff = 0; for (const range of searchAutocompleteQueryRanges) { - const itemKey = getSubstitutionsKey(range.key, range.value); + const itemKey = getSubstitutionMapKey(range.key, range.value); const substitutionEntry = substitutions[itemKey]; if (substitutionEntry) { @@ -35,5 +37,5 @@ function getQueryWithSubstitutions(changedQuery: string, substitutions: Substitu return resultQuery; } -// eslint-disable-next-line import/prefer-default-export -export {getQueryWithSubstitutions}; +export {getQueryWithSubstitutions, getSubstitutionMapKey}; +export type {SubstitutionMap}; diff --git a/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts new file mode 100644 index 000000000000..42d4cf0b7723 --- /dev/null +++ b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts @@ -0,0 +1,32 @@ +import type {SearchAutocompleteQueryRange} from '@components/Search/types'; +import * as parser from '@libs/SearchParser/autocompleteParser'; +import type {SubstitutionMap} from './getQueryWithSubstitutions'; + +const getSubstitutionsKey = (filterName: string, value: string) => `${filterName}:${value}`; + +function getUpdatedSubstitutionsMap(query: string, substitutions: SubstitutionMap): SubstitutionMap { + const parsedQuery = parser.parse(query) as {ranges: SearchAutocompleteQueryRange[]}; + + const searchAutocompleteQueryRanges = parsedQuery.ranges; + + if (searchAutocompleteQueryRanges.length === 0) { + return {}; + } + + const autocompleteQueryKeys = searchAutocompleteQueryRanges.map((range) => getSubstitutionsKey(range.key, range.value)); + + // Build a new substitutions map consisting of only the keys from old map, that appear in query + const updatedSubstitutionMap = autocompleteQueryKeys.reduce((map, key) => { + if (substitutions[key]) { + // eslint-disable-next-line no-param-reassign + map[key] = substitutions[key]; + } + + return map; + }, {} as SubstitutionMap); + + return updatedSubstitutionMap; +} + +// eslint-disable-next-line import/prefer-default-export +export {getUpdatedSubstitutionsMap}; diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index a85dd0853d93..925f7fcb3e30 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -80,6 +80,7 @@ type SearchQueryJSON = { flatFilters: QueryFilters; } & SearchQueryAST; +// Fixme [Search] remove duplicate type AutocompleteRange = { key: ValueOf; length: number; diff --git a/src/components/SelectionList/Search/SearchQueryListItem.tsx b/src/components/SelectionList/Search/SearchQueryListItem.tsx index f1636be0d88c..dd94a8d4afba 100644 --- a/src/components/SelectionList/Search/SearchQueryListItem.tsx +++ b/src/components/SelectionList/Search/SearchQueryListItem.tsx @@ -12,7 +12,8 @@ import type IconAsset from '@src/types/utils/IconAsset'; type SearchQueryItem = ListItem & { singleIcon?: IconAsset; - query?: string; + searchQuery?: string; + autocompleteID?: string; searchItemType?: ValueOf; }; diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index f33e2a82d445..182ffef5c5d4 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -68,11 +68,20 @@ function getAutocompleteTaxList(allTaxRates: Record, policy?: return Object.keys(allTaxRates).map((taxRateName) => taxRateName); } -function trimSearchQueryForAutocomplete(searchQuery: string) { - const lastColonIndex = searchQuery.lastIndexOf(':'); - const lastCommaIndex = searchQuery.lastIndexOf(','); - const trimmedUserSearchQuery = lastColonIndex > lastCommaIndex ? searchQuery.slice(0, lastColonIndex + 1) : searchQuery.slice(0, lastCommaIndex + 1); - return trimmedUserSearchQuery; +/** + * Given a query string, this function parses it with the autocomplete parser + * and returns only the part of the string before autocomplete. + * + * Ex: "test from:john@doe" -> "test from:" + */ +function getQueryWithoutAutocompletedPart(searchQuery: string) { + const parsedQuery = parseForAutocomplete(searchQuery); + if (!parsedQuery?.autocomplete) { + return searchQuery; + } + + const sliceEnd = parsedQuery.autocomplete.start; + return searchQuery.slice(0, sliceEnd); } export { @@ -82,5 +91,5 @@ export { getAutocompleteCategories, getAutocompleteRecentCategories, getAutocompleteTaxList, - trimSearchQueryForAutocomplete, + getQueryWithoutAutocompletedPart, }; diff --git a/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts b/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts new file mode 100644 index 000000000000..8dfeaa5900ef --- /dev/null +++ b/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts @@ -0,0 +1,73 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// we need "dirty" object key names in these tests +import {getUpdatedSubstitutionsMap} from '@src/components/Search/SearchRouter/getUpdatedSubstitutionsMap'; + +describe('getUpdatedSubstitutionsMap should return updated and cleaned substitutions map', () => { + test('when there were no substitutions', () => { + const userTypedQuery = 'foo bar'; + const substitutionsMock = {}; + + const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock); + + expect(result).toStrictEqual({}); + }); + + test('when query has a substitution and it did not change', () => { + const userTypedQuery = 'foo from:Mat'; + const substitutionsMock = { + 'from:Mat': { + value: '@mateusz', + }, + }; + + const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock); + + expect(result).toStrictEqual({ + 'from:Mat': { + value: '@mateusz', + }, + }); + }); + + test('when query has a substitution and it changed', () => { + const userTypedQuery = 'foo from:Johnny'; + const substitutionsMock = { + 'from:Steven': { + value: '@steven', + }, + }; + + const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock); + + expect(result).toStrictEqual({}); + }); + + test('when query has multiple substitutions and some changed but some stayed', () => { + const userTypedQuery = 'from:Johnny to:Steven category:Fruitzzzz'; + const substitutionsMock = { + 'from:Johnny': { + value: '@johnny', + }, + 'to:Steven': { + value: '@steven', + }, + 'from:OldName': { + value: '@oldName', + }, + 'category:Fruit': { + value: '123456', + }, + }; + + const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock); + + expect(result).toStrictEqual({ + 'from:Johnny': { + value: '@johnny', + }, + 'to:Steven': { + value: '@steven', + }, + }); + }); +}); From db340d7f96461c6b0bbe940ea33ef8c73bab660c Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Tue, 29 Oct 2024 16:40:20 +0100 Subject: [PATCH 3/8] Add autocomplete working for taxRates and taxes from policy --- src/CONST.ts | 10 +- .../Search/SearchRouter/SearchRouter.tsx | 143 +++++++++--------- .../SearchRouter/getQueryWithSubstitutions.ts | 2 - src/libs/SearchAutocompleteUtils.ts | 15 +- 4 files changed, 90 insertions(+), 80 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index 325754682013..4cba57223fb6 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5807,15 +5807,15 @@ const CONST = { CURRENCY: 'currency', MERCHANT: 'merchant', DESCRIPTION: 'description', - FROM: 'from', // Fixme substitute with accountID - TO: 'to', // Fixme substitute with accountID + FROM: 'from', + TO: 'to', CATEGORY: 'category', TAG: 'tag', - TAX_RATE: 'taxRate', // Fixme substitute with tax id? - CARD_ID: 'cardID', // Fixme substitue bank id? + TAX_RATE: 'taxRate', + CARD_ID: 'cardID', // Fixme substitute bank id? REPORT_ID: 'reportID', KEYWORD: 'keyword', - IN: 'in', // Fixme substitute with reportID + IN: 'in', }, EMPTY_VALUE: 'none', SEARCH_ROUTER_ITEM_TYPE: { diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index b3a5fd3a1a4f..47d2d08cc03a 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -8,7 +8,7 @@ import {usePersonalDetails} from '@components/OnyxProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; import type {AutocompleteRange, SearchQueryJSON} from '@components/Search/types'; import type {SelectionListHandle} from '@components/SelectionList/types'; -import useActiveWorkspaceFromNavigationState from '@hooks/useActiveWorkspaceFromNavigationState'; +import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useDebouncedState from '@hooks/useDebouncedState'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useLocalize from '@hooks/useLocalize'; @@ -67,13 +67,12 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { return getQueryWithSubstitutions(textInputValue, autocompleteSubstitutions); }, [autocompleteSubstitutions, textInputValue]); - const activeWorkspaceID = useActiveWorkspaceFromNavigationState(); + const {activeWorkspaceID} = useActiveWorkspace(); const policy = usePolicy(activeWorkspaceID); + const typeAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES); const statusAutocompleteList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP}); const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE); - const allTaxRates = getAllTaxRates(); - const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(allTaxRates, policy), [policy, allTaxRates]); const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); const cardAutocompleteList = Object.values(cardList ?? {}).map((card) => card.bank); const personalDetailsForParticipants = usePersonalDetails(); @@ -81,9 +80,10 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { .filter((details): details is NonNullable => !!(details && details?.login)) .map((details) => ({ name: details.login ?? '', - accountID: details?.accountID, + accountID: details?.accountID.toString(), })); - + const allTaxRates = getAllTaxRates(); + const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(allTaxRates, policy), [policy, allTaxRates]); const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES); const categoryAutocompleteList = useMemo(() => { @@ -163,6 +163,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } alreadyAutocompletedKeys.push(range.value.toLowerCase()); }); + + let filteredAutocompleteSuggestions: AutocompleteItemData[] | undefined; switch (autocompleteType) { case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG: { const autocompleteList = autocompleteValue ? tagAutocompleteList : recentTagsAutocompleteList ?? []; @@ -170,13 +172,12 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { .filter((tag) => tag.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tag)) .sort() .slice(0, 10); - setAutocompleteSuggestions( - filteredTags.map((tagName) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, - text: `${SearchQueryUtils.sanitizeSearchValue(tagName)}`, - })), - ); - return; + + filteredAutocompleteSuggestions = filteredTags.map((tagName) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG, + text: tagName, + })); + break; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY: { const autocompleteList = autocompleteValue ? categoryAutocompleteList : recentCategoriesAutocompleteList; @@ -186,13 +187,12 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { }) .sort() .slice(0, 10); - setAutocompleteSuggestions( - filteredCategories.map((categoryName) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, - text: `${SearchQueryUtils.sanitizeSearchValue(categoryName)}`, - })), - ); - return; + + filteredAutocompleteSuggestions = filteredCategories.map((categoryName) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, + text: categoryName, + })); + break; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY: { const autocompleteList = autocompleteValue ? currencyAutocompleteList : recentCurrencyAutocompleteList ?? []; @@ -200,101 +200,105 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { .filter((currency) => currency.toLowerCase()?.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(currency.toLowerCase())) .sort() .slice(0, 10); - setAutocompleteSuggestions( - filteredCurrencies.map((currencyName) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY, - text: currencyName, - })), - ); - return; + + filteredAutocompleteSuggestions = filteredCurrencies.map((currencyName) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY, + text: currencyName, + })); + break; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE: { const filteredTaxRates = taxAutocompleteList - .filter((tax) => tax.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tax.toLowerCase())) + .filter((tax) => tax.taxRateName.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(tax.taxRateName.toLowerCase())) .sort() .slice(0, 10); - setAutocompleteSuggestions(filteredTaxRates.map((tax) => ({filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, text: SearchQueryUtils.sanitizeSearchValue(tax)}))); - return; + filteredAutocompleteSuggestions = filteredTaxRates.map((tax) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, + text: tax.taxRateName, + autocompleteID: tax.taxRateIds.join(','), + })); + + break; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: { const filteredParticipants = participantsAutocompleteList .filter((participant) => participant.name.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase())) .sort() .slice(0, 10); - setAutocompleteSuggestions( - filteredParticipants.map((participant) => ({filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, text: participant.name, autocompleteID: `${participant.accountID}`})), - ); - return; + filteredAutocompleteSuggestions = filteredParticipants.map((participant) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, + text: participant.name, + autocompleteID: participant.accountID, + })); + break; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: { const filteredParticipants = participantsAutocompleteList .filter((participant) => participant.name.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase())) .sort() .slice(0, 10); - setAutocompleteSuggestions( - filteredParticipants.map((participant) => ({filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, text: participant.name, autocompleteID: `${participant.accountID}`})), - ); - return; + filteredAutocompleteSuggestions = filteredParticipants.map((participant) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.TO, + text: participant.name, + autocompleteID: participant.accountID, + })); + break; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN: { const filteredChats = searchOptions.recentReports .filter((chat) => chat.text?.toLowerCase()?.includes(autocompleteValue.toLowerCase())) .sort((chatA, chatB) => (chatA > chatB ? 1 : -1)) .slice(0, 10); - setAutocompleteSuggestions( - filteredChats.map((chat) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.IN, - // text: SearchQueryUtils.sanitizeSearchValue(chat.text ?? ''), - text: chat.text ?? '', - autocompleteID: chat.reportID, - })), - ); - return; + filteredAutocompleteSuggestions = filteredChats.map((chat) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.IN, + text: chat.text ?? '', + autocompleteID: chat.reportID, + })); + break; } case CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE: { const filteredTypes = typeAutocompleteList .filter((type) => type.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(type.toLowerCase())) .sort(); - setAutocompleteSuggestions(filteredTypes.map((type) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE, text: type}))); - return; + filteredAutocompleteSuggestions = filteredTypes.map((type) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE, text: type})); + break; } case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS: { const filteredStatuses = statusAutocompleteList .filter((status) => status.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(status)) .sort() .slice(0, 10); - setAutocompleteSuggestions(filteredStatuses.map((status) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS, text: status}))); - return; + filteredAutocompleteSuggestions = filteredStatuses.map((status) => ({filterKey: CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS, text: status})); + break; } case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE: { const filteredExpenseTypes = expenseTypes .filter((expenseType) => expenseType.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(expenseType)) .sort(); - setAutocompleteSuggestions( - filteredExpenseTypes.map((expenseType) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE, - text: expenseType, - })), - ); - return; + + filteredAutocompleteSuggestions = filteredExpenseTypes.map((expenseType) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE, + text: expenseType, + })); + break; } + // Fixme implement card autocomplete ids case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID: { const filteredCards = cardAutocompleteList .filter((card) => card.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(card.toLowerCase())) .sort() .slice(0, 10); - setAutocompleteSuggestions( - filteredCards.map((card) => ({ - filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, - text: card, - })), - ); - return; + filteredAutocompleteSuggestions = filteredCards.map((card) => ({ + filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, + text: card, + })); + break; } default: { - setAutocompleteSuggestions(undefined); + filteredAutocompleteSuggestions = undefined; } } + setAutocompleteSuggestions(filteredAutocompleteSuggestions); }, [ tagAutocompleteList, @@ -332,7 +336,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { listRef.current?.updateAndScrollToFocusedIndex(-1); } }, - [autocompleteSubstitutions,autocompleteSuggestions, setTextInputValue, updateAutocomplete], + [autocompleteSubstitutions, autocompleteSuggestions, setTextInputValue, updateAutocomplete], ); const onSearchSubmit = useCallback( @@ -351,7 +355,6 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const updateSubstitutionsMap = (key: string, value: string) => { const substitutions = {...autocompleteSubstitutions, [key]: {value}}; - console.log('updateSubstitutionsMap', substitutions); setAutocompleteSubstitutions(substitutions); }; @@ -362,7 +365,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const modalWidth = shouldUseNarrowLayout ? styles.w100 : {width: variables.searchRouterPopoverWidth}; - console.log('[ROUTER]', {user: textInputValue, cleanQuery, autocompleteSubstitutions}); + // console.log('[ROUTER]', {user: textInputValue, cleanQuery, autocompleteSubstitutions}); return ( { - onSearchSubmit(SearchQueryUtils.buildSearchQueryJSON(textInputValue)); + onSearchSubmit(SearchQueryUtils.buildSearchQueryJSON(cleanQuery)); }} routerListRef={listRef} shouldShowOfflineMessage diff --git a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts index ac770e4a6886..80fe3b29aaec 100644 --- a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts +++ b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts @@ -7,7 +7,6 @@ type SubstitutionMap = Record; const getSubstitutionMapKey = (filterName: string, value: string) => `${filterName}:${value}`; function getQueryWithSubstitutions(changedQuery: string, substitutions: SubstitutionMap) { - console.log('getQueryWithSubstitutions', changedQuery, substitutions); const parsed = parser.parse(changedQuery) as {ranges: SearchAutocompleteQueryRange[]}; const searchAutocompleteQueryRanges = parsed.ranges; @@ -16,7 +15,6 @@ function getQueryWithSubstitutions(changedQuery: string, substitutions: Substitu return changedQuery; } - debugger; let resultQuery = changedQuery; let lengthDiff = 0; diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index 182ffef5c5d4..2e75b4196ebc 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -61,11 +61,20 @@ function getAutocompleteRecentCategories(allRecentCategories: OnyxCollection category); } -function getAutocompleteTaxList(allTaxRates: Record, policy?: OnyxEntry) { +function getAutocompleteTaxList(taxRates: Record, policy?: OnyxEntry) { if (policy) { - return Object.keys(policy?.taxRates?.taxes ?? {}).map((taxRateName) => taxRateName); + const policyTaxes = policy?.taxRates?.taxes ?? {}; + + return Object.keys(policyTaxes).map((taxID) => ({ + taxRateName: policyTaxes[taxID].name, + taxRateIds: [taxID], + })); } - return Object.keys(allTaxRates).map((taxRateName) => taxRateName); + + return Object.keys(taxRates).map((taxName) => ({ + taxRateName: taxName, + taxRateIds: taxRates[taxName].map((id) => taxRates[id] ?? id).flat(), + })); } /** From 217d562414f94fe0bf046237ebf4ab7b0aad122a Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Wed, 30 Oct 2024 12:04:43 +0100 Subject: [PATCH 4/8] Add autocomplete for cardIDs and correctly compute backend query --- src/CONST.ts | 2 +- src/components/Search/SearchPageHeader.tsx | 5 +- .../Search/SearchRouter/SearchRouter.tsx | 39 +++--- .../Search/SearchRouter/SearchRouterList.tsx | 6 +- src/components/Search/types.ts | 19 +-- src/libs/SearchParser/autocompleteParser.js | 117 ++++++++++-------- .../SearchParser/autocompleteParser.peggy | 1 + src/libs/SearchQueryUtils.ts | 110 +++++++++------- 8 files changed, 161 insertions(+), 138 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index 4cba57223fb6..09f89459f329 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5812,7 +5812,7 @@ const CONST = { CATEGORY: 'category', TAG: 'tag', TAX_RATE: 'taxRate', - CARD_ID: 'cardID', // Fixme substitute bank id? + CARD_ID: 'cardID', REPORT_ID: 'reportID', KEYWORD: 'keyword', IN: 'in', diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index 5665909185c4..a330be3d5ff6 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -340,7 +340,10 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) { } const inputQueryJSON = SearchQueryUtils.buildSearchQueryJSON(inputValue); if (inputQueryJSON) { - const standardizedQuery = SearchQueryUtils.standardizeQueryJSON(inputQueryJSON, cardList, taxRates); + // Todo traverse the tree to update all the display values into id values; this is only temporary until autocomplete code from SearchRouter is implement here + // After https://github.com/Expensify/App/pull/51633 is merged, autocomplete functionality will be included into this component, and `getFindIDFromDisplayValue` can be removed + const computeNodeValueFn = SearchQueryUtils.getFindIDFromDisplayValue(cardList, taxRates); + const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(inputQueryJSON, computeNodeValueFn); const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery); SearchActions.clearAllFilters(); Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 47d2d08cc03a..f97b459c60b5 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 {AutocompleteRange, SearchQueryJSON} from '@components/Search/types'; +import type {SearchAutocompleteQueryRange, SearchQueryString} from '@components/Search/types'; import type {SelectionListHandle} from '@components/SelectionList/types'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useDebouncedState from '@hooks/useDebouncedState'; @@ -63,10 +63,6 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { return state?.routes.at(-1)?.params?.reportID; }); - const cleanQuery = useMemo(() => { - return getQueryWithSubstitutions(textInputValue, autocompleteSubstitutions); - }, [autocompleteSubstitutions, textInputValue]); - const {activeWorkspaceID} = useActiveWorkspace(); const policy = usePolicy(activeWorkspaceID); @@ -74,7 +70,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const statusAutocompleteList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP}); const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE); const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); - const cardAutocompleteList = Object.values(cardList ?? {}).map((card) => card.bank); + const cardAutocompleteList = Object.values(cardList); const personalDetailsForParticipants = usePersonalDetails(); const participantsAutocompleteList = Object.values(personalDetailsForParticipants) .filter((details): details is NonNullable => !!(details && details?.login)) @@ -155,7 +151,7 @@ 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: SearchAutocompleteQueryRange[], autocompleteType?: ValueOf) => { const alreadyAutocompletedKeys: string[] = []; ranges.forEach((range) => { if (!autocompleteType || range.key !== autocompleteType) { @@ -282,15 +278,16 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { })); break; } - // Fixme implement card autocomplete ids case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID: { const filteredCards = cardAutocompleteList - .filter((card) => card.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(card.toLowerCase())) + .filter((card) => card.bank.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(card.bank.toLowerCase())) .sort() .slice(0, 10); + filteredAutocompleteSuggestions = filteredCards.map((card) => ({ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, - text: card, + text: card.bank, + autocompleteID: card.cardID.toString(), })); break; } @@ -340,17 +337,23 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { ); const onSearchSubmit = useCallback( - (query: SearchQueryJSON | undefined) => { - if (!query) { + (queryString: SearchQueryString) => { + const cleanedQueryString = getQueryWithSubstitutions(queryString, autocompleteSubstitutions); + const queryJSON = SearchQueryUtils.buildSearchQueryJSON(cleanedQueryString); + if (!queryJSON) { return; } + onRouterClose(); - const standardizedQuery = SearchQueryUtils.standardizeQueryJSON(query, cardList, allTaxRates); - const queryString = SearchQueryUtils.buildSearchQueryString(standardizedQuery); - Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: queryString})); + + const computeNodeValueFn = SearchQueryUtils.getUpdatedAmountValue; + const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(queryJSON, computeNodeValueFn); + const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery); + Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); + setTextInputValue(''); }, - [allTaxRates, cardList, onRouterClose, setTextInputValue], + [autocompleteSubstitutions, onRouterClose, setTextInputValue], ); const updateSubstitutionsMap = (key: string, value: string) => { @@ -365,8 +368,6 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const modalWidth = shouldUseNarrowLayout ? styles.w100 : {width: variables.searchRouterPopoverWidth}; - // console.log('[ROUTER]', {user: textInputValue, cleanQuery, autocompleteSubstitutions}); - return ( { - onSearchSubmit(SearchQueryUtils.buildSearchQueryJSON(cleanQuery)); + onSearchSubmit(textInputValue); }} routerListRef={listRef} shouldShowOfflineMessage diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index c517626cb235..74c6aa293622 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -3,7 +3,7 @@ import type {ForwardedRef} from 'react'; import {useOnyx} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; import {usePersonalDetails} from '@components/OnyxProvider'; -import type {SearchQueryJSON} from '@components/Search/types'; +import type {SearchQueryString} from '@components/Search/types'; import SelectionList from '@components/SelectionList'; import SearchQueryListItem from '@components/SelectionList/Search/SearchQueryListItem'; import type {SearchQueryItem, SearchQueryListItemProps} from '@components/SelectionList/Search/SearchQueryListItem'; @@ -56,7 +56,7 @@ type SearchRouterListProps = { autocompleteItems: AutocompleteItemData[] | undefined; /** Callback to submit query when selecting a list item */ - onSearchSubmit: (query: SearchQueryJSON | undefined) => void; + onSearchSubmit: (query: SearchQueryString) => void; /** Context present when opening SearchRouter from a report, invoice or workspace page */ reportForContextualSearch?: OptionData; @@ -213,7 +213,7 @@ function SearchRouterList( return; } - onSearchSubmit(SearchQueryUtils.buildSearchQueryJSON(item.searchQuery)); + onSearchSubmit(item.searchQuery); } // Handle selection of "Recent chat" diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 925f7fcb3e30..35d156350cd3 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -80,24 +80,16 @@ type SearchQueryJSON = { flatFilters: QueryFilters; } & SearchQueryAST; -// Fixme [Search] remove duplicate -type AutocompleteRange = { - key: ValueOf; - length: number; - start: number; - value: string; -}; - type SearchAutocompleteResult = { - autocomplete: AutocompleteRange | null; - ranges: AutocompleteRange[]; + autocomplete: SearchAutocompleteQueryRange | null; + ranges: SearchAutocompleteQueryRange[]; }; type SearchAutocompleteQueryRange = { - key: ValueOf; - value: string; - start: number; + key: ValueOf; length: number; + start: number; + value: string; }; export type { @@ -119,6 +111,5 @@ export type { TripSearchStatus, ChatSearchStatus, SearchAutocompleteResult, - AutocompleteRange, SearchAutocompleteQueryRange, }; diff --git a/src/libs/SearchParser/autocompleteParser.js b/src/libs/SearchParser/autocompleteParser.js index be57ff8a67a5..bd114b56e099 100644 --- a/src/libs/SearchParser/autocompleteParser.js +++ b/src/libs/SearchParser/autocompleteParser.js @@ -186,12 +186,13 @@ function peg$parse(input, options) { var peg$c8 = "expenseType"; var peg$c9 = "type"; var peg$c10 = "status"; - var peg$c11 = "!="; - var peg$c12 = ">="; - var peg$c13 = ">"; - var peg$c14 = "<="; - var peg$c15 = "<"; - var peg$c16 = "\""; + var peg$c11 = "cardID"; + var peg$c12 = "!="; + var peg$c13 = ">="; + var peg$c14 = ">"; + var peg$c15 = "<="; + var peg$c16 = "<"; + var peg$c17 = "\""; var peg$r0 = /^[:=]/; var peg$r1 = /^[^ ,"\t\n\r]/; @@ -211,21 +212,22 @@ function peg$parse(input, options) { var peg$e9 = peg$literalExpectation("expenseType", false); var peg$e10 = peg$literalExpectation("type", false); var peg$e11 = peg$literalExpectation("status", false); - var peg$e12 = peg$otherExpectation("operator"); - var peg$e13 = peg$classExpectation([":", "="], false, false); - var peg$e14 = peg$literalExpectation("!=", false); - var peg$e15 = peg$literalExpectation(">=", false); - var peg$e16 = peg$literalExpectation(">", false); - var peg$e17 = peg$literalExpectation("<=", false); - var peg$e18 = peg$literalExpectation("<", false); - var peg$e19 = peg$otherExpectation("quote"); - var peg$e20 = peg$classExpectation([" ", ",", "\"", "\t", "\n", "\r"], true, false); - var peg$e21 = peg$literalExpectation("\"", false); - var peg$e22 = peg$classExpectation(["\"", "\r", "\n"], true, false); - var peg$e23 = peg$classExpectation([" ", ",", "\t", "\n", "\r"], true, false); - var peg$e24 = peg$otherExpectation("word"); - var peg$e25 = peg$otherExpectation("whitespace"); - var peg$e26 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); + var peg$e12 = peg$literalExpectation("cardID", false); + var peg$e13 = peg$otherExpectation("operator"); + var peg$e14 = peg$classExpectation([":", "="], false, false); + var peg$e15 = peg$literalExpectation("!=", false); + var peg$e16 = peg$literalExpectation(">=", false); + var peg$e17 = peg$literalExpectation(">", false); + var peg$e18 = peg$literalExpectation("<=", false); + var peg$e19 = peg$literalExpectation("<", false); + var peg$e20 = peg$otherExpectation("quote"); + var peg$e21 = peg$classExpectation([" ", ",", "\"", "\t", "\n", "\r"], true, false); + var peg$e22 = peg$literalExpectation("\"", false); + var peg$e23 = peg$classExpectation(["\"", "\r", "\n"], true, false); + var peg$e24 = peg$classExpectation([" ", ",", "\t", "\n", "\r"], true, false); + var peg$e25 = peg$otherExpectation("word"); + var peg$e26 = peg$otherExpectation("whitespace"); + var peg$e27 = peg$classExpectation([" ", "\t", "\r", "\n"], false, false); var peg$f0 = function(ranges) { return { autocomplete, ranges }; }; var peg$f1 = function(filters) { return filters.filter(Boolean).flat(); }; @@ -644,6 +646,15 @@ function peg$parse(input, options) { s1 = peg$FAILED; if (peg$silentFails === 0) { peg$fail(peg$e11); } } + if (s1 === peg$FAILED) { + if (input.substr(peg$currPos, 6) === peg$c11) { + s1 = peg$c11; + peg$currPos += 6; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e12); } + } + } } } } @@ -740,7 +751,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e13); } + if (peg$silentFails === 0) { peg$fail(peg$e14); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -749,12 +760,12 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c11) { - s1 = peg$c11; + if (input.substr(peg$currPos, 2) === peg$c12) { + s1 = peg$c12; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e14); } + if (peg$silentFails === 0) { peg$fail(peg$e15); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -763,12 +774,12 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c12) { - s1 = peg$c12; + if (input.substr(peg$currPos, 2) === peg$c13) { + s1 = peg$c13; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e15); } + if (peg$silentFails === 0) { peg$fail(peg$e16); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -778,11 +789,11 @@ function peg$parse(input, options) { if (s0 === peg$FAILED) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 62) { - s1 = peg$c13; + s1 = peg$c14; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e16); } + if (peg$silentFails === 0) { peg$fail(peg$e17); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -791,12 +802,12 @@ function peg$parse(input, options) { s0 = s1; if (s0 === peg$FAILED) { s0 = peg$currPos; - if (input.substr(peg$currPos, 2) === peg$c14) { - s1 = peg$c14; + if (input.substr(peg$currPos, 2) === peg$c15) { + s1 = peg$c15; peg$currPos += 2; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e17); } + if (peg$silentFails === 0) { peg$fail(peg$e18); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -806,11 +817,11 @@ function peg$parse(input, options) { if (s0 === peg$FAILED) { s0 = peg$currPos; if (input.charCodeAt(peg$currPos) === 60) { - s1 = peg$c15; + s1 = peg$c16; peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e18); } + if (peg$silentFails === 0) { peg$fail(peg$e19); } } if (s1 !== peg$FAILED) { peg$savedPos = s0; @@ -825,7 +836,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e12); } + if (peg$silentFails === 0) { peg$fail(peg$e13); } } return s0; @@ -842,7 +853,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e20); } + if (peg$silentFails === 0) { peg$fail(peg$e21); } } while (s2 !== peg$FAILED) { s1.push(s2); @@ -851,15 +862,15 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e20); } + if (peg$silentFails === 0) { peg$fail(peg$e21); } } } if (input.charCodeAt(peg$currPos) === 34) { - s2 = peg$c16; + s2 = peg$c17; peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e21); } + if (peg$silentFails === 0) { peg$fail(peg$e22); } } if (s2 !== peg$FAILED) { s3 = []; @@ -868,7 +879,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e22); } + if (peg$silentFails === 0) { peg$fail(peg$e23); } } while (s4 !== peg$FAILED) { s3.push(s4); @@ -877,15 +888,15 @@ function peg$parse(input, options) { peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e22); } + if (peg$silentFails === 0) { peg$fail(peg$e23); } } } if (input.charCodeAt(peg$currPos) === 34) { - s4 = peg$c16; + s4 = peg$c17; peg$currPos++; } else { s4 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e21); } + if (peg$silentFails === 0) { peg$fail(peg$e22); } } if (s4 !== peg$FAILED) { s5 = []; @@ -894,7 +905,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } + if (peg$silentFails === 0) { peg$fail(peg$e24); } } while (s6 !== peg$FAILED) { s5.push(s6); @@ -903,7 +914,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s6 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } + if (peg$silentFails === 0) { peg$fail(peg$e24); } } } peg$savedPos = s0; @@ -919,7 +930,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e19); } + if (peg$silentFails === 0) { peg$fail(peg$e20); } } return s0; @@ -936,7 +947,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } + if (peg$silentFails === 0) { peg$fail(peg$e24); } } if (s2 !== peg$FAILED) { while (s2 !== peg$FAILED) { @@ -946,7 +957,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s2 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e23); } + if (peg$silentFails === 0) { peg$fail(peg$e24); } } } } else { @@ -960,7 +971,7 @@ function peg$parse(input, options) { peg$silentFails--; if (s0 === peg$FAILED) { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e24); } + if (peg$silentFails === 0) { peg$fail(peg$e25); } } return s0; @@ -988,7 +999,7 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e26); } + if (peg$silentFails === 0) { peg$fail(peg$e27); } } while (s1 !== peg$FAILED) { s0.push(s1); @@ -997,12 +1008,12 @@ function peg$parse(input, options) { peg$currPos++; } else { s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e26); } + if (peg$silentFails === 0) { peg$fail(peg$e27); } } } peg$silentFails--; s1 = peg$FAILED; - if (peg$silentFails === 0) { peg$fail(peg$e25); } + if (peg$silentFails === 0) { peg$fail(peg$e26); } return s0; } diff --git a/src/libs/SearchParser/autocompleteParser.peggy b/src/libs/SearchParser/autocompleteParser.peggy index 89d89fd07cd4..e2a8bed9a9cc 100644 --- a/src/libs/SearchParser/autocompleteParser.peggy +++ b/src/libs/SearchParser/autocompleteParser.peggy @@ -61,6 +61,7 @@ autocompleteKey "key" / "expenseType" / "type" / "status" + / "cardID" ) identifier diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index c84e42704fb9..731639942a5a 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -125,11 +125,11 @@ function getFilters(queryJSON: SearchQueryJSON) { return; } - if (typeof node?.left === 'object' && node.left) { + if (typeof node.left === 'object' && node.left) { traverse(node.left); } - if (typeof node?.right === 'object' && node.right && !Array.isArray(node.right)) { + if (typeof node.right === 'object' && node.right && !Array.isArray(node.right)) { traverse(node.right); } @@ -148,7 +148,7 @@ function getFilters(queryJSON: SearchQueryJSON) { node.right.forEach((element) => { filterArray.push({ operator: node.operator, - value: element as string | number, + value: element, }); }); } @@ -163,52 +163,66 @@ function getFilters(queryJSON: SearchQueryJSON) { } /** - * @private * Given a filter name and its value, this function returns the corresponding ID found in Onyx data. + * Returns a function that can be used as a computeNodeValue callback for traversing the filters tree */ -function findIDFromDisplayValue(filterName: ValueOf, filter: string | string[], cardList: OnyxTypes.CardList, taxRates: Record) { - if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) { - if (typeof filter === 'string') { - const email = filter; - return PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? filter; +function getFindIDFromDisplayValue(cardList: OnyxTypes.CardList, taxRates: Record) { + return (filterName: ValueOf, filter: string | string[]) => { + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) { + if (typeof filter === 'string') { + const email = filter; + return PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? filter; + } + const emails = filter; + return emails.map((email) => PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? email); } - const emails = filter; - return emails.map((email) => PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? email); - } - if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) { - const names = Array.isArray(filter) ? filter : ([filter] as string[]); - return names.map((name) => taxRates[name] ?? name).flat(); - } - if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) { - if (typeof filter === 'string') { - const bank = filter; - const ids = - Object.values(cardList) - .filter((card) => card.bank === bank) - .map((card) => card.cardID.toString()) ?? filter; - return ids.length > 0 ? ids : bank; + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) { + const names = Array.isArray(filter) ? filter : ([filter] as string[]); + return names.map((name) => taxRates[name] ?? name).flat(); } - const banks = filter; - return banks - .map( - (bank) => + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) { + if (typeof filter === 'string') { + const bank = filter; + const ids = Object.values(cardList) .filter((card) => card.bank === bank) - .map((card) => card.cardID.toString()) ?? bank, - ) - .flat(); - } - if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { - if (typeof filter === 'string') { - const backendAmount = CurrencyUtils.convertToBackendAmount(Number(filter)); - return Number.isNaN(backendAmount) ? filter : backendAmount.toString(); + .map((card) => card.cardID.toString()) ?? filter; + return ids.length > 0 ? ids : bank; + } + const banks = filter; + return banks + .map( + (bank) => + Object.values(cardList) + .filter((card) => card.bank === bank) + .map((card) => card.cardID.toString()) ?? bank, + ) + .flat(); + } + if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { + return getUpdatedAmountValue(filterName, filter); } - return filter.map((amount) => { - const backendAmount = CurrencyUtils.convertToBackendAmount(Number(amount)); - return Number.isNaN(backendAmount) ? amount : backendAmount.toString(); - }); + + return filter; + }; +} + +/** + * Returns an updated amount value for query filters, correctly formatted to "backend" amount + */ +function getUpdatedAmountValue(filterName: ValueOf, filter: string | string[]) { + if (filterName !== CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { + return filter; } - return filter; + + if (typeof filter === 'string') { + const backendAmount = CurrencyUtils.convertToBackendAmount(Number(filter)); + return Number.isNaN(backendAmount) ? filter : backendAmount.toString(); + } + return filter.map((amount) => { + const backendAmount = CurrencyUtils.convertToBackendAmount(Number(amount)); + return Number.isNaN(backendAmount) ? amount : backendAmount.toString(); + }); } /** @@ -604,23 +618,23 @@ function isCannedSearchQuery(queryJSON: SearchQueryJSON) { /** * Given a search query, this function will standardize the query by replacing display values with their corresponding IDs. */ -function standardizeQueryJSON(queryJSON: SearchQueryJSON, cardList: OnyxTypes.CardList, taxRates: Record) { +function traverseAndUpdatedQuery(queryJSON: SearchQueryJSON, computeNodeValue: (left: ValueOf, right: string | string[]) => string | string[]) { const standardQuery = cloneDeep(queryJSON); const filters = standardQuery.filters; const traverse = (node: ASTNode) => { if (!node.operator) { return; } - if (typeof node.left === 'object' && node.left) { + if (typeof node.left === 'object') { traverse(node.left); } - if (typeof node.right === 'object' && node.right && !Array.isArray(node.right)) { + if (typeof node.right === 'object' && !Array.isArray(node.right)) { traverse(node.right); } - if (typeof node.left !== 'object') { + if (typeof node.left !== 'object' && (Array.isArray(node.right) || typeof node.right === 'string')) { // eslint-disable-next-line no-param-reassign - node.right = findIDFromDisplayValue(node.left, node.right as string | string[], cardList, taxRates); + node.right = computeNodeValue(node.left, node.right); } }; @@ -641,6 +655,8 @@ export { getPolicyIDFromSearchQuery, buildCannedSearchQuery, isCannedSearchQuery, - standardizeQueryJSON, + traverseAndUpdatedQuery, + getFindIDFromDisplayValue, + getUpdatedAmountValue, sanitizeSearchValue, }; From e3e559bcb5bdf339077836544907ac1080f5477a Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Thu, 31 Oct 2024 11:53:44 +0100 Subject: [PATCH 5/8] Fix autocomplete for contextual search item --- .../Search/SearchRouter/SearchRouter.tsx | 8 ++-- .../Search/SearchRouter/SearchRouterList.tsx | 42 +++++++++++-------- .../SearchRouter/getQueryWithSubstitutions.ts | 12 ++++++ .../getUpdatedSubstitutionsMap.ts | 11 +++++ src/libs/SearchAutocompleteUtils.ts | 23 ++++++++++ 5 files changed, 75 insertions(+), 21 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index f97b459c60b5..92d990505cd7 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -356,8 +356,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { [autocompleteSubstitutions, onRouterClose, setTextInputValue], ); - const updateSubstitutionsMap = (key: string, value: string) => { - const substitutions = {...autocompleteSubstitutions, [key]: {value}}; + const onAutocompleteSuggestionClick = (autocompleteKey: string, autocompleteId: string) => { + const substitutions = {...autocompleteSubstitutions, [autocompleteKey]: {value: autocompleteId}}; setAutocompleteSubstitutions(substitutions); }; @@ -400,10 +400,10 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { reportForContextualSearch={contextualReportData} recentSearches={sortedRecentSearches?.slice(0, 5)} recentReports={recentReports} - autocompleteItems={autocompleteSuggestions} + autocompleteSuggestions={autocompleteSuggestions} onSearchSubmit={onSearchSubmit} closeRouter={onRouterClose} - onAutocompleteSuggestionClick={updateSubstitutionsMap} + onAutocompleteSuggestionClick={onAutocompleteSuggestionClick} ref={listRef} /> diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 74c6aa293622..8fb903526bd5 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -53,7 +53,7 @@ type SearchRouterListProps = { recentReports: OptionData[]; /** Autocomplete items */ - autocompleteItems: AutocompleteItemData[] | undefined; + autocompleteSuggestions: AutocompleteItemData[] | undefined; /** Callback to submit query when selecting a list item */ onSearchSubmit: (query: SearchQueryString) => void; @@ -61,11 +61,11 @@ type SearchRouterListProps = { /** Context present when opening SearchRouter from a report, invoice or workspace page */ reportForContextualSearch?: OptionData; + /** Callback to run when user clicks a suggestion item that contains autocomplete data */ + onAutocompleteSuggestionClick: (autocompleteKey: string, autocompleteId: string) => void; + /** Callback to close and clear SearchRouter */ closeRouter: () => void; - - /** Callback WIP */ - onAutocompleteSuggestionClick: (id: string, value: string) => void; }; const setPerformanceTimersEnd = () => { @@ -73,8 +73,8 @@ const setPerformanceTimersEnd = () => { Performance.markEnd(CONST.TIMING.SEARCH_ROUTER_RENDER); }; -function getContextualSearchQuery(reportID: string) { - return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${CONST.SEARCH.DATA_TYPES.CHAT} in:${reportID}`; +function getContextualSearchQuery(reportName: string) { + return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${CONST.SEARCH.DATA_TYPES.CHAT} ${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${SearchQueryUtils.sanitizeSearchValue(reportName)}`; } function isSearchQueryItem(item: OptionData | SearchQueryItem): item is SearchQueryItem { @@ -85,6 +85,13 @@ function isSearchQueryListItem(listItem: UserListItemProps | SearchQ return isSearchQueryItem(listItem.item); } +function getItemHeight(item: OptionData | SearchQueryItem) { + if (isSearchQueryItem(item)) { + return 44; + } + return 64; +} + function SearchRouterItem(props: UserListItemProps | SearchQueryListItemProps) { const styles = useThemeStyles(); @@ -112,7 +119,7 @@ function SearchRouterList( setTextInputValue, reportForContextualSearch, recentSearches, - autocompleteItems, + autocompleteSuggestions, recentReports, onSearchSubmit, onAutocompleteSuggestionClick, @@ -146,12 +153,14 @@ function SearchRouterList( } if (reportForContextualSearch && !textInputValue) { + const reportQueryValue = reportForContextualSearch.text ?? reportForContextualSearch.alternateText ?? reportForContextualSearch.reportID; sections.push({ data: [ { text: `${translate('search.searchIn')} ${reportForContextualSearch.text ?? reportForContextualSearch.alternateText}`, singleIcon: Expensicons.MagnifyingGlass, - searchQuery: getContextualSearchQuery(reportForContextualSearch.reportID), + searchQuery: reportQueryValue, + autocompleteID: reportForContextualSearch.reportID, itemStyle: styles.activeComponentBG, keyForList: 'contextualSearch', searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION, @@ -160,7 +169,7 @@ function SearchRouterList( }); } - const autocompleteData = autocompleteItems?.map(({filterKey, text, autocompleteID}) => { + const autocompleteData = autocompleteSuggestions?.map(({filterKey, text, autocompleteID}) => { return { text: getSubstitutionMapKey(filterKey, text), singleIcon: Expensicons.MagnifyingGlass, @@ -200,7 +209,13 @@ function SearchRouterList( return; } if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) { - updateSearchValue(`${item.searchQuery} `); + const searchQuery = getContextualSearchQuery(item.searchQuery); + updateSearchValue(`${searchQuery} `); + + if (item.autocompleteID) { + const autocompleteKey = `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${item.searchQuery}`; + onAutocompleteSuggestionClick(autocompleteKey, item.autocompleteID); + } return; } if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) { @@ -243,13 +258,6 @@ function SearchRouterList( [setTextInputValue, textInputValue, onAutocompleteSuggestionClick], ); - const getItemHeight = useCallback((item: OptionData | SearchQueryItem) => { - if (isSearchQueryItem(item)) { - return 44; - } - return 64; - }, []); - return ( sections={sections} diff --git a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts index 80fe3b29aaec..ffd8a85d58c2 100644 --- a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts +++ b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts @@ -6,6 +6,18 @@ type SubstitutionMap = Record; const getSubstitutionMapKey = (filterName: string, value: string) => `${filterName}:${value}`; +/** + * Given a plaintext query and a SubstitutionMap object, this function will return a transformed query where: + * - any autocomplete mention in the original query will be substituted with an id taken from `substitutions` object + * - anything that does not match will stay as is + * + * Ex: + * query: `A from:@johndoe A` + * substitutions: { + * from:@johndoe: 9876 + * } + * return: `A from:9876 A` + */ function getQueryWithSubstitutions(changedQuery: string, substitutions: SubstitutionMap) { const parsed = parser.parse(changedQuery) as {ranges: SearchAutocompleteQueryRange[]}; diff --git a/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts index 42d4cf0b7723..5d52890e64bf 100644 --- a/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts +++ b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts @@ -4,6 +4,17 @@ import type {SubstitutionMap} from './getQueryWithSubstitutions'; const getSubstitutionsKey = (filterName: string, value: string) => `${filterName}:${value}`; +/** + * Given a plaintext query and a SubstitutionMap object, + * this function will remove any substitution keys that do not appear in the query and return an updated object + * + * Ex: + * query: `Test from:John1` + * substitutions: { + * from:SomeOtherJohn: 12345 + * } + * return: {} + */ function getUpdatedSubstitutionsMap(query: string, substitutions: SubstitutionMap): SubstitutionMap { const parsedQuery = parser.parse(query) as {ranges: SearchAutocompleteQueryRange[]}; diff --git a/src/libs/SearchAutocompleteUtils.ts b/src/libs/SearchAutocompleteUtils.ts index 2e75b4196ebc..fd427b7480c6 100644 --- a/src/libs/SearchAutocompleteUtils.ts +++ b/src/libs/SearchAutocompleteUtils.ts @@ -5,6 +5,10 @@ import type {Policy, PolicyCategories, PolicyTagLists, RecentlyUsedCategories, R import {getTagNamesFromTagsLists} from './PolicyUtils'; import * as autocompleteParser from './SearchParser/autocompleteParser'; +/** + * Parses given query using the autocomplete parser. + * This is a smaller and simpler version of search parser used for autocomplete displaying logic. + */ function parseForAutocomplete(text: string) { try { const parsedAutocomplete = autocompleteParser.parse(text) as SearchAutocompleteResult; @@ -14,6 +18,9 @@ function parseForAutocomplete(text: string) { } } +/** + * Returns data for computing the `Tag` filter autocomplete list. + */ function getAutocompleteTags(allPoliciesTagsLists: OnyxCollection, policyID?: string) { const singlePolicyTagsList: PolicyTagLists | undefined = allPoliciesTagsLists?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]; if (!singlePolicyTagsList) { @@ -28,6 +35,9 @@ function getAutocompleteTags(allPoliciesTagsLists: OnyxCollection, policyID?: string) { const singlePolicyRecentTags: RecentlyUsedTags | undefined = allRecentTags?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${policyID}`]; if (!singlePolicyRecentTags) { @@ -41,6 +51,9 @@ function getAutocompleteRecentTags(allRecentTags: OnyxCollection, policyID?: string) { const singlePolicyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; if (!singlePolicyCategories) { @@ -51,6 +64,9 @@ function getAutocompleteCategories(allPolicyCategories: OnyxCollection category.name); } +/** + * Returns data for computing the recent categories autocomplete list. + */ function getAutocompleteRecentCategories(allRecentCategories: OnyxCollection, policyID?: string) { const singlePolicyRecentCategories = allRecentCategories?.[`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${policyID}`]; if (!singlePolicyRecentCategories) { @@ -61,6 +77,13 @@ function getAutocompleteRecentCategories(allRecentCategories: OnyxCollection category); } +/** + * Returns data for computing the `Tax` filter autocomplete list + * + * Please note: taxes are stored in a quite convoluted and non-obvious way, and there can be multiple taxes with the same id + * because tax ids are generated based on a tax name, so they look like this: `id_My_Tax` and are not numeric. + * That is why this function may seem a bit complex. + */ function getAutocompleteTaxList(taxRates: Record, policy?: OnyxEntry) { if (policy) { const policyTaxes = policy?.taxRates?.taxes ?? {}; From 7f2b3e7155f56ba269bd4266039140bdae32faeb Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Mon, 4 Nov 2024 14:14:29 +0100 Subject: [PATCH 6/8] Improve types around Search autocomplete and add minor tweaks --- .../Search/SearchRouter/SearchRouter.tsx | 13 +++---- .../Search/SearchRouter/SearchRouterList.tsx | 6 ++-- .../SearchRouter/getQueryWithSubstitutions.ts | 11 +++--- .../getUpdatedSubstitutionsMap.ts | 4 +-- src/components/Search/types.ts | 14 +++++--- src/pages/Search/AdvancedSearchFilters.tsx | 26 +++++++------- .../Search/getQueryWithSubstitutionsTest.ts | 36 +++++-------------- .../Search/getUpdatedSubstitutionsMapTest.ts | 36 +++++-------------- 8 files changed, 57 insertions(+), 89 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index 92d990505cd7..d425e803f0a6 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -1,4 +1,5 @@ import {useNavigationState} from '@react-navigation/native'; +import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; @@ -15,6 +16,7 @@ import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as CardUtils from '@libs/CardUtils'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import {getAllTaxRates} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -75,7 +77,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const participantsAutocompleteList = Object.values(personalDetailsForParticipants) .filter((details): details is NonNullable => !!(details && details?.login)) .map((details) => ({ - name: details.login ?? '', + name: details.displayName ?? Str.removeSMSDomain(details.login ?? ''), accountID: details?.accountID.toString(), })); const allTaxRates = getAllTaxRates(); @@ -286,7 +288,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { filteredAutocompleteSuggestions = filteredCards.map((card) => ({ filterKey: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, - text: card.bank, + text: CardUtils.getCardDescription(card.cardID), autocompleteID: card.cardID.toString(), })); break; @@ -346,8 +348,7 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { onRouterClose(); - const computeNodeValueFn = SearchQueryUtils.getUpdatedAmountValue; - const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(queryJSON, computeNodeValueFn); + const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(queryJSON, SearchQueryUtils.getUpdatedAmountValue); const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery); Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query})); @@ -356,8 +357,8 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { [autocompleteSubstitutions, onRouterClose, setTextInputValue], ); - const onAutocompleteSuggestionClick = (autocompleteKey: string, autocompleteId: string) => { - const substitutions = {...autocompleteSubstitutions, [autocompleteKey]: {value: autocompleteId}}; + const onAutocompleteSuggestionClick = (autocompleteKey: string, autocompleteID: string) => { + const substitutions = {...autocompleteSubstitutions, [autocompleteKey]: autocompleteID}; setAutocompleteSubstitutions(substitutions); }; diff --git a/src/components/Search/SearchRouter/SearchRouterList.tsx b/src/components/Search/SearchRouter/SearchRouterList.tsx index 8fb903526bd5..cc854ff926c3 100644 --- a/src/components/Search/SearchRouter/SearchRouterList.tsx +++ b/src/components/Search/SearchRouter/SearchRouterList.tsx @@ -3,7 +3,7 @@ import type {ForwardedRef} from 'react'; import {useOnyx} from 'react-native-onyx'; import * as Expensicons from '@components/Icon/Expensicons'; import {usePersonalDetails} from '@components/OnyxProvider'; -import type {SearchQueryString} from '@components/Search/types'; +import type {SearchFilterKey, SearchQueryString} from '@components/Search/types'; import SelectionList from '@components/SelectionList'; import SearchQueryListItem from '@components/SelectionList/Search/SearchQueryListItem'; import type {SearchQueryItem, SearchQueryListItemProps} from '@components/SelectionList/Search/SearchQueryListItem'; @@ -31,7 +31,7 @@ type SearchQueryItemData = { }; type AutocompleteItemData = { - filterKey: string; + filterKey: SearchFilterKey; text: string; autocompleteID?: string; }; @@ -62,7 +62,7 @@ type SearchRouterListProps = { reportForContextualSearch?: OptionData; /** Callback to run when user clicks a suggestion item that contains autocomplete data */ - onAutocompleteSuggestionClick: (autocompleteKey: string, autocompleteId: string) => void; + onAutocompleteSuggestionClick: (autocompleteKey: string, autocompleteID: string) => void; /** Callback to close and clear SearchRouter */ closeRouter: () => void; diff --git a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts index ffd8a85d58c2..117745fee480 100644 --- a/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts +++ b/src/components/Search/SearchRouter/getQueryWithSubstitutions.ts @@ -1,10 +1,9 @@ -import type {SearchAutocompleteQueryRange} from '@components/Search/types'; +import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types'; import * as parser from '@libs/SearchParser/autocompleteParser'; -type SubstitutionEntry = {value: string}; -type SubstitutionMap = Record; +type SubstitutionMap = Record; -const getSubstitutionMapKey = (filterName: string, value: string) => `${filterName}:${value}`; +const getSubstitutionMapKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`; /** * Given a plaintext query and a SubstitutionMap object, this function will return a transformed query where: @@ -39,8 +38,8 @@ function getQueryWithSubstitutions(changedQuery: string, substitutions: Substitu const substitutionEnd = range.start + range.length; // generate new query but substituting "user-typed" value with the entity id/email from substitutions - resultQuery = resultQuery.slice(0, substitutionStart) + substitutionEntry.value + changedQuery.slice(substitutionEnd); - lengthDiff = lengthDiff + substitutionEntry.value.length - range.length; + resultQuery = resultQuery.slice(0, substitutionStart) + substitutionEntry + changedQuery.slice(substitutionEnd); + lengthDiff = lengthDiff + substitutionEntry.length - range.length; } } diff --git a/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts index 5d52890e64bf..ee7bf3850259 100644 --- a/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts +++ b/src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts @@ -1,8 +1,8 @@ -import type {SearchAutocompleteQueryRange} from '@components/Search/types'; +import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types'; import * as parser from '@libs/SearchParser/autocompleteParser'; import type {SubstitutionMap} from './getQueryWithSubstitutions'; -const getSubstitutionsKey = (filterName: string, value: string) => `${filterName}:${value}`; +const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`; /** * Given a plaintext query and a SubstitutionMap object, diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 35d156350cd3..a332a8828ec4 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -1,4 +1,4 @@ -import type {ValueOf} from 'react-native-gesture-handler/lib/typescript/typeUtils'; +import type {ValueOf} from 'type-fest'; import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import type CONST from '@src/CONST'; import type {SearchDataTypes, SearchReport} from '@src/types/onyx/SearchResults'; @@ -56,10 +56,14 @@ type QueryFilter = { value: string | number; }; -type AdvancedFiltersKeys = ValueOf; +type SearchFilterKey = + | ValueOf + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS + | typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID; type QueryFilters = Array<{ - key: AdvancedFiltersKeys; + key: SearchFilterKey; filters: QueryFilter[]; }>; @@ -86,7 +90,7 @@ type SearchAutocompleteResult = { }; type SearchAutocompleteQueryRange = { - key: ValueOf; + key: SearchFilterKey; length: number; start: number; value: string; @@ -105,7 +109,7 @@ export type { ASTNode, QueryFilter, QueryFilters, - AdvancedFiltersKeys, + SearchFilterKey, ExpenseSearchStatus, InvoiceSearchStatus, TripSearchStatus, diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index ce4daabc983a..58fd159b5bed 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -9,7 +9,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {usePersonalDetails} from '@components/OnyxProvider'; import ScrollView from '@components/ScrollView'; -import type {AdvancedFiltersKeys} from '@components/Search/types'; +import type {SearchFilterKey} from '@components/Search/types'; import useLocalize from '@hooks/useLocalize'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -150,8 +150,8 @@ const sortOptionsWithEmptyValue = (a: string, b: string) => { return localeCompare(a, b); }; -function getFilterDisplayTitle(filters: Partial, fieldName: AdvancedFiltersKeys, translate: LocaleContextProps['translate']) { - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE) { +function getFilterDisplayTitle(filters: Partial, filterKey: SearchFilterKey, translate: LocaleContextProps['translate']) { + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE) { // the value of date filter is a combination of dateBefore + dateAfter values const {dateAfter, dateBefore} = filters; let dateValue = ''; @@ -168,7 +168,7 @@ function getFilterDisplayTitle(filters: Partial, fiel return dateValue; } - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { const {lessThan, greaterThan} = filters; if (lessThan && greaterThan) { return translate('search.filters.amount.between', { @@ -186,32 +186,32 @@ function getFilterDisplayTitle(filters: Partial, fiel return; } - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY && filters[fieldName]) { - const filterArray = filters[fieldName] ?? []; + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY && filters[filterKey]) { + const filterArray = filters[filterKey] ?? []; return filterArray.sort(localeCompare).join(', '); } - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY && filters[fieldName]) { - const filterArray = filters[fieldName] ?? []; + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY && filters[filterKey]) { + const filterArray = filters[filterKey] ?? []; return filterArray .sort(sortOptionsWithEmptyValue) .map((value) => (value === CONST.SEARCH.EMPTY_VALUE ? translate('search.noCategory') : value)) .join(', '); } - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG && filters[fieldName]) { - const filterArray = filters[fieldName] ?? []; + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG && filters[filterKey]) { + const filterArray = filters[filterKey] ?? []; return filterArray .sort(sortOptionsWithEmptyValue) .map((value) => (value === CONST.SEARCH.EMPTY_VALUE ? translate('search.noTag') : value)) .join(', '); } - if (fieldName === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION) { - return filters[fieldName]; + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.DESCRIPTION) { + return filters[filterKey]; } - const filterValue = filters[fieldName]; + const filterValue = filters[filterKey]; return Array.isArray(filterValue) ? filterValue.join(', ') : filterValue; } diff --git a/tests/unit/Search/getQueryWithSubstitutionsTest.ts b/tests/unit/Search/getQueryWithSubstitutionsTest.ts index 98918423eef8..8ca2eec31256 100644 --- a/tests/unit/Search/getQueryWithSubstitutionsTest.ts +++ b/tests/unit/Search/getQueryWithSubstitutionsTest.ts @@ -17,9 +17,7 @@ describe('getQueryWithSubstitutions should compute and return correct new query' // given this previous query: "foo from:@mateusz" const userTypedQuery = 'foo from:Mat test'; const substitutionsMock = { - 'from:Mat': { - value: '@mateusz', - }, + 'from:Mat': '@mateusz', }; const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); @@ -31,9 +29,7 @@ describe('getQueryWithSubstitutions should compute and return correct new query' // given this previous query: "foo from:@mateusz1" const userTypedQuery = 'foo bar from:Mat1'; const substitutionsMock = { - 'from:Mat1': { - value: '@mateusz1', - }, + 'from:Mat1': '@mateusz1', }; const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); @@ -45,9 +41,7 @@ describe('getQueryWithSubstitutions should compute and return correct new query' // given this previous query: "foo from:@mateusz" const userTypedQuery = 'foo from:Ma'; const substitutionsMock = { - 'from:Mat': { - value: '@mateusz', - }, + 'from:Mat': '@mateusz', }; const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); @@ -59,9 +53,7 @@ describe('getQueryWithSubstitutions should compute and return correct new query' // given this previous query: "foo from:@mateusz1" const userTypedQuery = 'foo from:Maat1'; const substitutionsMock = { - 'from:Mat1': { - value: '@mateusz1', - }, + 'from:Mat1': '@mateusz1', }; const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); @@ -74,15 +66,9 @@ describe('getQueryWithSubstitutions should compute and return correct new query' // oldHumanReadableQ = 'foo in:admin,admins from:Jakub' const userTypedQuery = 'foo in:admin,admins from:Jakub2'; const substitutionsMock = { - 'in:admin': { - value: '123', - }, - 'in:admins': { - value: '456', - }, - 'from:Jakub': { - value: '@jakub', - }, + 'in:admin': '123', + 'in:admins': '456', + 'from:Jakub': '@jakub', }; const result = getQueryWithSubstitutions(userTypedQuery, substitutionsMock); @@ -95,12 +81,8 @@ describe('getQueryWithSubstitutions should compute and return correct new query' const userTypedQuery = 'foo in:wave2,waveControl from:zzzz'; const substM = { - 'in:wave': { - value: 'aabbccdd123', - }, - 'in:waveControl': { - value: 'zxcv123', - }, + 'in:wave': 'aabbccdd123', + 'in:waveControl': 'zxcv123', }; const result = getQueryWithSubstitutions(userTypedQuery, substM); diff --git a/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts b/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts index 8dfeaa5900ef..43829af9f873 100644 --- a/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts +++ b/tests/unit/Search/getUpdatedSubstitutionsMapTest.ts @@ -15,26 +15,20 @@ describe('getUpdatedSubstitutionsMap should return updated and cleaned substitut test('when query has a substitution and it did not change', () => { const userTypedQuery = 'foo from:Mat'; const substitutionsMock = { - 'from:Mat': { - value: '@mateusz', - }, + 'from:Mat': '@mateusz', }; const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock); expect(result).toStrictEqual({ - 'from:Mat': { - value: '@mateusz', - }, + 'from:Mat': '@mateusz', }); }); test('when query has a substitution and it changed', () => { const userTypedQuery = 'foo from:Johnny'; const substitutionsMock = { - 'from:Steven': { - value: '@steven', - }, + 'from:Steven': '@steven', }; const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock); @@ -45,29 +39,17 @@ describe('getUpdatedSubstitutionsMap should return updated and cleaned substitut test('when query has multiple substitutions and some changed but some stayed', () => { const userTypedQuery = 'from:Johnny to:Steven category:Fruitzzzz'; const substitutionsMock = { - 'from:Johnny': { - value: '@johnny', - }, - 'to:Steven': { - value: '@steven', - }, - 'from:OldName': { - value: '@oldName', - }, - 'category:Fruit': { - value: '123456', - }, + 'from:Johnny': '@johnny', + 'to:Steven': '@steven', + 'from:OldName': '@oldName', + 'category:Fruit': '123456', }; const result = getUpdatedSubstitutionsMap(userTypedQuery, substitutionsMock); expect(result).toStrictEqual({ - 'from:Johnny': { - value: '@johnny', - }, - 'to:Steven': { - value: '@steven', - }, + 'from:Johnny': '@johnny', + 'to:Steven': '@steven', }); }); }); From 4c66af7d089f476831e32a6a1f2b773210d1d676 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Tue, 5 Nov 2024 09:16:20 +0100 Subject: [PATCH 7/8] Fix duplicated tax rate names in SearchResults header --- src/libs/SearchQueryUtils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 731639942a5a..56e2fe63757b 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -569,7 +569,9 @@ function buildUserReadableQueryString( }) .flat(); - displayQueryFilters = taxRateNames.map((taxRate) => ({ + const uniqueTaxRateNames = [...new Set(taxRateNames)]; + + displayQueryFilters = uniqueTaxRateNames.map((taxRate) => ({ operator: queryFilter.at(0)?.operator ?? CONST.SEARCH.SYNTAX_OPERATORS.AND, value: taxRate, })); From 82cb2ecc78ec1d29a4e21d9fcd53860ab2387486 Mon Sep 17 00:00:00 2001 From: Mateusz Titz Date: Wed, 6 Nov 2024 19:14:42 +0100 Subject: [PATCH 8/8] Improve autocomplete case matching --- .../Search/SearchRouter/SearchRouter.tsx | 97 ++++++++++--------- 1 file changed, 53 insertions(+), 44 deletions(-) diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx index d425e803f0a6..e65b12deb64b 100644 --- a/src/components/Search/SearchRouter/SearchRouter.tsx +++ b/src/components/Search/SearchRouter/SearchRouter.tsx @@ -55,53 +55,16 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { const [recentSearches] = useOnyx(ONYXKEYS.RECENT_SEARCHES); const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]); + const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); const {shouldUseNarrowLayout} = useResponsiveLayout(); const listRef = useRef(null); - const [autocompleteSubstitutions, setAutocompleteSubstitutions] = useState({}); const [textInputValue, debouncedInputValue, setTextInputValue] = useDebouncedState('', 500); const contextualReportID = useNavigationState, string | undefined>((state) => { return state?.routes.at(-1)?.params?.reportID; }); - const {activeWorkspaceID} = useActiveWorkspace(); - const policy = usePolicy(activeWorkspaceID); - - const typeAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES); - const statusAutocompleteList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP}); - const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE); - const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); - const cardAutocompleteList = Object.values(cardList); - const personalDetailsForParticipants = usePersonalDetails(); - const participantsAutocompleteList = Object.values(personalDetailsForParticipants) - .filter((details): details is NonNullable => !!(details && details?.login)) - .map((details) => ({ - name: details.displayName ?? Str.removeSMSDomain(details.login ?? ''), - accountID: details?.accountID.toString(), - })); - const allTaxRates = getAllTaxRates(); - const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(allTaxRates, policy), [policy, allTaxRates]); - const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); - const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES); - const categoryAutocompleteList = useMemo(() => { - return getAutocompleteCategories(allPolicyCategories, activeWorkspaceID); - }, [activeWorkspaceID, allPolicyCategories]); - const recentCategoriesAutocompleteList = useMemo(() => { - return getAutocompleteRecentCategories(allRecentCategories, activeWorkspaceID); - }, [activeWorkspaceID, allRecentCategories]); - - const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST); - const currencyAutocompleteList = Object.keys(currencyList ?? {}); - const [recentCurrencyAutocompleteList] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); - - const [allPoliciesTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); - const [allRecentTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS); - const tagAutocompleteList = useMemo(() => { - return getAutocompleteTags(allPoliciesTags, activeWorkspaceID); - }, [activeWorkspaceID, allPoliciesTags]); - const recentTagsAutocompleteList = getAutocompleteRecentTags(allRecentTags, activeWorkspaceID); - const sortedRecentSearches = useMemo(() => { return Object.values(recentSearches ?? {}).sort((a, b) => b.timestamp.localeCompare(a.timestamp)); }, [recentSearches]); @@ -146,12 +109,50 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { return reports.slice(0, 10); }, [debouncedInputValue, filteredOptions, searchOptions]); - useEffect(() => { - Report.searchInServer(debouncedInputValue.trim()); - }, [debouncedInputValue]); - const contextualReportData = contextualReportID ? searchOptions.recentReports?.find((option) => option.reportID === contextualReportID) : undefined; + const {activeWorkspaceID} = useActiveWorkspace(); + const policy = usePolicy(activeWorkspaceID); + + const typeAutocompleteList = Object.values(CONST.SEARCH.DATA_TYPES); + const statusAutocompleteList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP}); + const expenseTypes = Object.values(CONST.SEARCH.TRANSACTION_TYPE); + const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + const cardAutocompleteList = Object.values(cardList); + const personalDetailsForParticipants = usePersonalDetails(); + + const participantsAutocompleteList = useMemo( + () => + Object.values(personalDetailsForParticipants) + .filter((details): details is NonNullable => !!(details && details?.login)) + .map((details) => ({ + name: details.displayName ?? Str.removeSMSDomain(details.login ?? ''), + accountID: details?.accountID.toString(), + })), + [personalDetailsForParticipants], + ); + const allTaxRates = getAllTaxRates(); + const taxAutocompleteList = useMemo(() => getAutocompleteTaxList(allTaxRates, policy), [policy, allTaxRates]); + const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); + const [allRecentCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES); + const categoryAutocompleteList = useMemo(() => { + return getAutocompleteCategories(allPolicyCategories, activeWorkspaceID); + }, [activeWorkspaceID, allPolicyCategories]); + const recentCategoriesAutocompleteList = useMemo(() => { + return getAutocompleteRecentCategories(allRecentCategories, activeWorkspaceID); + }, [activeWorkspaceID, allRecentCategories]); + + const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST); + const currencyAutocompleteList = Object.keys(currencyList ?? {}); + const [recentCurrencyAutocompleteList] = useOnyx(ONYXKEYS.RECENTLY_USED_CURRENCIES); + + const [allPoliciesTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); + const [allRecentTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS); + const tagAutocompleteList = useMemo(() => { + return getAutocompleteTags(allPoliciesTags, activeWorkspaceID); + }, [activeWorkspaceID, allPoliciesTags]); + const recentTagsAutocompleteList = getAutocompleteRecentTags(allRecentTags, activeWorkspaceID); + const updateAutocomplete = useCallback( (autocompleteValue: string, ranges: SearchAutocompleteQueryRange[], autocompleteType?: ValueOf) => { const alreadyAutocompletedKeys: string[] = []; @@ -220,7 +221,9 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM: { const filteredParticipants = participantsAutocompleteList - .filter((participant) => participant.name.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase())) + .filter( + (participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase()), + ) .sort() .slice(0, 10); filteredAutocompleteSuggestions = filteredParticipants.map((participant) => ({ @@ -232,7 +235,9 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { } case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO: { const filteredParticipants = participantsAutocompleteList - .filter((participant) => participant.name.includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase())) + .filter( + (participant) => participant.name.toLowerCase().includes(autocompleteValue.toLowerCase()) && !alreadyAutocompletedKeys.includes(participant.name.toLowerCase()), + ) .sort() .slice(0, 10); filteredAutocompleteSuggestions = filteredParticipants.map((participant) => ({ @@ -316,6 +321,10 @@ function SearchRouter({onRouterClose}: SearchRouterProps) { ], ); + useEffect(() => { + Report.searchInServer(debouncedInputValue.trim()); + }, [debouncedInputValue]); + const onSearchChange = useCallback( (userQuery: string) => { let newUserQuery = userQuery;