feat/81501: Add Range filter for all date type filters#81897
feat/81501: Add Range filter for all date type filters#81897btkcodedev wants to merge 68 commits intoExpensify:mainfrom
Conversation
|
Hey, I noticed you changed If you want to automatically generate translations for other locales, an Expensify employee will have to:
Alternatively, if you are an external contributor, you can run the translation script locally with your own OpenAI API key. To learn more, try running: npx ts-node ./scripts/generateTranslations.ts --helpTypically, you'd want to translate only what you changed by running |
|
Hey! I see that you made changes to our Form component. Make sure to update the docs in FORMS.md accordingly. Cheers! |
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [isSearchAdvancedFiltersFormLoading]); | ||
|
|
||
| useEffect(() => { |
There was a problem hiding this comment.
❌ PERF-10
Reasoning: Parent-child communication should not use useEffect. Instead, lift the state up to the parent component and pass it down as props. This follows React unidirectional data flow pattern, eliminates synchronization issues, reduces unnecessary renders, and makes the data flow clearer.
Suggested fix: Instead of calling onDateValuesChange in useEffect, call it directly when setDateValues is called in setDateValue:
const setDateValue = useCallback((dateModifier: SearchDateModifier, value: string | undefined) => {
setDateValues((prevDateValues) => {
const newDateValues = {...prevDateValues, [dateModifier]: value};
// Update parent immediately with new values
onDateValuesChange?.(newDateValues);
return newDateValues;
});
}, [onDateValuesChange]);Then remove the useEffect entirely.
Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.
trjExpensify
left a comment
There was a problem hiding this comment.
Looks pretty good to me. CC: @Expensify/design @JmillsExpensify
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppAndroid: mWeb ChromeiOS: HybridAppiOS: mWeb SafariMacOS: Chrome / Safari |
|
Good to run a test build to check it out? |
|
Approval for Spanish translation commented on Slack: |
Codecov Report❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.
|
|
Comments addressed, please continue review 🙇 |
|
@Pujan92 can you complete your review today, please? Thanks! |
| {useRangeLayout ? ( | ||
| <View style={[styles.flexRow, styles.mh5, styles.alignItemsCenter, styles.pt1]}> | ||
| {shouldShowInlineRangeText ? ( | ||
| <> | ||
| <View style={[styles.flex1, styles.mr2]}> | ||
| {!!rangeText && ( | ||
| <Text style={[styles.textLabelSupporting]}> | ||
| {`${translate('common.range')}: `} | ||
| <Text style={[styles.textLabel]}>{rangeText}</Text> | ||
| </Text> | ||
| )} | ||
| </View> | ||
| <View style={[styles.flex1, styles.ml2]}> | ||
| <View style={[styles.flexRow, styles.gap2]}> | ||
| <Button | ||
| medium | ||
| style={[styles.flex1]} | ||
| text={translate('common.reset')} | ||
| onPress={resetChanges} | ||
| /> | ||
| <Button | ||
| success | ||
| medium | ||
| style={[styles.flex1]} | ||
| text={translate('common.apply')} | ||
| onPress={applyChanges} | ||
| /> | ||
| </View> | ||
| </View> | ||
| </> | ||
| ) : ( | ||
| <View style={[styles.flexRow, styles.gap2, styles.flex1]}> | ||
| <Button | ||
| medium | ||
| style={[styles.flex1]} | ||
| text={translate('common.reset')} | ||
| onPress={resetChanges} | ||
| /> | ||
| <Button | ||
| success | ||
| medium | ||
| style={[styles.flex1]} | ||
| text={translate('common.apply')} | ||
| onPress={applyChanges} | ||
| /> | ||
| </View> | ||
| )} | ||
| </View> | ||
| ) : ( | ||
| <View style={[styles.flexRow, styles.gap2, styles.ph5]}> | ||
| <Button | ||
| medium | ||
| style={[styles.flex1]} | ||
| text={translate('common.reset')} | ||
| onPress={resetChanges} | ||
| /> | ||
| <Button | ||
| success | ||
| medium | ||
| style={[styles.flex1]} | ||
| text={translate('common.apply')} | ||
| onPress={applyChanges} | ||
| /> | ||
| </View> | ||
| )} |
There was a problem hiding this comment.
| {useRangeLayout ? ( | |
| <View style={[styles.flexRow, styles.mh5, styles.alignItemsCenter, styles.pt1]}> | |
| {shouldShowInlineRangeText ? ( | |
| <> | |
| <View style={[styles.flex1, styles.mr2]}> | |
| {!!rangeText && ( | |
| <Text style={[styles.textLabelSupporting]}> | |
| {`${translate('common.range')}: `} | |
| <Text style={[styles.textLabel]}>{rangeText}</Text> | |
| </Text> | |
| )} | |
| </View> | |
| <View style={[styles.flex1, styles.ml2]}> | |
| <View style={[styles.flexRow, styles.gap2]}> | |
| <Button | |
| medium | |
| style={[styles.flex1]} | |
| text={translate('common.reset')} | |
| onPress={resetChanges} | |
| /> | |
| <Button | |
| success | |
| medium | |
| style={[styles.flex1]} | |
| text={translate('common.apply')} | |
| onPress={applyChanges} | |
| /> | |
| </View> | |
| </View> | |
| </> | |
| ) : ( | |
| <View style={[styles.flexRow, styles.gap2, styles.flex1]}> | |
| <Button | |
| medium | |
| style={[styles.flex1]} | |
| text={translate('common.reset')} | |
| onPress={resetChanges} | |
| /> | |
| <Button | |
| success | |
| medium | |
| style={[styles.flex1]} | |
| text={translate('common.apply')} | |
| onPress={applyChanges} | |
| /> | |
| </View> | |
| )} | |
| </View> | |
| ) : ( | |
| <View style={[styles.flexRow, styles.gap2, styles.ph5]}> | |
| <Button | |
| medium | |
| style={[styles.flex1]} | |
| text={translate('common.reset')} | |
| onPress={resetChanges} | |
| /> | |
| <Button | |
| success | |
| medium | |
| style={[styles.flex1]} | |
| text={translate('common.apply')} | |
| onPress={applyChanges} | |
| /> | |
| </View> | |
| )} | |
| <View style={[styles.flexRow, styles.gap2, styles.ph5, styles.alignItemsCenter, styles.pt1]}> | |
| {useRangeLayout && <View style={[styles.flex1]}> | |
| {!!rangeText && ( | |
| <Text style={[styles.textLabelSupporting]}> | |
| {`${translate('common.range')}: `} | |
| <Text style={[styles.textLabel]}>{rangeText}</Text> | |
| </Text> | |
| )} | |
| </View>} | |
| <View style={[styles.flex1]}> | |
| <View style={[styles.flexRow, styles.gap2]}> | |
| <Button | |
| medium | |
| style={[styles.flex1]} | |
| text={translate('common.reset')} | |
| onPress={resetChanges} | |
| /> | |
| <Button | |
| success | |
| medium | |
| style={[styles.flex1]} | |
| text={translate('common.apply')} | |
| onPress={applyChanges} | |
| /> | |
| </View> | |
| </View> | |
| </View> |
We can minimize this block here
|
All comments address |
There was a problem hiding this comment.
Thanks @btkcodedev. I just suggested changes of not maintaining non-mandatory trackedDateValues state in the DateSelectPopup and DateFilterBase.
I tried in my local by applying the suggested changes and it looks good to me. Let me know if you think the other way
Screen.Recording.2026-02-26.at.22.46.06.mov
| const [trackedDateValues, setTrackedDateValues] = useState<SearchDateValues>(value); | ||
|
|
||
| const updateTrackedDateValues = useCallback((dateValues: SearchDateValues) => { | ||
| setTrackedDateValues(dateValues); | ||
| }, []); |
| // Sync trackedDateValues when actual date values change from parent | ||
| useEffect(() => { | ||
| // eslint-disable-next-line react-hooks/set-state-in-effect | ||
| setTrackedDateValues(value); | ||
| }, [value]); |
There was a problem hiding this comment.
| // Sync trackedDateValues when actual date values change from parent | |
| useEffect(() => { | |
| // eslint-disable-next-line react-hooks/set-state-in-effect | |
| setTrackedDateValues(value); | |
| }, [value]); |
| const dateValues = searchDatePresetFilterBaseRef.current.setDateValueOfSelectedDateModifier(); | ||
| updateTrackedDateValues(dateValues); |
There was a problem hiding this comment.
| const dateValues = searchDatePresetFilterBaseRef.current.setDateValueOfSelectedDateModifier(); | |
| updateTrackedDateValues(dateValues); | |
| searchDatePresetFilterBaseRef.current.setDateValueOfSelectedDateModifier(); |
|
|
||
| const dateValues = searchDatePresetFilterBaseRef.current.getDateValues(); | ||
| onChange(dateValues); | ||
| onChange(trackedDateValues); |
There was a problem hiding this comment.
| onChange(trackedDateValues); | |
| const dateValues = searchDatePresetFilterBaseRef.current.getDateValues(); | |
| onChange(dateValues); |
| const rangeText = getDateRangeDisplayValueFromFormValue( | ||
| trackedDateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE], | ||
| trackedDateValues[CONST.SEARCH.DATE_MODIFIERS.AFTER], | ||
| trackedDateValues[CONST.SEARCH.DATE_MODIFIERS.BEFORE], | ||
| ); |
There was a problem hiding this comment.
| const rangeText = getDateRangeDisplayValueFromFormValue( | |
| trackedDateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE], | |
| trackedDateValues[CONST.SEARCH.DATE_MODIFIERS.AFTER], | |
| trackedDateValues[CONST.SEARCH.DATE_MODIFIERS.BEFORE], | |
| ); |
| onSelectDateModifier={setSelectedDateModifier} | ||
| presets={presets} | ||
| shouldShowRangeError={shouldShowRangeError} | ||
| onDateValuesChange={updateTrackedDateValues} |
There was a problem hiding this comment.
| onDateValuesChange={updateTrackedDateValues} | |
| onDateValuesChange={() => setRangeText(searchDatePresetFilterBaseRef.current?.getRangeDisplayText() ?? '')} |
| const isSearchAdvancedFiltersFormLoading = isLoadingOnyxValue(searchAdvancedFiltersFormMetadata); | ||
| const [selectedDateModifier, setSelectedDateModifier] = useState<SearchDateModifier | null>(null); | ||
| const [shouldShowRangeError, setShouldShowRangeError] = useState(false); | ||
| const [trackedDateValues, setTrackedDateValues] = useState<SearchDateValues>(emptyDateValues); |
There was a problem hiding this comment.
| const [trackedDateValues, setTrackedDateValues] = useState<SearchDateValues>(emptyDateValues); |
Similar to the suggested changes in DateFilterPopup
| /** Sets the date value of the selected date modifier to the ephemeral date value (the selected date in calendar) and returns the updated values */ | ||
| setDateValueOfSelectedDateModifier: () => SearchDateValues; |
There was a problem hiding this comment.
Ok, as we update the pattern and won't maintain the trackedDateValues, we can revert this change(void as a return type) now.
| /** Clears the date value of the selected date modifier */ | ||
| clearDateValueOfSelectedDateModifier: () => void; | ||
| /** Clears the date value of the selected date modifier and returns the updated values */ | ||
| clearDateValueOfSelectedDateModifier: () => SearchDateValues; |
|
Also tagging @neil-marcellini in case they can do the parallel review here. |
Co-authored-by: Pujan Shah <shahpujan1992@gmail.com>
|
All comments cleared |
|
All checks have passed. I believe all reported issues have been addressed. |
Explanation of Change
Added a new Range date filter option with dual side-by-side calendars for easier date range selection across all search date filters (Date, Approved, Paid, Submitted, Withdrawn, Posted, Exported).
Key Improvements
New Range Mode: Users can now select date ranges using two calendars displayed side-by-side (desktop) or stacked (mobile/sidebar)
Two-Date Validation: Enforces that both From and To dates must be selected when using Range mode, preventing incomplete date ranges
Consistent Display: Date ranges are now displayed as "Range: Jan 1, 2024 - Jan 31, 2024" entries in filter lists and titles
Auto-Detection: When both After and Before dates exist on page load, it automatically switches to Range mode for better UX
Responsive Layouts:
Preserved Workflows: Individual After/Before editing still works when selected directly from menu items
Technical Changes
CONST.SEARCH.DATE_MODIFIERS.RANGERangeDatePickercomponent with dual calendarsDateFilterBase,DatePresetFilterBase, andDateSelectPopupto support Range modeAdvancedSearchFiltersandSearchFiltersReportFieldPageDropdownButtonFixed Issues
$ #81501
PROPOSAL: #81501 (comment)
Tests
Test Steps
Range Mode - Desktop Popup
Range Mode - Sidebar Filters
Two-Date Validation
Auto-Detection on Reload
Individual After/Before Editing
Mobile/Narrow Layout
Offline tests
Same as above
QA Steps
Design verification
Verify that the designs are as given in the issue section
Range Mode - Desktop Popup
Range Mode - Sidebar Filters
Two-Date Validation
Auto-Detection on Reload
Individual After/Before Editing
Mobile/Narrow Layout
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectioncanBeMissingparam foruseOnyxtoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Screen_Recording_20260209_234831_New.Expensify.Dev.mp4
Android: mWeb Chrome
Screen_Recording_20260209_234141_Brave.mp4
iOS: Native
Screen.Recording.2026-02-09.at.11.30.23.PM.mov
iOS: mWeb Safari
Screen.Recording.2026-02-09.at.11.34.04.PM.mov
MacOS: Chrome / Safari
Screen.Recording.2026-02-10.at.12.04.47.AM.mov