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

Make autocomplete accountIDs work when pasting user emails #53453

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
24 changes: 5 additions & 19 deletions src/components/Search/SearchPageHeaderInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
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 type {OptionData} from '@libs/ReportUtils';
Expand Down Expand Up @@ -112,28 +111,15 @@ function SearchPageHeaderInput({queryJSON, children}: SearchPageHeaderInputProps

const submitSearch = useCallback(
(queryString: SearchQueryString) => {
if (!queryString) {
const queryWithSubstitutions = getQueryWithSubstitutions(queryString, autocompleteSubstitutions);
const updatedQuery = SearchQueryUtils.getQueryWithUpdatedValues(queryWithSubstitutions, queryJSON.policyID);
if (!updatedQuery) {
return;
}

const cleanedQueryString = getQueryWithSubstitutions(queryString, autocompleteSubstitutions);
const userQueryJSON = SearchQueryUtils.buildSearchQueryJSON(cleanedQueryString);
Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: updatedQuery}));

if (!userQueryJSON) {
Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} user query failed to parse`, {}, false);
return;
}

if (queryJSON.policyID) {
userQueryJSON.policyID = queryJSON.policyID;
}

const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(userQueryJSON, SearchQueryUtils.getUpdatedAmountValue);
const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery);

Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query}));

if (query !== originalInputQuery) {
if (updatedQuery !== originalInputQuery) {
SearchActions.clearAllFilters();
setTextInputValue('');
setAutocompleteQueryValue('');
Expand Down
13 changes: 5 additions & 8 deletions src/components/Search/SearchRouter/SearchRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,17 +250,14 @@ function SearchRouter({onRouterClose, shouldHideInputCaret}: SearchRouterProps)

const submitSearch = useCallback(
(queryString: SearchQueryString) => {
const cleanedQueryString = getQueryWithSubstitutions(queryString, autocompleteSubstitutions);
const queryJSON = SearchQueryUtils.buildSearchQueryJSON(cleanedQueryString);
if (!queryJSON) {
const queryWithSubstitutions = getQueryWithSubstitutions(queryString, autocompleteSubstitutions);
const updatedQuery = SearchQueryUtils.getQueryWithUpdatedValues(queryWithSubstitutions, activeWorkspaceID);
if (!updatedQuery) {
return;
}
queryJSON.policyID = activeWorkspaceID;
onRouterClose();

const standardizedQuery = SearchQueryUtils.traverseAndUpdatedQuery(queryJSON, SearchQueryUtils.getUpdatedAmountValue);
const query = SearchQueryUtils.buildSearchQueryString(standardizedQuery);
Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query}));
onRouterClose();
Navigation.navigate(ROUTES.SEARCH_CENTRAL_PANE.getRoute({query: updatedQuery}));

setTextInputValue('');
setAutocompleteQueryValue('');
Expand Down
60 changes: 46 additions & 14 deletions src/libs/SearchQueryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
import * as CardUtils from './CardUtils';
import * as CurrencyUtils from './CurrencyUtils';
import localeCompare from './LocaleCompare';
import Log from './Log';
import {validateAmount} from './MoneyRequestUtils';
import * as PersonalDetailsUtils from './PersonalDetailsUtils';
import {getTagNamesFromTagsLists} from './PolicyUtils';
import * as ReportUtils from './ReportUtils';
import * as searchParser from './SearchParser/searchParser';
Expand Down Expand Up @@ -163,21 +165,32 @@ function getFilters(queryJSON: SearchQueryJSON) {
}

/**
* Returns an updated amount value for query filters, correctly formatted to "backend" amount
* @private
* Returns an updated filter value for some query filters.
* - for `AMOUNT` it formats value to "backend" amount
* - for personal filters it tries to substitute any user emails with accountIDs
*/
function getUpdatedAmountValue(filterName: ValueOf<typeof CONST.SEARCH.SYNTAX_FILTER_KEYS>, filter: string | string[]) {
if (filterName !== CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) {
return filter;
function getUpdatedFilterValue(filterName: ValueOf<typeof CONST.SEARCH.SYNTAX_FILTER_KEYS>, filterValue: string | string[]) {
if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO) {
if (typeof filterValue === 'string') {
return PersonalDetailsUtils.getPersonalDetailByEmail(filterValue)?.accountID.toString() ?? filterValue;
}

return filterValue.map((email) => PersonalDetailsUtils.getPersonalDetailByEmail(email)?.accountID.toString() ?? email);
}

if (typeof filter === 'string') {
const backendAmount = CurrencyUtils.convertToBackendAmount(Number(filter));
return Number.isNaN(backendAmount) ? filter : backendAmount.toString();
if (filterName === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) {
if (typeof filterValue === 'string') {
const backendAmount = CurrencyUtils.convertToBackendAmount(Number(filterValue));
return Number.isNaN(backendAmount) ? filterValue : backendAmount.toString();
}
return filterValue.map((amount) => {
const backendAmount = CurrencyUtils.convertToBackendAmount(Number(amount));
return Number.isNaN(backendAmount) ? amount : backendAmount.toString();
});
}
return filter.map((amount) => {
const backendAmount = CurrencyUtils.convertToBackendAmount(Number(amount));
return Number.isNaN(backendAmount) ? amount : backendAmount.toString();
});

return filterValue;
}

/**
Expand Down Expand Up @@ -266,7 +279,7 @@ function buildSearchQueryString(queryJSON?: SearchQueryJSON) {

for (const filter of filters) {
const filterValueString = buildFilterValuesString(filter.key, filter.filters);
queryParts.push(filterValueString);
queryParts.push(filterValueString.trim());
}

return queryParts.join(' ');
Expand Down Expand Up @@ -625,6 +638,26 @@ function traverseAndUpdatedQuery(queryJSON: SearchQueryJSON, computeNodeValue: (
return standardQuery;
}

/**
* Returns new string query, after parsing it and traversing to update some filter values.
* If there are any personal emails, it will try to substitute them with accountIDs
*/
function getQueryWithUpdatedValues(query: string, policyID?: string) {
const queryJSON = buildSearchQueryJSON(query);

if (!queryJSON) {
Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} user query failed to parse`, {}, false);
return;
}

