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

add display name replacement and make search page router editable #49838

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
44 changes: 35 additions & 9 deletions src/components/Search/SearchPageHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useMemo, useState} from 'react';
import React, {useEffect, useMemo, useState} from 'react';
import {InteractionManager, View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import Button from '@components/Button';
Expand All @@ -20,6 +20,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import * as SearchActions from '@libs/actions/Search';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import {getAllTaxRates} from '@libs/PolicyUtils';
import * as SearchUtils from '@libs/SearchUtils';
Expand All @@ -39,12 +40,14 @@ import type {SearchQueryJSON} from './types';

type HeaderWrapperProps = Pick<HeaderWithBackButtonProps, 'icon' | 'children'> & {
text: string;
value: string;
isCannedQuery: boolean;
onSubmit: () => void;
setValue: (input: string) => void;
};

function HeaderWrapper({icon, children, text, isCannedQuery}: HeaderWrapperProps) {
function HeaderWrapper({icon, children, text, value, isCannedQuery, onSubmit, setValue}: HeaderWrapperProps) {
const styles = useThemeStyles();

// If the icon is present, the header bar should be taller and use different font.
const isCentralPaneSettings = !!icon;

Expand All @@ -69,10 +72,10 @@ function HeaderWrapper({icon, children, text, isCannedQuery}: HeaderWrapperProps
) : (
<View style={styles.pr5}>
<SearchRouterInput
value={text}
setValue={() => {}}
value={value}
setValue={setValue}
onSubmit={onSubmit}
updateSearch={() => {}}
disabled
isFullWidth
wrapperStyle={[styles.searchRouterInputResults, styles.br2]}
wrapperFocusedStyle={styles.searchRouterInputResultsFocused}
Expand Down Expand Up @@ -128,13 +131,18 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) {
const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false);
const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false);

const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {});

const {status, type} = queryJSON;
const isCannedQuery = SearchUtils.isCannedSearchQuery(queryJSON);
const headerText = isCannedQuery ? translate(getHeaderContent(type).titleText) : SearchUtils.getSearchHeaderTitle(queryJSON, personalDetails, cardList, reports, taxRates);
const [inputValue, setInputValue] = useState(headerText);

useEffect(() => {
setInputValue(headerText);
}, [headerText]);

const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {});

const headerIcon = getHeaderContent(type).icon;
const headerText = isCannedQuery ? translate(getHeaderContent(type).titleText) : SearchUtils.getSearchHeaderTitle(queryJSON, personalDetails, cardList, reports, taxRates);

