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

Highlight autocomplete value on a match #56243

Merged
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
1 change: 1 addition & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6189,6 +6189,7 @@ const CONST = {
LOWER_THAN: 'lt',
LOWER_THAN_OR_EQUAL_TO: 'lte',
},
SYNTAX_RANGE_NAME: 'syntax',
SYNTAX_ROOT_KEYS: {
TYPE: 'type',
STATUS: 'status',
Expand Down
77 changes: 70 additions & 7 deletions src/components/Search/SearchAutocompleteInput.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
import type {ForwardedRef, ReactNode, RefObject} from 'react';
import React, {forwardRef, useLayoutEffect, useState} from 'react';
import React, {forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useState} from 'react';
import {View} from 'react-native';
import type {StyleProp, TextInputProps, ViewStyle} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import {useSharedValue} from 'react-native-reanimated';
import FormHelpMessage from '@components/FormHelpMessage';
import type {SelectionListHandle} from '@components/SelectionList/types';
import TextInput from '@components/TextInput';
import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import {parseFSAttributes} from '@libs/Fullstory';
import {parseForLiveMarkdown} from '@libs/SearchAutocompleteUtils';
import runOnLiveMarkdownRuntime from '@libs/runOnLiveMarkdownRuntime';
import {getAutocompleteCategories, getAutocompleteTags, parseForLiveMarkdown} from '@libs/SearchAutocompleteUtils';
import handleKeyPress from '@libs/SearchInputOnKeyPress';
import shouldDelayFocus from '@libs/shouldDelayFocus';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {SubstitutionMap} from './SearchRouter/getQueryWithSubstitutions';

type SearchAutocompleteInputProps = {
/** Value of TextInput */
Expand Down Expand Up @@ -61,6 +65,9 @@ type SearchAutocompleteInputProps = {

/** Whether the search reports API call is running */
isSearchingForReports?: boolean;

/** Map of autocomplete suggestions. Required for highlighting to work properly */
substitutionMap: SubstitutionMap;
} & Pick<TextInputProps, 'caretHidden' | 'autoFocus' | 'selection'>;

function SearchAutocompleteInput(
Expand All @@ -82,20 +89,80 @@ function SearchAutocompleteInput(
rightComponent,
isSearchingForReports,
selection,
substitutionMap,
}: SearchAutocompleteInputProps,
ref: ForwardedRef<BaseTextInputRef>,
) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [isFocused, setIsFocused] = useState<boolean>(false);
const {isOffline} = useNetwork();
const {activeWorkspaceID} = useActiveWorkspace();
const currentUserPersonalDetails = useCurrentUserPersonalDetails();

const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST);
const currencyAutocompleteList = Object.keys(currencyList ?? {});
const currencySharedValue = useSharedValue(currencyAutocompleteList);

const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
const categoryAutocompleteList = useMemo(() => {
return getAutocompleteCategories(allPolicyCategories, activeWorkspaceID);
}, [activeWorkspaceID, allPolicyCategories]);
const categorySharedValue = useSharedValue(categoryAutocompleteList);

const [allPoliciesTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
const tagAutocompleteList = useMemo(() => {
return getAutocompleteTags(allPoliciesTags, activeWorkspaceID);
}, [activeWorkspaceID, allPoliciesTags]);
const tagSharedValue = useSharedValue(tagAutocompleteList);

const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST);
const emailList = Object.keys(loginList ?? {});
const emailListSharedValue = useSharedValue(emailList);

const offlineMessage: string = isOffline && shouldShowOfflineMessage ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : '';

useEffect(() => {
runOnLiveMarkdownRuntime(() => {
'worklet';

emailListSharedValue.set(emailList);
})();
}, [emailList, emailListSharedValue]);
289Adam289 marked this conversation as resolved.
Show resolved Hide resolved

useEffect(() => {
runOnLiveMarkdownRuntime(() => {
'worklet';

currencySharedValue.set(currencyAutocompleteList);
})();
}, [currencyAutocompleteList, currencySharedValue]);

useEffect(() => {
runOnLiveMarkdownRuntime(() => {
'worklet';

categorySharedValue.set(categoryAutocompleteList);
})();
}, [categorySharedValue, categoryAutocompleteList]);

useEffect(() => {
runOnLiveMarkdownRuntime(() => {
'worklet';

tagSharedValue.set(tagAutocompleteList);
});
}, [tagSharedValue, tagAutocompleteList]);

const parser = useCallback(
(input: string) => {
'worklet';

return parseForLiveMarkdown(input, currentUserPersonalDetails.displayName ?? '', substitutionMap, emailListSharedValue, currencySharedValue, categorySharedValue, tagSharedValue);
},
[currentUserPersonalDetails.displayName, substitutionMap, currencySharedValue, categorySharedValue, tagSharedValue, emailListSharedValue],
);

const inputWidth = isFullWidth ? styles.w100 : {width: variables.popoverWidth};

// Parse Fullstory attributes on initial render
Expand Down Expand Up @@ -145,11 +212,7 @@ function SearchAutocompleteInput(
onKeyPress={handleKeyPress(onSubmit)}
type="markdown"
multiline={false}
parser={(input: string) => {
'worklet';

return parseForLiveMarkdown(input, emailList, currentUserPersonalDetails.displayName ?? '');
}}
parser={parser}
selection={selection}
/>
</View>
Expand Down
6 changes: 5 additions & 1 deletion src/components/Search/SearchPageHeaderInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {useIsFocused} from '@react-navigation/native';
import isEqual from 'lodash/isEqual';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
Expand Down Expand Up @@ -123,7 +124,9 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps
setAutocompleteQueryValue(updatedUserQuery);

const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions);
setAutocompleteSubstitutions(updatedSubstitutionsMap);
if (!isEqual(autocompleteSubstitutions, updatedSubstitutionsMap)) {
setAutocompleteSubstitutions(updatedSubstitutionsMap);
}

if (updatedUserQuery) {
listRef.current?.updateAndScrollToFocusedIndex(0);
Expand Down Expand Up @@ -290,6 +293,7 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps
autocompleteListRef={listRef}
ref={textInputRef}
selection={selection}
substitutionMap={autocompleteSubstitutions}
/>
<View style={[styles.mh85vh, !isAutocompleteListVisible && styles.dNone]}>
<SearchAutocompleteList
Expand Down
6 changes: 5 additions & 1 deletion src/components/Search/SearchRouter/SearchRouter.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {useNavigationState} from '@react-navigation/native';
import isEqual from 'lodash/isEqual';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {View} from 'react-native';
import type {TextInputProps} from 'react-native';
Expand Down Expand Up @@ -185,7 +186,9 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps)
setAutocompleteQueryValue(updatedUserQuery);

const updatedSubstitutionsMap = getUpdatedSubstitutionsMap(userQuery, autocompleteSubstitutions);
setAutocompleteSubstitutions(updatedSubstitutionsMap);
if (!isEqual(autocompleteSubstitutions, updatedSubstitutionsMap)) {
setAutocompleteSubstitutions(updatedSubstitutionsMap);
}

if (updatedUserQuery || textInputValue.length > 0) {
listRef.current?.updateAndScrollToFocusedIndex(0);
Expand Down Expand Up @@ -323,6 +326,7 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps)
wrapperFocusedStyle={[styles.borderColorFocus]}
isSearchingForReports={isSearchingForReports}
selection={selection}
substitutionMap={autocompleteSubstitutions}
ref={textInputRef}
/>
<SearchAutocompleteList
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ function buildSubstitutionsMap(
): SubstitutionMap {
const parsedQuery = parse(query) as {ranges: SearchAutocompleteQueryRange[]};

const searchAutocompleteQueryRanges = parsedQuery.ranges;
const searchAutocompleteQueryRanges = parsedQuery.ranges.filter((range) => range.key !== CONST.SEARCH.SYNTAX_RANGE_NAME);

if (searchAutocompleteQueryRanges.length === 0) {
return {};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types';
import type {SearchAutocompleteQueryRange, SearchAutocompleteQueryRangeKey} from '@components/Search/types';
import {parse} from '@libs/SearchParser/autocompleteParser';
import {sanitizeSearchValue} from '@libs/SearchQueryUtils';
import CONST from '@src/CONST';

type SubstitutionMap = Record<string, string>;

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

/**
* Given a plaintext query and a SubstitutionMap object, this function will return a transformed query where:
Expand All @@ -21,7 +22,7 @@ const getSubstitutionMapKey = (filterKey: SearchFilterKey, value: string) => `${
function getQueryWithSubstitutions(changedQuery: string, substitutions: SubstitutionMap) {
const parsed = parse(changedQuery) as {ranges: SearchAutocompleteQueryRange[]};

const searchAutocompleteQueryRanges = parsed.ranges;
const searchAutocompleteQueryRanges = parsed.ranges.filter((range) => range.key !== CONST.SEARCH.SYNTAX_RANGE_NAME);

if (searchAutocompleteQueryRanges.length === 0) {
return changedQuery;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type {SearchAutocompleteQueryRange, SearchFilterKey} from '@components/Search/types';
import * as parser from '@libs/SearchParser/autocompleteParser';
import type {SearchAutocompleteQueryRange, SearchAutocompleteQueryRangeKey} from '@components/Search/types';
import {parse} from '@libs/SearchParser/autocompleteParser';
import CONST from '@src/CONST';
import type {SubstitutionMap} from './getQueryWithSubstitutions';

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

/**
* Given a plaintext query and a SubstitutionMap object,
Expand All @@ -16,9 +17,9 @@ const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${fi
* return: {}
*/
function getUpdatedSubstitutionsMap(query: string, substitutions: SubstitutionMap): SubstitutionMap {
const parsedQuery = parser.parse(query) as {ranges: SearchAutocompleteQueryRange[]};
const parsedQuery = parse(query) as {ranges: SearchAutocompleteQueryRange[]};

const searchAutocompleteQueryRanges = parsedQuery.ranges;
const searchAutocompleteQueryRanges = parsedQuery.ranges.filter((range) => range.key !== CONST.SEARCH.SYNTAX_RANGE_NAME);

if (searchAutocompleteQueryRanges.length === 0) {
return {};
Expand Down
5 changes: 4 additions & 1 deletion src/components/Search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ type SearchFilterKey =
| typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS
| typeof CONST.SEARCH.SYNTAX_ROOT_KEYS.POLICY_ID;

type SearchAutocompleteQueryRangeKey = SearchFilterKey | typeof CONST.SEARCH.SYNTAX_RANGE_NAME;

type UserFriendlyKey = ValueOf<typeof CONST.SEARCH.SEARCH_USER_FRIENDLY_KEYS>;

type QueryFilters = Array<{
Expand Down Expand Up @@ -130,7 +132,7 @@ type SearchAutocompleteResult = {
};

type SearchAutocompleteQueryRange = {
key: SearchFilterKey;
key: SearchAutocompleteQueryRangeKey;
length: number;
start: number;
value: string;
Expand Down Expand Up @@ -159,4 +161,5 @@ export type {
SearchAutocompleteResult,
PaymentData,
SearchAutocompleteQueryRange,
SearchAutocompleteQueryRangeKey,
};
75 changes: 66 additions & 9 deletions src/libs/SearchAutocompleteUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type {MarkdownRange} from '@expensify/react-native-live-markdown';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import type {SearchAutocompleteResult} from '@components/Search/types';
import type {SharedValue} from 'react-native-reanimated/lib/typescript/commonTypes';
import type {SubstitutionMap} from '@components/Search/SearchRouter/getQueryWithSubstitutions';
import type {SearchAutocompleteQueryRange, SearchAutocompleteResult} from '@components/Search/types';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, PolicyCategories, PolicyTagLists, RecentlyUsedCategories, RecentlyUsedTags} from '@src/types/onyx';
Expand Down Expand Up @@ -133,26 +135,81 @@ function getAutocompleteQueryWithComma(prevQuery: string, newQuery: string) {
return newQuery;
}

function filterOutRangesWithCorrectValue(
range: SearchAutocompleteQueryRange,
userDisplayName: string,
substitutionMap: SubstitutionMap,
userLogins: SharedValue<string[]>,
currencyList: SharedValue<string[]>,
categoryList: SharedValue<string[]>,
tagList: SharedValue<string[]>,
) {
'worklet';

const typeList = Object.values(CONST.SEARCH.DATA_TYPES) as string[];
const expenseTypeList = Object.values(CONST.SEARCH.TRANSACTION_TYPE) as string[];
const statusList = Object.values({...CONST.SEARCH.STATUS.TRIP, ...CONST.SEARCH.STATUS.INVOICE, ...CONST.SEARCH.STATUS.CHAT, ...CONST.SEARCH.STATUS.TRIP}) as string[];

switch (range.key) {
case CONST.SEARCH.SYNTAX_FILTER_KEYS.IN:
case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE:
case CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID:
return substitutionMap[`${range.key}:${range.value}`] !== undefined;

case CONST.SEARCH.SYNTAX_FILTER_KEYS.TO:
case CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM:
return substitutionMap[`${range.key}:${range.value}`] !== undefined || userLogins.get().includes(range.value);

case CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY:
return currencyList.get().includes(range.value);
case CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE:
return typeList.includes(range.value);
case CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE:
return expenseTypeList.includes(range.value);
case CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS:
return statusList.includes(range.value);
case CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY:
return categoryList.get().includes(range.value);
case CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG:
return tagList.get().includes(range.value);
default:
return true;
}
}

/**
* Parses input string using the autocomplete parser and returns array of
* markdown ranges that can be used by RNMarkdownTextInput.
* It is simpler version of search parser that can be run on UI.
*/
function parseForLiveMarkdown(input: string, userLogins: string[], userDisplayName: string) {
function parseForLiveMarkdown(
input: string,
userDisplayName: string,
map: SubstitutionMap,
userLogins: SharedValue<string[]>,
currencyList: SharedValue<string[]>,
categoryList: SharedValue<string[]>,
tagList: SharedValue<string[]>,
) {
'worklet';

const parsedAutocomplete = parse(input) as SearchAutocompleteResult;
const ranges = parsedAutocomplete.ranges;
return ranges
.filter((range) => filterOutRangesWithCorrectValue(range, userDisplayName, map, userLogins, currencyList, categoryList, tagList))
.map((range) => {
let type = 'mention-user';

return ranges.map((range) => {
let type = 'mention-user';
if (range.key === CONST.SEARCH.SYNTAX_RANGE_NAME) {
type = CONST.SEARCH.SYNTAX_RANGE_NAME;
}
289Adam289 marked this conversation as resolved.
Show resolved Hide resolved

if ((range.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO || CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM) && (userLogins.includes(range.value) || range.value === userDisplayName)) {
type = 'mention-here';
}
if ((range.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO || CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM) && (userLogins.get().includes(range.value) || range.value === userDisplayName)) {
type = 'mention-here';
}

return {...range, type};
}) as MarkdownRange[];
return {...range, type};
}) as MarkdownRange[];
}

export {
Expand Down
Loading