diff --git a/frontend/src/component/common/FilterDateItem/DateRangePresets.tsx b/frontend/src/component/common/FilterDateItem/DateRangePresets.tsx
new file mode 100644
index 000000000000..5af96c089b75
--- /dev/null
+++ b/frontend/src/component/common/FilterDateItem/DateRangePresets.tsx
@@ -0,0 +1,81 @@
+import {
+ Box,
+ List,
+ ListItem,
+ ListItemButton,
+ styled,
+ Typography,
+} from '@mui/material';
+import type { FilterItemParams } from '../../filter/FilterItem/FilterItem';
+import type { FC } from 'react';
+import { calculateDateRange, type RangeType } from './calculateDateRange';
+
+export const PresetsHeader = styled(Typography)(({ theme }) => ({
+ paddingLeft: theme.spacing(2),
+ paddingBottom: theme.spacing(1),
+}));
+
+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..7f20939a7b32 100644
--- a/frontend/src/component/common/FilterDateItem/FilterDateItem.tsx
+++ b/frontend/src/component/common/FilterDateItem/FilterDateItem.tsx
@@ -8,12 +8,17 @@ 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;
- onChipClose: () => 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,
@@ -54,11 +60,13 @@ export const FilterDateItem: FC = ({
: [];
const selectedDate = state ? new Date(state.values[0]) : null;
const currentOperator = state ? state.operator : operators[0];
- const onDelete = () => {
- onChange({ operator: operators[0], values: [] });
- onClose();
- onChipClose();
- };
+ const onDelete = onChipClose
+ ? () => {
+ onChange({ operator: operators[0], values: [] });
+ onClose();
+ onChipClose();
+ }
+ : undefined;
useEffect(() => {
if (state && !operators.includes(state.operator)) {
@@ -115,6 +123,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..d25f8279a728 100644
--- a/frontend/src/component/events/EventLog/EventLogFilters.tsx
+++ b/frontend/src/component/events/EventLog/EventLogFilters.tsx
@@ -57,6 +57,9 @@ export const useEventLogFilters = (
options: [],
filterKey: 'from',
dateOperators: ['IS'],
+ fromFilterKey: 'from',
+ toFilterKey: 'to',
+ persistent: true,
},
{
label: 'Date To',
@@ -64,6 +67,9 @@ export const useEventLogFilters = (
options: [],
filterKey: 'to',
dateOperators: ['IS'],
+ fromFilterKey: 'from',
+ toFilterKey: 'to',
+ persistent: true,
},
{
label: 'Created by',
diff --git a/frontend/src/component/events/EventLog/useEventLogSearch.ts b/frontend/src/component/events/EventLog/useEventLogSearch.ts
index 150b6ec965db..a3746a0fba55 100644
--- a/frontend/src/component/events/EventLog/useEventLogSearch.ts
+++ b/frontend/src/component/events/EventLog/useEventLogSearch.ts
@@ -10,6 +10,7 @@ import mapValues from 'lodash.mapvalues';
import { useEventSearch } from 'hooks/api/getters/useEventSearch/useEventSearch';
import type { SearchEventsParams } from 'openapi';
import type { FilterItemParamHolder } from 'component/filter/Filters/Filters';
+import { format, subMonths } from 'date-fns';
type Log =
| { type: 'global' }
@@ -60,8 +61,14 @@ export const useEventLogSearch = (
offset: withDefault(NumberParam, 0),
limit: withDefault(NumberParam, DEFAULT_PAGE_SIZE),
query: StringParam,
- from: FilterItemParam,
- to: FilterItemParam,
+ from: withDefault(FilterItemParam, {
+ values: [format(subMonths(new Date(), 1), 'yyyy-MM-dd')],
+ operator: 'IS',
+ }),
+ to: withDefault(FilterItemParam, {
+ values: [format(new Date(), 'yyyy-MM-dd')],
+ operator: 'IS',
+ }),
createdBy: FilterItemParam,
type: FilterItemParam,
...extraParameters(logType),
@@ -81,6 +88,7 @@ export const useEventLogSearch = (
const [tableState, setTableState] = usePersistentTableState(
fullStorageKey,
stateConfig,
+ ['from', 'to', 'offset'],
);
const filterState = (() => {
diff --git a/frontend/src/component/filter/FilterItem/FilterItemChip/FilterItemChip.tsx b/frontend/src/component/filter/FilterItem/FilterItemChip/FilterItemChip.tsx
index 4fab6eec5f49..3929a18ff09b 100644
--- a/frontend/src/component/filter/FilterItem/FilterItemChip/FilterItemChip.tsx
+++ b/frontend/src/component/filter/FilterItem/FilterItemChip/FilterItemChip.tsx
@@ -31,6 +31,7 @@ const StyledLabel = styled('div')(({ theme }) => ({
alignItems: 'center',
justifyContent: 'space-between',
fontWeight: theme.typography.fontWeightBold,
+ minHeight: theme.spacing(3.5),
}));
const StyledOptions = styled('button')(({ theme }) => ({
diff --git a/frontend/src/component/filter/Filters/Filters.tsx b/frontend/src/component/filter/Filters/Filters.tsx
index 472e22145f2d..eaee651bc7df 100644
--- a/frontend/src/component/filter/Filters/Filters.tsx
+++ b/frontend/src/component/filter/Filters/Filters.tsx
@@ -41,6 +41,9 @@ type ITextFilterItem = IBaseFilterItem & {
type IDateFilterItem = IBaseFilterItem & {
dateOperators: [string, ...string[]];
+ fromFilterKey?: string;
+ toFilterKey?: string;
+ persistent?: boolean;
};
export type IFilterItem = ITextFilterItem | IDateFilterItem;
@@ -116,6 +119,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,11 +162,16 @@ 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)}
+ onChipClose={
+ filter.persistent
+ ? undefined
+ : () => deselectFilter(filter.label)
+ }
/>
);
}
diff --git a/frontend/src/component/insights/Insights.test.tsx b/frontend/src/component/insights/Insights.test.tsx
index b8ad1cb0c6ba..268392e6ab40 100644
--- a/frontend/src/component/insights/Insights.test.tsx
+++ b/frontend/src/component/insights/Insights.test.tsx
@@ -33,9 +33,6 @@ test('Filter insights by project and date', async () => {
render();
const addFilter = await screen.findByText('Add Filter');
fireEvent.click(addFilter);
-
- const dateFromFilter = await screen.findByText('Date From');
- await screen.findByText('Date To');
const projectFilter = await screen.findByText('Project');
// filter by project
@@ -45,11 +42,17 @@ test('Filter insights by project and date', async () => {
await fireEvent.click(projectName);
expect(window.location.href).toContain('project=IS%3AprojectB');
- // filter by from date
- fireEvent.click(dateFromFilter);
- const day = await screen.findByText('25');
- fireEvent.click(day);
+ // last month moving window by default
+ const fromDate = await screen.findByText('03/25/2024');
+ await screen.findByText('04/25/2024');
+
+ // change dates by preset range
+ fireEvent.click(fromDate);
+ const previousMonth = await screen.findByText('Previous month');
+ fireEvent.click(previousMonth);
+ await screen.findByText('03/01/2024');
+ await screen.findByText('03/31/2024');
expect(window.location.href).toContain(
- 'project=IS%3AprojectB&from=IS%3A2024-04-25',
+ '?project=IS%3AprojectB&from=IS%3A2024-03-01&to=IS%3A2024-03-31',
);
});
diff --git a/frontend/src/component/insights/Insights.tsx b/frontend/src/component/insights/Insights.tsx
index 5d4987396525..49ea683e0921 100644
--- a/frontend/src/component/insights/Insights.tsx
+++ b/frontend/src/component/insights/Insights.tsx
@@ -9,6 +9,8 @@ import { InsightsCharts } from './InsightsCharts';
import { Sticky } from 'component/common/Sticky/Sticky';
import { InsightsFilters } from './InsightsFilters';
import { FilterItemParam } from '../../utils/serializeQueryParams';
+import { format, subMonths } from 'date-fns';
+import { withDefault } from 'use-query-params';
const StyledWrapper = styled('div')(({ theme }) => ({
paddingTop: theme.spacing(2),
@@ -32,10 +34,20 @@ export const Insights: FC = ({ withCharts = true }) => {
const stateConfig = {
project: FilterItemParam,
- from: FilterItemParam,
- to: FilterItemParam,
+ from: withDefault(FilterItemParam, {
+ values: [format(subMonths(new Date(), 1), 'yyyy-MM-dd')],
+ operator: 'IS',
+ }),
+ to: withDefault(FilterItemParam, {
+ values: [format(new Date(), 'yyyy-MM-dd')],
+ operator: 'IS',
+ }),
};
- const [state, setState] = usePersistentTableState('insights', stateConfig);
+ const [state, setState] = usePersistentTableState('insights', stateConfig, [
+ 'from',
+ 'to',
+ ]);
+
const { insights, loading } = useInsights(
state.from?.values[0],
state.to?.values[0],
diff --git a/frontend/src/component/insights/InsightsFilters.tsx b/frontend/src/component/insights/InsightsFilters.tsx
index 99ecb9a3114c..51778606c702 100644
--- a/frontend/src/component/insights/InsightsFilters.tsx
+++ b/frontend/src/component/insights/InsightsFilters.tsx
@@ -34,6 +34,9 @@ export const InsightsFilters: FC = ({
options: [],
filterKey: 'from',
dateOperators: ['IS'],
+ fromFilterKey: 'from',
+ toFilterKey: 'to',
+ persistent: true,
},
{
label: 'Date To',
@@ -41,6 +44,9 @@ export const InsightsFilters: FC = ({
options: [],
filterKey: 'to',
dateOperators: ['IS'],
+ fromFilterKey: 'from',
+ toFilterKey: 'to',
+ persistent: true,
},
...(hasMultipleProjects
? ([