diff --git a/src/CONST/index.ts b/src/CONST/index.ts index a54109df179d4..513562a8d15e7 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6860,6 +6860,7 @@ const CONST = { SEARCH_CONTEXT_MEMBER_INVITE: 'memberInvite', SEARCH_CONTEXT_SHARE_LOG: 'shareLog', SEARCH_CONTEXT_SHARE_DESTINATION: 'shareDestination', + SEARCH_CONTEXT_ATTENDEES: 'attendees', }, EXPENSE: { TYPE: { diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector.base.ts index e6897611ca73c..bc5805b61123a 100644 --- a/src/hooks/useSearchSelector.base.ts +++ b/src/hooks/useSearchSelector.base.ts @@ -12,7 +12,7 @@ import useOnyx from './useOnyx'; type SearchSelectorContext = (typeof CONST.SEARCH_SELECTOR)[keyof Pick< typeof CONST.SEARCH_SELECTOR, - 'SEARCH_CONTEXT_GENERAL' | 'SEARCH_CONTEXT_SEARCH' | 'SEARCH_CONTEXT_MEMBER_INVITE' | 'SEARCH_CONTEXT_SHARE_LOG' | 'SEARCH_CONTEXT_SHARE_DESTINATION' + 'SEARCH_CONTEXT_GENERAL' | 'SEARCH_CONTEXT_SEARCH' | 'SEARCH_CONTEXT_MEMBER_INVITE' | 'SEARCH_CONTEXT_SHARE_LOG' | 'SEARCH_CONTEXT_SHARE_DESTINATION' | 'SEARCH_CONTEXT_ATTENDEES' >]; type SearchSelectorSelectionMode = (typeof CONST.SEARCH_SELECTOR)[keyof Pick]; @@ -38,6 +38,9 @@ type UseSearchSelectorConfig = { /** Whether to include recent reports (for getMemberInviteOptions) */ includeRecentReports?: boolean; + /** Whether to include current user */ + includeCurrentUser?: boolean; + /** Enable phone contacts integration */ enablePhoneContacts?: boolean; @@ -136,6 +139,7 @@ function useSearchSelectorBase({ initialSelected, shouldInitialize = true, contactOptions, + includeCurrentUser = false, }: UseSearchSelectorConfig): UseSearchSelectorReturn { const {options: defaultOptions, areOptionsInitialized} = useOptionsList({ shouldInitialize, @@ -246,6 +250,21 @@ function useSearchSelectorBase({ maxElements: maxResults, includeUserToInvite, }); + case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_ATTENDEES: + return getValidOptions(optionsWithContacts, draftComments, nvpDismissedProductTraining, { + ...getValidOptionsConfig, + betas: betas ?? [], + includeP2P: true, + includeSelectedOptions: false, + excludeLogins, + loginsToExclude: excludeLogins, + includeRecentReports, + maxElements: maxResults, + maxRecentReportElements: maxRecentReportsToShow, + searchString: computedSearchTerm, + includeUserToInvite, + includeCurrentUser, + }); default: return getEmptyOptions(); } diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 1a6c796a76c7b..be61d69cf7810 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -2,7 +2,6 @@ import * as Sentry from '@sentry/react-native'; import {Str} from 'expensify-common'; import deburr from 'lodash/deburr'; -import keyBy from 'lodash/keyBy'; import lodashOrderBy from 'lodash/orderBy'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; @@ -140,7 +139,6 @@ import {getTaskCreatedMessage, getTaskReportActionMessage} from '@libs/TaskUtils import {generateAccountID} from '@libs/UserUtils'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; -import type {IOUAction} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type { Beta, @@ -1985,9 +1983,9 @@ function getValidOptions( let workspaceChats: Array> = []; let selfDMChat: SearchOptionData | undefined; + const searchTerms = processSearchString(searchString); if (includeRecentReports) { // if maxElements is passed, filter the recent reports by searchString and return only most recent reports (@see recentReportsComparator) - const searchTerms = processSearchString(searchString); const isWorkspaceChat = (report: SearchOption) => shouldSeparateWorkspaceChat && report.isPolicyExpenseChat && !report.private_isArchived; const isSelfDMChat = (report: SearchOption) => shouldSeparateSelfDMChat && report.isSelfDM && !report.private_isArchived; @@ -2068,7 +2066,11 @@ function getValidOptions( return false; }); - recentReportOptions = recentAttendees as Array>; + recentReportOptions = filterReports(recentAttendees as SearchOptionData[], searchTerms) as Array>; + + if (maxRecentReportElements) { + recentReportOptions = recentReportOptions.slice(0, maxRecentReportElements); + } } // Get valid personal details and check if we can find the current user: @@ -2086,7 +2088,6 @@ function getValidOptions( }; } - const searchTerms = processSearchString(searchString); const filteringFunction = (personalDetail: OptionData) => { if ( !personalDetail?.login || @@ -2245,40 +2246,7 @@ function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail: OnyxEn }; } -type GetAttendeeOptionsParams = { - reports: Array>; - personalDetails: Array>; - betas: OnyxEntry; - attendees: Attendee[]; - recentAttendees: Attendee[]; - draftComments: OnyxCollection; - nvpDismissedProductTraining: OnyxEntry; - includeOwnedWorkspaceChats: boolean; - includeP2P: boolean; - includeInvoiceRooms: boolean; - action: IOUAction | undefined; - countryCode: number; -}; - -function getAttendeeOptions({ - reports, - personalDetails, - betas, - attendees, - recentAttendees, - draftComments, - nvpDismissedProductTraining, - includeOwnedWorkspaceChats = false, - includeP2P = true, - includeInvoiceRooms = false, - action = undefined, - countryCode = CONST.DEFAULT_COUNTRY_CODE, -}: GetAttendeeOptionsParams) { - const personalDetailList = keyBy( - personalDetails.map(({item}) => item), - 'accountID', - ); - +function getFilteredRecentAttendees(personalDetails: OnyxEntry, attendees: Attendee[], recentAttendees: Attendee[]): Option[] { const recentAttendeeHasCurrentUser = recentAttendees.find((attendee) => attendee.email === currentUserLogin || attendee.login === currentUserLogin); if (!recentAttendeeHasCurrentUser && currentUserLogin) { const details = getPersonalDetailByEmail(currentUserLogin); @@ -2300,27 +2268,9 @@ function getAttendeeOptions({ login: attendee.email ?? attendee.displayName, ...getPersonalDetailByEmail(attendee.email), })) - .map((attendee) => getParticipantsOption(attendee, personalDetailList as never)); + .map((attendee) => getParticipantsOption(attendee, personalDetails)); - return getValidOptions( - {reports, personalDetails}, - draftComments, - nvpDismissedProductTraining, - { - betas, - selectedOptions: attendees.map((attendee) => ({...attendee, login: attendee.email})), - excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, - includeOwnedWorkspaceChats, - includeRecentReports: false, - includeP2P, - includeSelectedOptions: false, - includeSelfDM: false, - includeInvoiceRooms, - action, - recentAttendees: filteredRecentAttendees, - }, - countryCode, - ); + return filteredRecentAttendees; } /** @@ -2851,7 +2801,7 @@ export { formatMemberForList, formatSectionsFromSearchTerm, getAlternateText, - getAttendeeOptions, + getFilteredRecentAttendees, getCurrentUserSearchTerms, getEmptyOptions, getFirstKeyForList, diff --git a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx index f02eac741caba..8d824bebf5dea 100644 --- a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx @@ -1,30 +1,26 @@ 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, + getFilteredRecentAttendees, getHeaderMessage, getParticipantsOption, getPersonalDetailSearchTerms, @@ -34,7 +30,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'; @@ -61,114 +57,149 @@ 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 = CONST.DEFAULT_COUNTRY_CODE] = 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 [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, {canBeMissing: true}); - const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {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]); + const recentAttendeeLists = useMemo(() => getFilteredRecentAttendees(personalDetails, attendees, recentAttendees ?? []), [personalDetails, attendees, recentAttendees]); + const initialSelectedOptions = useMemo( + () => + attendees.map((attendee) => ({ + ...attendee, + reportID: CONST.DEFAULT_NUMBER_ID.toString(), + selected: true, + login: attendee.email, + ...getPersonalDetailByEmail(attendee.email), + })), + [attendees], + ); + + const {searchTerm, debouncedSearchTerm, 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: false, + includeCurrentUser: true, + getValidOptionsConfig: { + includeSelfDM: false, + includeInvoiceRooms: false, + action, + recentAttendees: recentAttendeeLists, + }, + initialSelected: initialSelectedOptions, + shouldInitialize: didScreenTransitionEnd, + onSelectionChange: (newSelectedOptions) => { + const newAttendees: Attendee[] = newSelectedOptions.map((option) => { + const iconSource = option.icons?.[0]?.source; + const icon = typeof iconSource === 'function' ? '' : SafeString(iconSource); + return { + accountID: option.accountID ?? CONST.DEFAULT_NUMBER_ID, + login: option.login, + email: option.login ?? '', + displayName: option.displayName ?? option.text ?? option.login ?? '', + selected: true, + searchText: option.searchText, + avatarUrl: option.avatarUrl ?? icon, + iouType, + }; + }); + onAttendeesAdded(newAttendees); + }, + maxRecentReportsToShow: 5, + }); + useEffect(() => { searchInServer(debouncedSearchTerm.trim()); }, [debouncedSearchTerm]); - const defaultOptions = useMemo(() => { - if (!areOptionsInitialized || !didScreenTransitionEnd) { - getEmptyOptions(); + const orderedAvailableOptions = useMemo(() => { + if (!isPaidGroupPolicy || !areOptionsInitialized) { + return availableOptions; } - const optionList = getAttendeeOptions({ - reports: options.reports, - personalDetails: options.personalDetails, - betas, - attendees, - recentAttendees: recentAttendees ?? [], - draftComments: draftComments ?? {}, - nvpDismissedProductTraining, - includeOwnedWorkspaceChats: iouType === CONST.IOU.TYPE.SUBMIT, - includeP2P: true, - includeInvoiceRooms: false, - action, - countryCode, - }); - 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, - draftComments, - nvpDismissedProductTraining, - iouType, - action, - countryCode, - isPaidGroupPolicy, - searchTerm, - ]); + }, + ); - const chatOptions = useMemo(() => { - if (!areOptionsInitialized) { - return { - userToInvite: null, - recentReports: [], - personalDetails: [], - currentUserOption: null, - headerMessage: '', - }; + return { + ...availableOptions, + recentReports: orderedOptions.recentReports, + personalDetails: orderedOptions.personalDetails, + workspaceChats: orderedOptions.workspaceChats, + }; + }, [availableOptions, isPaidGroupPolicy, areOptionsInitialized, searchTerm, action]); + + 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; } - 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(), - selected: true, - login: attendee.email, - ...getPersonalDetailByEmail(attendee.email), - })), - }); - return newOptions; - }, [areOptionsInitialized, defaultOptions, cleanSearchTerm, isPaidGroupPolicy, attendees, countryCode]); + + return ( + <> + {shouldShowErrorMessage && ( + + )} +