Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
103 changes: 30 additions & 73 deletions src/components/Search/FilterDropdowns/UserSelectPopup.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,26 @@
import {accountIDSelector} from '@selectors/Session';
import isEmpty from 'lodash/isEmpty';
import React, {memo, useCallback, useMemo, useRef, useState} from 'react';
import React, {memo, useCallback, useMemo, useRef} from 'react';
import {View} from 'react-native';
import type {SectionListData} from 'react-native';
import Button from '@components/Button';
import {usePersonalDetails} from '@components/OnyxListItemProvider';
import {useOptionsList} from '@components/OptionListContextProvider';
import SelectionList from '@components/SelectionListWithSections';
import UserSelectionListItem from '@components/SelectionListWithSections/Search/UserSelectionListItem';
import type {SelectionListHandle} from '@components/SelectionListWithSections/types';
import type {Section, SelectionListHandle} from '@components/SelectionListWithSections/types';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSearchSelector from '@hooks/useSearchSelector';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus';
import memoize from '@libs/memoize';
import type {Option, Section} from '@libs/OptionsListUtils';
import {filterAndOrderOptions, getValidOptions} from '@libs/OptionsListUtils';
import {getParticipantsOption} from '@libs/OptionsListUtils';
import type {OptionData} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';

function getSelectedOptionData(option: Option) {
return {...option, reportID: `${option.reportID}`, selected: true};
}

const optionsMatch = (opt1: Option, opt2: Option) => {
// Below is just a boolean expression.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
return (opt1.accountID && opt1.accountID === opt2?.accountID) || (opt1.reportID && opt1.reportID === opt2?.reportID);
};

const memoizedGetValidOptions = memoize(getValidOptions, {maxSize: 5, monitoringName: 'UserSelectPopup.getValidOptions'});
type Sections = Array<SectionListData<OptionData, Section<OptionData>>>;

type UserSelectPopupProps = {
/** The currently selected users */
Expand All @@ -48,77 +37,46 @@ function UserSelectPopup({value, closeOverlay, onChange}: UserSelectPopupProps)
const selectionListRef = useRef<SelectionListHandle | null>(null);
const styles = useThemeStyles();
const {translate} = useLocalize();
const {options} = useOptionsList();
const personalDetails = usePersonalDetails();
const {windowHeight} = useWindowDimensions();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const [accountID] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true, selector: accountIDSelector});
const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false});
const [searchTerm, setSearchTerm] = useState('');
const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false, canBeMissing: true});
const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, {canBeMissing: true});
const initialSelectedOptions = useMemo(() => {

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.

why keep this. SHouldn't we just use selectedOptions from introduced hook?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

initialSelectedOptions is used to display the default selected option, we passed it as initialSelected param of the hook.

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.

Ok

return value.reduce<OptionData[]>((acc, id) => {
const participant = personalDetails?.[id];
if (!participant) {
return acc;
}

const optionData = getSelectedOptionData(participant);
const optionData = {
...getParticipantsOption(participant, personalDetails),
isSelected: true,
};

if (optionData) {
acc.push(optionData);
acc.push(optionData as OptionData);
}

return acc;
}, []);
}, [value, personalDetails]);

const [selectedOptions, setSelectedOptions] = useState<Option[]>(initialSelectedOptions);

const cleanSearchTerm = searchTerm.trim().toLowerCase();

const selectedAccountIDs = useMemo(() => {
return new Set(selectedOptions.map((option) => option.accountID).filter(Boolean));
}, [selectedOptions]);

const optionsList = useMemo(() => {
return memoizedGetValidOptions(
{
reports: options.reports,
personalDetails: options.personalDetails,
},
draftComments,
{
excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT,
includeCurrentUser: true,
},
countryCode,
);
}, [options.reports, options.personalDetails, draftComments, countryCode]);

const filteredOptions = useMemo(() => {
return filterAndOrderOptions(optionsList, cleanSearchTerm, countryCode, {
excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT,
maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW,
canInviteUser: false,
});
}, [optionsList, cleanSearchTerm, countryCode]);
const {searchTerm, setSearchTerm, availableOptions, selectedOptions, toggleSelection, areOptionsInitialized, selectedOptionsForDisplay} = useSearchSelector({
selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI,
searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL,
initialSelected: initialSelectedOptions,
excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT,
maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW,
includeUserToInvite: false,
includeCurrentUser: true,
});

