diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector.base.ts index 33901dc93cfb0..853b8edf3e0e0 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 47ecd6259245c..bdd9031c14b3f 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -187,6 +187,7 @@ import type { } from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {getCurrentUserSearchTerms, getPersonalDetailSearchTerms, isPersonalDetailMatchingSearchTerm} from './searchMatchUtils'; import type { FilterUserToInviteConfig, GetOptionsConfig, @@ -2660,10 +2661,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) => + isPersonalDetailMatchingSearchTerm(personalDetail, currentUserAccountID, term, { + useLocaleLowerCase: true, + transformSearchText: (concatenatedSearchTerms) => deburr(`${concatenatedSearchTerms} ${personalDetail.text ?? ''}`), + }), + ); }; // when we expect that function return eg. 50 elements and we already found 40 recent reports, we should adjust the max personal details number @@ -2995,7 +2998,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 = isPersonalDetailMatchingSearchTerm(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); @@ -3023,18 +3026,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. */ @@ -3417,7 +3408,6 @@ export { formatSectionsFromSearchTerm, getAlternateText, getFilteredRecentAttendees, - getCurrentUserSearchTerms, getEmptyOptions, getHeaderMessage, getHeaderMessageForNonUserList, @@ -3429,7 +3419,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 0000000000000..53098a31c2c74 --- /dev/null +++ b/src/libs/OptionsListUtils/searchMatchUtils.ts @@ -0,0 +1,53 @@ +// 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 = { + /** Use toLocaleLowerCase() instead of toLowerCase(). Default: false */ + useLocaleLowerCase?: boolean; + + /** + * Optional callback to transform the concatenated search terms before matching. + * Receives the joined terms string (already lowercased). + * Return the final string to match against. + */ + transformSearchText?: (concatenatedSearchTerms: string) => string; +}; + +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')]; +} + +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 isPersonalDetailMatchingSearchTerm( + 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, isPersonalDetailMatchingSearchTerm}; +export type {SearchMatchConfig}; diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index d80ab7d7d46ba..a04d65e3f4a31 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 {isPersonalDetailMatchingSearchTerm} from '@libs/OptionsListUtils/searchMatchUtils'; import type {OptionWithKey} from '@libs/OptionsListUtils/types'; import type {OptionData} from '@libs/ReportUtils'; import variables from '@styles/variables'; @@ -139,7 +131,7 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor !!options.userToInvite, debouncedSearchTerm.trim(), countryCode, - selectedOptions.some((participant) => getPersonalDetailSearchTerms(participant, currentUserAccountID).join(' ').toLowerCase?.().includes(cleanSearchTerm)), + selectedOptions.some((participant) => isPersonalDetailMatchingSearchTerm(participant, currentUserAccountID, cleanSearchTerm)), ); useFocusEffect(() => { @@ -245,19 +237,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, () => ({ @@ -280,20 +269,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) => isPersonalDetailMatchingSearchTerm(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 7493ef69f3583..7fd164103e56b 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 {isPersonalDetailMatchingSearchTerm} 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) => isPersonalDetailMatchingSearchTerm(attendee, currentUserAccountID, cleanSearchTerm)), ); sections = newSections; } diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index 8e95c38c2b23a..5e35ed895a2b5 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 {isPersonalDetailMatchingSearchTerm} 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'; @@ -264,7 +265,7 @@ function MoneyRequestParticipantsSelector({ !!availableOptions?.userToInvite, debouncedSearchTerm.trim(), countryCode, - participants.some((participant) => getPersonalDetailSearchTerms(participant, currentUserAccountID).join(' ').toLowerCase().includes(cleanSearchTerm)), + participants.some((participant) => isPersonalDetailMatchingSearchTerm(participant, currentUserAccountID, cleanSearchTerm)), ), // eslint-disable-next-line react-hooks/exhaustive-deps [ diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 5f3d938236635..68f10723cbcbd 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,