diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f2d66c6656c65..330a3869c9829 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2155,6 +2155,8 @@ const CONST = { GLOBAL_CREATE: '\uE100', }, + MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD: 8, + INVISIBLE_CODEPOINTS: ['fe0f', '200d', '2066'], UNICODE: { diff --git a/src/components/ApproverSelectionList.tsx b/src/components/ApproverSelectionList.tsx index 9723f4402178e..83850166473a6 100644 --- a/src/components/ApproverSelectionList.tsx +++ b/src/components/ApproverSelectionList.tsx @@ -1,5 +1,6 @@ import React, {useMemo} from 'react'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -7,6 +8,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import {getSearchValueForPhoneOrEmail, sortAlphabetically} from '@libs/OptionsListUtils'; import {goBackFromInvalidPolicy, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {reorderItemsByInitialSelection} from '@libs/SelectionListOrderUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -76,6 +78,14 @@ function ApproverSelectionList({ const lazyIllustrations = useMemoizedLazyIllustrations(['TurtleInShell']); const selectedMembers = useMemo(() => allApprovers.filter((approver) => approver.isSelected), [allApprovers]); + const initialSelectedApproverKeys = useInitialSelectionRef( + allApprovers + .filter((approver) => approver.isSelected) + .map((approver) => approver.keyForList?.toString() ?? '') + .filter(Boolean), + {resetDeps: [allApprovers.length], resetOnFocus: true}, + ); + const initialFocusedApproverKey = useInitialSelectionRef(initiallyFocusedOptionKey, {resetDeps: [allApprovers.length], resetOnFocus: true}); // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !isPolicyAdmin(policy) || isPendingDeletePolicy(policy) || shouldShowNotFoundViewProp; @@ -86,8 +96,14 @@ function ApproverSelectionList({ ? tokenizedSearch(allApprovers, getSearchValueForPhoneOrEmail(debouncedSearchTerm, countryCode), (option) => [option.text ?? '', option.login ?? '']) : allApprovers; - return sortAlphabetically(filteredApprovers, 'text', localeCompare); - }, [allApprovers, debouncedSearchTerm, countryCode, localeCompare]); + const sortedApprovers = sortAlphabetically(filteredApprovers, 'text', localeCompare); + + if (debouncedSearchTerm || initialSelectedApproverKeys.length === 0) { + return sortedApprovers; + } + + return reorderItemsByInitialSelection(sortedApprovers, initialSelectedApproverKeys); + }, [allApprovers, countryCode, debouncedSearchTerm, initialSelectedApproverKeys, localeCompare]); const shouldShowListEmptyContent = !debouncedSearchTerm && !data.length && shouldShowListEmptyContentProp; @@ -160,7 +176,7 @@ function ApproverSelectionList({ shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} listEmptyContent={listEmptyContent} shouldShowListEmptyContent={shouldShowListEmptyContent} - initiallyFocusedItemKey={initiallyFocusedOptionKey} + initiallyFocusedItemKey={initialFocusedApproverKey} shouldShowTextInput={shouldShowTextInput} shouldShowLoadingPlaceholder={shouldShowLoadingPlaceholder} footerContent={footerContent} @@ -168,6 +184,7 @@ function ApproverSelectionList({ shouldUpdateFocusedIndex={shouldUpdateFocusedIndex} showScrollIndicator isRowMultilineSupported + shouldScrollToTopOnSelect={false} /> diff --git a/src/components/CountryPicker/CountrySelectorModal.tsx b/src/components/CountryPicker/CountrySelectorModal.tsx index a2f7e0fffeeb3..e6c7064b05051 100644 --- a/src/components/CountryPicker/CountrySelectorModal.tsx +++ b/src/components/CountryPicker/CountrySelectorModal.tsx @@ -5,10 +5,12 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -36,10 +38,13 @@ type CountrySelectorModalProps = { function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onClose, label, onBackdropPress}: CountrySelectorModalProps) { const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const initialSelectedValues = useInitialSelectionRef(currentCountry ? [currentCountry] : [], {resetDeps: [isVisible]}); + + const countryKeys = useMemo(() => Object.keys(CONST.ALL_COUNTRIES), []); const countries = useMemo( () => - Object.keys(CONST.ALL_COUNTRIES).map((countryISO) => { + countryKeys.map((countryISO) => { const countryName = translate(`allCountries.${countryISO}` as TranslationPaths); return { value: countryISO, @@ -49,10 +54,20 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), }; }), - [translate, currentCountry], + [translate, countryKeys, currentCountry], ); - const searchResults = searchOptions(debouncedSearchValue, countries); + const orderedCountries = useMemo(() => { + const shouldReorderInitialSelection = initialSelectedValues.length > 0 && countries.length > CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD; + + if (!shouldReorderInitialSelection) { + return countries; + } + + return moveInitialSelectionToTopByValue(countries, initialSelectedValues); + }, [countries, initialSelectedValues]); + + const searchResults = useMemo(() => searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries), [countries, orderedCountries, debouncedSearchValue]); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const styles = useThemeStyles(); diff --git a/src/components/CurrencySelectionList/index.tsx b/src/components/CurrencySelectionList/index.tsx index 160ce3443de80..ebcb79813b219 100644 --- a/src/components/CurrencySelectionList/index.tsx +++ b/src/components/CurrencySelectionList/index.tsx @@ -1,19 +1,24 @@ import {Str} from 'expensify-common'; -import React, {useState} from 'react'; +import React, {useMemo, useState} from 'react'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; import {useCurrencyListActions, useCurrencyListState} from '@hooks/useCurrencyList'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; import useLocalize from '@hooks/useLocalize'; import getMatchScore from '@libs/getMatchScore'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import arraysEqual from '@src/utils/arraysEqual'; import type {CurrencyListItem, CurrencySelectionListProps} from './types'; +const EMPTY_SELECTED_CURRENCIES: string[] = []; + function CurrencySelectionList({ searchInputLabel, initiallySelectedCurrencyCode, onSelect, didScreenTransitionEnd = true, - selectedCurrencies = [], + selectedCurrencies = EMPTY_SELECTED_CURRENCIES, recentlyUsedCurrencies, excludedCurrencies = [], ...restProps @@ -23,6 +28,22 @@ function CurrencySelectionList({ const [searchValue, setSearchValue] = useState(''); const {translate} = useLocalize(); const getUnselectedOptions = (options: CurrencyListItem[]) => options.filter((option) => !option.isSelected); + const initialSelectedCurrencyCodes = useMemo(() => { + const codes = new Set(); + if (initiallySelectedCurrencyCode) { + codes.add(initiallySelectedCurrencyCode); + } + for (const currencyCode of selectedCurrencies) { + codes.add(currencyCode); + } + return Array.from(codes); + }, [initiallySelectedCurrencyCode, selectedCurrencies]); + const initialSelectedCurrencyCodesSignature = useMemo(() => initialSelectedCurrencyCodes.join('|'), [initialSelectedCurrencyCodes]); + const initialSelectedCurrencySnapshot = useInitialSelectionRef(initialSelectedCurrencyCodes, { + resetDeps: [initialSelectedCurrencyCodesSignature], + resetOnFocus: true, + isEqual: arraysEqual, + }); const currencyOptions: CurrencyListItem[] = Object.entries(currencyList).reduce((acc, [currencyCode, currencyInfo]) => { const isSelectedCurrency = currencyCode === initiallySelectedCurrencyCode || selectedCurrencies.includes(currencyCode); @@ -57,11 +78,19 @@ function CurrencySelectionList({ .filter((currencyOption) => searchRegex.test(currencyOption.text ?? '') || searchRegex.test(currencyOption.currencyName)) .sort((currency1, currency2) => getMatchScore(currency2.text ?? '', searchValue) - getMatchScore(currency1.text ?? '', searchValue)); - const isEmpty = searchValue.trim() && !filteredCurrencies.length; + const shouldReorderInitialSelection = !searchValue && initialSelectedCurrencySnapshot.length > 0; + const displayCurrencies = shouldReorderInitialSelection + ? moveInitialSelectionToTopByValue( + filteredCurrencies.map((currency) => ({...currency, value: currency.currencyCode})), + initialSelectedCurrencySnapshot, + ) + : filteredCurrencies; + + const isEmpty = searchValue.trim() && !displayCurrencies.length; const shouldDisplayRecentlyOptions = !isEmptyObject(recentlyUsedCurrencyOptions) && !searchValue; - const selectedOptions = filteredCurrencies.filter((option) => option.isSelected); + const selectedOptions = displayCurrencies.filter((option) => option.isSelected); const shouldDisplaySelectedOptionOnTop = selectedOptions.length > 0; - const unselectedOptions = getUnselectedOptions(filteredCurrencies); + const unselectedOptions = getUnselectedOptions(displayCurrencies); const sections = []; if (shouldDisplaySelectedOptionOnTop) { @@ -80,12 +109,12 @@ function CurrencySelectionList({ data: shouldDisplaySelectedOptionOnTop ? getUnselectedOptions(recentlyUsedCurrencyOptions) : recentlyUsedCurrencyOptions, sectionIndex: 1, }, - {title: translate('common.all'), data: shouldDisplayRecentlyOptions ? unselectedOptions : filteredCurrencies, sectionIndex: 2}, + {title: translate('common.all'), data: shouldDisplayRecentlyOptions ? unselectedOptions : displayCurrencies, sectionIndex: 2}, ); } } else if (!isEmpty) { sections.push({ - data: shouldDisplaySelectedOptionOnTop ? unselectedOptions : filteredCurrencies, + data: shouldDisplaySelectedOptionOnTop ? unselectedOptions : displayCurrencies, sectionIndex: 3, }); } diff --git a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx index 26b2909899d29..209e8abc86787 100644 --- a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx @@ -5,8 +5,10 @@ import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import {reorderItemsByInitialSelection} from '@libs/SelectionListOrderUtils'; import CONST from '@src/CONST'; import type CalendarPickerListItem from './types'; @@ -31,13 +33,18 @@ function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear const styles = useThemeStyles(); const {translate} = useLocalize(); const [searchText, setSearchText] = useState(''); + const initialSelectedValues = useInitialSelectionRef([currentYear.toString()], {resetDeps: [isVisible]}); + const initiallyFocusedYear = initialSelectedValues.at(0); const {data, headerMessage} = useMemo(() => { const yearsList = searchText === '' ? years : years.filter((year) => year.text?.includes(searchText)); + const sortedYears = [...yearsList].sort((a, b) => b.value - a.value); + const orderedYears = + searchText || sortedYears.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD ? sortedYears : reorderItemsByInitialSelection(sortedYears, initialSelectedValues); return { - headerMessage: !yearsList.length ? translate('common.noResultsFound') : '', - data: yearsList.sort((a, b) => b.value - a.value), + headerMessage: !orderedYears.length ? translate('common.noResultsFound') : '', + data: orderedYears, }; - }, [years, searchText, translate]); + }, [initialSelectedValues, searchText, translate, years]); useEffect(() => { if (isVisible) { @@ -84,11 +91,13 @@ function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear ListItem={RadioListItem} onSelectRow={(option) => { Keyboard.dismiss(); - onYearChange?.(option.value); + onYearChange?.(Number(option.keyForList)); }} textInputOptions={textInputOptions} - initiallyFocusedItemKey={currentYear.toString()} + initiallyFocusedItemKey={initiallyFocusedYear} disableMaintainingScrollPosition + shouldScrollToFocusedIndex={false} + shouldScrollToFocusedIndexOnMount={false} addBottomSafeAreaPadding shouldStopPropagation showScrollIndicator diff --git a/src/components/PushRowWithModal/PushRowModal.tsx b/src/components/PushRowWithModal/PushRowModal.tsx index a2f94504d49a6..27a66344663c4 100644 --- a/src/components/PushRowWithModal/PushRowModal.tsx +++ b/src/components/PushRowWithModal/PushRowModal.tsx @@ -5,8 +5,10 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; import useLocalize from '@hooks/useLocalize'; import searchOptions from '@libs/searchOptions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; @@ -44,19 +46,35 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const initialSelectedValues = useInitialSelectionRef(selectedOption ? [selectedOption] : [], {resetDeps: [isVisible]}); + + const optionKeys = useMemo(() => Object.keys(optionsList), [optionsList]); const options = useMemo( () => - Object.entries(optionsList).map(([key, value]) => ({ - value: key, - text: value, - keyForList: key, - isSelected: key === selectedOption, - searchValue: StringUtils.sanitizeString(value), - })), - [optionsList, selectedOption], + optionKeys.map((key) => { + const value = optionsList[key]; + return { + value: key, + text: value, + keyForList: key, + isSelected: key === selectedOption, + searchValue: StringUtils.sanitizeString(value), + }; + }), + [optionKeys, optionsList, selectedOption], ); + const orderedOptions = useMemo(() => { + const shouldReorderInitialSelection = initialSelectedValues.length > 0 && options.length > CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD; + + if (!shouldReorderInitialSelection) { + return options; + } + + return moveInitialSelectionToTopByValue(options, initialSelectedValues); + }, [initialSelectedValues, options]); + const handleSelectRow = (option: ListItemType) => { onOptionChange(option.value); onClose(); @@ -67,7 +85,7 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio setSearchValue(''); }; - const searchResults = searchOptions(debouncedSearchValue, options); + const searchResults = useMemo(() => searchOptions(debouncedSearchValue, debouncedSearchValue ? options : orderedOptions), [debouncedSearchValue, options, orderedOptions]); const textInputOptions = useMemo( () => ({ diff --git a/src/components/Rule/RuleSelectionBase.tsx b/src/components/Rule/RuleSelectionBase.tsx index fa0791fd72b3d..7f3d168565d03 100644 --- a/src/components/Rule/RuleSelectionBase.tsx +++ b/src/components/Rule/RuleSelectionBase.tsx @@ -2,12 +2,12 @@ import React from 'react'; import {View} from 'react-native'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; -import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {TranslationPaths} from '@src/languages/types'; import type {Route} from '@src/ROUTES'; import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; +import RuleSelectionPicker from './RuleSelectionPicker'; type SelectionItem = { name: string; @@ -59,12 +59,11 @@ function RuleSelectionBase({titleKey, title, testID, selectedItem, items, onSave onBackButtonPress={onBack} /> - diff --git a/src/components/Rule/RuleSelectionPicker.tsx b/src/components/Rule/RuleSelectionPicker.tsx new file mode 100644 index 0000000000000..f2f812ff559e4 --- /dev/null +++ b/src/components/Rule/RuleSelectionPicker.tsx @@ -0,0 +1,61 @@ +import React, {useCallback} from 'react'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; +import useLocalize from '@hooks/useLocalize'; +import Navigation from '@libs/Navigation/Navigation'; +import type {Route} from '@src/ROUTES'; +import type {RuleSelectionListItem, SelectionItem} from './hooks/useRuleSelectionList'; +import useRuleSelectionList from './hooks/useRuleSelectionList'; + +type RuleSelectionPickerProps = { + items: SelectionItem[]; + initiallySelectedItem?: SelectionItem; + onSaveSelection: (value?: string) => void; + backToRoute: Route; +}; + +function RuleSelectionPicker({items, initiallySelectedItem, onSaveSelection, backToRoute}: RuleSelectionPickerProps) { + const {translate} = useLocalize(); + const {sections, noResultsFound, searchTerm, setSearchTerm, initiallyFocusedItemKey} = useRuleSelectionList({ + items, + initiallySelectedItem, + }); + + const onSelectRow = useCallback( + (item: {text?: string; keyForList?: string; isSelected?: boolean}) => { + if (!item.text || !item.keyForList) { + return; + } + + const isRemovingSelection = !!item.isSelected; + const newValue = isRemovingSelection ? '' : item.keyForList; + + onSaveSelection(newValue); + Navigation.goBack(backToRoute); + }, + [backToRoute, onSaveSelection], + ); + + const textInputOptions = { + value: searchTerm, + label: translate('common.search'), + onChangeText: setSearchTerm, + headerMessage: noResultsFound ? translate('common.noResultsFound') : undefined, + }; + + return ( + + sections={sections} + ListItem={SingleSelectListItem} + onSelectRow={onSelectRow} + shouldShowTextInput + textInputOptions={textInputOptions} + shouldShowLoadingPlaceholder={false} + initiallyFocusedItemKey={initiallyFocusedItemKey} + shouldStopPropagation + shouldScrollToTopOnSelect={false} + /> + ); +} + +export default RuleSelectionPicker; diff --git a/src/components/Rule/hooks/useRuleSelectionList.ts b/src/components/Rule/hooks/useRuleSelectionList.ts new file mode 100644 index 0000000000000..ed18404942329 --- /dev/null +++ b/src/components/Rule/hooks/useRuleSelectionList.ts @@ -0,0 +1,120 @@ +import {useMemo} from 'react'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; +import type {Section} from '@components/SelectionList/SelectionListWithSections/types'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; +import useLocalize from '@hooks/useLocalize'; +import {sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; +import CONST from '@src/CONST'; + +type SelectionItem = { + name: string; + value: string; +}; + +type RuleSelectionListItem = ListItem & { + value: string; +}; + +type UseRuleSelectionListParams = { + /** All options to display */ + items: SelectionItem[]; + + /** Currently selected item */ + initiallySelectedItem?: SelectionItem; +}; + +type UseRuleSelectionListResult = { + /** Sections ready for SelectionListWithSections */ + sections: Array>; + + /** Whether search returned no results */ + noResultsFound: boolean; + + /** Current search value */ + searchTerm: string; + + /** Update search value */ + setSearchTerm: (value: string) => void; + + /** Key to focus initially */ + initiallyFocusedItemKey?: string; +}; + +function useRuleSelectionList({items, initiallySelectedItem}: UseRuleSelectionListParams): UseRuleSelectionListResult { + const {localeCompare} = useLocalize(); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const initialSelectedValues = useInitialSelectionRef(initiallySelectedItem ? [initiallySelectedItem.value] : [], {resetOnFocus: true}); + + const normalizedSearch = debouncedSearchTerm?.toLowerCase() ?? ''; + + const sortedItems = useMemo( + () => items.filter((item) => item.name.toLowerCase().includes(normalizedSearch)).sort((a, b) => sortOptionsWithEmptyValue(a.name.toString(), b.name.toString(), localeCompare)), + [items, localeCompare, normalizedSearch], + ); + + const orderedItems = useMemo(() => { + const mappedItems = sortedItems.map((item) => ({ + text: item.name, + keyForList: item.value, + value: item.value, + })); + + const shouldShowStaleSelectedItem = + !!initiallySelectedItem && + !sortedItems.some((item) => item.value === initiallySelectedItem.value) && + (!normalizedSearch || initiallySelectedItem.name.toLowerCase().includes(normalizedSearch)); + + const itemsForDisplay = shouldShowStaleSelectedItem + ? [ + { + text: initiallySelectedItem.name, + keyForList: initiallySelectedItem.value, + value: initiallySelectedItem.value, + }, + ...mappedItems, + ] + : mappedItems; + + const shouldReorderInitialSelection = !normalizedSearch && initialSelectedValues.length > 0 && itemsForDisplay.length > CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD; + + return shouldReorderInitialSelection ? moveInitialSelectionToTopByValue(itemsForDisplay, initialSelectedValues) : itemsForDisplay; + }, [initialSelectedValues, initiallySelectedItem, normalizedSearch, sortedItems]); + + const listData = useMemo( + () => + orderedItems.map((item) => ({ + ...item, + isSelected: initiallySelectedItem?.value === item.value, + })), + [initiallySelectedItem?.value, orderedItems], + ); + + const {sections, noResultsFound} = useMemo(() => { + const hasNoItems = listData.length === 0; + const isSearchMiss = !!normalizedSearch && hasNoItems; + + const preparedSections: Array> = hasNoItems + ? [] + : [ + { + data: listData, + sectionIndex: 0, + }, + ]; + + return {sections: preparedSections, noResultsFound: isSearchMiss}; + }, [listData, normalizedSearch]); + + return { + sections, + noResultsFound, + searchTerm, + setSearchTerm, + initiallyFocusedItemKey: initialSelectedValues.at(0), + }; +} + +export default useRuleSelectionList; +export type {SelectionItem, RuleSelectionListItem}; diff --git a/src/components/Search/FilterDropdowns/DropdownButton.tsx b/src/components/Search/FilterDropdowns/DropdownButton.tsx index f0012ea465e7d..118e8ebb32bd1 100644 --- a/src/components/Search/FilterDropdowns/DropdownButton.tsx +++ b/src/components/Search/FilterDropdowns/DropdownButton.tsx @@ -21,6 +21,7 @@ import type WithSentryLabel from '@src/types/utils/SentryLabel'; type PopoverComponentProps = { closeOverlay: () => void; + isVisible: boolean; }; type DropdownButtonProps = WithSentryLabel & { @@ -122,8 +123,8 @@ function DropdownButton({label, value, viewportOffsetTop, PopoverComponent, medi }, [isSmallScreenWidth, styles]); const popoverContent = useMemo(() => { - return PopoverComponent({closeOverlay: toggleOverlay}); - }, [PopoverComponent, toggleOverlay]); + return PopoverComponent({closeOverlay: toggleOverlay, isVisible: isOverlayVisible}); + }, [PopoverComponent, toggleOverlay, isOverlayVisible]); return ( = { /** Search input placeholder. Defaults to 'common.search' when not provided. */ searchPlaceholder?: string; + + /** Whether to move initially selected items to the top on open (no reordering while toggling). */ + shouldMoveSelectedItemsToTopOnOpen?: boolean; + + /** Whether the popup content is currently visible */ + isVisible?: boolean; }; -function MultiSelectPopup({label, value, items, closeOverlay, onChange, isSearchable, searchPlaceholder}: MultiSelectPopupProps) { +function MultiSelectPopup({ + label, + value, + items, + closeOverlay, + onChange, + isSearchable, + searchPlaceholder, + shouldMoveSelectedItemsToTopOnOpen = false, + isVisible = false, +}: MultiSelectPopupProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -50,16 +68,47 @@ function MultiSelectPopup({label, value, items, closeOverlay, const {windowHeight} = useWindowDimensions(); const [selectedItems, setSelectedItems] = useState(value); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const initialSelectedValues = useInitialSelectionRef( + value.map((item) => item.value), + {resetDeps: [isVisible]}, + ); + const selectedValues = useMemo(() => new Set(selectedItems.map((item) => item.value)), [selectedItems]); - const listData: ListItem[] = useMemo(() => { - const filteredItems = isSearchable ? items.filter((item) => item.text.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) : items; - return filteredItems.map((item) => ({ + const filteredItems = useMemo( + () => (isSearchable ? items.filter((item) => item.text.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) : items), + [debouncedSearchTerm, isSearchable, items], + ); + + const orderedItems = useMemo(() => { + const mappedItems = filteredItems.map((item) => ({ text: item.text, keyForList: item.value, - isSelected: !!selectedItems.find((i) => i.value === item.value), + value: item.value, icons: item.icons, })); - }, [items, selectedItems, isSearchable, debouncedSearchTerm]); + + const shouldReorderInitialSelection = + shouldMoveSelectedItemsToTopOnOpen && + isVisible && + !debouncedSearchTerm && + initialSelectedValues.length > 0 && + mappedItems.length > CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD; + + if (!shouldReorderInitialSelection) { + return mappedItems; + } + + return moveInitialSelectionToTopByValue(mappedItems, initialSelectedValues); + }, [debouncedSearchTerm, filteredItems, initialSelectedValues, isVisible, shouldMoveSelectedItemsToTopOnOpen]); + + const listData: ListItem[] = useMemo( + () => + orderedItems.map((item) => ({ + ...item, + isSelected: selectedValues.has(item.value), + })), + [orderedItems, selectedValues], + ); const headerMessage = isSearchable && listData.length === 0 ? translate('common.noResultsFound') : undefined; @@ -110,6 +159,7 @@ function MultiSelectPopup({label, value, items, closeOverlay, ListItem={MultiSelectListItem} onSelectRow={updateSelectedItems} textInputOptions={textInputOptions} + shouldScrollToTopOnSelect={false} /> diff --git a/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx b/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx index 40d836d2af625..4dd70b80ab5f3 100644 --- a/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx +++ b/src/components/Search/FilterDropdowns/SingleSelectPopup.tsx @@ -6,10 +6,12 @@ import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelec import type {ListItem, SelectionListStyle} from '@components/SelectionList/types'; import Text from '@components/Text'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import CONST from '@src/CONST'; type SingleSelectItem = { @@ -44,9 +46,23 @@ type SingleSelectPopupProps = { /** Custom styles for the SelectionList */ selectionListStyle?: SelectionListStyle; + + /** Whether the popup content is currently visible */ + isVisible?: boolean; }; -function SingleSelectPopup({label, value, items, closeOverlay, onChange, isSearchable, searchPlaceholder, defaultValue, selectionListStyle}: SingleSelectPopupProps) { +function SingleSelectPopup({ + label, + value, + items, + closeOverlay, + onChange, + isSearchable, + searchPlaceholder, + defaultValue, + selectionListStyle, + isVisible = false, +}: SingleSelectPopupProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -54,37 +70,44 @@ function SingleSelectPopup({label, value, items, closeOverlay, const {windowHeight} = useWindowDimensions(); const [selectedItem, setSelectedItem] = useState(value); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const initialSelectedValues = useInitialSelectionRef(value ? [value.value] : [], {resetDeps: [isVisible]}); + const initialFocusedItemKey = initialSelectedValues.at(0); + + const filteredItems = useMemo( + () => + items.filter((item) => { + if (!isSearchable) { + return true; + } + const term = debouncedSearchTerm?.toLowerCase(); + return item.text.toLowerCase().includes(term); + }), + [debouncedSearchTerm, isSearchable, items], + ); + + const orderedItems = useMemo(() => { + const mappedItems = filteredItems.map((item) => ({ + text: item.text, + keyForList: item.value, + value: item.value, + })); + + const shouldReorderInitialSelection = + isVisible && !debouncedSearchTerm && initialSelectedValues.length > 0 && mappedItems.length > CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD; - const {options, noResultsFound} = useMemo(() => { - // If the selection is searchable, we push the initially selected item into its own section and display it at the top - if (isSearchable) { - const initiallySelectedOption = value?.text.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()) - ? [{text: value.text, keyForList: value.value, isSelected: selectedItem?.value === value.value}] - : []; - const remainingOptions = items - .filter((item) => item?.value !== value?.value && item?.text?.toLowerCase().includes(debouncedSearchTerm?.toLowerCase())) - .map((item) => ({ - text: item.text, - keyForList: item.value, - isSelected: selectedItem?.value === item.value, - })); - const allOptions = [...initiallySelectedOption, ...remainingOptions]; - const isEmpty = allOptions.length === 0; - return { - options: allOptions, - noResultsFound: isEmpty, - }; - } - - return { - options: items.map((item) => ({ - text: item.text, - keyForList: item.value, + return shouldReorderInitialSelection ? moveInitialSelectionToTopByValue(mappedItems, initialSelectedValues) : mappedItems; + }, [debouncedSearchTerm, filteredItems, initialSelectedValues, isVisible]); + + const options = useMemo( + () => + orderedItems.map((item) => ({ + ...item, isSelected: item.value === selectedItem?.value, })), - noResultsFound: false, - }; - }, [isSearchable, items, value, selectedItem?.value, debouncedSearchTerm]); + [orderedItems, selectedItem?.value], + ); + + const noResultsFound = options.length === 0 && !!debouncedSearchTerm; const updateSelectedItem = useCallback( (item: ListItem) => { @@ -127,9 +150,11 @@ function SingleSelectPopup({label, value, items, closeOverlay, ListItem={SingleSelectListItem} onSelectRow={updateSelectedItem} textInputOptions={textInputOptions} + shouldUpdateFocusedIndex={false} + initiallyFocusedItemKey={isSearchable ? initialFocusedItemKey : undefined} + shouldScrollToFocusedIndex={false} style={selectionListStyle} - shouldUpdateFocusedIndex={isSearchable} - initiallyFocusedItemKey={isSearchable ? value?.value : undefined} + shouldScrollToFocusedIndexOnMount={false} shouldShowLoadingPlaceholder={!noResultsFound} /> diff --git a/src/components/Search/FilterDropdowns/UserSelectPopup.tsx b/src/components/Search/FilterDropdowns/UserSelectPopup.tsx index 86594fbc3beeb..c17db13bbb791 100644 --- a/src/components/Search/FilterDropdowns/UserSelectPopup.tsx +++ b/src/components/Search/FilterDropdowns/UserSelectPopup.tsx @@ -1,12 +1,11 @@ import isEmpty from 'lodash/isEmpty'; -import React, {memo, useCallback, useMemo, useRef, useState} from 'react'; +import React, {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import Button from '@components/Button'; -import {usePersonalDetails} from '@components/OnyxListItemProvider'; import SelectionList from '@components/SelectionList'; import UserSelectionListItem from '@components/SelectionList/ListItem/UserSelectionListItem'; -import type {ListItem, SelectionListHandle} from '@components/SelectionList/types'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePersonalDetailOptions from '@hooks/usePersonalDetailOptions'; @@ -17,6 +16,7 @@ import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; import memoize from '@libs/memoize'; import {filterOption, getValidOptions} from '@libs/PersonalDetailOptionsListUtils'; import type {OptionData} from '@libs/PersonalDetailOptionsListUtils'; +import {reorderItemsByInitialSelection} from '@libs/SelectionListOrderUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -38,14 +38,15 @@ type UserSelectPopupProps = { * Set to true to always show search, or false to never show search regardless of user count. */ isSearchable?: boolean; + + /** Whether the popup content is currently visible */ + isVisible?: boolean; }; -function UserSelectPopup({value, closeOverlay, onChange, isSearchable}: UserSelectPopupProps) { - const selectionListRef = useRef | null>(null); +function UserSelectPopup({value, closeOverlay, onChange, isSearchable, isVisible = false}: UserSelectPopupProps) { const styles = useThemeStyles(); const {translate, formatPhoneNumber} = useLocalize(); - const {options, currentOption} = usePersonalDetailOptions(); - const personalDetails = usePersonalDetails(); + const {options, currentOption, isLoading} = usePersonalDetailOptions(); const {windowHeight} = useWindowDimensions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -55,19 +56,34 @@ function UserSelectPopup({value, closeOverlay, onChange, isSearchable}: UserSele const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); const [searchTerm, setSearchTerm] = useState(''); const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); + const hasBeenVisibleRef = useRef(false); + const availableAccountIDs = new Set(options?.map((option) => option.accountID.toString()) ?? []); + if (currentOption?.accountID) { + availableAccountIDs.add(currentOption.accountID.toString()); + } + const incomingSelectedAccountIDs = value.filter((accountID) => availableAccountIDs.has(accountID)); + + const [selectedAccountIDs, setSelectedAccountIDs] = useState>(() => new Set(incomingSelectedAccountIDs)); + const initialSelectedAccountIDs = useInitialSelectionRef(incomingSelectedAccountIDs, {resetDeps: [isVisible, isLoading]}); + + useEffect(() => { + if (!isVisible) { + hasBeenVisibleRef.current = false; + return; + } - const getInitialSelectedIDs = useCallback(() => { - return value.reduce>((acc, id) => { - const participant = personalDetails?.[id]; - if (!participant) { - return acc; - } - acc.add(id); - return acc; - }, new Set()); - }, [value, personalDetails]); + if (isLoading) { + return; + } + + if (hasBeenVisibleRef.current) { + return; + } - const [selectedAccountIDs, setSelectedAccountIDs] = useState>(() => getInitialSelectedIDs()); + hasBeenVisibleRef.current = true; + setSelectedAccountIDs(new Set(incomingSelectedAccountIDs)); + setSearchTerm(''); + }, [incomingSelectedAccountIDs, isLoading, isVisible]); const cleanSearchTerm = searchTerm.trim().toLowerCase(); @@ -85,6 +101,7 @@ function UserSelectPopup({value, closeOverlay, onChange, isSearchable}: UserSele excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, includeCurrentUser: false, includeRecentReports: false, + includeSelectedOptions: true, searchString: cleanSearchTerm, }); }, [transformedOptions, currentUserEmail, cleanSearchTerm, formatPhoneNumber, countryCode, loginList]); @@ -102,16 +119,21 @@ function UserSelectPopup({value, closeOverlay, onChange, isSearchable}: UserSele return newOption; }, [currentOption, cleanSearchTerm, selectedAccountIDs, currentUserSearchTerms]); - const listData = useMemo(() => { + const baseVisibleOptions = useMemo(() => { if (!filteredCurrentUserOption) { - return [...optionsList.selectedOptions, ...optionsList.personalDetails]; + return optionsList.personalDetails; } - const isCurrentOptionSelected = filteredCurrentUserOption.isSelected; - if (isCurrentOptionSelected) { - return [filteredCurrentUserOption, ...optionsList.selectedOptions, ...optionsList.personalDetails]; + + return [filteredCurrentUserOption, ...optionsList.personalDetails]; + }, [filteredCurrentUserOption, optionsList.personalDetails]); + + const listData = useMemo(() => { + if (cleanSearchTerm) { + return baseVisibleOptions; } - return [...optionsList.selectedOptions, filteredCurrentUserOption, ...optionsList.personalDetails]; - }, [filteredCurrentUserOption, optionsList.selectedOptions, optionsList.personalDetails]); + + return reorderItemsByInitialSelection(baseVisibleOptions, initialSelectedAccountIDs, baseVisibleOptions.length); + }, [baseVisibleOptions, cleanSearchTerm, initialSelectedAccountIDs]); const headerMessage = useMemo(() => { const noResultsFound = isEmpty(listData); @@ -123,7 +145,6 @@ function UserSelectPopup({value, closeOverlay, onChange, isSearchable}: UserSele const isSelected = selectedAccountIDs.has(option.accountID.toString()); setSelectedAccountIDs((prev) => (isSelected ? new Set([...prev].filter((id) => id !== option.accountID.toString())) : new Set([...prev, option.accountID.toString()]))); - selectionListRef?.current?.scrollToIndex(0); }, [selectedAccountIDs], ); @@ -141,6 +162,7 @@ function UserSelectPopup({value, closeOverlay, onChange, isSearchable}: UserSele const isLoadingNewOptions = !!isSearchingForReports; const shouldShowSearchInput = isSearchable ?? transformedOptions.length >= CONST.STANDARD_LIST_ITEM_LIMIT; + const shouldShowLoadingPlaceholder = isLoading; const textInputOptions = useMemo( () => @@ -160,13 +182,14 @@ function UserSelectPopup({value, closeOverlay, onChange, isSearchable}: UserSele diff --git a/src/components/Search/SearchAutocompleteList.tsx b/src/components/Search/SearchAutocompleteList.tsx index 4cf098ded08ec..6f21f0650e52d 100644 --- a/src/components/Search/SearchAutocompleteList.tsx +++ b/src/components/Search/SearchAutocompleteList.tsx @@ -1,5 +1,5 @@ import type {ForwardedRef, RefObject} from 'react'; -import React, {useContext, useEffect, useRef, useState} from 'react'; +import React, {useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {OptionsListStateContext, useOptionsList} from '@components/OptionListContextProvider'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; @@ -22,7 +22,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import FS from '@libs/Fullstory'; import type {Options, SearchOption} from '@libs/OptionsListUtils'; -import {combineOrderingOfReportsAndPersonalDetails, getSearchOptions} from '@libs/OptionsListUtils'; +import {combineOrderingOfReportsAndPersonalDetails, getEmptyOptions, getSearchOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import {getAllTaxRates} from '@libs/PolicyUtils'; import {getReportAction} from '@libs/ReportActionsUtils'; @@ -85,14 +85,6 @@ type SearchAutocompleteListProps = { ref?: ForwardedRef; }; -const defaultListOptions = { - userToInvite: null, - recentReports: [], - personalDetails: [], - currentUserOption: null, - categoryOptions: [], -}; - const setPerformanceTimersEnd = () => { endSpan(CONST.TELEMETRY.SPAN_OPEN_SEARCH_ROUTER); }; @@ -186,9 +178,9 @@ function SearchAutocompleteList({ } }, [contextAreOptionsInitialized]); - const searchOptions = (() => { + const searchOptions = useMemo(() => { if (!areOptionsInitialized) { - return defaultListOptions; + return getEmptyOptions(); } return getSearchOptions({ options, @@ -212,7 +204,21 @@ function SearchAutocompleteList({ policyCollection: policies, personalDetails, }); - })(); + }, [ + areOptionsInitialized, + options, + draftComments, + nvpDismissedProductTraining, + betas, + autocompleteQueryValue, + countryCode, + loginList, + visibleReportActionsData, + currentUserAccountID, + currentUserEmail, + policies, + personalDetails, + ]); const [isInitialRender, setIsInitialRender] = useState(true); const prevQueryRef = useRef(autocompleteQueryValue); @@ -319,7 +325,7 @@ function SearchAutocompleteList({ }; }); - const recentReportsOptions = (() => { + const recentReportsOptions = useMemo(() => { if (autocompleteQueryValue.trim() === '') { return searchOptions.recentReports; } @@ -335,7 +341,7 @@ function SearchAutocompleteList({ } return reportOptions.slice(0, 20); - })(); + }, [autocompleteQueryValue, searchOptions]); const debounceHandleSearch = useDebounce(() => { if (!handleSearch || !autocompleteQueryWithoutFilters) { @@ -349,62 +355,80 @@ function SearchAutocompleteList({ debounceHandleSearch(); }, [autocompleteQueryWithoutFilters, debounceHandleSearch]); - /* Sections generation */ - const sections: Array> = []; - let sectionIndex = 0; + const {sections, normalizedReferenceText, firstRecentReportKey} = useMemo(() => { + const nextSections: Array> = []; + let sectionIndex = 0; - if (searchQueryItem) { - sections.push({data: [searchQueryItem as AutocompleteListItem], sectionIndex: sectionIndex++}); - } + if (searchQueryItem) { + nextSections.push({data: [searchQueryItem as AutocompleteListItem], sectionIndex: sectionIndex++}); + } - const additionalSections = getAdditionalSections?.(searchOptions, sectionIndex); + const additionalSections = getAdditionalSections?.(searchOptions, sectionIndex); - if (additionalSections) { - for (const section of additionalSections) { - sections.push(section); - sectionIndex++; + if (additionalSections) { + for (const section of additionalSections) { + nextSections.push(section); + sectionIndex++; + } } - } - - if (!autocompleteQueryValue && recentSearchesData && recentSearchesData.length > 0) { - sections.push({title: translate('search.recentSearches'), data: recentSearchesData as AutocompleteListItem[], sectionIndex: sectionIndex++}); - } - const styledRecentReports = recentReportsOptions.map((option) => { - const report = getReportOrDraftReport(option.reportID); - const reportAction = getReportAction(report?.parentReportID, report?.parentReportActionID); - const shouldParserToHTML = reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT; - const keyForList = option.keyForList ?? option.reportID ?? (option.accountID ? String(option.accountID) : undefined); - return { - ...option, - keyForList, - pressableStyle: styles.br2, - text: StringUtils.lineBreaksToSpaces(shouldParserToHTML ? Parser.htmlToText(option.text ?? '') : (option.text ?? '')), - wrapperStyle: [styles.pr3, styles.pl3], - } as AutocompleteListItem; - }); - sections.push({title: autocompleteQueryValue.trim() === '' ? translate('search.recentChats') : undefined, data: styledRecentReports, sectionIndex: sectionIndex++}); + if (!autocompleteQueryValue && recentSearchesData && recentSearchesData.length > 0) { + nextSections.push({title: translate('search.recentSearches'), data: recentSearchesData as AutocompleteListItem[], sectionIndex: sectionIndex++}); + } - if (autocompleteSuggestions.length > 0) { - const autocompleteData: AutocompleteListItem[] = autocompleteSuggestions.map(({filterKey, text, autocompleteID, mapKey}) => { + const styledRecentReports = recentReportsOptions.map((option) => { + const report = getReportOrDraftReport(option.reportID); + const reportAction = getReportAction(report?.parentReportID, report?.parentReportActionID); + const shouldParserToHTML = reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT; + const keyForList = option.keyForList ?? option.reportID ?? (option.accountID ? String(option.accountID) : undefined); return { - text: getAutocompleteDisplayText(filterKey, text), - mapKey: mapKey ? getSubstitutionMapKey(mapKey, text) : undefined, - singleIcon: expensifyIcons.MagnifyingGlass, - searchQuery: text, - autocompleteID, - keyForList: autocompleteID ?? text, // in case we have a unique identifier then use it because text might not be unique - searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION, - }; + ...option, + keyForList, + pressableStyle: styles.br2, + text: StringUtils.lineBreaksToSpaces(shouldParserToHTML ? Parser.htmlToText(option.text ?? '') : (option.text ?? '')), + wrapperStyle: [styles.pr3, styles.pl3], + } as AutocompleteListItem; }); - sections.push({title: translate('search.suggestions'), data: autocompleteData, sectionIndex: sectionIndex++}); - } + nextSections.push({title: autocompleteQueryValue.trim() === '' ? translate('search.recentChats') : undefined, data: styledRecentReports, sectionIndex: sectionIndex++}); + + if (autocompleteSuggestions.length > 0) { + const autocompleteData: AutocompleteListItem[] = autocompleteSuggestions.map(({filterKey, text, autocompleteID, mapKey}) => { + return { + text: getAutocompleteDisplayText(filterKey, text), + mapKey: mapKey ? getSubstitutionMapKey(mapKey, text) : undefined, + singleIcon: expensifyIcons.MagnifyingGlass, + searchQuery: text, + autocompleteID, + keyForList: autocompleteID ?? text, + searchItemType: CONST.SEARCH.SEARCH_ROUTER_ITEM_TYPE.AUTOCOMPLETE_SUGGESTION, + }; + }); + + nextSections.push({title: translate('search.suggestions'), data: autocompleteData, sectionIndex: sectionIndex++}); + } - const sectionItemText = sections?.at(1)?.data?.[0]?.text ?? ''; - const normalizedReferenceText = sectionItemText.toLowerCase(); + const sectionItemText = nextSections.at(1)?.data?.[0]?.text ?? ''; - const firstRecentReportKey = styledRecentReports.at(0)?.keyForList; + return { + sections: nextSections, + normalizedReferenceText: sectionItemText.toLowerCase(), + firstRecentReportKey: styledRecentReports.at(0)?.keyForList, + }; + }, [ + searchQueryItem, + getAdditionalSections, + searchOptions, + autocompleteQueryValue, + recentSearchesData, + recentReportsOptions, + styles.br2, + styles.pr3, + styles.pl3, + autocompleteSuggestions, + expensifyIcons.MagnifyingGlass, + translate, + ]); // When options initialize after the list is already mounted, initiallyFocusedItemKey has no effect // because useState(initialFocusedIndex) in useArrowKeyFocusManager only reads the initial value. diff --git a/src/components/Search/SearchFiltersChatsSelector.tsx b/src/components/Search/SearchFiltersChatsSelector.tsx index 34b532b1b58b2..1b1308941f092 100644 --- a/src/components/Search/SearchFiltersChatsSelector.tsx +++ b/src/components/Search/SearchFiltersChatsSelector.tsx @@ -12,7 +12,7 @@ import useReportAttributes from '@hooks/useReportAttributes'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {createOptionFromReport, filterAndOrderOptions, formatSectionsFromSearchTerm, getAlternateText, getSearchOptions} from '@libs/OptionsListUtils'; +import {createOptionFromReport, filterAndOrderOptions, formatSectionsFromSearchTerm, getAlternateText, getEmptyOptions, getSearchOptions} from '@libs/OptionsListUtils'; import type {Option} from '@libs/OptionsListUtils'; import type {OptionWithKey, SelectionListSections} from '@libs/OptionsListUtils/types'; import type {OptionData} from '@libs/ReportUtils'; @@ -23,14 +23,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SearchFilterPageFooterButtons from './SearchFilterPageFooterButtons'; -const defaultListOptions = { - recentReports: [], - personalDetails: [], - userToInvite: null, - currentUserOption: null, - headerMessage: '', -}; - function getSelectedOptionData(option: Option & Pick): OptionData { return {...option, isSelected: true, keyForList: option.keyForList ?? option.reportID}; } @@ -80,7 +72,7 @@ function SearchFiltersChatsSelector({initialReportIDs, onFiltersUpdate, isScreen const defaultOptions = !areOptionsInitialized || !isScreenTransitionEnd - ? defaultListOptions + ? getEmptyOptions() : getSearchOptions({ options, draftComments, diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 9458f6b0cedea..e2ee6a5b27929 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; import UserSelectionListItem from '@components/SelectionList/ListItem/UserSelectionListItem'; @@ -11,63 +11,19 @@ import useReportAttributes from '@hooks/useReportAttributes'; import useScreenWrapperTransitionStatus from '@hooks/useScreenWrapperTransitionStatus'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import memoize from '@libs/memoize'; -import {filterAndOrderOptions, filterSelectedOptions, formatSectionsFromSearchTerm, getFilteredRecentAttendees, getValidOptions} from '@libs/OptionsListUtils'; -import type {Option} from '@libs/OptionsListUtils'; +import {filterAndOrderOptions, formatSectionsFromSearchTerm, getEmptyOptions, getFilteredRecentAttendees, getValidOptions} from '@libs/OptionsListUtils'; +import type {Option, SearchOptionData} from '@libs/OptionsListUtils'; import type {SelectionListSections} from '@libs/OptionsListUtils/types'; -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 = { - userToInvite: null, - recentReports: [], - personalDetails: [], - currentUserOption: null, - headerMessage: '', -}; +import {getOptionSelectionKey, getSelectedOptionData, resolveInitialSelectedOptions} from './SearchFiltersParticipantsSelectorUtils'; const memoizedGetValidOptions = memoize(getValidOptions, {maxSize: 5, monitoringName: 'SearchFiltersParticipantsSelector.getValidOptions'}); -function getSelectedOptionData(option: Option): OptionData { - // eslint-disable-next-line rulesdir/no-default-id-values - const reportID = option.reportID ?? '-1'; - return {...option, selected: true, reportID, keyForList: option.keyForList ?? reportID}; -} - -/** - * 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', - keyForList: `${attendee.accountID ?? attendee.email}`, - 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; @@ -90,7 +46,8 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserEmail = currentUserPersonalDetails.email ?? ''; - const [selectedOptions, setSelectedOptions] = useState([]); + const [interactiveSelectedOptions, setInteractiveSelectedOptions] = useState([]); + const [interactionSignature, setInteractionSignature] = useState(null); const [searchTerm, setSearchTerm] = useState(''); const cleanSearchTerm = useMemo(() => searchTerm.trim().toLowerCase(), [searchTerm]); const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); @@ -107,7 +64,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const defaultOptions = useMemo(() => { if (!areOptionsInitialized) { - return defaultListOptions; + return getEmptyOptions(); } return memoizedGetValidOptions( @@ -148,34 +105,58 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, currentUserEmail, ]); - const unselectedOptions = useMemo(() => { - if (!shouldAllowNameOnlyOptions) { - return filterSelectedOptions(defaultOptions, new Set(selectedOptions.map((option) => option.accountID))); - } + const initialSelectedOptions = useMemo( + () => + resolveInitialSelectedOptions({ + initialAccountIDs, + currentUserOption: defaultOptions.currentUserOption, + recentReports: defaultOptions.recentReports, + personalDetailsOptions: defaultOptions.personalDetails, + userToInvite: shouldAllowNameOnlyOptions ? defaultOptions.userToInvite : null, + personalDetails, + recentAttendees, + shouldAllowNameOnlyOptions, + }), + [ + defaultOptions.currentUserOption, + defaultOptions.personalDetails, + defaultOptions.recentReports, + defaultOptions.userToInvite, + initialAccountIDs, + personalDetails, + recentAttendees, + shouldAllowNameOnlyOptions, + ], + ); + const initialSelectionSignature = useMemo(() => initialAccountIDs.join('|'), [initialAccountIDs]); + const selectedOptions = interactionSignature === initialSelectionSignature ? interactiveSelectedOptions : initialSelectedOptions; - // 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 totalOptionsCount = useMemo( + () => defaultOptions.personalDetails.length + defaultOptions.recentReports.length + (defaultOptions.currentUserOption ? 1 : 0) + (defaultOptions.userToInvite ? 1 : 0), + [defaultOptions.currentUserOption, defaultOptions.personalDetails.length, defaultOptions.recentReports.length, defaultOptions.userToInvite], + ); + const shouldPrioritizeInitialSelection = cleanSearchTerm.length === 0 && initialSelectedOptions.length > 0 && totalOptionsCount > CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD; + const initialSelectedKeys = useMemo(() => initialSelectedOptions.map(getOptionSelectionKey).filter(Boolean), [initialSelectedOptions]); + const initialSelectedKeySet = useMemo(() => new Set(initialSelectedKeys), [initialSelectedKeys]); + const selectedOptionKeySet = useMemo(() => new Set(selectedOptions.map(getOptionSelectionKey).filter(Boolean)), [selectedOptions]); + + const displayedOptions = useMemo(() => { + if (!shouldPrioritizeInitialSelection) { + return defaultOptions; + } - 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; - }; + const isInitiallySelected = (option?: Partial | null) => !!option && initialSelectedKeySet.has(getOptionSelectionKey(option)); return { ...defaultOptions, - personalDetails: defaultOptions.personalDetails.filter((option) => !isSelected(option)), - recentReports: defaultOptions.recentReports.filter((option) => !isSelected(option)), + currentUserOption: isInitiallySelected(defaultOptions.currentUserOption) ? null : defaultOptions.currentUserOption, + personalDetails: defaultOptions.personalDetails.filter((option) => !isInitiallySelected(option)), + recentReports: defaultOptions.recentReports.filter((option) => !isInitiallySelected(option)), }; - }, [defaultOptions, selectedOptions, shouldAllowNameOnlyOptions]); + }, [defaultOptions, initialSelectedKeySet, shouldPrioritizeInitialSelection]); const chatOptions = useMemo(() => { - const filteredOptions = filterAndOrderOptions(unselectedOptions, cleanSearchTerm, countryCode, loginList, currentUserEmail, currentUserAccountID, personalDetails, { + const filteredOptions = filterAndOrderOptions(displayedOptions, cleanSearchTerm, countryCode, loginList, currentUserEmail, currentUserAccountID, personalDetails, { selectedOptions, excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, @@ -184,7 +165,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, searchInputValue: searchTerm, }); - const {currentUserOption} = unselectedOptions; + const {currentUserOption} = displayedOptions; // Ensure current user is not in personalDetails when they should be excluded if (currentUserOption) { @@ -192,36 +173,48 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, } return filteredOptions; - }, [unselectedOptions, cleanSearchTerm, countryCode, loginList, selectedOptions, shouldAllowNameOnlyOptions, searchTerm, currentUserEmail, currentUserAccountID, personalDetails]); + }, [displayedOptions, cleanSearchTerm, countryCode, loginList, selectedOptions, shouldAllowNameOnlyOptions, searchTerm, currentUserEmail, currentUserAccountID, personalDetails]); const {sections, headerMessage} = useMemo(() => { const newSections: SelectionListSections = []; if (!areOptionsInitialized) { return {sections: [], headerMessage: undefined}; } - - const formattedResults = formatSectionsFromSearchTerm( - cleanSearchTerm, - selectedOptions, - chatOptions.recentReports, - chatOptions.personalDetails, - privateIsArchivedMap, - currentUserAccountID, - personalDetails, - true, - undefined, - reportAttributesDerived, - ); - - const selectedCurrentUser = formattedResults.section.data.find((option) => option.accountID === chatOptions.currentUserOption?.accountID); - - // If the current user is already selected, remove them from the recent reports and personal details - if (selectedCurrentUser) { - chatOptions.recentReports = chatOptions.recentReports.filter((report) => report.accountID !== selectedCurrentUser.accountID); - chatOptions.personalDetails = chatOptions.personalDetails.filter((detail) => detail.accountID !== selectedCurrentUser.accountID); + let nextSectionIndex = 0; + + const formattedResults = cleanSearchTerm + ? formatSectionsFromSearchTerm( + cleanSearchTerm, + selectedOptions, + chatOptions.recentReports, + chatOptions.personalDetails, + privateIsArchivedMap, + currentUserAccountID, + personalDetails, + true, + undefined, + reportAttributesDerived, + ) + : undefined; + const selectedSectionData = shouldPrioritizeInitialSelection + ? initialSelectedOptions.map((option) => ({ + ...option, + isSelected: selectedOptionKeySet.has(getOptionSelectionKey(option)), + })) + : (formattedResults?.section.data ?? []); + const selectedSectionKeySet = new Set(selectedSectionData.map(getOptionSelectionKey).filter(Boolean)); + const selectedCurrentUser = !!chatOptions.currentUserOption && selectedSectionKeySet.has(getOptionSelectionKey(chatOptions.currentUserOption)); + + if (selectedSectionData.length > 0) { + newSections.push({ + title: undefined, + sectionIndex: nextSectionIndex, + data: selectedSectionData, + }); + nextSectionIndex += 1; } - // If the current user is not selected, add them to the top of the list + // If the current user is not selected, place them immediately after the selected section when present. if (!selectedCurrentUser && chatOptions.currentUserOption) { const formattedName = getDisplayNameForParticipant({ accountID: chatOptions.currentUserOption.accountID, @@ -229,34 +222,53 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, personalDetailsData: personalDetails, formatPhoneNumber, }); - chatOptions.currentUserOption.text = formattedName; + const isCurrentUserSelected = selectedOptionKeySet.has(getOptionSelectionKey(chatOptions.currentUserOption)); newSections.push({ - data: [chatOptions.currentUserOption], - sectionIndex: 0, + data: [ + { + ...chatOptions.currentUserOption, + text: formattedName, + isSelected: isCurrentUserSelected, + selected: isCurrentUserSelected, + }, + ], + sectionIndex: nextSectionIndex, }); + nextSectionIndex += 1; } - 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, - ); + const filteredRecentReports = chatOptions.recentReports + .filter( + (report) => + !selectedSectionKeySet.has(getOptionSelectionKey(report)) && + (!report.accountID || !chatOptions.currentUserOption?.accountID || report.accountID !== chatOptions.currentUserOption.accountID), + ) + .map((report) => ({ + ...report, + isSelected: selectedOptionKeySet.has(getOptionSelectionKey(report)), + })); newSections.push({ data: filteredRecentReports, - sectionIndex: 1, + sectionIndex: nextSectionIndex, }); + nextSectionIndex += 1; newSections.push({ - data: chatOptions.personalDetails, - sectionIndex: 2, + data: chatOptions.personalDetails + .filter((detail) => !selectedSectionKeySet.has(getOptionSelectionKey(detail))) + .map((detail) => ({ + ...detail, + isSelected: selectedOptionKeySet.has(getOptionSelectionKey(detail)), + })), + sectionIndex: nextSectionIndex, }); - const noResultsFound = chatOptions.personalDetails.length === 0 && chatOptions.recentReports.length === 0 && !chatOptions.currentUserOption; + const noResultsFound = selectedSectionData.length === 0 && chatOptions.personalDetails.length === 0 && chatOptions.recentReports.length === 0 && !chatOptions.currentUserOption; const message = noResultsFound ? translate('common.noResultsFound') : undefined; return { @@ -265,20 +277,24 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, }; }, [ areOptionsInitialized, - cleanSearchTerm, - selectedOptions, chatOptions, + cleanSearchTerm, + currentUserAccountID, + formatPhoneNumber, + initialSelectedOptions, personalDetails, reportAttributesDerived, + selectedOptionKeySet, + selectedOptions, + shouldPrioritizeInitialSelection, translate, - formatPhoneNumber, - currentUserAccountID, privateIsArchivedMap, ]); const resetChanges = useCallback(() => { - setSelectedOptions([]); - }, []); + setInteractionSignature(initialSelectionSignature); + setInteractiveSelectedOptions([]); + }, [initialSelectionSignature]); const applyChanges = useCallback(() => { let selectedIdentifiers: string[]; @@ -304,104 +320,22 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, Navigation.goBack(ROUTES.SEARCH_ADVANCED_FILTERS.getRoute()); }, [onFiltersUpdate, selectedOptions, personalDetails, shouldAllowNameOnlyOptions]); - // This effect handles setting initial selectedOptions based on accountIDs (or displayNames for attendee filter) - useEffect(() => { - if (!initialAccountIDs || initialAccountIDs.length === 0 || !personalDetails) { - return; - } - - 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, 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; - } - - if (selectedOption.reportID && selectedOption.reportID === option?.reportID) { - return true; - } - - return false; - }); + const baseSelectedOptions = interactionSignature === initialSelectionSignature ? interactiveSelectedOptions : initialSelectedOptions; + const selectedOptionKey = getOptionSelectionKey(option); + const foundOptionIndex = baseSelectedOptions.findIndex((selectedOption) => getOptionSelectionKey(selectedOption) === selectedOptionKey); if (foundOptionIndex < 0) { - setSelectedOptions([...selectedOptions, getSelectedOptionData(option)]); + setInteractionSignature(initialSelectionSignature); + setInteractiveSelectedOptions([...baseSelectedOptions, getSelectedOptionData(option)]); } else { - const newSelectedOptions = [...selectedOptions.slice(0, foundOptionIndex), ...selectedOptions.slice(foundOptionIndex + 1)]; - setSelectedOptions(newSelectedOptions); + const newSelectedOptions = [...baseSelectedOptions.slice(0, foundOptionIndex), ...baseSelectedOptions.slice(foundOptionIndex + 1)]; + setInteractionSignature(initialSelectionSignature); + setInteractiveSelectedOptions(newSelectedOptions); } }, - [selectedOptions, shouldAllowNameOnlyOptions], + [initialSelectedOptions, initialSelectionSignature, interactionSignature, interactiveSelectedOptions], ); const footerContent = useMemo( @@ -439,6 +373,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, isLoadingNewOptions={isLoadingNewOptions} shouldShowLoadingPlaceholder={shouldShowLoadingPlaceholder} canSelectMultiple + shouldScrollToTopOnSelect={false} /> ); } diff --git a/src/components/Search/SearchFiltersParticipantsSelectorUtils.ts b/src/components/Search/SearchFiltersParticipantsSelectorUtils.ts new file mode 100644 index 0000000000000..6a52371a4fd66 --- /dev/null +++ b/src/components/Search/SearchFiltersParticipantsSelectorUtils.ts @@ -0,0 +1,274 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import {getParticipantsOption} from '@libs/OptionsListUtils'; +import type {Option, SearchOptionData} from '@libs/OptionsListUtils'; +import CONST from '@src/CONST'; +import type {Attendee, Participant} from '@src/types/onyx/IOU'; +import type {PersonalDetailsList} from '@src/types/onyx/PersonalDetails'; + +type ResolveInitialSelectedOptionsParams = { + initialAccountIDs: string[]; + currentUserOption: Option | null | undefined; + recentReports: Option[]; + personalDetailsOptions: Option[]; + userToInvite: Option | null | undefined; + personalDetails: OnyxEntry; + recentAttendees: Attendee[] | undefined; + shouldAllowNameOnlyOptions: boolean; +}; + +type ResolveInitialSelectedAccountOptionsParams = Omit; + +type SelectedPersonalDetail = { + accountID?: number; + login?: string; + displayName?: string; + avatar?: string | null; +}; + +type SelectionIdentityInput = { + accountID?: number | null; + keyForList?: string | number | null; + login?: string | null; + reportID?: string | null; + text?: string | null; + displayName?: string | null; +}; + +function hasValidAccountID(option?: SelectionIdentityInput) { + return !!option?.accountID && option.accountID !== CONST.DEFAULT_NUMBER_ID; +} + +function getNormalizedLogin(option?: SelectionIdentityInput) { + return option?.login?.trim().toLowerCase() ?? ''; +} + +function getNormalizedText(option?: SelectionIdentityInput) { + return (option?.text ?? option?.displayName)?.trim().toLowerCase() ?? ''; +} + +function getResolvedKeyForList(option: SelectionIdentityInput) { + return ( + option.keyForList?.toString() ?? (hasValidAccountID(option) ? option.accountID?.toString() : undefined) ?? option.reportID ?? option.login ?? option.text ?? option.displayName ?? '' + ); +} + +function toSelectedSearchOption(option: Partial & SelectionIdentityInput): SearchOptionData { + return { + ...option, + accountID: option.accountID ?? undefined, + login: option.login ?? undefined, + // eslint-disable-next-line rulesdir/no-default-id-values -- SearchOptionData requires a structural reportID for unresolved non-report rows. + reportID: option.reportID ?? '', + keyForList: getResolvedKeyForList(option), + selected: true, + isSelected: true, + }; +} + +function getSelectedOptionData(option: Option): SearchOptionData { + return toSelectedSearchOption(option); +} + +function getOptionSelectionKey(option?: SelectionIdentityInput) { + if (!option) { + return ''; + } + + if (hasValidAccountID(option)) { + return `accountID:${option.accountID}`; + } + + if (option.keyForList) { + return `key:${option.keyForList.toString()}`; + } + + const login = getNormalizedLogin(option); + if (login) { + return `login:${login}`; + } + + if (option.reportID) { + return `reportID:${option.reportID}`; + } + + const text = getNormalizedText(option); + if (text) { + return `text:${text}`; + } + + return ''; +} + +function areOptionSelectionsEqual(left: SearchOptionData[], right: SearchOptionData[]) { + if (left.length !== right.length) { + return false; + } + + return left.every((option, index) => getOptionSelectionKey(option) === getOptionSelectionKey(right.at(index))); +} + +function createSelectedOptionFromPersonalDetail(personalDetail: SelectedPersonalDetail, personalDetails: OnyxEntry): SearchOptionData { + const participant: Participant = { + accountID: personalDetail.accountID, + login: personalDetail.login ?? '', + displayName: personalDetail.displayName ?? personalDetail.login ?? '', + text: personalDetail.displayName ?? personalDetail.login ?? '', + selected: true, + isSelected: true, + }; + const participantOption = getParticipantsOption(participant, personalDetails); + + return toSelectedSearchOption({ + ...participantOption, + displayName: personalDetail.displayName ?? participantOption.text ?? personalDetail.login ?? '', + text: participantOption.text ?? personalDetail.displayName ?? personalDetail.login ?? '', + alternateText: participantOption.alternateText ?? personalDetail.login ?? personalDetail.displayName ?? '', + searchText: participantOption.searchText ?? personalDetail.displayName ?? personalDetail.login ?? '', + avatar: personalDetail.avatar ?? undefined, + keyForList: participantOption.keyForList ?? personalDetail.accountID?.toString() ?? personalDetail.login ?? personalDetail.displayName ?? '', + }); +} + +function createSelectedOptionFromAttendee(attendee: Attendee): SearchOptionData { + const login = attendee.email ?? attendee.login ?? attendee.displayName; + const keyForList = login ?? attendee.displayName; + + return toSelectedSearchOption({ + text: attendee.displayName ?? login, + alternateText: login ?? attendee.displayName, + login: login ?? undefined, + displayName: attendee.displayName ?? login, + accountID: attendee.accountID ?? CONST.DEFAULT_NUMBER_ID, + keyForList, + icons: attendee.avatarUrl + ? [ + { + source: attendee.avatarUrl, + type: CONST.ICON_TYPE_AVATAR, + name: attendee.displayName ?? login, + }, + ] + : [], + searchText: attendee.searchText ?? attendee.displayName ?? login ?? '', + }); +} + +function createFallbackSelectedOption(identifier: string): SearchOptionData { + return toSelectedSearchOption({ + text: identifier, + alternateText: identifier, + login: identifier, + displayName: identifier, + accountID: CONST.DEFAULT_NUMBER_ID, + keyForList: identifier, + icons: [], + searchText: identifier, + }); +} + +function createFallbackSelectedAccountOption(identifier: string): SearchOptionData { + const parsedAccountID = Number(identifier); + const accountID = + /^\d+$/.test(identifier) && Number.isSafeInteger(parsedAccountID) && parsedAccountID > 0 && parsedAccountID !== CONST.DEFAULT_NUMBER_ID ? parsedAccountID : CONST.DEFAULT_NUMBER_ID; + + return toSelectedSearchOption({ + text: identifier, + alternateText: identifier, + login: identifier, + displayName: identifier, + accountID, + keyForList: identifier, + icons: [], + searchText: identifier, + }); +} + +function resolveInitialSelectedAccountOption( + identifier: string, + {currentUserOption, recentReports, personalDetailsOptions, userToInvite, personalDetails}: Omit, +): SearchOptionData | null { + const candidateOptions = [currentUserOption, ...recentReports, ...personalDetailsOptions, ...(userToInvite ? [userToInvite] : [])].filter((option): option is Option => !!option); + const matchingOption = candidateOptions.find((option) => hasValidAccountID(option) && option.accountID?.toString() === identifier); + + if (matchingOption) { + return getSelectedOptionData(matchingOption); + } + + const personalDetail = personalDetails?.[identifier] as SelectedPersonalDetail | undefined; + if (personalDetail) { + return createSelectedOptionFromPersonalDetail(personalDetail, personalDetails); + } + + return null; +} + +function resolveInitialSelectedAccountOptions(params: ResolveInitialSelectedAccountOptionsParams): SearchOptionData[] { + return params.initialAccountIDs.map((identifier) => resolveInitialSelectedAccountOption(identifier, params) ?? createFallbackSelectedAccountOption(identifier)); +} + +function matchesIdentifier(option: Option, identifier: string, shouldAllowNameOnlyOptions: boolean) { + if (hasValidAccountID(option) && option.accountID?.toString() === identifier) { + return true; + } + + if (!shouldAllowNameOnlyOptions) { + return false; + } + + return option.keyForList?.toString() === identifier || getNormalizedLogin(option) === identifier.toLowerCase() || getNormalizedText(option) === identifier.toLowerCase(); +} + +function resolveInitialSelectedOptions({ + initialAccountIDs, + currentUserOption, + recentReports, + personalDetailsOptions, + userToInvite, + personalDetails, + recentAttendees, + shouldAllowNameOnlyOptions, +}: ResolveInitialSelectedOptionsParams): SearchOptionData[] { + if (!shouldAllowNameOnlyOptions) { + return resolveInitialSelectedAccountOptions({ + initialAccountIDs, + currentUserOption, + recentReports, + personalDetailsOptions, + userToInvite, + personalDetails, + }); + } + + const candidateOptions = [currentUserOption, ...recentReports, ...personalDetailsOptions, ...(userToInvite ? [userToInvite] : [])].filter((option): option is Option => !!option); + + return initialAccountIDs + .map((identifier) => { + const resolvedAccountOption = resolveInitialSelectedAccountOption(identifier, { + currentUserOption, + recentReports, + personalDetailsOptions, + userToInvite, + personalDetails, + }); + if (resolvedAccountOption) { + return resolvedAccountOption; + } + + const matchingOption = candidateOptions.find((option) => matchesIdentifier(option, identifier, shouldAllowNameOnlyOptions)); + if (matchingOption) { + return getSelectedOptionData(matchingOption); + } + + if (shouldAllowNameOnlyOptions) { + const attendee = recentAttendees?.find((recentAttendee) => recentAttendee.displayName === identifier || recentAttendee.email === identifier); + if (attendee) { + return createSelectedOptionFromAttendee(attendee); + } + } + + return createFallbackSelectedOption(identifier); + }) + .filter((option): option is SearchOptionData => !!option); +} + +export {areOptionSelectionsEqual, getOptionSelectionKey, getSelectedOptionData, resolveInitialSelectedAccountOptions, resolveInitialSelectedOptions}; diff --git a/src/components/Search/SearchMultipleSelectionPicker.tsx b/src/components/Search/SearchMultipleSelectionPicker.tsx index 7c232f345313e..82e927c05fb80 100644 --- a/src/components/Search/SearchMultipleSelectionPicker.tsx +++ b/src/components/Search/SearchMultipleSelectionPicker.tsx @@ -2,10 +2,13 @@ import React, {useState} from 'react'; import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import type {OptionData} from '@libs/ReportUtils'; import {sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils'; +import {reorderItemsByInitialSelection} from '@libs/SelectionListOrderUtils'; +import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import SearchFilterPageFooterButtons from './SearchFilterPageFooterButtons'; @@ -28,37 +31,62 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [selectedItemIDs, setSelectedItemIDs] = useState(() => new Set((initiallySelectedItems ?? []).map((item) => item.value.toString()))); + const initialSelectedValues = useInitialSelectionRef( + (initiallySelectedItems ?? []).map((item) => item.value.toString()), + {resetOnFocus: true}, + ); const searchLower = debouncedSearchTerm.toLowerCase(); - const selectedSectionData: Array<{text: string; keyForList: string; isSelected: boolean; value: string | string[]; leftElement?: React.ReactNode}> = []; - const remainingSectionData: typeof selectedSectionData = []; - for (const item of items) { - if (!item.name.toLowerCase().includes(searchLower)) { - continue; - } - const isSelected = selectedItemIDs.has(item.value.toString()); - (isSelected ? selectedSectionData : remainingSectionData).push({text: item.name, keyForList: item.name, isSelected, value: item.value, leftElement: item.leftElement}); - } - const sortByValue = (a: {value: string | string[]}, b: {value: string | string[]}) => sortOptionsWithEmptyValue(a.value.toString(), b.value.toString(), localeCompare); - selectedSectionData.sort(sortByValue); - remainingSectionData.sort(sortByValue); + const mappedItems: Array<{text: string; keyForList: string; isSelected: boolean; value: string | string[]; leftElement?: React.ReactNode}> = items + .filter((item) => item.name.toLowerCase().includes(searchLower)) + .map((item) => ({ + text: item.name, + keyForList: item.value.toString(), + isSelected: selectedItemIDs.has(item.value.toString()), + value: item.value, + leftElement: item.leftElement, + })) + .sort(sortByValue); - const noResultsFound = !selectedSectionData.length && !remainingSectionData.length; - const sections = noResultsFound - ? [] - : [ - { - title: undefined, - data: selectedSectionData, - sectionIndex: 0, - }, - { - title: pickerTitle, - data: remainingSectionData, - sectionIndex: 1, - }, - ]; + const shouldReorderInitialSelection = !searchLower && initialSelectedValues.length > 0 && mappedItems.length > CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD; + const orderedItems = shouldReorderInitialSelection ? reorderItemsByInitialSelection(mappedItems, initialSelectedValues) : mappedItems; + const initialSelectedSet = new Set(initialSelectedValues); + const initiallySelectedSectionData = orderedItems.filter((item) => initialSelectedSet.has(item.keyForList)); + const remainingSectionData = orderedItems.filter((item) => !initialSelectedSet.has(item.keyForList)); + const noResultsFound = orderedItems.length === 0; + let sections: + | Array<{ + title: string | undefined; + data: typeof orderedItems; + sectionIndex: number; + }> + | [] = []; + + if (!noResultsFound && !shouldReorderInitialSelection) { + sections = [ + { + title: pickerTitle, + data: orderedItems, + sectionIndex: 0, + }, + ]; + } + + if (!noResultsFound && shouldReorderInitialSelection) { + sections = [ + { + title: undefined, + data: initiallySelectedSectionData, + sectionIndex: 0, + }, + { + title: pickerTitle, + data: remainingSectionData, + sectionIndex: 1, + }, + ]; + } const onSelectItem = (item: Partial) => { if (!item.text || !item.keyForList || !item.value) { @@ -108,6 +136,7 @@ function SearchMultipleSelectionPicker({items, initiallySelectedItems, pickerTit resetChanges={resetChanges} /> } + shouldScrollToTopOnSelect={false} /> ); } diff --git a/src/components/Search/SearchPageHeader/MultiSelectFilterPopup.tsx b/src/components/Search/SearchPageHeader/MultiSelectFilterPopup.tsx index 1cf0404cf548a..0a45120a94ed2 100644 --- a/src/components/Search/SearchPageHeader/MultiSelectFilterPopup.tsx +++ b/src/components/Search/SearchPageHeader/MultiSelectFilterPopup.tsx @@ -13,7 +13,7 @@ type MultiSelectFilterPopupProps = PopoverComponentProps & { isSearchable?: boolean; }; -function MultiSelectFilterPopup({closeOverlay, translationKey, items, value, onChangeCallback, isSearchable}: MultiSelectFilterPopupProps) { +function MultiSelectFilterPopup({closeOverlay, translationKey, items, value, onChangeCallback, isSearchable, isVisible}: MultiSelectFilterPopupProps) { const {translate} = useLocalize(); return ( ({closeOverlay, translationKey, closeOverlay={closeOverlay} onChange={onChangeCallback} isSearchable={isSearchable} + isVisible={isVisible} /> ); } diff --git a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx index 73756b66bcadb..c7dccca4f617f 100644 --- a/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx +++ b/src/components/Search/SearchPageHeader/useSearchFiltersBar.tsx @@ -272,17 +272,18 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON, isMobileSelectionModeEn Navigation.navigate(ROUTES.SEARCH_COLUMNS); }; - const typeComponent = ({closeOverlay}: PopoverComponentProps) => ( + const typeComponent = ({closeOverlay, isVisible}: PopoverComponentProps) => ( updateFilterForm({type: item?.value ?? CONST.SEARCH.DATA_TYPES.EXPENSE})} + isVisible={isVisible} /> ); - const groupByComponent = ({closeOverlay}: PopoverComponentProps) => ( + const groupByComponent = ({closeOverlay, isVisible}: PopoverComponentProps) => ( ); - const viewComponent = ({closeOverlay}: PopoverComponentProps) => ( + const viewComponent = ({closeOverlay, isVisible}: PopoverComponentProps) => ( updateFilterForm({view: item?.value ?? CONST.SEARCH.VIEW.TABLE})} + isVisible={isVisible} /> ); - const groupCurrencyComponent = ({closeOverlay}: PopoverComponentProps) => ( + const groupCurrencyComponent = ({closeOverlay, isVisible}: PopoverComponentProps) => ( updateFilterForm({groupCurrency: item?.value})} isSearchable searchPlaceholder={translate('common.groupCurrency')} + isVisible={isVisible} /> ); @@ -331,6 +335,7 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON, isMobileSelectionModeEn items={feedOptions} value={feed} onChangeCallback={updateFeedFilterForm} + isVisible={props.isVisible} /> ); @@ -341,6 +346,7 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON, isMobileSelectionModeEn value={date} translationKey="common.date" updateFilterForm={updateFilterForm} + isVisible={props.isVisible} /> ); @@ -351,6 +357,7 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON, isMobileSelectionModeEn value={posted} translationKey="search.filters.posted" updateFilterForm={updateFilterForm} + isVisible={props.isVisible} /> ); @@ -361,16 +368,18 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON, isMobileSelectionModeEn value={withdrawn} translationKey="search.filters.withdrawn" updateFilterForm={updateFilterForm} + isVisible={props.isVisible} /> ); - const withdrawalTypeComponent = ({closeOverlay}: PopoverComponentProps) => ( + const withdrawalTypeComponent = ({closeOverlay, isVisible}: PopoverComponentProps) => ( updateFilterForm({withdrawalType: item?.value})} + isVisible={isVisible} /> ); @@ -385,6 +394,7 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON, isMobileSelectionModeEn items={statusOptions} value={status} onChangeCallback={updateStatusFilterForm} + isVisible={props.isVisible} /> ); @@ -398,6 +408,7 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON, isMobileSelectionModeEn items={hasOptions} value={has} onChangeCallback={updateHasFilterForm} + isVisible={props.isVisible} /> ); @@ -411,10 +422,11 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON, isMobileSelectionModeEn items={isOptions} value={is} onChangeCallback={updateIsFilterForm} + isVisible={props.isVisible} /> ); - const userPickerComponent = ({closeOverlay}: PopoverComponentProps) => { + const userPickerComponent = ({closeOverlay, isVisible}: PopoverComponentProps) => { const value = searchAdvancedFiltersForm.from ?? []; return ( @@ -422,6 +434,7 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON, isMobileSelectionModeEn value={value} closeOverlay={closeOverlay} onChange={(selectedUsers) => updateFilterForm({from: selectedUsers})} + isVisible={isVisible} /> ); }; @@ -430,7 +443,7 @@ function useSearchFiltersBar(queryJSON: SearchQueryJSON, isMobileSelectionModeEn updateFilterForm({policyID: items.map((item) => item.value)}); }; - const workspaceComponent = ({closeOverlay}: PopoverComponentProps) => ( + const workspaceComponent = ({closeOverlay, isVisible}: PopoverComponentProps) => ( ); diff --git a/src/components/Search/SearchSingleSelectionPicker.tsx b/src/components/Search/SearchSingleSelectionPicker.tsx index 1dcc2cf0a9b30..7abf251988dc0 100644 --- a/src/components/Search/SearchSingleSelectionPicker.tsx +++ b/src/components/Search/SearchSingleSelectionPicker.tsx @@ -1,11 +1,14 @@ -import React, {useEffect, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; import SelectionListWithSections from '@components/SelectionList/SelectionListWithSections'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import type {OptionData} from '@libs/ReportUtils'; import {sortOptionsWithEmptyValue} from '@libs/SearchQueryUtils'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; +import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import SearchFilterPageFooterButtons from './SearchFilterPageFooterButtons'; @@ -35,65 +38,68 @@ function SearchSingleSelectionPicker({ shouldShowTextInput = true, }: SearchSingleSelectionPickerProps) { const {translate, localeCompare} = useLocalize(); - const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [selectedItem, setSelectedItem] = useState(initiallySelectedItem); + const initialSelectedValues = useInitialSelectionRef(initiallySelectedItem ? [initiallySelectedItem.value] : [], {resetOnFocus: true}); useEffect(() => { setSelectedItem(initiallySelectedItem); }, [initiallySelectedItem]); - const initiallySelectedItemSection = initiallySelectedItem?.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase()) - ? [ - { - text: initiallySelectedItem.name, - keyForList: initiallySelectedItem.value, - isSelected: selectedItem?.value === initiallySelectedItem.value, - value: initiallySelectedItem.value, - }, - ] - : []; + const sortedItems = useMemo(() => { + const filteredItems = items.filter((item) => item.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase())); + return filteredItems.sort((a, b) => sortOptionsWithEmptyValue(a.name.toString(), b.name.toString(), localeCompare)); + }, [debouncedSearchTerm, items, localeCompare]); - const remainingItemsSection = items - .filter((item) => item.value !== initiallySelectedItem?.value && item.name.toLowerCase().includes(debouncedSearchTerm?.toLowerCase())) - .sort((a, b) => sortOptionsWithEmptyValue(a.name.toString(), b.name.toString(), localeCompare)) - .map((item) => ({ + const orderedItems = useMemo(() => { + const mappedItems = sortedItems.map((item) => ({ text: item.name, keyForList: item.value, - isSelected: selectedItem?.value === item.value, value: item.value, })); - const noResultsFound = !initiallySelectedItemSection.length && !remainingItemsSection.length; + const shouldReorderInitialSelection = !debouncedSearchTerm && initialSelectedValues.length > 0 && mappedItems.length > CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD; + + return shouldReorderInitialSelection ? moveInitialSelectionToTopByValue(mappedItems, initialSelectedValues) : mappedItems; + }, [debouncedSearchTerm, initialSelectedValues, sortedItems]); + + const listData = useMemo( + () => + orderedItems.map((item) => ({ + ...item, + isSelected: selectedItem?.value === item.value, + })), + [orderedItems, selectedItem?.value], + ); + + const noResultsFound = orderedItems.length === 0 && !!debouncedSearchTerm; const sections = noResultsFound ? [] : [ - { - title: undefined, - data: initiallySelectedItemSection, - sectionIndex: 0, - }, { title: pickerTitle, - data: remainingItemsSection, - sectionIndex: 1, + data: listData, + sectionIndex: 0, }, ]; - const onSelectItem = (item: Partial) => { - if (!item.text || !item.keyForList || !item.value) { - return; - } - if (shouldAutoSave) { - onSaveSelection(item.isSelected ? '' : item.value); - Navigation.goBack(backToRoute ?? ROUTES.SEARCH_ADVANCED_FILTERS.getRoute()); - return; - } - if (!item.isSelected) { - setSelectedItem({name: item.text, value: item.value}); - } - }; + const onSelectItem = useCallback( + (item: Partial) => { + if (!item.text || !item.keyForList || !item.value) { + return; + } + if (shouldAutoSave) { + onSaveSelection(item.isSelected ? '' : item.value); + Navigation.goBack(backToRoute ?? ROUTES.SEARCH_ADVANCED_FILTERS.getRoute()); + return; + } + if (!item.isSelected) { + setSelectedItem({name: item.text, value: item.value}); + } + }, + [backToRoute, onSaveSelection, shouldAutoSave], + ); const resetChanges = () => { setSelectedItem(undefined); diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 9b8e7888b258d..04048791d5349 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -93,6 +93,7 @@ function BaseSelectionList({ shouldUseDefaultRightHandSideCheckmark, shouldDisableHoverStyle = false, setShouldDisableHoverStyle = () => {}, + shouldScrollToTopOnSelect = false, }: SelectionListProps) { const styles = useThemeStyles(); const isFocused = useIsFocused(); @@ -212,6 +213,9 @@ function BaseSelectionList({ return; } if (canSelectMultiple) { + if (shouldScrollToTopOnSelect && !isItemSelected(item)) { + scrollToIndex(0); + } if (shouldShowTextInput && shouldClearInputOnSelect) { textInputOptions?.onChangeText?.(''); } else if (isSmallScreenWidth) { @@ -242,6 +246,9 @@ function BaseSelectionList({ textInputOptions, onCheckboxPress, setFocusedIndex, + shouldScrollToTopOnSelect, + isItemSelected, + scrollToIndex, ], ); @@ -491,7 +498,6 @@ function BaseSelectionList({ useSelectedItemFocusSync({ data, initiallyFocusedItemKey, - isItemSelected, focusedIndex, searchValue: textInputOptions?.value, setFocusedIndex, diff --git a/src/components/SelectionList/ListItem/BaseListItem.tsx b/src/components/SelectionList/ListItem/BaseListItem.tsx index d59a897f9567a..c805dcf260a4d 100644 --- a/src/components/SelectionList/ListItem/BaseListItem.tsx +++ b/src/components/SelectionList/ListItem/BaseListItem.tsx @@ -135,7 +135,7 @@ function BaseListItem({ id={keyForList ?? ''} style={[ pressableStyle, - isFocused && + (!!isFocused || !!item.isSelected) && shouldHighlightSelectedItem && StyleUtils.getItemBackgroundColorStyle(!!item.isSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG), ]} @@ -151,7 +151,7 @@ function BaseListItem({ testID={testID} style={[ wrapperStyle, - isFocused && + (!!isFocused || !!item.isSelected) && shouldHighlightSelectedItem && StyleUtils.getItemBackgroundColorStyle(!!item.isSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG), ]} diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 633c650066fef..991f5279b6a6e 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -71,6 +71,7 @@ function BaseSelectionListWithSections({ shouldDebounceScrolling = false, shouldUpdateFocusedIndex = false, shouldScrollToFocusedIndex = true, + shouldScrollToTopOnSelect = true, shouldSingleExecuteRowSelect = false, shouldPreventDefaultFocusOnSelectRow = false, shouldDisableHoverStyle = false, @@ -158,7 +159,7 @@ function BaseSelectionListWithSections({ return; } if (canSelectMultiple) { - if (sections.length > 1 && !isItemSelected(item)) { + if (shouldScrollToTopOnSelect && sections.length > 1 && !isItemSelected(item)) { scrollToIndex(0); } @@ -249,7 +250,6 @@ function BaseSelectionListWithSections({ useSelectedItemFocusSync({ data: flattenedData, initiallyFocusedItemKey, - isItemSelected, focusedIndex, searchValue: textInputOptions?.value, setFocusedIndex, diff --git a/src/components/SelectionList/SelectionListWithSections/types.ts b/src/components/SelectionList/SelectionListWithSections/types.ts index 62272718b9fbd..025fc76c388c0 100644 --- a/src/components/SelectionList/SelectionListWithSections/types.ts +++ b/src/components/SelectionList/SelectionListWithSections/types.ts @@ -47,6 +47,9 @@ type SelectionListWithSectionsProps = BaseSelectionListP /** Callback to fire when the list layout changes */ onLayout?: (event: LayoutChangeEvent) => void; + /** Whether to scroll the list to the top when selecting a new item in multi-select lists */ + shouldScrollToTopOnSelect?: boolean; + /** Whether product training tooltips can be displayed */ canShowProductTrainingTooltip?: boolean; }; diff --git a/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts b/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts index 235630fc6366c..db1a19a6dcca5 100644 --- a/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts +++ b/src/components/SelectionList/hooks/useSelectedItemFocusSync.ts @@ -1,16 +1,15 @@ import {useEffect, useMemo} from 'react'; import type {ListItem} from '@components/SelectionList/ListItem/types'; -type UseSelectedItemFocusSyncParams = { +type FocusableListItem = Pick; + +type UseSelectedItemFocusSyncParams = { /** Array of items to search in */ data: TData[]; /** Key of the item to focus initially */ initiallyFocusedItemKey: string | null | undefined; - /** Function to check if an item is selected */ - isItemSelected: (item: TData) => boolean; - /** Current focused index */ focusedIndex: number; @@ -22,30 +21,29 @@ type UseSelectedItemFocusSyncParams = { }; /** - * Custom hook that syncs the focused index with the selected item. - * When the selected item changes (and no search is active), updates the focused index. + * Custom hook that syncs the focused index with the item identified by `initiallyFocusedItemKey`. + * When that keyed item moves to a new index (and no search is active), updates the focused index. */ -function useSelectedItemFocusSync({ - data, - initiallyFocusedItemKey, - isItemSelected, - focusedIndex, - searchValue, - setFocusedIndex, -}: UseSelectedItemFocusSyncParams) { - const selectedItemIndex = useMemo(() => (initiallyFocusedItemKey ? data.findIndex(isItemSelected) : -1), [data, initiallyFocusedItemKey, isItemSelected]); +function useSelectedItemFocusSync({data, initiallyFocusedItemKey, focusedIndex, searchValue, setFocusedIndex}: UseSelectedItemFocusSyncParams) { + const focusedItemIndex = useMemo(() => { + if (!initiallyFocusedItemKey) { + return -1; + } + + return data.findIndex((item) => item.keyForList?.toString() === initiallyFocusedItemKey); + }, [data, initiallyFocusedItemKey]); useEffect(() => { - if (selectedItemIndex === -1 || selectedItemIndex === focusedIndex || searchValue) { + if (focusedItemIndex === -1 || focusedItemIndex === focusedIndex || searchValue) { return; } - setFocusedIndex(selectedItemIndex); + setFocusedIndex(focusedItemIndex); - // Only sync focus when selectedItemIndex changes, not when other dependencies update + // Only sync focus when focusedItemIndex changes, not when other dependencies update // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedItemIndex]); + }, [focusedItemIndex]); - return selectedItemIndex; + return focusedItemIndex; } export default useSelectedItemFocusSync; diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index cac9d942260c5..b84d826d666d4 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -103,6 +103,9 @@ type BaseSelectionListProps = { /** Whether to ignore focus events */ shouldIgnoreFocus?: boolean; + /** Whether to scroll to the top when selecting an item in multi-select lists */ + shouldScrollToTopOnSelect?: boolean; + /** Called when the list is scrolled and the user begins dragging */ onScrollBeginDrag?: () => void; diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index 13d9469a1f17d..ffa5dd5816153 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -127,6 +127,7 @@ function BaseSelectionListWithSections({ shouldDebounceScrolling = false, shouldPreventActiveCellVirtualization = false, shouldScrollToFocusedIndex = true, + shouldScrollToTopOnSelect = true, isSmallScreenWidth, onContentSizeChange, listItemTitleStyles, @@ -504,7 +505,7 @@ function BaseSelectionListWithSections({ } // In single-selection lists we don't care about updating the focused index, because the list is closed after selecting an item if (canSelectMultiple) { - if (sections.length > 1 && !isItemSelected(item)) { + if (shouldScrollToTopOnSelect && sections.length > 1 && !isItemSelected(item)) { // If we're selecting an item, scroll to its position at the top, so we can see it scrollToIndex(0, true); } @@ -540,6 +541,7 @@ function BaseSelectionListWithSections({ sections.length, isItemSelected, isSmallScreenWidth, + shouldScrollToTopOnSelect, scrollToIndex, clearInputAfterSelect, onCheckboxPress, diff --git a/src/components/SelectionListWithSections/types.ts b/src/components/SelectionListWithSections/types.ts index d82358af34486..526b2c2fd44e7 100644 --- a/src/components/SelectionListWithSections/types.ts +++ b/src/components/SelectionListWithSections/types.ts @@ -1110,6 +1110,9 @@ type SelectionListProps = Partial & { /** Whether to scroll to the focused index */ shouldScrollToFocusedIndex?: boolean; + /** Whether to scroll to the top when selecting an item in multi-select lists */ + shouldScrollToTopOnSelect?: boolean; + /** Whether the layout is narrow */ isSmallScreenWidth?: boolean; diff --git a/src/components/StatePicker/StateSelectorModal.tsx b/src/components/StatePicker/StateSelectorModal.tsx index d03e8183ca300..73524d2f65229 100644 --- a/src/components/StatePicker/StateSelectorModal.tsx +++ b/src/components/StatePicker/StateSelectorModal.tsx @@ -6,10 +6,12 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; @@ -39,6 +41,7 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const styles = useThemeStyles(); + const initialSelectedValues = useInitialSelectionRef(currentState ? [currentState] : [], {resetDeps: [isVisible]}); const countryStates = useMemo( () => @@ -57,7 +60,20 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, [translate, currentState], ); - const searchResults = searchOptions(debouncedSearchValue, countryStates); + const orderedCountryStates = useMemo(() => { + const shouldReorderInitialSelection = initialSelectedValues.length > 0 && countryStates.length > CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD; + + if (!shouldReorderInitialSelection) { + return countryStates; + } + + return moveInitialSelectionToTopByValue(countryStates, initialSelectedValues); + }, [countryStates, initialSelectedValues]); + + const searchResults = useMemo( + () => searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates), + [countryStates, orderedCountryStates, debouncedSearchValue], + ); const textInputOptions = useMemo( () => ({ diff --git a/src/components/Table/TableFilterButtons/buildFilterItems.tsx b/src/components/Table/TableFilterButtons/buildFilterItems.tsx index 832b93f68d16b..edf327f9d91eb 100644 --- a/src/components/Table/TableFilterButtons/buildFilterItems.tsx +++ b/src/components/Table/TableFilterButtons/buildFilterItems.tsx @@ -130,7 +130,7 @@ type MultiSelectPopoverFactoryProps = { * @returns The multi-select popover component. */ function createMultiSelectPopover({filterKey, filterConfig, currentFilterValue, setFilter}: MultiSelectPopoverFactoryProps) { - return ({closeOverlay}: PopoverComponentProps) => { + return ({closeOverlay, isVisible}: PopoverComponentProps) => { const currentValueArray = Array.isArray(currentFilterValue) ? currentFilterValue : []; const selectedItems = filterConfig.options .filter((option) => currentValueArray.includes(option.value)) @@ -152,6 +152,7 @@ function createMultiSelectPopover({filterKey, const values = items.map((item) => item.value); setFilter(filterKey, values); }} + isVisible={isVisible} /> ); }; @@ -180,7 +181,7 @@ type SingleSelectPopoverFactoryProps = { * @returns The single-select popover component. */ function createSingleSelectPopover({filterKey, filterConfig, currentFilterValue, setFilter}: SingleSelectPopoverFactoryProps) { - return ({closeOverlay}: PopoverComponentProps) => { + return ({closeOverlay, isVisible}: PopoverComponentProps) => { const foundOption = filterConfig.options.find((option) => option.value === currentFilterValue); const selectedItem = foundOption ? { @@ -200,6 +201,7 @@ function createSingleSelectPopover({filterKey value={selectedItem} closeOverlay={closeOverlay} onChange={(item) => setFilter(filterKey, item?.value ?? null)} + isVisible={isVisible} /> ); }; diff --git a/src/components/ValuePicker/ValueSelectionList.tsx b/src/components/ValuePicker/ValueSelectionList.tsx index cde8688085984..4b827fb3f28b2 100644 --- a/src/components/ValuePicker/ValueSelectionList.tsx +++ b/src/components/ValuePicker/ValueSelectionList.tsx @@ -1,6 +1,9 @@ import React, {useMemo} from 'react'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; +import CONST from '@src/CONST'; import type {ValueSelectionListProps} from './types'; function ValueSelectionList({ @@ -11,11 +14,22 @@ function ValueSelectionList({ addBottomSafeAreaPadding = true, disableKeyboardShortcuts = false, alternateNumberOfSupportedLines, + isVisible, }: ValueSelectionListProps) { - const options = useMemo( - () => items.map((item) => ({value: item.value, alternateText: item.description, text: item.label ?? '', isSelected: item === selectedItem, keyForList: item.value ?? ''})), - [items, selectedItem], - ); + const initialSelectedValues = useInitialSelectionRef(selectedItem?.value ? [selectedItem.value] : [], isVisible === undefined ? {resetOnFocus: true} : {resetDeps: [isVisible]}); + const options = useMemo(() => { + const mappedOptions = items.map((item) => ({ + value: item.value ?? '', + alternateText: item.description, + text: item.label ?? '', + isSelected: item.value === selectedItem?.value, + keyForList: item.value ?? '', + })); + + const shouldReorderInitialSelection = initialSelectedValues.length > 0 && mappedOptions.length > CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD; + + return shouldReorderInitialSelection ? moveInitialSelectionToTopByValue(mappedOptions, initialSelectedValues) : mappedOptions; + }, [initialSelectedValues, items, selectedItem?.value]); return ( diff --git a/src/components/ValuePicker/index.tsx b/src/components/ValuePicker/index.tsx index dadc697b09746..c5b9726c71908 100644 --- a/src/components/ValuePicker/index.tsx +++ b/src/components/ValuePicker/index.tsx @@ -74,6 +74,7 @@ function ValuePicker({ ) : ( ; type ValuePickerProps = ForwardedFSClassProps & { diff --git a/src/hooks/useInitialSelectionRef.ts b/src/hooks/useInitialSelectionRef.ts new file mode 100644 index 0000000000000..9fb250739e64a --- /dev/null +++ b/src/hooks/useInitialSelectionRef.ts @@ -0,0 +1,61 @@ +import {useFocusEffect} from '@react-navigation/native'; +import type {DependencyList} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; + +type UseInitialSelectionRefOptions = { + /** Dependencies that should trigger refreshing the snapshot (e.g., when a modal opens) */ + resetDeps?: DependencyList; + /** Whether to refresh the snapshot whenever the screen gains focus */ + resetOnFocus?: boolean; + /** Whether the snapshot should continue following incoming selection changes */ + shouldSyncSelection?: boolean; + /** Equality check used to avoid replacing the snapshot with equivalent values */ + isEqual?: (previousSelection: T, nextSelection: T) => boolean; +}; + +/** + * Keeps an immutable snapshot of the initial selection for the current open/focus cycle. + * Callers can refresh the snapshot by changing `resetDeps` or via screen focus. + */ +function useInitialSelectionRef(selection: T, options: UseInitialSelectionRefOptions = {}) { + const {resetDeps = [], resetOnFocus = false, shouldSyncSelection = false, isEqual = Object.is} = options; + const [initialSelection, setInitialSelection] = useState(selection); + const latestSelectionRef = useRef(selection); + + const updateInitialSelection = useCallback( + (nextSelection: T) => { + setInitialSelection((previousSelection) => (isEqual(previousSelection, nextSelection) ? previousSelection : nextSelection)); + }, + [isEqual], + ); + + useEffect(() => { + latestSelectionRef.current = selection; + }, [selection]); + + useEffect(() => { + updateInitialSelection(selection); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, resetDeps); + + useFocusEffect( + useCallback(() => { + if (!resetOnFocus) { + return; + } + updateInitialSelection(latestSelectionRef.current); + }, [resetOnFocus, updateInitialSelection]), + ); + + useEffect(() => { + if (!shouldSyncSelection) { + return; + } + + updateInitialSelection(selection); + }, [selection, shouldSyncSelection, updateInitialSelection]); + + return initialSelection; +} + +export default useInitialSelectionRef; diff --git a/src/hooks/useMemberInviteSections.ts b/src/hooks/useMemberInviteSections.ts new file mode 100644 index 0000000000000..5c66029b50b17 --- /dev/null +++ b/src/hooks/useMemberInviteSections.ts @@ -0,0 +1,123 @@ +import {useMemo} from 'react'; +import type {Section} from '@components/SelectionList/SelectionListWithSections/types'; +import type {Options} from '@libs/OptionsListUtils/types'; +import type {OptionData} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; + +type BuildMemberInviteSectionsParams = { + searchTerm: string; + searchOptions: Options; + selectedOptions: OptionData[]; + initialSelectedOptions: OptionData[]; + areOptionsInitialized: boolean; + translate: (path: 'common.contacts') => string; +}; + +function areSameMemberInviteOption(left?: Partial | null, right?: Partial | null) { + if (!left || !right) { + return false; + } + + if (left.accountID && left.accountID === right.accountID) { + return true; + } + + if (left.reportID && left.reportID === right.reportID) { + return true; + } + + if (left.login && left.login === right.login) { + return true; + } + + return false; +} + +function buildMemberInviteSections({ + searchTerm, + searchOptions, + selectedOptions, + initialSelectedOptions, + areOptionsInitialized, + translate, +}: BuildMemberInviteSectionsParams): Array> { + if (!areOptionsInitialized) { + return []; + } + + const trimmedSearchTerm = searchTerm.trim(); + const candidateCount = searchOptions.personalDetails.length + (searchOptions.userToInvite ? 1 : 0); + const baseOptions = [...searchOptions.personalDetails, ...(searchOptions.userToInvite ? [searchOptions.userToInvite] : [])]; + const hasInitialSelectionsOutsideBaseResults = initialSelectedOptions.some((option) => !baseOptions.some((baseOption) => areSameMemberInviteOption(baseOption, option))); + const shouldShowInitialSelectionSection = + trimmedSearchTerm.length === 0 && + initialSelectedOptions.length > 0 && + (candidateCount > CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD || hasInitialSelectionsOutsideBaseResults); + const sections: Array> = []; + + if (shouldShowInitialSelectionSection) { + const selectedSectionData = initialSelectedOptions.map((option) => { + const isSelected = selectedOptions.some((selectedOption) => areSameMemberInviteOption(selectedOption, option)); + + return { + ...option, + isSelected, + selected: isSelected, + }; + }); + + if (selectedSectionData.length > 0) { + sections.push({ + title: undefined, + data: selectedSectionData, + sectionIndex: sections.length, + }); + } + } + + const contactsSectionData = shouldShowInitialSelectionSection + ? searchOptions.personalDetails.filter((option) => !initialSelectedOptions.some((initialOption) => areSameMemberInviteOption(option, initialOption))) + : searchOptions.personalDetails; + + if (contactsSectionData.length > 0) { + sections.push({ + title: translate('common.contacts'), + data: contactsSectionData, + sectionIndex: sections.length, + }); + } + + const userToInvite = + shouldShowInitialSelectionSection && searchOptions.userToInvite && initialSelectedOptions.some((option) => areSameMemberInviteOption(searchOptions.userToInvite, option)) + ? null + : searchOptions.userToInvite; + + if (userToInvite) { + sections.push({ + title: undefined, + data: [userToInvite], + sectionIndex: sections.length, + }); + } + + return sections; +} + +function useMemberInviteSections({searchTerm, searchOptions, selectedOptions, initialSelectedOptions, areOptionsInitialized, translate}: BuildMemberInviteSectionsParams) { + return useMemo( + () => + buildMemberInviteSections({ + searchTerm, + searchOptions, + selectedOptions, + initialSelectedOptions, + areOptionsInitialized, + translate, + }), + [areOptionsInitialized, initialSelectedOptions, searchOptions, searchTerm, selectedOptions, translate], + ); +} + +export default useMemberInviteSections; +export {areSameMemberInviteOption, buildMemberInviteSections}; +export type {BuildMemberInviteSectionsParams}; diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector.base.ts index 9d950647dcdd6..cc99d74db1ee9 100644 --- a/src/hooks/useSearchSelector.base.ts +++ b/src/hooks/useSearchSelector.base.ts @@ -5,6 +5,7 @@ import {useOptionsList} from '@components/OptionListContextProvider'; import type {GetOptionsConfig, Options, SearchOption} from '@libs/OptionsListUtils'; import {getEmptyOptions, getSearchOptions, getSearchValueForPhoneOrEmail, getValidOptions} from '@libs/OptionsListUtils'; import type {OptionData} from '@libs/ReportUtils'; +import {moveInitialSelectionToTopByKey} from '@libs/SelectionListOrderUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails} from '@src/types/onyx'; @@ -67,6 +68,15 @@ type UseSearchSelectorConfig = { /** Additional contact options to merge (used by platform-specific implementations) */ contactOptions?: Array>; + + /** Whether to prioritize currently selected options when toggling (defaults to true to preserve existing behavior) */ + prioritizeSelectedOnToggle?: boolean; + + /** Optional snapshot of initially selected option keys to use when prioritization should remain frozen */ + initialSelectedKeys?: string[]; + + /** Custom key extractor for ordering when initialSelectedKeys is provided */ + getKeyForOption?: (option: OptionData) => string; }; type ContactState = { @@ -147,6 +157,9 @@ function useSearchSelectorBase({ shouldInitialize = true, contactOptions, includeCurrentUser = false, + prioritizeSelectedOnToggle = true, + initialSelectedKeys, + getKeyForOption, }: UseSearchSelectorConfig): UseSearchSelectorReturn { const {options: defaultOptions, areOptionsInitialized} = useOptionsList({ shouldInitialize, @@ -191,6 +204,63 @@ function useSearchSelectorBase({ }, [debouncedSearchTerm, countryCode]); const trimmedSearchInput = debouncedSearchTerm.trim(); + const optionKeyExtractor = useCallback( + (option: OptionData) => { + if (getKeyForOption) { + return getKeyForOption(option); + } + return option.login ?? option.reportID ?? option.accountID?.toString() ?? option.text ?? ''; + }, + [getKeyForOption], + ); + const initialSelectedKeysSnapshot = useMemo( + () => + initialSelectedKeys ?? + (initialSelected ?? []).map((option) => { + const key = optionKeyExtractor(option); + return key; + }), + [initialSelectedKeys, initialSelected, optionKeyExtractor], + ); + + const selectedOptionKeys = useMemo(() => selectedOptions.map(optionKeyExtractor), [selectedOptions, optionKeyExtractor]); + + const keysForPrioritization = prioritizeSelectedOnToggle ? selectedOptionKeys : initialSelectedKeysSnapshot; + + const reorderOptions = useCallback( + (optionsList: OptionData[]) => { + if (debouncedSearchTerm || !keysForPrioritization.length || optionsList.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) { + return optionsList; + } + + const optionKeys = optionsList.map(optionKeyExtractor); + const orderedKeys = moveInitialSelectionToTopByKey(optionKeys, keysForPrioritization); + const optionsByKey = new Map(); + + for (const option of optionsList) { + const key = optionKeyExtractor(option); + const bucket = optionsByKey.get(key) ?? []; + bucket.push(option); + optionsByKey.set(key, bucket); + } + + const reordered: OptionData[] = []; + for (const key of orderedKeys) { + const bucket = optionsByKey.get(key); + if (!bucket || bucket.length === 0) { + continue; + } + const nextOption = bucket.shift(); + if (nextOption) { + reordered.push(nextOption); + } + } + + return reordered; + }, + [debouncedSearchTerm, keysForPrioritization, optionKeyExtractor], + ); + const baseOptions = useMemo(() => { if (!areOptionsInitialized) { return getEmptyOptions(); @@ -338,16 +408,20 @@ function useSearchSelectorBase({ }, [selectedOptions]); const searchOptions = useMemo(() => { + const mappedPersonalDetails = baseOptions.personalDetails.map((option) => ({ + ...option, + isSelected: isOptionSelected(option), + })); + + const mappedRecentReports = baseOptions.recentReports.map((option) => ({ + ...option, + isSelected: isOptionSelected(option), + })); + return { ...baseOptions, - personalDetails: baseOptions.personalDetails.map((option) => ({ - ...option, - isSelected: isOptionSelected(option), - })), - recentReports: baseOptions.recentReports.map((option) => ({ - ...option, - isSelected: isOptionSelected(option), - })), + personalDetails: reorderOptions(mappedPersonalDetails), + recentReports: reorderOptions(mappedRecentReports), userToInvite: baseOptions.userToInvite ? { ...baseOptions.userToInvite, @@ -355,7 +429,7 @@ function useSearchSelectorBase({ } : null, }; - }, [baseOptions, isOptionSelected]); + }, [baseOptions, isOptionSelected, reorderOptions]); const availableOptions = useMemo(() => { const unselectedRecentReports = searchOptions.recentReports.filter((option) => !option.isSelected); diff --git a/src/hooks/useWorkspaceList.ts b/src/hooks/useWorkspaceList.ts index fda3ccc62605e..4a78d326a2dc5 100644 --- a/src/hooks/useWorkspaceList.ts +++ b/src/hooks/useWorkspaceList.ts @@ -19,10 +19,25 @@ type UseWorkspaceListParams = { searchTerm: string; localeCompare: LocaleContextProps['localeCompare']; additionalFilter?: (policy: OnyxEntry) => boolean; + /** If false, keep ordering stable when selection changes; use initialSelectedPolicyIDs for one-time ordering */ + prioritizeSelectedOnToggle?: boolean; + /** Initial selections to surface on first render when prioritizeSelectedOnToggle is false */ + initialSelectedPolicyIDs?: string[]; }; -function useWorkspaceList({policies, currentUserLogin, selectedPolicyIDs, searchTerm, shouldShowPendingDeletePolicy, localeCompare, additionalFilter}: UseWorkspaceListParams) { +function useWorkspaceList({ + policies, + currentUserLogin, + selectedPolicyIDs, + searchTerm, + shouldShowPendingDeletePolicy, + localeCompare, + additionalFilter, + prioritizeSelectedOnToggle = true, + initialSelectedPolicyIDs, +}: UseWorkspaceListParams) { const icons = useMemoizedLazyExpensifyIcons(['FallbackWorkspaceAvatar']); + const prioritySelection = prioritizeSelectedOnToggle ? selectedPolicyIDs : (initialSelectedPolicyIDs ?? selectedPolicyIDs); const usersWorkspaces = useMemo(() => { if (!policies || isEmptyObject(policies)) { return []; @@ -57,9 +72,9 @@ function useWorkspaceList({policies, currentUserLogin, selectedPolicyIDs, search const filteredAndSortedUserWorkspaces = useMemo( () => tokenizedSearch(usersWorkspaces, searchTerm, (policy) => [policy.text]).sort((policy1, policy2) => - sortWorkspacesBySelected({policyID: policy1.policyID, name: policy1.text}, {policyID: policy2.policyID, name: policy2.text}, selectedPolicyIDs, localeCompare), + sortWorkspacesBySelected({policyID: policy1.policyID, name: policy1.text}, {policyID: policy2.policyID, name: policy2.text}, prioritySelection, localeCompare), ), - [searchTerm, usersWorkspaces, selectedPolicyIDs, localeCompare], + [searchTerm, usersWorkspaces, prioritySelection, localeCompare], ); const sections = useMemo(() => { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 072269fe167f3..58c2df79e4e96 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1148,7 +1148,11 @@ type SettingsNavigatorParamList = { [SCREENS.TWO_FACTOR_AUTH.DISABLED]: undefined; [SCREENS.TWO_FACTOR_AUTH.DISABLE]: undefined; [SCREENS.SETTINGS.DELEGATE.VERIFY_ACCOUNT]: undefined; - [SCREENS.SETTINGS.DELEGATE.ADD_DELEGATE]: undefined; + [SCREENS.SETTINGS.DELEGATE.ADD_DELEGATE]: + | { + login?: string; + } + | undefined; [SCREENS.SETTINGS.DELEGATE.DELEGATE_ROLE]: { login: string; role?: string; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 2530ec6561898..4394f0e98e942 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -2647,7 +2647,13 @@ function getValidOptions( let userToInvite: SearchOptionData | null = null; if (includeUserToInvite) { userToInvite = filterUserToInvite( - {currentUserOption: currentUserRef.current, recentReports: recentReportOptions, personalDetails: personalDetailsOptions}, + { + currentUserOption: currentUserRef.current, + recentReports: recentReportOptions, + personalDetails: personalDetailsOptions, + workspaceChats, + selfDMChat: selfDMChat ?? null, + }, searchString ?? '', loginList, currentUserEmail, @@ -2668,7 +2674,7 @@ function getValidOptions( currentUserOption: currentUserRef.current, userToInvite, workspaceChats, - selfDMChat, + selfDMChat: selfDMChat ?? null, }; } @@ -3214,6 +3220,8 @@ function filterOptions( recentReports, personalDetails, currentUserOption, + workspaceChats: options.workspaceChats, + selfDMChat: options.selfDMChat ?? null, }, searchValue, loginList, @@ -3236,7 +3244,7 @@ function filterOptions( userToInvite, currentUserOption, workspaceChats, - selfDMChat, + selfDMChat: selfDMChat ?? null, }; } @@ -3257,7 +3265,7 @@ function combineOrderingOfReportsAndPersonalDetails( if (sortByReportTypeInSearch) { const personalDetailsWithoutDMs = filteredPersonalDetailsOfRecentReports(options.recentReports, options.personalDetails); const reportsAndPersonalDetails = options.recentReports.concat(personalDetailsWithoutDMs); - return orderOptions({recentReports: reportsAndPersonalDetails, personalDetails: []}, searchInputValue, orderReportOptionsConfig); + return orderOptions({recentReports: reportsAndPersonalDetails, personalDetails: [], workspaceChats: options.workspaceChats}, searchInputValue, orderReportOptionsConfig); } let orderedReports = orderReportOptionsWithSearch(options.recentReports, searchInputValue, orderReportOptionsConfig); @@ -3271,6 +3279,7 @@ function combineOrderingOfReportsAndPersonalDetails( return { recentReports: orderedReports, personalDetails: orderedPersonalDetails, + workspaceChats: options.workspaceChats, }; } @@ -3337,6 +3346,8 @@ function getEmptyOptions(): Options { personalDetails: [], userToInvite: null, currentUserOption: null, + workspaceChats: [], + selfDMChat: null, }; } diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index b5ef8448b05d5..7535d8421c2b4 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -266,8 +266,8 @@ type Options = { personalDetails: SearchOptionData[]; userToInvite: SearchOptionData | null; currentUserOption: SearchOptionData | null | undefined; - workspaceChats?: SearchOptionData[]; - selfDMChat?: SearchOptionData | undefined; + workspaceChats: SearchOptionData[]; + selfDMChat: SearchOptionData | null; }; type PreviewConfig = { diff --git a/src/libs/SelectionListOrderUtils.ts b/src/libs/SelectionListOrderUtils.ts new file mode 100644 index 0000000000000..dd4d688edde12 --- /dev/null +++ b/src/libs/SelectionListOrderUtils.ts @@ -0,0 +1,105 @@ +import CONST from '@src/CONST'; + +/** + * Reorders a list of keys by moving the initially selected keys to the top while keeping + * the relative ordering from the original list for both selected and remaining items. + */ +function moveInitialSelectionToTopByKey(keys: string[], initialSelectedKeys: string[]): string[] { + if (initialSelectedKeys.length === 0 || keys.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) { + return keys; + } + + const initialSelectionSet = new Set(initialSelectedKeys); + const selected: string[] = []; + const remaining: string[] = []; + + for (const key of keys) { + if (initialSelectionSet.has(key)) { + selected.push(key); + continue; + } + remaining.push(key); + } + + return [...selected, ...remaining]; +} + +/** + * Reorders items that contain a `value` field by moving the initially selected values to the top. + * Preserves the original ordering within the selected and remaining groups. + */ +function moveInitialSelectionToTopByValue(items: T[], initialSelectedValues: string[]): T[] { + if (initialSelectedValues.length === 0 || items.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) { + return items; + } + + const orderedValues = moveInitialSelectionToTopByKey( + items.map((item) => item.value), + initialSelectedValues, + ); + + const itemsByValue = new Map(); + for (const item of items) { + const bucket = itemsByValue.get(item.value) ?? []; + bucket.push(item); + itemsByValue.set(item.value, bucket); + } + + const reordered: T[] = []; + for (const value of orderedValues) { + const bucket = itemsByValue.get(value); + if (!bucket || bucket.length === 0) { + continue; + } + const nextItem = bucket.shift(); + if (nextItem) { + reordered.push(nextItem); + } + } + + return reordered; +} + +/** + * Reorders list items that expose `keyForList` using the provided initial selection keys. + * Keeps the relative ordering within selected and remaining groups. + */ +function reorderItemsByInitialSelection(items: T[], initialSelectedKeys: string[], overallItemCount?: number): T[] { + const itemsWithKey = items.filter((item) => item.keyForList !== undefined); + const itemsWithoutKey = items.filter((item) => item.keyForList === undefined); + + const itemCountForThreshold = overallItemCount ?? itemsWithKey.length; + + if (initialSelectedKeys.length === 0 || itemCountForThreshold <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) { + return items; + } + + const orderedKeys = moveInitialSelectionToTopByKey( + itemsWithKey.map((item) => item.keyForList?.toString() ?? ''), + initialSelectedKeys, + ); + + const itemsByKey = new Map(); + for (const item of itemsWithKey) { + const key = item.keyForList?.toString() ?? ''; + const bucket = itemsByKey.get(key) ?? []; + bucket.push(item); + itemsByKey.set(key, bucket); + } + + const reordered: T[] = []; + for (const value of orderedKeys) { + const bucket = itemsByKey.get(value); + if (!bucket || bucket.length === 0) { + continue; + } + const nextItem = bucket.shift(); + if (nextItem) { + reordered.push(nextItem); + } + } + + return [...reordered, ...itemsWithoutKey]; +} + +export {moveInitialSelectionToTopByKey, moveInitialSelectionToTopByValue, reorderItemsByInitialSelection}; diff --git a/src/libs/TagsOptionsListUtils.ts b/src/libs/TagsOptionsListUtils.ts index b1e93e6837851..374c2376dbf9a 100644 --- a/src/libs/TagsOptionsListUtils.ts +++ b/src/libs/TagsOptionsListUtils.ts @@ -116,12 +116,21 @@ function getTagListSections({ return tagSections; } - if (numberOfTags < CONST.STANDARD_LIST_ITEM_LIMIT) { + const enabledTagNames = new Set(enabledTags.map((tag) => tag.name)); + const selectedTagsOutsideEnabledTags = selectedTagsWithDisabledState.filter((tag) => !enabledTagNames.has(tag.name)); + const totalVisibleTags = enabledTags.length + selectedTagsOutsideEnabledTags.length; + + if (totalVisibleTags <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) { + const enabledTagsWithSelectionState = enabledTags.map((tag) => ({ + ...tag, + isSelected: selectedOptionNames.has(tag.name), + })); + tagSections.push({ - // "All" section when items amount less than the threshold + // Keep the natural sorted order for small lists and only preserve unmatched selected items outside the list. title: '', sectionIndex: 2, - data: getTagsOptions([...selectedTagsWithDisabledState, ...enabledTagsWithoutSelectedOptions], selectedOptions), + data: getTagsOptions([...selectedTagsOutsideEnabledTags, ...enabledTagsWithSelectionState], selectedOptions), }); return tagSections; diff --git a/src/libs/TaxOptionsListUtils.ts b/src/libs/TaxOptionsListUtils.ts index ce313b97a72b1..58d9bdc8f622a 100644 --- a/src/libs/TaxOptionsListUtils.ts +++ b/src/libs/TaxOptionsListUtils.ts @@ -69,12 +69,13 @@ function getTaxRatesSection({ const taxes = transformedTaxRates(policy, transaction); const sortedTaxRates = sortTaxRates(taxes, localeCompare); + const sortedTaxRateNames = new Set(sortedTaxRates.map((taxRate) => taxRate.modifiedName)); const selectedOptionNames = new Set(selectedOptions.map((selectedOption) => selectedOption.modifiedName)); const enabledTaxRates = sortedTaxRates.filter((taxRate) => !taxRate.isDisabled); const enabledTaxRatesNames = new Set(enabledTaxRates.map((tax) => tax.modifiedName)); const enabledTaxRatesWithoutSelectedOptions = enabledTaxRates.filter((tax) => tax.modifiedName && !selectedOptionNames.has(tax.modifiedName)); const selectedTaxRateWithDisabledState: Tax[] = []; - const numberOfTaxRates = enabledTaxRates.length; + const numberOfEnabledTaxRates = enabledTaxRates.length; for (const tax of selectedOptions) { if (enabledTaxRatesNames.has(tax.modifiedName)) { @@ -85,7 +86,7 @@ function getTaxRatesSection({ } // If all tax are disabled but there's a previously selected tag, show only the selected tag - if (numberOfTaxRates === 0 && selectedOptions.length > 0) { + if (numberOfEnabledTaxRates === 0 && selectedOptions.length > 0) { policyRatesSections.push({ // "Selected" section title: '', @@ -112,12 +113,20 @@ function getTaxRatesSection({ return policyRatesSections; } - if (numberOfTaxRates < CONST.STANDARD_LIST_ITEM_LIMIT) { + const selectedTaxRatesOutsideSortedTaxRates = selectedTaxRateWithDisabledState.filter((taxRate) => !taxRate.modifiedName || !sortedTaxRateNames.has(taxRate.modifiedName)); + const totalVisibleTaxRates = sortedTaxRates.length + selectedTaxRatesOutsideSortedTaxRates.length; + + if (totalVisibleTaxRates <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) { + const sortedTaxRatesWithSelectionState = sortedTaxRates.map((taxRate) => ({ + ...taxRate, + isSelected: !!taxRate.modifiedName && selectedOptionNames.has(taxRate.modifiedName), + })); + policyRatesSections.push({ - // "All" section when items amount less than the threshold + // Keep the natural sorted order for small lists and only preserve unmatched selected items outside the list. title: '', sectionIndex: 2, - data: getTaxRatesOptions([...selectedTaxRateWithDisabledState, ...enabledTaxRatesWithoutSelectedOptions]), + data: getTaxRatesOptions([...selectedTaxRatesOutsideSortedTaxRates, ...sortedTaxRatesWithSelectionState]), }); return policyRatesSections; diff --git a/src/libs/__tests__/SelectionListOrderUtils.test.ts b/src/libs/__tests__/SelectionListOrderUtils.test.ts new file mode 100644 index 0000000000000..1055c33598a70 --- /dev/null +++ b/src/libs/__tests__/SelectionListOrderUtils.test.ts @@ -0,0 +1,33 @@ +import {moveInitialSelectionToTopByKey, moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; + +describe('SelectionListOrderUtils', () => { + it('returns the original ordering when under threshold or empty selection', () => { + const keys = ['USD', 'EUR', 'GBP']; + expect(moveInitialSelectionToTopByKey(keys, [])).toEqual(keys); + expect(moveInitialSelectionToTopByKey(keys, ['EUR'])).toEqual(keys); + }); + + it('moves initial selections to the top while preserving relative order', () => { + const keys = ['USD', 'EUR', 'AUD', 'JPY', 'CAD', 'CHF', 'CNY', 'INR', 'BRL']; + const result = moveInitialSelectionToTopByKey(keys, ['AUD', 'USD']); + expect(result).toEqual(['USD', 'AUD', 'EUR', 'JPY', 'CAD', 'CHF', 'CNY', 'INR', 'BRL']); + }); + + it('reorders items by value and keeps duplicates stable', () => { + const items = [ + {value: 'USD', id: 1}, + {value: 'EUR', id: 2}, + {value: 'USD', id: 3}, + {value: 'JPY', id: 4}, + {value: 'EUR', id: 5}, + {value: 'AUD', id: 6}, + {value: 'CAD', id: 7}, + {value: 'CHF', id: 8}, + {value: 'BRL', id: 9}, + ]; + + const result = moveInitialSelectionToTopByValue(items, ['EUR', 'USD']); + + expect(result.map((item) => item.id)).toEqual([2, 5, 1, 3, 4, 6, 7, 8, 9]); + }); +}); diff --git a/src/pages/Debug/ConstantPicker.tsx b/src/pages/Debug/ConstantPicker.tsx index fd39d6928aace..c798315433b49 100644 --- a/src/pages/Debug/ConstantPicker.tsx +++ b/src/pages/Debug/ConstantPicker.tsx @@ -3,8 +3,11 @@ import React, {useMemo, useState} from 'react'; import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; import type {ListItem} from '@components/SelectionList/types'; +import useInitialSelectionRef from '@hooks/useInitialSelectionRef'; import useLocalize from '@hooks/useLocalize'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; +import CONST from '@src/CONST'; import type {DebugForms} from './const'; import {DETAILS_CONSTANT_FIELDS} from './const'; @@ -22,35 +25,46 @@ type ConstantPickerProps = { onSubmit: (item: ListItem) => void; }; +type ConstantPickerItem = ListItem & { + value: string; + searchText: string; +}; + function ConstantPicker({formType, fieldName, fieldValue, onSubmit}: ConstantPickerProps) { const {translate} = useLocalize(); const [searchValue, setSearchValue] = useState(''); - const sections: ListItem[] = useMemo( - () => - Object.entries(DETAILS_CONSTANT_FIELDS[formType as DebugForms].find((field) => field.fieldName === fieldName)?.options ?? {}) - .reduce((acc: Array<[string, string]>, [key, value]) => { - // Option has multiple constants, so we need to flatten these into separate options - if (isObject(value)) { - acc.push(...Object.entries(value)); - return acc; - } - acc.push([key, String(value)]); + const initialSelectedValues = useInitialSelectionRef(fieldValue ? [fieldValue] : [], {resetOnFocus: true}); + const sections: ConstantPickerItem[] = useMemo(() => { + const filteredItems = Object.entries(DETAILS_CONSTANT_FIELDS[formType as DebugForms].find((field) => field.fieldName === fieldName)?.options ?? {}) + .reduce((acc: Array<[string, string]>, [key, value]) => { + // Option has multiple constants, so we need to flatten these into separate options + if (isObject(value)) { + acc.push(...Object.entries(value)); return acc; - }, []) - .map( - ([key, value]) => - ({ - text: value, - keyForList: key, - isSelected: value === fieldValue, - searchText: value, - }) satisfies ListItem, - ) - .filter(({searchText}) => { - return tokenizedSearch([{searchText}], searchValue, (item) => [item.searchText]).length > 0; - }), - [fieldName, fieldValue, formType, searchValue], - ); + } + acc.push([key, String(value)]); + return acc; + }, []) + .map( + ([key, value]) => + ({ + text: value, + keyForList: key, + value, + isSelected: value === fieldValue, + searchText: value, + }) satisfies ConstantPickerItem, + ) + .filter(({searchText}) => { + return tokenizedSearch([{searchText}], searchValue, (item) => [item.searchText]).length > 0; + }); + + if (searchValue || initialSelectedValues.length === 0 || filteredItems.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) { + return filteredItems; + } + + return moveInitialSelectionToTopByValue(filteredItems, initialSelectedValues); + }, [fieldName, fieldValue, formType, initialSelectedValues, searchValue]); const selectedOptionKey = useMemo(() => sections.find((option) => option.searchText === fieldValue)?.keyForList, [sections, fieldValue]); const textInputOptions = useMemo( @@ -76,3 +90,4 @@ function ConstantPicker({formType, fieldName, fieldValue, onSubmit}: ConstantPic ConstantPicker.default = 'ConstantPicker'; export default ConstantPicker; +export {ConstantPicker}; diff --git a/src/pages/Debug/DebugDetailsConstantPickerPage.tsx b/src/pages/Debug/DebugDetailsConstantPickerPage.tsx index a1ce28ebd4b0b..4dd6229b70f96 100644 --- a/src/pages/Debug/DebugDetailsConstantPickerPage.tsx +++ b/src/pages/Debug/DebugDetailsConstantPickerPage.tsx @@ -14,7 +14,7 @@ import {appendParam} from '@libs/Url'; import CONST from '@src/CONST'; import type SCREENS from '@src/SCREENS'; import TRANSACTION_FORM_INPUT_IDS from '@src/types/form/DebugTransactionForm'; -import ConstantPicker from './ConstantPicker'; +import DebugConstantPicker from './ConstantPicker'; import DebugTagPicker from './DebugTagPicker'; type DebugDetailsConstantPickerPageProps = PlatformStackScreenProps; @@ -47,8 +47,10 @@ function DebugDetailsConstantPickerPage({ const renderPicker = useCallback(() => { if (([TRANSACTION_FORM_INPUT_IDS.CURRENCY, TRANSACTION_FORM_INPUT_IDS.MODIFIED_CURRENCY, TRANSACTION_FORM_INPUT_IDS.ORIGINAL_CURRENCY] as string[]).includes(fieldName)) { + const normalizedCurrencyCode = fieldValue?.match(/^[A-Z]{3}/i)?.[0] ?? fieldValue; return ( onSubmit({ text: currencyCode, @@ -80,7 +82,7 @@ function DebugDetailsConstantPickerPage({ } return ( - ) { + return option.keyForList?.toString() ?? option.login ?? option.reportID ?? option.accountID?.toString() ?? option.text ?? ''; +} + function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['reports'] | undefined) { const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); const [selectedOptions, setSelectedOptions] = useState([]); @@ -117,11 +121,9 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor }, ); - const unselectedOptions = filterSelectedOptions(defaultOptions, new Set(selectedOptions.map(({accountID}) => accountID))); - const areOptionsInitialized = !isLoading; - const options = filterAndOrderOptions(unselectedOptions, debouncedSearchTerm, countryCode, loginList, currentUserEmail, currentUserAccountID, allPersonalDetails, { + const options = filterAndOrderOptions(defaultOptions, debouncedSearchTerm, countryCode, loginList, currentUserEmail, currentUserAccountID, allPersonalDetails, { selectedOptions, maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, }); @@ -247,6 +249,7 @@ function NewChatPage({ref}: NewChatPageProps) { const reportAttributesDerived = reportAttributesDerivedFull?.reports; const selectionListRef = useRef(null); + const [hasUserInteracted, setHasUserInteracted] = useState(false); const allPersonalDetails = usePersonalDetails(); const {singleExecution} = useSingleExecution(); @@ -269,55 +272,100 @@ function NewChatPage({ref}: NewChatPageProps) { areOptionsInitialized, } = useOptions(reportAttributesDerived); + useFocusEffect( + useCallback(() => { + setHasUserInteracted(false); + }, []), + ); + + const initialSelectedOptions = useInitialSelectionRef(selectedOptions, {resetOnFocus: true, shouldSyncSelection: !hasUserInteracted}); + + const selectedOptionKeySet = useMemo(() => new Set(selectedOptions.map(getSelectedOptionKey).filter(Boolean)), [selectedOptions]); + const initialSelectedKeySet = useMemo(() => new Set(initialSelectedOptions.map(getSelectedOptionKey).filter(Boolean)), [initialSelectedOptions]); + const totalOptionsCount = recentReports.length + personalDetails.length + (userToInvite ? 1 : 0); + const shouldReorderInitialSelection = debouncedSearchTerm.trim().length === 0 && initialSelectedKeySet.size > 0 && totalOptionsCount > CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD; + const sections: Section[] = []; let firstKeyForList = ''; - const formatResults = formatSectionsFromSearchTerm( - debouncedSearchTerm, - selectedOptions as OptionData[], - recentReports, - personalDetails, - privateIsArchivedMap, - currentUserAccountID, - allPersonalDetails, - undefined, - undefined, - reportAttributesDerived, - ); - // Just a temporary fix to satisfy the type checker - // Will be fixed when migrating to use new SelectionListWithSections - sections.push({...formatResults.section, title: undefined, shouldShow: true}); + let selectedSectionData: Section['data'] = []; - if (!firstKeyForList) { - firstKeyForList = getFirstKeyForList(formatResults.section.data); + if (shouldReorderInitialSelection) { + selectedSectionData = initialSelectedOptions.map((option) => ({ + ...option, + isSelected: selectedOptionKeySet.has(getSelectedOptionKey(option)), + })); + } else if (debouncedSearchTerm.trim()) { + selectedSectionData = formatSectionsFromSearchTerm( + debouncedSearchTerm, + selectedOptions as OptionData[], + recentReports, + personalDetails, + privateIsArchivedMap, + currentUserAccountID, + allPersonalDetails, + undefined, + undefined, + reportAttributesDerived, + ).section.data; + } + + if (selectedSectionData.length > 0) { + sections.push({title: undefined, data: selectedSectionData, shouldShow: true}); + if (!firstKeyForList) { + firstKeyForList = getFirstKeyForList(selectedSectionData); + } } + const visibleRecentReports = recentReports + .filter((option) => !shouldReorderInitialSelection || !initialSelectedKeySet.has(getSelectedOptionKey(option))) + .filter((option) => !selectedOptions.length || !option.isSelfDM) + .map((option) => ({ + ...option, + isSelected: selectedOptionKeySet.has(getSelectedOptionKey(option)), + })); + sections.push({ title: translate('common.recents'), - data: selectedOptions.length ? recentReports.filter((option) => !option.isSelfDM) : recentReports, - shouldShow: !isEmpty(recentReports), + data: visibleRecentReports, + shouldShow: !isEmpty(visibleRecentReports), }); if (!firstKeyForList) { - firstKeyForList = getFirstKeyForList(recentReports); + firstKeyForList = getFirstKeyForList(visibleRecentReports); } + const visiblePersonalDetails = personalDetails + .filter((option) => !shouldReorderInitialSelection || !initialSelectedKeySet.has(getSelectedOptionKey(option))) + .map((option) => ({ + ...option, + isSelected: selectedOptionKeySet.has(getSelectedOptionKey(option)), + })); + sections.push({ title: translate('common.contacts'), - data: personalDetails, - shouldShow: !isEmpty(personalDetails), + data: visiblePersonalDetails, + shouldShow: !isEmpty(visiblePersonalDetails), }); if (!firstKeyForList) { - firstKeyForList = getFirstKeyForList(personalDetails); + firstKeyForList = getFirstKeyForList(visiblePersonalDetails); } - if (userToInvite) { + const visibleUserToInvite = + userToInvite && (!shouldReorderInitialSelection || !initialSelectedKeySet.has(getSelectedOptionKey(userToInvite))) + ? { + ...userToInvite, + isSelected: selectedOptionKeySet.has(getSelectedOptionKey(userToInvite)), + } + : undefined; + + if (visibleUserToInvite) { sections.push({ title: undefined, - data: [userToInvite], + data: [visibleUserToInvite], shouldShow: true, }); if (!firstKeyForList) { - firstKeyForList = getFirstKeyForList([userToInvite]); + firstKeyForList = getFirstKeyForList([visibleUserToInvite]); } } @@ -325,6 +373,7 @@ function NewChatPage({ref}: NewChatPageProps) { * Removes a selected option from list if already selected. If not already selected add this option to the list. */ const toggleOption = (option: ListItem & Partial) => { + setHasUserInteracted(true); const isOptionInList = !!option.isSelected; let newSelectedOptions: SelectedOption[]; @@ -333,7 +382,6 @@ function NewChatPage({ref}: NewChatPageProps) { newSelectedOptions = reject(selectedOptions, (selectedOption) => selectedOption.login === option.login); } else { newSelectedOptions = [...selectedOptions, {...option, isSelected: true, selected: true, reportID: option.reportID, keyForList: `${option.keyForList ?? option.reportID}`}]; - selectionListRef?.current?.scrollToIndex(0, true); } selectionListRef?.current?.clearInputAfterSelect?.(); @@ -406,7 +454,7 @@ function NewChatPage({ref}: NewChatPageProps) { }); }; - const itemRightSideComponent = (item: ListItem & Option, isFocused?: boolean) => { + const itemRightSideComponent = (item: ListItem & Option, itemIsFocused?: boolean) => { if (!!item.isSelfDM || (item.login && excludedGroupEmails.has(item.login)) || !item.login) { return null; } @@ -428,7 +476,7 @@ function NewChatPage({ref}: NewChatPageProps) { ); } - const buttonInnerStyles = isFocused ? styles.buttonDefaultHovered : {}; + const buttonInnerStyles = itemIsFocused ? styles.buttonDefaultHovered : {}; return (