Skip to content

Commit

Permalink
Merge pull request Expensify#37909 from callstack-internal/perf/searc…
Browse files Browse the repository at this point in the history
…h-options-filtering

perf: Implement filtering in search page
  • Loading branch information
roryabraham authored Apr 10, 2024
2 parents d990036 + 5b42f38 commit 8ae5491
Show file tree
Hide file tree
Showing 6 changed files with 421 additions and 39 deletions.
150 changes: 123 additions & 27 deletions src/libs/OptionsListUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import times from '@src/utils/times';
import Timing from './actions/Timing';
import * as CollectionUtils from './CollectionUtils';
import * as ErrorUtils from './ErrorUtils';
import filterArrayByMatch from './filterArrayByMatch';
import localeCompare from './LocaleCompare';
import * as LocalePhoneNumber from './LocalePhoneNumber';
import * as Localize from './Localize';
Expand Down Expand Up @@ -179,7 +180,7 @@ type MemberForList = {
type SectionForSearchTerm = {
section: CategorySection;
};
type GetOptions = {
type Options = {
recentReports: ReportUtils.OptionData[];
personalDetails: ReportUtils.OptionData[];
userToInvite: ReportUtils.OptionData | null;
Expand Down Expand Up @@ -1498,6 +1499,35 @@ function createOptionFromReport(report: Report, personalDetails: OnyxEntry<Perso
};
}

/**
* Options need to be sorted in the specific order
* @param options - list of options to be sorted
* @param searchValue - search string
* @returns a sorted list of options
*/
function orderOptions(options: ReportUtils.OptionData[], searchValue: string | undefined) {
return lodashOrderBy(
options,
[
(option) => {
if (!!option.isChatRoom || option.isArchivedRoom) {
return 3;
}
if (!option.login) {
return 2;
}
if (option.login.toLowerCase() !== searchValue?.toLowerCase()) {
return 1;
}

// When option.login is an exact match with the search value, returning 0 puts it at the top of the option list
return 0;
},
],
['asc'],
);
}

/**
* filter options based on specific conditions
*/
Expand Down Expand Up @@ -1540,7 +1570,7 @@ function getOptions(
policyReportFieldOptions = [],
recentlyUsedPolicyReportFieldOptions = [],
}: GetOptionsConfig,
): GetOptions {
): Options {
if (includeCategories) {
const categoryOptions = getCategoryListSections(categories, recentlyUsedCategories, selectedOptions as Category[], searchInputValue, maxRecentReportsToShow);

Expand Down Expand Up @@ -1598,7 +1628,7 @@ function getOptions(
}

const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchInputValue)));
const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number?.e164 : searchInputValue.toLowerCase();
const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number?.e164 ?? '' : searchInputValue.toLowerCase();
const topmostReportId = Navigation.getTopmostReportId() ?? '';

// Filter out all the reports that shouldn't be displayed
Expand Down Expand Up @@ -1848,26 +1878,7 @@ function getOptions(
// When sortByReportTypeInSearch is true, recentReports will be returned with all the reports including personalDetailsOptions in the correct Order.
recentReportOptions.push(...personalDetailsOptions);
personalDetailsOptions = [];
recentReportOptions = lodashOrderBy(
recentReportOptions,
[
(option) => {
if (!!option.isChatRoom || option.isArchivedRoom) {
return 3;
}
if (!option.login) {
return 2;
}
if (option.login.toLowerCase() !== searchValue?.toLowerCase()) {
return 1;
}

// When option.login is an exact match with the search value, returning 0 puts it at the top of the option list
return 0;
},
],
['asc'],
);
recentReportOptions = orderOptions(recentReportOptions, searchValue);
}

