diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 24ec39448a75..196153bf9daf 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -591,6 +591,9 @@ const ONYXKEYS = { /** Stores the role selected for members being imported from a spreadsheet */ IMPORTED_SPREADSHEET_MEMBER_ROLE: 'importedSpreadsheetMemberRole', + /** Stores the year selected in the year picker so it can be read back by the CalendarPicker that opened it */ + CALENDAR_PICKER_SELECTED_YEAR: 'calendarPickerSelectedYear', + /** Stores the route to open after changing app permission from settings */ LAST_ROUTE: 'lastRoute', @@ -1575,6 +1578,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IMPORTED_SPREADSHEET]: OnyxTypes.ImportedSpreadsheet; [ONYXKEYS.IMPORTED_SPREADSHEET_MEMBER_DATA]: OnyxTypes.ImportedSpreadsheetMemberData[]; [ONYXKEYS.IMPORTED_SPREADSHEET_MEMBER_ROLE]: ValueOf; + [ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR]: {contextID: string; year: number}; [ONYXKEYS.LAST_ROUTE]: string; [ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean; [ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES]: Record; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f6f0d34b6ae5..8378ce1699dd 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -108,6 +108,18 @@ const DYNAMIC_ROUTES = { path: 'imported-members-role', entryScreens: [SCREENS.WORKSPACE.MEMBERS_IMPORTED_CONFIRMATION], }, + YEAR_SELECTOR: { + path: 'year-selector', + queryParams: ['contextID', 'currentYear', 'minYear', 'maxYear'], + // CalendarPicker is a generic component reached from many screens (date input fields, + // DateSelectPopup, RangeDatePicker, DatePresetFilterBase, ScheduleCallPage, ...), and the + // previous in-place YearPickerModal had no screen restriction. Use '*' so the year selector + // remains reachable from every CalendarPicker host and doesn't silently break when new + // date-input screens are added (matches KEYBOARD_SHORTCUTS / EXIT_SURVEY_* generic flows). + entryScreens: ['*'], + getRoute: ({contextID, currentYear, minYear, maxYear}: {contextID: string; currentYear: number; minYear: number; maxYear: number}) => + getUrlWithParams('year-selector', {contextID, currentYear, minYear, maxYear}), + }, REPORT_SETTINGS_NAME: { path: 'settings/name', entryScreens: [SCREENS.REPORT_DETAILS.ROOT], diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 16f9aaf4beef..9a4e4f734074 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -136,6 +136,7 @@ const SCREENS = { HELP: 'Settings_Help', DYNAMIC_VERIFY_ACCOUNT: 'Dynamic_Verify_Account', DYNAMIC_ADD_BANK_ACCOUNT_VERIFY_ACCOUNT: 'Dynamic_Add_Bank_Account_Verify_Account', + DYNAMIC_YEAR_SELECTOR: 'Dynamic_Year_Selector', DYNAMIC_EXIT_SURVEY_CONFIRM: 'Dynamic_ExitSurvey_Confirm', DYNAMIC_EXIT_SURVEY_REASON: 'Dynamic_ExitSurvey_Reason', DYNAMIC_KEYBOARD_SHORTCUTS: 'Dynamic_Keyboard_Shortcuts', diff --git a/src/components/DatePicker/CalendarPicker/DynamicYearSelectorPage.tsx b/src/components/DatePicker/CalendarPicker/DynamicYearSelectorPage.tsx new file mode 100644 index 000000000000..79007cc00466 --- /dev/null +++ b/src/components/DatePicker/CalendarPicker/DynamicYearSelectorPage.tsx @@ -0,0 +1,94 @@ +import React, {useMemo, useState} from 'react'; +import {Keyboard} 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 useDynamicBackPath from '@hooks/useDynamicBackPath'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {setCalendarPickerSelectedYear} from '@libs/actions/CalendarPicker'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import CONST from '@src/CONST'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type CalendarPickerListItem from './types'; + +type DynamicYearSelectorPageProps = PlatformStackScreenProps; + +function DynamicYearSelectorPage({route}: DynamicYearSelectorPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.YEAR_SELECTOR.path); + + const {contextID} = route.params; + const currentYear = Number(route.params.currentYear) || new Date().getFullYear(); + const minYear = Number(route.params.minYear) || CONST.CALENDAR_PICKER.MIN_YEAR; + const maxYear = Number(route.params.maxYear) || CONST.CALENDAR_PICKER.MAX_YEAR; + + const [searchText, setSearchText] = useState(''); + + const years: CalendarPickerListItem[] = useMemo( + () => + Array.from({length: maxYear - minYear + 1}, (value, index) => index + minYear).map((year) => ({ + text: year.toString(), + value: year, + keyForList: year.toString(), + isSelected: year === currentYear, + })), + [minYear, maxYear, currentYear], + ); + + const {data, headerMessage} = useMemo(() => { + const yearsList = searchText === '' ? years : years.filter((year) => year.text?.includes(searchText)); + return { + headerMessage: !yearsList.length ? translate('common.noResultsFound') : '', + data: yearsList.sort((a, b) => b.value - a.value), + }; + }, [years, searchText, translate]); + + const textInputOptions = useMemo( + () => ({ + label: translate('yearPickerPage.selectYear'), + value: searchText, + onChangeText: (text: string) => setSearchText(text.replaceAll(CONST.REGEX.NON_NUMERIC, '').trim()), + headerMessage, + maxLength: 4, + inputMode: CONST.INPUT_MODE.NUMERIC, + }), + [headerMessage, searchText, translate], + ); + + return ( + + Navigation.goBack(backPath)} + /> + { + Keyboard.dismiss(); + setCalendarPickerSelectedYear(contextID, option.value); + Navigation.goBack(backPath); + }} + textInputOptions={textInputOptions} + initiallyFocusedItemKey={currentYear.toString()} + disableMaintainingScrollPosition + addBottomSafeAreaPadding + shouldStopPropagation + showScrollIndicator + /> + + ); +} + +export default DynamicYearSelectorPage; diff --git a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx deleted file mode 100644 index dd685b5d1494..000000000000 --- a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, {useEffect, useMemo, useState} from 'react'; -import {Keyboard} from 'react-native'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import Modal from '@components/Modal'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SelectionList from '@components/SelectionList'; -import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import CONST from '@src/CONST'; -import type CalendarPickerListItem from './types'; - -type YearPickerModalProps = { - /** Whether the modal is visible */ - isVisible: boolean; - - /** The list of years to render */ - years: CalendarPickerListItem[]; - - /** Currently selected year */ - currentYear?: number; - - /** Function to call when the user selects a year */ - onYearChange?: (year: number) => void; - - /** Function to call when the user closes the year picker */ - onClose?: () => void; - - /** Whether RIGHT_DOCKED modal should keep backdrop in narrow pane context */ - shouldEnableBackdropInNarrowPane?: boolean; -}; - -function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear(), onYearChange, onClose, shouldEnableBackdropInNarrowPane = false}: YearPickerModalProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const [searchText, setSearchText] = useState(''); - const {data, headerMessage} = useMemo(() => { - const yearsList = searchText === '' ? years : years.filter((year) => year.text?.includes(searchText)); - return { - headerMessage: !yearsList.length ? translate('common.noResultsFound') : '', - data: yearsList.sort((a, b) => b.value - a.value), - }; - }, [years, searchText, translate]); - - useEffect(() => { - if (isVisible) { - return; - } - setSearchText(''); - }, [isVisible]); - - const textInputOptions = useMemo( - () => ({ - label: translate('yearPickerPage.selectYear'), - value: searchText, - onChangeText: (text: string) => setSearchText(text.replaceAll(CONST.REGEX.NON_NUMERIC, '').trim()), - headerMessage, - maxLength: 4, - inputMode: CONST.INPUT_MODE.NUMERIC, - }), - [headerMessage, searchText, translate], - ); - - return ( - onClose?.()} - onModalHide={onClose} - shouldHandleNavigationBack - shouldUseCustomBackdrop - onBackdropPress={onClose} - shouldKeepRightDockedBackdropInNarrowPane={shouldEnableBackdropInNarrowPane} - enableEdgeToEdgeBottomSafeAreaPadding - > - - - { - Keyboard.dismiss(); - onYearChange?.(option.value); - }} - textInputOptions={textInputOptions} - initiallyFocusedItemKey={currentYear.toString()} - disableMaintainingScrollPosition - addBottomSafeAreaPadding - shouldStopPropagation - showScrollIndicator - /> - - - ); -} - -export default YearPickerModal; diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 2b34d6d559b6..154c569782ff 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -1,3 +1,4 @@ +import {findFocusedRoute} from '@react-navigation/native'; import {addMonths, addYears, format, isSameDay, parseISO, setDate, setMonth, setYear, startOfDay, subMonths, subYears} from 'date-fns'; import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useRef, useState} from 'react'; @@ -8,16 +9,23 @@ import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useRootNavigationState from '@hooks/useRootNavigationState'; import useThemeStyles from '@hooks/useThemeStyles'; +import {clearCalendarPickerSelectedYear} from '@libs/actions/CalendarPicker'; +import {closeTop} from '@libs/actions/Modal'; import DateUtils from '@libs/DateUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; import ArrowIcon from './ArrowIcon'; import Day from './Day'; import generateMonthMatrix from './generateMonthMatrix'; import MonthPickerModal from './MonthPickerModal'; -import type CalendarPickerListItem from './types'; -import YearPickerModal from './YearPickerModal'; type CalendarPickerProps = { /** An initial value of date string */ @@ -41,8 +49,27 @@ type CalendarPickerProps = { /** Optional style override for the header container */ headerContainerStyle?: StyleProp; - /** Whether Month/Year right-docked picker modals should keep backdrop in narrow pane context */ + /** Whether Month right-docked picker modal should keep backdrop in narrow pane context */ shouldEnableMonthYearBackdropInNarrowPane?: boolean; + + /** + * Stable identifier used to match the year selected on the year picker screen back to this + * CalendarPicker instance. Required because the host popover/modal may be dismissed when + * navigating (so this component unmounts and remounts on return); a parent-owned id keeps + * the year selection routed to the correct instance. Hosts that mount more than one + * CalendarPicker (e.g. range pickers) must pass distinct ids. + */ + pickerContextID: string; + + /** + * Whether the popover/modal hosting this CalendarPicker should be dismissed (via `Modal.closeTop`) + * before navigating to the year picker screen, so the year picker screen is not rendered behind it. + * Wide-screen popover hosts set this; the full-screen mobile overlay leaves it off. + */ + shouldCloseModalOnYearPickerOpen?: boolean; + + /** On web wide-screen the host keeps this CalendarPicker mounted while the year-picker RHP is open; when true, hide the calendar (opacity 0 + non-interactive) so the RHP renders on top, then restore on return. */ + shouldHideOnYearPickerOpen?: boolean; }; function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) { @@ -75,17 +102,22 @@ function CalendarPicker({ selectableDates, headerContainerStyle, shouldEnableMonthYearBackdropInNarrowPane = false, + pickerContextID, + shouldCloseModalOnYearPickerOpen = false, + shouldHideOnYearPickerOpen = false, }: CalendarPickerProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); const styles = useThemeStyles(); const themeStyles = useThemeStyles(); + const isYearPickerOpen = useRootNavigationState((state) => (state ? findFocusedRoute(state)?.name === SCREENS.SETTINGS.DYNAMIC_YEAR_SELECTOR : false)); + const isHiddenForYearPicker = shouldHideOnYearPickerOpen && isYearPickerOpen; const {translate} = useLocalize(); const pressableRef = useRef(null); const monthPressableRef = useRef(null); const [currentDateView, setCurrentDateView] = useState(() => getInitialCurrentDateView(value, minDate, maxDate)); - const [isYearPickerVisible, setIsYearPickerVisible] = useState(false); const [isMonthPickerVisible, setIsMonthPickerVisible] = useState(false); + const [selectedYearResult] = useOnyx(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR); const isFirstRender = useRef(true); const currentMonthView = currentDateView.getMonth(); @@ -97,27 +129,27 @@ function CalendarPicker({ const minYear = CONST.CALENDAR_PICKER.MIN_YEAR; const maxYear = CONST.CALENDAR_PICKER.MAX_YEAR; - const [years, setYears] = useState(() => - Array.from({length: maxYear - minYear + 1}, (v, i) => i + minYear).map((year) => ({ - text: year.toString(), - value: year, - keyForList: year.toString(), - isSelected: year === currentDateView.getFullYear(), - })), - ); - - const onYearSelected = (year: number) => { - setCurrentDateView((prev) => { - const newCurrentDateView = setYear(new Date(prev), year); - setYears((prevYears) => - prevYears.map((item) => ({ - ...item, - isSelected: item.value === newCurrentDateView.getFullYear(), - })), - ); - return newCurrentDateView; - }); - requestAnimationFrame(() => setIsYearPickerVisible(false)); + // When the year picker screen writes back a selection for this CalendarPicker instance, + // apply it to the displayed date and clear the transient result so it isn't re-applied. + useEffect(() => { + if (!selectedYearResult || selectedYearResult.contextID !== pickerContextID) { + return; + } + const {year} = selectedYearResult; + clearCalendarPickerSelectedYear(); + // Defer applying the year to the next frame: this effect reacts to an Onyx-delivered + // selection, and updating state synchronously inside the effect triggers a cascading + // render (react-hooks/set-state-in-effect). + requestAnimationFrame(() => setCurrentDateView((prev) => setYear(new Date(prev), year))); + }, [selectedYearResult, pickerContextID]); + + const openYearPicker = () => { + // Dismiss the popover/modal hosting this CalendarPicker (if any) so the year picker + // screen is not rendered behind it. + if (shouldCloseModalOnYearPickerOpen) { + closeTop(); + } + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.YEAR_SELECTOR.getRoute({contextID: pickerContextID, currentYear: currentYearView, minYear, maxYear}))); }; const onMonthSelected = (month: number) => { @@ -149,15 +181,6 @@ function CalendarPicker({ if (prevMonth.getFullYear() < CONST.CALENDAR_PICKER.MIN_YEAR) { return prev; } - // if year is subtracted, we need to update the years list - if (prevMonth.getFullYear() < prev.getFullYear()) { - setYears((prevYears) => - prevYears.map((item) => ({ - ...item, - isSelected: item.value === prevMonth.getFullYear(), - })), - ); - } return prevMonth; }); }; @@ -171,16 +194,6 @@ function CalendarPicker({ if (nextMonth.getFullYear() > CONST.CALENDAR_PICKER.MAX_YEAR) { return prev; } - // if year is added, we need to update the years list - if (nextMonth.getFullYear() > prev.getFullYear()) { - setYears((prevYears) => - prevYears.map((item) => ({ - ...item, - isSelected: item.value === nextMonth.getFullYear(), - })), - ); - } - return nextMonth; }); }; @@ -191,7 +204,6 @@ function CalendarPicker({ if (prevYear.getFullYear() < CONST.CALENDAR_PICKER.MIN_YEAR) { return prev; } - setYears((prevYears) => prevYears.map((item) => ({...item, isSelected: item.value === prevYear.getFullYear()}))); return prevYear; }); }; @@ -202,7 +214,6 @@ function CalendarPicker({ if (nextYear.getFullYear() > CONST.CALENDAR_PICKER.MAX_YEAR) { return prev; } - setYears((prevYears) => prevYears.map((item) => ({...item, isSelected: item.value === nextYear.getFullYear()}))); return nextYear; }); }; @@ -239,7 +250,7 @@ function CalendarPicker({ const getAccessibilityState = useCallback((isSelected: boolean) => ({selected: isSelected}), []); return ( - + { pressableRef?.current?.blur(); - setIsYearPickerVisible(true); + openYearPicker(); }} ref={pressableRef} style={[themeStyles.alignItemsCenter]} @@ -422,14 +433,6 @@ function CalendarPicker({ ))} - setIsYearPickerVisible(false)} - shouldEnableBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane} - /> { if (shouldSaveDraft && formID) { @@ -87,6 +89,9 @@ function DatePickerModal({ value={selectedDate} onSelected={handleDateSelection} shouldEnableMonthYearBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane} + pickerContextID={`datePicker-${inputID}`} + shouldCloseModalOnYearPickerOpen={!shouldHideInPlace} + shouldHideOnYearPickerOpen={shouldHideInPlace} /> ); diff --git a/src/components/Search/FilterComponents/DatePresetFilterBase.tsx b/src/components/Search/FilterComponents/DatePresetFilterBase.tsx index 5530b94fa84f..13d1fd170800 100644 --- a/src/components/Search/FilterComponents/DatePresetFilterBase.tsx +++ b/src/components/Search/FilterComponents/DatePresetFilterBase.tsx @@ -103,6 +103,11 @@ type DatePresetFilterBaseProps = { /** Force vertical stacking of calendars in range picker */ forceVerticalCalendars?: boolean; + /** Whether the hosting popover should be dismissed (via `Modal.closeTop`) before navigating to the year picker screen */ + shouldCloseModalOnYearPickerOpen?: boolean; + + shouldHideOnYearPickerOpen?: boolean; + /** The ref handle */ ref: Ref; }; @@ -123,6 +128,8 @@ function DatePresetFilterBase({ onDateValuesChange, onRangeValidationErrorChange, forceVerticalCalendars = false, + shouldCloseModalOnYearPickerOpen = false, + shouldHideOnYearPickerOpen = false, ref, }: DatePresetFilterBaseProps) { const theme = useTheme(); @@ -456,6 +463,8 @@ function DatePresetFilterBase({ onRangeValidationErrorChange?.(false); }} forceVertical={forceVerticalCalendars} + shouldCloseModalOnYearPickerOpen={shouldCloseModalOnYearPickerOpen} + shouldHideOnYearPickerOpen={shouldHideOnYearPickerOpen} /> ); } @@ -467,6 +476,9 @@ function DatePresetFilterBase({ onSelected={handleSingleDateSelected} minDate={CONST.CALENDAR_PICKER.MIN_DATE} maxDate={CONST.CALENDAR_PICKER.MAX_DATE} + pickerContextID="searchSingleDate" + shouldCloseModalOnYearPickerOpen={shouldCloseModalOnYearPickerOpen} + shouldHideOnYearPickerOpen={shouldHideOnYearPickerOpen} /> @@ -67,6 +83,9 @@ function RangeDatePicker({fromValue, toValue, onFromSelected, onToSelected, forc minDate={toMinDate} maxDate={CONST.CALENDAR_PICKER.MAX_DATE} headerContainerStyle={styles.ph4} + pickerContextID="searchRangeTo" + shouldCloseModalOnYearPickerOpen={shouldCloseModalOnYearPickerOpen} + shouldHideOnYearPickerOpen={shouldHideOnYearPickerOpen} /> diff --git a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx index e220db8f883d..c427c3b82008 100644 --- a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx @@ -1,5 +1,5 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {View} from 'react-native'; +import {Platform, View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; import FormHelpMessage from '@components/FormHelpMessage'; import ScrollView from '@components/ScrollView'; @@ -44,6 +44,7 @@ type DateSelectPopupProps = { function DateSelectPopup({label, value, presets, style, closeOverlay, onChange, setPopoverWidth}: DateSelectPopupProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth, isInLandscapeMode} = useResponsiveLayout(); + const shouldHideInPlace = Platform.OS === 'web' && !isSmallScreenWidth; const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -154,6 +155,8 @@ function DateSelectPopup({label, value, presets, style, closeOverlay, onChange, presets={presets} onDateValuesChange={updateRangeText} onRangeValidationErrorChange={setShouldShowRangeError} + shouldCloseModalOnYearPickerOpen={!shouldHideInPlace} + shouldHideOnYearPickerOpen={shouldHideInPlace} /> {shouldShowRangeError && ( require('../../../../pages/settings/Wallet/DynamicAddBankAccountVerifyAccountPage').default, ), + [SCREENS.SETTINGS.DYNAMIC_YEAR_SELECTOR]: () => require('../../../../components/DatePicker/CalendarPicker/DynamicYearSelectorPage').default, [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../../pages/ShareCodePage').default, [SCREENS.SETTINGS.PROFILE.PRONOUNS]: withAgentAccessDenied(() => require('../../../../pages/settings/Profile/PronounsPage').default), [SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: () => require('../../../../pages/settings/Profile/DisplayNamePage').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 4328017fda28..045a06cdd874 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -468,6 +468,7 @@ const config: LinkingOptions['config'] = { }, [SCREENS.SETTINGS.DYNAMIC_VERIFY_ACCOUNT]: DYNAMIC_ROUTES.VERIFY_ACCOUNT.path, [SCREENS.SETTINGS.DYNAMIC_ADD_BANK_ACCOUNT_VERIFY_ACCOUNT]: DYNAMIC_ROUTES.ADD_BANK_ACCOUNT_VERIFY_ACCOUNT.path, + [SCREENS.SETTINGS.DYNAMIC_YEAR_SELECTOR]: DYNAMIC_ROUTES.YEAR_SELECTOR.path, [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: { path: ROUTES.SETTINGS_CONTACT_METHODS.route, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index e36072ac31fc..352505d89adc 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -141,6 +141,19 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.LOCK.FAILED_TO_LOCK_ACCOUNT]: undefined; [SCREENS.SETTINGS.DYNAMIC_VERIFY_ACCOUNT]: undefined; [SCREENS.SETTINGS.DYNAMIC_ADD_BANK_ACCOUNT_VERIFY_ACCOUNT]: undefined; + [SCREENS.SETTINGS.DYNAMIC_YEAR_SELECTOR]: { + /** Stable id of the CalendarPicker instance that opened the year picker */ + contextID: string; + + /** Currently displayed year */ + currentYear: string; + + /** Minimum selectable year */ + minYear: string; + + /** Maximum selectable year */ + maxYear: string; + }; [SCREENS.SETTINGS.DYNAMIC_EXIT_SURVEY_REASON]: undefined; [SCREENS.SETTINGS.DYNAMIC_EXIT_SURVEY_CONFIRM]: undefined; [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: undefined; diff --git a/src/libs/actions/CalendarPicker.ts b/src/libs/actions/CalendarPicker.ts new file mode 100644 index 000000000000..11dc03bbd751 --- /dev/null +++ b/src/libs/actions/CalendarPicker.ts @@ -0,0 +1,17 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; + +/** + * Stores the year selected in the year picker screen so the CalendarPicker instance that + * opened it (identified by `contextID`) can read it back when it is next rendered. + */ +function setCalendarPickerSelectedYear(contextID: string, year: number) { + Onyx.set(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR, {contextID, year}); +} + +/** Clears the stored year picker selection once it has been consumed by a CalendarPicker. */ +function clearCalendarPickerSelectedYear() { + Onyx.set(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR, null); +} + +export {setCalendarPickerSelectedYear, clearCalendarPickerSelectedYear}; diff --git a/src/pages/ScheduleCall/ScheduleCallPage.tsx b/src/pages/ScheduleCall/ScheduleCallPage.tsx index f92eb15f667b..2de5d99973fa 100644 --- a/src/pages/ScheduleCall/ScheduleCallPage.tsx +++ b/src/pages/ScheduleCall/ScheduleCallPage.tsx @@ -194,6 +194,7 @@ function ScheduleCallPage() { selectableDates={Object.keys(timeSlotDateMap)} DayComponent={AvailableBookingDay} onSelected={loadTimeSlotsAndSaveDate} + pickerContextID="scheduleCall" /> diff --git a/tests/actions/CalendarPickerTest.ts b/tests/actions/CalendarPickerTest.ts new file mode 100644 index 000000000000..54ffe8c9a510 --- /dev/null +++ b/tests/actions/CalendarPickerTest.ts @@ -0,0 +1,37 @@ +import Onyx from 'react-native-onyx'; +import {clearCalendarPickerSelectedYear, setCalendarPickerSelectedYear} from '@src/libs/actions/CalendarPicker'; +import ONYXKEYS from '@src/ONYXKEYS'; +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +describe('actions/CalendarPicker', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => Onyx.clear().then(waitForBatchedUpdates)); + + it('setCalendarPickerSelectedYear stores the selected year with its context in Onyx', async () => { + setCalendarPickerSelectedYear('datePicker-dob', 1995); + await waitForBatchedUpdates(); + + const result = await getOnyxValue(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR); + + // The selection is lifted to Onyx so it survives the year picker screen navigation, + // and is tagged with the contextID of the CalendarPicker that opened it. + expect(result).toEqual({contextID: 'datePicker-dob', year: 1995}); + }); + + it('clearCalendarPickerSelectedYear removes the stored selection once it has been consumed', async () => { + setCalendarPickerSelectedYear('scheduleCall', 2031); + await waitForBatchedUpdates(); + expect(await getOnyxValue(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR)).toEqual({contextID: 'scheduleCall', year: 2031}); + + clearCalendarPickerSelectedYear(); + await waitForBatchedUpdates(); + + expect(await getOnyxValue(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR)).toBeUndefined(); + }); +}); diff --git a/tests/unit/CalendarPickerTest.tsx b/tests/unit/CalendarPickerTest.tsx index cdfec77767ae..45f7db23ed89 100644 --- a/tests/unit/CalendarPickerTest.tsx +++ b/tests/unit/CalendarPickerTest.tsx @@ -1,10 +1,13 @@ import type * as ReactNavigationNative from '@react-navigation/native'; import {fireEvent, render, screen, userEvent, within} from '@testing-library/react-native'; import {addMonths, addYears, subMonths, subYears} from 'date-fns'; -import type {ComponentType, ReactNode} from 'react'; +import {createElement} from 'react'; +import type {ComponentProps, ComponentType, ReactNode} from 'react'; import CalendarPicker from '@components/DatePicker/CalendarPicker'; +import * as Modal from '@libs/actions/Modal'; import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; type MockPressableProps = {testID?: string; accessibilityLabel?: string; role?: string; onPress?: () => void; children?: ReactNode}; type MockTextProps = {children?: ReactNode}; @@ -23,6 +26,13 @@ jest.mock('@react-navigation/native', () => ({ createNavigationContainerRef: jest.fn(), })); +// CalendarPicker reads the root navigation state to hide itself while the year selector is open. +// This test renders it without a NavigationContainer, so mock the hook to the "navigation not ready" default. +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: (selector: (state: undefined) => T): T => selector(undefined), +})); + jest.mock('../../src/hooks/useLocalize', () => jest.fn(() => ({ translate: jest.fn(), @@ -32,12 +42,6 @@ jest.mock('../../src/hooks/useLocalize', () => jest.mock('@src/components/ConfirmedRoute.tsx'); type MockMonthPickerModalProps = {isVisible: boolean; onMonthChange?: (month: number) => void; onClose?: () => void}; -type MockYearPickerModalProps = { - isVisible: boolean; - years: Array<{value: number; text: string}>; - onYearChange?: (year: number) => void; - onClose?: () => void; -}; jest.mock('@components/DatePicker/CalendarPicker/MonthPickerModal', () => { const ReactNativeActual = jest.requireActual('react-native'); @@ -74,43 +78,26 @@ jest.mock('@components/DatePicker/CalendarPicker/MonthPickerModal', () => { return MockMonthPickerModal; }); -jest.mock('@components/DatePicker/CalendarPicker/YearPickerModal', () => { - const ReactNativeActual = jest.requireActual('react-native'); - const {Pressable, Text, View} = ReactNativeActual; - function MockYearPickerModal({isVisible, years, onYearChange, onClose}: MockYearPickerModalProps) { - if (!isVisible) { - return null; - } - return ( - - {years.map((year) => ( - onYearChange?.(year.value)} - > - {year.text} - - ))} - - close - - - ); - } - return MockYearPickerModal; -}); +const mockNavigate = jest.fn(); +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + navigate: (route: string) => { + mockNavigate(route); + }, + getActiveRoute: jest.fn(() => 'settings/profile'), + }, +})); + +// CalendarPicker's pickerContextID is required (callers own a stable id so the year picker +// routes back to the correct instance). Tests default to 'test-calendar' unless they override it. +function CalendarPickerForTest({pickerContextID = 'test-calendar', ...rest}: Omit, 'pickerContextID'> & {pickerContextID?: string}) { + return createElement(CalendarPicker, {pickerContextID, ...rest}); +} describe('CalendarPicker', () => { test('renders calendar component', () => { - render(); + render(); }); test('displays the current month and year', () => { @@ -118,7 +105,7 @@ describe('CalendarPicker', () => { const maxDate = addYears(new Date(currentDate), 1); const minDate = subYears(new Date(currentDate), 1); render( - , @@ -132,7 +119,7 @@ describe('CalendarPicker', () => { const minDate = new Date('2022-01-01'); const maxDate = new Date('2030-01-01'); render( - , @@ -145,7 +132,7 @@ describe('CalendarPicker', () => { }); test('clicking previous month arrow updates the displayed month', () => { - render(); + render(); fireEvent.press(screen.getByTestId('prev-month-arrow')); @@ -159,7 +146,7 @@ describe('CalendarPicker', () => { const maxDate = new Date('2030-01-01'); const value = '2023-01-01'; render( - { const minDate = new Date('2022-01-01'); const maxDate = new Date('2030-01-01'); render( - { const value = new Date('2003-02-17'); render( - , @@ -216,7 +203,7 @@ describe('CalendarPicker', () => { const maxDate = new Date('2003-02-24'); const value = new Date('2003-02-17'); render( - , @@ -236,7 +223,7 @@ describe('CalendarPicker', () => { // given the max date is 27 render( - , @@ -250,7 +237,7 @@ describe('CalendarPicker', () => { const onSelectedMock = jest.fn(); const maxDate = new Date('2011-03-01'); render( - , @@ -263,7 +250,7 @@ describe('CalendarPicker', () => { test('should open the calendar on a year from max date if it is earlier than current year', () => { const maxDate = new Date('2011-03-01'); - render(); + render(); expect(within(screen.getByTestId('currentYearText')).getByText('2011')).toBeTruthy(); }); @@ -272,7 +259,7 @@ describe('CalendarPicker', () => { const minDate = new Date('2035-02-16'); const maxDate = new Date('2040-02-16'); render( - , @@ -288,7 +275,7 @@ describe('CalendarPicker', () => { // given the min date is 16 render( - { // given the max date is 24 render( - { // given the min date is 16 render( - , @@ -357,7 +344,7 @@ describe('CalendarPicker', () => { // given the max date is 24 render( - , @@ -372,7 +359,7 @@ describe('CalendarPicker', () => { const maxDate = new Date('2030-12-31'); const value = '2025-06-15'; render( - { const maxDate = new Date('2030-12-31'); const value = '2025-06-15'; render( - { const minDate = new Date('2023-01-01'); const value = new Date('2023-06-15'); render( - , @@ -422,7 +409,7 @@ describe('CalendarPicker', () => { const maxDate = new Date('2023-12-31'); const value = new Date('2023-06-15'); render( - , @@ -440,7 +427,7 @@ describe('CalendarPicker', () => { const maxDate = new Date('2030-12-31'); const value = '2024-03-15'; render( - { const maxDate = new Date('2025-04-20'); const value = '2024-09-15'; render( - { const maxDate = new Date('2030-12-31'); const value = '2025-12-15'; render( - { const maxDate = new Date('2030-12-31'); const value = '2025-01-15'; render( - { const value = new Date(CONST.CALENDAR_PICKER.MAX_YEAR, 5, 15); const maxDate = new Date(CONST.CALENDAR_PICKER.MAX_YEAR, 11, 31); render( - , @@ -529,7 +516,7 @@ describe('CalendarPicker', () => { const value = new Date(CONST.CALENDAR_PICKER.MIN_YEAR, 5, 15); const minDate = new Date(CONST.CALENDAR_PICKER.MIN_YEAR, 0, 1); render( - , @@ -545,7 +532,7 @@ describe('CalendarPicker', () => { const value = new Date(CONST.CALENDAR_PICKER.MAX_YEAR, 11, 15); const maxDate = new Date(CONST.CALENDAR_PICKER.MAX_YEAR, 11, 31); render( - , @@ -562,7 +549,7 @@ describe('CalendarPicker', () => { const value = new Date(CONST.CALENDAR_PICKER.MIN_YEAR, 0, 15); const minDate = new Date(CONST.CALENDAR_PICKER.MIN_YEAR, 0, 1); render( - , @@ -580,7 +567,7 @@ describe('CalendarPicker', () => { const maxDate = new Date('2030-12-31'); const value = '2025-06-15'; render( - { expect(within(screen.getByTestId('currentMonthText')).getByText(monthNames.at(8) ?? '')).toBeTruthy(); }); - test('clicking the year button opens the year picker and selecting a year updates the calendar', () => { + test('clicking the year button navigates to the year picker screen with the current year and context', () => { + mockNavigate.mockClear(); const minDate = new Date('2020-01-01'); const maxDate = new Date('2030-12-31'); const value = '2025-06-15'; render( - , ); fireEvent.press(screen.getByTestId('currentYearButton')); - const yearPickerModal = screen.getByTestId('YearPickerModal'); - expect(yearPickerModal).toBeTruthy(); + expect(mockNavigate).toHaveBeenCalledTimes(1); + const navigatedRoute = mockNavigate.mock.calls.at(0)?.at(0) ?? ''; + expect(navigatedRoute).toContain('year-selector'); + expect(navigatedRoute).toContain('contextID=datePicker-testInput'); + expect(navigatedRoute).toContain('currentYear=2025'); + }); + + test('the year button dismisses the host popover before navigating when shouldCloseModalOnYearPickerOpen is set', () => { + mockNavigate.mockClear(); + const closeTopSpy = jest.spyOn(Modal, 'closeTop').mockImplementation(() => {}); + render(); - fireEvent.press(within(yearPickerModal).getByTestId('year-option-2027')); + fireEvent.press(screen.getByTestId('currentYearButton')); - expect(within(screen.getByTestId('currentYearText')).getByText('2027')).toBeTruthy(); + expect(closeTopSpy).toHaveBeenCalledTimes(1); + expect(mockNavigate).toHaveBeenCalledTimes(1); + closeTopSpy.mockRestore(); }); - test('closing the year picker via onClose hides the modal', () => { - render(); + test('the year button does not dismiss any modal when shouldCloseModalOnYearPickerOpen is not set', () => { + mockNavigate.mockClear(); + const closeTopSpy = jest.spyOn(Modal, 'closeTop').mockImplementation(() => {}); + render(); fireEvent.press(screen.getByTestId('currentYearButton')); - expect(screen.getByTestId('YearPickerModal')).toBeTruthy(); - fireEvent.press(screen.getByTestId('year-modal-close')); - expect(screen.queryByTestId('YearPickerModal')).toBeNull(); + expect(closeTopSpy).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledTimes(1); + closeTopSpy.mockRestore(); }); test('closing the month picker via onClose hides the modal', () => { - render(); + render(); fireEvent.press(screen.getByTestId('currentMonthButton')); expect(screen.getByTestId('MonthPickerModal')).toBeTruthy(); @@ -651,4 +653,13 @@ describe('CalendarPicker', () => { expect(allMonths.find((m) => m.value === 6)?.isSelected).toBe(true); expect(allMonths.find((m) => m.value === 0)?.isSelected).toBe(false); }); + + test('the year selector dynamic route is reachable from any CalendarPicker host (not gated to an allowlist)', () => { + // CalendarPicker is rendered from many screens (date input fields, DateSelectPopup, + // RangeDatePicker, DatePresetFilterBase, ScheduleCallPage, ...). The previous in-place + // YearPickerModal had no screen restriction, so the migrated dynamic route must stay + // unrestricted; a partial entryScreens allowlist would silently break the year picker + // on any screen it omits. + expect(DYNAMIC_ROUTES.YEAR_SELECTOR.entryScreens).toContain('*'); + }); });