1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-09 00:18:00 +01:00

feat: date range selector (#8991)

This commit is contained in:
Mateusz Kwasniewski 2024-12-18 10:40:50 +01:00 committed by GitHub
parent 45035102f4
commit da16b316aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 281 additions and 23 deletions

View File

@ -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 (
<Box>
<PresetsHeader variant='h3'>Presets</PresetsHeader>
<List disablePadding sx={{ pb: 2 }}>
<ListItem disablePadding>
<ListItemButton onClick={rangeChangeHandler('thisMonth')}>
This month
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton
onClick={rangeChangeHandler('previousMonth')}
>
Previous month
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton onClick={rangeChangeHandler('thisQuarter')}>
This quarter
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton
onClick={rangeChangeHandler('previousQuarter')}
>
Previous quarter
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton onClick={rangeChangeHandler('thisYear')}>
This year
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton
onClick={rangeChangeHandler('previousYear')}
>
Previous year
</ListItemButton>
</ListItem>
</List>
</Box>
);
};

View File

@ -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<IFilterDateItemProps> = ({
name,
label,
onChange,
onRangeChange,
onChipClose,
state,
operators,
@ -54,11 +60,13 @@ export const FilterDateItem: FC<IFilterDateItemProps> = ({
: [];
const selectedDate = state ? new Date(state.values[0]) : null;
const currentOperator = state ? state.operator : operators[0];
const onDelete = () => {
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<IFilterDateItemProps> = ({
});
}}
/>
{onRangeChange && (
<DateRangePresets onRangeChange={onRangeChange} />
)}
</LocalizationProvider>
</StyledPopover>
</>

View File

@ -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');
});
});

View File

@ -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')];
};

View File

@ -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',

View File

@ -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 = (() => {

View File

@ -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 }) => ({

View File

@ -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<IFilterProps> = ({
}, [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 (
<StyledBox className={className}>
{selectedFilters.map((selectedFilter) => {
@ -143,11 +162,16 @@ export const Filters: FC<IFilterProps> = ({
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)
}
/>
);
}

View File

@ -33,9 +33,6 @@ test('Filter insights by project and date', async () => {
render(<Insights withCharts={false} />);
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',
);
});

View File

@ -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<InsightsProps> = ({ 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],

View File

@ -34,6 +34,9 @@ export const InsightsFilters: FC<IFeatureToggleFiltersProps> = ({
options: [],
filterKey: 'from',
dateOperators: ['IS'],
fromFilterKey: 'from',
toFilterKey: 'to',
persistent: true,
},
{
label: 'Date To',
@ -41,6 +44,9 @@ export const InsightsFilters: FC<IFeatureToggleFiltersProps> = ({
options: [],
filterKey: 'to',
dateOperators: ['IS'],
fromFilterKey: 'from',
toFilterKey: 'to',
persistent: true,
},
...(hasMultipleProjects
? ([