Skip to content

Commit

Permalink
Merge pull request #56243 from software-mansion-labs/289Adam289/50949…
Browse files Browse the repository at this point in the history
…-highlight-autocomplete-on-match

Highlight autocomplete value on a match
  • Loading branch information
luacmartins authored Feb 6, 2025
2 parents 8aa6de8 + 829f608 commit 7fa2cb1
Show file tree
Hide file tree
Showing 17 changed files with 281 additions and 54 deletions.
1 change: 1 addition & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6193,6 +6193,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]);

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
11 changes: 6 additions & 5 deletions src/components/Search/SearchRouter/getUpdatedSubstitutionsMap.ts
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;
}

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

0 comments on commit 7fa2cb1

Please sign in to comment.