diff --git a/frontend/src/component/common/FilterDateItem/DateRangePresets.tsx b/frontend/src/component/common/FilterDateItem/DateRangePresets.tsx new file mode 100644 index 000000000000..2eb92c2f133f --- /dev/null +++ b/frontend/src/component/common/FilterDateItem/DateRangePresets.tsx @@ -0,0 +1,71 @@ +import { Box, List, ListItem, ListItemButton, Typography } from '@mui/material'; +import type { FilterItemParams } from '../../filter/FilterItem/FilterItem'; +import type { FC } from 'react'; +import { calculateDateRange, type RangeType } from './calculateDateRange'; + +export const DateRangePresets: FC<{ + onRangeChange: (value: { + from: FilterItemParams; + to: FilterItemParams; + }) => void; +}> = ({ onRangeChange }) => { + const rangeChangeHandler = (rangeType: RangeType) => () => { + const [start, end] = calculateDateRange(rangeType); + onRangeChange({ + from: { + operator: 'IS', + values: [start], + }, + to: { + operator: 'IS', + values: [end], + }, + }); + }; + + return ( + + + Presets + + + + + This month + + + + + Previous month + + + + + This quarter + + + + + Previous quarter + + + + + This year + + + + + Previous year + + + + + ); +}; diff --git a/frontend/src/component/common/FilterDateItem/FilterDateItem.tsx b/frontend/src/component/common/FilterDateItem/FilterDateItem.tsx index 7e063519afd5..4f193555511a 100644 --- a/frontend/src/component/common/FilterDateItem/FilterDateItem.tsx +++ b/frontend/src/component/common/FilterDateItem/FilterDateItem.tsx @@ -8,11 +8,16 @@ import { format } from 'date-fns'; import { useLocationSettings } from 'hooks/useLocationSettings'; import { getLocalizedDateString } from '../util'; import type { FilterItemParams } from 'component/filter/FilterItem/FilterItem'; +import { DateRangePresets } from './DateRangePresets'; export interface IFilterDateItemProps { name: string; label: ReactNode; onChange: (value: FilterItemParams) => void; + onRangeChange?: (value: { + from: FilterItemParams; + to: FilterItemParams; + }) => void; onChipClose: () => void; state: FilterItemParams | null | undefined; operators: [string, ...string[]]; @@ -22,6 +27,7 @@ export const FilterDateItem: FC = ({ name, label, onChange, + onRangeChange, onChipClose, state, operators, @@ -115,6 +121,9 @@ export const FilterDateItem: FC = ({ }); }} /> + {onRangeChange && ( + + )} diff --git a/frontend/src/component/common/FilterDateItem/calculateDateRange.test.ts b/frontend/src/component/common/FilterDateItem/calculateDateRange.test.ts new file mode 100644 index 000000000000..7051cafd2480 --- /dev/null +++ b/frontend/src/component/common/FilterDateItem/calculateDateRange.test.ts @@ -0,0 +1,40 @@ +import { calculateDateRange, type RangeType } from './calculateDateRange'; + +describe('calculateDateRange', () => { + const fixedDate = new Date('2024-06-16'); + + test.each<[RangeType, string, string]>([ + ['thisMonth', '2024-06-01', '2024-06-30'], + ['previousMonth', '2024-05-01', '2024-05-31'], + ['thisQuarter', '2024-04-01', '2024-06-30'], + ['previousQuarter', '2024-01-01', '2024-03-31'], + ['thisYear', '2024-01-01', '2024-12-31'], + ['previousYear', '2023-01-01', '2023-12-31'], + ])( + 'should return correct range for %s', + (rangeType, expectedStart, expectedEnd) => { + const [start, end] = calculateDateRange(rangeType, fixedDate); + expect(start).toBe(expectedStart); + expect(end).toBe(expectedEnd); + }, + ); + + test('should default to previousMonth if rangeType is invalid', () => { + const [start, end] = calculateDateRange( + 'invalidRange' as RangeType, + fixedDate, + ); + expect(start).toBe('2024-05-01'); + expect(end).toBe('2024-05-31'); + }); + + test('should handle edge case for previousMonth at year boundary', () => { + const yearBoundaryDate = new Date('2024-01-15'); + const [start, end] = calculateDateRange( + 'previousMonth', + yearBoundaryDate, + ); + expect(start).toBe('2023-12-01'); + expect(end).toBe('2023-12-31'); + }); +}); diff --git a/frontend/src/component/common/FilterDateItem/calculateDateRange.ts b/frontend/src/component/common/FilterDateItem/calculateDateRange.ts new file mode 100644 index 000000000000..29c77d4e9105 --- /dev/null +++ b/frontend/src/component/common/FilterDateItem/calculateDateRange.ts @@ -0,0 +1,66 @@ +import { + endOfMonth, + endOfQuarter, + endOfYear, + format, + startOfMonth, + startOfQuarter, + startOfYear, + subMonths, + subQuarters, + subYears, +} from 'date-fns'; + +export type RangeType = + | 'thisMonth' + | 'previousMonth' + | 'thisQuarter' + | 'previousQuarter' + | 'thisYear' + | 'previousYear'; + +export const calculateDateRange = ( + rangeType: RangeType, + today = new Date(), +): [string, string] => { + let start: Date; + let end: Date; + + switch (rangeType) { + case 'thisMonth': { + start = startOfMonth(today); + end = endOfMonth(today); + break; + } + case 'thisQuarter': { + start = startOfQuarter(today); + end = endOfQuarter(today); + break; + } + case 'previousQuarter': { + const previousQuarter = subQuarters(today, 1); + start = startOfQuarter(previousQuarter); + end = endOfQuarter(previousQuarter); + break; + } + case 'thisYear': { + start = startOfYear(today); + end = endOfYear(today); + break; + } + case 'previousYear': { + const lastYear = subYears(today, 1); + start = startOfYear(lastYear); + end = endOfYear(lastYear); + break; + } + + default: { + const lastMonth = subMonths(today, 1); + start = startOfMonth(lastMonth); + end = endOfMonth(lastMonth); + } + } + + return [format(start, 'yyyy-MM-dd'), format(end, 'yyyy-MM-dd')]; +}; diff --git a/frontend/src/component/events/EventLog/EventLogFilters.tsx b/frontend/src/component/events/EventLog/EventLogFilters.tsx index 702598abd93b..4cd75e7229f8 100644 --- a/frontend/src/component/events/EventLog/EventLogFilters.tsx +++ b/frontend/src/component/events/EventLog/EventLogFilters.tsx @@ -57,6 +57,8 @@ export const useEventLogFilters = ( options: [], filterKey: 'from', dateOperators: ['IS'], + fromFilterKey: 'from', + toFilterKey: 'to', }, { label: 'Date To', @@ -64,6 +66,8 @@ export const useEventLogFilters = ( options: [], filterKey: 'to', dateOperators: ['IS'], + fromFilterKey: 'from', + toFilterKey: 'to', }, { label: 'Created by', diff --git a/frontend/src/component/filter/Filters/Filters.tsx b/frontend/src/component/filter/Filters/Filters.tsx index 472e22145f2d..a5c67918ea72 100644 --- a/frontend/src/component/filter/Filters/Filters.tsx +++ b/frontend/src/component/filter/Filters/Filters.tsx @@ -41,6 +41,8 @@ type ITextFilterItem = IBaseFilterItem & { type IDateFilterItem = IBaseFilterItem & { dateOperators: [string, ...string[]]; + fromFilterKey?: string; + toFilterKey?: string; }; export type IFilterItem = ITextFilterItem | IDateFilterItem; @@ -116,6 +118,22 @@ export const Filters: FC = ({ }, [JSON.stringify(state), JSON.stringify(availableFilters)]); const hasAvailableFilters = unselectedFilters.length > 0; + + const rangeChangeHandler = (filter: IDateFilterItem) => { + const fromKey = filter.fromFilterKey; + const toKey = filter.toFilterKey; + if (fromKey && toKey) { + return (value: { + from: FilterItemParams; + to: FilterItemParams; + }) => { + onChange({ [fromKey]: value.from }); + onChange({ [toKey]: value.to }); + }; + } + return undefined; + }; + return ( {selectedFilters.map((selectedFilter) => { @@ -143,9 +161,10 @@ export const Filters: FC = ({ label={label} name={filter.label} state={state[filter.filterKey]} - onChange={(value) => - onChange({ [filter.filterKey]: value }) - } + onChange={(value) => { + onChange({ [filter.filterKey]: value }); + }} + onRangeChange={rangeChangeHandler(filter)} operators={filter.dateOperators} onChipClose={() => deselectFilter(filter.label)} /> diff --git a/frontend/src/component/insights/InsightsFilters.tsx b/frontend/src/component/insights/InsightsFilters.tsx index 99ecb9a3114c..c3f231d36706 100644 --- a/frontend/src/component/insights/InsightsFilters.tsx +++ b/frontend/src/component/insights/InsightsFilters.tsx @@ -34,6 +34,8 @@ export const InsightsFilters: FC = ({ options: [], filterKey: 'from', dateOperators: ['IS'], + fromFilterKey: 'from', + toFilterKey: 'to', }, { label: 'Date To', @@ -41,6 +43,8 @@ export const InsightsFilters: FC = ({ options: [], filterKey: 'to', dateOperators: ['IS'], + fromFilterKey: 'from', + toFilterKey: 'to', }, ...(hasMultipleProjects ? ([