return {
Expand All @@ -1884,7 +1895,7 @@ function getOptions(
/**
* Build the options for the Search view
*/
function getSearchOptions(options: OptionList, searchValue = '', betas: Beta[] = []): GetOptions {
function getSearchOptions(options: OptionList, searchValue = '', betas: Beta[] = []): Options {
Timing.start(CONST.TIMING.LOAD_SEARCH_OPTIONS);
Performance.markStart(CONST.TIMING.LOAD_SEARCH_OPTIONS);
const optionList = getOptions(options, {
Expand All @@ -1909,7 +1920,7 @@ function getSearchOptions(options: OptionList, searchValue = '', betas: Beta[] =
return optionList;
}

function getShareLogOptions(options: OptionList, searchValue = '', betas: Beta[] = []): GetOptions {
function getShareLogOptions(options: OptionList, searchValue = '', betas: Beta[] = []): Options {
return getOptions(options, {
betas,
searchInputValue: searchValue.trim(),
Expand Down Expand Up @@ -2086,7 +2097,7 @@ function getMemberInviteOptions(
searchValue = '',
excludeLogins: string[] = [],
includeSelectedOptions = false,
): GetOptions {
): Options {
return getOptions(
{reports: [], personalDetails},
{
Expand Down Expand Up @@ -2205,6 +2216,90 @@ function formatSectionsFromSearchTerm(
};
}

/**
* Filters options based on the search input value
*/
function filterOptions(options: Options, searchInputValue: string): Options {
const searchValue = getSearchValueForPhoneOrEmail(searchInputValue);
const searchTerms = searchValue ? searchValue.split(' ') : [];

// The regex below is used to remove dots only from the local part of the user email (local-part@domain)
// so that we can match emails that have dots without explicitly writing the dots (e.g: fistlast@domain will match first.last@domain)
const emailRegex = /\.(?=[^\s@]*@)/g;

const getParticipantsLoginsArray = (item: ReportUtils.OptionData) => {
const keys: string[] = [];
const visibleChatMemberAccountIDs = item.participantsList ?? [];
if (allPersonalDetails) {
visibleChatMemberAccountIDs.forEach((participant) => {
const login = participant?.login;

if (participant?.displayName) {
keys.push(participant.displayName);
}

if (login) {
keys.push(login);
keys.push(login.replace(emailRegex, ''));
}
});
}

return keys;
};
const matchResults = searchTerms.reduceRight((items, term) => {
const recentReports = filterArrayByMatch(items.recentReports, term, (item) => {
let values: string[] = [];
if (item.text) {
values.push(item.text);
}

if (item.login) {
values.push(item.login);
values.push(item.login.replace(emailRegex, ''));
}

if (item.isThread) {
if (item.alternateText) {
values.push(item.alternateText);
}
} else if (!!item.isChatRoom || !!item.isPolicyExpenseChat) {
if (item.subtitle) {
values.push(item.subtitle);
}
}
values = values.concat(getParticipantsLoginsArray(item));

return uniqFast(values);
});
const personalDetails = filterArrayByMatch(items.personalDetails, term, (item) =>
uniqFast([item.participantsList?.[0]?.displayName ?? '', item.login ?? '', item.login?.replace(emailRegex, '') ?? '']),
);

return {
recentReports: recentReports ?? [],
personalDetails: personalDetails ?? [],
userToInvite: null,
currentUserOption: null,
categoryOptions: [],
tagOptions: [],
taxRatesOptions: [],
};
}, options);

const recentReports = matchResults.recentReports.concat(matchResults.personalDetails);

return {
personalDetails: [],
recentReports: orderOptions(recentReports, searchValue),
userToInvite: null,
currentUserOption: null,
categoryOptions: [],
tagOptions: [],
taxRatesOptions: [],
};
}

export {
getAvatarsForAccountIDs,
isCurrentUser,
Expand Down Expand Up @@ -2237,10 +2332,11 @@ export {
formatSectionsFromSearchTerm,
transformedTaxRates,
getShareLogOptions,
filterOptions,
createOptionList,
createOptionFromReport,
getReportOption,
getTaxRatesSection,
};

export type {MemberForList, CategorySection, CategoryTreeSection, GetOptions, OptionList, SearchOption, PayeePersonalDetails, Category, TaxRatesOption};
export type {MemberForList, CategorySection, CategoryTreeSection, Options, OptionList, SearchOption, PayeePersonalDetails, Category, TaxRatesOption};
19 changes: 18 additions & 1 deletion src/libs/StringUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,21 @@ function normalizeCRLF(value?: string): string | undefined {
return value?.replace(/\r\n/g, '\n');
}

export default {sanitizeString, isEmptyString, removeInvisibleCharacters, normalizeCRLF};
/**
* Generates an acronym for a string.
* @param string the string for which to produce the acronym
* @returns the acronym
*/
function getAcronym(string: string): string {
let acronym = '';
const wordsInString = string.split(' ');
wordsInString.forEach((wordInString) => {
const splitByHyphenWords = wordInString.split('-');
splitByHyphenWords.forEach((splitByHyphenWord) => {
acronym += splitByHyphenWord.substring(0, 1);
});
});
return acronym;
}

export default {sanitizeString, isEmptyString, removeInvisibleCharacters, normalizeCRLF, getAcronym};
117 changes: 117 additions & 0 deletions src/libs/filterArrayByMatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* This file is a slim version of match-sorter library (https://github.com/kentcdodds/match-sorter) adjusted to the needs.
Use `threshold` option with one of the rankings defined below to control the strictness of the match.
*/
import type {ValueOf} from 'type-fest';
import StringUtils from './StringUtils';

const MATCH_RANK = {
CASE_SENSITIVE_EQUAL: 7,
EQUAL: 6,
STARTS_WITH: 5,
WORD_STARTS_WITH: 4,
CONTAINS: 3,
ACRONYM: 2,
MATCHES: 1,
NO_MATCH: 0,
} as const;

type Ranking = ValueOf<typeof MATCH_RANK>;

/**
* Gives a rankings score based on how well the two strings match.
* @param testString - the string to test against
* @param stringToRank - the string to rank
* @returns the ranking for how well stringToRank matches testString
*/
function getMatchRanking(testString: string, stringToRank: string): Ranking {
// too long
if (stringToRank.length > testString.length) {
return MATCH_RANK.NO_MATCH;
}

// case sensitive equals
if (testString === stringToRank) {
return MATCH_RANK.CASE_SENSITIVE_EQUAL;
}

// Lower casing before further comparison
const lowercaseTestString = testString.toLowerCase();
const lowercaseStringToRank = stringToRank.toLowerCase();

// case insensitive equals
if (lowercaseTestString === lowercaseStringToRank) {
return MATCH_RANK.EQUAL;
}

// starts with
if (lowercaseTestString.startsWith(lowercaseStringToRank)) {
return MATCH_RANK.STARTS_WITH;
}

// word starts with
if (lowercaseTestString.includes(` ${lowercaseStringToRank}`)) {
return MATCH_RANK.WORD_STARTS_WITH;
}

// contains
if (lowercaseTestString.includes(lowercaseStringToRank)) {
return MATCH_RANK.CONTAINS;
}
if (lowercaseStringToRank.length === 1) {
return MATCH_RANK.NO_MATCH;
}

// acronym
if (StringUtils.getAcronym(lowercaseTestString).includes(lowercaseStringToRank)) {
return MATCH_RANK.ACRONYM;
}

// will return a number between rankings.MATCHES and rankings.MATCHES + 1 depending on how close of a match it is.
let matchingInOrderCharCount = 0;
let charNumber = 0;
for (const char of stringToRank) {
charNumber = lowercaseTestString.indexOf(char, charNumber) + 1;
if (!charNumber) {
return MATCH_RANK.NO_MATCH;
}
matchingInOrderCharCount++;
}

// Calculate ranking based on character sequence and spread
const spread = charNumber - lowercaseTestString.indexOf(stringToRank[0]);
const spreadPercentage = 1 / spread;
const inOrderPercentage = matchingInOrderCharCount / stringToRank.length;
const ranking = MATCH_RANK.MATCHES + inOrderPercentage * spreadPercentage;

return ranking as Ranking;
}

/**
* Takes an array of items and a value and returns a new array with the items that match the given value
* @param items - the items to filter
* @param searchValue - the value to use for ranking
* @param extractRankableValuesFromItem - an array of functions
* @returns the new filtered array
*/
function filterArrayByMatch<T = string>(items: readonly T[], searchValue: string, extractRankableValuesFromItem: (item: T) => string[]): T[] {
const filteredItems = [];
for (const item of items) {
const valuesToRank = extractRankableValuesFromItem(item);
let itemRank: Ranking = MATCH_RANK.NO_MATCH;
for (const value of valuesToRank) {
const rank = getMatchRanking(value, searchValue);
if (rank > itemRank) {
itemRank = rank;
}
}

if (itemRank >= MATCH_RANK.MATCHES + 1) {
filteredItems.push(item);
}
}
return filteredItems;
}

export default filterArrayByMatch;
export {MATCH_RANK};
Loading

0 comments on commit 8ae5491

Please sign in to comment.