diff --git a/src/hooks/usePersonalDetailSearchSelector/base.ts b/src/hooks/usePersonalDetailSearchSelector/base.ts index 4634efb6a2ef..13a1b6be3c62 100644 --- a/src/hooks/usePersonalDetailSearchSelector/base.ts +++ b/src/hooks/usePersonalDetailSearchSelector/base.ts @@ -36,7 +36,7 @@ type UseSearchSelectorConfig = { /** Logins to exclude from suggestions only (soft exclusions - can still be manually entered) */ excludeFromSuggestionsOnly?: Record; - /** Whether to include recent reports (for getMemberInviteOptions) */ + /** Whether to include recent reports */ includeRecentReports?: boolean; /** Whether to include current user */ @@ -48,8 +48,8 @@ type UseSearchSelectorConfig = { /** Enable phone contacts integration */ enablePhoneContacts?: boolean; - /** Callback when selection changes (multi-select mode) */ - onSelectionChange?: (selected: string[]) => void; + /** Callback when selection changes (multi-select mode). Receives the new selected accountIDs and the new selected options. */ + onSelectionChange?: (selected: string[], selectedOptions: OptionData[]) => void; /** Callback when single option is selected (single-select mode) */ onSingleSelect?: (option: OptionData) => void; @@ -280,11 +280,13 @@ function usePersonalDetailSearchSelectorBase({ } const newSet = new Set([...selectedAccountIDs].filter((accountID) => accountID !== option.accountID.toString())); setSelectedAccountIDs(newSet); - onSelectionChange?.(Array.from(newSet)); + const newSelectedOptions = selectedOptions.filter((selected) => selected.accountID !== option.accountID); + onSelectionChange?.(Array.from(newSet), newSelectedOptions); } else { const newSet = new Set(selectedAccountIDs).add(option.accountID.toString()); setSelectedAccountIDs(newSet); - onSelectionChange?.(Array.from(newSet)); + const newSelectedOptions = [...selectedOptions, {...option, isSelected: true}]; + onSelectionChange?.(Array.from(newSet), newSelectedOptions); if (!existingAccountIDs.has(option.accountID.toString())) { setExtraOptions((prev) => [...prev, {...option, isSelected: true}]); } diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 1583831c49af..5850ef8525da 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -19,7 +19,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {getIsOffline} from '@libs/NetworkState'; import Parser from '@libs/Parser'; import type {OptionData as PersonalDetailOptionData} from '@libs/PersonalDetailOptionsListUtils/types'; -import {getLoginByAccountID, getPersonalDetailByEmail, getPersonalDetailsListByIDs, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import {getLoginByAccountID, getPersonalDetailsListByIDs, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {addSMSDomainIfPhoneNumber, parsePhoneNumber} from '@libs/PhoneNumber'; import { canSendInvoiceFromWorkspace, @@ -187,7 +187,7 @@ import type { Transaction, VisibleReportActionsDerivedValue, } from '@src/types/onyx'; -import type {Attendee, Participant} from '@src/types/onyx/IOU'; +import type {Participant} from '@src/types/onyx/IOU'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -206,7 +206,6 @@ import type { GetValidReportsConfig, IsValidReportsConfig, MemberForList, - Option, OptionList, Options, OptionsResult, @@ -2825,51 +2824,6 @@ function getIOUConfirmationOptionsFromPayeePersonalDetail(personalDetail: OnyxEn }; } -function getFilteredRecentAttendees( - personalDetails: OnyxEntry, - attendees: Attendee[], - recentAttendees: Attendee[], - currentUserEmail: string, - currentUserAccountID: number, - translate: LocalizedTranslate, -): Option[] { - const recentAttendeeHasCurrentUser = recentAttendees.find((attendee) => attendee.email === currentUserEmail); - if (!recentAttendeeHasCurrentUser && currentUserEmail) { - const details = getPersonalDetailByEmail(currentUserEmail); - recentAttendees.push({ - email: currentUserEmail, - displayName: details?.displayName ?? currentUserEmail, - avatarUrl: details?.avatarThumbnail ?? '', - }); - } - - // Deduplicate recentAttendees: use email for regular users, displayName for name-only attendees - const seenAttendees = new Set(); - const deduplicatedRecentAttendees = recentAttendees.filter((attendee) => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const key = attendee.email || attendee.displayName || ''; - if (seenAttendees.has(key)) { - return false; - } - seenAttendees.add(key); - return true; - }); - - const filteredRecentAttendees = deduplicatedRecentAttendees - .filter((attendee) => !attendees.find(({email, displayName}) => (attendee.email ? email === attendee.email : displayName === attendee.displayName))) - .map((attendee) => ({ - ...attendee, - // Use || instead of ?? to handle empty string email for name-only attendees - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - login: attendee.email || attendee.displayName, - ...getPersonalDetailByEmail(attendee.email), - keyForList: `${currentUserAccountID}`, - })) - .map((attendee) => getParticipantsOption(attendee, personalDetails, translate)); - - return filteredRecentAttendees; -} - /** * Format personalDetails or userToInvite to be shown in the list * @@ -3363,7 +3317,6 @@ export { formatMemberForList, formatSectionsFromSearchTerm, getAlternateText, - getFilteredRecentAttendees, getEmptyOptions, getHeaderMessage, getHeaderMessageForNonUserList, diff --git a/src/libs/PersonalDetailOptionsListUtils/index.ts b/src/libs/PersonalDetailOptionsListUtils/index.ts index ac1469fa4e34..085f583cd176 100644 --- a/src/libs/PersonalDetailOptionsListUtils/index.ts +++ b/src/libs/PersonalDetailOptionsListUtils/index.ts @@ -12,6 +12,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {LoginList, OnyxInputOrEntry, PersonalDetails, PersonalDetailsList, Report, ReportAttributesDerivedValue} from '@src/types/onyx'; import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; +import type {Attendee} from '@src/types/onyx/IOU'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -365,6 +366,7 @@ function getValidOptions( } } } + recentOptions = optionsOrderBy(recentOptions, personalDetailsComparator, recentMaxElements, undefined, true).options; } else if (includeRecentReports) { // if maxElements is passed, filter the recent reports by searchString and return only most recent reports (@see recentReportsComparator) const filteringFunction = (option: OptionData) => { @@ -499,6 +501,37 @@ function getHeaderMessage(translate: LocaleContextProps['translate'], searchValu return translate('common.noResultsFound'); } +/** + * Returns the logins of recent attendees to suggest, ensuring the current user is included, deduplicated, + * and excluding attendees that are already selected. The returned logins are fed to getValidOptions as + * `recentAttendees`, which rebuilds the full options (matching personal details or creating optimistic ones). + */ +function getFilteredRecentAttendees(attendees: Attendee[], recentAttendees: Attendee[], currentUserEmail: string): string[] { + const allRecentAttendees = [...recentAttendees]; + if (currentUserEmail && !allRecentAttendees.some((attendee) => attendee.email === currentUserEmail)) { + allRecentAttendees.push({email: currentUserEmail, displayName: currentUserEmail, avatarUrl: ''}); + } + + const seenAttendees = new Set(); + return ( + allRecentAttendees + .filter((attendee) => { + // Deduplicate: use email for regular users, displayName for name-only attendees + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const key = attendee.email || attendee.displayName || ''; + if (!key || seenAttendees.has(key)) { + return false; + } + seenAttendees.add(key); + return true; + }) + .filter((attendee) => !attendees.find(({email, displayName}) => (attendee.email ? email === attendee.email : displayName === attendee.displayName))) + // Use || to fall back to displayName for name-only attendees (empty email) + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + .map((attendee) => attendee.email || attendee.displayName) + ); +} + export { createOption, getContactOption, @@ -509,6 +542,7 @@ export { createOptionList, getHeaderMessage, getUserToInviteOption, + getFilteredRecentAttendees, }; export type {OptionData}; diff --git a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx index 2e226d38e7fb..a471ec60b5cf 100644 --- a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx @@ -1,42 +1,27 @@ import Button from '@components/Button'; import EmptySelectionListContent from '@components/EmptySelectionListContent'; import FormHelpMessage from '@components/FormHelpMessage'; -import {usePersonalDetails} from '@components/OnyxListItemProvider'; import InviteMemberListItem from '@components/SelectionList/ListItem/InviteMemberListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; import type {Section} from '@components/SelectionList/SelectionListWithSections/types'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePolicy from '@hooks/usePolicy'; -import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; -import useReportAttributes from '@hooks/useReportAttributes'; +import usePersonalDetailSearchSelector from '@hooks/usePersonalDetailSearchSelector'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; -import useSearchSelector from '@hooks/useSearchSelector'; import useThemeStyles from '@hooks/useThemeStyles'; -import useUserToInviteReports from '@hooks/useUserToInviteReports'; import {searchUserInServer} from '@libs/actions/Report'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; -import { - formatSectionsFromSearchTerm, - getFilteredRecentAttendees, - getHeaderMessage, - getParticipantsOption, - getPolicyExpenseReportOption, - isCurrentUser, - orderOptions, - sortAlphabetically, -} from '@libs/OptionsListUtils'; -import {doesPersonalDetailMatchSearchTerm} from '@libs/OptionsListUtils/searchMatchUtils'; +import {getFilteredRecentAttendees, getHeaderMessage} from '@libs/PersonalDetailOptionsListUtils'; +import type {OptionData} from '@libs/PersonalDetailOptionsListUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; -import {isPaidGroupPolicy as isPaidGroupPolicyFn} from '@libs/PolicyUtils'; -import type {OptionData} from '@libs/ReportUtils'; -import {expensifyLoginsSelector} from '@libs/UserUtils'; +import {generateAccountID} from '@libs/UserUtils'; -import type {IOUAction, IOUType} from '@src/CONST'; +import type {IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Attendee} from '@src/types/onyx/IOU'; @@ -60,112 +45,88 @@ type MoneyRequestAttendeesSelectorProps = { /** The type of IOU report, i.e. split, request, send, track */ iouType: IOUType; - - /** The action of the IOU, i.e. create, split, move */ - action: IOUAction; }; -function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdded, iouType, action}: MoneyRequestAttendeesSelectorProps) { - const {translate, localeCompare} = useLocalize(); +function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdded, iouType}: MoneyRequestAttendeesSelectorProps) { + const {translate} = useLocalize(); const styles = useThemeStyles(); const {isOffline} = useNetwork(); - const personalDetails = usePersonalDetails(); + const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus(); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); - const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [recentAttendees] = useOnyx(ONYXKEYS.NVP_RECENT_ATTENDEES); - const policy = usePolicy(activePolicyID); const [isSearchingForReports] = useOnyx(ONYXKEYS.RAM_ONLY_IS_SEARCHING_FOR_REPORTS); - const reportAttributesDerived = useReportAttributes(); const offlineMessage: string = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; - const [loginList] = useOnyx(ONYXKEYS.LOGINS, {selector: expensifyLoginsSelector}); - const privateIsArchivedMap = usePrivateIsArchivedMap(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserEmail = currentUserPersonalDetails.email ?? ''; - const currentUserAccountID = currentUserPersonalDetails.accountID; - const isPaidGroupPolicy = isPaidGroupPolicyFn(policy); - const recentAttendeeLists = getFilteredRecentAttendees(personalDetails, attendees, recentAttendees ?? [], currentUserEmail, currentUserAccountID, translate); + const recentAttendeeLogins = getFilteredRecentAttendees(attendees, recentAttendees ?? [], currentUserEmail); - const initialSelectedOptions = sortAlphabetically([...attendees], 'displayName', localeCompare).map((attendee) => ({ - ...attendee, - reportID: CONST.DEFAULT_NUMBER_ID.toString(), - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - keyForList: attendee.email || attendee.displayName, - selected: true, + // Build the initial selection. Attendees that aren't in the personal details list (name-only or unknown emails) get a stable dummy + // accountID derived from their login (generateAccountID is deterministic), so they can be tracked by accountID like everyone else. + const initialSelectedOptions = attendees.map((attendee) => { // Use || to fall back to displayName for name-only attendees (empty email) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - login: attendee.email || attendee.displayName, - ...getPersonalDetailByEmail(attendee?.email), - })); + const login = attendee.email || attendee.displayName; + const personalDetail = getPersonalDetailByEmail(attendee.email); + const accountID = personalDetail?.accountID ?? generateAccountID(login); + return {attendee, login, personalDetail, accountID}; + }); + + const initialSelected = new Set(initialSelectedOptions.map((option) => String(option.accountID))); + + // Seed optimistic options for attendees that don't exist in the personal details list so they survive a round-trip through the hook + const initialExtraOptions: OptionData[] = initialSelectedOptions + .filter((option) => !option.personalDetail) + .map((option) => ({ + text: option.attendee.displayName || option.login, + alternateText: option.login || option.attendee.displayName, + login: option.login, + accountID: option.accountID, + keyForList: option.login, + isSelected: true, + icons: [ + { + source: option.attendee.avatarUrl || icons.FallbackAvatar, + name: option.login, + type: CONST.ICON_TYPE_AVATAR, + id: option.accountID, + }, + ], + })); - const {searchTerm, debouncedSearchTerm, setSearchTerm, availableOptions, selectedOptions, toggleSelection, areOptionsInitialized, onListEndReached} = useSearchSelector({ + const {searchTerm, debouncedSearchTerm, setSearchTerm, availableOptions, selectedOptions, toggleSelection, areOptionsInitialized} = usePersonalDetailSearchSelector({ 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, + recentAttendees: recentAttendeeLogins, + shouldAllowNameOnlyOptions: true, + initialSelected, + initialExtraOptions, shouldInitialize: didScreenTransitionEnd, - onSelectionChange: (newSelectedOptions) => { + maxRecentReportsToShow: 5, + // Propagate the selection to the parent directly from the selection event (the hook passes the new selected options) + onSelectionChange: (_selectedAccountIDs, 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, + // Use || to fall back for name-only attendees + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + displayName: option.text || option.login || '', + avatarUrl: icon, }; }); onAttendeesAdded(newAttendees); }, - maxRecentReportsToShow: 5, }); useEffect(() => { searchUserInServer(debouncedSearchTerm.trim()); }, [debouncedSearchTerm]); - let orderedAvailableOptions; - if (!isPaidGroupPolicy || !areOptionsInitialized) { - orderedAvailableOptions = availableOptions; - } else { - const orderedOptions = orderOptions( - { - recentReports: availableOptions.recentReports, - personalDetails: availableOptions.personalDetails, - workspaceChats: availableOptions.workspaceChats ?? [], - }, - searchTerm, - { - preferChatRoomsOverThreads: true, - preferPolicyExpenseChat: !!action, - preferRecentExpenseReports: action === CONST.IOU.ACTION.CREATE, - }, - ); - orderedAvailableOptions = { - ...availableOptions, - recentReports: orderedOptions.recentReports, - personalDetails: orderedOptions.personalDetails, - workspaceChats: orderedOptions.workspaceChats, - }; - } - - const {userToInviteExpenseReport} = useUserToInviteReports(orderedAvailableOptions?.userToInvite); - const userToInviteExpenseReportPolicy = usePolicy(userToInviteExpenseReport?.policyID); - const shouldShowErrorMessage = selectedOptions.length < 1; const handleConfirmSelection = (_keyEvent?: GestureResponderEvent | KeyboardEvent, option?: OptionData) => { @@ -214,75 +175,39 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde } else { const newSections: Array> = []; const cleanSearchTerm = searchTerm.trim().toLowerCase(); - const formatResults = formatSectionsFromSearchTerm( - cleanSearchTerm, - initialSelectedOptions, - orderedAvailableOptions.recentReports, - orderedAvailableOptions.personalDetails, - privateIsArchivedMap, - currentUserAccountID, - allPolicies, - translate, - personalDetails, - true, - undefined, - reportAttributesDerived, - ); - newSections.push({ - ...formatResults.section, - sectionIndex: 0, - data: formatResults.section.data as OptionData[], - }); + if (availableOptions.selectedOptions.length > 0) { + newSections.push({ + data: availableOptions.selectedOptions, + sectionIndex: 0, + }); + } - if (orderedAvailableOptions.recentReports.length > 0) { + if (availableOptions.recentOptions.length > 0) { newSections.push({ title: translate('common.recents'), - data: orderedAvailableOptions.recentReports, + data: availableOptions.recentOptions, sectionIndex: 1, }); } - if (orderedAvailableOptions.personalDetails.length > 0) { + if (availableOptions.personalDetails.length > 0) { newSections.push({ title: translate('common.contacts'), - data: orderedAvailableOptions.personalDetails, + data: availableOptions.personalDetails, sectionIndex: 2, }); } - if ( - orderedAvailableOptions.userToInvite && - !isCurrentUser( - { - ...orderedAvailableOptions.userToInvite, - accountID: orderedAvailableOptions.userToInvite?.accountID ?? CONST.DEFAULT_NUMBER_ID, - status: orderedAvailableOptions.userToInvite?.status ?? undefined, - }, - loginList, - currentUserEmail, - ) - ) { + if (availableOptions.userToInvite && availableOptions.userToInvite.login !== currentUserEmail) { newSections.push({ title: undefined, - data: [orderedAvailableOptions.userToInvite].map((participant) => { - const isPolicyExpenseChat = participant?.isPolicyExpenseChat ?? false; - const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${userToInviteExpenseReport?.reportID}`]; - return isPolicyExpenseChat - ? getPolicyExpenseReportOption(participant, privateIsArchived, personalDetails, userToInviteExpenseReport, userToInviteExpenseReportPolicy, reportAttributesDerived) - : getParticipantsOption(participant, personalDetails, translate); - }) as OptionData[], + data: [availableOptions.userToInvite], sectionIndex: 3, }); } - header = getHeaderMessage( - formatResults.section.data.length + (orderedAvailableOptions.personalDetails ?? []).length + (orderedAvailableOptions.recentReports ?? []).length !== 0, - !!orderedAvailableOptions?.userToInvite, - cleanSearchTerm, - countryCode, - attendees.some((attendee) => doesPersonalDetailMatchSearchTerm(attendee, currentUserAccountID, cleanSearchTerm)), - ); + header = newSections.length > 0 ? '' : getHeaderMessage(translate, cleanSearchTerm, countryCode); sections = newSections; } @@ -314,7 +239,6 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde shouldShowListEmptyContent={shouldShowListEmptyContent} listEmptyContent={} shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} - onEndReached={onListEndReached} shouldSingleExecuteRowSelect shouldShowTextInput canSelectMultiple diff --git a/src/pages/iou/request/step/IOURequestStepAttendees.tsx b/src/pages/iou/request/step/IOURequestStepAttendees.tsx index 28c8465321ed..c4449b9bbc46 100644 --- a/src/pages/iou/request/step/IOURequestStepAttendees.tsx +++ b/src/pages/iou/request/step/IOURequestStepAttendees.tsx @@ -136,7 +136,6 @@ function IOURequestStepAttendees({ onAttendeesAdded={(v) => setAttendees(v)} attendees={attendees} iouType={iouType} - action={action} /> ); diff --git a/tests/ui/AssignCardFeed.tsx b/tests/ui/AssignCardFeed.tsx index b5ce6ba9b323..60eedc54a0fe 100644 --- a/tests/ui/AssignCardFeed.tsx +++ b/tests/ui/AssignCardFeed.tsx @@ -7,13 +7,11 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; -import * as useSearchSelectorModule from '@hooks/useSearchSelector'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; import {setHasRadio} from '@libs/NetworkState'; -import {getEmptyOptions} from '@libs/OptionsListUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; @@ -211,21 +209,6 @@ describe('AssignCardFeed', () => { it('should render the cardholder selection header', async () => { await TestHelper.signInWithTestUser(); - // Mock useSearchSelector to return empty options - jest.spyOn(useSearchSelectorModule, 'default').mockReturnValue({ - searchTerm: '', - debouncedSearchTerm: '', - setSearchTerm: jest.fn(), - searchOptions: getEmptyOptions().options, - availableOptions: getEmptyOptions().options, - selectedOptions: [], - selectedOptionsForDisplay: [], - setSelectedOptions: jest.fn(), - toggleSelection: jest.fn(), - areOptionsInitialized: true, - onListEndReached: jest.fn(), - }); - const policy = { ...LHNTestUtils.getFakePolicy(), role: CONST.POLICY.ROLE.ADMIN, @@ -266,21 +249,6 @@ describe('AssignCardFeed', () => { it('should render the header title for card assignment', async () => { await TestHelper.signInWithTestUser(); - // Mock useSearchSelector - jest.spyOn(useSearchSelectorModule, 'default').mockReturnValue({ - searchTerm: '', - debouncedSearchTerm: '', - setSearchTerm: jest.fn(), - searchOptions: getEmptyOptions().options, - availableOptions: getEmptyOptions().options, - selectedOptions: [], - selectedOptionsForDisplay: [], - setSelectedOptions: jest.fn(), - toggleSelection: jest.fn(), - areOptionsInitialized: true, - onListEndReached: jest.fn(), - }); - const policy = { ...LHNTestUtils.getFakePolicy(), role: CONST.POLICY.ROLE.ADMIN, @@ -319,21 +287,6 @@ describe('AssignCardFeed', () => { it('should render with previously selected assignee email in editing mode', async () => { await TestHelper.signInWithTestUser(); - // Mock useSearchSelector - jest.spyOn(useSearchSelectorModule, 'default').mockReturnValue({ - searchTerm: '', - debouncedSearchTerm: '', - setSearchTerm: jest.fn(), - searchOptions: getEmptyOptions().options, - availableOptions: getEmptyOptions().options, - selectedOptions: [], - selectedOptionsForDisplay: [], - setSelectedOptions: jest.fn(), - toggleSelection: jest.fn(), - areOptionsInitialized: true, - onListEndReached: jest.fn(), - }); - const policy = { ...LHNTestUtils.getFakePolicy(), role: CONST.POLICY.ROLE.ADMIN, @@ -594,21 +547,6 @@ describe('AssignCardFeed', () => { const navigateSpy = jest.spyOn(Navigation, 'navigate'); - // Mock useSearchSelector - jest.spyOn(useSearchSelectorModule, 'default').mockReturnValue({ - searchTerm: '', - debouncedSearchTerm: '', - setSearchTerm: jest.fn(), - searchOptions: getEmptyOptions().options, - availableOptions: getEmptyOptions().options, - selectedOptions: [], - selectedOptionsForDisplay: [], - setSelectedOptions: jest.fn(), - toggleSelection: jest.fn(), - areOptionsInitialized: true, - onListEndReached: jest.fn(), - }); - const policy = { ...LHNTestUtils.getFakePolicy(), role: CONST.POLICY.ROLE.ADMIN, @@ -649,21 +587,6 @@ describe('AssignCardFeed', () => { const navigateSpy = jest.spyOn(Navigation, 'navigate'); - // Mock useSearchSelector - jest.spyOn(useSearchSelectorModule, 'default').mockReturnValue({ - searchTerm: '', - debouncedSearchTerm: '', - setSearchTerm: jest.fn(), - searchOptions: getEmptyOptions().options, - availableOptions: getEmptyOptions().options, - selectedOptions: [], - selectedOptionsForDisplay: [], - setSelectedOptions: jest.fn(), - toggleSelection: jest.fn(), - areOptionsInitialized: true, - onListEndReached: jest.fn(), - }); - const policy = { ...LHNTestUtils.getFakePolicy(), role: CONST.POLICY.ROLE.ADMIN, diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index aab397eca953..34b3341c516c 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -24,7 +24,6 @@ import { filterWorkspaceChats, formatMemberForList, formatSectionsFromSearchTerm, - getFilteredRecentAttendees, getLastActorDisplayName, getLastActorDisplayNameFromLastVisibleActions, getLastMessageTextForReport, @@ -82,7 +81,7 @@ import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, Policy, Report, ReportAction, ReportNameValuePairs, Transaction} from '@src/types/onyx'; import type {ReportAttributes} from '@src/types/onyx/DerivedValues'; -import type {Attendee, Participant} from '@src/types/onyx/IOU'; +import type {Participant} from '@src/types/onyx/IOU'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -8502,84 +8501,6 @@ describe('OptionsListUtils', () => { expect(result).toHaveProperty('personalDetails'); }); }); - describe('getFilteredRecentAttendees', () => { - it('should deduplicate recent attendees by email', () => { - const personalDetails = {}; - const attendees: Array<{ - email: string; - displayName: string; - avatarUrl: string; - }> = []; - const recentAttendees = [ - {email: 'user1@example.com', displayName: 'User One', avatarUrl: ''}, - { - email: 'user1@example.com', - displayName: 'User One Duplicate', - avatarUrl: '', - }, // Duplicate by email - {email: 'user2@example.com', displayName: 'User Two', avatarUrl: ''}, - ]; - - const result = getFilteredRecentAttendees(personalDetails, attendees, recentAttendees, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, translateLocal); - - // Should deduplicate by email - user1@example.com should only appear once - const logins = result.map((r) => r.login); - const user1Count = logins.filter((login) => login === 'user1@example.com').length; - expect(user1Count).toBe(1); - }); - - it('should deduplicate name-only attendees by displayName', () => { - const personalDetails = {}; - const attendees: Array<{ - email: string; - displayName: string; - avatarUrl: string; - }> = []; - const recentAttendees = [ - {email: '', displayName: 'Name Only', avatarUrl: ''}, - {email: '', displayName: 'Name Only', avatarUrl: ''}, // Duplicate by displayName (name-only attendee) - {email: '', displayName: 'Another Name', avatarUrl: ''}, - ]; - - const result = getFilteredRecentAttendees(personalDetails, attendees, recentAttendees, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, translateLocal); - - // Should deduplicate by displayName - Name Only should only appear once - const logins = result.map((r) => r.login); - const nameOnlyCount = logins.filter((login) => login === 'Name Only').length; - expect(nameOnlyCount).toBe(1); - }); - - it('should use displayName as login for name-only attendees', () => { - const personalDetails = {}; - const attendees: Array<{ - email: string; - displayName: string; - avatarUrl: string; - }> = []; - const recentAttendees = [{email: '', displayName: 'John Smith', avatarUrl: ''}]; - - const result = getFilteredRecentAttendees(personalDetails, attendees, recentAttendees, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, translateLocal); - - // Name-only attendee should have displayName as login - const johnSmith = result.find((r) => r.login === 'John Smith'); - expect(johnSmith).toBeDefined(); - }); - - it('should preserve displayName for recent attendees with undefined email', () => { - const personalDetails = {}; - const attendees: Array<{ - email: string; - displayName: string; - avatarUrl: string; - }> = []; - const recentAttendees: Attendee[] = [{displayName: 'Login Only User', avatarUrl: ''}]; - - const result = getFilteredRecentAttendees(personalDetails, attendees, recentAttendees, CURRENT_USER_EMAIL, CURRENT_USER_ACCOUNT_ID, translateLocal); - - expect(result.find((r) => r.login === 'Login Only User')).toBeDefined(); - }); - }); - describe('getValidOptions() with recentAttendees', () => { const recentAttendees = Array.from({length: 8}, (_, index) => ({ login: `john${index}@example.com`, diff --git a/tests/unit/PersonalDetailOptionsListUtilsTest.ts b/tests/unit/PersonalDetailOptionsListUtilsTest.ts index 69cf6028dca6..3546ec396cc7 100644 --- a/tests/unit/PersonalDetailOptionsListUtilsTest.ts +++ b/tests/unit/PersonalDetailOptionsListUtilsTest.ts @@ -1,13 +1,22 @@ import FallbackAvatar from '@assets/images/avatars/fallback-avatar.svg'; import DateUtils from '@libs/DateUtils'; -import {canCreateOptimisticPersonalDetailOption, createOption, createOptionList, filterOption, getValidOptions, matchesSearchTerms} from '@libs/PersonalDetailOptionsListUtils'; +import { + canCreateOptimisticPersonalDetailOption, + createOption, + createOptionList, + filterOption, + getFilteredRecentAttendees, + getValidOptions, + matchesSearchTerms, +} from '@libs/PersonalDetailOptionsListUtils'; import type {OptionData} from '@libs/PersonalDetailOptionsListUtils/types'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, Report} from '@src/types/onyx'; +import type {Attendee} from '@src/types/onyx/IOU'; // The rule is disabled for this file as test data uses numeric keys that don't follow naming conventions /* eslint-disable @typescript-eslint/naming-convention */ @@ -873,4 +882,76 @@ describe('PersonalDetailOptionsListUtils', () => { expect(result).toBeNull(); }); }); + + describe('getFilteredRecentAttendees', () => { + it('should deduplicate recent attendees by email', () => { + const attendees: Attendee[] = []; + const recentAttendees: Attendee[] = [ + {email: 'user1@example.com', displayName: 'User One', avatarUrl: ''}, + {email: 'user1@example.com', displayName: 'User One Duplicate', avatarUrl: ''}, // Duplicate by email + {email: 'user2@example.com', displayName: 'User Two', avatarUrl: ''}, + ]; + + const result = getFilteredRecentAttendees(attendees, recentAttendees, currentUserLogin); + + // Should deduplicate by email - user1@example.com should only appear once + const user1Count = result.filter((login) => login === 'user1@example.com').length; + expect(user1Count).toBe(1); + }); + + it('should deduplicate name-only attendees by displayName', () => { + const attendees: Attendee[] = []; + const recentAttendees: Attendee[] = [ + {email: '', displayName: 'Name Only', avatarUrl: ''}, + {email: '', displayName: 'Name Only', avatarUrl: ''}, // Duplicate by displayName (name-only attendee) + {email: '', displayName: 'Another Name', avatarUrl: ''}, + ]; + + const result = getFilteredRecentAttendees(attendees, recentAttendees, currentUserLogin); + + // Should deduplicate by displayName - Name Only should only appear once + const nameOnlyCount = result.filter((login) => login === 'Name Only').length; + expect(nameOnlyCount).toBe(1); + }); + + it('should use displayName as login for name-only attendees', () => { + const attendees: Attendee[] = []; + const recentAttendees: Attendee[] = [{email: '', displayName: 'John Smith', avatarUrl: ''}]; + + const result = getFilteredRecentAttendees(attendees, recentAttendees, currentUserLogin); + + expect(result).toContain('John Smith'); + }); + + it('should preserve displayName for recent attendees with undefined email', () => { + const attendees: Attendee[] = []; + const recentAttendees: Attendee[] = [{displayName: 'Login Only User', avatarUrl: ''}]; + + const result = getFilteredRecentAttendees(attendees, recentAttendees, currentUserLogin); + + expect(result).toContain('Login Only User'); + }); + + it('should exclude attendees that are already selected', () => { + const attendees: Attendee[] = [{email: 'user1@example.com', displayName: 'User One', avatarUrl: ''}]; + const recentAttendees: Attendee[] = [ + {email: 'user1@example.com', displayName: 'User One', avatarUrl: ''}, + {email: 'user2@example.com', displayName: 'User Two', avatarUrl: ''}, + ]; + + const result = getFilteredRecentAttendees(attendees, recentAttendees, currentUserLogin); + + expect(result).not.toContain('user1@example.com'); + expect(result).toContain('user2@example.com'); + }); + + it('should include the current user when not already present', () => { + const attendees: Attendee[] = []; + const recentAttendees: Attendee[] = [{email: 'user2@example.com', displayName: 'User Two', avatarUrl: ''}]; + + const result = getFilteredRecentAttendees(attendees, recentAttendees, currentUserLogin); + + expect(result).toContain(currentUserLogin); + }); + }); });