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:
parent
45035102f4
commit
da16b316aa
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
@ -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')];
|
||||
};
|
@ -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',
|
||||
|
@ -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 = (() => {
|
||||
|
@ -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 }) => ({
|
||||
|
@ -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)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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',
|
||||
);
|
||||
});
|
||||
|
@ -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],
|
||||
|
@ -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
|
||||
? ([
|
||||
|
Loading…
Reference in New Issue
Block a user