diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 0e2a82c999e6..ceeddd593517 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -632,6 +632,16 @@ const ONYXKEYS = { /** Stores the role selected for members being imported from a spreadsheet */ IMPORTED_SPREADSHEET_MEMBER_ROLE: 'importedSpreadsheetMemberRole', + /** Generic, `contextID`-routed transient command: the year selected in the year picker, read back and cleared by the CalendarPicker that opened it (any host: DOB, ScheduleCall, Search) */ + CALENDAR_PICKER_SELECTED_YEAR: 'calendarPickerSelectedYear', + + /** + * Search-only companion to `CALENDAR_PICKER_SELECTED_YEAR`: the active Search date-filter sub-view (Custom date/range modifier). + * The Search popover unmounts when the year picker screen opens, so this breadcrumb lets it restore the sub-view on return. + * Only honoured while a `search*`-context year write-back is pending, and cleared when the user leaves the sub-view. + */ + CALENDAR_PICKER_SELECTED_DATE_MODIFIER: 'calendarPickerSelectedDateModifier', + /** Stores the route to open after changing app permission from settings */ LAST_ROUTE: 'lastRoute', @@ -1654,6 +1664,8 @@ 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.CALENDAR_PICKER_SELECTED_DATE_MODIFIER]: string; [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 149457b0b48b..8157dbd727b2 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -156,6 +156,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: { path: 'report-settings', entryScreens: [SCREENS.REPORT_DETAILS.DYNAMIC_ROOT], diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 877bab0bc953..e8158128d42a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -95,6 +95,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..ecfb6593a181 --- /dev/null +++ b/src/components/DatePicker/CalendarPicker/DynamicYearSelectorPage.tsx @@ -0,0 +1,100 @@ +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 React, {useMemo, useState} from 'react'; +import {Keyboard} from 'react-native'; + +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 fdd23e69f946..000000000000 --- a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx +++ /dev/null @@ -1,109 +0,0 @@ -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 React, {useEffect, useMemo, useState} from 'react'; -import {Keyboard} from 'react-native'; - -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 7ff029cc1e77..45baa3dc23f5 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -1,30 +1,37 @@ +import useIsYearSelectorOpen from '@components/DatePicker/useIsYearSelectorOpen'; +import HiddenForOverlayContext from '@components/Modal/ReanimatedModal/HiddenForOverlayContext'; 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 useThemeStyles from '@hooks/useThemeStyles'; +import {clearCalendarPickerSelectedYear} from '@libs/actions/CalendarPicker'; +import {closeTop} from '@libs/actions/Modal'; import DateUtils from '@libs/DateUtils'; +import getPlatform from '@libs/getPlatform'; +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 type {StyleProp, ViewStyle} from 'react-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'; +import React, {useCallback, useContext, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; -import type CalendarPickerListItem from './types'; - import ArrowIcon from './ArrowIcon'; import Day from './Day'; import generateMonthMatrix from './generateMonthMatrix'; import MonthPickerModal from './MonthPickerModal'; -import YearPickerModal from './YearPickerModal'; type CalendarPickerProps = { /** An initial value of date string */ @@ -53,6 +60,22 @@ type CalendarPickerProps = { /** Whether Month/Year right-docked picker modals 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; }; function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) { @@ -86,6 +109,8 @@ function CalendarPicker({ headerContainerStyle, containerStyle, shouldEnableMonthYearBackdropInNarrowPane = false, + pickerContextID, + shouldCloseModalOnYearPickerOpen = false, }: CalendarPickerProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -95,8 +120,8 @@ function CalendarPicker({ 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(); @@ -108,27 +133,51 @@ 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 isYearSelectorOpen = useIsYearSelectorOpen(); + // On wide-screen web the host popover stays mounted while the @react-navigation year-selector RHP is open + // (so the picker context is preserved), but its modal — a z-index-9996 portal — would paint over the RHP and + // swallow its clicks. This CalendarPicker is the component that knows the year selector opened, so it asks + // the hosting ReanimatedModal to hide in place via context. When there is no modal ancestor (e.g. the picker + // is on a navigation card like ScheduleCall), it falls back to hiding itself. Narrow/native dismiss the host instead. + const isDesktopWeb = getPlatform() === CONST.PLATFORM.WEB && !isSmallScreenWidth; + const shouldHideForYearSelector = isDesktopWeb && isYearSelectorOpen; + const setModalHiddenForOverlay = useContext(HiddenForOverlayContext); + const shouldSelfHideForYearSelector = shouldHideForYearSelector && !setModalHiddenForOverlay; - 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)); + useEffect(() => { + if (!setModalHiddenForOverlay) { + return; + } + setModalHiddenForOverlay(shouldHideForYearSelector); + return () => setModalHiddenForOverlay(false); + }, [shouldHideForYearSelector, setModalHiddenForOverlay]); + + // 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(); + } else if (isDesktopWeb) { + // Kept-mounted host: hide the hosting modal before the navigation commits so the year-selector + // RHP never renders under a still-visible popover frame for a frame. The effect above keeps the + // hidden state in sync afterwards (and restores it when the selector closes). + setModalHiddenForOverlay?.(true); + } + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.YEAR_SELECTOR.getRoute({contextID: pickerContextID, currentYear: currentYearView, minYear, maxYear}))); }; const onMonthSelected = (month: number) => { @@ -160,15 +209,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; }); }; @@ -182,16 +222,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; }); }; @@ -202,7 +232,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; }); }; @@ -213,7 +242,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; }); }; @@ -250,7 +278,10 @@ function CalendarPicker({ const getAccessibilityState = useCallback((isSelected: boolean) => ({selected: isSelected}), []); return ( - + { pressableRef?.current?.blur(); - setIsYearPickerVisible(true); + openYearPicker(); }} ref={pressableRef} style={[themeStyles.alignItemsCenter]} @@ -433,14 +464,6 @@ function CalendarPicker({ ))} - setIsYearPickerVisible(false)} - shouldEnableBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane} - /> (null); const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to distinguish RHL and narrow layout // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); + const isDesktopWeb = getPlatform() === CONST.PLATFORM.WEB && !isSmallScreenWidth; + // On desktop web the date popover stays mounted while the year-selector RHP is open (so the picked year is + // applied on return). The CalendarPicker inside asks the hosting modal to hide in place via + // HiddenForOverlayContext — this host only has to keep the popover mounted and suppress the popstate close. + const isYearSelectorOpen = useIsYearSelectorOpen(); useEffect(() => { if (shouldSaveDraft && formID) { @@ -84,8 +94,10 @@ function DatePickerModal({ onClose={onClose} anchorPosition={anchorPosition} popoverDimensions={popoverDimensions} - shouldCloseWhenBrowserNavigationChanged={shouldCloseWhenBrowserNavigationChanged} - innerContainerStyle={isSmallScreenWidth ? styles.w100 : {width: CONST.POPOVER_DATE_WIDTH}} + // Suppress the popstate close while the year selector is open; selecting a year does a goBack (history + // change) and would otherwise tear this host down instead of returning to it with the new year applied. + shouldCloseWhenBrowserNavigationChanged={shouldCloseWhenBrowserNavigationChanged && !isYearSelectorOpen} + innerContainerStyle={isSmallScreenWidth ? styles.w100 : StyleUtils.getWidthStyle(CONST.POPOVER_DATE_WIDTH)} anchorAlignment={anchorAlignment} restoreFocusType={CONST.MODAL.RESTORE_FOCUS_TYPE.DELETE} shouldSwitchPositionIfOverflow @@ -103,6 +115,8 @@ function DatePickerModal({ onSelected={handleDateSelection} containerStyle={bottomSafeAreaPaddingStyle} shouldEnableMonthYearBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane} + pickerContextID={`datePicker-${inputID}`} + shouldCloseModalOnYearPickerOpen={!isDesktopWeb} /> ); diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 4bf525a9b0ca..42ef3c64d7ec 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -25,6 +25,7 @@ import {Keyboard, View} from 'react-native'; import type {DateInputWithPickerProps} from './types'; import DatePickerModal from './DatePickerModal'; +import useIsYearSelectorOpen from './useIsYearSelectorOpen'; const PADDING_MODAL_DATE_PICKER = 8; @@ -62,6 +63,13 @@ function DatePicker({ const textInputRef = useRef(null); const anchorRef = useRef(null); const [isInverted, setIsInverted] = useState(false); + const isYearSelectorOpen = useIsYearSelectorOpen(); + // Keep the picker open across the dynamic year-selector round-trip: the RHP opens over this popover and, on + // return, its goBack would otherwise close the picker before the picked year is applied. Track the open state in + // a ref (read synchronously in the close handler) plus a short grace window to absorb the return popstate that + // fires just as the flag flips back to false. + const isYearSelectorOpenRef = useRef(false); + const yearSelectorGraceRef = useRef(false); // Whether the user currently intends the picker to be open. Lets a deferred measurement skip opening if the // picker was dismissed before it resolved. const openIntentRef = useRef(false); @@ -132,7 +140,28 @@ function DatePicker({ openPicker(); }, [shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, calculatePopoverPosition, cancelAutoFocus]); + useEffect(() => { + isYearSelectorOpenRef.current = isYearSelectorOpen; + if (isYearSelectorOpen) { + yearSelectorGraceRef.current = true; + return; + } + if (!yearSelectorGraceRef.current) { + return; + } + const graceTimeoutID = setTimeout(() => { + yearSelectorGraceRef.current = false; + }, 250); + return () => clearTimeout(graceTimeoutID); + }, [isYearSelectorOpen]); + const closeDatePicker = useCallback(() => { + // Don't close while the year-selector RHP is open or just closed. Its goBack fires a popstate that reaches + // the popover's close listener AFTER isYearSelectorOpen has already flipped back to false (so the popstate + // gating can't catch it) — the grace window does, keeping the picker open with the picked year applied. + if (isYearSelectorOpenRef.current || yearSelectorGraceRef.current) { + return; + } openIntentRef.current = false; setIsModalVisible(false); diff --git a/src/components/DatePicker/useIsYearSelectorOpen.ts b/src/components/DatePicker/useIsYearSelectorOpen.ts new file mode 100644 index 000000000000..8835342bea3b --- /dev/null +++ b/src/components/DatePicker/useIsYearSelectorOpen.ts @@ -0,0 +1,19 @@ +import useRootNavigationState from '@hooks/useRootNavigationState'; + +import SCREENS from '@src/SCREENS'; + +import {findFocusedRoute} from '@react-navigation/native'; + +/** + * Whether the dynamic year-selector route is the currently focused screen. + * + * The year selector is a @react-navigation route, so opening it pushes a screen and selecting a year does a + * `goBack` (a real browser history change). Wide-screen popover hosts that keep their date picker mounted use + * this to (1) hide their whole popover frame while the selector is open and (2) suppress their + * `shouldCloseWhenBrowserNavigationChanged` listener so the `goBack` doesn't tear the host down. + */ +function useIsYearSelectorOpen(): boolean { + return useRootNavigationState((state) => (state ? findFocusedRoute(state)?.name === SCREENS.SETTINGS.DYNAMIC_YEAR_SELECTOR : false)); +} + +export default useIsYearSelectorOpen; diff --git a/src/components/Modal/ReanimatedModal/HiddenForOverlayContext.ts b/src/components/Modal/ReanimatedModal/HiddenForOverlayContext.ts new file mode 100644 index 000000000000..3c8ae61e58c0 --- /dev/null +++ b/src/components/Modal/ReanimatedModal/HiddenForOverlayContext.ts @@ -0,0 +1,14 @@ +import {createContext} from 'react'; + +/** + * Lets content rendered inside a ReanimatedModal ask its hosting modal to hide in place — visually hidden, + * backdrop dropped, and the whole modal subtree made pointer-transparent — while staying mounted so its state + * survives. Used when a @react-navigation route (e.g. the dynamic year-selector RHP) is intentionally shown + * over a kept-mounted popover: the modal's full-screen wrappers would otherwise paint over the route and + * swallow its clicks. The value is a stable React state setter; `undefined` means there is no ReanimatedModal + * ancestor (e.g. the content is hosted on a navigation card), in which case consumers fall back to hiding + * themselves. + */ +const HiddenForOverlayContext = createContext<((isHidden: boolean) => void) | undefined>(undefined); + +export default HiddenForOverlayContext; diff --git a/src/components/Modal/ReanimatedModal/index.tsx b/src/components/Modal/ReanimatedModal/index.tsx index e83763266120..8a555a24eac5 100644 --- a/src/components/Modal/ReanimatedModal/index.tsx +++ b/src/components/Modal/ReanimatedModal/index.tsx @@ -25,6 +25,7 @@ import type ReanimatedModalProps from './types'; import Backdrop from './Backdrop'; import Container from './Container'; +import HiddenForOverlayContext from './HiddenForOverlayContext'; function ReanimatedModal({ testID, @@ -64,11 +65,18 @@ function ReanimatedModal({ const [isVisibleState, setIsVisibleState] = useState(isVisible); const [isContainerOpen, setIsContainerOpen] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false); + // Content inside this modal (e.g. a CalendarPicker whose year selector opened as a route on top) can ask + // the modal to hide in place — visually hidden, no backdrop, pointer-transparent — while staying mounted. + // See HiddenForOverlayContext. + const [isHiddenForOverlay, setIsHiddenForOverlay] = useState(false); const {windowWidth, windowHeight} = useWindowDimensions(); const backHandlerListener = useRef(null); const handleRef = useRef(undefined); const transitionHandleRef = useRef(null); + // Web-only: RNW forwards the ref to ModalContent's outermost element; its parentElement is the + // full-screen portal root (ModalAnimation). See the pointer-events effect below. + const modalContentRef = useRef(null); const styles = useThemeStyles(); @@ -190,6 +198,28 @@ function ReanimatedModal({ return {zIndex: StyleSheet.flatten(style)?.zIndex}; }, [style]); + // react-native-web's renders two full-screen wrappers around the content: ModalAnimation + // (position:fixed, inset 0, z-index 9996 — the portal root) and ModalContent. While a route (the dynamic + // year-selector RHP) is intentionally shown over this kept-mounted popover, those wrappers would swallow + // the clicks meant for it, and RNW forwards no prop to make the root pointer-transparent (only `zIndex`). + // RNW does forward the ref to ModalContent's outermost element, whose parentElement IS the portal + // root — so it can be toggled directly, no DOM traversal needed. The content stays mounted (state survives + // the round-trip) and is already visually hidden, so disabling pointer events on the subtree is safe. + useEffect(() => { + if (getPlatform() !== CONST.PLATFORM.WEB) { + return; + } + const modalContent = modalContentRef.current; + const portalRoot = modalContent instanceof HTMLElement ? modalContent.parentElement : null; + if (!portalRoot) { + return; + } + portalRoot.style.pointerEvents = isHiddenForOverlay ? 'none' : ''; + return () => { + portalRoot.style.pointerEvents = ''; + }; + }, [isHiddenForOverlay, isVisibleState]); + const containerView = ( - {children} + {children} ); @@ -225,10 +255,10 @@ function ReanimatedModal({ if (!coverScreen && isVisibleState) { return ( - {hasBackdrop && backdropView} + {hasBackdrop && !isHiddenForOverlay && backdropView} {containerView} ); @@ -238,6 +268,11 @@ function ReanimatedModal({ return ( { + modalContentRef.current = node; + }} transparent animationType="none" visible={modalVisibility} @@ -253,7 +288,7 @@ function ReanimatedModal({ style={modalStyle} {...props} > - {isBackdropMounted && hasBackdrop && backdropView} + {isBackdropMounted && hasBackdrop && !isHiddenForOverlay && backdropView} {avoidKeyboard ? ( ; }; @@ -129,6 +132,7 @@ function DatePresetFilterBase({ onDateValuesChange, onRangeValidationErrorChange, forceVerticalCalendars = false, + shouldCloseModalOnYearPickerOpen = false, ref, }: DatePresetFilterBaseProps) { const theme = useTheme(); @@ -462,6 +466,7 @@ function DatePresetFilterBase({ onRangeValidationErrorChange?.(false); }} forceVertical={forceVerticalCalendars} + shouldCloseModalOnYearPickerOpen={shouldCloseModalOnYearPickerOpen} /> ); } @@ -473,6 +478,8 @@ function DatePresetFilterBase({ onSelected={handleSingleDateSelected} minDate={CONST.CALENDAR_PICKER.MIN_DATE} maxDate={CONST.CALENDAR_PICKER.MAX_DATE} + pickerContextID="searchSingleDate" + shouldCloseModalOnYearPickerOpen={shouldCloseModalOnYearPickerOpen} /> @@ -70,6 +75,8 @@ function RangeDatePicker({fromValue, toValue, onFromSelected, onToSelected, forc minDate={toMinDate} maxDate={CONST.CALENDAR_PICKER.MAX_DATE} headerContainerStyle={styles.ph4} + pickerContextID="searchRangeTo" + shouldCloseModalOnYearPickerOpen={shouldCloseModalOnYearPickerOpen} /> diff --git a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx index b3b3c168ed12..28bbf02aa231 100644 --- a/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/DateSelectPopup/index.tsx @@ -7,15 +7,19 @@ import type {SearchDatePreset} from '@components/Search/types'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import {clearCalendarPickerSelectedDateModifier, setCalendarPickerSelectedDateModifier} from '@libs/actions/CalendarPicker'; +import getPlatform from '@libs/getPlatform'; import type {SearchDateValues} from '@libs/SearchQueryUtils'; import {getDateModifierTitle, getDateRangeDisplayValueFromFormValue} from '@libs/SearchQueryUtils'; import type {SearchDateModifier} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {StyleProp, ViewStyle} from 'react-native'; @@ -50,6 +54,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 isDesktopWeb = getPlatform() === CONST.PLATFORM.WEB && !isSmallScreenWidth; const {translate} = useLocalize(); const styles = useThemeStyles(); @@ -58,6 +63,32 @@ function DateSelectPopup({label, value, presets, style, closeOverlay, onChange, const scrollViewRef = useRef>(null); const [selectedDateModifier, setSelectedDateModifier] = useState(null); const [shouldShowRangeError, setShouldShowRangeError] = useState(false); + + // Opening the year picker blurs the Search screen and unmounts this popover, resetting selectedDateModifier + // (the Custom date/range sub-view) back to the top menu on return. Persist it and restore it on return (a + // pending year write-back for a search calendar) so the calendar reopens with the picked year applied. + const [storedDateModifier] = useOnyx(ONYXKEYS.CALENDAR_PICKER_SELECTED_DATE_MODIFIER); + const [storedYearSelection] = useOnyx(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR); + const hasRestoredDateModifierRef = useRef(false); + + useEffect(() => { + if (!selectedDateModifier) { + return; + } + setCalendarPickerSelectedDateModifier(selectedDateModifier); + }, [selectedDateModifier]); + + useEffect(() => { + if (hasRestoredDateModifierRef.current || selectedDateModifier || !storedDateModifier || !storedYearSelection?.contextID.startsWith('search')) { + return; + } + const dateModifierToRestore = Object.values(CONST.SEARCH.DATE_MODIFIERS).find((modifier) => modifier === storedDateModifier); + if (!dateModifierToRestore) { + return; + } + hasRestoredDateModifierRef.current = true; + requestAnimationFrame(() => setSelectedDateModifier(dateModifierToRestore)); + }, [storedDateModifier, storedYearSelection, selectedDateModifier]); const [rangeText, setRangeText] = useState(() => getDateRangeDisplayValueFromFormValue(value[CONST.SEARCH.DATE_MODIFIERS.RANGE], value[CONST.SEARCH.DATE_MODIFIERS.AFTER], value[CONST.SEARCH.DATE_MODIFIERS.BEFORE]), ); @@ -82,6 +113,8 @@ function DateSelectPopup({label, value, presets, style, closeOverlay, onChange, }, [selectedDateModifier, setPopoverWidth]); const clearSelection = useCallback(() => { + // Leaving the sub-view (not via the year picker, which unmounts us instead) — drop the persisted breadcrumb. + clearCalendarPickerSelectedDateModifier(); setSelectedDateModifier(null); setShouldShowRangeError(false); }, []); @@ -139,6 +172,7 @@ function DateSelectPopup({label, value, presets, style, closeOverlay, onChange, presets={presets} onDateValuesChange={updateRangeText} onRangeValidationErrorChange={setShouldShowRangeError} + shouldCloseModalOnYearPickerOpen={!isDesktopWeb} /> {shouldShowRangeError && ( { + // The year-selector RHP's goBack fires this popover's onClose; ignore it while the year selector is open + // so the popover stays mounted and its CalendarPicker is there to consume/apply the picked year on return. + if (isYearSelectorOpen) { + return; + } setIsOverlayVisible((previousValue) => { if (!previousValue && willAlertModalBecomeVisible) { return false; @@ -107,8 +118,9 @@ function FilterPopupButton({viewportOffsetTop, popoverWidth, wrapperStyle, popov {/* Dropdown Trigger */} {renderButton({ref: triggerRef, onPress: calculatePopoverPositionAndToggleOverlay, isExpanded: isOverlayVisible})} - {/* Dropdown overlay */} - {isFocused && ( + {/* Dropdown overlay — keep mounted while the user has it open (isOverlayVisible), not while the + screen is focused, so opening the year-selector RHP (which blurs the screen) doesn't unmount it */} + {(isFocused || isOverlayVisible) && ( (null); + // Opening the year picker blurs the Search screen and unmounts this popover, resetting selectedDateModifier + // (the Custom date/range sub-view) back to the top menu on return. Persist it and restore it on return (a + // pending year write-back for a search calendar) so the calendar reopens with the picked year applied. + const [storedDateModifier] = useOnyx(ONYXKEYS.CALENDAR_PICKER_SELECTED_DATE_MODIFIER); + const [storedYearSelection] = useOnyx(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR); + const hasRestoredDateModifierRef = useRef(false); + + useEffect(() => { + if (!selectedDateModifier) { + return; + } + setCalendarPickerSelectedDateModifier(selectedDateModifier); + }, [selectedDateModifier]); + + useEffect(() => { + if (hasRestoredDateModifierRef.current || selectedDateModifier || !storedDateModifier || !storedYearSelection?.contextID.startsWith('search')) { + return; + } + const dateModifierToRestore = Object.values(CONST.SEARCH.DATE_MODIFIERS).find((modifier) => modifier === storedDateModifier); + if (!dateModifierToRestore) { + return; + } + hasRestoredDateModifierRef.current = true; + requestAnimationFrame(() => setSelectedDateModifier(dateModifierToRestore)); + }, [storedDateModifier, storedYearSelection, selectedDateModifier]); + + const handleDateModifierSelected = useCallback((modifier: SearchDateModifier | null) => { + // Leaving the sub-view (not via the year picker, which unmounts us instead) — drop the persisted breadcrumb. + if (!modifier) { + clearCalendarPickerSelectedDateModifier(); + } + setSelectedDateModifier(modifier); + }, []); + return ( diff --git a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx index 5bce405c838a..5899e55a1a55 100644 --- a/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx +++ b/src/components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx @@ -14,7 +14,7 @@ import type {SearchFilter} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import React, {useRef, useState} from 'react'; +import React, {useCallback, useRef, useState} from 'react'; import {View} from 'react-native'; import AmountFilterContentPopupWrapper from './AmountFilterContentPopupWrapper'; @@ -34,6 +34,30 @@ function SearchAdvancedFiltersPopup({queryJSON}: SearchAdvancedFiltersPopupProps const [selectedFilter, setSelectedFilter] = useState(CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE); const filterContentRef = useRef(null); const [searchAdvancedFiltersForm] = useOnyx(ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM); + const [storedYearSelection] = useOnyx(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR); + + // The year picker is reached only from the Date filter and opens as an RHP that blurs the Search screen. + // The popover now stays mounted across that blur (FilterPopupButton gates on isOverlayVisible), so + // selectedFilter remains Date. The only thing that would reset it is the FilterList's onFocus settling on the + // default Type item when focus returns — so while a search year write-back is pending (the user picked a year + // and is returning), ignore focus/hover-driven filter changes so the popover stays on the calendar. + const pendingSearchYearRestore = !!storedYearSelection?.contextID?.startsWith('search'); + + const handleSelectFilter = useCallback( + (key: SearchFilter['key']) => { + if (pendingSearchYearRestore) { + return; + } + setSelectedFilter(key); + }, + [pendingSearchYearRestore], + ); + + // While a search year write-back is pending, force the Date view so the CalendarPicker stays mounted to + // consume/apply the picked year and clear the pending key. This also makes the suppression above self-healing: + // even if a stale search year ever sticks in Onyx, the calendar is shown so it can consume and unblock — the + // filter never dead-ends on a blocked menu. + const effectiveFilter = pendingSearchYearRestore ? CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE : selectedFilter; const {updateFilterQueryParams} = useUpdateFilterQuery(queryJSON); @@ -44,9 +68,9 @@ function SearchAdvancedFiltersPopup({queryJSON}: SearchAdvancedFiltersPopupProps style={[styles.typeFiltersPopupContainer]} type={searchAdvancedFiltersForm?.type} policyID={searchAdvancedFiltersForm?.policyID} - selectedFilter={selectedFilter} - onHoverIn={setSelectedFilter} - onFocus={setSelectedFilter} + selectedFilter={effectiveFilter} + onHoverIn={handleSelectFilter} + onFocus={handleSelectFilter} /> ({ [SCREENS.SETTINGS.DYNAMIC_VERIFY_ACCOUNT]: () => require('../../../../pages/settings/DynamicVerifyAccountPage').default, [SCREENS.SETTINGS.DYNAMIC_ADD_BANK_ACCOUNT_VERIFY_ACCOUNT]: () => 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 061c14fdb2af..f08bebb91183 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -426,6 +426,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.SUBSCRIPTION.DYNAMIC_PAYMENT_CARD_CURRENCY_SELECTOR]: DYNAMIC_ROUTES.PAYMENT_CARD_CURRENCY_SELECTOR.path, [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: { path: ROUTES.SETTINGS_CONTACT_METHODS.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 1ce78ae876b4..acca607d3067 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -138,6 +138,19 @@ type SettingsNavigatorParamList = { backTo?: Routes; }; [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..08812d2eb2bf --- /dev/null +++ b/src/libs/actions/CalendarPicker.ts @@ -0,0 +1,31 @@ +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +/** + * 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); +} + +/** + * Stores the active Search date-filter sub-view (e.g. a Custom date/range modifier) so the date filter + * can restore it when the popover remounts after the year picker screen blurs and unmounts it. + */ +function setCalendarPickerSelectedDateModifier(selectedDateModifier: string) { + Onyx.set(ONYXKEYS.CALENDAR_PICKER_SELECTED_DATE_MODIFIER, selectedDateModifier); +} + +/** Clears the stored Search date-filter sub-view once it is no longer needed (the user left the sub-view rather than opening the year picker). */ +function clearCalendarPickerSelectedDateModifier() { + Onyx.set(ONYXKEYS.CALENDAR_PICKER_SELECTED_DATE_MODIFIER, null); +} + +export {setCalendarPickerSelectedYear, clearCalendarPickerSelectedYear, setCalendarPickerSelectedDateModifier, clearCalendarPickerSelectedDateModifier}; diff --git a/src/pages/ScheduleCall/ScheduleCallPage.tsx b/src/pages/ScheduleCall/ScheduleCallPage.tsx index 95856cb8749c..d17f89e68d7f 100644 --- a/src/pages/ScheduleCall/ScheduleCallPage.tsx +++ b/src/pages/ScheduleCall/ScheduleCallPage.tsx @@ -199,6 +199,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..c0d7efa20d84 --- /dev/null +++ b/tests/actions/CalendarPickerTest.ts @@ -0,0 +1,39 @@ +import {clearCalendarPickerSelectedYear, setCalendarPickerSelectedYear} from '@src/libs/actions/CalendarPicker'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import Onyx from 'react-native-onyx'; + +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/ui/TimeExpenseConfirmationTest.tsx b/tests/ui/TimeExpenseConfirmationTest.tsx index f0c448f5d049..b6ab041d3adb 100644 --- a/tests/ui/TimeExpenseConfirmationTest.tsx +++ b/tests/ui/TimeExpenseConfirmationTest.tsx @@ -27,6 +27,14 @@ import createRandomPolicy from '../utils/collections/policies'; import {signInWithTestUser} from '../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; +// DatePickerModal (rendered by the confirmation page's date field) mounts useRootNavigationState (via +// useIsYearSelectorOpen) to detect the year-selector route; this suite renders without a NavigationContainer, +// so mock it to the "navigation not ready" default. +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: (selector: (state: undefined) => T): T => selector(undefined), +})); + jest.mock('@components/MenuItemWithTopDescription', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const {View, Text} = require('react-native'); diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index 856c07514dfc..a5ad4fa5ae18 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -26,6 +26,14 @@ import createRandomPolicy from '../../utils/collections/policies'; import {signInWithTestUser, translateLocal} from '../../utils/TestHelper'; import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; +// DatePickerModal (rendered by the confirmation page's date field) mounts useRootNavigationState (via +// useIsYearSelectorOpen) to detect the year-selector route; this suite renders without a NavigationContainer, +// so mock it to the "navigation not ready" default. +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: (selector: (state: undefined) => T): T => selector(undefined), +})); + jest.mock('@rnmapbox/maps', () => { return { default: jest.fn(), diff --git a/tests/unit/CalendarPickerTest.tsx b/tests/unit/CalendarPickerTest.tsx index 2d90d881449f..11380342575e 100644 --- a/tests/unit/CalendarPickerTest.tsx +++ b/tests/unit/CalendarPickerTest.tsx @@ -1,15 +1,29 @@ -import {fireEvent, render, screen, userEvent, within} from '@testing-library/react-native'; +import {fireEvent, render, screen, userEvent, waitFor, within} from '@testing-library/react-native'; import CalendarPicker from '@components/DatePicker/CalendarPicker'; +import useIsYearSelectorOpen from '@components/DatePicker/useIsYearSelectorOpen'; +import useResponsiveLayoutDefault from '@hooks/useResponsiveLayout'; +import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; + +import {setCalendarPickerSelectedYear} from '@libs/actions/CalendarPicker'; +import * as Modal from '@libs/actions/Modal'; import DateUtils from '@libs/DateUtils'; +import getPlatform from '@libs/getPlatform'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as ReactNavigationNative from '@react-navigation/native'; -import type {ComponentType, ReactNode} from 'react'; +import type {ComponentProps, ComponentType, ReactNode} from 'react'; import {addMonths, addYears, subMonths, subYears} from 'date-fns'; +import {createElement} from 'react'; +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; type MockPressableProps = {testID?: string; accessibilityLabel?: string; role?: string; onPress?: () => void; children?: ReactNode}; type MockTextProps = {children?: ReactNode}; @@ -28,6 +42,41 @@ jest.mock('@react-navigation/native', () => ({ createNavigationContainerRef: jest.fn(), })); +// CalendarPicker reads useRootNavigationState (via useIsYearSelectorOpen); the bare navigationRef mock above +// has no isReady(), so stub the hook to resolve its selector against an undefined navigation state. +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: (selector: (state: undefined) => unknown) => selector(undefined), +})); + +// The wide-screen self-hide is driven by useIsYearSelectorOpen; default it to "closed" so every existing +// test renders the calendar normally, and toggle it to "open" per-test in the round-trip suite. +jest.mock('@components/DatePicker/useIsYearSelectorOpen', () => ({ + __esModule: true, + default: jest.fn(() => false), +})); + +// isDesktopWeb = getPlatform() === web && !isSmallScreenWidth. The real values in jsdom are already +// web + wide; mocking them with the same defaults keeps the existing tests unchanged while letting the +// round-trip suite switch to native / narrow to prove the hide does NOT apply there. +jest.mock('@libs/getPlatform', () => ({ + __esModule: true, + default: jest.fn(() => 'web'), +})); +jest.mock('@hooks/useResponsiveLayout', () => + jest.fn(() => ({ + shouldUseNarrowLayout: false, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isMediumScreenWidth: false, + isLargeScreenWidth: true, + isExtraSmallScreenWidth: false, + isSmallScreen: false, + onboardingIsMediumOrLargerScreenWidth: true, + })), +); + jest.mock('../../src/hooks/useLocalize', () => jest.fn(() => ({ translate: jest.fn(), @@ -37,12 +86,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'); @@ -79,43 +122,121 @@ 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; +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}); +} + +const mockedUseIsYearSelectorOpen = jest.mocked(useIsYearSelectorOpen); +const mockedGetPlatform = jest.mocked(getPlatform); +const mockedUseResponsiveLayout = jest.mocked(useResponsiveLayoutDefault); + +const WIDE_LAYOUT: ResponsiveLayoutResult = { + shouldUseNarrowLayout: false, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isMediumScreenWidth: false, + isLargeScreenWidth: true, + isExtraLargeScreenWidth: true, + isExtraSmallScreenWidth: false, + isSmallScreen: false, + onboardingIsMediumOrLargerScreenWidth: true, + isInLandscapeMode: true, +}; +const NARROW_LAYOUT: ResponsiveLayoutResult = { + ...WIDE_LAYOUT, + shouldUseNarrowLayout: true, + isSmallScreenWidth: true, + isLargeScreenWidth: false, + isExtraLargeScreenWidth: false, + isSmallScreen: true, +}; + +// The CalendarPicker self-hide is applied ONLY to its root View as the combination of +// pointerEvents='none' + a flattened style of {opacity: 0, visibility: 'hidden'}. Disabled/empty day +// cells also carry pointerEvents='none' (with no opacity), so match on the full hide signature. +type JsonNode = {type?: string; props?: {style?: unknown; pointerEvents?: unknown}; children?: Array | null}; + +function flattenStyle(style: unknown): {opacity?: unknown; visibility?: unknown} { + if (Array.isArray(style)) { + const merged: {opacity?: unknown; visibility?: unknown} = {}; + for (const entry of style) { + const flat = flattenStyle(entry); + if (flat.opacity !== undefined) { + merged.opacity = flat.opacity; + } + if (flat.visibility !== undefined) { + merged.visibility = flat.visibility; + } } - return ( - - {years.map((year) => ( - onYearChange?.(year.value)} - > - {year.text} - - ))} - - close - - - ); + return merged; + } + if (typeof style === 'object' && style !== null) { + return style; } - return MockYearPickerModal; + return {}; +} + +function isHidden(node: JsonNode): boolean { + const style = flattenStyle(node.props?.style); + // The hide uses styles.opacity0 + styles.visibilityHidden; the latter is a platform-split utility that is + // an empty object under jest's native module resolution (visibility is a web-only CSS rule), so the + // jest-observable hide signature is opacity 0 + pointerEvents none. + return node.props?.pointerEvents === 'none' && style.opacity === 0; +} + +// Walk the rendered JSON tree (plain serialized nodes — no react-test-renderer instances) and collect +// every element that has the full hide signature. +function collectHiddenNodes(node: JsonNode | null | undefined, acc: JsonNode[]): JsonNode[] { + if (!node) { + return acc; + } + if (isHidden(node)) { + acc.push(node); + } + for (const child of node.children ?? []) { + if (typeof child === 'string') { + continue; + } + collectHiddenNodes(child, acc); + } + return acc; +} + +function findHiddenRoots(): JsonNode[] { + const tree = screen.toJSON(); + const roots: Array = Array.isArray(tree) ? tree : [tree]; + const acc: JsonNode[] = []; + for (const root of roots) { + collectHiddenNodes(root, acc); + } + return acc; +} + +// Restore the default desktop-web / wide / year-selector-closed environment before every test so the +// per-test overrides in the round-trip suite can't leak into the rest of the file. +beforeEach(() => { + mockedUseIsYearSelectorOpen.mockReturnValue(false); + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockedUseResponsiveLayout.mockReturnValue(WIDE_LAYOUT); }); describe('CalendarPicker', () => { test('renders calendar component', () => { - render(); + render(); }); test('displays the current month and year', () => { @@ -123,7 +244,7 @@ describe('CalendarPicker', () => { const maxDate = addYears(new Date(currentDate), 1); const minDate = subYears(new Date(currentDate), 1); render( - , @@ -137,7 +258,7 @@ describe('CalendarPicker', () => { const minDate = new Date('2022-01-01'); const maxDate = new Date('2030-01-01'); render( - , @@ -150,7 +271,7 @@ describe('CalendarPicker', () => { }); test('clicking previous month arrow updates the displayed month', () => { - render(); + render(); fireEvent.press(screen.getByTestId('prev-month-arrow')); @@ -164,7 +285,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( - , @@ -221,7 +342,7 @@ describe('CalendarPicker', () => { const maxDate = new Date('2003-02-24'); const value = new Date('2003-02-17'); render( - , @@ -241,7 +362,7 @@ describe('CalendarPicker', () => { // given the max date is 27 render( - , @@ -255,7 +376,7 @@ describe('CalendarPicker', () => { const onSelectedMock = jest.fn(); const maxDate = new Date('2011-03-01'); render( - , @@ -268,7 +389,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(); }); @@ -277,7 +398,7 @@ describe('CalendarPicker', () => { const minDate = new Date('2035-02-16'); const maxDate = new Date('2040-02-16'); render( - , @@ -293,7 +414,7 @@ describe('CalendarPicker', () => { // given the min date is 16 render( - { // given the max date is 24 render( - { // given the min date is 16 render( - , @@ -362,7 +483,7 @@ describe('CalendarPicker', () => { // given the max date is 24 render( - , @@ -377,7 +498,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( - , @@ -427,7 +548,7 @@ describe('CalendarPicker', () => { const maxDate = new Date('2023-12-31'); const value = new Date('2023-06-15'); render( - , @@ -445,7 +566,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( - , @@ -534,7 +655,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( - , @@ -550,7 +671,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( - , @@ -567,7 +688,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( - , @@ -585,7 +706,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(); @@ -656,4 +792,157 @@ 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('*'); + }); +}); + +describe('year selector round-trip', () => { + const CONTEXT_ID = 'datePicker-roundTripInput'; + // A fixed starting view so an adopted year is unambiguous and "view preserved" can be asserted by + // checking that the month/day grid is NOT reset to today. + const START_VALUE = '2023-06-15'; + const MIN_DATE = new Date('2000-01-01'); + const MAX_DATE = new Date('2030-12-31'); + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(() => Onyx.clear().then(waitForBatchedUpdates)); + + test('adopts the year written back for its own contextID, preserves the month/day view, and clears the Onyx key', async () => { + render( + , + ); + + // Starts on the value's year/month, and the value's day (15) is selected in the grid. + expect(within(screen.getByTestId('currentYearText')).getByText('2023')).toBeTruthy(); + expect(within(screen.getByTestId('currentMonthText')).getByText(monthNames.at(5) ?? '')).toBeTruthy(); + expect(screen.getByLabelText('Thursday, June 15, 2023')).toBeTruthy(); + + // Simulate the year picker screen writing the user's selection back for THIS host's contextID, + // exactly as the real setter does on goBack. + setCalendarPickerSelectedYear(CONTEXT_ID, 2019); + await waitForBatchedUpdates(); + + // CalendarPicker consumes the matching contextID and applies the year to the displayed month + // (deferred via requestAnimationFrame). + await waitFor(() => { + expect(within(screen.getByTestId('currentYearText')).getByText('2019')).toBeTruthy(); + }); + + // The month/day VIEW is preserved (only the year changed via setYear(prev, year)) — it is NOT + // reset to today's month. June must still be shown, and the same day-of-month (June 15, 2019) + // is still rendered/selectable in the grid. + expect(within(screen.getByTestId('currentMonthText')).getByText(monthNames.at(5) ?? '')).toBeTruthy(); + expect(screen.getByLabelText('Saturday, June 15, 2019')).toBeTruthy(); + + // The transient selection is cleared so it isn't re-applied on the next render. + expect(await getOnyxValue(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR)).toBeUndefined(); + }); + + test('ignores a year written back for a DIFFERENT contextID and leaves the Onyx key intact for its owner', async () => { + render( + , + ); + + expect(within(screen.getByTestId('currentYearText')).getByText('2023')).toBeTruthy(); + + // A different host's selection lands in Onyx. + setCalendarPickerSelectedYear('datePicker-someOtherInput', 2019); + await waitForBatchedUpdates(); + + // Give the consume effect (and its deferred rAF) a chance to wrongly fire. + await waitFor(() => { + jest.advanceTimersByTime(0); + }); + await waitForBatchedUpdates(); + + // This instance must NOT consume it (contextID mismatch): year unchanged... + expect(within(screen.getByTestId('currentYearText')).getByText('2023')).toBeTruthy(); + expect(within(screen.getByTestId('currentMonthText')).getByText(monthNames.at(5) ?? '')).toBeTruthy(); + // ...and it must NOT clear the key — that's the owning host's responsibility. + expect(await getOnyxValue(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR)).toEqual({contextID: 'datePicker-someOtherInput', year: 2019}); + }); + + test('on desktop web the calendar root hides itself (opacity 0 + hidden + pointerEvents none) while the year selector is open', () => { + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockedUseResponsiveLayout.mockReturnValue(WIDE_LAYOUT); + mockedUseIsYearSelectorOpen.mockReturnValue(true); + + render( + , + ); + + // Exactly one element — the calendar root View — carries the hide signature. (visibility: 'hidden' + // is applied alongside via styles.visibilityHidden, a web-only platform-split utility that resolves + // to an empty object under jest's native module resolution, so it is not asserted here.) + const hiddenRoots = findHiddenRoots(); + expect(hiddenRoots).toHaveLength(1); + const root = hiddenRoots.at(0); + const style = flattenStyle(root?.props?.style); + expect(style.opacity).toBe(0); + expect(root?.props?.pointerEvents).toBe('none'); + }); + + test('on native the calendar does NOT hide even while the year selector is open', () => { + // Native dismisses its host instead of self-hiding, so isDesktopWeb is false (getPlatform !== web). + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.ANDROID); + mockedUseResponsiveLayout.mockReturnValue(NARROW_LAYOUT); + mockedUseIsYearSelectorOpen.mockReturnValue(true); + + render( + , + ); + + expect(findHiddenRoots()).toHaveLength(0); + expect(within(screen.getByTestId('currentYearText')).getByText('2023')).toBeTruthy(); + }); + + test('on narrow web the calendar does NOT self-hide even while the year selector is open (the narrow host owns dismissal)', () => { + // Narrow web keeps getPlatform === web but isSmallScreenWidth is true, so isDesktopWeb is false: + // the small-screen backdrop handles the overlap, the calendar must not opacity-hide itself. + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockedUseResponsiveLayout.mockReturnValue(NARROW_LAYOUT); + mockedUseIsYearSelectorOpen.mockReturnValue(true); + + render( + , + ); + + expect(findHiddenRoots()).toHaveLength(0); + expect(within(screen.getByTestId('currentYearText')).getByText('2023')).toBeTruthy(); + }); }); diff --git a/tests/unit/DatePickerModalYearSelectorTest.tsx b/tests/unit/DatePickerModalYearSelectorTest.tsx new file mode 100644 index 000000000000..e9ad1ad93e9f --- /dev/null +++ b/tests/unit/DatePickerModalYearSelectorTest.tsx @@ -0,0 +1,460 @@ +import {fireEvent, render, screen, waitFor, within} from '@testing-library/react-native'; + +import RealDatePicker from '@components/DatePicker'; +import type DatePickerModalType from '@components/DatePicker/DatePickerModal'; +import useIsYearSelectorOpen from '@components/DatePicker/useIsYearSelectorOpen'; + +import useResponsiveLayoutDefault from '@hooks/useResponsiveLayout'; +import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; + +import {setCalendarPickerSelectedYear} from '@libs/actions/CalendarPicker'; +import DateUtils from '@libs/DateUtils'; +import getPlatform from '@libs/getPlatform'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import type * as ReactNavigationNative from '@react-navigation/native'; +import type {ComponentProps, ComponentType, ReactNode} from 'react'; + +import {createElement} from 'react'; +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +type MockReactNativePrimitives = { + Pressable: ComponentType<{testID?: string; accessibilityLabel?: string; role?: string; onPress?: () => void; children?: ReactNode}>; + Text: ComponentType<{testID?: string; children?: ReactNode}>; + View: ComponentType<{testID?: string; style?: unknown; pointerEvents?: string; children?: ReactNode}>; +}; + +// DatePicker -> DatePickerModal -> CalendarPicker navigates via @libs/Navigation/Navigation and reads +// useRootNavigationState (through useIsYearSelectorOpen). Mirror CalendarPickerTest's mock setup so the bare +// navigation environment in this suite doesn't crash. +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({navigate: jest.fn()}), + createNavigationContainerRef: jest.fn(), +})); + +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: (selector: (state: undefined) => unknown) => selector(undefined), +})); + +// The keep-open guard (DatePicker) and the wide-screen frame-hide (DatePickerModal) are both driven by +// useIsYearSelectorOpen; default it "closed" and toggle it "open" per-test. +jest.mock('@components/DatePicker/useIsYearSelectorOpen', () => ({ + __esModule: true, + default: jest.fn(() => false), +})); + +// isDesktopWeb = getPlatform() === web && !isSmallScreenWidth. Mock both with desktop-web defaults so the +// real DatePickerModal's frame-hide branch is exercised, and switch to native/narrow per-test to prove it +// does NOT hide there. +jest.mock('@libs/getPlatform', () => ({ + __esModule: true, + default: jest.fn(() => 'web'), +})); +jest.mock('@hooks/useResponsiveLayout', () => + jest.fn(() => ({ + shouldUseNarrowLayout: false, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isMediumScreenWidth: false, + isLargeScreenWidth: true, + isExtraSmallScreenWidth: false, + isSmallScreen: false, + onboardingIsMediumOrLargerScreenWidth: true, + })), +); + +jest.mock('../../src/hooks/useLocalize', () => + jest.fn(() => ({ + translate: jest.fn(), + })), +); + +// The real DatePicker field wires useAutoFocusInput -> useFocusEffect -> useNavigation, which needs a +// NavigationContainer. The keep-open guard tests render the field in isolation and don't care about +// autofocus, so stub the hook with stable no-ops. +jest.mock('@hooks/useAutoFocusInput', () => ({ + __esModule: true, + default: () => ({ + inputCallbackRef: () => {}, + inputRef: {current: null}, + cancelAutoFocus: () => {}, + }), +})); + +jest.mock('@src/components/ConfirmedRoute.tsx'); + +// MonthPickerModal is irrelevant here but is rendered by CalendarPicker; stub it out. +jest.mock('@components/DatePicker/CalendarPicker/MonthPickerModal', () => { + const ReactNativeActual = jest.requireActual('react-native'); + const {View} = ReactNativeActual; + function MockMonthPickerModal({isVisible}: {isVisible: boolean}) { + return isVisible ? : null; + } + return MockMonthPickerModal; +}); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + navigate: jest.fn(), + getActiveRoute: jest.fn(() => 'settings/profile'), + }, +})); + +// Test 1 (keep-open guard) drives the REAL DatePicker (src/components/DatePicker/index.tsx) and only cares +// about whether closeDatePicker is suppressed. Stub DatePickerModal with a lightweight component that +// (a) renders its isVisible state to a testID and (b) exposes the onClose prop it received via a module-level +// ref, so the test can invoke the exact callback the real picker passes down. The real DatePickerModal is +// still reachable via jest.requireActual for the frame-hide tests. +jest.mock('@components/DatePicker/DatePickerModal', () => { + const ReactNativeActual = jest.requireActual('react-native'); + const {Pressable, Text, View} = ReactNativeActual; + function MockDatePickerModal({isVisible, onClose}: {isVisible: boolean; onClose: () => void}) { + return ( + + {isVisible ? 'visible' : 'hidden'} + {/* Invokes the exact onClose (closeDatePicker) the real picker passes down — the year-selector + goBack's popstate close path. fireEvent.press auto-wraps the state update in act(). */} + + + ); + } + return MockDatePickerModal; +}); + +// The real DatePickerModal is needed for the frame-hide tests; pull it past the jest.mock above. +const ActualDatePickerModal = jest.requireActual<{default: typeof DatePickerModalType}>('@components/DatePicker/DatePickerModal').default; + +// These tests assert what the real DatePickerModal computes for its host popover. PopoverWithMeasuredContent's +// real measurement/portal chain is too heavy to render reliably, so stub it with a component that surfaces the +// relevant props onto queryable rendered nodes. Since the hide-in-place moved into ReanimatedModal (driven by +// the CalendarPicker via HiddenForOverlayContext), the host passes NO hide wiring anymore — the mock renders +// 'unset' for the props the host no longer manages so the tests can assert exactly that. The mock also provides +// no HiddenForOverlayContext, so the CalendarPicker inside exercises its no-modal-ancestor self-hide fallback. +jest.mock('@components/PopoverWithMeasuredContent', () => { + const ReactNativeActual = jest.requireActual('react-native'); + const {Text, View} = ReactNativeActual; + function MockPopoverWithMeasuredContent({ + isVisible, + innerContainerStyle, + hasBackdrop, + children, + }: { + isVisible: boolean; + innerContainerStyle?: unknown; + hasBackdrop?: boolean; + children?: ReactNode; + }) { + if (!isVisible) { + return null; + } + return ( + + {hasBackdrop === undefined ? 'unset' : String(hasBackdrop)} + {children} + + ); + } + return MockPopoverWithMeasuredContent; +}); + +// CalendarPicker is rendered inside the real DatePickerModal (frame-hide tests); silence its month picker. +const monthNames = DateUtils.getMonthNames(); + +const mockedUseIsYearSelectorOpen = jest.mocked(useIsYearSelectorOpen); +const mockedGetPlatform = jest.mocked(getPlatform); +const mockedUseResponsiveLayout = jest.mocked(useResponsiveLayoutDefault); + +const WIDE_LAYOUT: ResponsiveLayoutResult = { + shouldUseNarrowLayout: false, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isMediumScreenWidth: false, + isLargeScreenWidth: true, + isExtraLargeScreenWidth: true, + isExtraSmallScreenWidth: false, + isSmallScreen: false, + onboardingIsMediumOrLargerScreenWidth: true, + isInLandscapeMode: true, +}; +const NARROW_LAYOUT: ResponsiveLayoutResult = { + ...WIDE_LAYOUT, + shouldUseNarrowLayout: true, + isSmallScreenWidth: true, + isLargeScreenWidth: false, + isExtraLargeScreenWidth: false, + isSmallScreen: true, +}; + +// Flatten an RN style (object | array | nested arrays) into the keys we assert on. +function flattenStyle(style: unknown): Record { + if (Array.isArray(style)) { + const merged: Record = {}; + for (const entry of style as unknown[]) { + Object.assign(merged, flattenStyle(entry)); + } + return merged; + } + if (typeof style !== 'object' || style === null) { + return {}; + } + return {...style}; +} + +// Restore the default desktop-web / wide / year-selector-closed environment before every test so a per-test +// override can't leak. Also reset the captured seams. +beforeEach(() => { + mockedUseIsYearSelectorOpen.mockReturnValue(false); + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockedUseResponsiveLayout.mockReturnValue(WIDE_LAYOUT); +}); + +describe('DatePicker keep-open guard across the year-selector round-trip', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(() => Onyx.clear().then(waitForBatchedUpdates)); + + function openPicker() { + // DatePicker's field is a combobox; pressing it runs showDatePickerModal() -> isModalVisible = true. + const field = screen.getByRole(CONST.ROLE.COMBOBOX); + fireEvent.press(field); + return field; + } + + test('suppresses closeDatePicker while the year selector is open (the picker stays open across the round-trip)', () => { + // The DOB host: a standalone date field that opens its picker in a popover. While the year-selector RHP + // is focused, the popover's onClose (closeDatePicker) must be a no-op so the picked year can be applied + // on return instead of the picker closing under the RHP. + mockedUseIsYearSelectorOpen.mockReturnValue(true); + + render(); + + openPicker(); + expect(screen.getByTestId('datePickerModalVisible')).toHaveTextContent('visible'); + + // Invoke the exact onClose the real picker handed to its modal (the year-selector goBack's popstate path). + fireEvent.press(screen.getByTestId('datePickerModalInvokeClose')); + + // The guard returns early: the picker is STILL open. + expect(screen.getByTestId('datePickerModalVisible')).toHaveTextContent('visible'); + }); + + test('closeDatePicker still closes the picker normally when the year selector is NOT open', () => { + // Fresh render with the year selector closed and no prior open->close transition, so the 250ms grace + // window is also clear: closeDatePicker proceeds and dismisses the popover as usual. + mockedUseIsYearSelectorOpen.mockReturnValue(false); + + render(); + + openPicker(); + expect(screen.getByTestId('datePickerModalVisible')).toHaveTextContent('visible'); + + fireEvent.press(screen.getByTestId('datePickerModalInvokeClose')); + + // Normal close path: the picker is now hidden. + expect(screen.getByTestId('datePickerModalVisible')).toHaveTextContent('hidden'); + }); +}); + +describe('DatePickerModal year-selector return path (DOB contextID)', () => { + const INPUT_ID = 'dob'; + const CONTEXT_ID = `datePicker-${INPUT_ID}`; + // A fixed starting view so an adopted year is unambiguous and "view preserved" is checkable. + const START_VALUE = '2023-06-15'; + const MIN_DATE = new Date('2000-01-01'); + const MAX_DATE = new Date('2030-12-31'); + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(() => Onyx.clear().then(waitForBatchedUpdates)); + + test('the date picker adopts the year written back for its datePicker- contextID, preserves the month/day view, and clears the Onyx key', async () => { + render( + , + ); + + // Starts on the value's year/month, with the value's day (15) selected in the grid. + expect(within(screen.getByTestId('currentYearText')).getByText('2023')).toBeTruthy(); + expect(within(screen.getByTestId('currentMonthText')).getByText(monthNames.at(5) ?? '')).toBeTruthy(); + expect(screen.getByLabelText('Thursday, June 15, 2023')).toBeTruthy(); + + // The year picker screen writes the user's selection back for THIS host's contextID, exactly as the + // real setter does on goBack. + setCalendarPickerSelectedYear(CONTEXT_ID, 2014); + await waitForBatchedUpdates(); + + // The inner CalendarPicker consumes the matching contextID and applies the year to the displayed + // month (deferred via requestAnimationFrame). + await waitFor(() => { + expect(within(screen.getByTestId('currentYearText')).getByText('2014')).toBeTruthy(); + }); + + // Only the year changed (setYear(prev, year)); June 15 is preserved (not reset to today). + expect(within(screen.getByTestId('currentMonthText')).getByText(monthNames.at(5) ?? '')).toBeTruthy(); + expect(screen.getByLabelText('Sunday, June 15, 2014')).toBeTruthy(); + + // The transient selection is cleared so it isn't re-applied on the next render. + expect(await getOnyxValue(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR)).toBeUndefined(); + }); + + test('a year written back for a DIFFERENT contextID is ignored and the Onyx key is left intact for its owner', async () => { + render( + , + ); + + expect(within(screen.getByTestId('currentYearText')).getByText('2023')).toBeTruthy(); + + // Another host's selection lands in Onyx; this instance must NOT consume it (contextID mismatch) and + // must NOT clear the key (that's the owning host's responsibility). + setCalendarPickerSelectedYear('datePicker-someOtherInput', 2014); + await waitForBatchedUpdates(); + + // Give the consume effect (and its deferred rAF) a chance to wrongly fire. + await waitFor(() => { + jest.advanceTimersByTime(0); + }); + await waitForBatchedUpdates(); + + expect(within(screen.getByTestId('currentYearText')).getByText('2023')).toBeTruthy(); + expect(within(screen.getByTestId('currentMonthText')).getByText(monthNames.at(5) ?? '')).toBeTruthy(); + expect(await getOnyxValue(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR)).toEqual({contextID: 'datePicker-someOtherInput', year: 2014}); + }); +}); + +describe('DatePickerModal year-selector hide wiring (hide lives in the modal via HiddenForOverlayContext, not the host)', () => { + const INPUT_ID = 'dob'; + const START_VALUE = '2023-06-15'; + const MIN_DATE = new Date('2000-01-01'); + const MAX_DATE = new Date('2030-12-31'); + + // The CalendarPicker's fallback self-hide signature (opacity 0 + pointerEvents none) — exercised here + // because the mocked PopoverWithMeasuredContent provides no HiddenForOverlayContext. visibility: 'hidden' + // is applied alongside via styles.visibilityHidden, a web-only platform-split utility that resolves to an + // empty object under jest's native module resolution, so it is not asserted. + const findSelfHiddenNodes = () => screen.UNSAFE_root.findAll((node) => node.props.pointerEvents === 'none' && flattenStyle(node.props.style).opacity === 0); + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(() => Onyx.clear().then(waitForBatchedUpdates)); + + test('on desktop web the host passes no hide wiring (the modal hides itself); without a modal ancestor the CalendarPicker self-hides', () => { + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockedUseResponsiveLayout.mockReturnValue(WIDE_LAYOUT); + mockedUseIsYearSelectorOpen.mockReturnValue(true); + + render( + , + ); + + // The host no longer hides the frame or manages the backdrop — that moved into ReanimatedModal, + // driven by the CalendarPicker through HiddenForOverlayContext (see ReanimatedModalHiddenForOverlayTest). + const frame = screen.getByTestId('datePickerModalFrame'); + const style = flattenStyle(frame.props.style); + expect(style.opacity).toBeUndefined(); + expect(screen.getByTestId('datePickerModalHasBackdrop')).toHaveTextContent('unset'); + + // The mocked popover provides no HiddenForOverlayContext, so the CalendarPicker falls back to + // hiding itself — the no-modal-ancestor path (same one navigation-card hosts rely on). + expect(findSelfHiddenNodes().length).toBeGreaterThanOrEqual(1); + }); + + test('on native nothing hides even while the year selector is open (the native host dismisses instead)', () => { + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.ANDROID); + mockedUseResponsiveLayout.mockReturnValue(NARROW_LAYOUT); + mockedUseIsYearSelectorOpen.mockReturnValue(true); + + render( + , + ); + + const frame = screen.getByTestId('datePickerModalFrame'); + const style = flattenStyle(frame.props.style); + expect(style.opacity).toBeUndefined(); + expect(frame.props.pointerEvents).toBeUndefined(); + expect(findSelfHiddenNodes()).toHaveLength(0); + }); + + test('on narrow web nothing hides even while the year selector is open (the small-screen backdrop handles the overlap)', () => { + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockedUseResponsiveLayout.mockReturnValue(NARROW_LAYOUT); + mockedUseIsYearSelectorOpen.mockReturnValue(true); + + render( + , + ); + + const frame = screen.getByTestId('datePickerModalFrame'); + const style = flattenStyle(frame.props.style); + expect(style.opacity).toBeUndefined(); + expect(frame.props.pointerEvents).toBeUndefined(); + expect(findSelfHiddenNodes()).toHaveLength(0); + }); +}); + +// Renders the real DatePicker field (src/components/DatePicker/index.tsx) used by the keep-open guard tests. +function DateFieldForTest(props: Partial>) { + return createElement(RealDatePicker, {inputID: 'dob', label: 'Date of birth', ...props}); +} diff --git a/tests/unit/RangeDatePickerYearSelectorTest.tsx b/tests/unit/RangeDatePickerYearSelectorTest.tsx new file mode 100644 index 000000000000..e64a0e2dcc53 --- /dev/null +++ b/tests/unit/RangeDatePickerYearSelectorTest.tsx @@ -0,0 +1,202 @@ +import {render, screen, within} from '@testing-library/react-native'; + +import RangeDatePicker from '@components/Search/FilterComponents/RangeDatePicker'; + +import {setCalendarPickerSelectedYear} from '@libs/actions/CalendarPicker'; +import DateUtils from '@libs/DateUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import type * as ReactNavigationNative from '@react-navigation/native'; +import type {ComponentType, ReactNode} from 'react'; + +import {act} from 'react'; +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +type MockViewProps = {children?: ReactNode}; +type MockReactNativePrimitives = { + View: ComponentType; +}; + +const monthNames = DateUtils.getMonthNames(); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({navigate: jest.fn()}), + createNavigationContainerRef: jest.fn(), +})); + +// CalendarPicker reads useRootNavigationState (via useIsYearSelectorOpen); the bare navigationRef mock above +// has no isReady(), so stub the hook to resolve its selector against an undefined navigation state. +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: (selector: (state: undefined) => unknown) => selector(undefined), +})); + +jest.mock('../../src/hooks/useLocalize', () => + jest.fn(() => ({ + translate: (key: string) => key, + })), +); + +jest.mock('@src/components/ConfirmedRoute.tsx'); + +type MockMonthPickerModalProps = {isVisible: boolean}; + +// RangeDatePicker mounts two CalendarPickers, each rendering a MonthPickerModal. Stub it so the +// suite doesn't pull in the modal machinery (we only care about the calendar month/year header). +jest.mock('@components/DatePicker/CalendarPicker/MonthPickerModal', () => { + const ReactNativeActual = jest.requireActual('react-native'); + const {View} = ReactNativeActual; + function MockMonthPickerModal({isVisible}: MockMonthPickerModalProps) { + if (!isVisible) { + return null; + } + return ; + } + return MockMonthPickerModal; +}); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + navigate: jest.fn(), + getActiveRoute: jest.fn(() => 'search'), + }, +})); + +/** + * The year write-back is delivered through Onyx and then applied to the displayed month on the next + * animation frame (see CalendarPicker's selectedYearResult effect). Flush both the batched Onyx + * updates and the queued requestAnimationFrame so the calendar header reflects the new year. + */ +async function flushYearWriteBack() { + await act(async () => { + await waitForBatchedUpdates(); + }); + await act(async () => { + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + }); +} + +/** + * Reads the year currently displayed in the From or To calendar. RangeDatePicker always renders the + * From CalendarPicker first and the To CalendarPicker second (see RangeDatePicker.tsx), so the two + * `currentYearText` nodes are deterministically ordered: index 0 == From, index 1 == To. The From/To + * section labels are asserted separately to confirm that ordering matches the rendered sections. + */ +function getDisplayedYear(section: 'from' | 'to'): string { + const [fromYearText, toYearText] = screen.getAllByTestId('currentYearText'); + const node = section === 'from' ? fromYearText : toYearText; + expect(node).toBeTruthy(); + // The year is rendered as a number child, so coerce to a string for a stable comparison. + return String(within(node).getByText(/\d{4}/).props.children); +} + +describe('RangeDatePicker year selector write-back (per-contextID isolation)', () => { + beforeEach(async () => { + await Onyx.set(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR, null); + await waitForBatchedUpdates(); + }); + + async function renderRangeDatePicker() { + const result = render( + , + ); + // Let the initial CALENDAR_PICKER_SELECTED_YEAR Onyx subscription settle inside act(...) + // so the (no-op) store callback doesn't trigger an unwrapped-update warning later. + await act(async () => { + await waitForBatchedUpdates(); + }); + return result; + } + + test('renders two calendars (From and To) seeded at their initial years', async () => { + await renderRangeDatePicker(); + + // Both section labels render, confirming two distinct calendars are mounted in From/To order. + expect(screen.getByText('common.from')).toBeTruthy(); + expect(screen.getByText('common.to')).toBeTruthy(); + + // The two CalendarPickers start on the years implied by their values. + expect(getDisplayedYear('from')).toBe('2010'); + expect(getDisplayedYear('to')).toBe('2012'); + }); + + test('a year write-back for searchRangeFrom only updates the From calendar and clears the key', async () => { + await renderRangeDatePicker(); + + expect(getDisplayedYear('from')).toBe('2010'); + expect(getDisplayedYear('to')).toBe('2012'); + + // Simulate the year picker screen returning a selection for the From calendar's context. + await act(async () => { + setCalendarPickerSelectedYear('searchRangeFrom', 2015); + await waitForBatchedUpdates(); + }); + await flushYearWriteBack(); + + // Only the From calendar adopts 2015; the To calendar is untouched (no key collision). + expect(getDisplayedYear('from')).toBe('2015'); + expect(getDisplayedYear('to')).toBe('2012'); + + // The month (June) is preserved on the From calendar — only the year changed. + const [fromMonthText] = screen.getAllByTestId('currentMonthText'); + expect(String(within(fromMonthText).getByText(/\w+/).props.children)).toBe(monthNames.at(5)); + + // The transient selection is cleared after being consumed, so it can't be re-applied. + const remaining = await getOnyxValue(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR); + expect(remaining).toBeFalsy(); + }); + + test('a year write-back for searchRangeTo only updates the To calendar and clears the key', async () => { + await renderRangeDatePicker(); + + expect(getDisplayedYear('from')).toBe('2010'); + expect(getDisplayedYear('to')).toBe('2012'); + + // Simulate the year picker screen returning a selection for the To calendar's context. + await act(async () => { + setCalendarPickerSelectedYear('searchRangeTo', 2017); + await waitForBatchedUpdates(); + }); + await flushYearWriteBack(); + + // Only the To calendar adopts 2017; the From calendar is untouched. + expect(getDisplayedYear('from')).toBe('2010'); + expect(getDisplayedYear('to')).toBe('2017'); + + const remaining = await getOnyxValue(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR); + expect(remaining).toBeFalsy(); + }); + + test('an unrelated contextID write-back updates neither calendar but is still cleared', async () => { + await renderRangeDatePicker(); + + // A selection tagged with a contextID belonging to a different host (e.g. a DatePickerModal) + // must be ignored by both range calendars — the guard is `contextID === pickerContextID`. + await act(async () => { + setCalendarPickerSelectedYear('datePicker-someOtherInput', 1999); + await waitForBatchedUpdates(); + }); + await flushYearWriteBack(); + + expect(getDisplayedYear('from')).toBe('2010'); + expect(getDisplayedYear('to')).toBe('2012'); + + // Neither calendar matched, so neither cleared it — assert it is still present and untouched, + // proving the From/To instances did not erroneously consume another instance's selection. + const remaining = await getOnyxValue(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR); + expect(remaining).toEqual({contextID: 'datePicker-someOtherInput', year: 1999}); + }); +}); diff --git a/tests/unit/ReanimatedModalHiddenForOverlayTest.tsx b/tests/unit/ReanimatedModalHiddenForOverlayTest.tsx new file mode 100644 index 000000000000..49673fc91882 --- /dev/null +++ b/tests/unit/ReanimatedModalHiddenForOverlayTest.tsx @@ -0,0 +1,150 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import ReanimatedModal from '@components/Modal/ReanimatedModal'; +import HiddenForOverlayContext from '@components/Modal/ReanimatedModal/HiddenForOverlayContext'; +import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; + +import type {ComponentType, ReactNode} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; + +import React, {useContext, useEffect} from 'react'; +import {View} from 'react-native'; + +type MockViewProps = {testID?: string; style?: StyleProp; children?: ReactNode}; +type MockReactNativePrimitives = {View: ComponentType}; +type MockReactPrimitives = {useEffect: typeof useEffect}; + +// The presentation-only subtrees are stubbed so the suite exercises ReanimatedModal's real logic: the +// HiddenForOverlayContext provider, the hidden-for-overlay state, the container hide style, and the backdrop +// gating. (The pointer-events effect on the RNW portal root is web-only DOM behavior and is exercised live.) +jest.mock('@components/FocusTrap/FocusTrapForModal', () => { + function MockFocusTrapForModal({children}: {children: ReactNode}) { + return children; + } + return MockFocusTrapForModal; +}); + +jest.mock('@components/Modal/ReanimatedModal/Backdrop', () => { + const ReactNativeActual = jest.requireActual('react-native'); + function MockBackdrop() { + return ; + } + return MockBackdrop; +}); + +jest.mock('@components/Modal/ReanimatedModal/Container', () => { + const ReactActual = jest.requireActual('react'); + const ReactNativeActual = jest.requireActual('react-native'); + function MockContainer({children, style, onOpenCallBack}: {children: ReactNode; style?: StyleProp; onOpenCallBack?: () => void}) { + ReactActual.useEffect(() => { + onOpenCallBack?.(); + }, []); + return ( + + {children} + + ); + } + return MockContainer; +}); + +function flattenStyle(style: unknown): Record { + if (Array.isArray(style)) { + let merged: Record = {}; + for (const part of style) { + merged = {...merged, ...flattenStyle(part)}; + } + return merged; + } + if (typeof style !== 'object' || style === null) { + return {}; + } + return {...style}; +} + +// A modal child standing in for the CalendarPicker: it consumes HiddenForOverlayContext and asks the hosting +// modal to hide/show itself, exactly like the picker does when the year-selector route opens/closes. +function HideRequester() { + const setHiddenForOverlay = useContext(HiddenForOverlayContext); + return ( + + + setHiddenForOverlay?.(true)} + /> + setHiddenForOverlay?.(false)} + /> + + ); +} + +// Mirrors the CalendarPicker unmount cleanup (setHiddenForOverlay(false) on effect teardown). +function HideOnMount() { + const setHiddenForOverlay = useContext(HiddenForOverlayContext); + useEffect(() => { + setHiddenForOverlay?.(true); + return () => setHiddenForOverlay?.(false); + }, [setHiddenForOverlay]); + return null; +} + +function renderModal(children: ReactNode) { + return render( + + {children} + , + ); +} + +describe('ReanimatedModal HiddenForOverlayContext', () => { + test('provides the context to its content', () => { + renderModal(); + expect(screen.getByTestId('hasProvider-true')).toBeTruthy(); + }); + + test('hides in place when content requests it: container gets the hide style and the backdrop is dropped, both restored on release', () => { + renderModal(); + + // Visible baseline: backdrop mounted, no hide style on the container. + expect(screen.getByTestId('reanimatedModalBackdrop')).toBeTruthy(); + expect(flattenStyle(screen.getByTestId('reanimatedModalContainer').props.style).opacity).toBeUndefined(); + + // Content asks the modal to hide (what CalendarPicker does when the year selector opens): the modal + // stays mounted — its content/state survive — but is visually hidden and backdrop-less. (visibility: + // 'hidden' rides along via styles.visibilityHidden, a web-only platform-split utility that is an empty + // object under jest's native module resolution, so opacity is the observable signal here.) + fireEvent.press(screen.getByTestId('requestHide')); + expect(screen.queryByTestId('reanimatedModalBackdrop')).toBeNull(); + expect(flattenStyle(screen.getByTestId('reanimatedModalContainer').props.style).opacity).toBe(0); + expect(screen.getByTestId('hasProvider-true')).toBeTruthy(); + + // Content releases the hide (year selector closed): backdrop and visibility restored. + fireEvent.press(screen.getByTestId('requestShow')); + expect(screen.getByTestId('reanimatedModalBackdrop')).toBeTruthy(); + expect(flattenStyle(screen.getByTestId('reanimatedModalContainer').props.style).opacity).toBeUndefined(); + }); + + test('a consumer unmounting while hidden releases the hide (effect cleanup)', () => { + renderModal(); + expect(screen.queryByTestId('reanimatedModalBackdrop')).toBeNull(); + + // Unmounting the requester (e.g. the picker view is torn down) must not leave the modal stuck hidden. + renderModal(); + expect(screen.getByTestId('reanimatedModalBackdrop')).toBeTruthy(); + }); +}); diff --git a/tests/unit/ScheduleCallYearSelectorTest.tsx b/tests/unit/ScheduleCallYearSelectorTest.tsx new file mode 100644 index 000000000000..e29ef26bd797 --- /dev/null +++ b/tests/unit/ScheduleCallYearSelectorTest.tsx @@ -0,0 +1,288 @@ +import {render, screen, waitFor, within} from '@testing-library/react-native'; + +import CalendarPicker from '@components/DatePicker/CalendarPicker'; +import useIsYearSelectorOpen from '@components/DatePicker/useIsYearSelectorOpen'; + +import useResponsiveLayoutDefault from '@hooks/useResponsiveLayout'; +import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; + +import {setCalendarPickerSelectedYear} from '@libs/actions/CalendarPicker'; +import DateUtils from '@libs/DateUtils'; +import getPlatform from '@libs/getPlatform'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import type * as ReactNavigationNative from '@react-navigation/native'; +import type {ComponentProps, ComponentType, ReactNode} from 'react'; + +import {createElement} from 'react'; +import Onyx from 'react-native-onyx'; + +import getOnyxValue from '../utils/getOnyxValue'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +type MockReactNativePrimitives = { + View: ComponentType<{testID?: string; children?: ReactNode}>; +}; + +// CalendarPicker navigates via @libs/Navigation/Navigation and reads useRootNavigationState (through +// useIsYearSelectorOpen). Mirror CalendarPickerTest's mock setup so the bare navigation environment in +// this suite doesn't crash. +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({navigate: jest.fn()}), + createNavigationContainerRef: jest.fn(), +})); + +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: (selector: (state: undefined) => unknown) => selector(undefined), +})); + +// The wide-screen self-hide is driven by useIsYearSelectorOpen; default it to "closed" and toggle it +// "open" per-test. +jest.mock('@components/DatePicker/useIsYearSelectorOpen', () => ({ + __esModule: true, + default: jest.fn(() => false), +})); + +// isDesktopWeb = getPlatform() === web && !isSmallScreenWidth. Mock both with desktop-web defaults so +// the inner self-hide branch is exercised (ScheduleCallPage has no host frame to hide). +jest.mock('@libs/getPlatform', () => ({ + __esModule: true, + default: jest.fn(() => 'web'), +})); +jest.mock('@hooks/useResponsiveLayout', () => + jest.fn(() => ({ + shouldUseNarrowLayout: false, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isMediumScreenWidth: false, + isLargeScreenWidth: true, + isExtraSmallScreenWidth: false, + isSmallScreen: false, + onboardingIsMediumOrLargerScreenWidth: true, + })), +); + +jest.mock('../../src/hooks/useLocalize', () => + jest.fn(() => ({ + translate: jest.fn(), + })), +); + +jest.mock('@src/components/ConfirmedRoute.tsx'); + +// MonthPickerModal is irrelevant here but is rendered by CalendarPicker; stub it out. +jest.mock('@components/DatePicker/CalendarPicker/MonthPickerModal', () => { + const ReactNativeActual = jest.requireActual('react-native'); + const {View} = ReactNativeActual; + function MockMonthPickerModal({isVisible}: {isVisible: boolean}) { + return isVisible ? : null; + } + return MockMonthPickerModal; +}); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + navigate: jest.fn(), + getActiveRoute: jest.fn(() => 'settings/profile'), + }, +})); + +const monthNames = DateUtils.getMonthNames(); + +const mockedUseIsYearSelectorOpen = jest.mocked(useIsYearSelectorOpen); +const mockedGetPlatform = jest.mocked(getPlatform); +const mockedUseResponsiveLayout = jest.mocked(useResponsiveLayoutDefault); + +const WIDE_LAYOUT: ResponsiveLayoutResult = { + shouldUseNarrowLayout: false, + isSmallScreenWidth: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isMediumScreenWidth: false, + isLargeScreenWidth: true, + isExtraLargeScreenWidth: false, + isExtraSmallScreenWidth: false, + isSmallScreen: false, + isInLandscapeMode: false, + onboardingIsMediumOrLargerScreenWidth: true, +}; +const NARROW_LAYOUT = {...WIDE_LAYOUT, shouldUseNarrowLayout: true, isSmallScreenWidth: true, isLargeScreenWidth: false, isSmallScreen: true}; + +// The CalendarPicker self-hide is applied ONLY to its root View as the combination of +// pointerEvents='none' + a style flattening to {opacity: 0, visibility: 'hidden'}. Disabled/empty day +// cells also carry pointerEvents='none' (with no opacity), so match on the full hide signature to +// isolate the root, not on pointerEvents alone. +function flattenStyle(style: unknown): Record { + if (Array.isArray(style)) { + const merged: Record = {}; + for (const entry of style as unknown[]) { + Object.assign(merged, flattenStyle(entry)); + } + return merged; + } + if (typeof style !== 'object' || style === null) { + return {}; + } + return {...style}; +} +function findHiddenRoots() { + return screen.UNSAFE_root.findAll((node) => node.props.pointerEvents === 'none' && flattenStyle(node.props.style).opacity === 0); +} + +// ScheduleCallPage renders CalendarPicker with this stable id (ScheduleCallPage.tsx:197) and relies on +// the inner CalendarPicker self-hide (no host frame-hide), so rendering CalendarPicker with this exact +// pickerContextID exercises the same consume/hide path the page uses. +const SCHEDULE_CALL_CONTEXT_ID = 'scheduleCall'; +// A fixed starting view so an adopted year is unambiguous. +const START_VALUE = '2020-06-15'; +const MIN_DATE = new Date('2010-01-01'); +const MAX_DATE = new Date('2030-12-31'); + +function ScheduleCallCalendarPicker(props: Omit, 'pickerContextID'>) { + return createElement(CalendarPicker, {pickerContextID: SCHEDULE_CALL_CONTEXT_ID, ...props}); +} + +// Restore the default desktop-web / wide / year-selector-closed environment before every test so a +// per-test override can't leak. +beforeEach(() => { + mockedUseIsYearSelectorOpen.mockReturnValue(false); + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + mockedUseResponsiveLayout.mockReturnValue(WIDE_LAYOUT); +}); + +describe('ScheduleCall year selector return path', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(() => Onyx.clear().then(waitForBatchedUpdates)); + + test('the scheduleCall CalendarPicker adopts the year written back for its contextID, preserves the month/day view, and clears the Onyx key', async () => { + render( + , + ); + + // Starts on the value's year/month, with the value's day (15) selected in the grid. + expect(within(screen.getByTestId('currentYearText')).getByText('2020')).toBeTruthy(); + expect(within(screen.getByTestId('currentMonthText')).getByText(monthNames.at(5) ?? '')).toBeTruthy(); + expect(screen.getByLabelText('Monday, June 15, 2020')).toBeTruthy(); + + // Simulate the year picker screen writing the user's selection back for THIS host's contextID, + // exactly as the real setter does on goBack. + setCalendarPickerSelectedYear(SCHEDULE_CALL_CONTEXT_ID, 2016); + await waitForBatchedUpdates(); + + // CalendarPicker consumes the matching contextID and applies the year to the displayed month + // (deferred via requestAnimationFrame). + await waitFor(() => { + expect(within(screen.getByTestId('currentYearText')).getByText('2016')).toBeTruthy(); + }); + + // Only the year changed (setYear(prev, year)); June 15 is preserved (not reset to today). + expect(within(screen.getByTestId('currentMonthText')).getByText(monthNames.at(5) ?? '')).toBeTruthy(); + expect(screen.getByLabelText('Wednesday, June 15, 2016')).toBeTruthy(); + + // The transient selection is cleared so it isn't re-applied on the next render. + expect(await getOnyxValue(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR)).toBeUndefined(); + }); + + test('a year written back for a DIFFERENT contextID is ignored and the Onyx key is left intact for its owner', async () => { + render( + , + ); + + expect(within(screen.getByTestId('currentYearText')).getByText('2020')).toBeTruthy(); + + // A different host's selection lands in Onyx; the scheduleCall instance must NOT consume it + // (contextID mismatch) and must NOT clear the key (that's the owning host's responsibility). + setCalendarPickerSelectedYear('datePicker-someOtherInput', 2016); + await waitForBatchedUpdates(); + + // Give the consume effect (and its deferred rAF) a chance to wrongly fire. + await waitFor(() => { + jest.advanceTimersByTime(0); + }); + await waitForBatchedUpdates(); + + // Year unchanged... + expect(within(screen.getByTestId('currentYearText')).getByText('2020')).toBeTruthy(); + expect(within(screen.getByTestId('currentMonthText')).getByText(monthNames.at(5) ?? '')).toBeTruthy(); + // ...and the foreign selection is left intact for its owner. + expect(await getOnyxValue(ONYXKEYS.CALENDAR_PICKER_SELECTED_YEAR)).toEqual({contextID: 'datePicker-someOtherInput', year: 2016}); + }); + + test('on desktop web the scheduleCall calendar root self-hides (opacity 0 + hidden + pointerEvents none) while the year selector is open', () => { + // ScheduleCallPage has no popover/modal frame around CalendarPicker, so the open-state hide must + // come from the inner CalendarPicker itself (no host frame-hide). + mockedUseIsYearSelectorOpen.mockReturnValue(true); + + render( + , + ); + + // The hide is applied to exactly one logical element — the calendar root View — though + // UNSAFE_root surfaces it more than once; assert every hidden match carries the hide signature. + // (visibility: 'hidden' is applied alongside via styles.visibilityHidden, a web-only platform-split + // utility that resolves to an empty object under jest's native module resolution, so it is not + // asserted here.) + const hiddenRoots = findHiddenRoots(); + expect(hiddenRoots.length).toBeGreaterThanOrEqual(1); + for (const node of hiddenRoots) { + const style = flattenStyle(node.props.style); + expect(style.opacity).toBe(0); + expect(node.props.pointerEvents).toBe('none'); + } + }); + + test('when the year selector is closed the scheduleCall calendar is visible and interactive', () => { + mockedUseIsYearSelectorOpen.mockReturnValue(false); + + render( + , + ); + + // No element carries the self-hide signature, so the calendar stays visible. + expect(findHiddenRoots()).toHaveLength(0); + expect(within(screen.getByTestId('currentYearText')).getByText('2020')).toBeTruthy(); + }); + + test('on native the scheduleCall calendar does NOT self-hide even while the year selector is open', () => { + // Native dismisses its host instead of self-hiding, so isDesktopWeb is false (getPlatform !== web). + mockedGetPlatform.mockReturnValue(CONST.PLATFORM.ANDROID); + mockedUseResponsiveLayout.mockReturnValue(NARROW_LAYOUT); + mockedUseIsYearSelectorOpen.mockReturnValue(true); + + render( + , + ); + + expect(findHiddenRoots()).toHaveLength(0); + expect(within(screen.getByTestId('currentYearText')).getByText('2020')).toBeTruthy(); + }); +}); diff --git a/tests/unit/SearchAdvancedFiltersYearSelectorTest.tsx b/tests/unit/SearchAdvancedFiltersYearSelectorTest.tsx new file mode 100644 index 000000000000..931ae857f64b --- /dev/null +++ b/tests/unit/SearchAdvancedFiltersYearSelectorTest.tsx @@ -0,0 +1,174 @@ +import {render, screen} from '@testing-library/react-native'; + +import type SearchAdvancedFiltersPopupComponent from '@components/Search/FilterDropdowns/SearchAdvancedFiltersPopup'; + +import {clearCalendarPickerSelectedYear, setCalendarPickerSelectedYear} from '@libs/actions/CalendarPicker'; +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import type * as ReactNavigationNative from '@react-navigation/native'; +import type {ComponentType, ReactNode} from 'react'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +// The popover lives only in the WEB variant (index.tsx) — the native variant returns null because advanced +// filters are served from a full page on native. jest-expo's haste platform resolves the bare import to +// index.native.tsx, so pull in the web file explicitly to exercise the effectiveFilter logic under test. +const {default: SearchAdvancedFiltersPopup} = jest.requireActual<{default: typeof SearchAdvancedFiltersPopupComponent}>( + '@components/Search/FilterDropdowns/SearchAdvancedFiltersPopup/index.tsx', +); + +type MockReactNativePrimitives = { + View: ComponentType<{testID?: string; children?: ReactNode}>; + Text: ComponentType<{children?: ReactNode}>; +}; + +// The host wraps its body in SafeTriangle (pulls in react-native-svg + Browser detection). +// We only care about what filter the host hands down, so render the children straight through. +jest.mock('@components/SafeTriangle', () => { + const {View} = jest.requireActual('react-native'); + function MockSafeTriangle({children}: {children?: ReactNode}) { + return {children}; + } + return MockSafeTriangle; +}); + +// FilterList renders icons, lazy assets and the whole advanced-filters menu tree. We don't need any of +// that to prove the host's selection logic — we only need to observe the `selectedFilter` it is handed. +jest.mock('@components/Search/FilterComponents/AdvancedFilters/FilterList', () => { + const {View, Text} = jest.requireActual('react-native'); + function MockFilterList({selectedFilter}: {selectedFilter?: string}) { + return ( + + {`selectedFilter:${selectedFilter ?? 'undefined'}`} + + ); + } + return MockFilterList; +}); + +// SearchAdvancedFiltersContent renders the concrete per-filter content (date pickers, amount inputs, ...). +// Stub it to surface the `filterKey` (== effectiveFilter) the host selected to display. +jest.mock('@components/Search/FilterComponents/AdvancedFilters/SearchAdvancedFiltersContent', () => { + const {View, Text} = jest.requireActual('react-native'); + function MockSearchAdvancedFiltersContent({filterKey}: {filterKey?: string}) { + return ( + + {`filterKey:${filterKey ?? 'undefined'}`} + + ); + } + return MockSearchAdvancedFiltersContent; +}); + +// useUpdateFilterQuery only matters when the user edits a filter value; it pulls in Navigation/query-building. +// The host just needs `updateFilterQueryParams` to exist for the (untriggered) onChange handler. +jest.mock('@components/Search/hooks/useUpdateFilterQuery', () => ({ + __esModule: true, + default: () => ({updateFilterQueryParams: jest.fn(), setFilterQueryParams: jest.fn()}), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({navigate: jest.fn()}), + createNavigationContainerRef: jest.fn(), +})); + +// The year-selector hide gate reads useRootNavigationState via useIsYearSelectorOpen; resolve the selector +// against an undefined navigation state so the bare navigation mock above doesn't crash (mirrors CalendarPickerTest). +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: (selector: (state: undefined) => unknown) => selector(undefined), +})); + +// A real, fully-typed SearchQueryJSON (the host only forwards it to the mocked useUpdateFilterQuery and to the +// mocked content's policyIDQuery, so a minimal expense query is enough). buildSearchQueryJSON returns undefined +// only for an unparseable query; throw if that ever happens so the prop stays non-optional without a cast. +const builtQueryJSON = buildSearchQueryJSON(`${CONST.SEARCH.SYNTAX_ROOT_KEYS.TYPE}:${CONST.SEARCH.DATA_TYPES.EXPENSE}`); +if (!builtQueryJSON) { + throw new Error('Failed to build the test SearchQueryJSON'); +} +const queryJSON = builtQueryJSON; + +const TYPE = CONST.SEARCH.SYNTAX_FILTER_KEYS.TYPE; +const DATE = CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE; + +describe('SearchAdvancedFiltersPopup year-selector return (BUG#1)', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(() => Onyx.clear().then(waitForBatchedUpdates)); + + test('with no pending year write-back, the host shows the default Type filter (not Date)', async () => { + render(); + await waitForBatchedUpdates(); + + // The default selectedFilter is TYPE, and nothing forces Date, so both the menu and the content are on Type. + expect(screen.getByText(`selectedFilter:${TYPE}`)).toBeOnTheScreen(); + expect(screen.getByTestId(`content-${TYPE}`)).toBeOnTheScreen(); + expect(screen.queryByTestId(`content-${DATE}`)).toBeNull(); + }); + + test('a NON-search-context pending year write-back does NOT force Date (stays on default Type)', async () => { + // A DOB/date-input picker writes a `datePicker-*` context — only `search*` contexts belong to this host, + // so this one must be ignored and the menu must keep its normal default. + setCalendarPickerSelectedYear('datePicker-dob', 1995); + await waitForBatchedUpdates(); + + render(); + await waitForBatchedUpdates(); + + expect(screen.getByText(`selectedFilter:${TYPE}`)).toBeOnTheScreen(); + expect(screen.getByTestId(`content-${TYPE}`)).toBeOnTheScreen(); + expect(screen.queryByTestId(`content-${DATE}`)).toBeNull(); + }); + + test('a pending SEARCH-context year write-back forces the Date view (BUG#1 fix)', async () => { + // Simulate returning from the year selector that was opened from the search single-date picker. + setCalendarPickerSelectedYear('searchSingleDate', 2018); + await waitForBatchedUpdates(); + + render(); + await waitForBatchedUpdates(); + + // effectiveFilter is forced to DATE so the CalendarPicker re-mounts to consume the picked year, + // instead of the popover resetting to the Type menu. + expect(screen.getByText(`selectedFilter:${DATE}`)).toBeOnTheScreen(); + expect(screen.getByTestId(`content-${DATE}`)).toBeOnTheScreen(); + expect(screen.queryByTestId(`content-${TYPE}`)).toBeNull(); + }); + + test('any other search* context (e.g. searchRangeFrom) also forces the Date view', async () => { + // The host gates on contextID.startsWith('search'), so every search picker context (single/range from/to) + // routes the return back to the Date view, not just the single-date one. + setCalendarPickerSelectedYear('searchRangeFrom', 2020); + await waitForBatchedUpdates(); + + render(); + await waitForBatchedUpdates(); + + expect(screen.getByText(`selectedFilter:${DATE}`)).toBeOnTheScreen(); + expect(screen.getByTestId(`content-${DATE}`)).toBeOnTheScreen(); + }); + + test('once the pending search year is cleared, the host returns to the default Type filter', async () => { + setCalendarPickerSelectedYear('searchSingleDate', 2018); + await waitForBatchedUpdates(); + clearCalendarPickerSelectedYear(); + await waitForBatchedUpdates(); + + render(); + await waitForBatchedUpdates(); + + // No pending search write-back => nothing forces Date => default Type menu. + expect(screen.getByText(`selectedFilter:${TYPE}`)).toBeOnTheScreen(); + expect(screen.getByTestId(`content-${TYPE}`)).toBeOnTheScreen(); + expect(screen.queryByTestId(`content-${DATE}`)).toBeNull(); + }); +});