-
Notifications
You must be signed in to change notification settings - Fork 3.7k
perf: Implement filtering in search page #37909
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
Changes from all commits
4c017a6
3cda73b
c2e3157
bd323a0
4a4ba24
74a2ecd
d2b234c
39b5036
b04b4ae
67e5dc8
7c2c077
d5a8f97
450c5f0
b71dbea
104b658
4dfeee1
8ea3f82
d9d0e84
32a7100
4702bdc
790a191
8099082
40a9715
3223b35
15ac693
5fbc428
f370b02
18e28c2
8d070e4
6636270
48fddbd
99a0cd7
671c0d8
eb4c265
ae77968
dea2fb3
528ff86
b0bd01d
eab2574
f81bbea
765ae52
f962092
e3b3b97
5ea71db
51771d9
a82c147
fae57e2
78cb949
480f3be
850dd5b
0682936
efedd80
f4fdacd
f94881e
7620b35
5b42f38
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -179,7 +180,7 @@ type MemberForList = { | |
| type SectionForSearchTerm = { | ||
| section: CategorySection; | ||
| }; | ||
| type GetOptions = { | ||
| type Options = { | ||
| recentReports: ReportUtils.OptionData[]; | ||
| personalDetails: ReportUtils.OptionData[]; | ||
| userToInvite: ReportUtils.OptionData | null; | ||
|
|
@@ -1497,6 +1498,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 | ||
| */ | ||
|
|
@@ -1539,7 +1569,7 @@ function getOptions( | |
| policyReportFieldOptions = [], | ||
| recentlyUsedPolicyReportFieldOptions = [], | ||
| }: GetOptionsConfig, | ||
| ): GetOptions { | ||
| ): Options { | ||
| if (includeCategories) { | ||
| const categoryOptions = getCategoryListSections(categories, recentlyUsedCategories, selectedOptions as Category[], searchInputValue, maxRecentReportsToShow); | ||
|
|
||
|
|
@@ -1597,7 +1627,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 | ||
|
|
@@ -1847,26 +1877,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 { | ||
|
|
@@ -1883,7 +1894,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, { | ||
|
|
@@ -1908,7 +1919,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(), | ||
|
|
@@ -2085,7 +2096,7 @@ function getMemberInviteOptions( | |
| searchValue = '', | ||
| excludeLogins: string[] = [], | ||
| includeSelectedOptions = false, | ||
| ): GetOptions { | ||
| ): Options { | ||
| return getOptions( | ||
| {reports: [], personalDetails}, | ||
| { | ||
|
|
@@ -2204,6 +2215,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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. probably you can initialize it outside of the function
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. as it is not used anywhere else, I'd leave it here so it's clear where and why this regex belongs to
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. rather than using a regex to replace dots in emails, can we just have
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hmm, I think it might negatively affect the performance, because we would have to call it for each value under a specified keys. With current approach, we can limit those checks only to the keys we need. |
||
|
|
||
| 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) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm a bit confused why we're doing a
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it might be hard to achieve, even current implementation loops through all the search terms. By default A good example from the app is searching for Additionally, note that with every next term, we are filtering the results narrowed to those matching the previous search terms. |
||
| 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, | ||
|
|
@@ -2236,10 +2331,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}; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would be nice to leave a comment that this is a slim version of what's available in |
| 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}; |
Uh oh!
There was an error while loading. Please reload this page.