-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Fix filtering attendees names in the Reports page
#79498
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
946b202
f766237
de7be0d
aa16954
464c711
9d702a8
3b68965
598df1e
9eda669
acaadd1
89ddbbb
0f9b16e
62c7da5
e6ea8e0
5bedf95
1b16099
f0b43e0
728b738
2f036de
90e7127
f7bb719
0af64d4
9795d5d
d67de86
648e0e3
60cb539
6423a0f
ee55089
4f0e5fd
ed14893
61f5975
175b97a
a7285ea
de6865f
2bb4acf
bffe931
4e92916
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 = { | ||||||||||||||||||
|
|
@@ -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<OptionData> => { | ||||||||||||||||||
| return !!option; | ||||||||||||||||||
| }); | ||||||||||||||||||
| let preSelectedOptions: OptionData[]; | ||||||||||||||||||
|
|
||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||
| }) | ||||||||||||||||||
| .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; | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In Useful? React with 👍 / 👎. |
||||||||||||||||||
| } | ||||||||||||||||||
|
|
@@ -221,7 +376,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}: | |||||||||||||||||
| setSelectedOptions(newSelectedOptions); | ||||||||||||||||||
| } | ||||||||||||||||||
| }, | ||||||||||||||||||
| [selectedOptions], | ||||||||||||||||||
| [selectedOptions, shouldAllowNameOnlyOptions], | ||||||||||||||||||
| ); | ||||||||||||||||||
|
|
||||||||||||||||||
| const footerContent = useMemo( | ||||||||||||||||||
|
|
||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.