Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make autocomplete work with entity ids #51633

Merged
5 changes: 4 additions & 1 deletion src/components/Search/SearchPageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}));
Expand Down
262 changes: 155 additions & 107 deletions src/components/Search/SearchRouter/SearchRouter.tsx

Large diffs are not rendered by default.

117 changes: 76 additions & 41 deletions src/components/Search/SearchRouter/SearchRouterList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type {ForwardedRef} from 'react';
import {useOnyx} from 'react-native-onyx';
import * as Expensicons from '@components/Icon/Expensicons';
import {usePersonalDetails} from '@components/OnyxProvider';
import type {SearchQueryJSON} from '@components/Search/types';
import type {SearchFilterKey, SearchQueryString} from '@components/Search/types';
import SelectionList from '@components/SelectionList';
import SearchQueryListItem from '@components/SelectionList/Search/SearchQueryListItem';
import type {SearchQueryItem, SearchQueryListItemProps} from '@components/SelectionList/Search/SearchQueryListItem';
Expand All @@ -16,20 +16,26 @@ import Navigation from '@libs/Navigation/Navigation';
import Performance from '@libs/Performance';
import {getAllTaxRates} from '@libs/PolicyUtils';
import type {OptionData} from '@libs/ReportUtils';
import {trimSearchQueryForAutocomplete} from '@libs/SearchAutocompleteUtils';
import {getQueryWithoutAutocompletedPart} from '@libs/SearchAutocompleteUtils';
import * as SearchQueryUtils from '@libs/SearchQueryUtils';
import * as Report from '@userActions/Report';
import Timing from '@userActions/Timing';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import {getSubstitutionMapKey} from './getQueryWithSubstitutions';

