Skip to content

Commit

Permalink
Merge pull request #51633 from software-mansion-labs/kicu/autocomplet…
Browse files Browse the repository at this point in the history
…e-query-ids

Make autocomplete work with entity ids
  • Loading branch information
luacmartins authored Nov 6, 2024
2 parents b3a2bcc + 82cb2ec commit c755314
Show file tree
Hide file tree
Showing 14 changed files with 687 additions and 285 deletions.
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};
43 changes: 43 additions & 0 deletions src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts
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

0 comments on commit c755314

Please sign in to comment.