if (policyID) {
queryJSON.policyID = policyID;
}

const standardizedQuery = traverseAndUpdatedQuery(queryJSON, getUpdatedFilterValue);
return buildSearchQueryString(standardizedQuery);
}

export {
buildSearchQueryJSON,
buildSearchQueryString,
Expand All @@ -635,7 +668,6 @@ export {
getPolicyIDFromSearchQuery,
buildCannedSearchQuery,
isCannedSearchQuery,
traverseAndUpdatedQuery,
getUpdatedAmountValue,
sanitizeSearchValue,
getQueryWithUpdatedValues,
};
66 changes: 66 additions & 0 deletions tests/unit/Search/SearchQueryUtilsTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/* eslint-disable @typescript-eslint/naming-convention */
// we need "dirty" object key names in these tests
import {getQueryWithUpdatedValues} from '@src/libs/SearchQueryUtils';

const personalDetailsFakeData = {
'[email protected]': {
accountID: 12345,
},
'[email protected]': {
accountID: 78901,
},
} as Record<string, {accountID: number}>;

jest.mock('@libs/PersonalDetailsUtils', () => {
return {
getPersonalDetailByEmail(email: string) {
return personalDetailsFakeData[email];
},
};
});

// The default query is generated by default values from parser, which are defined in grammar.
// We don't want to test or mock the grammar and the parser, so we're simply defining this string directly here.
const defaultQuery = `type:expense status:all sortBy:date sortOrder:desc`;

describe('getQueryWithUpdatedValues', () => {
test('returns default query for empty value', () => {
const userQuery = '';

const result = getQueryWithUpdatedValues(userQuery);

expect(result).toEqual(defaultQuery);
});

test('returns query with updated amounts', () => {
const userQuery = 'foo test amount:20000';

const result = getQueryWithUpdatedValues(userQuery);

expect(result).toEqual(`${defaultQuery} amount:2000000 foo test`);
});

test('returns query with user emails substituted', () => {
const userQuery = 'from:[email protected] hello';

const result = getQueryWithUpdatedValues(userQuery);

expect(result).toEqual(`${defaultQuery} from:12345 hello`);
});

test('returns query with user emails substituted and preserves user ids', () => {
const userQuery = 'from:[email protected] to:112233';

const result = getQueryWithUpdatedValues(userQuery);

expect(result).toEqual(`${defaultQuery} from:12345 to:112233`);
});

test('returns query with all of the fields correctly substituted', () => {
const userQuery = 'from:9876,87654 to:[email protected] hello amount:150 test';

const result = getQueryWithUpdatedValues(userQuery);

expect(result).toEqual(`${defaultQuery} from:9876,87654 to:78901 amount:15000 hello test`);
});
});
Loading