Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
946b202
add `shouldAcceptName` option to GetOptionsConfig
lakchote Jan 13, 2026
f766237
fix name-only attendee filtering and deduplication
lakchote Jan 13, 2026
de7be0d
enable `shouldAcceptName` for attendees context
lakchote Jan 13, 2026
aa16954
fix name-only attendee search filter support
lakchote Jan 13, 2026
464c711
fix display name fallback for name-only attendees
lakchote Jan 13, 2026
9d702a8
fix attendee filter to accept name-only values
lakchote Jan 13, 2026
3b68965
fix login fallback for name-only attendees
lakchote Jan 13, 2026
598df1e
add tests for `shouldAcceptName` option
lakchote Jan 13, 2026
9eda669
merge origin/main and resolve conflicts
lakchote Jan 13, 2026
acaadd1
fix prettier
lakchote Jan 14, 2026
89ddbbb
fix attendees name
lakchote Jan 14, 2026
0f9b16e
add eslint-disable for intentional || usage
lakchote Jan 14, 2026
62c7da5
fix eslint-disable for multi-line || expression
lakchote Jan 14, 2026
e6ea8e0
add tests for `getFilteredRecentAttendees`
lakchote Jan 14, 2026
5bedf95
add test for attendee filter name preservation
lakchote Jan 14, 2026
1b16099
Merge remote-tracking branch 'origin/main' into lucien/fix-attendees-…
lakchote Jan 14, 2026
f0b43e0
Merge main into lucien/fix-attendees-search
lakchote Jan 15, 2026
728b738
Merge main into lucien/fix-attendees-search
lakchote Jan 16, 2026
2f036de
preserve name-only attendee filters across sessions
lakchote Jan 16, 2026
90e7127
skip reportID match for default value in selection
lakchote Jan 16, 2026
f7bb719
add `shouldAllowNameOnlyOptions` prop to gate name-only behavior
lakchote Jan 16, 2026
0af64d4
enable name-only options for attendee filter
lakchote Jan 16, 2026
9795d5d
add blank lines
lakchote Jan 16, 2026
d67de86
fix style
lakchote Jan 16, 2026
648e0e3
Merge main into lucien/fix-attendees-search
lakchote Jan 19, 2026
60cb539
pass raw search input to options filtering
lakchote Jan 19, 2026
6423a0f
preserve name casing in invite options
lakchote Jan 19, 2026
ee55089
add searchinputvalue to options config types
lakchote Jan 19, 2026
4f0e5fd
dedupe recent attendees by email or display name
lakchote Jan 19, 2026
ed14893
fix name-only attendees filter in sections
lakchote Jan 19, 2026
61f5975
Merge origin/main into lucien/fix-attendees-search
lakchote Jan 19, 2026
175b97a
Merge remote-tracking branch 'origin/main' into lucien/fix-attendees-…
lakchote Jan 19, 2026
a7285ea
Merge remote-tracking branch 'origin/main' into lucien/fix-attendees-…
lakchote Jan 19, 2026
de6865f
fix typescript check
lakchote Jan 19, 2026
2bb4acf
Merge main into lucien/fix-attendees-search
lakchote Jan 20, 2026
bffe931
fix style
lakchote Jan 20, 2026
4e92916
Merge branch 'main' into lucien/fix-attendees-search
lakchote Jan 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 182 additions & 27 deletions src/components/Search/SearchFiltersParticipantsSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ 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';
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 = {
Expand All @@ -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();
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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[] = [];
Expand Down Expand Up @@ -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({
Expand All @@ -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[];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

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<OptionData> => {
return !!option;
});
let preSelectedOptions: OptionData[];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

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<OptionData> => !!option);
} else {
preSelectedOptions = initialAccountIDs
.map((accountID) => {
const participant = personalDetails[accountID];
if (!participant) {
return undefined;
}
return getSelectedOptionData(participant);
Comment on lines +326 to +329
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!participant) {
return undefined;
}
return getSelectedOptionData(participant);
if (participant) {
return getSelectedOptionData(participant);
}
return undefined;

})
.filter((option): option is NonNullable<OptionData> => !!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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip reportID match for name-only selections

In handleParticipantSelection, the selection match uses reportID equality before the login check. Name-only attendees created in this component are given reportID: '-1' (see getOptionDataFromAttendee), so every name-only entry shares the same reportID. As a result, selecting a second name-only attendee is treated as a duplicate and toggles off the first one, preventing multi-select of name-only attendees. Consider skipping the reportID match when it is the default or moving the login match ahead of the reportID check.

Useful? React with 👍 / 👎.

}
Expand All @@ -221,7 +376,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}:
setSelectedOptions(newSelectedOptions);
}
},
[selectedOptions],
[selectedOptions, shouldAllowNameOnlyOptions],
);

const footerContent = useMemo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,17 @@ function UserSelectionListItem<TItem extends ListItem>({
}, [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 (
<BaseListItem
Expand Down
7 changes: 7 additions & 0 deletions src/hooks/useSearchSelector.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ function useSearchSelectorBase({
const computedSearchTerm = useMemo(() => {
return getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode);
}, [debouncedSearchTerm, countryCode]);
const trimmedSearchInput = debouncedSearchTerm.trim();

const baseOptions = useMemo(() => {
if (!areOptionsInitialized) {
Expand Down Expand Up @@ -207,13 +208,15 @@ function useSearchSelectorBase({
maxElements: maxResults,
maxRecentReportElements: maxRecentReportsToShow,
searchString: computedSearchTerm,
searchInputValue: trimmedSearchInput,
includeUserToInvite,
});
case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL:
return getValidOptions(optionsWithContacts, allPolicies, draftComments, nvpDismissedProductTraining, loginList, {
...getValidOptionsConfig,
betas: betas ?? [],
searchString: computedSearchTerm,
searchInputValue: trimmedSearchInput,
maxElements: maxResults,
maxRecentReportElements: maxRecentReportsToShow,
includeUserToInvite,
Expand All @@ -236,6 +239,7 @@ function useSearchSelectorBase({
includeThreads: true,
includeReadOnly: false,
searchString: computedSearchTerm,
searchInputValue: trimmedSearchInput,
maxElements: maxResults,
includeUserToInvite,
},
Expand All @@ -256,6 +260,7 @@ function useSearchSelectorBase({
includeOwnedWorkspaceChats: true,
includeSelfDM: true,
searchString: computedSearchTerm,
searchInputValue: trimmedSearchInput,
maxElements: maxResults,
includeUserToInvite,
});
Expand All @@ -271,6 +276,7 @@ function useSearchSelectorBase({
maxElements: maxResults,
maxRecentReportElements: maxRecentReportsToShow,
searchString: computedSearchTerm,
searchInputValue: trimmedSearchInput,
includeUserToInvite,
includeCurrentUser,
shouldAcceptName: true,
Expand All @@ -296,6 +302,7 @@ function useSearchSelectorBase({
getValidOptionsConfig,
selectedOptions,
includeCurrentUser,
trimmedSearchInput,
]);

const isOptionSelected = useMemo(() => {
Expand Down
Loading
Loading