diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index d1b83281b886..98828205d3fe 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -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; @@ -1498,6 +1499,35 @@ function createOptionFromReport(report: Report, personalDetails: OnyxEntry { + 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 */ @@ -1540,7 +1570,7 @@ function getOptions( policyReportFieldOptions = [], recentlyUsedPolicyReportFieldOptions = [], }: GetOptionsConfig, -): GetOptions { +): Options { if (includeCategories) { const categoryOptions = getCategoryListSections(categories, recentlyUsedCategories, selectedOptions as Category[], searchInputValue, maxRecentReportsToShow); @@ -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 @@ -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 { @@ -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, { @@ -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(), @@ -2086,7 +2097,7 @@ function getMemberInviteOptions( searchValue = '', excludeLogins: string[] = [], includeSelectedOptions = false, -): GetOptions { +): Options { return getOptions( {reports: [], personalDetails}, { @@ -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, @@ -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}; diff --git a/src/libs/StringUtils.ts b/src/libs/StringUtils.ts index 2fb918c7a233..94cd04046ccc 100644 --- a/src/libs/StringUtils.ts +++ b/src/libs/StringUtils.ts @@ -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}; diff --git a/src/libs/filterArrayByMatch.ts b/src/libs/filterArrayByMatch.ts new file mode 100644 index 000000000000..3abf82b6afab --- /dev/null +++ b/src/libs/filterArrayByMatch.ts @@ -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; + +/** + * 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(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}; diff --git a/src/pages/SearchPage/index.tsx b/src/pages/SearchPage/index.tsx index c072bfd56913..7d2a5bfecbb8 100644 --- a/src/pages/SearchPage/index.tsx +++ b/src/pages/SearchPage/index.tsx @@ -1,4 +1,5 @@ import type {StackScreenProps} from '@react-navigation/stack'; +import isEmpty from 'lodash/isEmpty'; import React, {useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -36,6 +37,8 @@ type SearchPageOnyxProps = { type SearchPageProps = SearchPageOnyxProps & StackScreenProps; +type Options = OptionsListUtils.Options & {headerMessage: string}; + type SearchPageSectionItem = { data: OptionData[]; shouldShow: boolean; @@ -72,24 +75,45 @@ function SearchPage({betas, isSearchingForReports, navigation}: SearchPageProps) Report.searchInServer(debouncedSearchValue.trim()); }, [debouncedSearchValue]); - const { - recentReports, - personalDetails: localPersonalDetails, - userToInvite, - headerMessage, - } = useMemo(() => { + const searchOptions: Options = useMemo(() => { if (!areOptionsInitialized) { return { recentReports: [], personalDetails: [], userToInvite: null, + currentUserOption: null, + categoryOptions: [], + tagOptions: [], + taxRatesOptions: [], headerMessage: '', }; } - const optionList = OptionsListUtils.getSearchOptions(options, debouncedSearchValue.trim(), betas ?? []); - const header = OptionsListUtils.getHeaderMessage(optionList.recentReports.length + optionList.personalDetails.length !== 0, Boolean(optionList.userToInvite), debouncedSearchValue); + const optionList = OptionsListUtils.getSearchOptions(options, '', betas ?? []); + const header = OptionsListUtils.getHeaderMessage(optionList.recentReports.length + optionList.personalDetails.length !== 0, Boolean(optionList.userToInvite), ''); return {...optionList, headerMessage: header}; - }, [areOptionsInitialized, options, debouncedSearchValue, betas]); + }, [areOptionsInitialized, betas, options]); + + const filteredOptions = useMemo(() => { + if (debouncedSearchValue.trim() === '') { + return { + recentReports: [], + personalDetails: [], + userToInvite: null, + headerMessage: '', + }; + } + + const newOptions = OptionsListUtils.filterOptions(searchOptions, debouncedSearchValue); + const header = OptionsListUtils.getHeaderMessage(newOptions.recentReports.length > 0, false, debouncedSearchValue); + return { + recentReports: newOptions.recentReports, + personalDetails: newOptions.personalDetails, + userToInvite: null, + headerMessage: header, + }; + }, [debouncedSearchValue, searchOptions]); + + const {recentReports, personalDetails: localPersonalDetails, userToInvite, headerMessage} = debouncedSearchValue.trim() !== '' ? filteredOptions : searchOptions; const sections = useMemo((): SearchPageSectionList => { const newSections: SearchPageSectionList = []; @@ -108,7 +132,7 @@ function SearchPage({betas, isSearchingForReports, navigation}: SearchPageProps) }); } - if (userToInvite) { + if (!isEmpty(userToInvite)) { newSections.push({ data: [userToInvite], shouldShow: true, diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts index 6333ee6f1bc7..af5782b1ca32 100644 --- a/tests/unit/OptionsListUtilsTest.ts +++ b/tests/unit/OptionsListUtilsTest.ts @@ -262,9 +262,23 @@ describe('OptionsListUtils', () => { }, }; + const REPORTS_WITH_CHAT_ROOM = { + ...REPORTS, + 15: { + lastReadTime: '2021-01-14 11:25:39.301', + lastVisibleActionCreated: '2022-11-22 03:26:02.000', + isPinned: false, + reportID: '15', + participantAccountIDs: [3, 4], + visibleChatMemberAccountIDs: [3, 4], + reportName: 'Spider-Man, Black Panther', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, + }, + }; + const PERSONAL_DETAILS_WITH_CONCIERGE: PersonalDetailsList = { ...PERSONAL_DETAILS, - '999': { accountID: 999, displayName: 'Concierge', @@ -2581,4 +2595,98 @@ describe('OptionsListUtils', () => { // `isDisabled` is always false expect(formattedMembers.every((personalDetail) => !personalDetail.isDisabled)).toBe(true); }); + + describe('filterOptions', () => { + it('should return all options when search is empty', () => { + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + const filteredOptions = OptionsListUtils.filterOptions(options, ''); + + expect(options.recentReports.length + options.personalDetails.length).toBe(filteredOptions.recentReports.length); + }); + + it('should return filtered options in correct order', () => { + const searchText = 'man'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + expect(filteredOptions.recentReports.length).toBe(5); + expect(filteredOptions.recentReports[0].text).toBe('Invisible Woman'); + expect(filteredOptions.recentReports[1].text).toBe('Spider-Man'); + expect(filteredOptions.recentReports[2].text).toBe('Black Widow'); + expect(filteredOptions.recentReports[3].text).toBe('Mister Fantastic'); + expect(filteredOptions.recentReports[4].text).toBe("SHIELD's workspace (archived)"); + }); + + it('should filter users by email', () => { + const searchText = 'mistersinister@marauders.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports[0].text).toBe('Mr Sinister'); + }); + + it('should find archived chats', () => { + const searchText = 'Archived'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports[0].isArchivedRoom).toBe(true); + }); + + it('should filter options by email if dot is skipped in the email', () => { + const searchText = 'barryallen'; + const OPTIONS_WITH_PERIODS = OptionsListUtils.createOptionList(PERSONAL_DETAILS_WITH_PERIODS, REPORTS); + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_PERIODS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports[0].login).toBe('barry.allen@expensify.com'); + }); + + it('should include workspaces in the search results', () => { + const searchText = 'avengers'; + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_WORKSPACES, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(1); + expect(filteredOptions.recentReports[0].subtitle).toBe('Avengers Room'); + }); + + it('should put exact match by login on the top of the list', () => { + const searchText = 'reedrichards@expensify.com'; + const options = OptionsListUtils.getSearchOptions(OPTIONS, '', [CONST.BETAS.ALL]); + + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(2); + expect(filteredOptions.recentReports[0].login).toBe(searchText); + }); + + it('should prioritize options with matching display name over chatrooms', () => { + const searchText = 'spider'; + const OPTIONS_WITH_CHATROOMS = OptionsListUtils.createOptionList(PERSONAL_DETAILS, REPORTS_WITH_CHAT_ROOM); + const options = OptionsListUtils.getSearchOptions(OPTIONS_WITH_CHATROOMS, '', [CONST.BETAS.ALL]); + + const filterOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filterOptions.recentReports.length).toBe(2); + expect(filterOptions.recentReports[1].isChatRoom).toBe(true); + }); + + it('should put the item with latest lastVisibleActionCreated on top when search value match multiple items', () => { + const searchText = 'fantastic'; + + const options = OptionsListUtils.getSearchOptions(OPTIONS, ''); + const filteredOptions = OptionsListUtils.filterOptions(options, searchText); + + expect(filteredOptions.recentReports.length).toBe(2); + expect(filteredOptions.recentReports[0].text).toBe('Mister Fantastic'); + expect(filteredOptions.recentReports[1].text).toBe('Mister Fantastic'); + }); + }); }); diff --git a/tests/unit/StringUtilsTest.ts b/tests/unit/StringUtilsTest.ts new file mode 100644 index 000000000000..04ce748c984b --- /dev/null +++ b/tests/unit/StringUtilsTest.ts @@ -0,0 +1,20 @@ +import StringUtils from '@libs/StringUtils'; + +describe('StringUtils', () => { + describe('getAcronym', () => { + it('should return the acronym of a string with multiple words', () => { + const acronym = StringUtils.getAcronym('Hello World'); + expect(acronym).toBe('HW'); + }); + + it('should return an acronym of a string with a single word', () => { + const acronym = StringUtils.getAcronym('Hello'); + expect(acronym).toBe('H'); + }); + + it('should return an acronym of a string when word in a string has a hyphen', () => { + const acronym = StringUtils.getAcronym('Hello Every-One'); + expect(acronym).toBe('HEO'); + }); + }); +});