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

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
190 changes: 114 additions & 76 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 {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: string;
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};
51 changes: 51 additions & 0 deletions src/components/Search/SearchRouter/getQueryWithSubstitutions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type {SearchAutocompleteQueryRange} from '@components/Search/types';
import * as parser from '@libs/SearchParser/autocompleteParser';

type SubstitutionEntry = {value: string};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need the value key here? AFAIK we always have the map as {[filterKey:typedValue]: {value: [id]}}. Could we simplify it to {[filterKey:typedValue]: [id]}?

Suggested change
type SubstitutionEntry = {value: string};
type SubstitutionEntry = {value: string};

type SubstitutionMap = Record<string, SubstitutionEntry>;

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[]};

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.value + changedQuery.slice(substitutionEnd);
lengthDiff = lengthDiff + substitutionEntry.value.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} from '@components/Search/types';
import * as parser from '@libs/SearchParser/autocompleteParser';
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[]};

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};
14 changes: 7 additions & 7 deletions src/components/Search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,18 +80,18 @@ type SearchQueryJSON = {
flatFilters: QueryFilters;
} & SearchQueryAST;

type AutocompleteRange = {
type SearchAutocompleteResult = {
autocomplete: SearchAutocompleteQueryRange | null;
ranges: SearchAutocompleteQueryRange[];
};

type SearchAutocompleteQueryRange = {
key: ValueOf<typeof CONST.SEARCH.SYNTAX_FILTER_KEYS & typeof CONST.SEARCH.SYNTAX_ROOT_KEYS>;
length: number;
start: number;
value: string;
};

type SearchAutocompleteResult = {
autocomplete: AutocompleteRange | null;
ranges: AutocompleteRange[];
};

export type {
SelectedTransactionInfo,
SelectedTransactions,
Expand All @@ -111,5 +111,5 @@ export type {
TripSearchStatus,
ChatSearchStatus,
SearchAutocompleteResult,
AutocompleteRange,
SearchAutocompleteQueryRange,
};
Loading
Loading