diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index cec1f874bb2cd..aaa3b2eeb358e 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -247,6 +247,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,
@@ -7728,6 +7729,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',
@@ -7802,6 +7804,7 @@ const CONST = {
ON_PREFIX: 'reportFieldOn-',
AFTER_PREFIX: 'reportFieldAfter-',
BEFORE_PREFIX: 'reportFieldBefore-',
+ RANGE_PREFIX: 'reportFieldRange-',
},
TAG_EMPTY_VALUE: 'none',
CATEGORY_EMPTY_VALUE: 'none',
@@ -7931,11 +7934,16 @@ 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;
},
DATE_FILTER_SUB_PAGE: {
ON: 'on',
AFTER: 'after',
BEFORE: 'before',
+ RANGE: 'range',
},
AMOUNT_MODIFIERS: {
LESS_THAN: 'LessThan',
diff --git a/src/components/DatePicker/CalendarPicker/Day.tsx b/src/components/DatePicker/CalendarPicker/Day.tsx
index 3db884bb7fdc0..f9f45c0b533ab 100644
--- a/src/components/DatePicker/CalendarPicker/Day.tsx
+++ b/src/components/DatePicker/CalendarPicker/Day.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import {View} from 'react-native';
import Text from '@components/Text';
import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import getButtonState from '@libs/getButtonState';
@@ -23,17 +24,32 @@ type DayProps = {
};
function Day({disabled, selected, pressed, hovered, children}: DayProps) {
+ const theme = useTheme();
const themeStyles = useThemeStyles();
const StyleUtils = useStyleUtils();
return (
- {children}
+
+ {children}
+
);
}
diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx
index b520650c8c74c..26c0be13e2936 100644
--- a/src/components/DatePicker/CalendarPicker/index.tsx
+++ b/src/components/DatePicker/CalendarPicker/index.tsx
@@ -1,6 +1,7 @@
import {addMonths, endOfDay, endOfMonth, format, getYear, isSameDay, parseISO, setDate, setYear, startOfDay, startOfMonth, subMonths} from 'date-fns';
import {Str} from 'expensify-common';
import React, {useCallback, useEffect, useRef, useState} from 'react';
+import type {StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
@@ -35,6 +36,9 @@ type CalendarPickerProps = {
/** A function called when the date is selected */
onSelected?: (selectedDate: string) => 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 with collapsable={false} avoids
+ // activating Reanimated's Fabric commit hook, which on Android can interfere with React's
+ // reconciliation of child view styles 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) => (
{week.map((day, index) => {
@@ -311,7 +324,7 @@ function CalendarPicker({
})}
))}
-
+
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;
};
@@ -39,15 +42,15 @@ type DateFilterBaseProps = {
onSelectDateModifier?: (dateModifier: SearchDateModifier | null) => void;
/** Callback when the date modifier screen is opened or closed (on/after/before) */
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,
@@ -66,63 +69,87 @@ function DateFilterBase({
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const normalizedDefaultDateValues = useMemo(() => ({...getEmptyDateValues(), ...defaultDateValues}), [defaultDateValues]);
const searchDatePresetFilterBaseRef = useRef(null);
const [selectedDateModifierState, setSelectedDateModifierState] = 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],
+ ),
+ );
+
+ 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 isDateModifierControlled = selectedDateModifierProp !== undefined;
const selectedDateModifier = isDateModifierControlled ? selectedDateModifierProp : selectedDateModifierState;
- const setSelectedDateModifier = (dateModifier: SearchDateModifier | null) => {
- if (isDateModifierControlled) {
- onSelectDateModifier?.(dateModifier);
- return;
- }
+ const setSelectedDateModifier = useCallback(
+ (dateModifier: SearchDateModifier | null) => {
+ if (isDateModifierControlled) {
+ onSelectDateModifier?.(dateModifier);
+ return;
+ }
+ setSelectedDateModifierState(dateModifier);
+ },
+ [isDateModifierControlled, onSelectDateModifier],
+ );
- setSelectedDateModifierState(dateModifier);
- };
-
- 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);
- }
- };
+ const handleSelectDateModifier = useCallback(
+ (dateModifier: SearchDateModifier | null) => {
+ setSelectedDateModifier(dateModifier);
+ onDateModifierChange?.(!!dateModifier);
+ onDateValuesChange?.(searchDatePresetFilterBaseRef.current?.getDateValues() ?? getEmptyDateValues());
+ },
+ [onDateModifierChange, onDateValuesChange, setSelectedDateModifier],
+ );
- const goBack = () => {
+ 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, setSelectedDateModifier]);
+
+ 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;
}
@@ -130,51 +157,72 @@ function DateFilterBase({
if (selectedDateModifier) {
searchDatePresetFilterBaseRef.current.clearDateValueOfSelectedDateModifier();
setSelectedDateModifier(null);
+ setShouldShowRangeError(false);
onDateModifierChange?.(false);
return;
}
searchDatePresetFilterBaseRef.current.clearDateValues();
- };
+ setShouldShowRangeError(false);
+ }, [onDateModifierChange, selectedDateModifier, setSelectedDateModifier]);
- 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, setSelectedDateModifier]);
- 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 && (
<>
>
)}
- >
+
);
-
- return content;
}
export type {DateFilterBaseHandle};
diff --git a/src/components/Search/FilterComponents/DatePresetFilterBase.tsx b/src/components/Search/FilterComponents/DatePresetFilterBase.tsx
index 5ef860d7c7d66..eef0e0739204b 100644
--- a/src/components/Search/FilterComponents/DatePresetFilterBase.tsx
+++ b/src/components/Search/FilterComponents/DatePresetFilterBase.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useEffect, useImperativeHandle, useMemo, useState} from 'react';
+import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
import type {Ref} from 'react';
import CalendarPicker from '@components/DatePicker/CalendarPicker';
import MenuItem from '@components/MenuItem';
@@ -9,16 +9,55 @@ import useLocalize from '@hooks/useLocalize';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
-import {isSearchDatePreset} from '@libs/SearchQueryUtils';
-import type {SearchDateModifier} from '@libs/SearchUIUtils';
+import type {SearchDateValues} from '@libs/SearchQueryUtils';
+import {getDateRangeDisplayValueFromFormValue, getEmptyDateValues, getRangeBoundariesFromFormValue, getRangeQueryValue, isSearchDatePreset} from '@libs/SearchQueryUtils';
+import type {SearchDateModifier, SearchDateModifierLower} from '@libs/SearchUIUtils';
import CONST from '@src/CONST';
+import RangeDatePicker from './RangeDatePicker';
-type SearchDateValues = Record;
+type CustomDateModifier = Exclude;
+
+const normalizeDateValues = (dateValues: Partial | SearchDateValues): SearchDateValues => ({
+ ...getEmptyDateValues(),
+ ...dateValues,
+});
+
+const getExclusiveDateValues = (dateModifier: SearchDateModifier, value: string | undefined): SearchDateValues => {
+ const exclusiveValues = getEmptyDateValues();
+ exclusiveValues[dateModifier] = value;
+ return exclusiveValues;
+};
+
+function getCustomDateModifierFromDateValues(dateValues: SearchDateValues): CustomDateModifier {
+ const onValue = dateValues[CONST.SEARCH.DATE_MODIFIERS.ON];
+
+ if (onValue && !isSearchDatePreset(onValue)) {
+ return CONST.SEARCH.DATE_MODIFIERS.ON;
+ }
+
+ if (dateValues[CONST.SEARCH.DATE_MODIFIERS.BEFORE]) {
+ return CONST.SEARCH.DATE_MODIFIERS.BEFORE;
+ }
+
+ if (dateValues[CONST.SEARCH.DATE_MODIFIERS.AFTER]) {
+ return CONST.SEARCH.DATE_MODIFIERS.AFTER;
+ }
+
+ // Default to `On` when no custom date values are set and the custom date page is opened.
+ return CONST.SEARCH.DATE_MODIFIERS.ON;
+}
+
+function isCustomDateModifier(dateModifier: SearchDateModifier | null): dateModifier is CustomDateModifier {
+ return !!dateModifier && dateModifier !== CONST.SEARCH.DATE_MODIFIERS.RANGE;
+}
type DatePresetFilterBaseHandle = {
/** Gets date values */
getDateValues: () => SearchDateValues;
+ /** Gets the formatted range display text for current date values */
+ getRangeDisplayText: () => string;
+
/** Clears date values */
clearDateValues: () => void;
@@ -27,6 +66,15 @@ type DatePresetFilterBaseHandle = {
/** Clears the date value of the selected date modifier */
clearDateValueOfSelectedDateModifier: () => void;
+
+ /** Restores the Range value to what it was when Range mode was entered, discarding any unsaved ephemeral picks */
+ restoreRangeToEntrySnapshot: () => void;
+
+ /** Resets date values to the provided defaults */
+ resetDateValuesToDefault: () => void;
+
+ /** Validates the selected date modifier input */
+ validate: () => boolean;
};
type DatePresetFilterBaseProps = {
@@ -45,8 +93,17 @@ type DatePresetFilterBaseProps = {
/** Whether the search advanced filters form Onyx data is loading or not */
isSearchAdvancedFiltersFormLoading?: boolean;
- /** Callback when a date value changes (e.g. preset click or calendar save) */
- onDateValueChange?: (values: SearchDateValues) => void;
+ /** Whether to show the range validation error */
+ shouldShowRangeError?: boolean;
+
+ /** Callback when date values change */
+ onDateValuesChange?: (dateValues: SearchDateValues) => void;
+
+ /** Callback when range validation error changes */
+ onRangeValidationErrorChange?: (shouldShowRangeError: boolean) => void;
+
+ /** Force vertical stacking of calendars in range picker */
+ forceVerticalCalendars?: boolean;
/** The ref handle */
ref: Ref;
@@ -55,12 +112,9 @@ type DatePresetFilterBaseProps = {
/**
* SearchDatePresetFilterBase is a partially controlled component:
* - The selected date modifier is controlled.
- * - The date values are uncontrolled. This is done to avoid duplicating the `setDateValue` logic and also to avoid exposing the `ephemeralDateValue` state.
+ * - The date values are uncontrolled. This avoids duplicating the setDateValue logic and exposing ephemeral picker state.
*
- * There are cases where the parent is required to alter the internal date values e.g. reset the values, in such cases you should use the ref handle.
- * Typically you are expected to use this component with a save and a reset button.
- * - On save: if a date modifier is selected (i.e. user clicked save at the calendar picker) you should `setDateValueOfSelectedDateModifier` otherwise `getDateValues`
- * - On reset: if a date modifier is selected (i.e. user clicked reset at the calendar picker) you should `clearDateValueOfSelectedDateModifier` otherwise `clearDateValues`
+ * There are cases where the parent is required to alter the internal date values, e.g. reset values. In those cases, use the ref handle.
*/
function DatePresetFilterBase({
defaultDateValues,
@@ -68,7 +122,10 @@ function DatePresetFilterBase({
onSelectDateModifier,
presets,
isSearchAdvancedFiltersFormLoading,
- onDateValueChange,
+ shouldShowRangeError = false,
+ onDateValuesChange,
+ onRangeValidationErrorChange,
+ forceVerticalCalendars = false,
ref,
}: DatePresetFilterBaseProps) {
const theme = useTheme();
@@ -77,45 +134,100 @@ function DatePresetFilterBase({
const {translate} = useLocalize();
const shouldShowHorizontalRule = !!presets?.length;
+ const customDateTitle = translate('search.filters.date.customDate');
+ const customRangeTitle = translate('search.filters.date.customRange');
+ const normalizedDefaultDateValues = useMemo(() => normalizeDateValues(defaultDateValues), [defaultDateValues]);
+
+ const getRangeDisplayTextFromDateValues = useCallback((dateValues: SearchDateValues) => {
+ const rangeValue = dateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE];
+ if (!rangeValue) {
+ return '';
+ }
+ return getDateRangeDisplayValueFromFormValue(rangeValue, dateValues[CONST.SEARCH.DATE_MODIFIERS.AFTER], dateValues[CONST.SEARCH.DATE_MODIFIERS.BEFORE]);
+ }, []);
- const [dateValues, setDateValues] = useState(defaultDateValues);
+ const getRangeEphemeralValuesFromDateValues = useCallback((dateValues: SearchDateValues) => {
+ const rangeBoundaries = getRangeBoundariesFromFormValue(dateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE]);
+
+ return {
+ from: rangeBoundaries.from,
+ to: rangeBoundaries.to,
+ };
+ }, []);
+
+ const notifyDateValuesChange = useCallback(
+ (values: SearchDateValues) => {
+ onDateValuesChange?.(values);
+ },
+ [onDateValuesChange],
+ );
+
+ const [dateValues, setDateValues] = useState(normalizedDefaultDateValues);
+ const dateValuesRef = useRef(normalizedDefaultDateValues);
+ const updateDateValues = useCallback(
+ (updater: SearchDateValues | ((prevDateValues: SearchDateValues) => SearchDateValues), shouldNotify = true) => {
+ const nextDateValues = typeof updater === 'function' ? updater(dateValuesRef.current) : updater;
+ dateValuesRef.current = nextDateValues;
+ setDateValues(nextDateValues);
+
+ if (shouldNotify) {
+ notifyDateValuesChange(nextDateValues);
+ }
+ },
+ [notifyDateValuesChange],
+ );
useEffect(() => {
if (isSearchAdvancedFiltersFormLoading) {
return;
}
- setDateValues(defaultDateValues);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isSearchAdvancedFiltersFormLoading]);
+
+ const currentDateValues = dateValuesRef.current;
+ const hasDefaultValuesChanged =
+ currentDateValues[CONST.SEARCH.DATE_MODIFIERS.ON] !== normalizedDefaultDateValues[CONST.SEARCH.DATE_MODIFIERS.ON] ||
+ currentDateValues[CONST.SEARCH.DATE_MODIFIERS.AFTER] !== normalizedDefaultDateValues[CONST.SEARCH.DATE_MODIFIERS.AFTER] ||
+ currentDateValues[CONST.SEARCH.DATE_MODIFIERS.BEFORE] !== normalizedDefaultDateValues[CONST.SEARCH.DATE_MODIFIERS.BEFORE] ||
+ currentDateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE] !== normalizedDefaultDateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE];
+
+ if (!hasDefaultValuesChanged) {
+ return;
+ }
+
+ dateValuesRef.current = normalizedDefaultDateValues;
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setDateValues(normalizedDefaultDateValues);
+ }, [isSearchAdvancedFiltersFormLoading, normalizedDefaultDateValues]);
const setDateValue = useCallback(
(dateModifier: SearchDateModifier, value: string | undefined) => {
- setDateValues((prevDateValues) => {
- let newValues: SearchDateValues;
-
+ updateDateValues((prevDateValues) => {
if (dateModifier === CONST.SEARCH.DATE_MODIFIERS.ON && isSearchDatePreset(value)) {
- newValues = {
- [CONST.SEARCH.DATE_MODIFIERS.ON]: value,
- [CONST.SEARCH.DATE_MODIFIERS.BEFORE]: undefined,
- [CONST.SEARCH.DATE_MODIFIERS.AFTER]: undefined,
- };
- } else if (dateModifier !== CONST.SEARCH.DATE_MODIFIERS.ON && isSearchDatePreset(prevDateValues[CONST.SEARCH.DATE_MODIFIERS.ON])) {
- newValues = {
+ return getExclusiveDateValues(dateModifier, value);
+ }
+
+ if (dateModifier === CONST.SEARCH.DATE_MODIFIERS.RANGE) {
+ return {...prevDateValues, [dateModifier]: value};
+ }
+
+ if (dateModifier !== CONST.SEARCH.DATE_MODIFIERS.ON && isSearchDatePreset(prevDateValues[CONST.SEARCH.DATE_MODIFIERS.ON])) {
+ return {
...prevDateValues,
[dateModifier]: value,
[CONST.SEARCH.DATE_MODIFIERS.ON]: undefined,
};
- } else {
- newValues = {...prevDateValues, [dateModifier]: value};
}
- // Call the callback immediately with the new values so parents don't need to depend on async state updates or refs
- onDateValueChange?.(newValues);
-
- return newValues;
+ return {...prevDateValues, [dateModifier]: value};
});
},
- [onDateValueChange],
+ [updateDateValues],
+ );
+
+ const setExclusiveDateValue = useCallback(
+ (dateModifier: SearchDateModifier, value: string | undefined) => {
+ updateDateValues(getExclusiveDateValues(dateModifier, value));
+ },
+ [updateDateValues],
);
const dateDisplayValues = useMemo(() => {
@@ -128,6 +240,7 @@ function DatePresetFilterBase({
[CONST.SEARCH.DATE_MODIFIERS.ON]: isSearchDatePreset(dateOn) ? undefined : dateOn,
[CONST.SEARCH.DATE_MODIFIERS.AFTER]: dateAfter,
[CONST.SEARCH.DATE_MODIFIERS.BEFORE]: dateBefore,
+ [CONST.SEARCH.DATE_MODIFIERS.RANGE]: dateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE] ? 'range' : undefined,
};
}, [dateValues]);
@@ -138,23 +251,78 @@ function DatePresetFilterBase({
[getInitialEphemeralDateValue],
);
+ const [rangeEphemeralValues, setRangeEphemeralValues] = useState<{from?: string; to?: string}>(() => getRangeEphemeralValuesFromDateValues(normalizedDefaultDateValues));
+
+ // Synchronize dateValues when rangeEphemeralValues change, keeping the side effect
+ // out of the setRangeEphemeralValues state updater to avoid unpredictable batching on Android.
+ useEffect(() => {
+ if (selectedDateModifier !== CONST.SEARCH.DATE_MODIFIERS.RANGE) {
+ return;
+ }
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setDateValue(CONST.SEARCH.DATE_MODIFIERS.RANGE, getRangeQueryValue(rangeEphemeralValues.from, rangeEphemeralValues.to) || undefined);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [rangeEphemeralValues.from, rangeEphemeralValues.to]);
+
+ // Used to discard unsaved range picks when the user leaves Range mode without saving.
+ const rangeEntrySnapshotRef = useRef(undefined);
+
const selectDateModifier = useCallback(
(dateModifier: SearchDateModifier | null) => {
- resetEphemeralDateValue(dateModifier);
+ const isSwitchingBetweenCustomDateModifiers = isCustomDateModifier(selectedDateModifier) && isCustomDateModifier(dateModifier);
+ if (!isSwitchingBetweenCustomDateModifiers) {
+ resetEphemeralDateValue(dateModifier);
+ }
+
+ onRangeValidationErrorChange?.(false);
+
+ if (dateModifier === CONST.SEARCH.DATE_MODIFIERS.RANGE) {
+ const currentDateValues = dateValuesRef.current;
+ rangeEntrySnapshotRef.current = currentDateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE];
+ setRangeEphemeralValues(getRangeEphemeralValuesFromDateValues(currentDateValues));
+ }
+
onSelectDateModifier(dateModifier);
},
- [resetEphemeralDateValue, onSelectDateModifier],
+ [getRangeEphemeralValuesFromDateValues, onRangeValidationErrorChange, onSelectDateModifier, resetEphemeralDateValue, selectedDateModifier],
);
+ const validate = useCallback(() => {
+ if (selectedDateModifier !== CONST.SEARCH.DATE_MODIFIERS.RANGE) {
+ onRangeValidationErrorChange?.(false);
+ return true;
+ }
+
+ const isValid = !!(rangeEphemeralValues.from && rangeEphemeralValues.to);
+ onRangeValidationErrorChange?.(!isValid);
+ return isValid;
+ }, [onRangeValidationErrorChange, rangeEphemeralValues.from, rangeEphemeralValues.to, selectedDateModifier]);
+
useImperativeHandle(
ref,
() => ({
getDateValues() {
- return dateValues;
+ return dateValuesRef.current;
+ },
+
+ getRangeDisplayText() {
+ return getRangeDisplayTextFromDateValues(dateValuesRef.current);
+ },
+
+ resetDateValuesToDefault() {
+ updateDateValues(normalizedDefaultDateValues);
+ onRangeValidationErrorChange?.(false);
+ },
+
+ validate() {
+ return validate();
},
clearDateValues() {
- setDateValues({[CONST.SEARCH.DATE_MODIFIERS.ON]: undefined, [CONST.SEARCH.DATE_MODIFIERS.BEFORE]: undefined, [CONST.SEARCH.DATE_MODIFIERS.AFTER]: undefined});
+ updateDateValues(getEmptyDateValues());
+ setEphemeralDateValue(undefined);
+ setRangeEphemeralValues({});
+ onRangeValidationErrorChange?.(false);
},
setDateValueOfSelectedDateModifier() {
@@ -162,7 +330,13 @@ function DatePresetFilterBase({
return;
}
- setDateValue(selectedDateModifier, ephemeralDateValue);
+ const updatedValue =
+ selectedDateModifier === CONST.SEARCH.DATE_MODIFIERS.RANGE ? getRangeQueryValue(rangeEphemeralValues.from, rangeEphemeralValues.to) || undefined : ephemeralDateValue;
+ if (updatedValue === undefined) {
+ return;
+ }
+
+ updateDateValues(getExclusiveDateValues(selectedDateModifier, updatedValue));
},
clearDateValueOfSelectedDateModifier() {
@@ -170,65 +344,147 @@ function DatePresetFilterBase({
return;
}
- setDateValue(selectedDateModifier, undefined);
+ const currentDateValues = dateValuesRef.current;
+ updateDateValues({...currentDateValues, [selectedDateModifier]: undefined});
+
+ if (selectedDateModifier === CONST.SEARCH.DATE_MODIFIERS.RANGE) {
+ setRangeEphemeralValues({});
+ } else {
+ setEphemeralDateValue(undefined);
+ }
+
+ onRangeValidationErrorChange?.(false);
+ },
+
+ restoreRangeToEntrySnapshot() {
+ const updatedValues = {
+ ...dateValuesRef.current,
+ [CONST.SEARCH.DATE_MODIFIERS.RANGE]: rangeEntrySnapshotRef.current,
+ };
+
+ updateDateValues(updatedValues);
+ setRangeEphemeralValues(getRangeEphemeralValuesFromDateValues(updatedValues));
+ onRangeValidationErrorChange?.(false);
},
}),
- [selectedDateModifier, dateValues, ephemeralDateValue, setDateValue],
+ [
+ ephemeralDateValue,
+ getRangeDisplayTextFromDateValues,
+ getRangeEphemeralValuesFromDateValues,
+ normalizedDefaultDateValues,
+ onRangeValidationErrorChange,
+ rangeEphemeralValues.from,
+ rangeEphemeralValues.to,
+ selectedDateModifier,
+ updateDateValues,
+ validate,
+ ],
);
- return !selectedDateModifier ? (
+ const rangeDescription = getRangeDisplayTextFromDateValues(dateValues) || undefined;
+ const customDateModifier = useMemo(() => getCustomDateModifierFromDateValues(dateValues), [dateValues]);
+
+ const customDateDescription = useMemo(() => {
+ const customDateValue = dateDisplayValues[customDateModifier];
+ if (!customDateValue) {
+ return undefined;
+ }
+
+ return `${translate(`common.${customDateModifier.toLowerCase() as SearchDateModifierLower}`)} ${customDateValue}`;
+ }, [customDateModifier, dateDisplayValues, translate]);
+ const handleSingleDateSelected = useCallback((date: string) => {
+ setEphemeralDateValue(date);
+ }, []);
+ const selectCustomDateMode = useCallback(() => selectDateModifier(customDateModifier), [customDateModifier, selectDateModifier]);
+
+ if (!selectedDateModifier) {
+ return (
+ <>
+ {presets?.map((preset) => (
+ setExclusiveDateValue(CONST.SEARCH.DATE_MODIFIERS.ON, preset)}
+ wrapperStyle={styles.flexReset}
+ />
+ ))}
+ {shouldShowHorizontalRule && (
+
+ )}
+
+