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 { useLocationSettings } from 'hooks/useLocationSettings';
|
||||||
import { getLocalizedDateString } from '../util';
|
import { getLocalizedDateString } from '../util';
|
||||||
import type { FilterItemParams } from 'component/filter/FilterItem/FilterItem';
|
import type { FilterItemParams } from 'component/filter/FilterItem/FilterItem';
|
||||||
|
import { DateRangePresets } from './DateRangePresets';
|
||||||
|
|
||||||
export interface IFilterDateItemProps {
|
export interface IFilterDateItemProps {
|
||||||
name: string;
|
name: string;
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
onChange: (value: FilterItemParams) => void;
|
onChange: (value: FilterItemParams) => void;
|
||||||
onChipClose: () => void;
|
onRangeChange?: (value: {
|
||||||
|
from: FilterItemParams;
|
||||||
|
to: FilterItemParams;
|
||||||
|
}) => void;
|
||||||
|
onChipClose?: () => void;
|
||||||
state: FilterItemParams | null | undefined;
|
state: FilterItemParams | null | undefined;
|
||||||
operators: [string, ...string[]];
|
operators: [string, ...string[]];
|
||||||
}
|
}
|
||||||
@ -22,6 +27,7 @@ export const FilterDateItem: FC<IFilterDateItemProps> = ({
|
|||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
onChange,
|
onChange,
|
||||||
|
onRangeChange,
|
||||||
onChipClose,
|
onChipClose,
|
||||||
state,
|
state,
|
||||||
operators,
|
operators,
|
||||||
@ -54,11 +60,13 @@ export const FilterDateItem: FC<IFilterDateItemProps> = ({
|
|||||||
: [];
|
: [];
|
||||||
const selectedDate = state ? new Date(state.values[0]) : null;
|
const selectedDate = state ? new Date(state.values[0]) : null;
|
||||||
const currentOperator = state ? state.operator : operators[0];
|
const currentOperator = state ? state.operator : operators[0];
|
||||||
const onDelete = () => {
|
const onDelete = onChipClose
|
||||||
onChange({ operator: operators[0], values: [] });
|
? () => {
|
||||||
onClose();
|
onChange({ operator: operators[0], values: [] });
|
||||||
onChipClose();
|
onClose();
|
||||||
};
|
onChipClose();
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state && !operators.includes(state.operator)) {
|
if (state && !operators.includes(state.operator)) {
|
||||||
@ -115,6 +123,9 @@ export const FilterDateItem: FC<IFilterDateItemProps> = ({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{onRangeChange && (
|
||||||
|
<DateRangePresets onRangeChange={onRangeChange} />
|
||||||
|
)}
|
||||||
</LocalizationProvider>
|
</LocalizationProvider>
|
||||||
</StyledPopover>
|
</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: [],
|
options: [],
|
||||||
filterKey: 'from',
|
filterKey: 'from',
|
||||||
dateOperators: ['IS'],
|
dateOperators: ['IS'],
|
||||||
|
fromFilterKey: 'from',
|
||||||
|
toFilterKey: 'to',
|
||||||
|
persistent: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Date To',
|
label: 'Date To',
|
||||||
@ -64,6 +67,9 @@ export const useEventLogFilters = (
|
|||||||
options: [],
|
options: [],
|
||||||
filterKey: 'to',
|
filterKey: 'to',
|
||||||
dateOperators: ['IS'],
|
dateOperators: ['IS'],
|
||||||
|
fromFilterKey: 'from',
|
||||||
|
toFilterKey: 'to',
|
||||||
|
persistent: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Created by',
|
label: 'Created by',
|
||||||
|
@ -10,6 +10,7 @@ import mapValues from 'lodash.mapvalues';
|
|||||||
import { useEventSearch } from 'hooks/api/getters/useEventSearch/useEventSearch';
|
import { useEventSearch } from 'hooks/api/getters/useEventSearch/useEventSearch';
|
||||||
import type { SearchEventsParams } from 'openapi';
|
import type { SearchEventsParams } from 'openapi';
|
||||||
import type { FilterItemParamHolder } from 'component/filter/Filters/Filters';
|
import type { FilterItemParamHolder } from 'component/filter/Filters/Filters';
|
||||||
|
import { format, subMonths } from 'date-fns';
|
||||||
|
|
||||||
type Log =
|
type Log =
|
||||||
| { type: 'global' }
|
| { type: 'global' }
|
||||||
@ -60,8 +61,14 @@ export const useEventLogSearch = (
|
|||||||
offset: withDefault(NumberParam, 0),
|
offset: withDefault(NumberParam, 0),
|
||||||
limit: withDefault(NumberParam, DEFAULT_PAGE_SIZE),
|
limit: withDefault(NumberParam, DEFAULT_PAGE_SIZE),
|
||||||
query: StringParam,
|
query: StringParam,
|
||||||
from: FilterItemParam,
|
from: withDefault(FilterItemParam, {
|
||||||
to: 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,
|
createdBy: FilterItemParam,
|
||||||
type: FilterItemParam,
|
type: FilterItemParam,
|
||||||
...extraParameters(logType),
|
...extraParameters(logType),
|
||||||
@ -81,6 +88,7 @@ export const useEventLogSearch = (
|
|||||||
const [tableState, setTableState] = usePersistentTableState(
|
const [tableState, setTableState] = usePersistentTableState(
|
||||||
fullStorageKey,
|
fullStorageKey,
|
||||||
stateConfig,
|
stateConfig,
|
||||||
|
['from', 'to', 'offset'],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filterState = (() => {
|
const filterState = (() => {
|
||||||
|
@ -31,6 +31,7 @@ const StyledLabel = styled('div')(({ theme }) => ({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
fontWeight: theme.typography.fontWeightBold,
|
fontWeight: theme.typography.fontWeightBold,
|
||||||
|
minHeight: theme.spacing(3.5),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledOptions = styled('button')(({ theme }) => ({
|
const StyledOptions = styled('button')(({ theme }) => ({
|
||||||
|
@ -41,6 +41,9 @@ type ITextFilterItem = IBaseFilterItem & {
|
|||||||
|
|
||||||
type IDateFilterItem = IBaseFilterItem & {
|
type IDateFilterItem = IBaseFilterItem & {
|
||||||
dateOperators: [string, ...string[]];
|
dateOperators: [string, ...string[]];
|
||||||
|
fromFilterKey?: string;
|
||||||
|
toFilterKey?: string;
|
||||||
|
persistent?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IFilterItem = ITextFilterItem | IDateFilterItem;
|
export type IFilterItem = ITextFilterItem | IDateFilterItem;
|
||||||
@ -116,6 +119,22 @@ export const Filters: FC<IFilterProps> = ({
|
|||||||
}, [JSON.stringify(state), JSON.stringify(availableFilters)]);
|
}, [JSON.stringify(state), JSON.stringify(availableFilters)]);
|
||||||
|
|
||||||
const hasAvailableFilters = unselectedFilters.length > 0;
|
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 (
|
return (
|
||||||
<StyledBox className={className}>
|
<StyledBox className={className}>
|
||||||
{selectedFilters.map((selectedFilter) => {
|
{selectedFilters.map((selectedFilter) => {
|
||||||
@ -143,11 +162,16 @@ export const Filters: FC<IFilterProps> = ({
|
|||||||
label={label}
|
label={label}
|
||||||
name={filter.label}
|
name={filter.label}
|
||||||
state={state[filter.filterKey]}
|
state={state[filter.filterKey]}
|
||||||
onChange={(value) =>
|
onChange={(value) => {
|
||||||
onChange({ [filter.filterKey]: value })
|
onChange({ [filter.filterKey]: value });
|
||||||
}
|
}}
|
||||||
|
onRangeChange={rangeChangeHandler(filter)}
|
||||||
operators={filter.dateOperators}
|
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} />);
|
render(<Insights withCharts={false} />);
|
||||||
const addFilter = await screen.findByText('Add Filter');
|
const addFilter = await screen.findByText('Add Filter');
|
||||||
fireEvent.click(addFilter);
|
fireEvent.click(addFilter);
|
||||||
|
|
||||||
const dateFromFilter = await screen.findByText('Date From');
|
|
||||||
await screen.findByText('Date To');
|
|
||||||
const projectFilter = await screen.findByText('Project');
|
const projectFilter = await screen.findByText('Project');
|
||||||
|
|
||||||
// filter by project
|
// filter by project
|
||||||
@ -45,11 +42,17 @@ test('Filter insights by project and date', async () => {
|
|||||||
await fireEvent.click(projectName);
|
await fireEvent.click(projectName);
|
||||||
expect(window.location.href).toContain('project=IS%3AprojectB');
|
expect(window.location.href).toContain('project=IS%3AprojectB');
|
||||||
|
|
||||||
// filter by from date
|
// last month moving window by default
|
||||||
fireEvent.click(dateFromFilter);
|
const fromDate = await screen.findByText('03/25/2024');
|
||||||
const day = await screen.findByText('25');
|
await screen.findByText('04/25/2024');
|
||||||
fireEvent.click(day);
|
|
||||||
|
// 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(
|
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 { Sticky } from 'component/common/Sticky/Sticky';
|
||||||
import { InsightsFilters } from './InsightsFilters';
|
import { InsightsFilters } from './InsightsFilters';
|
||||||
import { FilterItemParam } from '../../utils/serializeQueryParams';
|
import { FilterItemParam } from '../../utils/serializeQueryParams';
|
||||||
|
import { format, subMonths } from 'date-fns';
|
||||||
|
import { withDefault } from 'use-query-params';
|
||||||
|
|
||||||
const StyledWrapper = styled('div')(({ theme }) => ({
|
const StyledWrapper = styled('div')(({ theme }) => ({
|
||||||
paddingTop: theme.spacing(2),
|
paddingTop: theme.spacing(2),
|
||||||
@ -32,10 +34,20 @@ export const Insights: FC<InsightsProps> = ({ withCharts = true }) => {
|
|||||||
|
|
||||||
const stateConfig = {
|
const stateConfig = {
|
||||||
project: FilterItemParam,
|
project: FilterItemParam,
|
||||||
from: FilterItemParam,
|
from: withDefault(FilterItemParam, {
|
||||||
to: 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(
|
const { insights, loading } = useInsights(
|
||||||
state.from?.values[0],
|
state.from?.values[0],
|
||||||
state.to?.values[0],
|
state.to?.values[0],
|
||||||
|
@ -34,6 +34,9 @@ export const InsightsFilters: FC<IFeatureToggleFiltersProps> = ({
|
|||||||
options: [],
|
options: [],
|
||||||
filterKey: 'from',
|
filterKey: 'from',
|
||||||
dateOperators: ['IS'],
|
dateOperators: ['IS'],
|
||||||
|
fromFilterKey: 'from',
|
||||||
|
toFilterKey: 'to',
|
||||||
|
persistent: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Date To',
|
label: 'Date To',
|
||||||
@ -41,6 +44,9 @@ export const InsightsFilters: FC<IFeatureToggleFiltersProps> = ({
|
|||||||
options: [],
|
options: [],
|
||||||
filterKey: 'to',
|
filterKey: 'to',
|
||||||
dateOperators: ['IS'],
|
dateOperators: ['IS'],
|
||||||
|
fromFilterKey: 'from',
|
||||||
|
toFilterKey: 'to',
|
||||||
|
persistent: true,
|
||||||
},
|
},
|
||||||
...(hasMultipleProjects
|
...(hasMultipleProjects
|
||||||
? ([
|
? ([
|
||||||
|
Loading…
Reference in New Issue
Block a user