diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f41294669029..229fe7a79e68 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -261,6 +261,7 @@ const CONST = { POPOVER_DROPDOWN_MAX_HEIGHT: 416, POPOVER_MENU_MAX_HEIGHT: 496, POPOVER_MENU_MAX_HEIGHT_MOBILE: 432, + MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD: 8, POPOVER_DATE_WIDTH: 338, POPOVER_DATE_RANGE_WIDTH: 672, POPOVER_DATE_MAX_HEIGHT: 366, diff --git a/src/components/CountryPicker/CountrySelectorModal.tsx b/src/components/CountryPicker/CountrySelectorModal.tsx index 36db43f2e08c..af7e163f0737 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 SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -36,6 +38,8 @@ type CountrySelectorModalProps = { function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onClose, label, onBackdropPress}: CountrySelectorModalProps) { const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const initialSelectedValue = useInitialSelection(currentCountry || undefined, {isVisible}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; const countries = useMemo( () => @@ -51,8 +55,8 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC }), [translate, currentCountry], ); - - const searchResults = searchOptions(debouncedSearchValue, countries); + const orderedCountries = moveInitialSelectionToTop(countries, initialSelectedValues); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const styles = useThemeStyles(); @@ -89,9 +93,10 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC diff --git a/src/components/PushRowWithModal/PushRowModal.tsx b/src/components/PushRowWithModal/PushRowModal.tsx index e8017c553f41..4a93f370e6ff 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 SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import searchOptions from '@libs/searchOptions'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; @@ -44,6 +46,8 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const initialSelectedValue = useInitialSelection(selectedOption || undefined, {isVisible}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; const options = useMemo( () => @@ -57,6 +61,8 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio [optionsList, selectedOption], ); + const orderedOptions = moveInitialSelectionToTop(options, initialSelectedValues); + const handleSelectRow = (option: ListItemType) => { onOptionChange(option.value); onClose(); @@ -67,7 +73,7 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio setSearchValue(''); }; - const searchResults = searchOptions(debouncedSearchValue, options); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? options : orderedOptions); const textInputOptions = useMemo( () => ({ @@ -102,7 +108,8 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio ListItem={SingleSelectListItem} onSelectRow={handleSelectRow} textInputOptions={textInputOptions} - initiallyFocusedItemKey={selectedOption} + searchValueForFocusSync={debouncedSearchValue} + initiallyFocusedItemKey={initialSelectedValue} disableMaintainingScrollPosition shouldShowTooltips={false} showScrollIndicator diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index f9b017b3ee50..ea3a96a89c08 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -43,6 +43,7 @@ function BaseSelectionList({ ref, ListItem, textInputOptions, + searchValueForFocusSync, initiallyFocusedItemKey, onSelectRow, onSelectAll, @@ -230,6 +231,7 @@ function BaseSelectionList({ // Including data.length ensures FlashList resets its layout cache when the list size changes // This prevents "index out of bounds" errors when filtering reduces the list size const extraData = useMemo(() => [data.length], [data.length]); + const syncedSearchValue = searchValueForFocusSync ?? textInputOptions?.value; const selectRow = useCallback( (item: TItem, indexToFocus?: number) => { @@ -529,7 +531,7 @@ function BaseSelectionList({ initiallyFocusedItemKey, isItemSelected, focusedIndex, - searchValue: textInputOptions?.value, + searchValue: syncedSearchValue, setFocusedIndex, }); @@ -538,7 +540,7 @@ function BaseSelectionList({ }, []); useSearchFocusSync({ - searchValue: textInputOptions?.value, + searchValue: syncedSearchValue, data, selectedOptionsCount: dataDetails.selectedOptions.length, isItemSelected, diff --git a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx index 1dc3a1ab49b4..617451820a2d 100644 --- a/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -42,6 +42,7 @@ function BaseSelectionListWithSections({ ref, ListItem, textInputOptions, + searchValueForFocusSync, initiallyFocusedItemKey, confirmButtonOptions, initialScrollIndex, @@ -250,6 +251,7 @@ function BaseSelectionListWithSections({ // Disable `Enter` shortcut if the active element is a button, checkbox, or switch const disableEnterShortcut = activeElementRole && [CONST.ROLE.BUTTON, CONST.ROLE.CHECKBOX, CONST.ROLE.SWITCH].includes(activeElementRole as InteractiveElementRoles); + const syncedSearchValue = searchValueForFocusSync ?? textInputOptions?.value; useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedItem, { captureOnInputs: true, @@ -288,7 +290,7 @@ function BaseSelectionListWithSections({ initiallyFocusedItemKey, isItemSelected, focusedIndex, - searchValue: textInputOptions?.value, + searchValue: syncedSearchValue, setFocusedIndex, }); @@ -297,7 +299,7 @@ function BaseSelectionListWithSections({ }; useSearchFocusSync({ - searchValue: textInputOptions?.value, + searchValue: syncedSearchValue, data: flattenedData, selectedOptionsCount: selectedItems.length, isItemSelected, diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 26bdb3fae0a8..cad10e905d79 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -58,6 +58,9 @@ type BaseSelectionListProps = { /** Configuration options for the text input */ textInputOptions?: TextInputOptions; + /** Search value used for focus synchronization. Defaults to textInputOptions.value */ + searchValueForFocusSync?: string; + /** Whether to show the text input */ shouldShowTextInput?: boolean; diff --git a/src/components/StatePicker/StateSelectorModal.tsx b/src/components/StatePicker/StateSelectorModal.tsx index eaad784b256c..ee7bc9a6202e 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 SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; @@ -39,6 +41,8 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const styles = useThemeStyles(); + const initialSelectedValue = useInitialSelection(currentState || undefined, {isVisible}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; const countryStates = useMemo( () => @@ -57,7 +61,8 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, [translate, currentState], ); - const searchResults = searchOptions(debouncedSearchValue, countryStates); + const orderedCountryStates = moveInitialSelectionToTop(countryStates, initialSelectedValues); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates); const textInputOptions = useMemo( () => ({ @@ -93,7 +98,8 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, ListItem={SingleSelectListItem} onSelectRow={onStateSelected} textInputOptions={textInputOptions} - initiallyFocusedItemKey={currentState} + searchValueForFocusSync={debouncedSearchValue} + initiallyFocusedItemKey={initialSelectedValue} disableMaintainingScrollPosition shouldSingleExecuteRowSelect shouldStopPropagation diff --git a/src/components/ValuePicker/ValueSelectionList.tsx b/src/components/ValuePicker/ValueSelectionList.tsx index 906dbb140eaa..f6979c79d410 100644 --- a/src/components/ValuePicker/ValueSelectionList.tsx +++ b/src/components/ValuePicker/ValueSelectionList.tsx @@ -1,6 +1,8 @@ import React, {useMemo} from 'react'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import useInitialSelection from '@hooks/useInitialSelection'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import type {ValueSelectionListProps} from './types'; function ValueSelectionList({ @@ -11,20 +13,26 @@ 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 initialSelectedValue = useInitialSelection(selectedItem?.value ? selectedItem.value : undefined, isVisible === undefined ? {resetOnFocus: true} : {isVisible}); + + const options = useMemo(() => { + const mappedOptions = items.map((item) => ({value: item.value ?? '', alternateText: item.description, text: item.label ?? '', keyForList: item.value ?? ''})); + const orderedOptions = moveInitialSelectionToTop(mappedOptions, initialSelectedValue ? [initialSelectedValue] : []); + + return orderedOptions.map((item) => ({...item, isSelected: item.value === selectedItem?.value})); + }, [initialSelectedValue, items, selectedItem?.value]); return ( onItemSelected?.(item)} - initiallyFocusedItemKey={selectedItem?.value} + initiallyFocusedItemKey={initialSelectedValue} shouldStopPropagation shouldShowTooltips={shouldShowTooltips} - shouldUpdateFocusedIndex + shouldScrollToFocusedIndex={false} + shouldScrollToFocusedIndexOnMount={false} ListItem={SingleSelectListItem} addBottomSafeAreaPadding={addBottomSafeAreaPadding} disableKeyboardShortcuts={disableKeyboardShortcuts} diff --git a/src/components/ValuePicker/ValueSelectorModal.tsx b/src/components/ValuePicker/ValueSelectorModal.tsx index 4aa1ba794ced..24afba16afe7 100644 --- a/src/components/ValuePicker/ValueSelectorModal.tsx +++ b/src/components/ValuePicker/ValueSelectorModal.tsx @@ -42,6 +42,7 @@ function ValueSelectorModal({ ; +> & { + /** Whether the parent modal is visible */ + isVisible?: boolean; +}; type ValuePickerProps = ForwardedFSClassProps & { /** Item to display */ diff --git a/src/hooks/useInitialSelection.ts b/src/hooks/useInitialSelection.ts new file mode 100644 index 000000000000..d3f85dd36b16 --- /dev/null +++ b/src/hooks/useInitialSelection.ts @@ -0,0 +1,56 @@ +import {useFocusEffect} from '@react-navigation/native'; +import {useCallback, useEffect, useRef, useState} from 'react'; + +type UseInitialSelectionOptions = { + /** Whether the current cycle is visible; refresh the snapshot when it becomes visible */ + isVisible?: boolean; + + /** Whether to refresh the snapshot whenever the screen gains focus */ + resetOnFocus?: boolean; +}; + +/** + * Keeps an immutable snapshot of the initial selection for the current open/focus cycle. + * Callers can refresh the snapshot when a modal becomes visible or via screen focus. + */ +function useInitialSelection(selection: T, options: UseInitialSelectionOptions = {}) { + const {isVisible, resetOnFocus = false} = options; + const [initialSelection, setInitialSelection] = useState(selection); + const latestSelectionRef = useRef(selection); + const previousIsVisibleRef = useRef(isVisible); + + const updateInitialSelection = useCallback((nextSelection: T) => { + setInitialSelection((previousSelection) => (Object.is(previousSelection, nextSelection) ? previousSelection : nextSelection)); + }, []); + + useEffect(() => { + latestSelectionRef.current = selection; + }, [selection]); + + useEffect(() => { + const wasVisible = previousIsVisibleRef.current; + previousIsVisibleRef.current = isVisible; + + if (isVisible === undefined || !isVisible || wasVisible === isVisible) { + return; + } + + // Refresh only when a new visible cycle starts. + // Live selection changes while the picker stays open should not repin or refocus the list. + updateInitialSelection(latestSelectionRef.current); + }, [isVisible, updateInitialSelection]); + + useFocusEffect( + useCallback(() => { + if (!resetOnFocus) { + return; + } + + updateInitialSelection(latestSelectionRef.current); + }, [resetOnFocus, updateInitialSelection]), + ); + + return initialSelection; +} + +export default useInitialSelection; diff --git a/src/libs/SelectionListOrderUtils.ts b/src/libs/SelectionListOrderUtils.ts new file mode 100644 index 000000000000..ec0e2ee261ec --- /dev/null +++ b/src/libs/SelectionListOrderUtils.ts @@ -0,0 +1,24 @@ +import CONST from '@src/CONST'; + +function moveInitialSelectionToTop(items: T[], initialSelectedValues: string[]): T[] { + if (initialSelectedValues.length === 0 || items.length <= CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD) { + return items; + } + + const selectedValues = new Set(initialSelectedValues); + const selected: T[] = []; + const remaining: T[] = []; + + for (const item of items) { + if (selectedValues.has(item.value)) { + selected.push(item); + continue; + } + + remaining.push(item); + } + + return [...selected, ...remaining]; +} + +export default moveInitialSelectionToTop; diff --git a/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx index 3e782b08c969..c6d5153b25ee 100644 --- a/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx @@ -1,15 +1,18 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useMemo} from 'react'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import type {Option} from '@libs/searchOptions'; import searchOptions from '@libs/searchOptions'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import {appendParam} from '@libs/Url'; import CONST from '@src/CONST'; @@ -20,10 +23,12 @@ import type SCREENS from '@src/SCREENS'; type DynamicCountrySelectionPageProps = PlatformStackScreenProps; function DynamicCountrySelectionPage({route}: DynamicCountrySelectionPageProps) { - const [searchValue, setSearchValue] = useState(''); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const {translate} = useLocalize(); const currentCountry = route.params.country; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.ADDRESS_COUNTRY.path); + const initialSelectedValue = useInitialSelection(currentCountry ?? undefined, {resetOnFocus: true}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; const countries = useMemo( () => @@ -40,7 +45,8 @@ function DynamicCountrySelectionPage({route}: DynamicCountrySelectionPageProps) [translate, currentCountry], ); - const searchResults = searchOptions(searchValue, countries); + const orderedCountries = moveInitialSelectionToTop(countries, initialSelectedValues); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); const selectCountry = useCallback( (option: Option) => { @@ -51,12 +57,12 @@ function DynamicCountrySelectionPage({route}: DynamicCountrySelectionPageProps) const textInputOptions = useMemo( () => ({ - headerMessage: searchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', + headerMessage: debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', label: translate('common.country'), value: searchValue, onChangeText: setSearchValue, }), - [searchResults.length, searchValue, translate], + [debouncedSearchValue, searchResults.length, searchValue, translate, setSearchValue], ); return ( @@ -77,7 +83,8 @@ function DynamicCountrySelectionPage({route}: DynamicCountrySelectionPageProps) ListItem={SingleSelectListItem} onSelectRow={selectCountry} textInputOptions={textInputOptions} - initiallyFocusedItemKey={currentCountry} + searchValueForFocusSync={debouncedSearchValue} + initiallyFocusedItemKey={initialSelectedValue} shouldSingleExecuteRowSelect addBottomSafeAreaPadding /> diff --git a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx index c9bf3f233340..261ab7a8e015 100644 --- a/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx +++ b/src/pages/settings/Profile/PersonalDetails/StateSelectionPage.tsx @@ -1,15 +1,18 @@ import {useRoute} from '@react-navigation/native'; import {CONST as COMMON_CONST} from 'expensify-common'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import {appendParam} from '@libs/Url'; import type {Route} from '@src/ROUTES'; @@ -26,10 +29,12 @@ function StateSelectionPage() { const route = useRoute(); const {translate} = useLocalize(); - const [searchValue, setSearchValue] = useState(''); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const params = route.params as RouteParams | undefined; const currentState = params?.state; const label = params?.label; + const initialSelectedValue = useInitialSelection(currentState ?? undefined, {resetOnFocus: true}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; const countryStates = useMemo( () => @@ -48,8 +53,9 @@ function StateSelectionPage() { [translate, currentState], ); - const searchResults = searchOptions(searchValue, countryStates); - const headerMessage = searchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; + const orderedCountryStates = moveInitialSelectionToTop(countryStates, initialSelectedValues); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates); + const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const selectCountryState = useCallback( (option: Option) => { @@ -75,7 +81,7 @@ function StateSelectionPage() { value: searchValue, onChangeText: setSearchValue, }), - [headerMessage, label, searchValue, translate], + [headerMessage, label, searchValue, setSearchValue, translate], ); return ( @@ -106,7 +112,8 @@ function StateSelectionPage() { ListItem={SingleSelectListItem} onSelectRow={selectCountryState} textInputOptions={textInputOptions} - initiallyFocusedItemKey={currentState} + searchValueForFocusSync={debouncedSearchValue} + initiallyFocusedItemKey={initialSelectedValue} shouldSingleExecuteRowSelect disableMaintainingScrollPosition addBottomSafeAreaPadding diff --git a/src/pages/settings/Wallet/CountrySelectionList.tsx b/src/pages/settings/Wallet/CountrySelectionList.tsx index c90581745dc3..cf38e518b339 100644 --- a/src/pages/settings/Wallet/CountrySelectionList.tsx +++ b/src/pages/settings/Wallet/CountrySelectionList.tsx @@ -1,13 +1,16 @@ -import React, {useState} from 'react'; +import React from 'react'; import {View} from 'react-native'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import Text from '@src/components/Text'; import type {TranslationPaths} from '@src/languages/types'; @@ -36,7 +39,9 @@ function CountrySelectionList({isEditing, selectedCountry, countries, onCountryS const {translate} = useLocalize(); const {isOffline} = useNetwork(); const styles = useThemeStyles(); - const [searchValue, setSearchValue] = useState(''); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const initialSelectedValue = useInitialSelection(selectedCountry ?? undefined, {resetOnFocus: true}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; const onSelectionChange = (country: Option) => { onCountrySelected(country.value); @@ -53,13 +58,14 @@ function CountrySelectionList({isEditing, selectedCountry, countries, onCountryS }; }); - const searchResults = searchOptions(searchValue, countriesList); + const orderedCountries = moveInitialSelectionToTop(countriesList, initialSelectedValues); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countriesList : orderedCountries); const textInputOptions = { label: translate('common.search'), value: searchValue, onChangeText: setSearchValue, - headerMessage: searchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', + headerMessage: debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : '', }; const confirmButtonOptions = { @@ -75,16 +81,21 @@ function CountrySelectionList({isEditing, selectedCountry, countries, onCountryS {translate('addPersonalBankAccount.countrySelectionStepHeader')} diff --git a/src/pages/settings/Wallet/PersonalCards/steps/SelectCountryStep.tsx b/src/pages/settings/Wallet/PersonalCards/steps/SelectCountryStep.tsx index 8e4acb767115..cf6c2a32305d 100644 --- a/src/pages/settings/Wallet/PersonalCards/steps/SelectCountryStep.tsx +++ b/src/pages/settings/Wallet/PersonalCards/steps/SelectCountryStep.tsx @@ -10,12 +10,14 @@ import Text from '@components/Text'; import {useCurrencyListState} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {getPlaidCountry} from '@libs/CardUtils'; import searchOptions from '@libs/searchOptions'; import type {Option} from '@libs/searchOptions'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import Navigation from '@navigation/Navigation'; import {clearAddNewPersonalCardFlow, setAddNewPersonalCardStepAndData} from '@userActions/PersonalCards'; @@ -42,6 +44,9 @@ function SelectCountryStep({disableAutoFocus}: {disableAutoFocus?: boolean}) { }; const [currentCountry, setCurrentCountry] = useState(getCountry); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const initialSelectedValue = useInitialSelection(currentCountry || undefined, {resetOnFocus: true}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; const [hasError, setHasError] = useState(false); const isUS = currentCountry === CONST.COUNTRY.US; @@ -88,7 +93,9 @@ function SelectCountryStep({disableAutoFocus}: {disableAutoFocus?: boolean}) { const countries = getCountries(); - const searchResults = searchOptions(debouncedSearchValue, countries); + const orderedCountries = moveInitialSelectionToTop(countries, initialSelectedValues); + const filteredCountries = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); + const searchResults = filteredCountries.map((country) => ({...country, isSelected: currentCountry === country.value})); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; return ( @@ -117,15 +124,18 @@ function SelectCountryStep({disableAutoFocus}: {disableAutoFocus?: boolean}) { onChangeText: setSearchValue, disableAutoFocus, }} + searchValueForFocusSync={debouncedSearchValue} confirmButtonOptions={{ onConfirm: submit, showButton: true, text: translate('common.next'), }} - initiallyFocusedItemKey={currentCountry} + initiallyFocusedItemKey={initialSelectedValue} disableMaintainingScrollPosition shouldSingleExecuteRowSelect shouldUpdateFocusedIndex + shouldScrollToFocusedIndex={false} + shouldScrollToFocusedIndexOnMount={false} addBottomSafeAreaPadding shouldStopPropagation > diff --git a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx index e52890a04f3a..8edabfef124b 100644 --- a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx @@ -10,12 +10,14 @@ import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelec import Text from '@components/Text'; import {useCurrencyListState} from '@hooks/useCurrencyList'; import useDebouncedState from '@hooks/useDebouncedState'; +import useInitialSelection from '@hooks/useInitialSelection'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {getPlaidCountry, isPlaidSupportedCountry} from '@libs/CardUtils'; import searchOptions from '@libs/searchOptions'; +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import Navigation from '@navigation/Navigation'; import type {PlatformStackRouteProp} from '@navigation/PlatformStackNavigation/types'; @@ -43,6 +45,8 @@ function SelectCountryStep({policyID}: CountryStepProps) { const [selectedCountry, setSelectedCountry] = useState(null); const currentCountry = selectedCountry ?? addNewCard?.data?.selectedCountry ?? getPlaidCountry(policy?.outputCurrency, currencyList, countryByIp); + const initialSelectedValue = useInitialSelection(currentCountry || undefined, {resetOnFocus: true}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; const [hasError, setHasError] = useState(false); const doesCountrySupportPlaid = isPlaidSupportedCountry(currentCountry); @@ -85,8 +89,9 @@ function SelectCountryStep({policyID}: CountryStepProps) { searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), }; }); - - const searchResults = searchOptions(debouncedSearchValue, countries); + const orderedCountries = moveInitialSelectionToTop(countries, initialSelectedValues); + const filteredCountries = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); + const searchResults = filteredCountries.map((country) => ({...country, isSelected: currentCountry === country.value})); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const textInputOptions = { @@ -124,11 +129,14 @@ function SelectCountryStep({policyID}: CountryStepProps) { setSelectedCountry(countryOption.value ?? null); }} textInputOptions={textInputOptions} + searchValueForFocusSync={debouncedSearchValue} confirmButtonOptions={confirmButtonOptions} - initiallyFocusedItemKey={currentCountry} + initiallyFocusedItemKey={initialSelectedValue} disableMaintainingScrollPosition shouldSingleExecuteRowSelect shouldUpdateFocusedIndex + shouldScrollToFocusedIndex={false} + shouldScrollToFocusedIndexOnMount={false} addBottomSafeAreaPadding shouldStopPropagation > diff --git a/tests/ui/BankAccountPurposeCountrySelectionTest.tsx b/tests/ui/BankAccountPurposeCountrySelectionTest.tsx new file mode 100644 index 000000000000..c3d9bac2ac65 --- /dev/null +++ b/tests/ui/BankAccountPurposeCountrySelectionTest.tsx @@ -0,0 +1,89 @@ +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import {View} from 'react-native'; +import CountrySelection from '@pages/settings/Wallet/BankAccountPurposePage/substeps/CountrySelection'; +import CountrySelectionList from '@pages/settings/Wallet/CountrySelectionList'; +import {clearReimbursementAccount, clearReimbursementAccountDraft, navigateToBankAccountRoute, updateReimbursementAccountDraft} from '@userActions/ReimbursementAccount'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +jest.mock('@components/FormAlertWithSubmitButton', () => jest.fn(() => null)); +jest.mock('@pages/settings/Wallet/CountrySelectionList', () => jest.fn(() => null)); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); +jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined, jest.fn()])); +jest.mock('@hooks/usePersonalPolicy', () => jest.fn(() => ({outputCurrency: undefined}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn(() => ({ + mt5: {}, + })), +); +jest.mock('@userActions/ReimbursementAccount', () => ({ + clearReimbursementAccount: jest.fn(), + clearReimbursementAccountDraft: jest.fn(), + navigateToBankAccountRoute: jest.fn(), + updateReimbursementAccountDraft: jest.fn(), +})); + +describe('BankAccountPurpose CountrySelection', () => { + const mockedCountrySelectionList = jest.mocked(CountrySelectionList); + const mockedClearReimbursementAccount = jest.mocked(clearReimbursementAccount); + const mockedClearReimbursementAccountDraft = jest.mocked(clearReimbursementAccountDraft); + const mockedNavigateToBankAccountRoute = jest.mocked(navigateToBankAccountRoute); + const mockedUpdateReimbursementAccountDraft = jest.mocked(updateReimbursementAccountDraft); + + let mockMountCount = 0; + let mockUnmountCount = 0; + + beforeEach(() => { + mockMountCount = 0; + mockUnmountCount = 0; + mockedCountrySelectionList.mockClear(); + mockedClearReimbursementAccount.mockClear(); + mockedClearReimbursementAccountDraft.mockClear(); + mockedNavigateToBankAccountRoute.mockClear(); + mockedUpdateReimbursementAccountDraft.mockClear(); + mockedCountrySelectionList.mockImplementation(() => { + React.useEffect(() => { + mockMountCount += 1; + + return () => { + mockUnmountCount += 1; + }; + }, []); + + return ; + }); + }); + + it('keeps the child list mounted while persisting the selected country and navigating', () => { + render(); + + const initialProps = mockedCountrySelectionList.mock.lastCall?.[0]; + expect(mockMountCount).toBe(1); + expect(mockUnmountCount).toBe(0); + + act(() => { + initialProps?.onCountrySelected('LT'); + }); + + const updatedSelectionProps = mockedCountrySelectionList.mock.lastCall?.[0]; + expect(updatedSelectionProps?.selectedCountry).toBe('LT'); + expect(mockMountCount).toBe(1); + expect(mockUnmountCount).toBe(0); + + act(() => { + updatedSelectionProps?.onConfirm(); + }); + + expect(mockedClearReimbursementAccount).toHaveBeenCalled(); + expect(mockedClearReimbursementAccountDraft).toHaveBeenCalled(); + expect(mockedUpdateReimbursementAccountDraft).toHaveBeenCalledWith({country: 'LT', currency: CONST.BBA_COUNTRY_CURRENCY_MAP.LT}); + expect(mockedNavigateToBankAccountRoute).toHaveBeenCalledWith({backTo: ROUTES.SETTINGS_BANK_ACCOUNT_PURPOSE}); + expect(mockMountCount).toBe(1); + expect(mockUnmountCount).toBe(0); + }); +}); diff --git a/tests/ui/CountrySelectionListTest.tsx b/tests/ui/CountrySelectionListTest.tsx new file mode 100644 index 000000000000..59bb755da00c --- /dev/null +++ b/tests/ui/CountrySelectionListTest.tsx @@ -0,0 +1,221 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import {View} from 'react-native'; +import SelectionList from '@components/SelectionList'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import CountrySelectionList from '@pages/settings/Wallet/CountrySelectionList'; +import CONST from '@src/CONST'; + +const mockUseState = React.useState; +const mockAllCountries = CONST.ALL_COUNTRIES; +let mockFocusEffectCallbacks: Array<() => void> = []; +let mockMountCount = 0; +let mockUnmountCount = 0; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn((callback: () => void) => { + mockFocusEffectCallbacks.push(callback); + }), + }; +}); + +jest.mock('@components/BlockingViews/FullPageOfflineBlockingView', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (key.startsWith('allCountries.')) { + const countryISO = key.split('.').at(-1) ?? ''; + return mockAllCountries[countryISO as keyof typeof mockAllCountries] ?? key; + } + + return key; + }, + })), +); +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn(() => ({ + ph5: {}, + textHeadlineLineHeightXXL: {}, + mb6: {}, + mt5: {}, + })), +); +jest.mock('@src/components/Text', () => jest.fn(() => null)); + +describe('CountrySelectionList', () => { + const mockedSelectionList = jest.mocked(SelectionList); + const countries = Object.keys(CONST.ALL_COUNTRIES).slice(0, CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 2); + const initialCountry = countries.at(-1) ?? ''; + const updatedCountry = countries.at(-2) ?? ''; + + beforeEach(() => { + mockFocusEffectCallbacks = []; + mockMountCount = 0; + mockUnmountCount = 0; + mockedSelectionList.mockClear(); + mockedSelectionList.mockImplementation(() => { + React.useEffect(() => { + mockMountCount += 1; + + return () => { + mockUnmountCount += 1; + }; + }, []); + + return ; + }); + }); + + it('pins the saved country to the top on reopen and disables focus-driven scroll', () => { + render( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: initialCountry, + value: initialCountry, + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe(initialCountry); + expect(selectionListProps?.searchValueForFocusSync).toBe(''); + expect(selectionListProps?.shouldUpdateFocusedIndex).toBe(true); + expect(selectionListProps?.shouldScrollToFocusedIndex).toBe(false); + expect(selectionListProps?.shouldScrollToFocusedIndexOnMount).toBe(false); + expect(mockMountCount).toBe(1); + expect(mockUnmountCount).toBe(0); + }); + + it('remounts the inner selection list when the pinned initial selection changes on focus return', () => { + const {rerender} = render( + , + ); + + expect(mockMountCount).toBe(1); + expect(mockUnmountCount).toBe(0); + + rerender( + , + ); + + expect(mockMountCount).toBe(1); + expect(mockUnmountCount).toBe(0); + + act(() => { + for (const callback of mockFocusEffectCallbacks.slice(-1)) { + callback(); + } + }); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: updatedCountry, + value: updatedCountry, + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe(updatedCountry); + expect(mockMountCount).toBe(2); + expect(mockUnmountCount).toBe(1); + }); + + it('keeps the initially pinned country at the top while the live selection changes during the same mount', () => { + const {rerender} = render( + , + ); + + rerender( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: initialCountry, + isSelected: false, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe(initialCountry); + expect(selectionListProps?.data.find((item) => item.keyForList === updatedCountry)).toEqual( + expect.objectContaining({ + keyForList: updatedCountry, + isSelected: true, + }), + ); + }); + + it('keeps natural filtered ordering while search is active', () => { + render( + , + ); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('Uni'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'Uni', + countries.map((countryISO) => ({ + value: countryISO, + keyForList: countryISO, + text: CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES], + isSelected: countryISO === initialCountry, + searchValue: StringUtils.sanitizeString(`${countryISO}${CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES]}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + expect(searchedProps?.searchValueForFocusSync).toBe('Uni'); + }); +}); diff --git a/tests/ui/CountrySelectorModalTest.tsx b/tests/ui/CountrySelectorModalTest.tsx new file mode 100644 index 000000000000..799a5eac7a51 --- /dev/null +++ b/tests/ui/CountrySelectorModalTest.tsx @@ -0,0 +1,111 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import CountrySelectorModal from '@components/CountryPicker/CountrySelectorModal'; +import SelectionList from '@components/SelectionList'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import CONST from '@src/CONST'; + +const mockUseState = React.useState; +const mockAllCountries = CONST.ALL_COUNTRIES; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/Modal', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (key.startsWith('allCountries.')) { + const countryISO = key.split('.').at(-1) ?? ''; + return mockAllCountries[countryISO as keyof typeof mockAllCountries] ?? key; + } + + return key; + }, + })), +); +jest.mock('@hooks/useThemeStyles', () => + jest.fn(() => ({ + pb0: {}, + })), +); + +describe('CountrySelectorModal', () => { + const mockedSelectionList = jest.mocked(SelectionList); + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the saved country to the top on reopen', () => { + render( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'US', + value: 'US', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('US'); + }); + + it('keeps natural filtered ordering while search is active', () => { + render( + , + ); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('Uni'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'Uni', + Object.keys(CONST.ALL_COUNTRIES).map((countryISO) => ({ + value: countryISO, + keyForList: countryISO, + text: CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES], + isSelected: countryISO === 'US', + searchValue: StringUtils.sanitizeString(`${countryISO}${CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES]}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + }); +}); diff --git a/tests/ui/DynamicCountrySelectionPageTest.tsx b/tests/ui/DynamicCountrySelectionPageTest.tsx new file mode 100644 index 000000000000..7ebd267db89e --- /dev/null +++ b/tests/ui/DynamicCountrySelectionPageTest.tsx @@ -0,0 +1,105 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import DynamicCountrySelectionPage from '@pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage'; +import CONST from '@src/CONST'; + +const mockUseState = React.useState; +const mockAllCountries = CONST.ALL_COUNTRIES; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useDynamicBackPath', () => jest.fn(() => 'settings/profile/address')); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (key.startsWith('allCountries.')) { + const countryISO = key.split('.').at(-1) ?? ''; + return mockAllCountries[countryISO as keyof typeof mockAllCountries] ?? key; + } + + return key; + }, + })), +); +jest.mock('@libs/Navigation/Navigation', () => ({ + goBack: jest.fn(), +})); + +describe('DynamicCountrySelectionPage', () => { + const mockedSelectionList = jest.mocked(SelectionList); + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the saved country to the top on reopen and wires debounced focus sync', () => { + render( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'US', + value: 'US', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('US'); + expect(selectionListProps?.searchValueForFocusSync).toBe(''); + }); + + it('keeps natural filtered ordering while search is active', () => { + render( + , + ); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('Uni'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'Uni', + Object.keys(CONST.ALL_COUNTRIES).map((countryISO) => ({ + value: countryISO, + keyForList: countryISO, + text: CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES], + isSelected: countryISO === 'US', + searchValue: StringUtils.sanitizeString(`${countryISO}${CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES]}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + expect(searchedProps?.searchValueForFocusSync).toBe('Uni'); + }); +}); diff --git a/tests/ui/PushRowModalTest.tsx b/tests/ui/PushRowModalTest.tsx new file mode 100644 index 000000000000..30a39cccf0fb --- /dev/null +++ b/tests/ui/PushRowModalTest.tsx @@ -0,0 +1,112 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import PushRowModal from '@src/components/PushRowWithModal/PushRowModal'; + +const mockUseState = React.useState; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/Modal', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); + +describe('PushRowModal', () => { + const mockedSelectionList = jest.mocked(SelectionList); + const optionsList = { + one: 'Option 1', + two: 'Option 2', + three: 'Option 3', + four: 'Option 4', + five: 'Option 5', + six: 'Option 6', + seven: 'Option 7', + eight: 'Option 8', + nine: 'Option 9', + ten: 'Option 10', + }; + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the saved option to the top on reopen', () => { + render( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'ten', + value: 'ten', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('ten'); + }); + + it('keeps natural filtered ordering while search is active', () => { + render( + , + ); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('Option 1'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'Option 1', + Object.entries(optionsList).map(([key, value]) => ({ + value: key, + keyForList: key, + text: value, + isSelected: key === 'ten', + searchValue: StringUtils.sanitizeString(value), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + }); +}); diff --git a/tests/ui/SelectCountryStepTest.tsx b/tests/ui/SelectCountryStepTest.tsx new file mode 100644 index 000000000000..d5b797469c7e --- /dev/null +++ b/tests/ui/SelectCountryStepTest.tsx @@ -0,0 +1,181 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import useOnyx from '@hooks/useOnyx'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import SelectCountryStep from '@pages/workspace/companyCards/addNew/SelectCountryStep'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +const mockUseState = React.useState; +const mockAllCountries = CONST.ALL_COUNTRIES; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + useIsFocused: jest.fn(() => true), + useRoute: jest.fn(() => ({params: {backTo: ''}})), + }; +}); + +jest.mock('@components/FormHelpMessage', () => jest.fn(() => null)); +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); +jest.mock('@components/Text', () => jest.fn(() => null)); +jest.mock('@hooks/useCurrencyList', () => ({ + useCurrencyListState: jest.fn(() => ({ + currencyList: {}, + })), +})); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (key.startsWith('allCountries.')) { + const countryISO = key.split('.').at(-1) ?? ''; + return mockAllCountries[countryISO as keyof typeof mockAllCountries] ?? key; + } + + return key; + }, + })), +); +jest.mock('@hooks/useOnyx', () => jest.fn()); +jest.mock('@hooks/usePolicy', () => jest.fn(() => ({outputCurrency: 'USD'}))); +jest.mock('@hooks/useThemeStyles', () => + jest.fn(() => ({ + textHeadlineLineHeightXXL: {}, + ph5: {}, + mv3: {}, + ph3: {}, + mb3: {}, + })), +); +jest.mock('@libs/CardUtils', () => ({ + getPlaidCountry: jest.fn(() => 'US'), + isPlaidSupportedCountry: jest.fn(() => true), +})); +jest.mock('@navigation/Navigation', () => ({ + goBack: jest.fn(), + navigate: jest.fn(), +})); +jest.mock('@userActions/CompanyCards', () => ({ + clearAddNewCardFlow: jest.fn(), + setAddNewCompanyCardStepAndData: jest.fn(), +})); + +describe('SelectCountryStep', () => { + const mockedSelectionList = jest.mocked(SelectionList); + const mockedUseOnyx = jest.mocked(useOnyx); + + let addNewCardCountry: string | undefined; + + beforeEach(() => { + addNewCardCountry = undefined; + mockedSelectionList.mockClear(); + mockedUseOnyx.mockImplementation((key) => { + if (key === ONYXKEYS.COUNTRY) { + return ['US', jest.fn()] as never; + } + + if (key === ONYXKEYS.ADD_NEW_COMPANY_CARD) { + return [{data: {selectedCountry: addNewCardCountry}}, jest.fn()] as never; + } + + return [undefined, jest.fn()] as never; + }); + }); + + it('pins the saved country to the top on reopen and disables focus-driven scroll', () => { + addNewCardCountry = 'US'; + + render(); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'US', + value: 'US', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('US'); + expect(selectionListProps?.shouldScrollToFocusedIndex).toBe(false); + expect(selectionListProps?.shouldScrollToFocusedIndexOnMount).toBe(false); + expect(selectionListProps?.shouldUpdateFocusedIndex).toBe(true); + }); + + it('keeps the initially pinned country at the top while the live selection changes during the same mount', () => { + addNewCardCountry = 'US'; + + render(); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + const selectedCountry = initialProps?.data.find((item) => item.keyForList === 'GB'); + + expect(selectedCountry).toBeDefined(); + + act(() => { + if (!selectedCountry) { + return; + } + + initialProps?.onSelectRow?.(selectedCountry); + }); + + const updatedProps = mockedSelectionList.mock.lastCall?.[0]; + expect(updatedProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'US', + isSelected: false, + }), + ); + expect(updatedProps?.initiallyFocusedItemKey).toBe('US'); + expect(updatedProps?.data.find((item) => item.keyForList === 'GB')).toEqual( + expect.objectContaining({ + keyForList: 'GB', + isSelected: true, + }), + ); + }); + + it('keeps natural filtered ordering while search is active', () => { + addNewCardCountry = 'US'; + + render(); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('Uni'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'Uni', + Object.keys(CONST.ALL_COUNTRIES) + .filter((countryISO) => !CONST.PLAID_EXCLUDED_COUNTRIES.includes(countryISO)) + .map((countryISO) => ({ + value: countryISO, + keyForList: countryISO, + text: CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES], + isSelected: false, + searchValue: StringUtils.sanitizeString(`${countryISO}${CONST.ALL_COUNTRIES[countryISO as keyof typeof CONST.ALL_COUNTRIES]}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + }); +}); diff --git a/tests/ui/StateSelectionPageTest.tsx b/tests/ui/StateSelectionPageTest.tsx new file mode 100644 index 000000000000..1f9c92a49c5d --- /dev/null +++ b/tests/ui/StateSelectionPageTest.tsx @@ -0,0 +1,101 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import {CONST as COMMON_CONST} from 'expensify-common'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import StateSelectionPage from '@pages/settings/Profile/PersonalDetails/StateSelectionPage'; + +const mockUseState = React.useState; +const mockStates = COMMON_CONST.STATES; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + useRoute: jest.fn(() => ({params: {state: 'NY', label: 'State', backTo: ''}})), + }; +}); + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (!key.startsWith('allStates.')) { + return key; + } + + const [, stateKey, property] = key.split('.'); + const state = mockStates[stateKey as keyof typeof mockStates]; + + if (property === 'stateName') { + return state.stateName; + } + + return state.stateISO; + }, + })), +); +jest.mock('@libs/Navigation/Navigation', () => ({ + goBack: jest.fn(), +})); + +describe('StateSelectionPage', () => { + const mockedSelectionList = jest.mocked(SelectionList); + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the saved state to the top on reopen and wires debounced focus sync', () => { + render(); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'NY', + value: 'NY', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('NY'); + expect(selectionListProps?.searchValueForFocusSync).toBe(''); + }); + + it('keeps natural filtered ordering while search is active', () => { + render(); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('New'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'New', + Object.keys(mockStates).map((state) => ({ + value: mockStates[state as keyof typeof mockStates].stateISO, + keyForList: mockStates[state as keyof typeof mockStates].stateISO, + text: mockStates[state as keyof typeof mockStates].stateName, + isSelected: mockStates[state as keyof typeof mockStates].stateISO === 'NY', + searchValue: StringUtils.sanitizeString(`${mockStates[state as keyof typeof mockStates].stateISO}${mockStates[state as keyof typeof mockStates].stateName}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + expect(searchedProps?.searchValueForFocusSync).toBe('New'); + }); +}); diff --git a/tests/ui/StateSelectorModalTest.tsx b/tests/ui/StateSelectorModalTest.tsx new file mode 100644 index 000000000000..3e484d67b8fa --- /dev/null +++ b/tests/ui/StateSelectorModalTest.tsx @@ -0,0 +1,117 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {act, render} from '@testing-library/react-native'; +import {CONST as COMMON_CONST} from 'expensify-common'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import StateSelectorModal from '@components/StatePicker/StateSelectorModal'; +import searchOptions from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; + +const mockUseState = React.useState; +const mockStates = COMMON_CONST.STATES; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/Modal', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); +jest.mock('@hooks/useDebouncedState', () => + jest.fn((initialValue: string) => { + const [value, setValue] = mockUseState(initialValue); + return [value, value, setValue]; + }), +); +jest.mock('@hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => { + if (!key.startsWith('allStates.')) { + return key; + } + + const [, stateKey, property] = key.split('.'); + const state = mockStates[stateKey as keyof typeof mockStates]; + + if (property === 'stateName') { + return state.stateName; + } + + return state.stateISO; + }, + })), +); +jest.mock('@hooks/useThemeStyles', () => + jest.fn(() => ({ + pb0: {}, + })), +); + +describe('StateSelectorModal', () => { + const mockedSelectionList = jest.mocked(SelectionList); + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the saved state to the top on reopen', () => { + render( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: 'NY', + value: 'NY', + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe('NY'); + }); + + it('keeps natural filtered ordering while search is active', () => { + render( + , + ); + + const initialProps = mockedSelectionList.mock.lastCall?.[0]; + + act(() => { + initialProps?.textInputOptions?.onChangeText?.('New'); + }); + + const searchedProps = mockedSelectionList.mock.lastCall?.[0]; + const expectedSearchResults = searchOptions( + 'New', + Object.keys(mockStates).map((state) => ({ + value: mockStates[state as keyof typeof mockStates].stateISO, + keyForList: mockStates[state as keyof typeof mockStates].stateISO, + text: mockStates[state as keyof typeof mockStates].stateName, + isSelected: mockStates[state as keyof typeof mockStates].stateISO === 'NY', + searchValue: StringUtils.sanitizeString(`${mockStates[state as keyof typeof mockStates].stateISO}${mockStates[state as keyof typeof mockStates].stateName}`), + })), + ); + + expect(searchedProps?.data.map((item) => item.keyForList)).toEqual(expectedSearchResults.map((item) => item.keyForList)); + }); +}); diff --git a/tests/ui/ValueSelectionListTest.tsx b/tests/ui/ValueSelectionListTest.tsx new file mode 100644 index 000000000000..4114ba4d9947 --- /dev/null +++ b/tests/ui/ValueSelectionListTest.tsx @@ -0,0 +1,86 @@ +import type * as ReactNavigation from '@react-navigation/native'; +import {render} from '@testing-library/react-native'; +import React from 'react'; +import SelectionList from '@components/SelectionList'; +import ValueSelectionList from '@components/ValuePicker/ValueSelectionList'; +import CONST from '@src/CONST'; + +jest.mock('@react-navigation/native', () => { + const actualNavigation: typeof ReactNavigation = jest.requireActual('@react-navigation/native'); + + return { + ...actualNavigation, + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/SingleSelectListItem', () => jest.fn(() => null)); + +describe('ValueSelectionList', () => { + const mockedSelectionList = jest.mocked(SelectionList); + const items = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 2}, (_, index) => ({ + value: `value-${index}`, + label: `Label ${index}`, + description: `Description ${index}`, + })); + + beforeEach(() => { + mockedSelectionList.mockClear(); + }); + + it('pins the initial value to the top and disables focus-driven scroll', () => { + render( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: items.at(-1)?.value, + isSelected: true, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe(items.at(-1)?.value); + expect(selectionListProps?.shouldUpdateFocusedIndex).toBeUndefined(); + expect(selectionListProps?.shouldScrollToFocusedIndex).toBe(false); + expect(selectionListProps?.shouldScrollToFocusedIndexOnMount).toBe(false); + }); + + it('keeps the initially pinned value at the top while the live selection changes during the same mount', () => { + const {rerender} = render( + , + ); + + rerender( + , + ); + + const selectionListProps = mockedSelectionList.mock.lastCall?.[0]; + expect(selectionListProps?.data.at(0)).toEqual( + expect.objectContaining({ + keyForList: items.at(-1)?.value, + isSelected: false, + }), + ); + expect(selectionListProps?.initiallyFocusedItemKey).toBe(items.at(-1)?.value); + expect(selectionListProps?.data.find((item) => item.keyForList === items.at(-2)?.value)).toEqual( + expect.objectContaining({ + keyForList: items.at(-2)?.value, + isSelected: true, + }), + ); + }); +}); diff --git a/tests/ui/ValueSelectorModalTest.tsx b/tests/ui/ValueSelectorModalTest.tsx new file mode 100644 index 000000000000..77c71176f8bf --- /dev/null +++ b/tests/ui/ValueSelectorModalTest.tsx @@ -0,0 +1,35 @@ +import {render} from '@testing-library/react-native'; +import React from 'react'; +import ValueSelectionList from '@components/ValuePicker/ValueSelectionList'; +import ValueSelectorModal from '@components/ValuePicker/ValueSelectorModal'; + +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/Modal', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/ScreenWrapper', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/ValuePicker/ValueSelectionList', () => jest.fn(() => null)); + +describe('ValueSelectorModal', () => { + const mockedValueSelectionList = jest.mocked(ValueSelectionList); + + beforeEach(() => { + mockedValueSelectionList.mockClear(); + }); + + it('forwards modal visibility to ValueSelectionList', () => { + render( + , + ); + + expect(mockedValueSelectionList.mock.lastCall?.[0]).toEqual(expect.objectContaining({isVisible: true})); + }); +}); diff --git a/tests/unit/SelectionListOrderUtilsTest.ts b/tests/unit/SelectionListOrderUtilsTest.ts new file mode 100644 index 000000000000..4f926634e9d9 --- /dev/null +++ b/tests/unit/SelectionListOrderUtilsTest.ts @@ -0,0 +1,25 @@ +import moveInitialSelectionToTop from '@libs/SelectionListOrderUtils'; +import CONST from '@src/CONST'; + +describe('SelectionListOrderUtils', () => { + it('does not reorder values when the list is under the global threshold', () => { + const items = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD}, (_, index) => ({ + value: `item-${index}`, + keyForList: `item-${index}`, + })); + + expect(moveInitialSelectionToTop(items, ['item-3'])).toEqual(items); + }); + + it('moves the initially selected values to the top while preserving source order', () => { + const items = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 2}, (_, index) => ({ + value: `item-${index}`, + keyForList: `item-${index}`, + })); + const selectedValues = [`item-${CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD}`, `item-${CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 1}`]; + + const reorderedItems = moveInitialSelectionToTop(items, selectedValues); + + expect(reorderedItems.map((item) => item.value)).toEqual([...selectedValues, ...items.filter((item) => !selectedValues.includes(item.value)).map((item) => item.value)]); + }); +}); diff --git a/tests/unit/components/SelectionList/useSearchFocusSync.test.ts b/tests/unit/components/SelectionList/useSearchFocusSync.test.ts new file mode 100644 index 000000000000..b40b53e8ce2b --- /dev/null +++ b/tests/unit/components/SelectionList/useSearchFocusSync.test.ts @@ -0,0 +1,44 @@ +import {renderHook} from '@testing-library/react-native'; +import useSearchFocusSync from '@components/SelectionList/hooks/useSearchFocusSync'; + +type MockItem = { + keyForList: string; + isSelected?: boolean; +}; + +describe('useSearchFocusSync', () => { + it('focuses the selected row when the debounced search is cleared and the full list returns', () => { + const scrollToIndex = jest.fn(); + const setFocusedIndex = jest.fn(); + const filteredData: MockItem[] = [{keyForList: 'match'}]; + const fullData: MockItem[] = [{keyForList: 'a'}, {keyForList: 'b'}, {keyForList: 'selected', isSelected: true}, {keyForList: 'c'}]; + + const {rerender} = renderHook( + ({searchValue, data}: {searchValue: string; data: MockItem[]}) => + useSearchFocusSync({ + searchValue, + data, + selectedOptionsCount: data.filter((item) => item.isSelected).length, + isItemSelected: (item) => !!item.isSelected, + canSelectMultiple: false, + shouldUpdateFocusedIndex: false, + scrollToIndex, + setFocusedIndex, + }), + { + initialProps: { + searchValue: 'uni', + data: filteredData, + }, + }, + ); + + rerender({ + searchValue: '', + data: fullData, + }); + + expect(scrollToIndex).toHaveBeenCalledWith(2, false); + expect(setFocusedIndex).toHaveBeenCalledWith(2); + }); +});