From 1af86552ed74b0334c6f0ca5e416ddb195fdd7b0 Mon Sep 17 00:00:00 2001 From: btkcodedev Date: Fri, 13 Mar 2026 12:35:03 +0530 Subject: [PATCH 01/16] feat: date range filter UI and year picker fix (#81501) --- src/CONST/index.ts | 7 + .../DatePicker/CalendarPicker/Day.tsx | 9 +- .../CalendarPicker/YearPickerModal.tsx | 1 + .../DatePicker/CalendarPicker/index.tsx | 25 +- .../FilterComponents/DateFilterBase.tsx | 149 ++++--- .../FilterComponents/DatePresetFilterBase.tsx | 413 ++++++++++++++---- .../FilterComponents/RangeDatePicker.tsx | 81 ++++ .../FilterDropdowns/DateSelectPopup.tsx | 266 +++++++++-- .../Search/FilterDropdowns/DropdownButton.tsx | 12 +- .../Search/SearchDatePresetFilterBasePage.tsx | 32 +- .../DatePickerFilterPopup.tsx | 4 +- .../SearchPageHeader/useSearchFiltersBar.tsx | 30 +- src/languages/de.ts | 6 + src/languages/en.ts | 6 + src/languages/es.ts | 6 + src/languages/fr.ts | 6 + src/languages/it.ts | 6 + src/languages/ja.ts | 6 + src/languages/nl.ts | 6 + src/languages/pl.ts | 6 + src/languages/pt-BR.ts | 6 + src/languages/zh-hans.ts | 6 + src/libs/DateUtils.ts | 11 +- src/libs/SearchQueryUtils.ts | 237 +++++++++- src/pages/Search/AdvancedSearchFilters.tsx | 76 +++- .../ReportFieldDate.tsx | 13 +- .../SearchFiltersReportFieldPage/index.tsx | 10 +- .../WorkspaceTravelInvoicingExportPage.tsx | 25 +- src/types/form/SearchAdvancedFiltersForm.ts | 19 +- tests/unit/Search/SearchQueryUtilsTest.ts | 199 +++++++++ 30 files changed, 1435 insertions(+), 244 deletions(-) create mode 100644 src/components/Search/FilterComponents/RangeDatePicker.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index aa699de640f12..5e0892482a46a 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -248,6 +248,7 @@ const CONST = { POPOVER_MENU_MAX_HEIGHT: 496, POPOVER_MENU_MAX_HEIGHT_MOBILE: 432, POPOVER_DATE_WIDTH: 338, + POPOVER_DATE_RANGE_WIDTH: 672, POPOVER_DATE_MAX_HEIGHT: 366, POPOVER_DATE_MIN_HEIGHT: 322, TOOLTIP_ANIMATION_DURATION: 500, @@ -7566,6 +7567,7 @@ const CONST = { EQUAL_TO: 'eq', CONTAINS: 'contains', NOT_EQUAL_TO: 'neq', + RANGE: 'range', GREATER_THAN: 'gt', GREATER_THAN_OR_EQUAL_TO: 'gte', LOWER_THAN: 'lt', @@ -7640,6 +7642,7 @@ const CONST = { ON_PREFIX: 'reportFieldOn-', AFTER_PREFIX: 'reportFieldAfter-', BEFORE_PREFIX: 'reportFieldBefore-', + RANGE_PREFIX: 'reportFieldRange-', }, TAG_EMPTY_VALUE: 'none', CATEGORY_EMPTY_VALUE: 'none', @@ -7769,6 +7772,10 @@ const CONST = { ON: 'On', AFTER: 'After', BEFORE: 'Before', + RANGE: 'Range', + }, + get CUSTOM_DATE_MODIFIERS() { + return [this.DATE_MODIFIERS.ON, this.DATE_MODIFIERS.BEFORE, this.DATE_MODIFIERS.AFTER] as const; }, AMOUNT_MODIFIERS: { LESS_THAN: 'LessThan', diff --git a/src/components/DatePicker/CalendarPicker/Day.tsx b/src/components/DatePicker/CalendarPicker/Day.tsx index 3db884bb7fdc0..d1f22ceee8689 100644 --- a/src/components/DatePicker/CalendarPicker/Day.tsx +++ b/src/components/DatePicker/CalendarPicker/Day.tsx @@ -27,13 +27,16 @@ function Day({disabled, selected, pressed, hovered, children}: DayProps) { const StyleUtils = useStyleUtils(); return ( - {children} + {children} ); } diff --git a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx index 26b2909899d29..02916ae4baaf5 100644 --- a/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/YearPickerModal.tsx @@ -73,6 +73,7 @@ function YearPickerModal({isVisible, years, currentYear = new Date().getFullYear style={[styles.pb0]} includePaddingTop={false} enableEdgeToEdgeBottomSafeAreaPadding + shouldEnablePickerAvoiding={false} testID="YearPickerModal" > void; + + /** Optional style override for the header container */ + headerContainerStyle?: StyleProp; }; function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) { @@ -56,6 +60,7 @@ function CalendarPicker({ onSelected, DayComponent = Day, selectableDates, + headerContainerStyle, }: CalendarPickerProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -104,11 +109,9 @@ function CalendarPicker({ * @param day - The day of the month that was selected. */ const onDayPressed = (day: number) => { - setCurrentDateView((prev) => { - const newCurrentDateView = setDate(new Date(prev), day); - onSelected?.(format(new Date(newCurrentDateView), CONST.DATE.FNS_FORMAT_STRING)); - return newCurrentDateView; - }); + const newCurrentDateView = setDate(new Date(currentDateView), day); + setCurrentDateView(newCurrentDateView); + onSelected?.(format(newCurrentDateView, CONST.DATE.FNS_FORMAT_STRING)); }; /** @@ -175,13 +178,19 @@ function CalendarPicker({ const webOnlyMarginStyle = isSmallScreenWidth ? {} : styles.mh1; const calendarContainerStyle = isSmallScreenWidth ? [webOnlyMarginStyle, themeStyles.calendarBodyContainer] : [webOnlyMarginStyle, animatedStyle]; + const headerPaddingStyle = headerContainerStyle ?? themeStyles.ph5; + // On mobile (isSmallScreenWidth is always true on native), the height animation is skipped + // so using Animated.View is unnecessary. Using a plain View avoids activating Reanimated's + // Fabric commit hook, which can interfere with React's reconciliation of child view styles + // on Android and prevent day-selection background changes from painting. + const CalendarBody = isSmallScreenWidth ? View : Animated.View; const getAccessibilityState = useCallback((isSelected: boolean) => ({selected: isSelected}), []); return ( ))} - + {calendarDaysMatrix?.map((week) => ( ))} - + SearchDateValues; - /** Handles back navigation — closes date modifier if open, otherwise calls onBackButtonPress */ + /** Handles back navigation by closing the active date modifier before leaving the screen */ goBack: () => void; }; @@ -33,17 +36,17 @@ type DateFilterBaseProps = { onSubmit: (values: SearchDateValues) => void; /** Callback when a date value changes (e.g. preset click or calendar save) */ onDateValuesChange?: (values: SearchDateValues) => void; - /** Callback when the date modifier screen is opened or closed (on/after/before) */ + /** Callback when the date modifier screen is opened or closed (on/after/before/range) */ onDateModifierChange?: (isOpen: boolean) => void; - /** If true, the Reset/Save buttons are only shown when a date modifier (On/After/Before) is selected. Defaults to false (always show buttons). */ + /** If true, the Reset/Save buttons are only shown when a date modifier is selected. */ shouldShowButtonsOnlyWithDateModifier?: boolean; - /** Whether to render the built-in HeaderWithBackButton. Defaults to true. Set to false when the parent provides its own header. */ + /** Whether to render the built-in HeaderWithBackButton. Defaults to true. */ shouldShowHeader?: boolean; /** The ref handle */ ref?: React.Ref; }; -// Component uses ref as a prop, which is supported in modern React +// Component uses ref as a prop, which is supported in modern React. function DateFilterBase({ title, defaultDateValues, @@ -60,85 +63,114 @@ function DateFilterBase({ const styles = useThemeStyles(); const {translate} = useLocalize(); + const normalizedDefaultDateValues = useMemo(() => ({...getEmptyDateValues(), ...defaultDateValues}), [defaultDateValues]); const searchDatePresetFilterBaseRef = useRef(null); const [selectedDateModifier, setSelectedDateModifier] = useState(null); + const [shouldShowRangeError, setShouldShowRangeError] = useState(false); + const [rangeDisplayText, setRangeDisplayText] = useState(() => + getDateRangeDisplayValueFromFormValue( + normalizedDefaultDateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE], + normalizedDefaultDateValues[CONST.SEARCH.DATE_MODIFIERS.AFTER], + normalizedDefaultDateValues[CONST.SEARCH.DATE_MODIFIERS.BEFORE], + ), + ); - const handleSelectDateModifier = (dateModifier: SearchDateModifier | null) => { - setSelectedDateModifier(dateModifier); - onDateModifierChange?.(!!dateModifier); - if (onDateValuesChange) { - const values = searchDatePresetFilterBaseRef.current?.getDateValues() ?? { - [CONST.SEARCH.DATE_MODIFIERS.ON]: undefined, - [CONST.SEARCH.DATE_MODIFIERS.BEFORE]: undefined, - [CONST.SEARCH.DATE_MODIFIERS.AFTER]: undefined, - }; - onDateValuesChange(values); - } - }; + useEffect(() => { + setRangeDisplayText( + getDateRangeDisplayValueFromFormValue( + normalizedDefaultDateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE], + normalizedDefaultDateValues[CONST.SEARCH.DATE_MODIFIERS.AFTER], + normalizedDefaultDateValues[CONST.SEARCH.DATE_MODIFIERS.BEFORE], + ), + ); + }, [normalizedDefaultDateValues]); + + const handleDateValuesChange = useCallback( + (values: SearchDateValues) => { + setRangeDisplayText( + getDateRangeDisplayValueFromFormValue(values[CONST.SEARCH.DATE_MODIFIERS.RANGE], values[CONST.SEARCH.DATE_MODIFIERS.AFTER], values[CONST.SEARCH.DATE_MODIFIERS.BEFORE]), + ); + onDateValuesChange?.(values); + }, + [onDateValuesChange], + ); - const goBack = () => { + const handleSelectDateModifier = useCallback( + (dateModifier: SearchDateModifier | null) => { + setSelectedDateModifier(dateModifier); + onDateModifierChange?.(!!dateModifier); + onDateValuesChange?.(searchDatePresetFilterBaseRef.current?.getDateValues() ?? getEmptyDateValues()); + }, + [onDateModifierChange, onDateValuesChange], + ); + + const goBack = useCallback(() => { if (selectedDateModifier) { + if (selectedDateModifier === CONST.SEARCH.DATE_MODIFIERS.RANGE) { + searchDatePresetFilterBaseRef.current?.restoreRangeToEntrySnapshot(); + } setSelectedDateModifier(null); + setShouldShowRangeError(false); onDateModifierChange?.(false); return; } onBackButtonPress?.(); - }; - - useImperativeHandle(ref, () => ({ - getDateValues: () => - searchDatePresetFilterBaseRef.current?.getDateValues() ?? { - [CONST.SEARCH.DATE_MODIFIERS.ON]: undefined, - [CONST.SEARCH.DATE_MODIFIERS.BEFORE]: undefined, - [CONST.SEARCH.DATE_MODIFIERS.AFTER]: undefined, - }, - goBack, - })); - - function getComputedTitle() { - if (selectedDateModifier) { - return translate(`common.${selectedDateModifier.toLowerCase() as SearchDateModifierLower}`); - } + }, [onBackButtonPress, onDateModifierChange, selectedDateModifier]); + + useImperativeHandle( + ref, + () => ({ + getDateValues: () => searchDatePresetFilterBaseRef.current?.getDateValues() ?? getEmptyDateValues(), + goBack, + }), + [goBack], + ); - return title; - } + const computedTitle = getDateModifierTitle(selectedDateModifier, title ?? '', translate); - const reset = () => { + const reset = useCallback(() => { if (!searchDatePresetFilterBaseRef.current) { return; } if (selectedDateModifier) { searchDatePresetFilterBaseRef.current.clearDateValueOfSelectedDateModifier(); - setSelectedDateModifier(null); - onDateModifierChange?.(false); + setShouldShowRangeError(false); return; } searchDatePresetFilterBaseRef.current.clearDateValues(); - }; + setShouldShowRangeError(false); + }, [selectedDateModifier]); - const save = () => { + const save = useCallback(() => { if (!searchDatePresetFilterBaseRef.current) { return; } if (selectedDateModifier) { + if (!searchDatePresetFilterBaseRef.current.validate()) { + return; + } + searchDatePresetFilterBaseRef.current.setDateValueOfSelectedDateModifier(); + const dateValues = searchDatePresetFilterBaseRef.current.getDateValues(); setSelectedDateModifier(null); + setShouldShowRangeError(false); onDateModifierChange?.(false); + onSubmit(dateValues); return; } - const dateValues = searchDatePresetFilterBaseRef.current.getDateValues(); - onSubmit(dateValues); - }; + onSubmit(searchDatePresetFilterBaseRef.current.getDateValues()); + }, [onDateModifierChange, onSubmit, selectedDateModifier]); - const computedTitle = getComputedTitle(); + const shouldShowActionButtons = !shouldShowButtonsOnlyWithDateModifier || !!selectedDateModifier; + const shouldShowRangeSummary = selectedDateModifier === CONST.SEARCH.DATE_MODIFIERS.RANGE && !!rangeDisplayText; - const content = ( - <> + return ( + {shouldShowHeader && ( + {shouldShowRangeSummary && ( + + {`${translate('common.range')}: `} + {rangeDisplayText} + + )} - {(!shouldShowButtonsOnlyWithDateModifier || !!selectedDateModifier) && ( + {shouldShowActionButtons && ( <>