diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 552f96d55b20a..d146fb0eb763b 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -252,6 +252,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 a2f7e0fffeeb3..9d85f55d6ee3b 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 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 {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -36,6 +38,9 @@ type CountrySelectorModalProps = { function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onClose, label, onBackdropPress}: CountrySelectorModalProps) { const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const initialSelectedValue = useInitialSelection(currentCountry || undefined, {resetDeps: [isVisible]}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; + const initiallyFocusedCountry = initialSelectedValue; const countries = useMemo( () => @@ -51,8 +56,8 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC }), [translate, currentCountry], ); - - const searchResults = searchOptions(debouncedSearchValue, countries); + const orderedCountries = moveInitialSelectionToTopByValue(countries, initialSelectedValues); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countries : orderedCountries); const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; const styles = useThemeStyles(); @@ -89,9 +94,10 @@ function CountrySelectorModal({isVisible, currentCountry, onCountrySelected, onC diff --git a/src/components/PushRowWithModal/PushRowModal.tsx b/src/components/PushRowWithModal/PushRowModal.tsx index a2f94504d49a6..0c4acb2f7ddbd 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 useInitialSelection from '@hooks/useInitialSelection'; 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,6 +46,9 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const initialSelectedValue = useInitialSelection(selectedOption || undefined, {resetDeps: [isVisible]}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; + const initiallyFocusedOption = initialSelectedValue; const options = useMemo( () => @@ -57,6 +62,8 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio [optionsList, selectedOption], ); + const orderedOptions = moveInitialSelectionToTopByValue(options, initialSelectedValues); + const handleSelectRow = (option: ListItemType) => { onOptionChange(option.value); onClose(); @@ -67,7 +74,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 +109,8 @@ function PushRowModal({isVisible, selectedOption, onOptionChange, onClose, optio ListItem={RadioListItem} onSelectRow={handleSelectRow} textInputOptions={textInputOptions} - initiallyFocusedItemKey={selectedOption} + searchValueForFocusSync={debouncedSearchValue} + initiallyFocusedItemKey={initiallyFocusedOption} disableMaintainingScrollPosition shouldShowTooltips={false} showScrollIndicator diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index a38baf7bdb373..ae38df2cdba97 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -42,6 +42,7 @@ function BaseSelectionList({ ref, ListItem, textInputOptions, + searchValueForFocusSync, initiallyFocusedItemKey, onSelectRow, onSelectAll, @@ -205,6 +206,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) => { @@ -494,12 +496,12 @@ function BaseSelectionList({ initiallyFocusedItemKey, isItemSelected, focusedIndex, - searchValue: textInputOptions?.value, + searchValue: syncedSearchValue, setFocusedIndex, }); useSearchFocusSync({ - searchValue: textInputOptions?.value, + searchValue: syncedSearchValue, data, selectedOptionsCount: dataDetails.selectedOptions.length, isItemSelected, diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index e14207e1461cc..80d10a43728d7 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -56,6 +56,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 d03e8183ca300..b66e2046264a5 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 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 {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import CONST from '@src/CONST'; @@ -39,6 +41,9 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, const {translate} = useLocalize(); const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); const styles = useThemeStyles(); + const initialSelectedValue = useInitialSelection(currentState || undefined, {resetDeps: [isVisible]}); + const initialSelectedValues = initialSelectedValue ? [initialSelectedValue] : []; + const initiallyFocusedState = initialSelectedValue; const countryStates = useMemo( () => @@ -57,7 +62,8 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, [translate, currentState], ); - const searchResults = searchOptions(debouncedSearchValue, countryStates); + const orderedCountryStates = moveInitialSelectionToTopByValue(countryStates, initialSelectedValues); + const searchResults = searchOptions(debouncedSearchValue, debouncedSearchValue ? countryStates : orderedCountryStates); const textInputOptions = useMemo( () => ({ @@ -93,7 +99,8 @@ function StateSelectorModal({isVisible, currentState, onStateSelected, onClose, ListItem={RadioListItem} onSelectRow={onStateSelected} textInputOptions={textInputOptions} - initiallyFocusedItemKey={currentState} + searchValueForFocusSync={debouncedSearchValue} + initiallyFocusedItemKey={initiallyFocusedState} disableMaintainingScrollPosition shouldSingleExecuteRowSelect shouldStopPropagation diff --git a/src/components/ValuePicker/ValueSelectionList.tsx b/src/components/ValuePicker/ValueSelectionList.tsx index cde8688085984..d212cd39d8259 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 RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +import useInitialSelection from '@hooks/useInitialSelection'; +import {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import type {ValueSelectionListProps} from './types'; function ValueSelectionList({ @@ -11,20 +13,27 @@ 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} : {resetDeps: [isVisible]}); + const initiallyFocusedItemKey = initialSelectedValue; + + const options = useMemo(() => { + const mappedOptions = items.map((item) => ({value: item.value ?? '', alternateText: item.description, text: item.label ?? '', keyForList: item.value ?? ''})); + const orderedOptions = moveInitialSelectionToTopByValue(mappedOptions, initialSelectedValue ? [initialSelectedValue] : []); + + return orderedOptions.map((item) => ({...item, isSelected: item.value === selectedItem?.value})); + }, [initialSelectedValue, items, selectedItem?.value]); return ( onItemSelected?.(item)} - initiallyFocusedItemKey={selectedItem?.value} + initiallyFocusedItemKey={initiallyFocusedItemKey} shouldStopPropagation shouldShowTooltips={shouldShowTooltips} - shouldUpdateFocusedIndex + shouldScrollToFocusedIndex={false} + shouldScrollToFocusedIndexOnMount={false} ListItem={RadioListItem} addBottomSafeAreaPadding={addBottomSafeAreaPadding} disableKeyboardShortcuts={disableKeyboardShortcuts} diff --git a/src/components/ValuePicker/ValueSelectorModal.tsx b/src/components/ValuePicker/ValueSelectorModal.tsx index 4aa1ba794ceda..24afba16afe7a 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 0000000000000..c61868e650e66 --- /dev/null +++ b/src/hooks/useInitialSelection.ts @@ -0,0 +1,50 @@ +import {useFocusEffect} from '@react-navigation/native'; +import type {DependencyList} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; + +type UseInitialSelectionOptions = { + /** 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; +}; + +/** + * 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 useInitialSelection(selection: T, options: UseInitialSelectionOptions = {}) { + const {resetDeps = [], resetOnFocus = false} = options; + const [initialSelection, setInitialSelection] = useState(selection); + const latestSelectionRef = useRef(selection); + + const updateInitialSelection = useCallback((nextSelection: T) => { + setInitialSelection((previousSelection) => (Object.is(previousSelection, nextSelection) ? previousSelection : nextSelection)); + }, []); + + useEffect(() => { + latestSelectionRef.current = selection; + }, [selection]); + + useEffect(() => { + // Intentionally refresh the snapshot only when the caller marks a new open/focus cycle. + // Live selection changes while the picker stays open should not repin or refocus the list. + updateInitialSelection(selection); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, resetDeps); + + 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 0000000000000..29cccec119b3b --- /dev/null +++ b/src/libs/SelectionListOrderUtils.ts @@ -0,0 +1,45 @@ +import CONST from '@src/CONST'; + +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 selectedKeys = new Set(initialSelectedKeys); + const selected: string[] = []; + const remaining: string[] = []; + + for (const key of keys) { + if (selectedKeys.has(key)) { + selected.push(key); + continue; + } + + remaining.push(key); + } + + return [...selected, ...remaining]; +} + +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 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 {moveInitialSelectionToTopByKey, moveInitialSelectionToTopByValue}; diff --git a/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx b/src/pages/settings/Profile/PersonalDetails/DynamicCountrySelectionPage.tsx index e83eecb21dd90..210fe5586283c 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 RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +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 {moveInitialSelectionToTopByValue} 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 = moveInitialSelectionToTopByValue(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={RadioListItem} 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 27148e19171c3..684b0ae223f60 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 RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +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 {moveInitialSelectionToTopByValue} 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 = moveInitialSelectionToTopByValue(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={RadioListItem} 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 30c39317c1f60..a80f94f17f710 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 RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; +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 {moveInitialSelectionToTopByValue} 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 = moveInitialSelectionToTopByValue(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 = { @@ -79,12 +85,14 @@ function CountrySelectionList({isEditing, selectedCountry, countries, onCountryS ListItem={RadioListItem} onSelectRow={onSelectionChange} textInputOptions={textInputOptions} + searchValueForFocusSync={debouncedSearchValue} confirmButtonOptions={confirmButtonOptions} - initiallyFocusedItemKey={selectedCountry} + initiallyFocusedItemKey={initialSelectedValue} footerContent={footerContent} disableMaintainingScrollPosition shouldSingleExecuteRowSelect - shouldUpdateFocusedIndex + shouldScrollToFocusedIndex={false} + shouldScrollToFocusedIndexOnMount={false} shouldStopPropagation /> diff --git a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx index 6aac0599a93b5..43e1532ebb58c 100644 --- a/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx +++ b/src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx @@ -9,12 +9,14 @@ import RadioListItem from '@components/SelectionList/ListItem/RadioListItem'; 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 {moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; import StringUtils from '@libs/StringUtils'; import Navigation from '@navigation/Navigation'; import type {PlatformStackRouteProp} from '@navigation/PlatformStackNavigation/types'; @@ -42,6 +44,9 @@ 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 initiallyFocusedCountry = initialSelectedValue; const [hasError, setHasError] = useState(false); const doesCountrySupportPlaid = isPlaidSupportedCountry(currentCountry); @@ -84,8 +89,9 @@ function SelectCountryStep({policyID}: CountryStepProps) { searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), }; }); - - const searchResults = searchOptions(debouncedSearchValue, countries); + const orderedCountries = moveInitialSelectionToTopByValue(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 = { @@ -121,11 +127,13 @@ function SelectCountryStep({policyID}: CountryStepProps) { setSelectedCountry(countryOption.value ?? null); }} textInputOptions={textInputOptions} + searchValueForFocusSync={debouncedSearchValue} confirmButtonOptions={confirmButtonOptions} - initiallyFocusedItemKey={currentCountry} + initiallyFocusedItemKey={initiallyFocusedCountry} disableMaintainingScrollPosition shouldSingleExecuteRowSelect - shouldUpdateFocusedIndex + shouldScrollToFocusedIndex={false} + shouldScrollToFocusedIndexOnMount={false} addBottomSafeAreaPadding shouldStopPropagation > diff --git a/tests/ui/CountrySelectionListTest.tsx b/tests/ui/CountrySelectionListTest.tsx new file mode 100644 index 0000000000000..abe5e7561bcca --- /dev/null +++ b/tests/ui/CountrySelectionListTest.tsx @@ -0,0 +1,155 @@ +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 CountrySelectionList from '@pages/settings/Wallet/CountrySelectionList'; +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/BlockingViews/FullPageOfflineBlockingView', () => jest.fn(({children}: {children: React.ReactNode}) => children)); +jest.mock('@components/SelectionList', () => jest.fn(() => null)); +jest.mock('@components/SelectionList/ListItem/RadioListItem', () => 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(() => { + mockedSelectionList.mockClear(); + }); + + 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).toBeUndefined(); + expect(selectionListProps?.shouldScrollToFocusedIndex).toBe(false); + expect(selectionListProps?.shouldScrollToFocusedIndexOnMount).toBe(false); + }); + + 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 0000000000000..c51ff2f39da71 --- /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/RadioListItem', () => 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 0000000000000..f1fcad19e2e62 --- /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/RadioListItem', () => 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 0000000000000..5ab2d4370d593 --- /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/RadioListItem', () => 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 0000000000000..210e1b05fd677 --- /dev/null +++ b/tests/ui/SelectCountryStepTest.tsx @@ -0,0 +1,180 @@ +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(), + 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/RadioListItem', () => 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).toBeUndefined(); + }); + + 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 0000000000000..b08dcaa591a6c --- /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/RadioListItem', () => 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 0000000000000..4df6f34618b2f --- /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/RadioListItem', () => 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 0000000000000..bfc4e9bad4f0a --- /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/RadioListItem', () => 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 0000000000000..77c71176f8bff --- /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 0000000000000..71c93d962755f --- /dev/null +++ b/tests/unit/SelectionListOrderUtilsTest.ts @@ -0,0 +1,31 @@ +import {moveInitialSelectionToTopByKey, moveInitialSelectionToTopByValue} from '@libs/SelectionListOrderUtils'; +import CONST from '@src/CONST'; + +describe('SelectionListOrderUtils', () => { + it('does not reorder keys when there is no initial selection', () => { + const keys = Array.from({length: CONST.MOVE_SELECTED_ITEMS_TO_TOP_OF_LIST_THRESHOLD + 2}, (_, index) => `item-${index}`); + + expect(moveInitialSelectionToTopByKey(keys, [])).toEqual(keys); + }); + + 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(moveInitialSelectionToTopByValue(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 = moveInitialSelectionToTopByValue(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 0000000000000..d0ac6349ab85e --- /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); + expect(setFocusedIndex).toHaveBeenCalledWith(2); + }); +});