const listData = useMemo(() => {
const personalDetailList = filteredOptions.personalDetails.map((participant) => ({
...participant,
isSelected: selectedAccountIDs.has(participant.accountID),
}));

const recentReportsList = filteredOptions.recentReports.map((report) => ({
...report,
isSelected: selectedAccountIDs.has(report.accountID),
}));
const combinedOptions = [...selectedOptionsForDisplay, ...availableOptions.personalDetails, ...availableOptions.recentReports];

const combined = [...personalDetailList, ...recentReportsList];

combined.sort((a, b) => {
combinedOptions.sort((a, b) => {
// selected items first
if (a.isSelected && !b.isSelected) {
return -1;
Expand All @@ -137,11 +95,11 @@ function UserSelectPopup({value, closeOverlay, onChange}: UserSelectPopupProps)
return 0;
});

return combined;
}, [filteredOptions, accountID, selectedAccountIDs]);
return combinedOptions;
}, [availableOptions.personalDetails, availableOptions.recentReports, selectedOptionsForDisplay, accountID]);

const {sections, headerMessage} = useMemo(() => {
const newSections: Section[] = [
const newSections: Sections = [
{
title: '',
data: listData,
Expand All @@ -159,13 +117,11 @@ function UserSelectPopup({value, closeOverlay, onChange}: UserSelectPopupProps)
}, [listData, translate]);

const selectUser = useCallback(
(option: Option) => {
const isSelected = selectedOptions.some((selected) => optionsMatch(selected, option));

setSelectedOptions((prev) => (isSelected ? prev.filter((selected) => !optionsMatch(selected, option)) : [...prev, getSelectedOptionData(option)]));
(option: OptionData) => {
toggleSelection(option);
selectionListRef?.current?.scrollToIndex(0, true);
},
[selectedOptions],
[toggleSelection],
);

const applyChanges = useCallback(() => {
Expand Down Expand Up @@ -198,6 +154,7 @@ function UserSelectPopup({value, closeOverlay, onChange}: UserSelectPopupProps)
onSelectRow={selectUser}
onChangeText={setSearchTerm}
isLoadingNewOptions={isLoadingNewOptions}
showLoadingPlaceholder={!areOptionsInitialized}
/>

<View style={[styles.flexRow, styles.gap2, styles.mh5, !shouldUseNarrowLayout && styles.mb4]}>
Expand Down
127 changes: 37 additions & 90 deletions src/components/Search/SearchFiltersParticipantsSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import reportsSelector from '@selectors/Attributes';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import React, {useCallback, useEffect, useMemo} from 'react';
import {usePersonalDetails} from '@components/OnyxListItemProvider';
import {useOptionsList} from '@components/OptionListContextProvider';
import SelectionList from '@components/SelectionListWithSections';
import UserSelectionListItem from '@components/SelectionListWithSections/Search/UserSelectionListItem';
import type {Sections} from '@components/SelectionListWithSections/types';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus';
import useSearchSelector from '@hooks/useSearchSelector';
import {canUseTouchScreen} from '@libs/DeviceCapabilities';
import memoize from '@libs/memoize';
import {filterAndOrderOptions, filterSelectedOptions, formatSectionsFromSearchTerm, getValidOptions} from '@libs/OptionsListUtils';
import type {Option, Section} from '@libs/OptionsListUtils';
import {formatSectionsFromSearchTerm, getParticipantsOption} from '@libs/OptionsListUtils';
import type {OptionData} from '@libs/ReportUtils';
import {getDisplayNameForParticipant} from '@libs/ReportUtils';
import Navigation from '@navigation/Navigation';
Expand All @@ -19,21 +18,6 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import SearchFilterPageFooterButtons from './SearchFilterPageFooterButtons';

const defaultListOptions = {
userToInvite: null,
recentReports: [],
personalDetails: [],
currentUserOption: null,
headerMessage: '',
};

const memoizedGetValidOptions = memoize(getValidOptions, {maxSize: 5, monitoringName: 'SearchFiltersParticipantsSelector.getValidOptions'});

function getSelectedOptionData(option: Option): OptionData {
// eslint-disable-next-line rulesdir/no-default-id-values
return {...option, selected: true, reportID: option.reportID ?? '-1'};
}

type SearchFiltersParticipantsSelectorProps = {
initialAccountIDs: string[];
onFiltersUpdate: (accountIDs: string[]) => void;
Expand All @@ -43,64 +27,37 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}:
const {translate} = useLocalize();
const personalDetails = usePersonalDetails();
const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus();
const {options, areOptionsInitialized} = useOptionsList({
shouldInitialize: didScreenTransitionEnd,
});
const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {canBeMissing: false, initWithStoredValues: false});
const [reportAttributesDerived] = useOnyx(ONYXKEYS.DERIVED.REPORT_ATTRIBUTES, {canBeMissing: true, selector: reportsSelector});
const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE, {canBeMissing: false});
const [selectedOptions, setSelectedOptions] = useState<OptionData[]>([]);
const [searchTerm, setSearchTerm] = useState('');

const {searchTerm, setSearchTerm, availableOptions, selectedOptions, setSelectedOptions, toggleSelection, areOptionsInitialized} = useSearchSelector({
selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_MULTI,
searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL,
maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW,
includeUserToInvite: true,
excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT,
includeRecentReports: true,
shouldInitialize: didScreenTransitionEnd,
includeCurrentUser: true,
});

const cleanSearchTerm = useMemo(() => searchTerm.trim().toLowerCase(), [searchTerm]);
const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT, {canBeMissing: true});

const defaultOptions = useMemo(() => {
const {sections, headerMessage} = useMemo(() => {
const newSections: Sections = [];
if (!areOptionsInitialized) {
return defaultListOptions;
return {sections: [], headerMessage: undefined};
}

return memoizedGetValidOptions(
{
reports: options.reports,
personalDetails: options.personalDetails,
},
draftComments,
{
excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT,
includeCurrentUser: true,
},
countryCode,
);
}, [areOptionsInitialized, draftComments, options.personalDetails, options.reports, countryCode]);

const unselectedOptions = useMemo(() => {
return filterSelectedOptions(defaultOptions, new Set(selectedOptions.map((option) => option.accountID)));
}, [defaultOptions, selectedOptions]);

const chatOptions = useMemo(() => {
const filteredOptions = filterAndOrderOptions(unselectedOptions, cleanSearchTerm, countryCode, {
selectedOptions,
excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT,
maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW,
canInviteUser: false,
});

const {currentUserOption} = unselectedOptions;
const chatOptions = {...availableOptions};
const currentUserOption = chatOptions.currentUserOption;

// Ensure current user is not in personalDetails when they should be excluded
if (currentUserOption) {
filteredOptions.personalDetails = filteredOptions.personalDetails.filter((detail) => detail.accountID !== currentUserOption.accountID);
}

return filteredOptions;
}, [unselectedOptions, cleanSearchTerm, selectedOptions, countryCode]);

const {sections, headerMessage} = useMemo(() => {
const newSections: Section[] = [];
if (!areOptionsInitialized) {
return {sections: [], headerMessage: undefined};
chatOptions.personalDetails = chatOptions.personalDetails.filter((detail) => detail.accountID !== currentUserOption.accountID);
Comment thread
mkzie2 marked this conversation as resolved.
}

// Format selected options to display
const formattedResults = formatSectionsFromSearchTerm(
cleanSearchTerm,
selectedOptions,
Expand Down Expand Up @@ -136,7 +93,10 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}:
});
}

newSections.push(formattedResults.section);
newSections.push({
...formattedResults.section,
data: formattedResults.section.data.map((option) => ({...option, isSelected: true})) as OptionData[],
});

newSections.push({
title: '',
Expand All @@ -157,11 +117,11 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}:
sections: newSections,
headerMessage: message,
};
}, [areOptionsInitialized, cleanSearchTerm, selectedOptions, chatOptions, personalDetails, reportAttributesDerived, translate]);
}, [areOptionsInitialized, availableOptions, cleanSearchTerm, selectedOptions, personalDetails, reportAttributesDerived, translate]);

const resetChanges = useCallback(() => {
setSelectedOptions([]);
}, []);
}, [setSelectedOptions]);

