From fe5d3fe239e6f756890b97890a48c5db7b3af390 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Wed, 23 Jul 2025 16:24:42 +0200 Subject: [PATCH 01/32] Refactor: Introduce useSearchSelector Hook and Clean Up Invite Components This commit adds a unified useSearchSelector hook to consolidate search and selection logic across invitation-related components, reducing code duplication and improving performance. Key Changes: Introduced useSearchSelector hook with support for multiple get*Options functions. Added heap optimization parameters to OptionsListUtils functions. Refactored InviteReportParticipantsPage and WorkspaceInvitePage to use the new hook. Implemented deduplication logic to prevent duplicate users across sections. Updated personalDetailsComparator to sort consistently using the text field. Hook Features: Debounced search input Multi/single selection handling Option filtering and deduplication Heap-based optimization for large datasets --- src/hooks/useSearchSelector.ts | 236 ++++++++++++++++++++ src/libs/OptionsListUtils.ts | 36 ++- src/pages/InviteReportParticipantsPage.tsx | 171 +++++--------- src/pages/Share/ShareTab.tsx | 1 - src/pages/workspace/WorkspaceInvitePage.tsx | 229 ++++++------------- 5 files changed, 392 insertions(+), 281 deletions(-) create mode 100644 src/hooks/useSearchSelector.ts diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts new file mode 100644 index 000000000000..3daeddc01984 --- /dev/null +++ b/src/hooks/useSearchSelector.ts @@ -0,0 +1,236 @@ +import {useCallback, useMemo, useState} from 'react'; +import {useOptionsList} from '@components/OptionListContextProvider'; +import type {Options} from '@libs/OptionsListUtils'; +import {getAttendeeOptions, getEmptyOptions, getMemberInviteOptions, getSearchOptions, getShareDestinationOptions, getShareLogOptions, getValidOptions} from '@libs/OptionsListUtils'; +import type {OptionData} from '@libs/ReportUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useDebouncedState from './useDebouncedState'; +import useOnyx from './useOnyx'; + +type GetOptionsFunction = 'getSearchOptions' | 'getMemberInviteOptions' | 'getAttendeeOptions' | 'getShareLogOptions' | 'getShareDestinationOptions' | 'getValidOptions'; + +type UseSearchSelectorConfig = { + /** Selection mode - single or multiple selection */ + selectionMode: 'single' | 'multi'; + /** Maximum number of results to return (for heap optimization) */ + maxResults?: number; + /** Which options function to use for filtering */ + getOptionsFunction?: GetOptionsFunction; + /** Whether to include user to invite option */ + includeUserToInvite?: boolean; + /** Logins to exclude from results */ + excludeLogins?: Record; + /** Whether to include recent reports (for getMemberInviteOptions) */ + includeRecentReports?: boolean; + /** Callback when selection changes (multi-select mode) */ + onSelectionChange?: (selected: OptionData[]) => void; + /** Callback when single option is selected (single-select mode) */ + onSingleSelect?: (option: OptionData) => void; + /** Initial selected options */ + initialSelected?: OptionData[]; +}; + +type UseSearchSelectorReturn = { + /** Current search term */ + searchTerm: string; + /** Function to update search term */ + setSearchTerm: (value: string) => void; + /** Filtered and optimized search options with selection state */ + searchOptions: Options; + /** Available (unselected) options */ + availableOptions: Options; + /** Currently selected options */ + selectedOptions: OptionData[]; + /** Function to set selected options */ + setSelectedOptions: (options: OptionData[]) => void; + /** Function to toggle option selection */ + toggleOption: (option: OptionData) => void; + /** Whether options are initialized */ + areOptionsInitialized: boolean; +}; + +/** + * Hook that combines search functionality with selection logic for option lists. + * Leverages heap optimization for performance with large datasets. + * + * @param config - Configuration object for the hook + * @returns Object with search and selection utilities + */ +function useSearchSelector({ + selectionMode, + maxResults = 20, + getOptionsFunction = 'getSearchOptions', + includeUserToInvite = true, + excludeLogins = {}, + includeRecentReports = false, + onSelectionChange, + onSingleSelect, + initialSelected = [], +}: UseSearchSelectorConfig): UseSearchSelectorReturn { + const {options, areOptionsInitialized} = useOptionsList(); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const [selectedOptions, setSelectedOptions] = useState(initialSelected); + + // Get optimized options with heap filtering and mark selection state + const searchOptions = useMemo(() => { + if (!areOptionsInitialized) { + return getEmptyOptions(); + } + + let baseOptions: Options; + switch (getOptionsFunction) { + case 'getSearchOptions': + baseOptions = getSearchOptions(options, betas ?? [], true, true, debouncedSearchTerm, maxResults, includeUserToInvite); + break; + case 'getMemberInviteOptions': + baseOptions = getMemberInviteOptions( + options.personalDetails, + betas ?? [], + excludeLogins, + false, + options.reports, + includeRecentReports, + debouncedSearchTerm, + maxResults, + includeUserToInvite, + ); + console.log('morwa baseOptions', baseOptions, options); + break; + case 'getAttendeeOptions': + baseOptions = getAttendeeOptions( + options.reports, + options.personalDetails, + betas ?? [], + [], + [], + false, + true, + false, + undefined, + debouncedSearchTerm, + maxResults, + includeUserToInvite, + ); + break; + case 'getShareLogOptions': + baseOptions = getShareLogOptions(options, betas ?? [], debouncedSearchTerm, maxResults, includeUserToInvite); + break; + case 'getShareDestinationOptions': + baseOptions = getShareDestinationOptions( + options.reports, + options.personalDetails, + betas ?? [], + [], + excludeLogins, + true, + debouncedSearchTerm, + maxResults, + includeUserToInvite, + ); + break; + case 'getValidOptions': + baseOptions = getValidOptions(options, { + betas: betas ?? [], + searchString: debouncedSearchTerm, + maxElements: maxResults, + includeUserToInvite, + loginsToExclude: excludeLogins, + }); + break; + default: + baseOptions = getEmptyOptions(); + } + + // Mark selection state on all options + const isOptionSelected = (option: OptionData) => + selectedOptions.some( + (selected) => + (selected.accountID && selected.accountID === option.accountID) || + (selected.reportID && selected.reportID === option.reportID) || + (selected.login && selected.login === option.login), + ); + + return { + ...baseOptions, + personalDetails: baseOptions.personalDetails.map((option) => ({ + ...option, + isSelected: isOptionSelected(option), + })), + recentReports: baseOptions.recentReports.map((option) => ({ + ...option, + isSelected: isOptionSelected(option), + })), + userToInvite: baseOptions.userToInvite + ? { + ...baseOptions.userToInvite, + isSelected: isOptionSelected(baseOptions.userToInvite), + } + : null, + }; + }, [areOptionsInitialized, options, betas, debouncedSearchTerm, maxResults, getOptionsFunction, includeUserToInvite, excludeLogins, includeRecentReports, selectedOptions]); + + // Available options (unselected items only with proper deduplication) + const availableOptions = useMemo(() => { + const unselectedRecentReports = searchOptions.recentReports.filter((option) => !option.isSelected); + + // Filter out people who appear in recent reports from personal details (recents take priority) + const recentReportLogins = new Set(unselectedRecentReports.map((option) => option.login).filter(Boolean)); + const unselectedPersonalDetails = searchOptions.personalDetails.filter((option) => !option.isSelected && !recentReportLogins.has(option.login)); + + return { + ...searchOptions, + personalDetails: unselectedPersonalDetails, + recentReports: unselectedRecentReports, + userToInvite: searchOptions.userToInvite?.isSelected ? null : searchOptions.userToInvite, + }; + }, [searchOptions]); + + /** + * Toggle option selection based on selection mode + */ + const toggleOption = useCallback( + (option: OptionData) => { + if (selectionMode === 'single') { + onSingleSelect?.(option); + return; + } + + const isSelected = selectedOptions.some( + (selected) => + (selected.accountID && selected.accountID === option.accountID) || + (selected.reportID && selected.reportID === option.reportID) || + (selected.login && selected.login === option.login), + ); + + const newSelected = isSelected + ? selectedOptions.filter( + (selected) => + !( + (selected.accountID && selected.accountID === option.accountID) || + (selected.reportID && selected.reportID === option.reportID) || + (selected.login && selected.login === option.login) + ), + ) + : [...selectedOptions, {...option, isSelected: true}]; + + setSelectedOptions(newSelected); + onSelectionChange?.(newSelected); + }, + [selectedOptions, selectionMode, onSelectionChange, onSingleSelect], + ); + + return { + searchTerm, + setSearchTerm, + searchOptions, + availableOptions, + selectedOptions, + setSelectedOptions, + toggleOption, + areOptionsInitialized, + }; +} + +export default useSearchSelector; +export type {GetOptionsFunction, UseSearchSelectorConfig, UseSearchSelectorReturn}; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 623f209068f6..6b695deb810c 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -1309,7 +1309,7 @@ function orderReportOptions(options: OptionData[]) { * Sort personal details by displayName or login in alphabetical order */ const personalDetailsComparator = (personalDetail: OptionData) => { - const name = personalDetail.displayName ?? personalDetail.login ?? ''; + const name = personalDetail.text ?? personalDetail.alternateText ?? ''; return name.toLowerCase(); }; @@ -2013,7 +2013,7 @@ function getValidOptions( if (personalDetailLoginsToExclude[personalDetail.login]) { return false; } - const searchText = `${personalDetail.displayName?.toLowerCase() ?? ''} ${personalDetail.login?.toLowerCase() ?? ''}`.toLocaleLowerCase(); + const searchText = `${personalDetail.displayName ?? ''} ${personalDetail.login ?? ''} ${personalDetail.text ?? ''}`.toLocaleLowerCase(); return searchTerms.length > 0 ? searchTerms.every((term) => searchText.includes(term)) : true; }; @@ -2091,7 +2091,7 @@ function getSearchOptions( return optionList; } -function getShareLogOptions(options: OptionList, betas: Beta[] = []): Options { +function getShareLogOptions(options: OptionList, betas: Beta[] = [], searchString = '', maxElements?: number, includeUserToInvite = false): Options { return getValidOptions(options, { betas, includeMultipleParticipantReports: true, @@ -2101,6 +2101,9 @@ function getShareLogOptions(options: OptionList, betas: Beta[] = []): Options { includeSelfDM: true, includeThreads: true, includeReadOnly: false, + searchString, + maxElements, + includeUserToInvite, }); } @@ -2138,6 +2141,9 @@ function getAttendeeOptions( includeP2P = true, includeInvoiceRooms = false, action: IOUAction | undefined = undefined, + searchString = '', + maxElements?: number, + includeUserToInvite = false, ) { const personalDetailList = keyBy( personalDetails.map(({item}) => item), @@ -2181,6 +2187,9 @@ function getAttendeeOptions( includeInvoiceRooms, action, recentAttendees: filteredRecentAttendees, + searchString, + maxElements, + includeUserToInvite, }, ); } @@ -2196,6 +2205,9 @@ function getShareDestinationOptions( selectedOptions: Array> = [], excludeLogins: Record = {}, includeOwnedWorkspaceChats = true, + searchString = '', + maxElements?: number, + includeUserToInvite = false, ) { return getValidOptions( {reports, personalDetails}, @@ -2211,6 +2223,9 @@ function getShareDestinationOptions( excludeLogins, includeOwnedWorkspaceChats, includeSelfDM: true, + searchString, + maxElements, + includeUserToInvite, }, ); } @@ -2251,8 +2266,11 @@ function getMemberInviteOptions( includeSelectedOptions = false, reports: Array> = [], includeRecentReports = false, + searchString = '', + maxElements?: number, + includeUserToInvite = false, ): Options { - const options = getValidOptions( + return getValidOptions( {reports, personalDetails}, { betas, @@ -2260,15 +2278,11 @@ function getMemberInviteOptions( excludeLogins, includeSelectedOptions, includeRecentReports, + searchString, + maxElements, + includeUserToInvite, }, ); - - const orderedOptions = orderOptions(options); - return { - ...options, - personalDetails: orderedOptions.personalDetails, - recentReports: orderedOptions.recentReports, - }; } /** diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 71f77826c7ed..eeac31772cd7 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -1,18 +1,16 @@ import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo} from 'react'; import type {SectionListData} from 'react-native'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import {useOptionsList} from '@components/OptionListContextProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import InviteMemberListItem from '@components/SelectionList/InviteMemberListItem'; import type {Section} from '@components/SelectionList/types'; -import withNavigationTransitionEnd from '@components/withNavigationTransitionEnd'; import type {WithNavigationTransitionEndProps} from '@components/withNavigationTransitionEnd'; -import useDebouncedState from '@hooks/useDebouncedState'; +import withNavigationTransitionEnd from '@components/withNavigationTransitionEnd'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; +import useSearchSelector from '@hooks/useSearchSelector'; import useThemeStyles from '@hooks/useThemeStyles'; import {inviteToGroupChat, searchInServer} from '@libs/actions/Report'; import {clearUserSearchPhrase, updateUserSearchPhrase} from '@libs/actions/RoomMembersUserSearchPhrase'; @@ -21,23 +19,12 @@ import {appendCountryCode} from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {ParticipantsNavigatorParamList} from '@libs/Navigation/types'; -import { - filterAndOrderOptions, - formatMemberForList, - getEmptyOptions, - getHeaderMessage, - getMemberInviteOptions, - getSearchValueForPhoneOrEmail, - isPersonalDetailsReady, -} from '@libs/OptionsListUtils'; -import type {MemberForList} from '@libs/OptionsListUtils'; +import {getHeaderMessage} from '@libs/OptionsListUtils'; import {getLoginsByAccountIDs} from '@libs/PersonalDetailsUtils'; import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber'; -import {getGroupChatName, getParticipantsAccountIDsForDisplay} from '@libs/ReportUtils'; import type {OptionData} from '@libs/ReportUtils'; -import tokenizedSearch from '@libs/tokenizedSearch'; +import {getGroupChatName, getParticipantsAccountIDsForDisplay} from '@libs/ReportUtils'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {InvitedEmailsToAccountIDs} from '@src/types/onyx'; @@ -46,25 +33,13 @@ import withReportOrNotFound from './home/report/withReportOrNotFound'; type InviteReportParticipantsPageProps = WithReportOrNotFoundProps & WithNavigationTransitionEndProps; -type Sections = Array>>; +type Sections = Array>>; function InviteReportParticipantsPage({betas, report, didScreenTransitionEnd}: InviteReportParticipantsPageProps) { + console.log('morwa InviteReportParticipantsPage is called'); const route = useRoute>(); - const {options, areOptionsInitialized} = useOptionsList({ - shouldInitialize: didScreenTransitionEnd, - }); - const styles = useThemeStyles(); const {translate} = useLocalize(); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); - const [userSearchPhrase] = useOnyx(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE, {canBeMissing: true}); - const [searchValue, debouncedSearchTerm, setSearchValue] = useDebouncedState(userSearchPhrase ?? ''); - const [selectedOptions, setSelectedOptions] = useState([]); - - useEffect(() => { - updateUserSearchPhrase(debouncedSearchTerm); - searchInServer(debouncedSearchTerm); - }, [debouncedSearchTerm]); // Any existing participants and Expensify emails should not be eligible for invitation const excludedUsers = useMemo(() => { @@ -79,33 +54,21 @@ function InviteReportParticipantsPage({betas, report, didScreenTransitionEnd}: I return res; }, [report]); - const defaultOptions = useMemo(() => { - if (!areOptionsInitialized) { - return getEmptyOptions(); - } - - return getMemberInviteOptions(options.personalDetails, betas ?? [], excludedUsers, false, options.reports, true); - }, [areOptionsInitialized, betas, excludedUsers, options.personalDetails, options.reports]); - - const inviteOptions = useMemo(() => filterAndOrderOptions(defaultOptions, debouncedSearchTerm, {excludeLogins: excludedUsers}), [debouncedSearchTerm, defaultOptions, excludedUsers]); + const {searchTerm, setSearchTerm, availableOptions, selectedOptions, setSelectedOptions, toggleOption, areOptionsInitialized} = useSearchSelector({ + selectionMode: 'multi', + maxResults: 50, + getOptionsFunction: 'getMemberInviteOptions', + includeUserToInvite: true, + excludeLogins: excludedUsers, + includeRecentReports: true, + initialSelected: [], + onSelectionChange: () => {}, + }); useEffect(() => { - // Update selectedOptions with the latest personalDetails information - const detailsMap: Record = {}; - inviteOptions.personalDetails.forEach((detail) => { - if (!detail.login) { - return; - } - detailsMap[detail.login] = formatMemberForList(detail); - }); - const newSelectedOptions: OptionData[] = []; - selectedOptions.forEach((option) => { - newSelectedOptions.push(option.login && option.login in detailsMap ? {...detailsMap[option.login], isSelected: true} : option); - }); - - setSelectedOptions(newSelectedOptions); - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change - }, [personalDetails, betas, debouncedSearchTerm, excludedUsers, options]); + updateUserSearchPhrase(searchTerm); + searchInServer(searchTerm); + }, [searchTerm]); const sections = useMemo(() => { const sectionsArr: Sections = []; @@ -114,66 +77,48 @@ function InviteReportParticipantsPage({betas, report, didScreenTransitionEnd}: I return []; } - // Filter all options that is a part of the search term or in the personal details - let filterSelectedOptions = selectedOptions; - if (debouncedSearchTerm !== '') { - const processedSearchValue = getSearchValueForPhoneOrEmail(debouncedSearchTerm); - filterSelectedOptions = tokenizedSearch(selectedOptions, processedSearchValue, (option) => [option.text ?? '', option.login ?? '']).filter((option) => { - const accountID = option?.accountID; - const isOptionInPersonalDetails = inviteOptions.personalDetails.some((personalDetail) => accountID && personalDetail?.accountID === accountID); - const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(processedSearchValue) || !!option.login?.toLowerCase().includes(processedSearchValue); - return isPartOfSearchTerm || isOptionInPersonalDetails; + // Selected options section + if (selectedOptions.length > 0) { + sectionsArr.push({ + title: undefined, + data: selectedOptions, }); } - const filterSelectedOptionsFormatted = filterSelectedOptions.map((selectedOption) => formatMemberForList(selectedOption)); - - sectionsArr.push({ - title: undefined, - data: filterSelectedOptionsFormatted, - }); - // Filtering out selected users from the search results - const selectedLogins = selectedOptions.map(({login}) => login); - const recentReportsWithoutSelected = inviteOptions.recentReports.filter(({login}) => !selectedLogins.includes(login)); - const recentReportsFormatted = recentReportsWithoutSelected.map((reportOption) => formatMemberForList(reportOption)); - const personalDetailsWithoutSelected = inviteOptions.personalDetails.filter(({login}) => !selectedLogins.includes(login)); - const personalDetailsFormatted = personalDetailsWithoutSelected.map((personalDetail) => formatMemberForList(personalDetail)); - const hasUnselectedUserToInvite = inviteOptions.userToInvite && !selectedLogins.includes(inviteOptions.userToInvite.login); - - sectionsArr.push({ - title: translate('common.recents'), - data: recentReportsFormatted, - }); + // Recent reports section + if (availableOptions.recentReports.length > 0) { + sectionsArr.push({ + title: translate('common.recents'), + data: availableOptions.recentReports, + }); + } - sectionsArr.push({ - title: translate('common.contacts'), - data: personalDetailsFormatted, - }); + // Contacts section + if (availableOptions.personalDetails.length > 0) { + sectionsArr.push({ + title: translate('common.contacts'), + data: availableOptions.personalDetails, + }); + } - if (hasUnselectedUserToInvite) { + // User to invite section + if (availableOptions.userToInvite) { sectionsArr.push({ title: undefined, - data: inviteOptions.userToInvite ? [formatMemberForList(inviteOptions.userToInvite)] : [], + data: [availableOptions.userToInvite], }); } return sectionsArr; - }, [areOptionsInitialized, selectedOptions, debouncedSearchTerm, inviteOptions.recentReports, inviteOptions.personalDetails, inviteOptions.userToInvite, translate]); - - const toggleOption = useCallback( - (option: MemberForList) => { - const isOptionInList = selectedOptions.some((selectedOption) => selectedOption.login === option.login); + }, [areOptionsInitialized, selectedOptions, availableOptions, translate]); - let newSelectedOptions: OptionData[]; - if (isOptionInList) { - newSelectedOptions = selectedOptions.filter((selectedOption) => selectedOption.login !== option.login); - } else { - newSelectedOptions = [...selectedOptions, {...option, isSelected: true}]; - } + console.log('morwa availableOptions', availableOptions); - setSelectedOptions(newSelectedOptions); + const handleToggleOption = useCallback( + (option: OptionData) => { + toggleOption(option); }, - [selectedOptions], + [toggleOption], ); const validate = useCallback(() => selectedOptions.length > 0, [selectedOptions]); @@ -203,19 +148,19 @@ function InviteReportParticipantsPage({betas, report, didScreenTransitionEnd}: I }, [selectedOptions, goBack, reportID, validate]); const headerMessage = useMemo(() => { - const processedLogin = debouncedSearchTerm.trim().toLowerCase(); + const processedLogin = searchTerm.trim().toLowerCase(); const expensifyEmails = CONST.EXPENSIFY_EMAILS; - if (!inviteOptions.userToInvite && expensifyEmails.includes(processedLogin)) { + if (!availableOptions.userToInvite && expensifyEmails.includes(processedLogin)) { return translate('messages.errorMessageInvalidEmail'); } if ( - !inviteOptions.userToInvite && + !availableOptions.userToInvite && excludedUsers[parsePhoneNumber(appendCountryCode(processedLogin)).possible ? addSMSDomainIfPhoneNumber(appendCountryCode(processedLogin)) : processedLogin] ) { return translate('messages.userIsAlreadyMember', {login: processedLogin, name: reportName ?? ''}); } - return getHeaderMessage(inviteOptions.recentReports.length + inviteOptions.personalDetails.length !== 0, !!inviteOptions.userToInvite, processedLogin); - }, [debouncedSearchTerm, inviteOptions.userToInvite, inviteOptions.recentReports.length, inviteOptions.personalDetails.length, excludedUsers, translate, reportName]); + return getHeaderMessage(availableOptions.recentReports.length + availableOptions.personalDetails.length !== 0, !!availableOptions.userToInvite, processedLogin); + }, [searchTerm, availableOptions, excludedUsers, translate, reportName]); const footerContent = useMemo( () => ( @@ -249,16 +194,14 @@ function InviteReportParticipantsPage({betas, report, didScreenTransitionEnd}: I sections={sections} ListItem={InviteMemberListItem} textInputLabel={translate('selectionList.nameEmailOrPhoneNumber')} - textInputValue={searchValue} - onChangeText={(value) => { - setSearchValue(value); - }} + textInputValue={searchTerm} + onChangeText={setSearchTerm} headerMessage={headerMessage} - onSelectRow={toggleOption} + onSelectRow={handleToggleOption} onConfirm={inviteUsers} showScrollIndicator shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} - showLoadingPlaceholder={!didScreenTransitionEnd || !isPersonalDetailsReady(personalDetails)} + showLoadingPlaceholder={!didScreenTransitionEnd || !areOptionsInitialized} footerContent={footerContent} /> diff --git a/src/pages/Share/ShareTab.tsx b/src/pages/Share/ShareTab.tsx index b8d0b26a8cee..6f109af7685c 100644 --- a/src/pages/Share/ShareTab.tsx +++ b/src/pages/Share/ShareTab.tsx @@ -57,7 +57,6 @@ function ShareTab(_: unknown, ref: React.Ref) { return getSearchOptions(options, betas ?? [], false, false, textInputValue, 20, true); }, [areOptionsInitialized, betas, options, textInputValue]); - const recentReportsOptions = useMemo(() => { if (textInputValue.trim() === '') { return optionsOrderBy(searchOptions.recentReports, recentReportComparator, 20); diff --git a/src/pages/workspace/WorkspaceInvitePage.tsx b/src/pages/workspace/WorkspaceInvitePage.tsx index fa8647289204..44966329d229 100644 --- a/src/pages/workspace/WorkspaceInvitePage.tsx +++ b/src/pages/workspace/WorkspaceInvitePage.tsx @@ -2,17 +2,16 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {SectionListData} from 'react-native'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import {useOptionsList} from '@components/OptionListContextProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import InviteMemberListItem from '@components/SelectionList/InviteMemberListItem'; import type {Section} from '@components/SelectionList/types'; -import withNavigationTransitionEnd from '@components/withNavigationTransitionEnd'; import type {WithNavigationTransitionEndProps} from '@components/withNavigationTransitionEnd'; -import useDebouncedState from '@hooks/useDebouncedState'; +import withNavigationTransitionEnd from '@components/withNavigationTransitionEnd'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import useSearchSelector from '@hooks/useSearchSelector'; import useThemeStyles from '@hooks/useThemeStyles'; import {setWorkspaceInviteMembersDraft} from '@libs/actions/Policy/Member'; import {clearErrors, openWorkspaceInvitePage as policyOpenWorkspaceInvitePage, setWorkspaceErrors} from '@libs/actions/Policy/Policy'; @@ -23,8 +22,7 @@ import HttpUtils from '@libs/HttpUtils'; import {appendCountryCode} from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {filterAndOrderOptions, formatMemberForList, getHeaderMessage, getMemberInviteOptions, getSearchValueForPhoneOrEmail} from '@libs/OptionsListUtils'; -import type {MemberForList} from '@libs/OptionsListUtils'; +import {getHeaderMessage} from '@libs/OptionsListUtils'; import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber'; import {getIneligibleInvitees, getMemberAccountIDsForWorkspace, goBackFromInvalidPolicy} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -37,10 +35,10 @@ import type {InvitedEmailsToAccountIDs} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import AccessOrNotFoundWrapper from './AccessOrNotFoundWrapper'; -import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; +import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; -type MembersSection = SectionListData>; +type MembersSection = SectionListData>; type WorkspaceInvitePageProps = WithPolicyAndFullscreenLoadingProps & WithNavigationTransitionEndProps & @@ -49,33 +47,17 @@ type WorkspaceInvitePageProps = WithPolicyAndFullscreenLoadingProps & function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); - const [selectedOptions, setSelectedOptions] = useState([]); - const [personalDetails, setPersonalDetails] = useState([]); - const [usersToInvite, setUsersToInvite] = useState([]); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); const firstRenderRef = useRef(true); - const [betas] = useOnyx(ONYXKEYS.BETAS); const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`); const openWorkspaceInvitePage = () => { const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList); policyOpenWorkspaceInvitePage(route.params.policyID, Object.keys(policyMemberEmailsToAccountIDs)); }; - const {options, areOptionsInitialized} = useOptionsList({ - shouldInitialize: didScreenTransitionEnd, - }); - useEffect(() => { - clearErrors(route.params.policyID); - openWorkspaceInvitePage(); - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- policyID changes remount the component - }, []); - - useNetwork({onReconnect: openWorkspaceInvitePage}); - - const excludedUsers = useMemo(() => { + const excludeLogins = useMemo(() => { const ineligibleInvites = getIneligibleInvitees(policy?.employeeList); return ineligibleInvites.reduce( (acc, login) => { @@ -86,82 +68,41 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { ); }, [policy?.employeeList]); - const defaultOptions = useMemo(() => { - if (!areOptionsInitialized) { - return {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null}; - } - - const inviteOptions = getMemberInviteOptions(options.personalDetails, betas ?? [], excludedUsers, true); + const {searchTerm, setSearchTerm, availableOptions, selectedOptions, setSelectedOptions, toggleOption, areOptionsInitialized} = useSearchSelector({ + selectionMode: 'multi', + maxResults: 50, + getOptionsFunction: 'getMemberInviteOptions', + includeUserToInvite: true, + excludeLogins, + onSelectionChange: () => { + clearErrors(route.params.policyID); + }, + }); - return {...inviteOptions, recentReports: [], currentUserOption: null}; - }, [areOptionsInitialized, betas, excludedUsers, options.personalDetails]); + useEffect(() => { + clearErrors(route.params.policyID); + openWorkspaceInvitePage(); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- policyID changes remount the component + }, []); - const inviteOptions = useMemo(() => filterAndOrderOptions(defaultOptions, debouncedSearchTerm, {excludeLogins: excludedUsers}), [debouncedSearchTerm, defaultOptions, excludedUsers]); + useNetwork({onReconnect: openWorkspaceInvitePage}); useEffect(() => { - if (!areOptionsInitialized) { + if (!(firstRenderRef.current && invitedEmailsToAccountIDsDraft)) { return; } - - const newUsersToInviteDict: Record = {}; - const newPersonalDetailsDict: Record = {}; - const newSelectedOptionsDict: Record = {}; - - // Update selectedOptions with the latest personalDetails and policyEmployeeList information - const detailsMap: Record = {}; - inviteOptions.personalDetails.forEach((detail) => { - if (!detail.login) { - return; - } - - detailsMap[detail.login] = formatMemberForList(detail); - }); - - const newSelectedOptions: MemberForList[] = []; - if (firstRenderRef.current) { - // We only want to add the saved selected user on first render - firstRenderRef.current = false; - Object.keys(invitedEmailsToAccountIDsDraft ?? {}).forEach((login) => { - if (!(login in detailsMap)) { - return; - } - newSelectedOptions.push({...detailsMap[login], isSelected: true}); - }); - } - selectedOptions.forEach((option) => { - newSelectedOptions.push(option.login && option.login in detailsMap ? {...detailsMap[option.login], isSelected: true} : option); - }); - - const userToInvite = inviteOptions.userToInvite; - - // Only add the user to the invites list if it is valid - if (typeof userToInvite?.accountID === 'number') { - newUsersToInviteDict[userToInvite.accountID] = userToInvite; - } - - // Add all personal details to the new dict - inviteOptions.personalDetails.forEach((details) => { - if (typeof details.accountID !== 'number') { - return; - } - newPersonalDetailsDict[details.accountID] = details; + firstRenderRef.current = false; + const savedOptions = Object.keys(invitedEmailsToAccountIDsDraft).map((login) => { + const accountID = invitedEmailsToAccountIDsDraft[login]; + return { + login, + accountID, + text: login, + selected: true, + } as OptionData; }); - - // Add all selected options to the new dict - newSelectedOptions.forEach((option) => { - if (typeof option.accountID !== 'number') { - return; - } - newSelectedOptionsDict[option.accountID] = option; - }); - - // Strip out dictionary keys and update arrays - setUsersToInvite(Object.values(newUsersToInviteDict)); - setPersonalDetails(Object.values(newPersonalDetailsDict)); - setSelectedOptions(Object.values(newSelectedOptionsDict)); - - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change - }, [options.personalDetails, policy?.employeeList, betas, debouncedSearchTerm, excludedUsers, areOptionsInitialized, inviteOptions.personalDetails, inviteOptions.userToInvite]); + setSelectedOptions(savedOptions); + }, [invitedEmailsToAccountIDsDraft, setSelectedOptions]); const sections: MembersSection[] = useMemo(() => { const sectionsArr: MembersSection[] = []; @@ -170,66 +111,44 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { return []; } - // Filter all options that is a part of the search term or in the personal details - let filterSelectedOptions = selectedOptions; - if (debouncedSearchTerm !== '') { - filterSelectedOptions = selectedOptions.filter((option) => { - const accountID = option.accountID; - const isOptionInPersonalDetails = Object.values(personalDetails).some((personalDetail) => personalDetail.accountID === accountID); - - const searchValue = getSearchValueForPhoneOrEmail(debouncedSearchTerm); - - const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(searchValue) || !!option.login?.toLowerCase().includes(searchValue); - return isPartOfSearchTerm || isOptionInPersonalDetails; + // Selected options section + if (selectedOptions.length > 0) { + sectionsArr.push({ + title: undefined, + data: selectedOptions, + shouldShow: true, }); } - sectionsArr.push({ - title: undefined, - data: filterSelectedOptions, - shouldShow: true, - }); - - // Filtering out selected users from the search results - const selectedLogins = selectedOptions.map(({login}) => login); - const personalDetailsWithoutSelected = Object.values(personalDetails).filter(({login}) => !selectedLogins.some((selectedLogin) => selectedLogin === login)); - const personalDetailsFormatted = personalDetailsWithoutSelected.map((item) => formatMemberForList(item)); - - sectionsArr.push({ - title: translate('common.contacts'), - data: personalDetailsFormatted, - shouldShow: !isEmptyObject(personalDetailsFormatted), - }); - - Object.values(usersToInvite).forEach((userToInvite) => { - const hasUnselectedUserToInvite = !selectedLogins.some((selectedLogin) => selectedLogin === userToInvite.login); + // Contacts section (excluding selected users) + if (availableOptions.personalDetails.length > 0) { + sectionsArr.push({ + title: translate('common.contacts'), + data: availableOptions.personalDetails, + shouldShow: true, + }); + } - if (hasUnselectedUserToInvite) { - sectionsArr.push({ - title: undefined, - data: [formatMemberForList(userToInvite)], - shouldShow: true, - }); - } - }); + // User to invite section + if (availableOptions.userToInvite) { + sectionsArr.push({ + title: undefined, + data: [availableOptions.userToInvite], + shouldShow: true, + }); + } return sectionsArr; - }, [areOptionsInitialized, selectedOptions, debouncedSearchTerm, personalDetails, translate, usersToInvite]); + }, [areOptionsInitialized, selectedOptions, availableOptions, translate]); - const toggleOption = (option: MemberForList) => { - clearErrors(route.params.policyID); - - const isOptionInList = selectedOptions.some((selectedOption) => selectedOption.login === option.login); - - let newSelectedOptions: MemberForList[]; - if (isOptionInList) { - newSelectedOptions = selectedOptions.filter((selectedOption) => selectedOption.login !== option.login); - } else { - newSelectedOptions = [...selectedOptions, {...option, isSelected: true}]; - } - - setSelectedOptions(newSelectedOptions); - }; + const handleToggleOption = useCallback( + (option: OptionData) => { + clearErrors(route.params.policyID); + toggleOption(option); + }, + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- policyID changes remount the component + [toggleOption], + ); const inviteUser = useCallback(() => { const errors: Errors = {}; @@ -261,18 +180,18 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { const [policyName, shouldShowAlertPrompt] = useMemo(() => [policy?.name ?? '', !isEmptyObject(policy?.errors) || !!policy?.alertMessage], [policy]); const headerMessage = useMemo(() => { - const searchValue = debouncedSearchTerm.trim().toLowerCase(); - if (usersToInvite.length === 0 && CONST.EXPENSIFY_EMAILS_OBJECT[searchValue]) { + const searchValue = searchTerm.trim().toLowerCase(); + if (!availableOptions.userToInvite && CONST.EXPENSIFY_EMAILS_OBJECT[searchValue]) { return translate('messages.errorMessageInvalidEmail'); } if ( - usersToInvite.length === 0 && - excludedUsers[parsePhoneNumber(appendCountryCode(searchValue)).possible ? addSMSDomainIfPhoneNumber(appendCountryCode(searchValue)) : searchValue] + !availableOptions.userToInvite && + excludeLogins[parsePhoneNumber(appendCountryCode(searchValue)).possible ? addSMSDomainIfPhoneNumber(appendCountryCode(searchValue)) : searchValue] ) { return translate('messages.userIsAlreadyMember', {login: searchValue, name: policyName}); } - return getHeaderMessage(personalDetails.length !== 0, usersToInvite.length > 0, searchValue); - }, [excludedUsers, translate, debouncedSearchTerm, policyName, usersToInvite, personalDetails.length]); + return getHeaderMessage(availableOptions.personalDetails.length !== 0, !!availableOptions.userToInvite, searchValue); + }, [excludeLogins, translate, searchTerm, policyName, availableOptions]); const footerContent = useMemo( () => ( @@ -290,8 +209,8 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { ); useEffect(() => { - searchInServer(debouncedSearchTerm); - }, [debouncedSearchTerm]); + searchInServer(searchTerm); + }, [searchTerm]); return ( Date: Thu, 24 Jul 2025 11:34:25 +0200 Subject: [PATCH 02/32] Add phone contact' related logic to useSearchSelector --- src/hooks/useSearchSelector.ts | 103 ++++++++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 9 deletions(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index 3daeddc01984..c134ea534dc0 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -1,9 +1,19 @@ import {useCallback, useMemo, useState} from 'react'; +import {InteractionManager} from 'react-native'; +import {RESULTS} from 'react-native-permissions'; +import type {PermissionStatus} from 'react-native-permissions'; import {useOptionsList} from '@components/OptionListContextProvider'; -import type {Options} from '@libs/OptionsListUtils'; +import type {Options, SearchOption} from '@libs/OptionsListUtils'; import {getAttendeeOptions, getEmptyOptions, getMemberInviteOptions, getSearchOptions, getShareDestinationOptions, getShareLogOptions, getValidOptions} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; +import contactImport from '@libs/ContactImport'; +import type {ContactImportResult} from '@libs/ContactImport/types'; +import useContactPermissions from '@libs/ContactPermission/useContactPermissions'; +import getContacts from '@libs/ContactUtils'; +import getPlatform from '@libs/getPlatform'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetails} from '@src/types/onyx'; import useDebouncedState from './useDebouncedState'; import useOnyx from './useOnyx'; @@ -22,6 +32,8 @@ type UseSearchSelectorConfig = { excludeLogins?: Record; /** Whether to include recent reports (for getMemberInviteOptions) */ includeRecentReports?: boolean; + /** Enable phone contacts integration */ + enablePhoneContacts?: boolean; /** Callback when selection changes (multi-select mode) */ onSelectionChange?: (selected: OptionData[]) => void; /** Callback when single option is selected (single-select mode) */ @@ -30,6 +42,21 @@ type UseSearchSelectorConfig = { initialSelected?: OptionData[]; }; +type ContactState = { + /** Current permission status */ + permissionStatus: PermissionStatus; + /** Contact options from device */ + contactOptions: Array>; + /** Whether to show import UI */ + showImportUI: boolean; + /** Function to trigger contact import */ + importContacts: () => void; + /** Function to initiate contact import and set state */ + initiateContactImportAndSetState: () => void; + /** Function to set permission state */ + setContactPermissionState: (status: PermissionStatus) => void; +}; + type UseSearchSelectorReturn = { /** Current search term */ searchTerm: string; @@ -47,6 +74,8 @@ type UseSearchSelectorReturn = { toggleOption: (option: OptionData) => void; /** Whether options are initialized */ areOptionsInitialized: boolean; + /** Contact-related state and functions (when enablePhoneContacts is true) */ + contactState?: ContactState; }; /** @@ -63,6 +92,7 @@ function useSearchSelector({ includeUserToInvite = true, excludeLogins = {}, includeRecentReports = false, + enablePhoneContacts = false, onSelectionChange, onSingleSelect, initialSelected = [], @@ -72,20 +102,62 @@ function useSearchSelector({ const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [selectedOptions, setSelectedOptions] = useState(initialSelected); + // Phone contacts logic + const [contactPermissionState, setContactPermissionState] = useState(RESULTS.UNAVAILABLE); + const [contacts, setContacts] = useState>>([]); + const platform = getPlatform(); + const isNative = platform === CONST.PLATFORM.ANDROID || platform === CONST.PLATFORM.IOS; + const shouldEnableContacts = enablePhoneContacts && isNative; + const showImportContacts = shouldEnableContacts && !(contactPermissionState === RESULTS.GRANTED || contactPermissionState === RESULTS.LIMITED); + + const importAndSaveContacts = useCallback(() => { + if (!shouldEnableContacts) { + return; + } + contactImport().then(({contactList, permissionStatus}: ContactImportResult) => { + console.log('morwa importAndSaveContacts', contactList, permissionStatus); + setContactPermissionState(permissionStatus); + const usersFromContact = getContacts(contactList); + setContacts(usersFromContact); + }); + }, [shouldEnableContacts]); + + const initiateContactImportAndSetState = useCallback(() => { + setContactPermissionState(RESULTS.GRANTED); + InteractionManager.runAfterInteractions(importAndSaveContacts); + }, [importAndSaveContacts]); + + useContactPermissions({ + importAndSaveContacts, + setContacts, + contactPermissionState, + setContactPermissionState, + }); + // Get optimized options with heap filtering and mark selection state const searchOptions = useMemo(() => { if (!areOptionsInitialized) { return getEmptyOptions(); } + // Integrate contacts into personalDetails if enabled + const personalDetailsWithContacts = shouldEnableContacts + ? options.personalDetails.concat(contacts) + : options.personalDetails; + + const optionsWithContacts = { + ...options, + personalDetails: personalDetailsWithContacts, + }; + let baseOptions: Options; switch (getOptionsFunction) { case 'getSearchOptions': - baseOptions = getSearchOptions(options, betas ?? [], true, true, debouncedSearchTerm, maxResults, includeUserToInvite); + baseOptions = getSearchOptions(optionsWithContacts, betas ?? [], true, true, debouncedSearchTerm, maxResults, includeUserToInvite); break; case 'getMemberInviteOptions': baseOptions = getMemberInviteOptions( - options.personalDetails, + personalDetailsWithContacts, betas ?? [], excludeLogins, false, @@ -100,7 +172,7 @@ function useSearchSelector({ case 'getAttendeeOptions': baseOptions = getAttendeeOptions( options.reports, - options.personalDetails, + personalDetailsWithContacts, betas ?? [], [], [], @@ -114,12 +186,12 @@ function useSearchSelector({ ); break; case 'getShareLogOptions': - baseOptions = getShareLogOptions(options, betas ?? [], debouncedSearchTerm, maxResults, includeUserToInvite); + baseOptions = getShareLogOptions(optionsWithContacts, betas ?? [], debouncedSearchTerm, maxResults, includeUserToInvite); break; case 'getShareDestinationOptions': baseOptions = getShareDestinationOptions( options.reports, - options.personalDetails, + personalDetailsWithContacts, betas ?? [], [], excludeLogins, @@ -130,7 +202,7 @@ function useSearchSelector({ ); break; case 'getValidOptions': - baseOptions = getValidOptions(options, { + baseOptions = getValidOptions(optionsWithContacts, { betas: betas ?? [], searchString: debouncedSearchTerm, maxElements: maxResults, @@ -168,7 +240,7 @@ function useSearchSelector({ } : null, }; - }, [areOptionsInitialized, options, betas, debouncedSearchTerm, maxResults, getOptionsFunction, includeUserToInvite, excludeLogins, includeRecentReports, selectedOptions]); + }, [areOptionsInitialized, options, betas, debouncedSearchTerm, maxResults, getOptionsFunction, includeUserToInvite, excludeLogins, includeRecentReports, selectedOptions, shouldEnableContacts, contacts]); // Available options (unselected items only with proper deduplication) const availableOptions = useMemo(() => { @@ -220,6 +292,18 @@ function useSearchSelector({ [selectedOptions, selectionMode, onSelectionChange, onSingleSelect], ); + // Build contact state if enabled + const contactState: ContactState | undefined = shouldEnableContacts + ? { + permissionStatus: contactPermissionState, + contactOptions: contacts, + showImportUI: showImportContacts, + importContacts: importAndSaveContacts, + initiateContactImportAndSetState, + setContactPermissionState, + } + : undefined; + return { searchTerm, setSearchTerm, @@ -229,8 +313,9 @@ function useSearchSelector({ setSelectedOptions, toggleOption, areOptionsInitialized, + contactState, }; } export default useSearchSelector; -export type {GetOptionsFunction, UseSearchSelectorConfig, UseSearchSelectorReturn}; +export type {GetOptionsFunction, UseSearchSelectorConfig, UseSearchSelectorReturn, ContactState}; From acd379708f87d73c1bfec7f9328ccbf78abb17db Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 24 Jul 2025 12:20:10 +0200 Subject: [PATCH 03/32] Cleanup the code --- src/hooks/useSearchSelector.ts | 46 ++++++++++++++------- src/libs/OptionsListUtils.ts | 5 ++- src/pages/InviteReportParticipantsPage.tsx | 7 +--- src/pages/workspace/WorkspaceInvitePage.tsx | 4 +- 4 files changed, 37 insertions(+), 25 deletions(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index c134ea534dc0..b276d8ba244f 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -3,14 +3,14 @@ import {InteractionManager} from 'react-native'; import {RESULTS} from 'react-native-permissions'; import type {PermissionStatus} from 'react-native-permissions'; import {useOptionsList} from '@components/OptionListContextProvider'; -import type {Options, SearchOption} from '@libs/OptionsListUtils'; -import {getAttendeeOptions, getEmptyOptions, getMemberInviteOptions, getSearchOptions, getShareDestinationOptions, getShareLogOptions, getValidOptions} from '@libs/OptionsListUtils'; -import type {OptionData} from '@libs/ReportUtils'; import contactImport from '@libs/ContactImport'; import type {ContactImportResult} from '@libs/ContactImport/types'; import useContactPermissions from '@libs/ContactPermission/useContactPermissions'; import getContacts from '@libs/ContactUtils'; import getPlatform from '@libs/getPlatform'; +import type {GetOptionsConfig, Options, SearchOption} from '@libs/OptionsListUtils'; +import {getAttendeeOptions, getEmptyOptions, getMemberInviteOptions, getSearchOptions, getShareDestinationOptions, getShareLogOptions, getValidOptions} from '@libs/OptionsListUtils'; +import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails} from '@src/types/onyx'; @@ -34,6 +34,8 @@ type UseSearchSelectorConfig = { includeRecentReports?: boolean; /** Enable phone contacts integration */ enablePhoneContacts?: boolean; + /** Additional configuration for getValidOptions function */ + getValidOptionsConfig?: Partial; /** Callback when selection changes (multi-select mode) */ onSelectionChange?: (selected: OptionData[]) => void; /** Callback when single option is selected (single-select mode) */ @@ -93,12 +95,13 @@ function useSearchSelector({ excludeLogins = {}, includeRecentReports = false, enablePhoneContacts = false, + getValidOptionsConfig = {}, onSelectionChange, onSingleSelect, initialSelected = [], }: UseSearchSelectorConfig): UseSearchSelectorReturn { const {options, areOptionsInitialized} = useOptionsList(); - const [betas] = useOnyx(ONYXKEYS.BETAS); + const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [selectedOptions, setSelectedOptions] = useState(initialSelected); @@ -115,7 +118,6 @@ function useSearchSelector({ return; } contactImport().then(({contactList, permissionStatus}: ContactImportResult) => { - console.log('morwa importAndSaveContacts', contactList, permissionStatus); setContactPermissionState(permissionStatus); const usersFromContact = getContacts(contactList); setContacts(usersFromContact); @@ -141,9 +143,7 @@ function useSearchSelector({ } // Integrate contacts into personalDetails if enabled - const personalDetailsWithContacts = shouldEnableContacts - ? options.personalDetails.concat(contacts) - : options.personalDetails; + const personalDetailsWithContacts = shouldEnableContacts ? options.personalDetails.concat(contacts) : options.personalDetails; const optionsWithContacts = { ...options, @@ -167,7 +167,6 @@ function useSearchSelector({ maxResults, includeUserToInvite, ); - console.log('morwa baseOptions', baseOptions, options); break; case 'getAttendeeOptions': baseOptions = getAttendeeOptions( @@ -203,6 +202,7 @@ function useSearchSelector({ break; case 'getValidOptions': baseOptions = getValidOptions(optionsWithContacts, { + ...getValidOptionsConfig, betas: betas ?? [], searchString: debouncedSearchTerm, maxElements: maxResults, @@ -218,8 +218,8 @@ function useSearchSelector({ const isOptionSelected = (option: OptionData) => selectedOptions.some( (selected) => - (selected.accountID && selected.accountID === option.accountID) || - (selected.reportID && selected.reportID === option.reportID) || + (selected.accountID && selected.accountID === option.accountID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison + (selected.reportID && selected.reportID === option.reportID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison (selected.login && selected.login === option.login), ); @@ -240,7 +240,21 @@ function useSearchSelector({ } : null, }; - }, [areOptionsInitialized, options, betas, debouncedSearchTerm, maxResults, getOptionsFunction, includeUserToInvite, excludeLogins, includeRecentReports, selectedOptions, shouldEnableContacts, contacts]); + }, [ + areOptionsInitialized, + options, + betas, + debouncedSearchTerm, + maxResults, + getOptionsFunction, + includeUserToInvite, + excludeLogins, + includeRecentReports, + selectedOptions, + shouldEnableContacts, + contacts, + getValidOptionsConfig, + ]); // Available options (unselected items only with proper deduplication) const availableOptions = useMemo(() => { @@ -270,8 +284,8 @@ function useSearchSelector({ const isSelected = selectedOptions.some( (selected) => - (selected.accountID && selected.accountID === option.accountID) || - (selected.reportID && selected.reportID === option.reportID) || + (selected.accountID && selected.accountID === option.accountID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison + (selected.reportID && selected.reportID === option.reportID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison (selected.login && selected.login === option.login), ); @@ -279,8 +293,8 @@ function useSearchSelector({ ? selectedOptions.filter( (selected) => !( - (selected.accountID && selected.accountID === option.accountID) || - (selected.reportID && selected.reportID === option.reportID) || + (selected.accountID && selected.accountID === option.accountID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison + (selected.reportID && selected.reportID === option.reportID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison (selected.login && selected.login === option.login) ), ) diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 2b9aa17ac91c..d55d483d1c69 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -2260,11 +2260,12 @@ function formatMemberForList(member: OptionData): MemberForList { * Build the options for the Workspace Member Invite view */ function getMemberInviteOptions( + reports: Array> = [], personalDetails: Array>, betas: Beta[] = [], excludeLogins: Record = {}, includeSelectedOptions = false, - reports: Array> = [], + includeRecentReports = false, searchString = '', maxElements?: number, @@ -2757,4 +2758,4 @@ export { sortAlphabetically, }; -export type {MemberForList, Option, OptionList, OptionTree, Options, ReportAndPersonalDetailOptions, SearchOption, Section, SectionBase}; +export type {MemberForList, Option, OptionList, OptionTree, Options, ReportAndPersonalDetailOptions, SearchOption, Section, SectionBase, GetOptionsConfig}; diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index eeac31772cd7..e5f8e94af597 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -35,8 +35,7 @@ type InviteReportParticipantsPageProps = WithReportOrNotFoundProps & WithNavigat type Sections = Array>>; -function InviteReportParticipantsPage({betas, report, didScreenTransitionEnd}: InviteReportParticipantsPageProps) { - console.log('morwa InviteReportParticipantsPage is called'); +function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteReportParticipantsPageProps) { const route = useRoute>(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -54,7 +53,7 @@ function InviteReportParticipantsPage({betas, report, didScreenTransitionEnd}: I return res; }, [report]); - const {searchTerm, setSearchTerm, availableOptions, selectedOptions, setSelectedOptions, toggleOption, areOptionsInitialized} = useSearchSelector({ + const {searchTerm, setSearchTerm, availableOptions, selectedOptions, toggleOption, areOptionsInitialized} = useSearchSelector({ selectionMode: 'multi', maxResults: 50, getOptionsFunction: 'getMemberInviteOptions', @@ -112,8 +111,6 @@ function InviteReportParticipantsPage({betas, report, didScreenTransitionEnd}: I return sectionsArr; }, [areOptionsInitialized, selectedOptions, availableOptions, translate]); - console.log('morwa availableOptions', availableOptions); - const handleToggleOption = useCallback( (option: OptionData) => { toggleOption(option); diff --git a/src/pages/workspace/WorkspaceInvitePage.tsx b/src/pages/workspace/WorkspaceInvitePage.tsx index 44966329d229..ace308ab52b1 100644 --- a/src/pages/workspace/WorkspaceInvitePage.tsx +++ b/src/pages/workspace/WorkspaceInvitePage.tsx @@ -48,9 +48,9 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); - const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); + const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false, canBeMissing: true}); const firstRenderRef = useRef(true); - const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`); + const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`, {canBeMissing: true}); const openWorkspaceInvitePage = () => { const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList); From fa500357f44ac5c42939884cf2d88bdb08a3078c Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 24 Jul 2025 12:35:12 +0200 Subject: [PATCH 04/32] fix --- src/libs/OptionsListUtils.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index d55d483d1c69..96b5c692833e 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -2260,12 +2260,11 @@ function formatMemberForList(member: OptionData): MemberForList { * Build the options for the Workspace Member Invite view */ function getMemberInviteOptions( - reports: Array> = [], personalDetails: Array>, betas: Beta[] = [], excludeLogins: Record = {}, includeSelectedOptions = false, - + reports: Array> = [], includeRecentReports = false, searchString = '', maxElements?: number, @@ -2758,4 +2757,4 @@ export { sortAlphabetically, }; -export type {MemberForList, Option, OptionList, OptionTree, Options, ReportAndPersonalDetailOptions, SearchOption, Section, SectionBase, GetOptionsConfig}; +export type {MemberForList, Option, OptionList, OptionTree, Options, ReportAndPersonalDetailOptions, SearchOption, Section, SectionBase, GetValidOptionsConfig}; From 2c9d2dc151eb9451fe64bd15843b43de929f6328 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Wed, 13 Aug 2025 14:20:08 +0200 Subject: [PATCH 05/32] Lint fixes --- src/hooks/useSearchSelector.ts | 15 +-------------- src/libs/OptionsListUtils.ts | 8 +++----- .../iou/request/MoneyRequestAttendeeSelector.tsx | 2 +- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index b276d8ba244f..4f947a8ef143 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -169,20 +169,7 @@ function useSearchSelector({ ); break; case 'getAttendeeOptions': - baseOptions = getAttendeeOptions( - options.reports, - personalDetailsWithContacts, - betas ?? [], - [], - [], - false, - true, - false, - undefined, - debouncedSearchTerm, - maxResults, - includeUserToInvite, - ); + baseOptions = getAttendeeOptions(options.reports, personalDetailsWithContacts, betas ?? [], [], [], false, undefined, debouncedSearchTerm, maxResults, includeUserToInvite); break; case 'getShareLogOptions': baseOptions = getShareLogOptions(optionsWithContacts, betas ?? [], debouncedSearchTerm, maxResults, includeUserToInvite); diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index dfff1f583bba..37b9a35facb0 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -2144,8 +2144,6 @@ function getAttendeeOptions( attendees: Attendee[], recentAttendees: Attendee[], includeOwnedWorkspaceChats = false, - includeP2P = true, - includeInvoiceRooms = false, action: IOUAction | undefined = undefined, searchString = '', maxElements?: number, @@ -2187,10 +2185,10 @@ function getAttendeeOptions( excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, includeOwnedWorkspaceChats, includeRecentReports: false, - includeP2P, + includeP2P: true, includeSelectedOptions: false, includeSelfDM: false, - includeInvoiceRooms, + includeInvoiceRooms: false, action, recentAttendees: filteredRecentAttendees, searchString, @@ -2796,4 +2794,4 @@ export { sortAlphabetically, }; -export type {MemberForList, Option, OptionList, OptionTree, Options, ReportAndPersonalDetailOptions, SearchOption, Section, SectionBase, GetValidOptionsConfig}; +export type {MemberForList, Option, OptionList, OptionTree, Options, ReportAndPersonalDetailOptions, SearchOption, Section, SectionBase, GetOptionsConfig}; diff --git a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx index 4fb7a21cfff1..a213754e47ae 100644 --- a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx @@ -85,7 +85,7 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde if (!areOptionsInitialized || !didScreenTransitionEnd) { getEmptyOptions(); } - const optionList = getAttendeeOptions(options.reports, options.personalDetails, betas, attendees, recentAttendees ?? [], iouType === CONST.IOU.TYPE.SUBMIT, true, false, action); + const optionList = getAttendeeOptions(options.reports, options.personalDetails, betas, attendees, recentAttendees ?? [], iouType === CONST.IOU.TYPE.SUBMIT, action); if (isPaidGroupPolicy) { const orderedOptions = orderOptions(optionList, searchTerm, { preferChatRoomsOverThreads: true, From df576d3ca70e554dee790ac24d1423f931d3a1af Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 14 Aug 2025 09:31:39 +0200 Subject: [PATCH 06/32] revert MoneyRequestAttendeeSelector changes --- src/libs/OptionsListUtils.ts | 38 ++++++++++++------- .../request/MoneyRequestAttendeeSelector.tsx | 2 +- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 37b9a35facb0..ee8a6eca4ce7 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -235,6 +235,25 @@ type GetValidReportsConfig = { showRBR?: boolean; } & GetValidOptionsSharedConfig; +type IsValidReportsConfig = Pick< + GetValidReportsConfig, + | 'betas' + | 'includeMultipleParticipantReports' + | 'includeOwnedWorkspaceChats' + | 'includeThreads' + | 'includeTasks' + | 'includeMoneyRequests' + | 'includeReadOnly' + | 'transactionViolations' + | 'includeSelfDM' + | 'includeInvoiceRooms' + | 'action' + | 'includeP2P' + | 'includeDomainEmail' + | 'loginsToExclude' + | 'excludeNonAdminWorkspaces' +>; + type GetValidReportsReturnTypeCombined = { selfDMOption: OptionData | undefined; workspaceOptions: OptionData[]; @@ -1639,7 +1658,7 @@ function getUserToInviteContactOption({ return userToInvite; } -function isValidReport(option: SearchOption, config: GetValidReportsConfig): boolean { +function isValidReport(option: SearchOption, config: IsValidReportsConfig): boolean { const { betas = [], includeMultipleParticipantReports = false, @@ -1954,11 +1973,7 @@ function getValidOptions( ...getValidReportsConfig, includeP2P, includeDomainEmail, - selectedOptions, loginsToExclude, - shouldBoldTitleByDefault, - shouldSeparateSelfDMChat, - shouldSeparateWorkspaceChat, }); }; @@ -1967,7 +1982,6 @@ function getValidOptions( const {recentReports, workspaceOptions, selfDMOption} = getValidReports(filteredReports, { ...getValidReportsConfig, selectedOptions, - loginsToExclude, shouldBoldTitleByDefault, shouldSeparateSelfDMChat, shouldSeparateWorkspaceChat, @@ -2144,10 +2158,9 @@ function getAttendeeOptions( attendees: Attendee[], recentAttendees: Attendee[], includeOwnedWorkspaceChats = false, + includeP2P = true, + includeInvoiceRooms = false, action: IOUAction | undefined = undefined, - searchString = '', - maxElements?: number, - includeUserToInvite = false, ) { const personalDetailList = keyBy( personalDetails.map(({item}) => item), @@ -2185,15 +2198,12 @@ function getAttendeeOptions( excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, includeOwnedWorkspaceChats, includeRecentReports: false, - includeP2P: true, + includeP2P, includeSelectedOptions: false, includeSelfDM: false, - includeInvoiceRooms: false, + includeInvoiceRooms, action, recentAttendees: filteredRecentAttendees, - searchString, - maxElements, - includeUserToInvite, }, ); } diff --git a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx index a213754e47ae..4fb7a21cfff1 100644 --- a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx @@ -85,7 +85,7 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde if (!areOptionsInitialized || !didScreenTransitionEnd) { getEmptyOptions(); } - const optionList = getAttendeeOptions(options.reports, options.personalDetails, betas, attendees, recentAttendees ?? [], iouType === CONST.IOU.TYPE.SUBMIT, action); + const optionList = getAttendeeOptions(options.reports, options.personalDetails, betas, attendees, recentAttendees ?? [], iouType === CONST.IOU.TYPE.SUBMIT, true, false, action); if (isPaidGroupPolicy) { const orderedOptions = orderOptions(optionList, searchTerm, { preferChatRoomsOverThreads: true, From e7a8b2a784742a0c9bacb9fd68f1bae8271a551f Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 14 Aug 2025 09:44:49 +0200 Subject: [PATCH 07/32] revert WorkspaceInvitePage changes --- src/hooks/useSearchSelector.ts | 2 +- src/libs/OptionsListUtils.ts | 2 +- src/pages/workspace/WorkspaceInvitePage.tsx | 233 +++++++++++++------- 3 files changed, 159 insertions(+), 78 deletions(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index 4f947a8ef143..fcfc3b37315d 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -163,9 +163,9 @@ function useSearchSelector({ false, options.reports, includeRecentReports, + includeUserToInvite, debouncedSearchTerm, maxResults, - includeUserToInvite, ); break; case 'getAttendeeOptions': diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index ee8a6eca4ce7..bd0cf808b3db 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -2280,9 +2280,9 @@ function getMemberInviteOptions( includeSelectedOptions = false, reports: Array> = [], includeRecentReports = false, + includeUserToInvite = false, searchString = '', maxElements?: number, - includeUserToInvite = false, ): Options { return getValidOptions( {reports, personalDetails}, diff --git a/src/pages/workspace/WorkspaceInvitePage.tsx b/src/pages/workspace/WorkspaceInvitePage.tsx index ace308ab52b1..fa8647289204 100644 --- a/src/pages/workspace/WorkspaceInvitePage.tsx +++ b/src/pages/workspace/WorkspaceInvitePage.tsx @@ -2,16 +2,17 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {SectionListData} from 'react-native'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import {useOptionsList} from '@components/OptionListContextProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import InviteMemberListItem from '@components/SelectionList/InviteMemberListItem'; import type {Section} from '@components/SelectionList/types'; -import type {WithNavigationTransitionEndProps} from '@components/withNavigationTransitionEnd'; import withNavigationTransitionEnd from '@components/withNavigationTransitionEnd'; +import type {WithNavigationTransitionEndProps} from '@components/withNavigationTransitionEnd'; +import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import useSearchSelector from '@hooks/useSearchSelector'; import useThemeStyles from '@hooks/useThemeStyles'; import {setWorkspaceInviteMembersDraft} from '@libs/actions/Policy/Member'; import {clearErrors, openWorkspaceInvitePage as policyOpenWorkspaceInvitePage, setWorkspaceErrors} from '@libs/actions/Policy/Policy'; @@ -22,7 +23,8 @@ import HttpUtils from '@libs/HttpUtils'; import {appendCountryCode} from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import {getHeaderMessage} from '@libs/OptionsListUtils'; +import {filterAndOrderOptions, formatMemberForList, getHeaderMessage, getMemberInviteOptions, getSearchValueForPhoneOrEmail} from '@libs/OptionsListUtils'; +import type {MemberForList} from '@libs/OptionsListUtils'; import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber'; import {getIneligibleInvitees, getMemberAccountIDsForWorkspace, goBackFromInvalidPolicy} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -35,10 +37,10 @@ import type {InvitedEmailsToAccountIDs} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import AccessOrNotFoundWrapper from './AccessOrNotFoundWrapper'; -import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; +import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; -type MembersSection = SectionListData>; +type MembersSection = SectionListData>; type WorkspaceInvitePageProps = WithPolicyAndFullscreenLoadingProps & WithNavigationTransitionEndProps & @@ -47,17 +49,33 @@ type WorkspaceInvitePageProps = WithPolicyAndFullscreenLoadingProps & function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const [selectedOptions, setSelectedOptions] = useState([]); + const [personalDetails, setPersonalDetails] = useState([]); + const [usersToInvite, setUsersToInvite] = useState([]); const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false); - const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false, canBeMissing: true}); + const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); const firstRenderRef = useRef(true); - const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`, {canBeMissing: true}); + const [betas] = useOnyx(ONYXKEYS.BETAS); + const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`); const openWorkspaceInvitePage = () => { const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList); policyOpenWorkspaceInvitePage(route.params.policyID, Object.keys(policyMemberEmailsToAccountIDs)); }; + const {options, areOptionsInitialized} = useOptionsList({ + shouldInitialize: didScreenTransitionEnd, + }); - const excludeLogins = useMemo(() => { + useEffect(() => { + clearErrors(route.params.policyID); + openWorkspaceInvitePage(); + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- policyID changes remount the component + }, []); + + useNetwork({onReconnect: openWorkspaceInvitePage}); + + const excludedUsers = useMemo(() => { const ineligibleInvites = getIneligibleInvitees(policy?.employeeList); return ineligibleInvites.reduce( (acc, login) => { @@ -68,41 +86,82 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { ); }, [policy?.employeeList]); - const {searchTerm, setSearchTerm, availableOptions, selectedOptions, setSelectedOptions, toggleOption, areOptionsInitialized} = useSearchSelector({ - selectionMode: 'multi', - maxResults: 50, - getOptionsFunction: 'getMemberInviteOptions', - includeUserToInvite: true, - excludeLogins, - onSelectionChange: () => { - clearErrors(route.params.policyID); - }, - }); + const defaultOptions = useMemo(() => { + if (!areOptionsInitialized) { + return {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null}; + } - useEffect(() => { - clearErrors(route.params.policyID); - openWorkspaceInvitePage(); - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- policyID changes remount the component - }, []); + const inviteOptions = getMemberInviteOptions(options.personalDetails, betas ?? [], excludedUsers, true); - useNetwork({onReconnect: openWorkspaceInvitePage}); + return {...inviteOptions, recentReports: [], currentUserOption: null}; + }, [areOptionsInitialized, betas, excludedUsers, options.personalDetails]); + + const inviteOptions = useMemo(() => filterAndOrderOptions(defaultOptions, debouncedSearchTerm, {excludeLogins: excludedUsers}), [debouncedSearchTerm, defaultOptions, excludedUsers]); useEffect(() => { - if (!(firstRenderRef.current && invitedEmailsToAccountIDsDraft)) { + if (!areOptionsInitialized) { return; } - firstRenderRef.current = false; - const savedOptions = Object.keys(invitedEmailsToAccountIDsDraft).map((login) => { - const accountID = invitedEmailsToAccountIDsDraft[login]; - return { - login, - accountID, - text: login, - selected: true, - } as OptionData; + + const newUsersToInviteDict: Record = {}; + const newPersonalDetailsDict: Record = {}; + const newSelectedOptionsDict: Record = {}; + + // Update selectedOptions with the latest personalDetails and policyEmployeeList information + const detailsMap: Record = {}; + inviteOptions.personalDetails.forEach((detail) => { + if (!detail.login) { + return; + } + + detailsMap[detail.login] = formatMemberForList(detail); + }); + + const newSelectedOptions: MemberForList[] = []; + if (firstRenderRef.current) { + // We only want to add the saved selected user on first render + firstRenderRef.current = false; + Object.keys(invitedEmailsToAccountIDsDraft ?? {}).forEach((login) => { + if (!(login in detailsMap)) { + return; + } + newSelectedOptions.push({...detailsMap[login], isSelected: true}); + }); + } + selectedOptions.forEach((option) => { + newSelectedOptions.push(option.login && option.login in detailsMap ? {...detailsMap[option.login], isSelected: true} : option); + }); + + const userToInvite = inviteOptions.userToInvite; + + // Only add the user to the invites list if it is valid + if (typeof userToInvite?.accountID === 'number') { + newUsersToInviteDict[userToInvite.accountID] = userToInvite; + } + + // Add all personal details to the new dict + inviteOptions.personalDetails.forEach((details) => { + if (typeof details.accountID !== 'number') { + return; + } + newPersonalDetailsDict[details.accountID] = details; }); - setSelectedOptions(savedOptions); - }, [invitedEmailsToAccountIDsDraft, setSelectedOptions]); + + // Add all selected options to the new dict + newSelectedOptions.forEach((option) => { + if (typeof option.accountID !== 'number') { + return; + } + newSelectedOptionsDict[option.accountID] = option; + }); + + // Strip out dictionary keys and update arrays + setUsersToInvite(Object.values(newUsersToInviteDict)); + setPersonalDetails(Object.values(newPersonalDetailsDict)); + setSelectedOptions(Object.values(newSelectedOptionsDict)); + + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want to recalculate when selectedOptions change + }, [options.personalDetails, policy?.employeeList, betas, debouncedSearchTerm, excludedUsers, areOptionsInitialized, inviteOptions.personalDetails, inviteOptions.userToInvite]); const sections: MembersSection[] = useMemo(() => { const sectionsArr: MembersSection[] = []; @@ -111,44 +170,66 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { return []; } - // Selected options section - if (selectedOptions.length > 0) { - sectionsArr.push({ - title: undefined, - data: selectedOptions, - shouldShow: true, - }); - } + // Filter all options that is a part of the search term or in the personal details + let filterSelectedOptions = selectedOptions; + if (debouncedSearchTerm !== '') { + filterSelectedOptions = selectedOptions.filter((option) => { + const accountID = option.accountID; + const isOptionInPersonalDetails = Object.values(personalDetails).some((personalDetail) => personalDetail.accountID === accountID); - // Contacts section (excluding selected users) - if (availableOptions.personalDetails.length > 0) { - sectionsArr.push({ - title: translate('common.contacts'), - data: availableOptions.personalDetails, - shouldShow: true, - }); - } + const searchValue = getSearchValueForPhoneOrEmail(debouncedSearchTerm); - // User to invite section - if (availableOptions.userToInvite) { - sectionsArr.push({ - title: undefined, - data: [availableOptions.userToInvite], - shouldShow: true, + const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(searchValue) || !!option.login?.toLowerCase().includes(searchValue); + return isPartOfSearchTerm || isOptionInPersonalDetails; }); } + sectionsArr.push({ + title: undefined, + data: filterSelectedOptions, + shouldShow: true, + }); + + // Filtering out selected users from the search results + const selectedLogins = selectedOptions.map(({login}) => login); + const personalDetailsWithoutSelected = Object.values(personalDetails).filter(({login}) => !selectedLogins.some((selectedLogin) => selectedLogin === login)); + const personalDetailsFormatted = personalDetailsWithoutSelected.map((item) => formatMemberForList(item)); + + sectionsArr.push({ + title: translate('common.contacts'), + data: personalDetailsFormatted, + shouldShow: !isEmptyObject(personalDetailsFormatted), + }); + + Object.values(usersToInvite).forEach((userToInvite) => { + const hasUnselectedUserToInvite = !selectedLogins.some((selectedLogin) => selectedLogin === userToInvite.login); + + if (hasUnselectedUserToInvite) { + sectionsArr.push({ + title: undefined, + data: [formatMemberForList(userToInvite)], + shouldShow: true, + }); + } + }); + return sectionsArr; - }, [areOptionsInitialized, selectedOptions, availableOptions, translate]); + }, [areOptionsInitialized, selectedOptions, debouncedSearchTerm, personalDetails, translate, usersToInvite]); - const handleToggleOption = useCallback( - (option: OptionData) => { - clearErrors(route.params.policyID); - toggleOption(option); - }, - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- policyID changes remount the component - [toggleOption], - ); + const toggleOption = (option: MemberForList) => { + clearErrors(route.params.policyID); + + const isOptionInList = selectedOptions.some((selectedOption) => selectedOption.login === option.login); + + let newSelectedOptions: MemberForList[]; + if (isOptionInList) { + newSelectedOptions = selectedOptions.filter((selectedOption) => selectedOption.login !== option.login); + } else { + newSelectedOptions = [...selectedOptions, {...option, isSelected: true}]; + } + + setSelectedOptions(newSelectedOptions); + }; const inviteUser = useCallback(() => { const errors: Errors = {}; @@ -180,18 +261,18 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { const [policyName, shouldShowAlertPrompt] = useMemo(() => [policy?.name ?? '', !isEmptyObject(policy?.errors) || !!policy?.alertMessage], [policy]); const headerMessage = useMemo(() => { - const searchValue = searchTerm.trim().toLowerCase(); - if (!availableOptions.userToInvite && CONST.EXPENSIFY_EMAILS_OBJECT[searchValue]) { + const searchValue = debouncedSearchTerm.trim().toLowerCase(); + if (usersToInvite.length === 0 && CONST.EXPENSIFY_EMAILS_OBJECT[searchValue]) { return translate('messages.errorMessageInvalidEmail'); } if ( - !availableOptions.userToInvite && - excludeLogins[parsePhoneNumber(appendCountryCode(searchValue)).possible ? addSMSDomainIfPhoneNumber(appendCountryCode(searchValue)) : searchValue] + usersToInvite.length === 0 && + excludedUsers[parsePhoneNumber(appendCountryCode(searchValue)).possible ? addSMSDomainIfPhoneNumber(appendCountryCode(searchValue)) : searchValue] ) { return translate('messages.userIsAlreadyMember', {login: searchValue, name: policyName}); } - return getHeaderMessage(availableOptions.personalDetails.length !== 0, !!availableOptions.userToInvite, searchValue); - }, [excludeLogins, translate, searchTerm, policyName, availableOptions]); + return getHeaderMessage(personalDetails.length !== 0, usersToInvite.length > 0, searchValue); + }, [excludedUsers, translate, debouncedSearchTerm, policyName, usersToInvite, personalDetails.length]); const footerContent = useMemo( () => ( @@ -209,8 +290,8 @@ function WorkspaceInvitePage({route, policy}: WorkspaceInvitePageProps) { ); useEffect(() => { - searchInServer(searchTerm); - }, [searchTerm]); + searchInServer(debouncedSearchTerm); + }, [debouncedSearchTerm]); return ( Date: Thu, 14 Aug 2025 10:31:06 +0200 Subject: [PATCH 08/32] InviteReportParticipantsPage modification and useSearchSelector changes for better usability --- src/hooks/useSearchSelector.ts | 61 ++++++++-------------- src/libs/OptionsListUtils.ts | 3 +- src/pages/InviteReportParticipantsPage.tsx | 2 +- 3 files changed, 23 insertions(+), 43 deletions(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index fcfc3b37315d..8fcf5a8de950 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -1,7 +1,7 @@ import {useCallback, useMemo, useState} from 'react'; import {InteractionManager} from 'react-native'; -import {RESULTS} from 'react-native-permissions'; import type {PermissionStatus} from 'react-native-permissions'; +import {RESULTS} from 'react-native-permissions'; import {useOptionsList} from '@components/OptionListContextProvider'; import contactImport from '@libs/ContactImport'; import type {ContactImportResult} from '@libs/ContactImport/types'; @@ -9,7 +9,7 @@ import useContactPermissions from '@libs/ContactPermission/useContactPermissions import getContacts from '@libs/ContactUtils'; import getPlatform from '@libs/getPlatform'; import type {GetOptionsConfig, Options, SearchOption} from '@libs/OptionsListUtils'; -import {getAttendeeOptions, getEmptyOptions, getMemberInviteOptions, getSearchOptions, getShareDestinationOptions, getShareLogOptions, getValidOptions} from '@libs/OptionsListUtils'; +import {getEmptyOptions, getSearchOptions, getValidOptions} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -17,15 +17,15 @@ import type {PersonalDetails} from '@src/types/onyx'; import useDebouncedState from './useDebouncedState'; import useOnyx from './useOnyx'; -type GetOptionsFunction = 'getSearchOptions' | 'getMemberInviteOptions' | 'getAttendeeOptions' | 'getShareLogOptions' | 'getShareDestinationOptions' | 'getValidOptions'; +type SearchSelectorContext = 'general' | 'search' | 'memberInvite'; type UseSearchSelectorConfig = { /** Selection mode - single or multiple selection */ selectionMode: 'single' | 'multi'; /** Maximum number of results to return (for heap optimization) */ maxResults?: number; - /** Which options function to use for filtering */ - getOptionsFunction?: GetOptionsFunction; + /** What is the context that we are using this hook for */ + searchContext?: SearchSelectorContext; /** Whether to include user to invite option */ includeUserToInvite?: boolean; /** Logins to exclude from results */ @@ -90,7 +90,7 @@ type UseSearchSelectorReturn = { function useSearchSelector({ selectionMode, maxResults = 20, - getOptionsFunction = 'getSearchOptions', + searchContext = 'search', includeUserToInvite = true, excludeLogins = {}, includeRecentReports = false, @@ -151,43 +151,24 @@ function useSearchSelector({ }; let baseOptions: Options; - switch (getOptionsFunction) { - case 'getSearchOptions': + switch (searchContext) { + case 'search': baseOptions = getSearchOptions(optionsWithContacts, betas ?? [], true, true, debouncedSearchTerm, maxResults, includeUserToInvite); break; - case 'getMemberInviteOptions': - baseOptions = getMemberInviteOptions( - personalDetailsWithContacts, - betas ?? [], + case 'memberInvite': + baseOptions = getValidOptions(optionsWithContacts, { + betas: betas ?? [], + includeP2P: true, + includeSelectedOptions: false, excludeLogins, - false, - options.reports, includeRecentReports, - includeUserToInvite, - debouncedSearchTerm, - maxResults, - ); - break; - case 'getAttendeeOptions': - baseOptions = getAttendeeOptions(options.reports, personalDetailsWithContacts, betas ?? [], [], [], false, undefined, debouncedSearchTerm, maxResults, includeUserToInvite); - break; - case 'getShareLogOptions': - baseOptions = getShareLogOptions(optionsWithContacts, betas ?? [], debouncedSearchTerm, maxResults, includeUserToInvite); - break; - case 'getShareDestinationOptions': - baseOptions = getShareDestinationOptions( - options.reports, - personalDetailsWithContacts, - betas ?? [], - [], - excludeLogins, - true, - debouncedSearchTerm, - maxResults, - includeUserToInvite, - ); + maxElements: maxResults, + searchString: debouncedSearchTerm, + }); + // baseOptions = getMemberInviteOptions(personalDetailsWithContacts, + // betas ?? [], excludeLogins, false, options.reports, includeRecentReports, debouncedSearchTerm, maxResults); break; - case 'getValidOptions': + case 'general': baseOptions = getValidOptions(optionsWithContacts, { ...getValidOptionsConfig, betas: betas ?? [], @@ -233,7 +214,7 @@ function useSearchSelector({ betas, debouncedSearchTerm, maxResults, - getOptionsFunction, + searchContext, includeUserToInvite, excludeLogins, includeRecentReports, @@ -319,4 +300,4 @@ function useSearchSelector({ } export default useSearchSelector; -export type {GetOptionsFunction, UseSearchSelectorConfig, UseSearchSelectorReturn, ContactState}; +export type {ContactState}; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index bd0cf808b3db..48a70ef2538f 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -2272,6 +2272,7 @@ function formatMemberForList(member: OptionData): MemberForList { /** * Build the options for the Workspace Member Invite view + * This method will be removed. See https://github.com/Expensify/App/issues/66615 for more information. */ function getMemberInviteOptions( personalDetails: Array>, @@ -2280,7 +2281,6 @@ function getMemberInviteOptions( includeSelectedOptions = false, reports: Array> = [], includeRecentReports = false, - includeUserToInvite = false, searchString = '', maxElements?: number, ): Options { @@ -2294,7 +2294,6 @@ function getMemberInviteOptions( includeRecentReports, searchString, maxElements, - includeUserToInvite, }, ); } diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 2800b9e3ae99..7c1d879f25e4 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -56,7 +56,7 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe const {searchTerm, setSearchTerm, availableOptions, selectedOptions, toggleOption, areOptionsInitialized} = useSearchSelector({ selectionMode: 'multi', maxResults: 50, - getOptionsFunction: 'getMemberInviteOptions', + searchContext: 'memberInvite', includeUserToInvite: true, excludeLogins: excludedUsers, includeRecentReports: true, From f33734c2031bd497b9b6bb812ed5454e1998dfb9 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 14 Aug 2025 10:55:02 +0200 Subject: [PATCH 09/32] use of useContactImport --- src/hooks/useSearchSelector.ts | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index 8fcf5a8de950..577515e09554 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -3,11 +3,8 @@ import {InteractionManager} from 'react-native'; import type {PermissionStatus} from 'react-native-permissions'; import {RESULTS} from 'react-native-permissions'; import {useOptionsList} from '@components/OptionListContextProvider'; -import contactImport from '@libs/ContactImport'; -import type {ContactImportResult} from '@libs/ContactImport/types'; -import useContactPermissions from '@libs/ContactPermission/useContactPermissions'; -import getContacts from '@libs/ContactUtils'; import getPlatform from '@libs/getPlatform'; +import useContactImport from './useContactImport'; import type {GetOptionsConfig, Options, SearchOption} from '@libs/OptionsListUtils'; import {getEmptyOptions, getSearchOptions, getValidOptions} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -106,35 +103,16 @@ function useSearchSelector({ const [selectedOptions, setSelectedOptions] = useState(initialSelected); // Phone contacts logic - const [contactPermissionState, setContactPermissionState] = useState(RESULTS.UNAVAILABLE); - const [contacts, setContacts] = useState>>([]); + const {contacts, contactPermissionState, importAndSaveContacts, setContactPermissionState} = useContactImport(); const platform = getPlatform(); const isNative = platform === CONST.PLATFORM.ANDROID || platform === CONST.PLATFORM.IOS; const shouldEnableContacts = enablePhoneContacts && isNative; const showImportContacts = shouldEnableContacts && !(contactPermissionState === RESULTS.GRANTED || contactPermissionState === RESULTS.LIMITED); - const importAndSaveContacts = useCallback(() => { - if (!shouldEnableContacts) { - return; - } - contactImport().then(({contactList, permissionStatus}: ContactImportResult) => { - setContactPermissionState(permissionStatus); - const usersFromContact = getContacts(contactList); - setContacts(usersFromContact); - }); - }, [shouldEnableContacts]); - const initiateContactImportAndSetState = useCallback(() => { setContactPermissionState(RESULTS.GRANTED); InteractionManager.runAfterInteractions(importAndSaveContacts); - }, [importAndSaveContacts]); - - useContactPermissions({ - importAndSaveContacts, - setContacts, - contactPermissionState, - setContactPermissionState, - }); + }, [importAndSaveContacts, setContactPermissionState]); // Get optimized options with heap filtering and mark selection state const searchOptions = useMemo(() => { From e9becf1c2d1d4f4a5ccda906a58e359dbad70f2f Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 14 Aug 2025 11:00:47 +0200 Subject: [PATCH 10/32] prettier fix --- src/hooks/useSearchSelector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index 577515e09554..56005d59622d 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -4,13 +4,13 @@ import type {PermissionStatus} from 'react-native-permissions'; import {RESULTS} from 'react-native-permissions'; import {useOptionsList} from '@components/OptionListContextProvider'; import getPlatform from '@libs/getPlatform'; -import useContactImport from './useContactImport'; import type {GetOptionsConfig, Options, SearchOption} from '@libs/OptionsListUtils'; import {getEmptyOptions, getSearchOptions, getValidOptions} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails} from '@src/types/onyx'; +import useContactImport from './useContactImport'; import useDebouncedState from './useDebouncedState'; import useOnyx from './useOnyx'; From 66bd8a0d414c94b127c5e5a057d20d30b16e68a1 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Mon, 18 Aug 2025 16:36:27 +0200 Subject: [PATCH 11/32] InviteReportParticipantsPage optimization --- src/hooks/useSearchSelector.ts | 8 ++++---- src/pages/InviteReportParticipantsPage.tsx | 2 -- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index 56005d59622d..fb7d241bebdf 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -89,18 +89,18 @@ function useSearchSelector({ maxResults = 20, searchContext = 'search', includeUserToInvite = true, - excludeLogins = {}, + excludeLogins = CONST.EMPTY_OBJECT, includeRecentReports = false, enablePhoneContacts = false, - getValidOptionsConfig = {}, + getValidOptionsConfig = CONST.EMPTY_OBJECT, onSelectionChange, onSingleSelect, - initialSelected = [], + initialSelected, }: UseSearchSelectorConfig): UseSearchSelectorReturn { const {options, areOptionsInitialized} = useOptionsList(); const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); - const [selectedOptions, setSelectedOptions] = useState(initialSelected); + const [selectedOptions, setSelectedOptions] = useState(initialSelected ?? []); // Phone contacts logic const {contacts, contactPermissionState, importAndSaveContacts, setContactPermissionState} = useContactImport(); diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 7c1d879f25e4..67068f2c07df 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -60,8 +60,6 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe includeUserToInvite: true, excludeLogins: excludedUsers, includeRecentReports: true, - initialSelected: [], - onSelectionChange: () => {}, }); useEffect(() => { From 7a83c43806654b2e29578c67ed344771120a4331 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Mon, 18 Aug 2025 17:52:49 +0200 Subject: [PATCH 12/32] contacts memoization --- src/hooks/useSearchSelector.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index fb7d241bebdf..83fb39ac422d 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -104,6 +104,7 @@ function useSearchSelector({ // Phone contacts logic const {contacts, contactPermissionState, importAndSaveContacts, setContactPermissionState} = useContactImport(); + const memoizedContacts = useMemo(() => (contacts.length ? contacts : CONST.EMPTY_ARRAY), [contacts]); const platform = getPlatform(); const isNative = platform === CONST.PLATFORM.ANDROID || platform === CONST.PLATFORM.IOS; const shouldEnableContacts = enablePhoneContacts && isNative; @@ -121,7 +122,7 @@ function useSearchSelector({ } // Integrate contacts into personalDetails if enabled - const personalDetailsWithContacts = shouldEnableContacts ? options.personalDetails.concat(contacts) : options.personalDetails; + const personalDetailsWithContacts = shouldEnableContacts ? options.personalDetails.concat(memoizedContacts) : options.personalDetails; const optionsWithContacts = { ...options, @@ -198,7 +199,7 @@ function useSearchSelector({ includeRecentReports, selectedOptions, shouldEnableContacts, - contacts, + memoizedContacts, getValidOptionsConfig, ]); From 506704e58fa579825b002df9d9e6e5ae5826e526 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 21 Aug 2025 12:07:38 +0200 Subject: [PATCH 13/32] should initialize --- src/hooks/useSearchSelector.ts | 5 ++++- src/pages/InviteReportParticipantsPage.tsx | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index 83fb39ac422d..52e26982a292 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -39,6 +39,7 @@ type UseSearchSelectorConfig = { onSingleSelect?: (option: OptionData) => void; /** Initial selected options */ initialSelected?: OptionData[]; + shouldInitialize?: boolean; }; type ContactState = { @@ -96,6 +97,7 @@ function useSearchSelector({ onSelectionChange, onSingleSelect, initialSelected, + shouldInitialize = true, }: UseSearchSelectorConfig): UseSearchSelectorReturn { const {options, areOptionsInitialized} = useOptionsList(); const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); @@ -117,7 +119,7 @@ function useSearchSelector({ // Get optimized options with heap filtering and mark selection state const searchOptions = useMemo(() => { - if (!areOptionsInitialized) { + if (!areOptionsInitialized || !shouldInitialize) { return getEmptyOptions(); } @@ -201,6 +203,7 @@ function useSearchSelector({ shouldEnableContacts, memoizedContacts, getValidOptionsConfig, + shouldInitialize, ]); // Available options (unselected items only with proper deduplication) diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 67068f2c07df..c1db2ed1f631 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -60,6 +60,7 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe includeUserToInvite: true, excludeLogins: excludedUsers, includeRecentReports: true, + shouldInitialize: didScreenTransitionEnd, }); useEffect(() => { @@ -196,7 +197,7 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe onConfirm={inviteUsers} showScrollIndicator shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} - showLoadingPlaceholder={!didScreenTransitionEnd || !areOptionsInitialized} + showLoadingPlaceholder={!areOptionsInitialized || !didScreenTransitionEnd} footerContent={footerContent} /> From 74d424a81d84e3bc59724bf8ceecd6397e3b222e Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 21 Aug 2025 13:04:53 +0200 Subject: [PATCH 14/32] should initialize --- src/hooks/useSearchSelector.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index 52e26982a292..176868de207c 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -99,7 +99,9 @@ function useSearchSelector({ initialSelected, shouldInitialize = true, }: UseSearchSelectorConfig): UseSearchSelectorReturn { - const {options, areOptionsInitialized} = useOptionsList(); + const {options, areOptionsInitialized} = useOptionsList({ + shouldInitialize, + }); const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [selectedOptions, setSelectedOptions] = useState(initialSelected ?? []); From e5130edecb725e6a753fd698acf0c1c74fd24389 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Wed, 27 Aug 2025 12:11:30 +0200 Subject: [PATCH 15/32] fixes related to code review. Move static strings to CONST. Remove unused comments --- src/CONST/index.ts | 8 +++++++- src/hooks/useSearchSelector.ts | 16 ++++++++-------- src/pages/InviteReportParticipantsPage.tsx | 4 ++-- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7fd95768016e..b44d3c9a65fb 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6570,7 +6570,13 @@ const CONST = { }, GROUP_PREFIX: 'group_', }, - + SEARCH_SELECTOR: { + SELECTION_MODE_SINGLE: 'single', + SELECTION_MODE_MULTI: 'multi', + SEARCH_CONTEXT_GENERAL: 'general', + SEARCH_CONTEXT_SEARCH: 'search', + SEARCH_CONTEXT_MEMBER_INVITE: 'memberInvite', + }, EXPENSE: { TYPE: { CASH_CARD_NAME: 'Cash Expense', diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index 176868de207c..a4634176b714 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -14,11 +14,12 @@ import useContactImport from './useContactImport'; import useDebouncedState from './useDebouncedState'; import useOnyx from './useOnyx'; -type SearchSelectorContext = 'general' | 'search' | 'memberInvite'; +type SearchSelectorContext = (typeof CONST.SEARCH_SELECTOR)[keyof Pick]; +type SearchSelectorSelectionMode = (typeof CONST.SEARCH_SELECTOR)[keyof Pick]; type UseSearchSelectorConfig = { /** Selection mode - single or multiple selection */ - selectionMode: 'single' | 'multi'; + selectionMode: SearchSelectorSelectionMode; /** Maximum number of results to return (for heap optimization) */ maxResults?: number; /** What is the context that we are using this hook for */ @@ -39,6 +40,7 @@ type UseSearchSelectorConfig = { onSingleSelect?: (option: OptionData) => void; /** Initial selected options */ initialSelected?: OptionData[]; + /** Whether to initialize the hook */ shouldInitialize?: boolean; }; @@ -135,10 +137,10 @@ function useSearchSelector({ let baseOptions: Options; switch (searchContext) { - case 'search': + case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SEARCH: baseOptions = getSearchOptions(optionsWithContacts, betas ?? [], true, true, debouncedSearchTerm, maxResults, includeUserToInvite); break; - case 'memberInvite': + case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE: baseOptions = getValidOptions(optionsWithContacts, { betas: betas ?? [], includeP2P: true, @@ -148,10 +150,8 @@ function useSearchSelector({ maxElements: maxResults, searchString: debouncedSearchTerm, }); - // baseOptions = getMemberInviteOptions(personalDetailsWithContacts, - // betas ?? [], excludeLogins, false, options.reports, includeRecentReports, debouncedSearchTerm, maxResults); break; - case 'general': + case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL: baseOptions = getValidOptions(optionsWithContacts, { ...getValidOptionsConfig, betas: betas ?? [], @@ -229,7 +229,7 @@ function useSearchSelector({ */ const toggleOption = useCallback( (option: OptionData) => { - if (selectionMode === 'single') { + if (selectionMode === CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE) { onSingleSelect?.(option); return; } diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index c1db2ed1f631..68ca48ef1360 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -54,9 +54,9 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe }, [report]); const {searchTerm, setSearchTerm, availableOptions, selectedOptions, toggleOption, areOptionsInitialized} = useSearchSelector({ - selectionMode: 'multi', + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, maxResults: 50, - searchContext: 'memberInvite', + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, includeUserToInvite: true, excludeLogins: excludedUsers, includeRecentReports: true, From def2c8f07fbba41613243f496504a4c202330059 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Mon, 1 Sep 2025 09:46:04 +0200 Subject: [PATCH 16/32] IsValidReport type reintroduction --- src/libs/OptionsListUtils/index.ts | 1 + src/libs/OptionsListUtils/types.ts | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 617c17e69ce2..30baffdacf9b 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -162,6 +162,7 @@ import type { GetUserToInviteConfig, GetValidReportsConfig, GetValidReportsReturnTypeCombined, + IsValidReportsConfig, MemberForList, Option, OptionList, diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index e8beece799e8..a13bc4e8f802 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -152,6 +152,25 @@ type GetValidReportsConfig = { showRBR?: boolean; } & GetValidOptionsSharedConfig; +type IsValidReportsConfig = Pick< + GetValidReportsConfig, + | 'betas' + | 'includeMultipleParticipantReports' + | 'includeOwnedWorkspaceChats' + | 'includeThreads' + | 'includeTasks' + | 'includeMoneyRequests' + | 'includeReadOnly' + | 'transactionViolations' + | 'includeSelfDM' + | 'includeInvoiceRooms' + | 'action' + | 'includeP2P' + | 'includeDomainEmail' + | 'loginsToExclude' + | 'excludeNonAdminWorkspaces' +>; + type GetValidReportsReturnTypeCombined = { selfDMOption: SearchOptionData | undefined; workspaceOptions: SearchOptionData[]; @@ -266,4 +285,5 @@ export type { Section, SectionBase, SectionForSearchTerm, + IsValidReportsConfig, }; From 07e15ab062e8bd571abd67b98b8d846c502e0f11 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Mon, 1 Sep 2025 10:36:57 +0200 Subject: [PATCH 17/32] Introduce integration with SelectionList pagination via onEndReached --- src/hooks/useSearchSelector.ts | 12 ++++++++++-- src/pages/InviteReportParticipantsPage.tsx | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index a4634176b714..2a6e1cd63996 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -21,7 +21,7 @@ type UseSearchSelectorConfig = { /** Selection mode - single or multiple selection */ selectionMode: SearchSelectorSelectionMode; /** Maximum number of results to return (for heap optimization) */ - maxResults?: number; + maxResultsPerPage?: number; /** What is the context that we are using this hook for */ searchContext?: SearchSelectorContext; /** Whether to include user to invite option */ @@ -78,6 +78,8 @@ type UseSearchSelectorReturn = { areOptionsInitialized: boolean; /** Contact-related state and functions (when enablePhoneContacts is true) */ contactState?: ContactState; + /** Callback to handle list end reached */ + onListEndReached: () => void; }; /** @@ -89,7 +91,7 @@ type UseSearchSelectorReturn = { */ function useSearchSelector({ selectionMode, - maxResults = 20, + maxResultsPerPage = CONST.MAX_SELECTION_LIST_PAGE_LENGTH, searchContext = 'search', includeUserToInvite = true, excludeLogins = CONST.EMPTY_OBJECT, @@ -107,6 +109,7 @@ function useSearchSelector({ const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [selectedOptions, setSelectedOptions] = useState(initialSelected ?? []); + const [maxResults, setMaxResults] = useState(maxResultsPerPage); // Phone contacts logic const {contacts, contactPermissionState, importAndSaveContacts, setContactPermissionState} = useContactImport(); @@ -121,6 +124,10 @@ function useSearchSelector({ InteractionManager.runAfterInteractions(importAndSaveContacts); }, [importAndSaveContacts, setContactPermissionState]); + const onListEndReached = useCallback(() => { + setMaxResults((previous) => previous + maxResultsPerPage); + }, [maxResultsPerPage]); + // Get optimized options with heap filtering and mark selection state const searchOptions = useMemo(() => { if (!areOptionsInitialized || !shouldInitialize) { @@ -280,6 +287,7 @@ function useSearchSelector({ toggleOption, areOptionsInitialized, contactState, + onListEndReached, }; } diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 68ca48ef1360..2e7bef506d62 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -53,9 +53,8 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe return res; }, [report]); - const {searchTerm, setSearchTerm, availableOptions, selectedOptions, toggleOption, areOptionsInitialized} = useSearchSelector({ + const {searchTerm, setSearchTerm, availableOptions, selectedOptions, toggleOption, areOptionsInitialized, onListEndReached} = useSearchSelector({ selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, - maxResults: 50, searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, includeUserToInvite: true, excludeLogins: excludedUsers, @@ -199,6 +198,7 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} showLoadingPlaceholder={!areOptionsInitialized || !didScreenTransitionEnd} footerContent={footerContent} + onEndReached={onListEndReached} /> ); From 66f35c0ffb3a30bb557e176456e5ff0a52fd2dea Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Mon, 1 Sep 2025 11:00:01 +0200 Subject: [PATCH 18/32] should skip button --- src/pages/InviteReportParticipantsPage.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 2e7bef506d62..0be48e539136 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -173,6 +173,7 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe [selectedOptions.length, inviteUsers, translate, styles], ); + console.log('morwa sections', sections); return ( ); From 81b6012b7313026704c171abf576ae8e82922ca6 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Tue, 2 Sep 2025 15:32:41 +0200 Subject: [PATCH 19/32] remove console.log --- src/pages/InviteReportParticipantsPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 0be48e539136..c6a53ebfddf7 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -173,7 +173,6 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe [selectedOptions.length, inviteUsers, translate, styles], ); - console.log('morwa sections', sections); return ( Date: Mon, 8 Sep 2025 13:28:25 +0200 Subject: [PATCH 20/32] Introduce search by email/phone --- src/hooks/useSearchSelector.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index 2a6e1cd63996..8795bf8c49e4 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -5,7 +5,7 @@ import {RESULTS} from 'react-native-permissions'; import {useOptionsList} from '@components/OptionListContextProvider'; import getPlatform from '@libs/getPlatform'; import type {GetOptionsConfig, Options, SearchOption} from '@libs/OptionsListUtils'; -import {getEmptyOptions, getSearchOptions, getValidOptions} from '@libs/OptionsListUtils'; +import {getEmptyOptions, getSearchOptions, getSearchValueForPhoneOrEmail, getValidOptions} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -110,6 +110,7 @@ function useSearchSelector({ const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [selectedOptions, setSelectedOptions] = useState(initialSelected ?? []); const [maxResults, setMaxResults] = useState(maxResultsPerPage); + const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); // Phone contacts logic const {contacts, contactPermissionState, importAndSaveContacts, setContactPermissionState} = useContactImport(); @@ -128,6 +129,10 @@ function useSearchSelector({ setMaxResults((previous) => previous + maxResultsPerPage); }, [maxResultsPerPage]); + const computedSearchTerm = useMemo(() => { + return getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode); + }, [debouncedSearchTerm, countryCode]); + // Get optimized options with heap filtering and mark selection state const searchOptions = useMemo(() => { if (!areOptionsInitialized || !shouldInitialize) { @@ -145,7 +150,7 @@ function useSearchSelector({ let baseOptions: Options; switch (searchContext) { case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SEARCH: - baseOptions = getSearchOptions(optionsWithContacts, betas ?? [], true, true, debouncedSearchTerm, maxResults, includeUserToInvite); + baseOptions = getSearchOptions(optionsWithContacts, betas ?? [], true, true, computedSearchTerm, maxResults, includeUserToInvite); break; case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE: baseOptions = getValidOptions(optionsWithContacts, { @@ -155,14 +160,14 @@ function useSearchSelector({ excludeLogins, includeRecentReports, maxElements: maxResults, - searchString: debouncedSearchTerm, + searchString: computedSearchTerm, }); break; case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL: baseOptions = getValidOptions(optionsWithContacts, { ...getValidOptionsConfig, betas: betas ?? [], - searchString: debouncedSearchTerm, + searchString: computedSearchTerm, maxElements: maxResults, includeUserToInvite, loginsToExclude: excludeLogins, @@ -202,7 +207,7 @@ function useSearchSelector({ areOptionsInitialized, options, betas, - debouncedSearchTerm, + computedSearchTerm, maxResults, searchContext, includeUserToInvite, From 34c17618b17a2b2b706ac69a21ed3729d2e98940 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Mon, 8 Sep 2025 14:32:32 +0200 Subject: [PATCH 21/32] Do not show loader when list is already computed --- src/hooks/useSearchSelector.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index 8795bf8c49e4..68ce7ddc21f6 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -1,4 +1,4 @@ -import {useCallback, useMemo, useState} from 'react'; +import {useCallback, useEffect, useMemo, useState} from 'react'; import {InteractionManager} from 'react-native'; import type {PermissionStatus} from 'react-native-permissions'; import {RESULTS} from 'react-native-permissions'; @@ -135,7 +135,7 @@ function useSearchSelector({ // Get optimized options with heap filtering and mark selection state const searchOptions = useMemo(() => { - if (!areOptionsInitialized || !shouldInitialize) { + if (!areOptionsInitialized) { return getEmptyOptions(); } @@ -217,7 +217,6 @@ function useSearchSelector({ shouldEnableContacts, memoizedContacts, getValidOptionsConfig, - shouldInitialize, ]); // Available options (unselected items only with proper deduplication) From 259cb855f626945179c8d6de8145afaf83593b96 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Mon, 8 Sep 2025 14:46:58 +0200 Subject: [PATCH 22/32] empty list error removed when selected items --- src/pages/InviteReportParticipantsPage.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index c6a53ebfddf7..635744ecf2e7 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -154,8 +154,12 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe ) { return translate('messages.userIsAlreadyMember', {login: processedLogin, name: reportName ?? ''}); } - return getHeaderMessage(availableOptions.recentReports.length + availableOptions.personalDetails.length !== 0, !!availableOptions.userToInvite, processedLogin); - }, [searchTerm, availableOptions, excludedUsers, translate, reportName]); + return getHeaderMessage( + selectedOptions.length + availableOptions.recentReports.length + availableOptions.personalDetails.length !== 0, + !!availableOptions.userToInvite, + processedLogin, + ); + }, [searchTerm, availableOptions, selectedOptions, excludedUsers, translate, reportName]); const footerContent = useMemo( () => ( From 00264cb35c26074d7bef0013636d37bb26552c19 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Mon, 8 Sep 2025 15:51:13 +0200 Subject: [PATCH 23/32] Selected options for display --- src/hooks/useSearchSelector.ts | 9 +++++++++ src/pages/InviteReportParticipantsPage.tsx | 8 ++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index 68ce7ddc21f6..1e059a395d9e 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -70,6 +70,8 @@ type UseSearchSelectorReturn = { availableOptions: Options; /** Currently selected options */ selectedOptions: OptionData[]; + /** Currently selected options used for list display */ + selectedOptionsForDisplay: OptionData[]; /** Function to set selected options */ setSelectedOptions: (options: OptionData[]) => void; /** Function to toggle option selection */ @@ -281,6 +283,12 @@ function useSearchSelector({ } : undefined; + const selectedOptionsForDisplay = useMemo(() => { + return selectedOptions.filter((option) => { + return !!option.text?.toLowerCase().includes(computedSearchTerm) || !!option.login?.toLowerCase().includes(computedSearchTerm); + }); + }, [selectedOptions, computedSearchTerm]); + return { searchTerm, setSearchTerm, @@ -292,6 +300,7 @@ function useSearchSelector({ areOptionsInitialized, contactState, onListEndReached, + selectedOptionsForDisplay, }; } diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 635744ecf2e7..32a286cd891a 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -53,7 +53,7 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe return res; }, [report]); - const {searchTerm, setSearchTerm, availableOptions, selectedOptions, toggleOption, areOptionsInitialized, onListEndReached} = useSearchSelector({ + const {searchTerm, setSearchTerm, availableOptions, selectedOptions, selectedOptionsForDisplay, toggleOption, areOptionsInitialized, onListEndReached} = useSearchSelector({ selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, includeUserToInvite: true, @@ -75,10 +75,10 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe } // Selected options section - if (selectedOptions.length > 0) { + if (selectedOptionsForDisplay.length > 0) { sectionsArr.push({ title: undefined, - data: selectedOptions, + data: selectedOptionsForDisplay, }); } @@ -107,7 +107,7 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe } return sectionsArr; - }, [areOptionsInitialized, selectedOptions, availableOptions, translate]); + }, [areOptionsInitialized, selectedOptionsForDisplay, availableOptions, translate]); const handleToggleOption = useCallback( (option: OptionData) => { From afe29f58c5e9e289279a2ab8ec4ad3bfa4686f8e Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Mon, 8 Sep 2025 17:14:17 +0200 Subject: [PATCH 24/32] update imports --- src/hooks/useSearchSelector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index 1e059a395d9e..0eac5dbf67db 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -1,4 +1,4 @@ -import {useCallback, useEffect, useMemo, useState} from 'react'; +import {useCallback, useMemo, useState} from 'react'; import {InteractionManager} from 'react-native'; import type {PermissionStatus} from 'react-native-permissions'; import {RESULTS} from 'react-native-permissions'; From 2c0b0552ec976a9ba5a4d5d195e35b8744bfe525 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 11 Sep 2025 12:10:38 +0200 Subject: [PATCH 25/32] remove shouldSkipShowMoreButton prop which does not exist anymore --- src/pages/InviteReportParticipantsPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 32a286cd891a..d901e5774ee5 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -203,7 +203,6 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe showLoadingPlaceholder={!areOptionsInitialized || !didScreenTransitionEnd} footerContent={footerContent} onEndReached={onListEndReached} - shouldSkipShowMoreButton /> ); From 6f5c9d171236b40800a02c1c38244d8e79323d8c Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Thu, 11 Sep 2025 12:13:44 +0200 Subject: [PATCH 26/32] Fix header message sent to SelectionList --- src/hooks/useSearchSelector.ts | 4 ++-- src/pages/InviteReportParticipantsPage.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index 0eac5dbf67db..23367ada1717 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -68,9 +68,9 @@ type UseSearchSelectorReturn = { searchOptions: Options; /** Available (unselected) options */ availableOptions: Options; - /** Currently selected options */ + /** Currently selected options. This returns all selected options and are not affected by search term */ selectedOptions: OptionData[]; - /** Currently selected options used for list display */ + /** Currently selected options used for list display. This prop can be used in selection list to display selected options that are filtered by search term */ selectedOptionsForDisplay: OptionData[]; /** Function to set selected options */ setSelectedOptions: (options: OptionData[]) => void; diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index d901e5774ee5..868b3e3b0fdf 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -155,7 +155,7 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe return translate('messages.userIsAlreadyMember', {login: processedLogin, name: reportName ?? ''}); } return getHeaderMessage( - selectedOptions.length + availableOptions.recentReports.length + availableOptions.personalDetails.length !== 0, + selectedOptionsForDisplay.length + availableOptions.recentReports.length + availableOptions.personalDetails.length !== 0, !!availableOptions.userToInvite, processedLogin, ); From 9529acc798db1ef20a5b19dca11dffc2a1a5f0b0 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Fri, 12 Sep 2025 10:57:18 +0200 Subject: [PATCH 27/32] update to hook --- src/hooks/useSearchSelector.ts | 56 +++++++++++++--------------------- 1 file changed, 21 insertions(+), 35 deletions(-) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index 23367ada1717..76ee8cc0ff53 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -135,27 +135,27 @@ function useSearchSelector({ return getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode); }, [debouncedSearchTerm, countryCode]); - // Get optimized options with heap filtering and mark selection state - const searchOptions = useMemo(() => { - if (!areOptionsInitialized) { - return getEmptyOptions(); + const optionsWithContacts = useMemo(() => { + if (!shouldEnableContacts || !areOptionsInitialized) { + return options; } - - // Integrate contacts into personalDetails if enabled - const personalDetailsWithContacts = shouldEnableContacts ? options.personalDetails.concat(memoizedContacts) : options.personalDetails; - - const optionsWithContacts = { + const personalDetailsWithContacts = options.personalDetails.concat(memoizedContacts); + return { ...options, personalDetails: personalDetailsWithContacts, }; + }, [areOptionsInitialized, options, memoizedContacts, shouldEnableContacts]); + + const baseOptions = useMemo(() => { + if (!areOptionsInitialized) { + return getEmptyOptions(); + } - let baseOptions: Options; switch (searchContext) { case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SEARCH: - baseOptions = getSearchOptions(optionsWithContacts, betas ?? [], true, true, computedSearchTerm, maxResults, includeUserToInvite); - break; + return getSearchOptions(optionsWithContacts, betas ?? [], true, true, computedSearchTerm, maxResults, includeUserToInvite); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE: - baseOptions = getValidOptions(optionsWithContacts, { + return getValidOptions(optionsWithContacts, { betas: betas ?? [], includeP2P: true, includeSelectedOptions: false, @@ -164,9 +164,8 @@ function useSearchSelector({ maxElements: maxResults, searchString: computedSearchTerm, }); - break; case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL: - baseOptions = getValidOptions(optionsWithContacts, { + return getValidOptions(optionsWithContacts, { ...getValidOptionsConfig, betas: betas ?? [], searchString: computedSearchTerm, @@ -174,20 +173,22 @@ function useSearchSelector({ includeUserToInvite, loginsToExclude: excludeLogins, }); - break; default: - baseOptions = getEmptyOptions(); + return getEmptyOptions(); } + }, [areOptionsInitialized, optionsWithContacts, betas, computedSearchTerm, maxResults, searchContext, includeUserToInvite, excludeLogins, includeRecentReports, getValidOptionsConfig]); - // Mark selection state on all options - const isOptionSelected = (option: OptionData) => + const isOptionSelected = useMemo(() => { + return (option: OptionData) => selectedOptions.some( (selected) => (selected.accountID && selected.accountID === option.accountID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison (selected.reportID && selected.reportID === option.reportID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison (selected.login && selected.login === option.login), ); + }, [selectedOptions]); + const searchOptions = useMemo(() => { return { ...baseOptions, personalDetails: baseOptions.personalDetails.map((option) => ({ @@ -205,23 +206,8 @@ function useSearchSelector({ } : null, }; - }, [ - areOptionsInitialized, - options, - betas, - computedSearchTerm, - maxResults, - searchContext, - includeUserToInvite, - excludeLogins, - includeRecentReports, - selectedOptions, - shouldEnableContacts, - memoizedContacts, - getValidOptionsConfig, - ]); + }, [baseOptions, isOptionSelected]); - // Available options (unselected items only with proper deduplication) const availableOptions = useMemo(() => { const unselectedRecentReports = searchOptions.recentReports.filter((option) => !option.isSelected); From 1ba393b02ef4efc2d71f7ca3534b622b09ba0fd0 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Tue, 16 Sep 2025 10:21:36 +0200 Subject: [PATCH 28/32] comment additional tabs for easy of read --- src/hooks/useSearchSelector.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index 76ee8cc0ff53..5f5408e7168b 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -20,26 +20,37 @@ type SearchSelectorSelectionMode = (typeof CONST.SEARCH_SELECTOR)[keyof Pick; + /** Whether to include recent reports (for getMemberInviteOptions) */ includeRecentReports?: boolean; + /** Enable phone contacts integration */ enablePhoneContacts?: boolean; + /** Additional configuration for getValidOptions function */ getValidOptionsConfig?: Partial; + /** Callback when selection changes (multi-select mode) */ onSelectionChange?: (selected: OptionData[]) => void; + /** Callback when single option is selected (single-select mode) */ onSingleSelect?: (option: OptionData) => void; + /** Initial selected options */ initialSelected?: OptionData[]; + /** Whether to initialize the hook */ shouldInitialize?: boolean; }; @@ -47,14 +58,19 @@ type UseSearchSelectorConfig = { type ContactState = { /** Current permission status */ permissionStatus: PermissionStatus; + /** Contact options from device */ contactOptions: Array>; + /** Whether to show import UI */ showImportUI: boolean; + /** Function to trigger contact import */ importContacts: () => void; + /** Function to initiate contact import and set state */ initiateContactImportAndSetState: () => void; + /** Function to set permission state */ setContactPermissionState: (status: PermissionStatus) => void; }; @@ -62,24 +78,34 @@ type ContactState = { type UseSearchSelectorReturn = { /** Current search term */ searchTerm: string; + /** Function to update search term */ setSearchTerm: (value: string) => void; + /** Filtered and optimized search options with selection state */ searchOptions: Options; + /** Available (unselected) options */ availableOptions: Options; + /** Currently selected options. This returns all selected options and are not affected by search term */ selectedOptions: OptionData[]; + /** Currently selected options used for list display. This prop can be used in selection list to display selected options that are filtered by search term */ selectedOptionsForDisplay: OptionData[]; + /** Function to set selected options */ setSelectedOptions: (options: OptionData[]) => void; + /** Function to toggle option selection */ toggleOption: (option: OptionData) => void; + /** Whether options are initialized */ areOptionsInitialized: boolean; + /** Contact-related state and functions (when enablePhoneContacts is true) */ contactState?: ContactState; + /** Callback to handle list end reached */ onListEndReached: () => void; }; From 2c5a13f8b94c4beaf9aef77b9ffd9ee1336585ee Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Tue, 16 Sep 2025 11:12:05 +0200 Subject: [PATCH 29/32] split logic into web and native parts. --- src/hooks/useSearchSelector.base.ts | 290 ++++++++++++++++++++++++ src/hooks/useSearchSelector.native.ts | 56 +++++ src/hooks/useSearchSelector.ts | 313 +------------------------- 3 files changed, 351 insertions(+), 308 deletions(-) create mode 100644 src/hooks/useSearchSelector.base.ts create mode 100644 src/hooks/useSearchSelector.native.ts diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector.base.ts new file mode 100644 index 000000000000..cae78ccf9ae3 --- /dev/null +++ b/src/hooks/useSearchSelector.base.ts @@ -0,0 +1,290 @@ +import {useCallback, useMemo, useState} from 'react'; +import {useOptionsList} from '@components/OptionListContextProvider'; +import type {GetOptionsConfig, Options, SearchOption} from '@libs/OptionsListUtils'; +import {getEmptyOptions, getSearchOptions, getSearchValueForPhoneOrEmail, getValidOptions} from '@libs/OptionsListUtils'; +import type {OptionData} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetails} from '@src/types/onyx'; +import useDebouncedState from './useDebouncedState'; +import useOnyx from './useOnyx'; + +type SearchSelectorContext = (typeof CONST.SEARCH_SELECTOR)[keyof Pick]; +type SearchSelectorSelectionMode = (typeof CONST.SEARCH_SELECTOR)[keyof Pick]; + +type UseSearchSelectorConfig = { + /** Selection mode - single or multiple selection */ + selectionMode: SearchSelectorSelectionMode; + + /** Maximum number of results to return (for heap optimization) */ + maxResultsPerPage?: number; + + /** What is the context that we are using this hook for */ + searchContext?: SearchSelectorContext; + + /** Whether to include user to invite option */ + includeUserToInvite?: boolean; + + /** Logins to exclude from results */ + excludeLogins?: Record; + + /** Whether to include recent reports (for getMemberInviteOptions) */ + includeRecentReports?: boolean; + + /** Enable phone contacts integration */ + enablePhoneContacts?: boolean; + + /** Additional configuration for getValidOptions function */ + getValidOptionsConfig?: Partial; + + /** Callback when selection changes (multi-select mode) */ + onSelectionChange?: (selected: OptionData[]) => void; + + /** Callback when single option is selected (single-select mode) */ + onSingleSelect?: (option: OptionData) => void; + + /** Initial selected options */ + initialSelected?: OptionData[]; + + /** Whether to initialize the hook */ + shouldInitialize?: boolean; +}; + +type ContactState = { + /** Current permission status */ + permissionStatus: string; + + /** Contact options from device */ + contactOptions: Array>; + + /** Whether to show import UI */ + showImportUI: boolean; + + /** Function to trigger contact import */ + importContacts: () => void; + + /** Function to initiate contact import and set state */ + initiateContactImportAndSetState: () => void; + + /** Function to set permission state */ + setContactPermissionState: (status: string) => void; +}; + +type UseSearchSelectorReturn = { + /** Current search term */ + searchTerm: string; + + /** Function to update search term */ + setSearchTerm: (value: string) => void; + + /** Filtered and optimized search options with selection state */ + searchOptions: Options; + + /** Available (unselected) options */ + availableOptions: Options; + + /** Currently selected options. This returns all selected options and are not affected by search term */ + selectedOptions: OptionData[]; + + /** Currently selected options used for list display. This prop can be used in selection list to display selected options that are filtered by search term */ + selectedOptionsForDisplay: OptionData[]; + + /** Function to set selected options */ + setSelectedOptions: (options: OptionData[]) => void; + + /** Function to toggle option selection */ + toggleOption: (option: OptionData) => void; + + /** Whether options are initialized */ + areOptionsInitialized: boolean; + + /** Contact-related state and functions (when enablePhoneContacts is true) */ + contactState?: ContactState; + + /** Callback to handle list end reached */ + onListEndReached: () => void; +}; + +/** + * Base hook that provides search functionality with selection logic for option lists. + * This contains the core logic without platform-specific dependencies. + */ +function useSearchSelectorBase({ + selectionMode, + maxResultsPerPage = CONST.MAX_SELECTION_LIST_PAGE_LENGTH, + searchContext = 'search', + includeUserToInvite = true, + excludeLogins = CONST.EMPTY_OBJECT, + includeRecentReports = false, + getValidOptionsConfig = CONST.EMPTY_OBJECT, + onSelectionChange, + onSingleSelect, + initialSelected, + shouldInitialize = true, + contactOptions, +}: UseSearchSelectorConfig & { + /** Additional contact options to merge (used by platform-specific implementations) */ + contactOptions?: Array>; +}): UseSearchSelectorReturn { + const {options: defaultOptions, areOptionsInitialized} = useOptionsList({ + shouldInitialize, + }); + + const optionsWithContacts = useMemo(() => { + if (!contactOptions?.length || !areOptionsInitialized) { + return defaultOptions; + } + const personalDetailsWithContacts = defaultOptions.personalDetails.concat(contactOptions); + return { + ...defaultOptions, + personalDetails: personalDetailsWithContacts, + }; + }, [areOptionsInitialized, defaultOptions, contactOptions]); + const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const [selectedOptions, setSelectedOptions] = useState(initialSelected ?? []); + const [maxResults, setMaxResults] = useState(maxResultsPerPage); + const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); + + const onListEndReached = useCallback(() => { + setMaxResults((previous) => previous + maxResultsPerPage); + }, [maxResultsPerPage]); + + const computedSearchTerm = useMemo(() => { + return getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode); + }, [debouncedSearchTerm, countryCode]); + + const baseOptions = useMemo(() => { + if (!areOptionsInitialized) { + return getEmptyOptions(); + } + + switch (searchContext) { + case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SEARCH: + return getSearchOptions(optionsWithContacts, betas ?? [], true, true, computedSearchTerm, maxResults, includeUserToInvite); + case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE: + return getValidOptions(optionsWithContacts, { + betas: betas ?? [], + includeP2P: true, + includeSelectedOptions: false, + excludeLogins, + includeRecentReports, + maxElements: maxResults, + searchString: computedSearchTerm, + }); + case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL: + return getValidOptions(optionsWithContacts, { + ...getValidOptionsConfig, + betas: betas ?? [], + searchString: computedSearchTerm, + maxElements: maxResults, + includeUserToInvite, + loginsToExclude: excludeLogins, + }); + default: + return getEmptyOptions(); + } + }, [areOptionsInitialized, optionsWithContacts, betas, computedSearchTerm, maxResults, searchContext, includeUserToInvite, excludeLogins, includeRecentReports, getValidOptionsConfig]); + + const isOptionSelected = useMemo(() => { + return (option: OptionData) => + selectedOptions.some( + (selected) => + (selected.accountID && selected.accountID === option.accountID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison + (selected.reportID && selected.reportID === option.reportID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison + (selected.login && selected.login === option.login), + ); + }, [selectedOptions]); + + const searchOptions = useMemo(() => { + return { + ...baseOptions, + personalDetails: baseOptions.personalDetails.map((option) => ({ + ...option, + isSelected: isOptionSelected(option), + })), + recentReports: baseOptions.recentReports.map((option) => ({ + ...option, + isSelected: isOptionSelected(option), + })), + userToInvite: baseOptions.userToInvite + ? { + ...baseOptions.userToInvite, + isSelected: isOptionSelected(baseOptions.userToInvite), + } + : null, + }; + }, [baseOptions, isOptionSelected]); + + const availableOptions = useMemo(() => { + const unselectedRecentReports = searchOptions.recentReports.filter((option) => !option.isSelected); + + // Filter out people who appear in recent reports from personal details (recents take priority) + const recentReportLogins = new Set(unselectedRecentReports.map((option) => option.login).filter(Boolean)); + const unselectedPersonalDetails = searchOptions.personalDetails.filter((option) => !option.isSelected && !recentReportLogins.has(option.login)); + + return { + ...searchOptions, + personalDetails: unselectedPersonalDetails, + recentReports: unselectedRecentReports, + userToInvite: searchOptions.userToInvite?.isSelected ? null : searchOptions.userToInvite, + }; + }, [searchOptions]); + + /** + * Toggle option selection based on selection mode + */ + const toggleOption = useCallback( + (option: OptionData) => { + if (selectionMode === CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE) { + onSingleSelect?.(option); + return; + } + + const isSelected = selectedOptions.some( + (selected) => + (selected.accountID && selected.accountID === option.accountID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison + (selected.reportID && selected.reportID === option.reportID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison + (selected.login && selected.login === option.login), + ); + + const newSelected = isSelected + ? selectedOptions.filter( + (selected) => + !( + (selected.accountID && selected.accountID === option.accountID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison + (selected.reportID && selected.reportID === option.reportID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison + (selected.login && selected.login === option.login) + ), + ) + : [...selectedOptions, {...option, isSelected: true}]; + + setSelectedOptions(newSelected); + onSelectionChange?.(newSelected); + }, + [selectedOptions, selectionMode, onSelectionChange, onSingleSelect], + ); + + const selectedOptionsForDisplay = useMemo(() => { + return selectedOptions.filter((option) => { + return !!option.text?.toLowerCase().includes(computedSearchTerm) || !!option.login?.toLowerCase().includes(computedSearchTerm); + }); + }, [selectedOptions, computedSearchTerm]); + + return { + searchTerm, + setSearchTerm, + searchOptions, + availableOptions, + selectedOptions, + setSelectedOptions, + toggleOption, + areOptionsInitialized, + contactState: undefined, + onListEndReached, + selectedOptionsForDisplay, + }; +} + +export default useSearchSelectorBase; +export type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn, SearchSelectorContext, SearchSelectorSelectionMode}; \ No newline at end of file diff --git a/src/hooks/useSearchSelector.native.ts b/src/hooks/useSearchSelector.native.ts new file mode 100644 index 000000000000..0723a0b8c392 --- /dev/null +++ b/src/hooks/useSearchSelector.native.ts @@ -0,0 +1,56 @@ +import {useCallback, useMemo} from 'react'; +import {InteractionManager} from 'react-native'; +import type {PermissionStatus} from 'react-native-permissions'; +import {RESULTS} from 'react-native-permissions'; +import CONST from '@src/CONST'; +import useContactImport from './useContactImport'; +import useSearchSelectorBase from './useSearchSelector.base'; +import type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn} from './useSearchSelector.base'; + +/** + * Hook that combines search functionality with selection logic for option lists. + * Leverages heap optimization for performance with large datasets. + * Native version includes phone contacts integration. + * + * @param config - Configuration object for the hook + * @returns Object with search and selection utilities + */ +function useSearchSelector(config: UseSearchSelectorConfig): UseSearchSelectorReturn { + const {enablePhoneContacts = false} = config; + + // Phone contacts logic + const {contacts, contactPermissionState, importAndSaveContacts, setContactPermissionState} = useContactImport(); + const memoizedContacts = useMemo(() => (contacts.length ? contacts : CONST.EMPTY_ARRAY), [contacts]); + const showImportContacts = enablePhoneContacts && !(contactPermissionState === RESULTS.GRANTED || contactPermissionState === RESULTS.LIMITED); + + const initiateContactImportAndSetState = useCallback(() => { + setContactPermissionState(RESULTS.GRANTED); + InteractionManager.runAfterInteractions(importAndSaveContacts); + }, [importAndSaveContacts, setContactPermissionState]); + + // Use base hook with contact options + const baseResult = useSearchSelectorBase({ + ...config, + contactOptions: enablePhoneContacts ? memoizedContacts : undefined, + }); + + // Build contact state if enabled + const contactState: ContactState | undefined = enablePhoneContacts + ? { + permissionStatus: contactPermissionState, + contactOptions: contacts, + showImportUI: showImportContacts, + importContacts: importAndSaveContacts, + initiateContactImportAndSetState, + setContactPermissionState, + } + : undefined; + + return { + ...baseResult, + contactState, + }; +} + +export default useSearchSelector; +export type {ContactState}; \ No newline at end of file diff --git a/src/hooks/useSearchSelector.ts b/src/hooks/useSearchSelector.ts index 5f5408e7168b..943146cd3281 100644 --- a/src/hooks/useSearchSelector.ts +++ b/src/hooks/useSearchSelector.ts @@ -1,319 +1,16 @@ -import {useCallback, useMemo, useState} from 'react'; -import {InteractionManager} from 'react-native'; -import type {PermissionStatus} from 'react-native-permissions'; -import {RESULTS} from 'react-native-permissions'; -import {useOptionsList} from '@components/OptionListContextProvider'; -import getPlatform from '@libs/getPlatform'; -import type {GetOptionsConfig, Options, SearchOption} from '@libs/OptionsListUtils'; -import {getEmptyOptions, getSearchOptions, getSearchValueForPhoneOrEmail, getValidOptions} from '@libs/OptionsListUtils'; -import type {OptionData} from '@libs/ReportUtils'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails} from '@src/types/onyx'; -import useContactImport from './useContactImport'; -import useDebouncedState from './useDebouncedState'; -import useOnyx from './useOnyx'; - -type SearchSelectorContext = (typeof CONST.SEARCH_SELECTOR)[keyof Pick]; -type SearchSelectorSelectionMode = (typeof CONST.SEARCH_SELECTOR)[keyof Pick]; - -type UseSearchSelectorConfig = { - /** Selection mode - single or multiple selection */ - selectionMode: SearchSelectorSelectionMode; - - /** Maximum number of results to return (for heap optimization) */ - maxResultsPerPage?: number; - - /** What is the context that we are using this hook for */ - searchContext?: SearchSelectorContext; - - /** Whether to include user to invite option */ - includeUserToInvite?: boolean; - - /** Logins to exclude from results */ - excludeLogins?: Record; - - /** Whether to include recent reports (for getMemberInviteOptions) */ - includeRecentReports?: boolean; - - /** Enable phone contacts integration */ - enablePhoneContacts?: boolean; - - /** Additional configuration for getValidOptions function */ - getValidOptionsConfig?: Partial; - - /** Callback when selection changes (multi-select mode) */ - onSelectionChange?: (selected: OptionData[]) => void; - - /** Callback when single option is selected (single-select mode) */ - onSingleSelect?: (option: OptionData) => void; - - /** Initial selected options */ - initialSelected?: OptionData[]; - - /** Whether to initialize the hook */ - shouldInitialize?: boolean; -}; - -type ContactState = { - /** Current permission status */ - permissionStatus: PermissionStatus; - - /** Contact options from device */ - contactOptions: Array>; - - /** Whether to show import UI */ - showImportUI: boolean; - - /** Function to trigger contact import */ - importContacts: () => void; - - /** Function to initiate contact import and set state */ - initiateContactImportAndSetState: () => void; - - /** Function to set permission state */ - setContactPermissionState: (status: PermissionStatus) => void; -}; - -type UseSearchSelectorReturn = { - /** Current search term */ - searchTerm: string; - - /** Function to update search term */ - setSearchTerm: (value: string) => void; - - /** Filtered and optimized search options with selection state */ - searchOptions: Options; - - /** Available (unselected) options */ - availableOptions: Options; - - /** Currently selected options. This returns all selected options and are not affected by search term */ - selectedOptions: OptionData[]; - - /** Currently selected options used for list display. This prop can be used in selection list to display selected options that are filtered by search term */ - selectedOptionsForDisplay: OptionData[]; - - /** Function to set selected options */ - setSelectedOptions: (options: OptionData[]) => void; - - /** Function to toggle option selection */ - toggleOption: (option: OptionData) => void; - - /** Whether options are initialized */ - areOptionsInitialized: boolean; - - /** Contact-related state and functions (when enablePhoneContacts is true) */ - contactState?: ContactState; - - /** Callback to handle list end reached */ - onListEndReached: () => void; -}; +import useSearchSelectorBase from './useSearchSelector.base'; +import type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn} from './useSearchSelector.base'; /** * Hook that combines search functionality with selection logic for option lists. * Leverages heap optimization for performance with large datasets. + * Web/desktop version without phone contacts integration. * * @param config - Configuration object for the hook * @returns Object with search and selection utilities */ -function useSearchSelector({ - selectionMode, - maxResultsPerPage = CONST.MAX_SELECTION_LIST_PAGE_LENGTH, - searchContext = 'search', - includeUserToInvite = true, - excludeLogins = CONST.EMPTY_OBJECT, - includeRecentReports = false, - enablePhoneContacts = false, - getValidOptionsConfig = CONST.EMPTY_OBJECT, - onSelectionChange, - onSingleSelect, - initialSelected, - shouldInitialize = true, -}: UseSearchSelectorConfig): UseSearchSelectorReturn { - const {options, areOptionsInitialized} = useOptionsList({ - shouldInitialize, - }); - const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: true}); - const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); - const [selectedOptions, setSelectedOptions] = useState(initialSelected ?? []); - const [maxResults, setMaxResults] = useState(maxResultsPerPage); - const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); - - // Phone contacts logic - const {contacts, contactPermissionState, importAndSaveContacts, setContactPermissionState} = useContactImport(); - const memoizedContacts = useMemo(() => (contacts.length ? contacts : CONST.EMPTY_ARRAY), [contacts]); - const platform = getPlatform(); - const isNative = platform === CONST.PLATFORM.ANDROID || platform === CONST.PLATFORM.IOS; - const shouldEnableContacts = enablePhoneContacts && isNative; - const showImportContacts = shouldEnableContacts && !(contactPermissionState === RESULTS.GRANTED || contactPermissionState === RESULTS.LIMITED); - - const initiateContactImportAndSetState = useCallback(() => { - setContactPermissionState(RESULTS.GRANTED); - InteractionManager.runAfterInteractions(importAndSaveContacts); - }, [importAndSaveContacts, setContactPermissionState]); - - const onListEndReached = useCallback(() => { - setMaxResults((previous) => previous + maxResultsPerPage); - }, [maxResultsPerPage]); - - const computedSearchTerm = useMemo(() => { - return getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode); - }, [debouncedSearchTerm, countryCode]); - - const optionsWithContacts = useMemo(() => { - if (!shouldEnableContacts || !areOptionsInitialized) { - return options; - } - const personalDetailsWithContacts = options.personalDetails.concat(memoizedContacts); - return { - ...options, - personalDetails: personalDetailsWithContacts, - }; - }, [areOptionsInitialized, options, memoizedContacts, shouldEnableContacts]); - - const baseOptions = useMemo(() => { - if (!areOptionsInitialized) { - return getEmptyOptions(); - } - - switch (searchContext) { - case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SEARCH: - return getSearchOptions(optionsWithContacts, betas ?? [], true, true, computedSearchTerm, maxResults, includeUserToInvite); - case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE: - return getValidOptions(optionsWithContacts, { - betas: betas ?? [], - includeP2P: true, - includeSelectedOptions: false, - excludeLogins, - includeRecentReports, - maxElements: maxResults, - searchString: computedSearchTerm, - }); - case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL: - return getValidOptions(optionsWithContacts, { - ...getValidOptionsConfig, - betas: betas ?? [], - searchString: computedSearchTerm, - maxElements: maxResults, - includeUserToInvite, - loginsToExclude: excludeLogins, - }); - default: - return getEmptyOptions(); - } - }, [areOptionsInitialized, optionsWithContacts, betas, computedSearchTerm, maxResults, searchContext, includeUserToInvite, excludeLogins, includeRecentReports, getValidOptionsConfig]); - - const isOptionSelected = useMemo(() => { - return (option: OptionData) => - selectedOptions.some( - (selected) => - (selected.accountID && selected.accountID === option.accountID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison - (selected.reportID && selected.reportID === option.reportID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison - (selected.login && selected.login === option.login), - ); - }, [selectedOptions]); - - const searchOptions = useMemo(() => { - return { - ...baseOptions, - personalDetails: baseOptions.personalDetails.map((option) => ({ - ...option, - isSelected: isOptionSelected(option), - })), - recentReports: baseOptions.recentReports.map((option) => ({ - ...option, - isSelected: isOptionSelected(option), - })), - userToInvite: baseOptions.userToInvite - ? { - ...baseOptions.userToInvite, - isSelected: isOptionSelected(baseOptions.userToInvite), - } - : null, - }; - }, [baseOptions, isOptionSelected]); - - const availableOptions = useMemo(() => { - const unselectedRecentReports = searchOptions.recentReports.filter((option) => !option.isSelected); - - // Filter out people who appear in recent reports from personal details (recents take priority) - const recentReportLogins = new Set(unselectedRecentReports.map((option) => option.login).filter(Boolean)); - const unselectedPersonalDetails = searchOptions.personalDetails.filter((option) => !option.isSelected && !recentReportLogins.has(option.login)); - - return { - ...searchOptions, - personalDetails: unselectedPersonalDetails, - recentReports: unselectedRecentReports, - userToInvite: searchOptions.userToInvite?.isSelected ? null : searchOptions.userToInvite, - }; - }, [searchOptions]); - - /** - * Toggle option selection based on selection mode - */ - const toggleOption = useCallback( - (option: OptionData) => { - if (selectionMode === CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE) { - onSingleSelect?.(option); - return; - } - - const isSelected = selectedOptions.some( - (selected) => - (selected.accountID && selected.accountID === option.accountID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison - (selected.reportID && selected.reportID === option.reportID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison - (selected.login && selected.login === option.login), - ); - - const newSelected = isSelected - ? selectedOptions.filter( - (selected) => - !( - (selected.accountID && selected.accountID === option.accountID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison - (selected.reportID && selected.reportID === option.reportID) || // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- this is boolean comparison - (selected.login && selected.login === option.login) - ), - ) - : [...selectedOptions, {...option, isSelected: true}]; - - setSelectedOptions(newSelected); - onSelectionChange?.(newSelected); - }, - [selectedOptions, selectionMode, onSelectionChange, onSingleSelect], - ); - - // Build contact state if enabled - const contactState: ContactState | undefined = shouldEnableContacts - ? { - permissionStatus: contactPermissionState, - contactOptions: contacts, - showImportUI: showImportContacts, - importContacts: importAndSaveContacts, - initiateContactImportAndSetState, - setContactPermissionState, - } - : undefined; - - const selectedOptionsForDisplay = useMemo(() => { - return selectedOptions.filter((option) => { - return !!option.text?.toLowerCase().includes(computedSearchTerm) || !!option.login?.toLowerCase().includes(computedSearchTerm); - }); - }, [selectedOptions, computedSearchTerm]); - - return { - searchTerm, - setSearchTerm, - searchOptions, - availableOptions, - selectedOptions, - setSelectedOptions, - toggleOption, - areOptionsInitialized, - contactState, - onListEndReached, - selectedOptionsForDisplay, - }; +function useSearchSelector(config: UseSearchSelectorConfig): UseSearchSelectorReturn { + return useSearchSelectorBase(config); } export default useSearchSelector; From 1de188999fe8e2ba5142d94bacaf182aaea4b451 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Tue, 16 Sep 2025 11:13:07 +0200 Subject: [PATCH 30/32] var rename for best practices --- src/pages/InviteReportParticipantsPage.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index 868b3e3b0fdf..dab2bebdbbde 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -68,7 +68,7 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe }, [searchTerm]); const sections = useMemo(() => { - const sectionsArr: Sections = []; + const sectionsArray: Sections = []; if (!areOptionsInitialized) { return []; @@ -76,7 +76,7 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe // Selected options section if (selectedOptionsForDisplay.length > 0) { - sectionsArr.push({ + sectionsArray.push({ title: undefined, data: selectedOptionsForDisplay, }); @@ -84,7 +84,7 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe // Recent reports section if (availableOptions.recentReports.length > 0) { - sectionsArr.push({ + sectionsArray.push({ title: translate('common.recents'), data: availableOptions.recentReports, }); @@ -92,7 +92,7 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe // Contacts section if (availableOptions.personalDetails.length > 0) { - sectionsArr.push({ + sectionsArray.push({ title: translate('common.contacts'), data: availableOptions.personalDetails, }); @@ -100,13 +100,13 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe // User to invite section if (availableOptions.userToInvite) { - sectionsArr.push({ + sectionsArray.push({ title: undefined, data: [availableOptions.userToInvite], }); } - return sectionsArr; + return sectionsArray; }, [areOptionsInitialized, selectedOptionsForDisplay, availableOptions, translate]); const handleToggleOption = useCallback( From 651e6da8cb4ed82ec965cb648c20f293db936e00 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Tue, 16 Sep 2025 11:17:03 +0200 Subject: [PATCH 31/32] rename callback for best practices --- src/hooks/useSearchSelector.base.ts | 12 ++++++------ src/pages/InviteReportParticipantsPage.tsx | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector.base.ts index cae78ccf9ae3..3b5a98036abb 100644 --- a/src/hooks/useSearchSelector.base.ts +++ b/src/hooks/useSearchSelector.base.ts @@ -92,8 +92,8 @@ type UseSearchSelectorReturn = { /** Function to set selected options */ setSelectedOptions: (options: OptionData[]) => void; - /** Function to toggle option selection */ - toggleOption: (option: OptionData) => void; + /** Function to toggle selection state of an option */ + toggleSelection: (option: OptionData) => void; /** Whether options are initialized */ areOptionsInitialized: boolean; @@ -232,9 +232,9 @@ function useSearchSelectorBase({ }, [searchOptions]); /** - * Toggle option selection based on selection mode + * Toggle selection state of option based on selection mode */ - const toggleOption = useCallback( + const toggleSelection = useCallback( (option: OptionData) => { if (selectionMode === CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE) { onSingleSelect?.(option); @@ -278,7 +278,7 @@ function useSearchSelectorBase({ availableOptions, selectedOptions, setSelectedOptions, - toggleOption, + toggleSelection, areOptionsInitialized, contactState: undefined, onListEndReached, @@ -287,4 +287,4 @@ function useSearchSelectorBase({ } export default useSearchSelectorBase; -export type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn, SearchSelectorContext, SearchSelectorSelectionMode}; \ No newline at end of file +export type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn, SearchSelectorContext, SearchSelectorSelectionMode}; diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index dab2bebdbbde..a2b2b2663e6b 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -53,7 +53,7 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe return res; }, [report]); - const {searchTerm, setSearchTerm, availableOptions, selectedOptions, selectedOptionsForDisplay, toggleOption, areOptionsInitialized, onListEndReached} = useSearchSelector({ + const {searchTerm, setSearchTerm, availableOptions, selectedOptions, selectedOptionsForDisplay, toggleSelection, areOptionsInitialized, onListEndReached} = useSearchSelector({ selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, includeUserToInvite: true, @@ -109,11 +109,11 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe return sectionsArray; }, [areOptionsInitialized, selectedOptionsForDisplay, availableOptions, translate]); - const handleToggleOption = useCallback( + const handleToggleSelection = useCallback( (option: OptionData) => { - toggleOption(option); + toggleSelection(option); }, - [toggleOption], + [toggleSelection], ); const validate = useCallback(() => selectedOptions.length > 0, [selectedOptions]); @@ -196,7 +196,7 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe textInputValue={searchTerm} onChangeText={setSearchTerm} headerMessage={headerMessage} - onSelectRow={handleToggleOption} + onSelectRow={handleToggleSelection} onConfirm={inviteUsers} showScrollIndicator shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} From a3478568ff79b28da7837312b61ead483817df44 Mon Sep 17 00:00:00 2001 From: Hubert Sosinski Date: Tue, 16 Sep 2025 11:40:07 +0200 Subject: [PATCH 32/32] lint fixes --- src/hooks/useSearchSelector.base.ts | 13 +++++++------ src/hooks/useSearchSelector.native.ts | 8 +++----- src/pages/InviteReportParticipantsPage.tsx | 2 +- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector.base.ts index 3b5a98036abb..f6b436e4e0df 100644 --- a/src/hooks/useSearchSelector.base.ts +++ b/src/hooks/useSearchSelector.base.ts @@ -1,4 +1,5 @@ import {useCallback, useMemo, useState} from 'react'; +import type {PermissionStatus} from 'react-native-permissions'; import {useOptionsList} from '@components/OptionListContextProvider'; import type {GetOptionsConfig, Options, SearchOption} from '@libs/OptionsListUtils'; import {getEmptyOptions, getSearchOptions, getSearchValueForPhoneOrEmail, getValidOptions} from '@libs/OptionsListUtils'; @@ -48,11 +49,14 @@ type UseSearchSelectorConfig = { /** Whether to initialize the hook */ shouldInitialize?: boolean; + + /** Additional contact options to merge (used by platform-specific implementations) */ + contactOptions?: Array>; }; type ContactState = { /** Current permission status */ - permissionStatus: string; + permissionStatus: PermissionStatus; /** Contact options from device */ contactOptions: Array>; @@ -67,7 +71,7 @@ type ContactState = { initiateContactImportAndSetState: () => void; /** Function to set permission state */ - setContactPermissionState: (status: string) => void; + setContactPermissionState: (status: PermissionStatus) => void; }; type UseSearchSelectorReturn = { @@ -122,10 +126,7 @@ function useSearchSelectorBase({ initialSelected, shouldInitialize = true, contactOptions, -}: UseSearchSelectorConfig & { - /** Additional contact options to merge (used by platform-specific implementations) */ - contactOptions?: Array>; -}): UseSearchSelectorReturn { +}: UseSearchSelectorConfig): UseSearchSelectorReturn { const {options: defaultOptions, areOptionsInitialized} = useOptionsList({ shouldInitialize, }); diff --git a/src/hooks/useSearchSelector.native.ts b/src/hooks/useSearchSelector.native.ts index 0723a0b8c392..c52cca653fd9 100644 --- a/src/hooks/useSearchSelector.native.ts +++ b/src/hooks/useSearchSelector.native.ts @@ -1,11 +1,9 @@ import {useCallback, useMemo} from 'react'; import {InteractionManager} from 'react-native'; -import type {PermissionStatus} from 'react-native-permissions'; import {RESULTS} from 'react-native-permissions'; -import CONST from '@src/CONST'; import useContactImport from './useContactImport'; -import useSearchSelectorBase from './useSearchSelector.base'; import type {ContactState, UseSearchSelectorConfig, UseSearchSelectorReturn} from './useSearchSelector.base'; +import useSearchSelectorBase from './useSearchSelector.base'; /** * Hook that combines search functionality with selection logic for option lists. @@ -20,7 +18,7 @@ function useSearchSelector(config: UseSearchSelectorConfig): UseSearchSelectorRe // Phone contacts logic const {contacts, contactPermissionState, importAndSaveContacts, setContactPermissionState} = useContactImport(); - const memoizedContacts = useMemo(() => (contacts.length ? contacts : CONST.EMPTY_ARRAY), [contacts]); + const memoizedContacts = useMemo(() => (contacts.length ? contacts : []), [contacts]); const showImportContacts = enablePhoneContacts && !(contactPermissionState === RESULTS.GRANTED || contactPermissionState === RESULTS.LIMITED); const initiateContactImportAndSetState = useCallback(() => { @@ -53,4 +51,4 @@ function useSearchSelector(config: UseSearchSelectorConfig): UseSearchSelectorRe } export default useSearchSelector; -export type {ContactState}; \ No newline at end of file +export type {ContactState}; diff --git a/src/pages/InviteReportParticipantsPage.tsx b/src/pages/InviteReportParticipantsPage.tsx index a2b2b2663e6b..8354cf40362e 100644 --- a/src/pages/InviteReportParticipantsPage.tsx +++ b/src/pages/InviteReportParticipantsPage.tsx @@ -159,7 +159,7 @@ function InviteReportParticipantsPage({report, didScreenTransitionEnd}: InviteRe !!availableOptions.userToInvite, processedLogin, ); - }, [searchTerm, availableOptions, selectedOptions, excludedUsers, translate, reportName]); + }, [searchTerm, availableOptions, selectedOptionsForDisplay, excludedUsers, translate, reportName]); const footerContent = useMemo( () => (