From 8080ff6a54e3aab428ed459c5d4fbb0b91f62080 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Tue, 17 Dec 2024 10:51:52 +0100 Subject: [PATCH 1/4] feat: date range selector --- .../FilterDateItem/DateRangePresets.tsx | 71 +++++++++++++++++++ .../common/FilterDateItem/FilterDateItem.tsx | 9 +++ .../FilterDateItem/calculateDateRange.test.ts | 40 +++++++++++ .../FilterDateItem/calculateDateRange.ts | 66 +++++++++++++++++ .../events/EventLog/EventLogFilters.tsx | 4 ++ .../src/component/filter/Filters/Filters.tsx | 25 ++++++- .../component/insights/InsightsFilters.tsx | 4 ++ 7 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 frontend/src/component/common/FilterDateItem/DateRangePresets.tsx create mode 100644 frontend/src/component/common/FilterDateItem/calculateDateRange.test.ts create mode 100644 frontend/src/component/common/FilterDateItem/calculateDateRange.ts 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 ? ([ From 4adcc6fb61a9b2fa3c5c56de58280982640b87ae Mon Sep 17 00:00:00 2001 From: kwasniew Date: Tue, 17 Dec 2024 10:58:57 +0100 Subject: [PATCH 2/4] feat: date range selector --- .../common/FilterDateItem/DateRangePresets.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/frontend/src/component/common/FilterDateItem/DateRangePresets.tsx b/frontend/src/component/common/FilterDateItem/DateRangePresets.tsx index 2eb92c2f133f..5af96c089b75 100644 --- a/frontend/src/component/common/FilterDateItem/DateRangePresets.tsx +++ b/frontend/src/component/common/FilterDateItem/DateRangePresets.tsx @@ -1,8 +1,20 @@ -import { Box, List, ListItem, ListItemButton, Typography } from '@mui/material'; +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; @@ -25,9 +37,7 @@ export const DateRangePresets: FC<{ return ( - - Presets - + Presets From 1f0150c4418447acd3273a6817ea8d13d70b7f8d Mon Sep 17 00:00:00 2001 From: kwasniew Date: Tue, 17 Dec 2024 13:47:11 +0100 Subject: [PATCH 3/4] feat: persistent dates with default values --- .../common/FilterDateItem/FilterDateItem.tsx | 14 ++++++++------ .../events/EventLog/EventLogFilters.tsx | 2 ++ .../events/EventLog/useEventLogSearch.ts | 12 ++++++++++-- .../FilterItemChip/FilterItemChip.tsx | 1 + .../src/component/filter/Filters/Filters.tsx | 7 ++++++- frontend/src/component/insights/Insights.tsx | 18 +++++++++++++++--- .../src/component/insights/InsightsFilters.tsx | 2 ++ 7 files changed, 44 insertions(+), 12 deletions(-) diff --git a/frontend/src/component/common/FilterDateItem/FilterDateItem.tsx b/frontend/src/component/common/FilterDateItem/FilterDateItem.tsx index 4f193555511a..7f20939a7b32 100644 --- a/frontend/src/component/common/FilterDateItem/FilterDateItem.tsx +++ b/frontend/src/component/common/FilterDateItem/FilterDateItem.tsx @@ -18,7 +18,7 @@ export interface IFilterDateItemProps { from: FilterItemParams; to: FilterItemParams; }) => void; - onChipClose: () => void; + onChipClose?: () => void; state: FilterItemParams | null | undefined; operators: [string, ...string[]]; } @@ -60,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)) { diff --git a/frontend/src/component/events/EventLog/EventLogFilters.tsx b/frontend/src/component/events/EventLog/EventLogFilters.tsx index 4cd75e7229f8..d25f8279a728 100644 --- a/frontend/src/component/events/EventLog/EventLogFilters.tsx +++ b/frontend/src/component/events/EventLog/EventLogFilters.tsx @@ -59,6 +59,7 @@ export const useEventLogFilters = ( dateOperators: ['IS'], fromFilterKey: 'from', toFilterKey: 'to', + persistent: true, }, { label: 'Date To', @@ -68,6 +69,7 @@ export const useEventLogFilters = ( 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 a5c67918ea72..eaee651bc7df 100644 --- a/frontend/src/component/filter/Filters/Filters.tsx +++ b/frontend/src/component/filter/Filters/Filters.tsx @@ -43,6 +43,7 @@ type IDateFilterItem = IBaseFilterItem & { dateOperators: [string, ...string[]]; fromFilterKey?: string; toFilterKey?: string; + persistent?: boolean; }; export type IFilterItem = ITextFilterItem | IDateFilterItem; @@ -166,7 +167,11 @@ export const Filters: FC = ({ }} 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.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 c3f231d36706..51778606c702 100644 --- a/frontend/src/component/insights/InsightsFilters.tsx +++ b/frontend/src/component/insights/InsightsFilters.tsx @@ -36,6 +36,7 @@ export const InsightsFilters: FC = ({ dateOperators: ['IS'], fromFilterKey: 'from', toFilterKey: 'to', + persistent: true, }, { label: 'Date To', @@ -45,6 +46,7 @@ export const InsightsFilters: FC = ({ dateOperators: ['IS'], fromFilterKey: 'from', toFilterKey: 'to', + persistent: true, }, ...(hasMultipleProjects ? ([ From 870569d7a92aac1a23a759a57f52c5fda4d7f066 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Tue, 17 Dec 2024 14:16:38 +0100 Subject: [PATCH 4/4] test: preset selectors --- .../src/component/insights/Insights.test.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) 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', ); });