type ItemWithQuery = {
type SearchQueryItemData = {
query: string;
id?: string;
text?: string;
};

type AutocompleteItemData = {
filterKey: SearchFilterKey;
text: string;
autocompleteID?: string;
};

type SearchRouterListProps = {
/** value of TextInput */
textInputValue: string;
Expand All @@ -41,20 +47,23 @@ type SearchRouterListProps = {
setTextInputValue: (text: string) => void;

/** Recent searches */
recentSearches: Array<ItemWithQuery & {timestamp: string}> | undefined;
recentSearches: Array<SearchQueryItemData & {timestamp: string}> | undefined;

/** Recent reports */
recentReports: OptionData[];

/** Autocomplete items */
autocompleteItems: ItemWithQuery[] | undefined;
autocompleteSuggestions: AutocompleteItemData[] | undefined;

/** Callback to submit query when selecting a list item */
onSearchSubmit: (query: SearchQueryJSON | undefined) => void;
onSearchSubmit: (query: SearchQueryString) => void;

/** Context present when opening SearchRouter from a report, invoice or workspace page */
reportForContextualSearch?: OptionData;

/** Callback to run when user clicks a suggestion item that contains autocomplete data */
onAutocompleteSuggestionClick: (autocompleteKey: string, autocompleteID: string) => void;

/** Callback to close and clear SearchRouter */
closeRouter: () => void;
};
Expand All @@ -64,21 +73,25 @@ const setPerformanceTimersEnd = () => {
Performance.markEnd(CONST.TIMING.SEARCH_ROUTER_RENDER);
};

function getContextualSearchQuery(reportID: string) {
return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${CONST.SEARCH.DATA_TYPES.CHAT} in:${reportID}`;
function getContextualSearchQuery(reportName: string) {
return `${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${CONST.SEARCH.DATA_TYPES.CHAT} ${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${SearchQueryUtils.sanitizeSearchValue(reportName)}`;
}

function isSearchQueryItem(item: OptionData | SearchQueryItem): item is SearchQueryItem {
if ('singleIcon' in item && item.singleIcon && 'query' in item && item.query) {
return true;
}
return false;
return 'searchItemType' in item;
}

function isSearchQueryListItem(listItem: UserListItemProps<OptionData> | SearchQueryListItemProps): listItem is SearchQueryListItemProps {
return isSearchQueryItem(listItem.item);
}

function getItemHeight(item: OptionData | SearchQueryItem) {
if (isSearchQueryItem(item)) {
return 44;
}
return 64;
}

function SearchRouterItem(props: UserListItemProps<OptionData> | SearchQueryListItemProps) {
const styles = useThemeStyles();

Expand All @@ -100,7 +113,18 @@ function SearchRouterItem(props: UserListItemProps<OptionData> | SearchQueryList
}

function SearchRouterList(
{textInputValue, updateSearchValue, setTextInputValue, reportForContextualSearch, recentSearches, autocompleteItems, recentReports, onSearchSubmit, closeRouter}: SearchRouterListProps,
{
textInputValue,
updateSearchValue,
setTextInputValue,
reportForContextualSearch,
recentSearches,
autocompleteSuggestions,
recentReports,
onSearchSubmit,
onAutocompleteSuggestionClick,
closeRouter,
}: SearchRouterListProps,
ref: ForwardedRef<SelectionListHandle>,
) {
const styles = useThemeStyles();
Expand All @@ -119,7 +143,7 @@ function SearchRouterList(
{
text: textInputValue,
singleIcon: Expensicons.MagnifyingGlass,
query: textInputValue,
searchQuery: textInputValue,
itemStyle: styles.activeComponentBG,
keyForList: 'findItem',
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH,
Expand All @@ -129,12 +153,14 @@ function SearchRouterList(
}

if (reportForContextualSearch && !textInputValue) {
const reportQueryValue = reportForContextualSearch.text ?? reportForContextualSearch.alternateText ?? reportForContextualSearch.reportID;
sections.push({
data: [
{
text: `${translate('search.searchIn')} ${reportForContextualSearch.text ?? reportForContextualSearch.alternateText}`,
singleIcon: Expensicons.MagnifyingGlass,
query: getContextualSearchQuery(reportForContextualSearch.reportID),
searchQuery: reportQueryValue,
autocompleteID: reportForContextualSearch.reportID,
itemStyle: styles.activeComponentBG,
keyForList: 'contextualSearch',
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION,
Expand All @@ -143,12 +169,13 @@ function SearchRouterList(
});
}

const autocompleteData = autocompleteItems?.map(({text, query}) => {
const autocompleteData = autocompleteSuggestions?.map(({filterKey, text, autocompleteID}) => {
return {
text,
text: getSubstitutionMapKey(filterKey, text),
singleIcon: Expensicons.MagnifyingGlass,
query,
keyForList: query,
searchQuery: text,
autocompleteID,
keyForList: autocompleteID ?? text, // in case we have a unique identifier then use it because text might not be unique
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION,
};
});
Expand All @@ -162,7 +189,7 @@ function SearchRouterList(
return {
text: searchQueryJSON ? SearchQueryUtils.buildUserReadableQueryString(searchQueryJSON, personalDetails, cardList, reports, taxRates) : query,
singleIcon: Expensicons.History,
query,
searchQuery: query,
keyForList: timestamp,
searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.SEARCH,
};
Expand All @@ -178,20 +205,30 @@ function SearchRouterList(
const onSelectRow = useCallback(
(item: OptionData | SearchQueryItem) => {
if (isSearchQueryItem(item)) {
if (!item?.query) {
if (!item.searchQuery) {
return;
}
if (item?.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) {
updateSearchValue(`${item?.query} `);
if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.CONTEXTUAL_SUGGESTION) {
const searchQuery = getContextualSearchQuery(item.searchQuery);
updateSearchValue(`${searchQuery} `);

if (item.autocompleteID) {
const autocompleteKey = `${CONST.SEARCH.SYNTAX_FILTER_KEYS.IN}:${item.searchQuery}`;
onAutocompleteSuggestionClick(autocompleteKey, item.autocompleteID);
}
return;
}
if (item?.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) {
const trimmedUserSearchQuery = trimSearchQueryForAutocomplete(textInputValue);
updateSearchValue(`${trimmedUserSearchQuery}${item?.query} `);
if (item.searchItemType === CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION && textInputValue) {
const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue);
updateSearchValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(item.searchQuery)} `);

if (item.autocompleteID && item.text) {
onAutocompleteSuggestionClick(item.text, item.autocompleteID);
}
return;
}

onSearchSubmit(SearchQueryUtils.buildSearchQueryJSON(item?.query));
onSearchSubmit(item.searchQuery);
}

// Handle selection of "Recent chat"
Expand All @@ -202,27 +239,25 @@ function SearchRouterList(
Report.navigateToAndOpenReport(item.login ? [item.login] : [], false);
}
},
[closeRouter, textInputValue, onSearchSubmit, updateSearchValue],
[closeRouter, textInputValue, onSearchSubmit, updateSearchValue, onAutocompleteSuggestionClick],
);

