diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector.base.ts index 4f78ca5d1cdc..1d0acfa6a0e6 100644 --- a/src/hooks/useSearchSelector.base.ts +++ b/src/hooks/useSearchSelector.base.ts @@ -5,7 +5,8 @@ import type {PermissionStatus} from 'react-native-permissions'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; import type {GetOptionsConfig, Option, Options, SearchOption} from '@libs/OptionsListUtils'; -import {getEmptyOptions, getPersonalDetailSearchTerms, getSearchOptions, getSearchValueForPhoneOrEmail, getValidOptions} from '@libs/OptionsListUtils'; +import {getEmptyOptions, getSearchOptions, getSearchValueForPhoneOrEmail, getValidOptions} from '@libs/OptionsListUtils'; +import {getPersonalDetailSearchTerms} from '@libs/OptionsListUtils/searchMatchUtils'; import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 943162be1021..8cef9315322e 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -188,6 +188,7 @@ import type { } from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {doesPersonalDetailMatchSearchTerm, getCurrentUserSearchTerms, getPersonalDetailSearchTerms} from './searchMatchUtils'; import type { FilterUserToInviteConfig, GetOptionsConfig, @@ -2668,10 +2669,12 @@ function getValidOptions( if (personalDetailLoginsToExclude[personalDetail.login]) { return false; } - const personalDetailSearchTerms = getPersonalDetailSearchTerms(personalDetail, currentUserAccountID); - const searchText = deburr(`${personalDetailSearchTerms.join(' ')} ${personalDetail.text ?? ''}`.toLocaleLowerCase()); - - return searchTerms.every((term) => searchText.includes(term)); + return searchTerms.every((term) => + doesPersonalDetailMatchSearchTerm(personalDetail, currentUserAccountID, term, { + useLocaleLowerCase: true, + transformSearchText: (concatenatedSearchTerms) => deburr(`${concatenatedSearchTerms} ${(personalDetail.text ?? '').toLocaleLowerCase()}`), + }), + ); }; // when we expect that function return eg. 50 elements and we already found 40 recent reports, we should adjust the max personal details number. @@ -3009,7 +3012,7 @@ function formatSectionsFromSearchTerm( // This will add them to the list of options, deduping them if they already exist in the other lists const selectedParticipantsWithoutDetails = selectedOptions.filter((participant) => { const accountID = participant.accountID ?? null; - const isPartOfSearchTerm = getPersonalDetailSearchTerms(participant, currentUserAccountID).join(' ').toLowerCase().includes(cleanSearchTerm); + const isPartOfSearchTerm = doesPersonalDetailMatchSearchTerm(participant, currentUserAccountID, cleanSearchTerm); const isReportInRecentReports = filteredRecentReports.some((report) => report.accountID === accountID) || filteredWorkspaceChats.some((report) => report.accountID === accountID); const isReportInPersonalDetails = filteredPersonalDetails.some((personalDetail) => personalDetail.accountID === accountID); @@ -3037,18 +3040,6 @@ function formatSectionsFromSearchTerm( }; } -function getPersonalDetailSearchTerms(item: Partial, currentUserAccountID: number) { - if (item.accountID === currentUserAccountID) { - return getCurrentUserSearchTerms(item); - } - return [item.participantsList?.[0]?.displayName ?? item.displayName ?? '', item.login ?? '', item.login?.replace(CONST.EMAIL_SEARCH_REGEX, '') ?? '']; -} - -function getCurrentUserSearchTerms(item: Partial) { - // eslint-disable-next-line @typescript-eslint/no-deprecated - return [item.text ?? item.displayName ?? '', item.login ?? '', item.login?.replace(CONST.EMAIL_SEARCH_REGEX, '') ?? '', translateLocal('common.you'), translateLocal('common.me')]; -} - /** * Remove the personal details for the DMs that are already in the recent reports so that we don't show duplicates. */ @@ -3441,7 +3432,6 @@ export { formatSectionsFromSearchTerm, getAlternateText, getFilteredRecentAttendees, - getCurrentUserSearchTerms, getEmptyOptions, getHeaderMessage, getHeaderMessageForNonUserList, @@ -3453,7 +3443,6 @@ export { getLastMessageTextForReport, getManagerMcTestParticipant, getParticipantsOption, - getPersonalDetailSearchTerms, getPersonalDetailsForAccountIDs, getPolicyExpenseReportOption, getReportDisplayOption, diff --git a/src/libs/OptionsListUtils/searchMatchUtils.ts b/src/libs/OptionsListUtils/searchMatchUtils.ts new file mode 100644 index 000000000000..d8112b56a810 --- /dev/null +++ b/src/libs/OptionsListUtils/searchMatchUtils.ts @@ -0,0 +1,66 @@ +// eslint-disable-next-line @typescript-eslint/no-deprecated +import {translateLocal} from '@libs/Localize'; +import CONST from '@src/CONST'; +import type {SearchOptionData} from './types'; + +type SearchMatchConfig = { + /** Whether to use toLocaleLowerCase() instead of toLowerCase(), defaults to false */ + useLocaleLowerCase?: boolean; + + /** + * Optional callback to transform the concatenated search terms before matching. + * @param concatenatedSearchTerms - the joined terms string, already lowercased + */ + transformSearchText?: (concatenatedSearchTerms: string) => string; +}; + +/** + * Includes localized "You"/"Me" so the current user is findable + * by those terms in any supported language. + * + * @returns Raw (not lowercased) terms: display text, login, + * login with dots stripped before @, and translated "You"/"Me". + */ +function getCurrentUserSearchTerms(item: Partial) { + // eslint-disable-next-line @typescript-eslint/no-deprecated + return [item.text ?? item.displayName ?? '', item.login ?? '', item.login?.replace(CONST.EMAIL_SEARCH_REGEX, '') ?? '', translateLocal('common.you'), translateLocal('common.me')]; +} + +/** + * For the current user, delegates to getCurrentUserSearchTerms. + * For others, includes display name and login with dots stripped + * before @ (so "john.doe@" matches "johndoe@"). + * + * @returns Raw (not lowercased) terms the person is searchable by. + */ +function getPersonalDetailSearchTerms(item: Partial, currentUserAccountID: number) { + if (item.accountID === currentUserAccountID) { + return getCurrentUserSearchTerms(item); + } + return [item.participantsList?.[0]?.displayName ?? item.displayName ?? '', item.login ?? '', item.login?.replace(CONST.EMAIL_SEARCH_REGEX, '') ?? '']; +} + +/** + * Checks whether a personal detail option matches a single search term + * by comparing against the option's searchable fields (displayName, login, etc.). + * + * Expects `searchTerm` to already be lowercased and trimmed. + */ +function doesPersonalDetailMatchSearchTerm( + item: Partial, + currentUserAccountID: number, + searchTerm: string, + {useLocaleLowerCase = false, transformSearchText}: SearchMatchConfig = {}, +): boolean { + const terms = getPersonalDetailSearchTerms(item, currentUserAccountID).join(' '); + let searchText = useLocaleLowerCase ? terms.toLocaleLowerCase() : terms.toLowerCase(); + + if (transformSearchText) { + searchText = transformSearchText(searchText); + } + + return searchText.includes(searchTerm); +} + +export {getCurrentUserSearchTerms, getPersonalDetailSearchTerms, doesPersonalDetailMatchSearchTerm}; +export type {SearchMatchConfig}; diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index 6472e21dfb06..8175c4f1ee12 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -24,7 +24,6 @@ import useIsFocusedRef from '@hooks/useIsFocusedRef'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -32,15 +31,8 @@ import {navigateToAndOpenReport, searchInServer, setGroupDraft} from '@libs/acti import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; -import { - filterAndOrderOptions, - filterSelectedOptions, - formatSectionsFromSearchTerm, - getHeaderMessage, - getPersonalDetailSearchTerms, - getUserToInviteOption, - getValidOptions, -} from '@libs/OptionsListUtils'; +import {filterAndOrderOptions, filterSelectedOptions, getHeaderMessage, getUserToInviteOption, getValidOptions} from '@libs/OptionsListUtils'; +import {doesPersonalDetailMatchSearchTerm} from '@libs/OptionsListUtils/searchMatchUtils'; import type {OptionWithKey} from '@libs/OptionsListUtils/types'; import type {OptionData} from '@libs/ReportUtils'; import variables from '@styles/variables'; @@ -141,7 +133,7 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor !!options.userToInvite, debouncedSearchTerm.trim(), countryCode, - selectedOptions.some((participant) => getPersonalDetailSearchTerms(participant, currentUserAccountID).join(' ').toLowerCase?.().includes(cleanSearchTerm)), + selectedOptions.some((participant) => doesPersonalDetailMatchSearchTerm(participant, currentUserAccountID, cleanSearchTerm)), ); useFocusEffect(() => { @@ -247,19 +239,16 @@ function NewChatPage({ref}: NewChatPageProps) { const personalData = useCurrentUserPersonalDetails(); const currentUserAccountID = personalData.accountID; const {top} = useSafeAreaInsets(); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [betas] = useOnyx(ONYXKEYS.BETAS); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); - const privateIsArchivedMap = usePrivateIsArchivedMap(); const selectionListRef = useRef(null); const [reportAttributesDerivedFull] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES); const reportAttributesDerived = reportAttributesDerivedFull?.reports; - const allPersonalDetails = usePersonalDetails(); const {singleExecution} = useSingleExecution(); useImperativeHandle(ref, () => ({ @@ -282,20 +271,12 @@ function NewChatPage({ref}: NewChatPageProps) { const sections: Array> = []; - const formatResults = formatSectionsFromSearchTerm( - debouncedSearchTerm, - selectedOptions as OptionData[], - recentReports, - personalDetails, - privateIsArchivedMap, - currentUserAccountID, - allPolicies, - allPersonalDetails, - undefined, - undefined, - reportAttributesDerived, - ); - sections.push({...formatResults.section, title: undefined, sectionIndex: 0}); + const selectedSection = + debouncedSearchTerm === '' + ? selectedOptions + : selectedOptions.filter((participant) => doesPersonalDetailMatchSearchTerm(participant, currentUserAccountID, debouncedSearchTerm.trim().toLowerCase())); + + sections.push({data: selectedSection, title: undefined, sectionIndex: 0}); sections.push({ title: translate('common.recents'), diff --git a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx index 7493ef69f358..e457c355a089 100644 --- a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx @@ -26,11 +26,11 @@ import { getFilteredRecentAttendees, getHeaderMessage, getParticipantsOption, - getPersonalDetailSearchTerms, getPolicyExpenseReportOption, isCurrentUser, orderOptions, } from '@libs/OptionsListUtils'; +import {doesPersonalDetailMatchSearchTerm} from '@libs/OptionsListUtils/searchMatchUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {isPaidGroupPolicy as isPaidGroupPolicyFn} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -272,7 +272,7 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde !!orderedAvailableOptions?.userToInvite, cleanSearchTerm, countryCode, - attendees.some((attendee) => getPersonalDetailSearchTerms(attendee, currentUserAccountID).join(' ').toLowerCase().includes(cleanSearchTerm)), + attendees.some((attendee) => doesPersonalDetailMatchSearchTerm(attendee, currentUserAccountID, cleanSearchTerm)), ); sections = newSections; } diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index c833f68f19f4..73b13ce4bd38 100644 --- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx +++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx @@ -36,7 +36,8 @@ import goToSettings from '@libs/goToSettings'; import {isMovingTransactionFromTrackExpense} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {Option} from '@libs/OptionsListUtils'; -import {formatSectionsFromSearchTerm, getHeaderMessage, getParticipantsOption, getPersonalDetailSearchTerms, getPolicyExpenseReportOption, isCurrentUser} from '@libs/OptionsListUtils'; +import {formatSectionsFromSearchTerm, getHeaderMessage, getParticipantsOption, getPolicyExpenseReportOption, isCurrentUser} from '@libs/OptionsListUtils'; +import {doesPersonalDetailMatchSearchTerm} from '@libs/OptionsListUtils/searchMatchUtils'; import type {OptionWithKey} from '@libs/OptionsListUtils/types'; import {getActiveAdminWorkspaces, isPaidGroupPolicy as isPaidGroupPolicyUtil} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -265,7 +266,7 @@ function MoneyRequestParticipantsSelector({ !!availableOptions?.userToInvite, debouncedSearchTerm.trim(), countryCode, - participants.some((participant) => getPersonalDetailSearchTerms(participant, currentUserAccountID).join(' ').toLowerCase().includes(cleanSearchTerm)), + participants.some((participant) => doesPersonalDetailMatchSearchTerm(participant, currentUserAccountID, cleanSearchTerm)), ), // eslint-disable-next-line react-hooks/exhaustive-deps [ diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 2909b3465024..38edaaf944dc 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -24,13 +24,11 @@ import { filterWorkspaceChats, formatMemberForList, formatSectionsFromSearchTerm, - getCurrentUserSearchTerms, getFilteredRecentAttendees, getIOUReportIDOfLastAction, getLastActorDisplayName, getLastActorDisplayNameFromLastVisibleActions, getLastMessageTextForReport, - getPersonalDetailSearchTerms, getPolicyExpenseReportOption, getReportDisplayOption, getReportOption, @@ -45,6 +43,7 @@ import { shouldShowLastActorDisplayName, sortAlphabetically, } from '@libs/OptionsListUtils'; +import {getCurrentUserSearchTerms, getPersonalDetailSearchTerms} from '@libs/OptionsListUtils/searchMatchUtils'; import Parser from '@libs/Parser'; import { getAddedCardFeedMessage, diff --git a/tests/unit/searchMatchUtilsTest.ts b/tests/unit/searchMatchUtilsTest.ts new file mode 100644 index 000000000000..138f0a8c987b --- /dev/null +++ b/tests/unit/searchMatchUtilsTest.ts @@ -0,0 +1,145 @@ +// cspell:ignore René Résumé +import deburr from 'lodash/deburr'; +import {doesPersonalDetailMatchSearchTerm} from '@libs/OptionsListUtils/searchMatchUtils'; + +const CURRENT_USER_ACCOUNT_ID = 2; +const OTHER_USER_ACCOUNT_ID = 99; + +describe('doesPersonalDetailMatchSearchTerm', () => { + describe('basic matching', () => { + it('should match by displayName', () => { + expect(doesPersonalDetailMatchSearchTerm({displayName: 'John Doe'}, OTHER_USER_ACCOUNT_ID, 'john')).toBe(true); + }); + + it('should match by login (email)', () => { + expect(doesPersonalDetailMatchSearchTerm({login: 'john@example.com'}, OTHER_USER_ACCOUNT_ID, 'john@example')).toBe(true); + }); + + it('should match by login without dots before @', () => { + expect(doesPersonalDetailMatchSearchTerm({login: 'john.doe@example.com'}, OTHER_USER_ACCOUNT_ID, 'johndoe@')).toBe(true); + }); + + it('should match by participantsList displayName', () => { + const item = {participantsList: [{displayName: 'Jane Smith', accountID: 123}]}; + expect(doesPersonalDetailMatchSearchTerm(item, OTHER_USER_ACCOUNT_ID, 'jane')).toBe(true); + }); + + it('should not match when search term is absent from all fields', () => { + expect(doesPersonalDetailMatchSearchTerm({displayName: 'Alice', login: 'alice@test.com'}, OTHER_USER_ACCOUNT_ID, 'bob')).toBe(false); + }); + + it('should match when search term is empty string', () => { + expect(doesPersonalDetailMatchSearchTerm({displayName: 'Anyone'}, OTHER_USER_ACCOUNT_ID, '')).toBe(true); + }); + }); + + describe('case insensitivity', () => { + it('should match mixed case displayName against lowercased search', () => { + expect(doesPersonalDetailMatchSearchTerm({displayName: 'John DOE'}, OTHER_USER_ACCOUNT_ID, 'john doe')).toBe(true); + }); + + it('should match mixed case login against lowercased search', () => { + expect(doesPersonalDetailMatchSearchTerm({login: 'John.Doe@Example.COM'}, OTHER_USER_ACCOUNT_ID, 'john.doe@example.com')).toBe(true); + }); + }); + + describe('current user matching', () => { + it('should match by text field for current user', () => { + expect(doesPersonalDetailMatchSearchTerm({accountID: CURRENT_USER_ACCOUNT_ID, text: 'My Display Name'}, CURRENT_USER_ACCOUNT_ID, 'my display')).toBe(true); + }); + + it('should fall back to displayName when text is missing for current user', () => { + expect(doesPersonalDetailMatchSearchTerm({accountID: CURRENT_USER_ACCOUNT_ID, displayName: 'Fallback Name'}, CURRENT_USER_ACCOUNT_ID, 'fallback')).toBe(true); + }); + }); + + describe('partial / sparse items', () => { + it('should match with only login provided', () => { + expect(doesPersonalDetailMatchSearchTerm({login: 'solo@test.com'}, OTHER_USER_ACCOUNT_ID, 'solo')).toBe(true); + }); + + it('should match with only displayName provided', () => { + expect(doesPersonalDetailMatchSearchTerm({displayName: 'Solo'}, OTHER_USER_ACCOUNT_ID, 'solo')).toBe(true); + }); + + it('should not match empty item with a search term', () => { + expect(doesPersonalDetailMatchSearchTerm({}, OTHER_USER_ACCOUNT_ID, 'anything')).toBe(false); + }); + + it('should match empty item with empty search term', () => { + expect(doesPersonalDetailMatchSearchTerm({}, OTHER_USER_ACCOUNT_ID, '')).toBe(true); + }); + }); + + describe('cross-field matching', () => { + it('should match a search term that spans displayName and login', () => { + expect(doesPersonalDetailMatchSearchTerm({displayName: 'John', login: 'doe@test.com'}, OTHER_USER_ACCOUNT_ID, 'john doe')).toBe(true); + }); + }); + + describe('useLocaleLowerCase config', () => { + it('should lowercase with toLocaleLowerCase when enabled', () => { + expect( + doesPersonalDetailMatchSearchTerm({displayName: 'ISTANBUL'}, OTHER_USER_ACCOUNT_ID, 'istanbul', { + useLocaleLowerCase: true, + }), + ).toBe(true); + }); + + it('should lowercase with toLowerCase by default', () => { + expect(doesPersonalDetailMatchSearchTerm({displayName: 'ISTANBUL'}, OTHER_USER_ACCOUNT_ID, 'istanbul')).toBe(true); + }); + }); + + describe('transformSearchText config', () => { + it('should match against appended text from transform callback', () => { + expect( + doesPersonalDetailMatchSearchTerm({displayName: 'John'}, OTHER_USER_ACCOUNT_ID, 'extra', { + transformSearchText: (concatenatedSearchTerms) => `${concatenatedSearchTerms} extra stuff`, + }), + ).toBe(true); + }); + + it('should pass already-lowercased terms to the transform callback', () => { + let receivedText = ''; + doesPersonalDetailMatchSearchTerm({displayName: 'UPPER Case'}, OTHER_USER_ACCOUNT_ID, 'test', { + transformSearchText: (concatenatedSearchTerms) => { + receivedText = concatenatedSearchTerms; + return concatenatedSearchTerms; + }, + }); + expect(receivedText).toBe('upper case '); + }); + + it('should use the transform result as the final match target', () => { + expect( + doesPersonalDetailMatchSearchTerm({displayName: 'Alice'}, OTHER_USER_ACCOUNT_ID, 'replaced', { + transformSearchText: () => 'completely replaced', + }), + ).toBe(true); + }); + + it('should support deburr with appended text (real-world usage)', () => { + expect( + doesPersonalDetailMatchSearchTerm({displayName: 'René'}, OTHER_USER_ACCOUNT_ID, 'rene', { + useLocaleLowerCase: true, + transformSearchText: (concatenatedSearchTerms) => deburr(`${concatenatedSearchTerms} ${'Résumé'.toLocaleLowerCase()}`), + }), + ).toBe(true); + }); + }); + + describe('negative cases', () => { + it('should not match when search term is longer than all fields', () => { + expect(doesPersonalDetailMatchSearchTerm({displayName: 'Jo'}, OTHER_USER_ACCOUNT_ID, 'john')).toBe(false); + }); + + it('should not match when search contains characters not in any field', () => { + expect(doesPersonalDetailMatchSearchTerm({displayName: 'John'}, OTHER_USER_ACCOUNT_ID, 'john!')).toBe(false); + }); + + it('should not crash with undefined accountID', () => { + expect(doesPersonalDetailMatchSearchTerm({accountID: undefined, displayName: 'Test'}, CURRENT_USER_ACCOUNT_ID, 'test')).toBe(true); + }); + }); +});