Skip to content
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]);

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

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

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

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

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

tagSharedValue.set(tagAutocompleteList);
});
Copy link
Contributor

Choose a reason for hiding this comment

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

@289Adam289 You made a mistake here. You might want to fix this.

Copy link
Contributor

Choose a reason for hiding this comment

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

@shubham1206agra what do you think about me fixing this in my PR for short-mentions, its a quick small change, and my PR is also related to running parser code in worklet?

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok

}, [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);
}
Comment on lines +127 to +129
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like this condition logic led to the following issue:

which we fixed by adjusting the check such that we only setAutocompleteSubstitutions when the updatedSubstitutionsMap is not empty.

if (!isEqual(autocompleteSubstitutions, updatedSubstitutionsMap) && !isEmpty(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;
}

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