const onArrowFocus = useCallback(
(focusedItem: OptionData | SearchQueryItem) => {
if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !textInputValue) {
if (!isSearchQueryItem(focusedItem) || focusedItem?.searchItemType !== CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION || !focusedItem.searchQuery) {
return;
}
const trimmedUserSearchQuery = trimSearchQueryForAutocomplete(textInputValue);
setTextInputValue(`${trimmedUserSearchQuery}${focusedItem?.query} `);

const trimmedUserSearchQuery = getQueryWithoutAutocompletedPart(textInputValue);
setTextInputValue(`${trimmedUserSearchQuery}${SearchQueryUtils.sanitizeSearchValue(focusedItem.searchQuery)} `);

if (focusedItem.autocompleteID && focusedItem.text) {
onAutocompleteSuggestionClick(focusedItem.text, focusedItem.autocompleteID);
}
},
[setTextInputValue, textInputValue],
[setTextInputValue, textInputValue, onAutocompleteSuggestionClick],
);

const getItemHeight = useCallback((item: OptionData | SearchQueryItem) => {
if (isSearchQueryItem(item)) {
return 44;
}
return 64;
}, []);

return (
<SelectionList<OptionData | SearchQueryItem>
sections={sections}
Expand All @@ -244,4 +279,4 @@ function SearchRouterList(

export default forwardRef(SearchRouterList);
export {SearchRouterItem};
export type {ItemWithQuery};
export type {AutocompleteItemData};
50 changes: 50 additions & 0 deletions src/components/Search/SearchRouter/getQueryWithSubstitutions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types';
import * as parser from '@libs/SearchParser/autocompleteParser';

type SubstitutionMap = Record<string, string>;

const getSubstitutionMapKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`;

/**
* Given a plaintext query and a SubstitutionMap object, this function will return a transformed query where:
* - any autocomplete mention in the original query will be substituted with an id taken from `substitutions` object
* - anything that does not match will stay as is
*
* Ex:
* query: `A from:@johndoe A`
* substitutions: {
* from:@johndoe: 9876
* }
* return: `A from:9876 A`
*/
function getQueryWithSubstitutions(changedQuery: string, substitutions: SubstitutionMap) {
const parsed = parser.parse(changedQuery) as {ranges: SearchAutocompleteQueryRange[]};

const searchAutocompleteQueryRanges = parsed.ranges;

if (searchAutocompleteQueryRanges.length === 0) {
return changedQuery;
}

let resultQuery = changedQuery;
let lengthDiff = 0;

for (const range of searchAutocompleteQueryRanges) {
const itemKey = getSubstitutionMapKey(range.key, range.value);
const substitutionEntry = substitutions[itemKey];

if (substitutionEntry) {
const substitutionStart = range.start + lengthDiff;
const substitutionEnd = range.start + range.length;

// generate new query but substituting "user-typed" value with the entity id/email from substitutions
resultQuery = resultQuery.slice(0, substitutionStart) + substitutionEntry + changedQuery.slice(substitutionEnd);
lengthDiff = lengthDiff + substitutionEntry.length - range.length;
}
}

return resultQuery;
}

export {getQueryWithSubstitutions, getSubstitutionMapKey};
export type {SubstitutionMap};
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types';
import * as parser from '@libs/SearchParser/autocompleteParser';
import type {SubstitutionMap} from './getQueryWithSubstitutions';

const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`;

/**
* Given a plaintext query and a SubstitutionMap object,
* this function will remove any substitution keys that do not appear in the query and return an updated object
*
* Ex:
* query: `Test from:John1`
* substitutions: {
* from:SomeOtherJohn: 12345
* }
* return: {}
*/
function getUpdatedSubstitutionsMap(query: string, substitutions: SubstitutionMap): SubstitutionMap {
const parsedQuery = parser.parse(query) as {ranges: SearchAutocompleteQueryRange[]};

const searchAutocompleteQueryRanges = parsedQuery.ranges;

if (searchAutocompleteQueryRanges.length === 0) {
return {};
}

const autocompleteQueryKeys = searchAutocompleteQueryRanges.map((range) => getSubstitutionsKey(range.key, range.value));

// Build a new substitutions map consisting of only the keys from old map, that appear in query
const updatedSubstitutionMap = autocompleteQueryKeys.reduce((map, key) => {
if (substitutions[key]) {
// eslint-disable-next-line no-param-reassign
map[key] = substitutions[key];
}

return map;
}, {} as SubstitutionMap);

return updatedSubstitutionMap;
}

// eslint-disable-next-line import/prefer-default-export
export {getUpdatedSubstitutionsMap};
Loading
Loading