diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 27e829f323f76..d4bd61d90bc99 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -10,7 +10,7 @@ import useOnyx from '@hooks/useOnyx'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import memoize from '@libs/memoize'; -import {filterAndOrderOptions, filterSelectedOptions, formatSectionsFromSearchTerm, getValidOptions} from '@libs/OptionsListUtils'; +import {filterAndOrderOptions, filterSelectedOptions, formatSectionsFromSearchTerm, getFilteredRecentAttendees, getValidOptions} from '@libs/OptionsListUtils'; import type {Option, Section} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; import {getDisplayNameForParticipant} from '@libs/ReportUtils'; @@ -18,6 +18,7 @@ import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Attendee} from '@src/types/onyx/IOU'; import SearchFilterPageFooterButtons from './SearchFilterPageFooterButtons'; const defaultListOptions = { @@ -35,12 +36,43 @@ function getSelectedOptionData(option: Option): OptionData { return {...option, selected: true, reportID: option.reportID ?? '-1'}; } +/** + * Creates an OptionData object from a name-only attendee (attendee without a real accountID in personalDetails) + */ +function getOptionDataFromAttendee(attendee: Attendee): OptionData { + return { + text: attendee.displayName, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string email + alternateText: attendee.email || attendee.displayName, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string email + login: attendee.email || attendee.displayName, + displayName: attendee.displayName, + accountID: attendee.accountID ?? CONST.DEFAULT_NUMBER_ID, + // eslint-disable-next-line rulesdir/no-default-id-values + reportID: '-1', + selected: true, + icons: attendee.avatarUrl + ? [ + { + source: attendee.avatarUrl, + type: CONST.ICON_TYPE_AVATAR, + name: attendee.displayName, + }, + ] + : [], + searchText: attendee.searchText ?? attendee.displayName, + }; +} + type SearchFiltersParticipantsSelectorProps = { initialAccountIDs: string[]; onFiltersUpdate: (accountIDs: string[]) => void; + + /** Whether to allow name-only options (for attendee filter only) */ + shouldAllowNameOnlyOptions?: boolean; }; -function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: SearchFiltersParticipantsSelectorProps) { +function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, shouldAllowNameOnlyOptions = false}: SearchFiltersParticipantsSelectorProps) { const {translate, formatPhoneNumber} = useLocalize(); const personalDetails = usePersonalDetails(); const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus(); @@ -57,6 +89,14 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, {canBeMissing: true}); const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {canBeMissing: true}); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); + const [recentAttendees] = useOnyx(ONYXKEYS.NVP_RECENT_ATTENDEES, {canBeMissing: true}); + + // Transform raw recentAttendees into Option[] format for use with getValidOptions (only for attendee filter) + const recentAttendeeLists = useMemo( + () => (shouldAllowNameOnlyOptions ? getFilteredRecentAttendees(personalDetails, [], recentAttendees ?? []) : []), + [personalDetails, recentAttendees, shouldAllowNameOnlyOptions], + ); + const defaultOptions = useMemo(() => { if (!areOptionsInitialized) { return defaultListOptions; @@ -74,21 +114,59 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: { excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, includeCurrentUser: true, + shouldAcceptName: shouldAllowNameOnlyOptions, + includeUserToInvite: shouldAllowNameOnlyOptions, + recentAttendees: recentAttendeeLists, + includeRecentReports: false, }, countryCode, ); - }, [areOptionsInitialized, options.reports, options.personalDetails, allPolicies, draftComments, nvpDismissedProductTraining, loginList, countryCode]); + }, [ + areOptionsInitialized, + options.reports, + options.personalDetails, + allPolicies, + draftComments, + nvpDismissedProductTraining, + loginList, + countryCode, + recentAttendeeLists, + shouldAllowNameOnlyOptions, + ]); const unselectedOptions = useMemo(() => { - return filterSelectedOptions(defaultOptions, new Set(selectedOptions.map((option) => option.accountID))); - }, [defaultOptions, selectedOptions]); + if (!shouldAllowNameOnlyOptions) { + return filterSelectedOptions(defaultOptions, new Set(selectedOptions.map((option) => option.accountID))); + } + + // For name-only options, filter by both accountID (for regular users) AND login (for name-only attendees) + const selectedAccountIDs = new Set(selectedOptions.map((option) => option.accountID).filter((id): id is number => !!id && id !== CONST.DEFAULT_NUMBER_ID)); + const selectedLogins = new Set(selectedOptions.map((option) => option.login).filter((login): login is string => !!login)); + + const isSelected = (option: {accountID?: number; login?: string}) => { + if (option.accountID && option.accountID !== CONST.DEFAULT_NUMBER_ID && selectedAccountIDs.has(option.accountID)) { + return true; + } + if (option.login && selectedLogins.has(option.login)) { + return true; + } + return false; + }; + + return { + ...defaultOptions, + personalDetails: defaultOptions.personalDetails.filter((option) => !isSelected(option)), + recentReports: defaultOptions.recentReports.filter((option) => !isSelected(option)), + }; + }, [defaultOptions, selectedOptions, shouldAllowNameOnlyOptions]); const chatOptions = useMemo(() => { const filteredOptions = filterAndOrderOptions(unselectedOptions, cleanSearchTerm, countryCode, loginList, { selectedOptions, excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, - canInviteUser: false, + canInviteUser: shouldAllowNameOnlyOptions, + shouldAcceptName: shouldAllowNameOnlyOptions, }); const {currentUserOption} = unselectedOptions; @@ -99,7 +177,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: } return filteredOptions; - }, [unselectedOptions, cleanSearchTerm, countryCode, loginList, selectedOptions]); + }, [unselectedOptions, cleanSearchTerm, countryCode, loginList, selectedOptions, shouldAllowNameOnlyOptions]); const {sections, headerMessage} = useMemo(() => { const newSections: Section[] = []; @@ -145,10 +223,17 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: newSections.push(formattedResults.section); + // Filter current user from recentReports to avoid duplicate with currentUserOption section + // Only filter if both the report and currentUserOption have valid accountIDs to avoid + // accidentally filtering out name-only attendees (which have accountID: undefined) + const filteredRecentReports = chatOptions.recentReports.filter( + (report) => !report.accountID || !chatOptions.currentUserOption?.accountID || report.accountID !== chatOptions.currentUserOption.accountID, + ); + newSections.push({ title: '', - data: chatOptions.recentReports, - shouldShow: chatOptions.recentReports.length > 0, + data: filteredRecentReports, + shouldShow: filteredRecentReports.length > 0, }); newSections.push({ @@ -171,38 +256,108 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: }, []); const applyChanges = useCallback(() => { - const selectedAccountIDs = selectedOptions.map((option) => (option.accountID ? option.accountID.toString() : undefined)).filter(Boolean) as string[]; - onFiltersUpdate(selectedAccountIDs); + let selectedIdentifiers: string[]; + + if (shouldAllowNameOnlyOptions) { + selectedIdentifiers = selectedOptions + .map((option) => { + // For real users (with valid accountID in personalDetails), use accountID + if (option.accountID && option.accountID !== CONST.DEFAULT_NUMBER_ID && personalDetails?.[option.accountID]) { + return option.accountID.toString(); + } + + // For name-only attendees, use displayName or login as identifier + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string + return option.displayName || option.login; + }) + .filter(Boolean) as string[]; + } else { + selectedIdentifiers = selectedOptions.map((option) => (option.accountID ? option.accountID.toString() : undefined)).filter(Boolean) as string[]; + } + onFiltersUpdate(selectedIdentifiers); Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS.getRoute()); - }, [onFiltersUpdate, selectedOptions]); + }, [onFiltersUpdate, selectedOptions, personalDetails, shouldAllowNameOnlyOptions]); - // This effect handles setting initial selectedOptions based on accountIDs saved in onyx form + // This effect handles setting initial selectedOptions based on accountIDs (or displayNames for attendee filter) useEffect(() => { if (!initialAccountIDs || initialAccountIDs.length === 0 || !personalDetails) { return; } - const preSelectedOptions = initialAccountIDs - .map((accountID) => { - const participant = personalDetails[accountID]; - if (!participant) { - return; - } - - return getSelectedOptionData(participant); - }) - .filter((option): option is NonNullable => { - return !!option; - }); + let preSelectedOptions: OptionData[]; + + if (shouldAllowNameOnlyOptions) { + preSelectedOptions = initialAccountIDs + .map((identifier) => { + // First, try to look up as accountID in personalDetails + const participant = personalDetails[identifier]; + if (participant) { + return getSelectedOptionData(participant); + } + + // If not found in personalDetails, this might be a name-only attendee + // Search in recentAttendees by displayName or email + const attendee = recentAttendees?.find((recentAttendee) => recentAttendee.displayName === identifier || recentAttendee.email === identifier); + if (attendee) { + return getOptionDataFromAttendee(attendee); + } + + // Fallback: construct a minimal option from the identifier string to preserve + // name-only filters across sessions (e.g., after cache clear or on another device) + return { + text: identifier, + alternateText: identifier, + login: identifier, + displayName: identifier, + accountID: CONST.DEFAULT_NUMBER_ID, + // eslint-disable-next-line rulesdir/no-default-id-values + reportID: '-1', + selected: true, + icons: [], + searchText: identifier, + }; + }) + .filter((option): option is NonNullable => !!option); + } else { + preSelectedOptions = initialAccountIDs + .map((accountID) => { + const participant = personalDetails[accountID]; + if (!participant) { + return undefined; + } + return getSelectedOptionData(participant); + }) + .filter((option): option is NonNullable => !!option); + } setSelectedOptions(preSelectedOptions); // eslint-disable-next-line react-hooks/exhaustive-deps -- this should react only to changes in form data - }, [initialAccountIDs, personalDetails]); + }, [initialAccountIDs, personalDetails, recentAttendees, shouldAllowNameOnlyOptions]); const handleParticipantSelection = useCallback( (option: Option) => { const foundOptionIndex = selectedOptions.findIndex((selectedOption: Option) => { + if (shouldAllowNameOnlyOptions) { + // Match by accountID for real users (excluding DEFAULT_NUMBER_ID which is 0) + if (selectedOption.accountID && selectedOption.accountID !== CONST.DEFAULT_NUMBER_ID && selectedOption.accountID === option?.accountID) { + return true; + } + + // Skip reportID match for default '-1' value (used by name-only attendees) + if (selectedOption.reportID && selectedOption.reportID !== '-1' && selectedOption.reportID === option?.reportID) { + return true; + } + + // Match by login for name-only attendees + if (selectedOption.login && selectedOption.login === option?.login) { + return true; + } + + return false; + } + + // For non-name-only filters, use simple accountID and reportID matching if (selectedOption.accountID && selectedOption.accountID === option?.accountID) { return true; } @@ -221,7 +376,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: setSelectedOptions(newSelectedOptions); } }, - [selectedOptions], + [selectedOptions, shouldAllowNameOnlyOptions], ); const footerContent = useMemo( diff --git a/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx b/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx index 52266d391e927..9ad6f7405b7ec 100644 --- a/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx +++ b/src/components/SelectionListWithSections/Search/UserSelectionListItem.tsx @@ -61,11 +61,17 @@ function UserSelectionListItem({ }, [currentUserPersonalDetails.login, item.login]); const userDisplayName = useMemo(() => { - return getDisplayNameForParticipant({ - accountID: item.accountID ?? CONST.DEFAULT_NUMBER_ID, - formatPhoneNumber, - }); - }, [formatPhoneNumber, item.accountID]); + /* eslint-disable @typescript-eslint/prefer-nullish-coalescing -- need || to handle empty string from getDisplayNameForParticipant */ + return ( + getDisplayNameForParticipant({ + accountID: item.accountID ?? CONST.DEFAULT_NUMBER_ID, + formatPhoneNumber, + }) || + item.text || + '' + ); + /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ + }, [formatPhoneNumber, item.accountID, item.text]); return ( { return getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode); }, [debouncedSearchTerm, countryCode]); + const trimmedSearchInput = debouncedSearchTerm.trim(); const baseOptions = useMemo(() => { if (!areOptionsInitialized) { @@ -207,6 +208,7 @@ function useSearchSelectorBase({ maxElements: maxResults, maxRecentReportElements: maxRecentReportsToShow, searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, includeUserToInvite, }); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL: @@ -214,6 +216,7 @@ function useSearchSelectorBase({ ...getValidOptionsConfig, betas: betas ?? [], searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, maxElements: maxResults, maxRecentReportElements: maxRecentReportsToShow, includeUserToInvite, @@ -236,6 +239,7 @@ function useSearchSelectorBase({ includeThreads: true, includeReadOnly: false, searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, maxElements: maxResults, includeUserToInvite, }, @@ -256,6 +260,7 @@ function useSearchSelectorBase({ includeOwnedWorkspaceChats: true, includeSelfDM: true, searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, maxElements: maxResults, includeUserToInvite, }); @@ -271,6 +276,7 @@ function useSearchSelectorBase({ maxElements: maxResults, maxRecentReportElements: maxRecentReportsToShow, searchString: computedSearchTerm, + searchInputValue: trimmedSearchInput, includeUserToInvite, includeCurrentUser, shouldAcceptName: true, @@ -296,6 +302,7 @@ function useSearchSelectorBase({ getValidOptionsConfig, selectedOptions, includeCurrentUser, + trimmedSearchInput, ]); const isOptionSelected = useMemo(() => { diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 5f00a5e463cd7..6c25210b76ec2 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -1692,6 +1692,7 @@ function canCreateOptimisticPersonalDetailOption({ */ function getUserToInviteOption({ searchValue, + searchInputValue, loginsToExclude = {}, selectedOptions = [], showChatPreviewLine = false, @@ -1709,6 +1710,9 @@ function getUserToInviteOption({ const isValidEmail = Str.isValidEmail(searchValue) && !Str.isDomainEmail(searchValue) && !Str.endsWith(searchValue, CONST.SMS.DOMAIN); const isValidPhoneNumber = parsedPhoneNumber.possible && Str.isValidE164Phone(getPhoneNumberWithoutSpecialChars(parsedPhoneNumber.number?.input ?? '')); const isInOptionToExclude = loginsToExclude[addSMSDomainIfPhoneNumber(searchValue).toLowerCase()]; + const trimmedSearchInputValue = searchInputValue?.trim(); + const shouldUseSearchInputValue = shouldAcceptName && !!trimmedSearchInputValue && !isValidEmail && !isValidPhoneNumber; + const displayValue = shouldUseSearchInputValue ? trimmedSearchInputValue : searchValue; // Angle brackets are not valid characters for user names const hasInvalidCharacters = shouldAcceptName && (searchValue.includes('<') || searchValue.includes('>')); @@ -1732,9 +1736,11 @@ function getUserToInviteOption({ userToInvite.isOptimisticAccount = true; userToInvite.login = searchValue; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - userToInvite.text = userToInvite.text || searchValue; + userToInvite.text = userToInvite.text || displayValue; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - userToInvite.alternateText = userToInvite.alternateText || searchValue; + userToInvite.displayName = userToInvite.displayName || displayValue; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + userToInvite.alternateText = userToInvite.alternateText || displayValue; // If user doesn't exist, use a fallback avatar userToInvite.icons = [ @@ -2148,6 +2154,7 @@ function getValidOptions( excludeHiddenThreads = false, canShowManagerMcTest = false, searchString, + searchInputValue, maxElements, includeUserToInvite = false, maxRecentReportElements = undefined, @@ -2341,6 +2348,7 @@ function getValidOptions( { excludeLogins: loginsToExclude, shouldAcceptName, + searchInputValue, }, ); } @@ -2471,11 +2479,24 @@ function getFilteredRecentAttendees(personalDetails: OnyxEntry(); + const deduplicatedRecentAttendees = recentAttendees.filter((attendee) => { + 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, - login: attendee.email ? attendee.email : attendee.displayName, + // 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), })) .map((attendee) => getParticipantsOption(attendee, personalDetails)); @@ -2831,6 +2852,7 @@ function filterSelfDMChat(report: SearchOptionData, searchTerms: string[]): Sear function filterOptions(options: Options, searchInputValue: string, countryCode: number, loginList: OnyxEntry, config?: FilterUserToInviteConfig): Options { const trimmedSearchInput = searchInputValue.trim(); + const searchInputValueForInvite = config?.searchInputValue ?? trimmedSearchInput; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const parsedPhoneNumber = parsePhoneNumber(appendCountryCode(Str.removeSMSDomain(trimmedSearchInput), countryCode || CONST.DEFAULT_COUNTRY_CODE)); @@ -2849,7 +2871,10 @@ function filterOptions(options: Options, searchInputValue: string, countryCode: searchValue, loginList, countryCode, - config, + { + ...config, + searchInputValue: searchInputValueForInvite, + }, ); const workspaceChats = filterWorkspaceChats(options.workspaceChats ?? [], searchTerms); diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index fb2b73e3f30ec..955d112594bdb 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -190,6 +190,7 @@ type GetOptionsConfig = { excludeHiddenThreads?: boolean; canShowManagerMcTest?: boolean; searchString?: string; + searchInputValue?: string; maxElements?: number; maxRecentReportElements?: number; includeUserToInvite?: boolean; @@ -198,6 +199,7 @@ type GetOptionsConfig = { type GetUserToInviteConfig = { searchValue: string | undefined; + searchInputValue?: string; loginsToExclude?: Record; reportActions?: ReportActions; firstName?: string; @@ -246,7 +248,7 @@ type PreviewConfig = { isSelected?: boolean; }; -type FilterUserToInviteConfig = Pick & { +type FilterUserToInviteConfig = Pick & { canInviteUser?: boolean; excludeLogins?: Record; }; diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index cd1652b59ad92..2c50ac1a79fdc 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -809,11 +809,14 @@ function buildFilterFormValuesFromQuery( filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TO || filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.ASSIGNEE || - filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTER || - filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE + filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.EXPORTER ) { filtersForm[key as typeof filterKey] = filterValues.filter((id) => personalDetails?.[id]); } + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.ATTENDEE) { + // Don't filter attendee values by personalDetails - they can be accountIDs OR display names for name-only attendees + filtersForm[key as typeof filterKey] = filterValues; + } if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.PAYER) { filtersForm[key as typeof filterKey] = filterValues.find((id) => personalDetails?.[id]); diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index ca5cac8fc354a..3b2200ee62e39 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -4441,7 +4441,7 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U value: lodashUnionBy( transactionChanges.attendees?.map(({avatarUrl, displayName, email}) => ({avatarUrl, displayName, email})), recentAttendees, - 'email', + (attendee) => attendee.email || attendee.displayName, ).slice(0, CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW), }); } diff --git a/src/pages/Search/AdvancedSearchFilters.tsx b/src/pages/Search/AdvancedSearchFilters.tsx index d90f3b304b720..c6366beee615f 100644 --- a/src/pages/Search/AdvancedSearchFilters.tsx +++ b/src/pages/Search/AdvancedSearchFilters.tsx @@ -289,12 +289,12 @@ function getFilterCardDisplayTitle(filters: Partial, } function getFilterParticipantDisplayTitle(accountIDs: string[], personalDetails: PersonalDetailsList | undefined, formatPhoneNumber: LocaleContextProps['formatPhoneNumber']) { - const selectedPersonalDetails = accountIDs.map((id) => personalDetails?.[id]); - - return selectedPersonalDetails - .map((personalDetail) => { + return accountIDs + .map((id) => { + const personalDetail = personalDetails?.[id]; if (!personalDetail) { - return ''; + // Name-only attendees are stored by displayName, not accountID + return id; } return createDisplayName(personalDetail.login ?? '', personalDetail, formatPhoneNumber); diff --git a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersAttendeePage.tsx b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersAttendeePage.tsx index d2f67a5d3c3b4..e22dd30627ee0 100644 --- a/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersAttendeePage.tsx +++ b/src/pages/Search/SearchAdvancedFiltersPage/SearchFiltersAttendeePage.tsx @@ -39,6 +39,7 @@ function SearchFiltersAttendeePage() { attendee: selectedAccountIDs, }); }} + shouldAllowNameOnlyOptions /> diff --git a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx index 8b626bb99e5cc..e55b95eed5b29 100644 --- a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx @@ -79,7 +79,9 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde ...attendee, reportID: CONST.DEFAULT_NUMBER_ID.toString(), selected: true, - login: attendee.email ? attendee.email : attendee.displayName, + // 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), })), [attendees], diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 4c8635461c32e..07d55dfa5126d 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -21,6 +21,7 @@ import { filterWorkspaceChats, formatMemberForList, getCurrentUserSearchTerms, + getFilteredRecentAttendees, getLastActorDisplayName, getLastMessageTextForReport, getMemberInviteOptions, @@ -2013,6 +2014,32 @@ describe('OptionsListUtils', () => { expect(filteredOptions.userToInvite).toBe(null); }); + it('should not return userToInvite for plain text name when shouldAcceptName is false', () => { + // Given a set of options + const options = getValidOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, {}, {}, nvpDismissedProductTraining, loginList, { + includeUserToInvite: true, + }); + + // When we call filterAndOrderOptions with a plain text name (not email or phone) without shouldAcceptName + const filteredOptions = filterAndOrderOptions(options, 'Jeff Amazon', COUNTRY_CODE, loginList, {shouldAcceptName: false}); + + // Then userToInvite should be null since plain names are not accepted by default + expect(filteredOptions?.userToInvite).toBe(null); + }); + + it('should return userToInvite for plain text name when shouldAcceptName is true', () => { + // Given a set of options + const options = getValidOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, {}, {}, nvpDismissedProductTraining, loginList, { + includeUserToInvite: true, + }); + + // When we call filterAndOrderOptions with a plain text name (not email or phone) with shouldAcceptName + const filteredOptions = filterAndOrderOptions(options, 'Jeff', COUNTRY_CODE, loginList, {shouldAcceptName: true}); + + // Then userToInvite should be returned for the plain name + expect(filteredOptions?.userToInvite?.text).toBe('jeff'); + }); + it('should not return any options if search value does not match any personal details', () => { // Given a set of options const options = getValidOptions({reports: OPTIONS.reports, personalDetails: OPTIONS.personalDetails}, allPolicies, {}, nvpDismissedProductTraining, loginList); @@ -3552,4 +3579,52 @@ describe('OptionsListUtils', () => { expect(result?.login).toBe('Jeff Amazon'); }); }); + + 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); + + // 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); + + // 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); + + // Name-only attendee should have displayName as login + const johnSmith = result.find((r) => r.login === 'John Smith'); + expect(johnSmith).toBeDefined(); + }); + }); }); diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index 2e3b935d15f63..0155b91c174bb 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -465,6 +465,35 @@ describe('SearchQueryUtils', () => { amountEqualTo: '-54321', }); }); + + test('attendee filter preserves name-only attendees without filtering by personalDetails', () => { + const policyCategories = {}; + const policyTags = {}; + const currencyList = {}; + const personalDetails = { + 12345: {accountID: 12345, login: 'user@example.com'}, + }; + const cardList = {}; + const reports = {}; + const taxRates = {}; + + // Test with mix of accountID and name-only attendee + const queryString = 'sortBy:date sortOrder:desc type:expense attendee:12345,ZZ'; + const queryJSON = buildSearchQueryJSON(queryString); + + if (!queryJSON) { + throw new Error('Failed to parse query string'); + } + + const result = buildFilterFormValuesFromQuery(queryJSON, policyCategories, policyTags, currencyList, personalDetails, cardList, reports, taxRates); + + // Both values should be preserved - name-only attendees should not be filtered out + expect(result).toEqual({ + type: 'expense', + status: CONST.SEARCH.STATUS.EXPENSE.ALL, + attendee: ['12345', 'ZZ'], + }); + }); }); describe('shouldHighlight', () => {