Skip to content
106 changes: 78 additions & 28 deletions src/components/Search/SearchFiltersParticipantsSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import memoize from '@libs/memoize';
import {filterAndOrderOptions, filterSelectedOptions, formatSectionsFromSearchTerm, getFilteredRecentAttendees, getValidOptions} from '@libs/OptionsListUtils';
import type {Option} from '@libs/OptionsListUtils';
import type {SelectionListSections} from '@libs/OptionsListUtils/types';
import {getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils';
import type {OptionData} from '@libs/ReportUtils';
import {getDisplayNameForParticipant} from '@libs/ReportUtils';
import Navigation from '@navigation/Navigation';
Expand Down Expand Up @@ -74,9 +75,17 @@ type SearchFiltersParticipantsSelectorProps = {

/** Whether to allow name-only options (for attendee filter only) */
shouldAllowNameOnlyOptions?: boolean;

/** Whether to scope personal details to workspace members only and allow free text email input */
shouldScopeToWorkspaceMembers?: boolean;
};

function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, shouldAllowNameOnlyOptions = false}: SearchFiltersParticipantsSelectorProps) {
function SearchFiltersParticipantsSelector({
initialAccountIDs,
onFiltersUpdate,
shouldAllowNameOnlyOptions = false,
shouldScopeToWorkspaceMembers = false,
}: SearchFiltersParticipantsSelectorProps) {
const {translate, formatPhoneNumber} = useLocalize();
const personalDetails = usePersonalDetails();
const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus();
Expand Down Expand Up @@ -105,6 +114,27 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate,
[personalDetails, recentAttendees, currentUserEmail, currentUserAccountID, shouldAllowNameOnlyOptions],
);

// Collect all workspace member accountIDs across the user's policies to scope the filter suggestions
const workspaceMemberAccountIDs = useMemo(() => {
if (!shouldScopeToWorkspaceMembers || !allPolicies) {
return undefined;
}
const memberIDs = new Set<number>();
for (const policy of Object.values(allPolicies)) {
if (!policy?.employeeList) {
continue;
}
const members = getMemberAccountIDsForWorkspace(policy.employeeList);
for (const accountID of Object.values(members)) {
memberIDs.add(accountID);
}
}
memberIDs.add(currentUserAccountID);
return memberIDs;
}, [shouldScopeToWorkspaceMembers, allPolicies, currentUserAccountID]);

const allowFreeTextInput = shouldAllowNameOnlyOptions || shouldScopeToWorkspaceMembers;

const defaultOptions = useMemo(() => {
if (!areOptionsInitialized) {
return defaultListOptions;
Expand All @@ -124,10 +154,10 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate,
{
excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT,
includeCurrentUser: true,
shouldAcceptName: shouldAllowNameOnlyOptions,
includeUserToInvite: shouldAllowNameOnlyOptions,
shouldAcceptName: allowFreeTextInput,
includeUserToInvite: allowFreeTextInput,
recentAttendees: recentAttendeeLists,
includeRecentReports: !shouldAllowNameOnlyOptions,
includeRecentReports: !allowFreeTextInput,
personalDetails,
countryCode,
},
Expand All @@ -142,18 +172,29 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate,
loginList,
countryCode,
recentAttendeeLists,
shouldAllowNameOnlyOptions,
allowFreeTextInput,
personalDetails,
currentUserAccountID,
currentUserEmail,
]);

// When shouldScopeToWorkspaceMembers is true, filter personalDetails to only workspace members
const scopedDefaultOptions = useMemo(() => {
if (!workspaceMemberAccountIDs) {
return defaultOptions;
}
return {
...defaultOptions,
personalDetails: defaultOptions.personalDetails.filter((pd) => pd.accountID && workspaceMemberAccountIDs.has(pd.accountID)),
};
}, [defaultOptions, workspaceMemberAccountIDs]);

const unselectedOptions = useMemo(() => {
if (!shouldAllowNameOnlyOptions) {
return filterSelectedOptions(defaultOptions, new Set(selectedOptions.map((option) => option.accountID)));
if (!allowFreeTextInput) {
return filterSelectedOptions(scopedDefaultOptions, new Set(selectedOptions.map((option) => option.accountID)));
}

// For name-only options, filter by both accountID (for regular users) AND login (for name-only attendees)
// For free text input, filter by both accountID (for regular users) AND login (for name-only entries)
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));

Expand All @@ -168,19 +209,19 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate,
};

return {
...defaultOptions,
personalDetails: defaultOptions.personalDetails.filter((option) => !isSelected(option)),
recentReports: defaultOptions.recentReports.filter((option) => !isSelected(option)),
...scopedDefaultOptions,
personalDetails: scopedDefaultOptions.personalDetails.filter((option) => !isSelected(option)),
recentReports: scopedDefaultOptions.recentReports.filter((option) => !isSelected(option)),
};
}, [defaultOptions, selectedOptions, shouldAllowNameOnlyOptions]);
}, [scopedDefaultOptions, selectedOptions, allowFreeTextInput]);

