From 6d01fcef8cd7ab9c5991c89ac98249820510d51a Mon Sep 17 00:00:00 2001 From: NJ-2020 Date: Sat, 4 Oct 2025 20:34:47 +0700 Subject: [PATCH 01/19] refactor MoneyRequestAttendeeSelector to use useSearchSelector hook --- src/CONST/index.ts | 1 + src/hooks/useSearchSelector.base.ts | 17 +- src/libs/OptionsListUtils/index.ts | 25 ++ .../request/MoneyRequestAttendeeSelector.tsx | 314 +++++++++--------- 4 files changed, 204 insertions(+), 153 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3ff29cfbdd202..ce29702c74dc4 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6750,6 +6750,7 @@ const CONST = { SEARCH_CONTEXT_GENERAL: 'general', SEARCH_CONTEXT_SEARCH: 'search', SEARCH_CONTEXT_MEMBER_INVITE: 'memberInvite', + SEARCH_CONTEXT_ATTENDEES: 'attendees', }, EXPENSE: { TYPE: { diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector.base.ts index f19b75c33cd08..cf74b2b9ce191 100644 --- a/src/hooks/useSearchSelector.base.ts +++ b/src/hooks/useSearchSelector.base.ts @@ -10,7 +10,10 @@ import type {PersonalDetails} from '@src/types/onyx'; import useDebouncedState from './useDebouncedState'; import useOnyx from './useOnyx'; -type SearchSelectorContext = (typeof CONST.SEARCH_SELECTOR)[keyof Pick]; +type SearchSelectorContext = (typeof CONST.SEARCH_SELECTOR)[keyof Pick< + typeof CONST.SEARCH_SELECTOR, + 'SEARCH_CONTEXT_GENERAL' | 'SEARCH_CONTEXT_SEARCH' | 'SEARCH_CONTEXT_MEMBER_INVITE' | 'SEARCH_CONTEXT_ATTENDEES' +>]; type SearchSelectorSelectionMode = (typeof CONST.SEARCH_SELECTOR)[keyof Pick]; type UseSearchSelectorConfig = { @@ -155,6 +158,7 @@ function useSearchSelectorBase({ return getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode); }, [debouncedSearchTerm, countryCode]); + console.log('useSearchSotr hook', searchContext, areOptionsInitialized); const baseOptions = useMemo(() => { if (!areOptionsInitialized) { return getEmptyOptions(); @@ -174,6 +178,17 @@ function useSearchSelectorBase({ searchString: computedSearchTerm, includeUserToInvite, }); + case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_ATTENDEES: + return getValidOptions(optionsWithContacts, { + betas: betas ?? [], + includeP2P: true, + includeSelectedOptions: false, + excludeLogins, + includeRecentReports, + maxElements: maxResults, + searchString: computedSearchTerm, + includeUserToInvite, + }); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL: return getValidOptions(optionsWithContacts, { ...getValidOptionsConfig, diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index a596a72c089c2..7332ca0a20e48 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -1347,6 +1347,7 @@ function getUserToInviteOption({ showChatPreviewLine = false, shouldAcceptName = false, }: GetUserToInviteConfig): SearchOptionData | null { + debugger; if (!searchValue) { return null; } @@ -1765,6 +1766,14 @@ function getValidOptions( ...excludeLogins, ...restrictedLogins, }; + + console.log('get valid options data', { + includeUserToInvite, + excludeLogins, + restrictedLogins, + loginsToExclude, + options, + }); // If we're including selected options from the search results, we only want to exclude them if the search input is empty // This is because on certain pages, we show the selected options at the top when the search input is empty // This prevents the issue of seeing the selected option twice if you have them as a recent chat and select them @@ -1878,6 +1887,8 @@ function getValidOptions( personalDetailsOptions = optionsOrderBy(options.personalDetails, personalDetailsComparator, maxElements, filteringFunction, true); + console.log('personalDetailsOptions', personalDetailsOptions); + for (let i = 0; i < personalDetailsOptions.length; i++) { const personalDetail = personalDetailsOptions.at(i); if (!personalDetail) { @@ -1898,6 +1909,11 @@ function getValidOptions( } let userToInvite: SearchOptionData | null = null; + console.log( + 'get valid options', + includeUserToInvite, + filterUserToInvite({currentUserOption: currentUserRef.current, recentReports: recentReportOptions, personalDetails: personalDetailsOptions}, searchString ?? ''), + ); if (includeUserToInvite) { userToInvite = filterUserToInvite({currentUserOption: currentUserRef.current, recentReports: recentReportOptions, personalDetails: personalDetailsOptions}, searchString ?? ''); } @@ -2363,6 +2379,7 @@ function filterCurrentUserOption(currentUserOption: SearchOptionData | null | un function filterUserToInvite(options: Omit, searchValue: string, config?: FilterUserToInviteConfig): SearchOptionData | null { const {canInviteUser = true, excludeLogins = {}} = config ?? {}; if (!canInviteUser) { + console.log('trueee'); return null; } @@ -2374,6 +2391,7 @@ function filterUserToInvite(options: Omit, searchValue: }); if (!canCreateOptimisticDetail) { + console.log('cannot createOptimisticDetail'); return null; } @@ -2381,6 +2399,8 @@ function filterUserToInvite(options: Omit, searchValue: [CONST.EMAIL.NOTIFICATIONS]: true, ...excludeLogins, }; + + console.log('create invite option', loginsToExclude); return getUserToInviteOption({ searchValue, loginsToExclude, @@ -2505,6 +2525,11 @@ function filterAndOrderOptions(options: Options, searchInputValue: string, count return true; }); + console.log('filterr', { + filterResult, + orderedOptions, + }); + return { ...filterResult, ...orderedOptions, diff --git a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx index ed16a598bd07e..8bd93954dc704 100644 --- a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx @@ -1,30 +1,25 @@ import reportsSelector from '@selectors/Attributes'; import {deepEqual} from 'fast-equals'; -import lodashReject from 'lodash/reject'; import React, {memo, useCallback, useEffect, useMemo} from 'react'; import type {GestureResponderEvent} from 'react-native'; import Button from '@components/Button'; import EmptySelectionListContent from '@components/EmptySelectionListContent'; import FormHelpMessage from '@components/FormHelpMessage'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; -import {useOptionsList} from '@components/OptionListContextProvider'; import SelectionList from '@components/SelectionListWithSections'; import InviteMemberListItem from '@components/SelectionListWithSections/InviteMemberListItem'; import type {SectionListDataType} from '@components/SelectionListWithSections/types'; -import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; +import useSearchSelector from '@hooks/useSearchSelector'; import useThemeStyles from '@hooks/useThemeStyles'; +import {searchInServer} from '@libs/actions/Report'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import type {Option} from '@libs/OptionsListUtils'; import { - filterAndOrderOptions, formatSectionsFromSearchTerm, - getAttendeeOptions, - getEmptyOptions, getHeaderMessage, getParticipantsOption, getPersonalDetailSearchTerms, @@ -34,7 +29,7 @@ import { } from '@libs/OptionsListUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {isPaidGroupPolicy as isPaidGroupPolicyFn} from '@libs/PolicyUtils'; -import {searchInServer} from '@userActions/Report'; +import type {OptionData} from '@libs/ReportUtils'; import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -60,83 +55,164 @@ type MoneyRequestAttendeesSelectorProps = { function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdded, iouType, action}: MoneyRequestAttendeesSelectorProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const {isOffline} = useNetwork(); const personalDetails = usePersonalDetails(); const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus(); - const [countryCode] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false}); const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: false}); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); const [recentAttendees] = useOnyx(ONYXKEYS.NVP_RECENT_ATTENDEES, {canBeMissing: true}); const policy = usePolicy(activePolicyID); const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false, canBeMissing: true}); - const {options, areOptionsInitialized} = useOptionsList({ - shouldInitialize: didScreenTransitionEnd, - }); const [reportAttributesDerived] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {canBeMissing: true, selector: reportsSelector}); - const cleanSearchTerm = useMemo(() => searchTerm.trim().toLowerCase(), [searchTerm]); const offlineMessage: string = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; const isPaidGroupPolicy = useMemo(() => isPaidGroupPolicyFn(policy), [policy]); + // Convert attendees to OptionData format for useSearchSelector + const initialSelectedOptions = useMemo( + () => + attendees.map((attendee) => ({ + accountID: attendee.accountID ?? CONST.DEFAULT_NUMBER_ID, + login: attendee.email, + text: attendee.displayName, + searchText: attendee.searchText ?? attendee.displayName, + avatarUrl: attendee.avatarUrl, + reportID: CONST.DEFAULT_NUMBER_ID.toString(), + selected: true, + })), + [attendees], + ); + + const {searchTerm, setSearchTerm, availableOptions, selectedOptions, toggleSelection, areOptionsInitialized, onListEndReached} = useSearchSelector({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_ATTENDEES, + includeUserToInvite: true, + excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, + includeRecentReports: true, + getValidOptionsConfig: { + betas: betas ?? [], + selectedOptions: initialSelectedOptions, + includeOwnedWorkspaceChats: iouType === CONST.IOU.TYPE.SUBMIT, + includeP2P: true, + includeSelectedOptions: false, + includeSelfDM: false, + includeInvoiceRooms: false, + action, + recentAttendees: recentAttendees ?? [], + }, + initialSelected: initialSelectedOptions, + shouldInitialize: didScreenTransitionEnd, + }); + useEffect(() => { - searchInServer(debouncedSearchTerm.trim()); - }, [debouncedSearchTerm]); + searchInServer(searchTerm.trim()); + }, [searchTerm]); - const defaultOptions = useMemo(() => { - if (!areOptionsInitialized || !didScreenTransitionEnd) { - getEmptyOptions(); + // Apply ordering for paid group policies (preserving original behavior) + const orderedAvailableOptions = useMemo(() => { + if (!isPaidGroupPolicy || !areOptionsInitialized) { + return availableOptions; } - 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, { + + const orderedOptions = orderOptions( + { + recentReports: availableOptions.recentReports, + personalDetails: availableOptions.personalDetails, + workspaceChats: availableOptions.workspaceChats ?? [], + }, + searchTerm, + { preferChatRoomsOverThreads: true, preferPolicyExpenseChat: !!action, preferRecentExpenseReports: action === CONST.IOU.ACTION.CREATE, - }); - optionList.recentReports = orderedOptions.recentReports; - optionList.personalDetails = orderedOptions.personalDetails; - } - return optionList; - }, [areOptionsInitialized, didScreenTransitionEnd, options.reports, options.personalDetails, betas, attendees, recentAttendees, iouType, action, isPaidGroupPolicy, searchTerm]); + }, + ); - const chatOptions = useMemo(() => { - if (!areOptionsInitialized) { - return { - userToInvite: null, - recentReports: [], - personalDetails: [], - currentUserOption: null, - headerMessage: '', - }; - } - const newOptions = filterAndOrderOptions(defaultOptions, cleanSearchTerm, countryCode, { - excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, - preferPolicyExpenseChat: isPaidGroupPolicy, - shouldAcceptName: true, - selectedOptions: attendees.map((attendee) => ({ - ...attendee, - reportID: CONST.DEFAULT_NUMBER_ID.toString(), + return { + ...availableOptions, + recentReports: orderedOptions.recentReports, + personalDetails: orderedOptions.personalDetails, + workspaceChats: orderedOptions.workspaceChats, + }; + }, [availableOptions, isPaidGroupPolicy, areOptionsInitialized, searchTerm, action]); + + // Convert selectedOptions back to Attendee format for the parent component + const handleSelectionChange = useCallback( + (newSelectedOptions: OptionData[]) => { + const newAttendees: Attendee[] = newSelectedOptions.map((option) => ({ + accountID: option.accountID ?? CONST.DEFAULT_NUMBER_ID, + login: option.login ?? option.text, + email: option.login ?? option.text ?? '', + displayName: option.text ?? '', selected: true, - login: attendee.email, - ...getPersonalDetailByEmail(attendee.email), - })), - }); - return newOptions; - }, [areOptionsInitialized, defaultOptions, cleanSearchTerm, isPaidGroupPolicy, attendees, countryCode]); + searchText: option.searchText, + avatarUrl: option.avatarUrl ?? '', + iouType, + })); + onAttendeesAdded(newAttendees); + }, + [iouType, onAttendeesAdded], + ); + + // Update parent component when selection changes + useEffect(() => { + handleSelectionChange(selectedOptions); + }, [selectedOptions, handleSelectionChange]); + + const shouldShowErrorMessage = selectedOptions.length < 1; + + const handleConfirmSelection = useCallback( + (_keyEvent?: GestureResponderEvent | KeyboardEvent, option?: OptionData) => { + if (shouldShowErrorMessage || (!selectedOptions.length && !option)) { + return; + } + + onFinish(CONST.IOU.TYPE.SUBMIT); + }, + [shouldShowErrorMessage, onFinish, selectedOptions], + ); + + const showLoadingPlaceholder = useMemo(() => !areOptionsInitialized || !didScreenTransitionEnd, [areOptionsInitialized, didScreenTransitionEnd]); + + const footerContent = useMemo(() => { + if (!shouldShowErrorMessage && !selectedOptions.length) { + return; + } + + return ( + <> + {shouldShowErrorMessage && ( + + )} +