const handleDeleteExpenses = () => {
if (selectedTransactionsKeys.length === 0) {
Expand Down Expand Up @@ -321,12 +329,30 @@ function SearchPageHeader({queryJSON, hash}: SearchPageHeaderProps) {
Navigation.navigate(ROUTES.SEARCH_ADVANCED_FILTERS);
};

const onSubmit = () => {
if (!inputValue) {
return;
}
const inputQueryJSON = SearchUtils.buildSearchQueryJSON(inputValue);
if (inputQueryJSON) {
const standardizedQuery = SearchUtils.standardizeQueryJSON(inputQueryJSON, cardList, taxRates);
const query = SearchUtils.buildSearchQueryString(standardizedQuery);
SearchActions.clearAllFilters();
Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query}));
} else {
Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} user query failed to parse`, inputValue, false);
}
};

return (
<>
<HeaderWrapper
icon={headerIcon}
text={headerText}
value={inputValue}
isCannedQuery={isCannedQuery}
onSubmit={onSubmit}
setValue={setInputValue}
>
{headerButtonsOptions.length > 0 ? (
<ButtonWithDropdownMenu
Expand Down
7 changes: 6 additions & 1 deletion src/components/Search/SearchRouter/SearchRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import Log from '@libs/Log';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import {getAllTaxRates} from '@libs/PolicyUtils';
import type {OptionData} from '@libs/ReportUtils';
import * as SearchUtils from '@libs/SearchUtils';
import Navigation from '@navigation/Navigation';
Expand All @@ -41,6 +42,9 @@ function SearchRouter() {
const {isSearchRouterDisplayed, closeSearchRouter} = useSearchRouterContext();
const listRef = useRef<SelectionListHandle>(null);

const taxRates = getAllTaxRates();
const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST);

const [textInputValue, debouncedInputValue, setTextInputValue] = useDebouncedState('', 500);
const [userSearchQuery, setUserSearchQuery] = useState<SearchQueryJSON | undefined>(undefined);
const contextualReportID = useNavigationState<Record<string, {reportID: string}>, string | undefined>((state) => {
Expand Down Expand Up @@ -146,7 +150,8 @@ function SearchRouter() {
return;
}
closeSearchRouter();
const queryString = SearchUtils.buildSearchQueryString(query);
const standardizedQuery = SearchUtils.standardizeQueryJSON(query, cardList, taxRates);
const queryString = SearchUtils.buildSearchQueryString(standardizedQuery);
Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: queryString}));
clearUserQuery();
},
Expand Down
6 changes: 6 additions & 0 deletions src/components/Search/SearchRouter/SearchRouterInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ type SearchRouterInputProps = {
/** Callback to update search in SearchRouter */
updateSearch: (searchTerm: string) => void;

/** Callback invoked when the user submits the input */
onSubmit?: () => void;

/** SearchRouterList ref for managing TextInput and SearchRouterList focus */
routerListRef?: RefObject<SelectionListHandle>;

Expand All @@ -45,6 +48,7 @@ function SearchRouterInput({
value,
setValue,
updateSearch,
onSubmit = () => {},
routerListRef,
isFullWidth,
disabled = false,
Expand Down Expand Up @@ -75,7 +79,9 @@ function SearchRouterInput({
role={CONST.ROLE.PRESENTATION}
placeholder={translate('search.searchPlaceholder')}
autoCapitalize="none"
autoCorrect={false}
disabled={disabled}
onSubmitEditing={onSubmit}
shouldUseDisabledStyles={false}
textInputContainerStyles={styles.borderNone}
inputStyle={[styles.searchInputStyle, inputWidth, styles.pl3, styles.pr3]}
Expand Down
14 changes: 6 additions & 8 deletions src/components/Search/SearchStatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,10 @@ import type {TranslationPaths} from '@src/languages/types';
import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
import type IconAsset from '@src/types/utils/IconAsset';
import {useSearchContext} from './SearchContext';
import type {ChatSearchStatus, ExpenseSearchStatus, InvoiceSearchStatus, SearchStatus, TripSearchStatus} from './types';
import type {ChatSearchStatus, ExpenseSearchStatus, InvoiceSearchStatus, SearchQueryJSON, TripSearchStatus} from './types';

type SearchStatusBarProps = {
type: SearchDataTypes;
status: SearchStatus;
policyID: string | undefined;
queryJSON: SearchQueryJSON;
onStatusChange?: () => void;
};

Expand Down Expand Up @@ -154,13 +152,13 @@ function getOptions(type: SearchDataTypes) {
}
}

function SearchStatusBar({type, status, policyID, onStatusChange}: SearchStatusBarProps) {
function SearchStatusBar({queryJSON, onStatusChange}: SearchStatusBarProps) {
const {singleExecution} = useSingleExecution();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const theme = useTheme();
const {translate} = useLocalize();
const options = getOptions(type);
const options = getOptions(queryJSON.type);
const scrollRef = useRef<RNScrollView>(null);
const isScrolledRef = useRef(false);
const {shouldShowStatusBarLoading} = useSearchContext();
Expand All @@ -179,10 +177,10 @@ function SearchStatusBar({type, status, policyID, onStatusChange}: SearchStatusB
{options.map((item, index) => {
const onPress = singleExecution(() => {
onStatusChange?.();
const query = SearchUtils.buildCannedSearchQuery({type: item.type, status: item.status, policyID});
const query = SearchUtils.buildSearchQueryString({...queryJSON, status: item.status});
Navigation.setParams({q: query});
});
const isActive = status === item.status;
const isActive = queryJSON.status === item.status;
const isFirstItem = index === 0;
const isLastItem = index === options.length - 1;

Expand Down
95 changes: 88 additions & 7 deletions src/libs/SearchUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import cloneDeep from 'lodash/cloneDeep';
import type {OnyxCollection} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import type {ASTNode, QueryFilter, QueryFilters, SearchColumnType, SearchQueryJSON, SearchQueryString, SearchStatus, SortOrder} from '@components/Search/types';
Expand Down Expand Up @@ -728,18 +729,19 @@ function getPolicyIDFromSearchQuery(queryJSON: SearchQueryJSON) {

function getDisplayValue(filterName: string, filter: string, personalDetails: OnyxTypes.PersonalDetailsList, cardList: OnyxTypes.CardList, reports: OnyxCollection<OnyxTypes.Report>) {
if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) {
return PersonalDetailsUtils.createDisplayName(personalDetails?.[filter]?.login ?? '', personalDetails?.[filter]);
return personalDetails?.[filter]?.login ?? filter;
}
if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) {
return cardList[filter].bank;
return cardList[filter]?.bank ?? filter;
}
if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.IN) {
return ReportUtils.getReportName(reports?.[`${ONYXKEYS.COLLECTION.REPORT}${filter}`]);
}
return filter;
}

function buildFilterString(filterName: string, queryFilters: QueryFilter[], delimiter = ',') {
function buildFilterString(filterName: string, queryFilters: QueryFilter[]) {
const delimiter = filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD ? ' ' : ',';
let filterValueString = '';
queryFilters.forEach((queryFilter, index) => {
// If the previous queryFilter has the same operator (this rule applies only to eq and neq operators) then append the current value
Expand All @@ -748,6 +750,8 @@ function buildFilterString(filterName: string, queryFilters: QueryFilter[], deli
((queryFilter.operator === 'eq' && queryFilters?.at(index - 1)?.operator === 'eq') || (queryFilter.operator === 'neq' && queryFilters.at(index - 1)?.operator === 'neq'))
) {
filterValueString += `${delimiter}${sanitizeString(queryFilter.value.toString())}`;
} else if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.KEYWORD) {
filterValueString += `${delimiter}${sanitizeString(queryFilter.value.toString())}`;
} else {
filterValueString += ` ${filterName}${operatorToSignMap[queryFilter.operator]}${sanitizeString(queryFilter.value.toString())}`;
}
Expand All @@ -773,9 +777,15 @@ function getSearchHeaderTitle(
let displayQueryFilters: QueryFilter[] = [];
if (key === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) {
const taxRateIDs = queryFilter.map((filter) => filter.value.toString());
const taxRateNames = Object.entries(TaxRates)
.filter(([, taxRateKeys]) => taxRateKeys.some((taxID) => taxRateIDs.includes(taxID)))
.map(([taxRate]) => taxRate);
const taxRateNames = taxRateIDs
.map((id) => {
const taxRate = Object.entries(TaxRates)
.filter(([, IDs]) => IDs.includes(id))
.map(([name]) => name);
return taxRate?.length > 0 ? taxRate : id;
})
.flat();

displayQueryFilters = taxRateNames.map((taxRate) => ({
operator: queryFilter.at(0)?.operator ?? CONST.SEARCH.SYNTAX_OPERATORS.AND,
value: taxRate,
Expand All @@ -786,7 +796,7 @@ function getSearchHeaderTitle(
value: getDisplayValue(key, filter.value.toString(), PersonalDetails, cardList, reports),
}));
}
title += buildFilterString(key, displayQueryFilters, ' ');
title += buildFilterString(key, displayQueryFilters);
});

return title;
Expand Down Expand Up @@ -833,6 +843,76 @@ function getOverflowMenu(itemName: string, hash: number, inputQuery: string, sho
];
}

/**
* @private
* Given a filter name and its value, this function will try to find the corresponding ID.
*/
function findIDFromDisplayValue(filterName: ValueOf<typeof CONST.SEARCH.SYNTAX_FILTER_KEYS>, filter: string | string[], cardList: OnyxTypes.CardList, taxRates: Record<string, string[]>) {
if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) {
if (typeof filter === 'string') {
const email = filter;
return PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? filter;
}
const emails = filter;
return emails.map((email) => PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? email);
}
if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) {
const names = Array.isArray(filter) ? filter : ([filter] as string[]);
return names.map((name) => taxRates[name] ?? name).flat();
}
if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) {
if (typeof filter === 'string') {
const bank = filter;
const ids =
Object.values(cardList)
.filter((card) => card.bank === bank)
.map((card) => card.cardID.toString()) ?? filter;
return ids.length > 0 ? ids : bank;
}
const banks = filter;
return banks
.map(
(bank) =>
Object.values(cardList)
.filter((card) => card.bank === bank)
.map((card) => card.cardID.toString()) ?? bank,
)
.flat();
}
return filter;
}

/**
* Given a search query, this function will standardize the query by replacing display values with their corresponding IDs.
*/
function standardizeQueryJSON(queryJSON: SearchQueryJSON, cardList: OnyxTypes.CardList, taxRates: Record<string, string[]>) {
const standardQuery = cloneDeep(queryJSON);
const filters = standardQuery.filters;
const traverse = (node: ASTNode) => {
if (!node.operator) {
return;
}
if (typeof node.left === 'object' && node.left) {
traverse(node.left);
}
if (typeof node.right === 'object' && node.right && !Array.isArray(node.right)) {
traverse(node.right);
}

if (typeof node.left !== 'object') {
// eslint-disable-next-line no-param-reassign
node.right = findIDFromDisplayValue(node.left, node.right as string | string[], cardList, taxRates);
}
};

if (filters) {
traverse(filters);
}

standardQuery.flatFilters = getFilters(standardQuery);
return standardQuery;
}

/**
* Returns whether a given search query is a Canned query.
*
Expand Down Expand Up @@ -874,4 +954,5 @@ export {
getExpenseTypeTranslationKey,
getOverflowMenu,
isCorrectSearchUserName,
standardizeQueryJSON,
};
6 changes: 1 addition & 5 deletions src/pages/Search/SearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,7 @@ function SearchPage({route}: SearchPageProps) {
queryJSON={queryJSON}
hash={queryJSON.hash}
/>
<SearchStatusBar
type={queryJSON.type}
status={queryJSON.status}
policyID={queryJSON.policyID}
/>
<SearchStatusBar queryJSON={queryJSON} />
<Search queryJSON={queryJSON} />
</>
)}
Expand Down
4 changes: 1 addition & 3 deletions src/pages/Search/SearchPageBottomTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,7 @@ function SearchPageBottomTab() {
/>
{shouldUseNarrowLayout && (
<SearchStatusBar
type={queryJSON.type}
status={queryJSON.status}
policyID={queryJSON.policyID}
queryJSON={queryJSON}
onStatusChange={() => {
topBarOffset.value = withTiming(variables.searchHeaderHeight, {duration: ANIMATION_DURATION_IN_MS});
}}
Expand Down
Loading