const chatOptions = useMemo(() => {
const filteredOptions = filterAndOrderOptions(unselectedOptions, cleanSearchTerm, countryCode, loginList, currentUserEmail, currentUserAccountID, personalDetails, {
selectedOptions,
excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT,
maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW,
canInviteUser: shouldAllowNameOnlyOptions,
shouldAcceptName: shouldAllowNameOnlyOptions,
canInviteUser: allowFreeTextInput,
shouldAcceptName: allowFreeTextInput,
searchInputValue: searchTerm,
});

Expand All @@ -192,7 +233,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate,
}

return filteredOptions;
}, [unselectedOptions, cleanSearchTerm, countryCode, loginList, selectedOptions, shouldAllowNameOnlyOptions, searchTerm, currentUserEmail, currentUserAccountID, personalDetails]);
}, [unselectedOptions, cleanSearchTerm, countryCode, loginList, selectedOptions, allowFreeTextInput, searchTerm, currentUserEmail, currentUserAccountID, personalDetails]);

const {sections, headerMessage} = useMemo(() => {
const newSections: SelectionListSections = [];
Expand Down Expand Up @@ -256,7 +297,15 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate,
sectionIndex: 2,
});

const noResultsFound = chatOptions.personalDetails.length === 0 && chatOptions.recentReports.length === 0 && !chatOptions.currentUserOption;
// When free text input is enabled, show the userToInvite option so users can type arbitrary emails
if (allowFreeTextInput && chatOptions.userToInvite) {
newSections.push({
data: [chatOptions.userToInvite],
sectionIndex: 3,
});
}

const noResultsFound = chatOptions.personalDetails.length === 0 && chatOptions.recentReports.length === 0 && !chatOptions.currentUserOption && !chatOptions.userToInvite;
const message = noResultsFound ? translate('common.noResultsFound') : undefined;

return {
Expand All @@ -273,6 +322,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate,
translate,
formatPhoneNumber,
currentUserAccountID,
allowFreeTextInput,
privateIsArchivedMap,
]);

Expand All @@ -283,15 +333,15 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate,
const applyChanges = useCallback(() => {
let selectedIdentifiers: string[];

if (shouldAllowNameOnlyOptions) {
if (allowFreeTextInput) {
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
// For free text entries, 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;
})
Expand All @@ -302,17 +352,17 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate,

onFiltersUpdate(selectedIdentifiers);
Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS.getRoute());
}, [onFiltersUpdate, selectedOptions, personalDetails, shouldAllowNameOnlyOptions]);
}, [onFiltersUpdate, selectedOptions, personalDetails, allowFreeTextInput]);