const applyChanges = useCallback(() => {
const selectedAccountIDs = selectedOptions.map((option) => (option.accountID ? option.accountID.toString() : undefined)).filter(Boolean) as string[];
Expand All @@ -183,7 +143,11 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}:
return;
}

return getSelectedOptionData(participant);
const optionData = {
...getParticipantsOption(participant, personalDetails),
isSelected: true,
};
return optionData as OptionData;
})
.filter((option): option is NonNullable<OptionData> => {
return !!option;
Expand All @@ -194,27 +158,10 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate}:
}, [initialAccountIDs, personalDetails]);

const handleParticipantSelection = useCallback(
(option: Option) => {
const foundOptionIndex = selectedOptions.findIndex((selectedOption: Option) => {
if (selectedOption.accountID && selectedOption.accountID === option?.accountID) {
return true;
}

if (selectedOption.reportID && selectedOption.reportID === option?.reportID) {
return true;
}

return false;
});

if (foundOptionIndex < 0) {
setSelectedOptions([...selectedOptions, getSelectedOptionData(option)]);
} else {
const newSelectedOptions = [...selectedOptions.slice(0, foundOptionIndex), ...selectedOptions.slice(foundOptionIndex + 1)];
setSelectedOptions(newSelectedOptions);
}
(option: OptionData) => {
toggleSelection(option);
},
[selectedOptions],
[toggleSelection],
);

const footerContent = useMemo(
Expand Down
4 changes: 4 additions & 0 deletions src/components/SelectionListWithSections/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {AnimatedStyle} from 'react-native-reanimated';
import type {SearchRouterItem} from '@components/Search/SearchAutocompleteList';
import type {SearchColumnType, SearchGroupBy, SearchQueryJSON} from '@components/Search/types';
import type {ForwardedFSClassProps} from '@libs/Fullstory/types';
import type {OptionData} from '@libs/ReportUtils';
import type {BrickRoad} from '@libs/WorkspacesSettingsUtils';
import type UnreportedExpenseListItem from '@pages/UnreportedExpenseListItem';
// eslint-disable-next-line no-restricted-imports
Expand Down Expand Up @@ -610,6 +611,8 @@ type Section<TItem extends ListItem> = {
shouldShow?: boolean;
};

type Sections = Array<SectionListData<OptionData, Section<OptionData>>>;

type LoadingPlaceholderComponentProps = {
shouldStyleAsTable?: boolean;
fixedNumItems?: number;
Expand Down Expand Up @@ -1034,4 +1037,5 @@ export type {
SplitListItemType,
SearchListItem,
UnreportedExpenseListItemType,
Sections,
};
Loading
Loading