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 17 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_KEY: 'syntax',
Copy link
Member

Choose a reason for hiding this comment

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

this name might be confusing for other developers.

However for now I can't come up with a name better than SYNTAX_KEY_NAME. The main problem is that this const is not simply some "syntax key" but the NAME for key representing syntax 😅 which is why I would like it to have a bit longer more descriptive name.

If you can't come up with anything better just leave as is

Copy link
Contributor Author

Choose a reason for hiding this comment

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

How about SYNTAX_RANGE_NAME?

SYNTAX_ROOT_KEYS: {
TYPE: 'type',
STATUS: 'status',
Expand Down
86 changes: 79 additions & 7 deletions src/components/Search/SearchAutocompleteInput.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,28 @@
import isEqual from 'lodash/isEqual';
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 +66,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 +90,88 @@ 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 [map, setMap] = useState({});

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);

useEffect(() => {
if (isEqual(map, substitutionMap)) {
return;
}
setMap(substitutionMap);
}, [substitutionMap, map]);

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 ?? '', map, emailListSharedValue, currencySharedValue, categorySharedValue, tagSharedValue);
},
[currentUserPersonalDetails.displayName, map, currencySharedValue, categorySharedValue, tagSharedValue, emailListSharedValue],
);

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

// Parse Fullstory attributes on initial render
Expand Down Expand Up @@ -145,11 +221,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
1 change: 1 addition & 0 deletions src/components/Search/SearchPageHeaderInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps
autocompleteListRef={listRef}
ref={textInputRef}
selection={selection}
substitutionMap={autocompleteSubstitutions}
/>
<View style={[styles.mh85vh, !isAutocompleteListVisible && styles.dNone]}>
<SearchAutocompleteList
Expand Down
1 change: 1 addition & 0 deletions src/components/Search/SearchRouter/SearchRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,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 !== 'syntax');

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 {parse} from '@libs/SearchParser/autocompleteParser';
import {sanitizeSearchValue} from '@libs/SearchQueryUtils';
import type CONST from '@src/CONST';

type SubstitutionMap = Record<string, string>;

const getSubstitutionMapKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`;
const getSubstitutionMapKey = (filterKey: SearchFilterKey | typeof CONST.SEARCH.SYNTAX_KEY, 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 !== 'syntax');

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 {parse} from '@libs/SearchParser/autocompleteParser';
import type CONST from '@src/CONST';
import type {SubstitutionMap} from './getQueryWithSubstitutions';

const getSubstitutionsKey = (filterKey: SearchFilterKey, value: string) => `${filterKey}:${value}`;
const getSubstitutionsKey = (filterKey: SearchFilterKey | typeof CONST.SEARCH.SYNTAX_KEY, 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 !== 'syntax');

if (searchAutocompleteQueryRanges.length === 0) {
return {};
Expand Down
2 changes: 1 addition & 1 deletion src/components/Search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ type SearchAutocompleteResult = {
};

type SearchAutocompleteQueryRange = {
key: SearchFilterKey;
key: SearchFilterKey | typeof CONST.SEARCH.SYNTAX_KEY;
length: number;
start: number;
value: string;
Expand Down
56 changes: 46 additions & 10 deletions src/libs/SearchAutocompleteUtils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type {MarkdownRange} from '@expensify/react-native-live-markdown';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import type {SharedValue} from 'react-native-reanimated/lib/typescript/commonTypes';
import type {SubstitutionMap} from '@components/Search/SearchRouter/getQueryWithSubstitutions';
import type {SearchAutocompleteResult} from '@components/Search/types';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand Down Expand Up @@ -138,21 +140,55 @@ function getAutocompleteQueryWithComma(prevQuery: string, newQuery: string) {
* 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.map((range) => {
let type = 'mention-user';

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';
}

return {...range, type};
}) as MarkdownRange[];
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[];
const subMap = map;
return ranges
.filter(
(range) =>
!(range.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.IN || range.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE || range.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) ||
subMap[`${range.key}:${range.value}`] !== undefined,
)
.filter(
(range) =>
!(range.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO || range.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM) ||
subMap[`${range.key}:${range.value}`] !== undefined ||
userLogins.get().includes(range.value),
)
.filter((range) => range.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.CURRENCY || currencyList.get().includes(range.value))
.filter((range) => range.key !== CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE || typeList.includes(range.value))
.filter((range) => range.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPENSE_TYPE || expenseTypeList.includes(range.value))
.filter((range) => range.key !== CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS || statusList.includes(range.value))
.filter((range) => range.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY || categoryList.get().includes(range.value))
.filter((range) => range.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.TAG || tagList.get().includes(range.value))
.map((range) => {
let type = 'mention-user';

if (range.key === 'syntax') {
type = 'syntax';
}

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

export {
Expand Down
22 changes: 18 additions & 4 deletions src/libs/SearchParser/autocompleteParser.js
Original file line number Diff line number Diff line change
Expand Up @@ -283,24 +283,38 @@ function peg$parse(input, options) {
if (!value) {
autocomplete = {
key,
value: '',
value: "",
start: location().end.offset,
length: 0,
};
return;
return {
key: "syntax",
value: key,
start: location().start.offset,
length: location().end.offset - location().start.offset,
};
}

autocomplete = {
key,
...value[value.length - 1],
};

return value
const result = value
.filter((filter) => filter.length > 0)
.map((filter) => ({
key,
...filter,
}));

return [
{
key: "syntax",
value: key,
start: location().start.offset,
length: result[0].start - location().start.offset,
},
...result,
];
};
var peg$f3 = function() { autocomplete = null; };
var peg$f4 = function(parts, empty) {
Expand Down
24 changes: 19 additions & 5 deletions src/libs/SearchParser/autocompleteParser.peggy
Original file line number Diff line number Diff line change
Expand Up @@ -32,27 +32,41 @@ defaultFilter
if (!value) {
autocomplete = {
key,
value: '',
value: "",
start: location().end.offset,
length: 0,
};
return;
return {
key: "syntax",
value: key,
start: location().start.offset,
length: location().end.offset - location().start.offset,
};
}

autocomplete = {
key,
...value[value.length - 1],
};

return value
const result = value
.filter((filter) => filter.length > 0)
.map((filter) => ({
key,
...filter,
}));

return [
{
key: "syntax",
value: key,
start: location().start.offset,
length: result[0].start - location().start.offset,
},
...result,
];
}

freeTextFilter = _ (identifier/ ",") _ { autocomplete = null; }
freeTextFilter = _ (identifier / ",") _ { autocomplete = null; }

autocompleteKey "key"
= @(
Expand Down
Loading
Loading