// This effect handles setting initial selectedOptions based on accountIDs (or displayNames for attendee filter)
// This effect handles setting initial selectedOptions based on accountIDs (or displayNames/emails for free text filters)
useEffect(() => {
if (!initialAccountIDs || initialAccountIDs.length === 0 || !personalDetails) {
return;
}

let preSelectedOptions: OptionData[];

if (shouldAllowNameOnlyOptions) {
if (allowFreeTextInput) {
preSelectedOptions = initialAccountIDs
.map((identifier) => {
// First, try to look up as accountID in personalDetails
Expand All @@ -329,7 +379,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate,
}

// 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)
// free text filters across sessions (e.g., after cache clear or on another device)
return {
text: identifier,
alternateText: identifier,
Expand Down Expand Up @@ -358,31 +408,31 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate,

setSelectedOptions(preSelectedOptions);
// eslint-disable-next-line react-hooks/exhaustive-deps -- this should react only to changes in form data
}, [initialAccountIDs, personalDetails, recentAttendees, shouldAllowNameOnlyOptions]);
}, [initialAccountIDs, personalDetails, recentAttendees, allowFreeTextInput]);

const handleParticipantSelection = useCallback(
(option: Option) => {
const foundOptionIndex = selectedOptions.findIndex((selectedOption: Option) => {
if (shouldAllowNameOnlyOptions) {
if (allowFreeTextInput) {
// 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)
// Skip reportID match for default '-1' value (used by free text entries)
if (selectedOption.reportID && selectedOption.reportID !== '-1' && selectedOption.reportID === option?.reportID) {
return true;
}

// Match by login for name-only attendees
// Match by login for free text entries
if (selectedOption.login && selectedOption.login === option?.login) {
return true;
}

return false;
}

// For non-name-only filters, use simple accountID and reportID matching
// For non-free-text filters, use simple accountID and reportID matching
if (selectedOption.accountID && selectedOption.accountID === option?.accountID) {
return true;
}
Expand All @@ -401,7 +451,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate,
setSelectedOptions(newSelectedOptions);
}
},
[selectedOptions, shouldAllowNameOnlyOptions],
[selectedOptions, allowFreeTextInput],
);

const footerContent = useMemo(
Expand Down
2 changes: 1 addition & 1 deletion src/libs/OptionsListUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@
*/

let allReports: OnyxCollection<Report>;
Onyx.connect({

Check warning on line 209 in src/libs/OptionsListUtils/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function

Check warning on line 209 in src/libs/OptionsListUtils/index.ts

View workflow job for this annotation

GitHub Actions / ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -215,7 +215,7 @@
});

let allReportNameValuePairsOnyxConnect: OnyxCollection<ReportNameValuePairs>;
Onyx.connect({

Check warning on line 218 in src/libs/OptionsListUtils/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function

Check warning on line 218 in src/libs/OptionsListUtils/index.ts

View workflow job for this annotation

GitHub Actions / ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS,
waitForCollectionCallback: true,
callback: (value) => {
Expand All @@ -226,7 +226,7 @@
const lastReportActions: ReportActions = {};
const allSortedReportActions: Record<string, ReportAction[]> = {};
let allReportActions: OnyxCollection<ReportActions>;
Onyx.connect({

Check warning on line 229 in src/libs/OptionsListUtils/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
waitForCollectionCallback: true,
callback: (actions) => {
Expand Down Expand Up @@ -268,7 +268,7 @@
});

let activePolicyID: OnyxEntry<string>;
Onyx.connect({

Check warning on line 271 in src/libs/OptionsListUtils/index.ts

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

Onyx.connect() is deprecated. Use useOnyx() hook instead and pass the data as parameters to a pure function
key: ONYXKEYS.NVP_ACTIVE_POLICY_ID,
callback: (value) => (activePolicyID = value),
});
Expand Down Expand Up @@ -1372,7 +1372,7 @@
}

const isOneOnOneChat = reportUtilsIsOneOnOneChat(report);
const accountIDs = getParticipantsAccountIDsForDisplay(report);
const accountIDs = getParticipantsAccountIDsForDisplay(report, true);
const isChatRoom = reportUtilsIsChatRoom(report);

if ((!accountIDs || accountIDs.length === 0) && !isChatRoom) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ function SearchFiltersFromPage() {
from: selectedAccountIDs,
});
}}
shouldScopeToWorkspaceMembers
/>
</View>
</ScreenWrapper>
Expand Down
17 changes: 9 additions & 8 deletions tests/unit/OptionsListUtilsTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ describe('OptionsListUtils', () => {
reportID: '11',
isPinned: false,
participants: {
2: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS},
10: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN},
},
reportName: '',
Expand Down Expand Up @@ -819,8 +820,8 @@ describe('OptionsListUtils', () => {
// Then all personal details (including those that have reports) should be returned
expect(results.personalDetails.length).toBe(10);

// Then all of the reports should be shown including the archived rooms, except for the thread report with notificationPreferences hidden.
expect(results.recentReports.length).toBe(Object.values(OPTIONS.reports).length - 1);
// Then all of the reports should be shown including the archived rooms (reports with only hidden participants are already excluded from OPTIONS).
expect(results.recentReports.length).toBe(Object.values(OPTIONS.reports).length);
});

it('should include current user when includeCurrentUser is true for type:chat from suggestions', () => {
Expand Down Expand Up @@ -1503,8 +1504,8 @@ describe('OptionsListUtils', () => {
includeOwnedWorkspaceChats: true,
});

// Then the result should include all reports except the currently logged in user
expect(results.recentReports.length).toBe(OPTIONS_WITH_WORKSPACE_ROOM.reports.length - 1);
// Then the result should include all reports (reports with only hidden participants are already excluded from OPTIONS)
expect(results.recentReports.length).toBe(OPTIONS_WITH_WORKSPACE_ROOM.reports.length);
expect(results.recentReports).toEqual(expect.arrayContaining([expect.objectContaining({reportID: '14'})]));
});

Expand Down Expand Up @@ -1874,8 +1875,8 @@ describe('OptionsListUtils', () => {
},
);

// Then all the recent reports should be returned except the archived rooms and the hidden thread
expect(results.recentReports.length).toBe(Object.values(OPTIONS.reports).length - 2);
// Then all the recent reports should be returned except the archived room (the hidden thread is already excluded from OPTIONS)
expect(results.recentReports.length).toBe(Object.values(OPTIONS.reports).length - 1);
});

it('should include DMS, group chats, and workspace rooms in share destinations', () => {
Expand Down Expand Up @@ -1915,8 +1916,8 @@ describe('OptionsListUtils', () => {
},
);

// Then all recent reports should be returned except the archived rooms and the hidden thread
expect(results.recentReports.length).toBe(Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).length - 2);
// Then all recent reports should be returned except the archived room (the hidden thread is already excluded from OPTIONS)
expect(results.recentReports.length).toBe(Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).length - 1);
});
});

Expand Down
Loading