Alerting: adding a time picker for selecting relative time. (#33689)

* adding placeholder for relative time range.

* fixed story.

* added basic structure to handle open/close of time range picker.

* removed section from TimeOptions since it isn't used any where.

* adding mapper and tests

* move relativetimepicker to its own dir

* added some simple tests.

* changed test.

* use relativetimerangeinput

* redo state management

* refactored the tests.

* replace timerange with relativetimerange

* wip

* wip

* did some refactoring.

* refactored time option formatting.

* added proper formatting and display of time range.

* add relative time description, slight refactor of height

* fixed incorrect import.

* added validator and changed formatting.

* removed unused dep.

* reverted back to internal function.

* fixed display of relative time range picker.

* fixed failing tests.

* fixed parsing issue.

* fixed position of time range picker.

* some more refactorings.

* fixed validation of really big values.

* added another test.

Co-authored-by: Peter Holmberg <peter.hlmbrg@gmail.com>
This commit is contained in:
Marcus Andersson 2021-05-12 17:51:31 +02:00 committed by GitHub
parent 2459a0ceb5
commit 07ef4060a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 645 additions and 214 deletions

View File

@ -1,6 +1,6 @@
import { each, groupBy, has } from 'lodash';
import { each, has } from 'lodash';
import { RawTimeRange, TimeRange, TimeZone, IntervalValues, RelativeTimeRange } from '../types/time';
import { RawTimeRange, TimeRange, TimeZone, IntervalValues, RelativeTimeRange, TimeOption } from '../types/time';
import * as dateMath from './datemath';
import { isDateTime, DateTime, dateTime } from './moment_wrapper';
@ -17,69 +17,67 @@ const spans: { [key: string]: { display: string; section?: number } } = {
y: { display: 'year' },
};
const rangeOptions = [
{ from: 'now/d', to: 'now/d', display: 'Today', section: 2 },
{ from: 'now/d', to: 'now', display: 'Today so far', section: 2 },
{ from: 'now/w', to: 'now/w', display: 'This week', section: 2 },
{ from: 'now/w', to: 'now', display: 'This week so far', section: 2 },
{ from: 'now/M', to: 'now/M', display: 'This month', section: 2 },
{ from: 'now/M', to: 'now', display: 'This month so far', section: 2 },
{ from: 'now/y', to: 'now/y', display: 'This year', section: 2 },
{ from: 'now/y', to: 'now', display: 'This year so far', section: 2 },
const rangeOptions: TimeOption[] = [
{ from: 'now/d', to: 'now/d', display: 'Today' },
{ from: 'now/d', to: 'now', display: 'Today so far' },
{ from: 'now/w', to: 'now/w', display: 'This week' },
{ from: 'now/w', to: 'now', display: 'This week so far' },
{ from: 'now/M', to: 'now/M', display: 'This month' },
{ from: 'now/M', to: 'now', display: 'This month so far' },
{ from: 'now/y', to: 'now/y', display: 'This year' },
{ from: 'now/y', to: 'now', display: 'This year so far' },
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday', section: 1 },
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday' },
{
from: 'now-2d/d',
to: 'now-2d/d',
display: 'Day before yesterday',
section: 1,
},
{
from: 'now-7d/d',
to: 'now-7d/d',
display: 'This day last week',
section: 1,
},
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week', section: 1 },
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month', section: 1 },
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year', section: 1 },
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week' },
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month' },
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year' },
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3 },
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3 },
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3 },
{ from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3 },
{ from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3 },
{ from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3 },
{ from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3 },
{ from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3 },
{ from: 'now-2d', to: 'now', display: 'Last 2 days', section: 0 },
{ from: 'now-7d', to: 'now', display: 'Last 7 days', section: 0 },
{ from: 'now-30d', to: 'now', display: 'Last 30 days', section: 0 },
{ from: 'now-90d', to: 'now', display: 'Last 90 days', section: 0 },
{ from: 'now-6M', to: 'now', display: 'Last 6 months', section: 0 },
{ from: 'now-1y', to: 'now', display: 'Last 1 year', section: 0 },
{ from: 'now-2y', to: 'now', display: 'Last 2 years', section: 0 },
{ from: 'now-5y', to: 'now', display: 'Last 5 years', section: 0 },
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes' },
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes' },
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes' },
{ from: 'now-1h', to: 'now', display: 'Last 1 hour' },
{ from: 'now-3h', to: 'now', display: 'Last 3 hours' },
{ from: 'now-6h', to: 'now', display: 'Last 6 hours' },
{ from: 'now-12h', to: 'now', display: 'Last 12 hours' },
{ from: 'now-24h', to: 'now', display: 'Last 24 hours' },
{ from: 'now-2d', to: 'now', display: 'Last 2 days' },
{ from: 'now-7d', to: 'now', display: 'Last 7 days' },
{ from: 'now-30d', to: 'now', display: 'Last 30 days' },
{ from: 'now-90d', to: 'now', display: 'Last 90 days' },
{ from: 'now-6M', to: 'now', display: 'Last 6 months' },
{ from: 'now-1y', to: 'now', display: 'Last 1 year' },
{ from: 'now-2y', to: 'now', display: 'Last 2 years' },
{ from: 'now-5y', to: 'now', display: 'Last 5 years' },
];
const hiddenRangeOptions = [
{ from: 'now', to: 'now+1m', display: 'Next minute', section: 3 },
{ from: 'now', to: 'now+5m', display: 'Next 5 minutes', section: 3 },
{ from: 'now', to: 'now+15m', display: 'Next 15 minutes', section: 3 },
{ from: 'now', to: 'now+30m', display: 'Next 30 minutes', section: 3 },
{ from: 'now', to: 'now+1h', display: 'Next hour', section: 3 },
{ from: 'now', to: 'now+3h', display: 'Next 3 hours', section: 3 },
{ from: 'now', to: 'now+6h', display: 'Next 6 hours', section: 3 },
{ from: 'now', to: 'now+12h', display: 'Next 12 hours', section: 3 },
{ from: 'now', to: 'now+24h', display: 'Next 24 hours', section: 3 },
{ from: 'now', to: 'now+2d', display: 'Next 2 days', section: 0 },
{ from: 'now', to: 'now+7d', display: 'Next 7 days', section: 0 },
{ from: 'now', to: 'now+30d', display: 'Next 30 days', section: 0 },
{ from: 'now', to: 'now+90d', display: 'Next 90 days', section: 0 },
{ from: 'now', to: 'now+6M', display: 'Next 6 months', section: 0 },
{ from: 'now', to: 'now+1y', display: 'Next year', section: 0 },
{ from: 'now', to: 'now+2y', display: 'Next 2 years', section: 0 },
{ from: 'now', to: 'now+5y', display: 'Next 5 years', section: 0 },
const hiddenRangeOptions: TimeOption[] = [
{ from: 'now', to: 'now+1m', display: 'Next minute' },
{ from: 'now', to: 'now+5m', display: 'Next 5 minutes' },
{ from: 'now', to: 'now+15m', display: 'Next 15 minutes' },
{ from: 'now', to: 'now+30m', display: 'Next 30 minutes' },
{ from: 'now', to: 'now+1h', display: 'Next hour' },
{ from: 'now', to: 'now+3h', display: 'Next 3 hours' },
{ from: 'now', to: 'now+6h', display: 'Next 6 hours' },
{ from: 'now', to: 'now+12h', display: 'Next 12 hours' },
{ from: 'now', to: 'now+24h', display: 'Next 24 hours' },
{ from: 'now', to: 'now+2d', display: 'Next 2 days' },
{ from: 'now', to: 'now+7d', display: 'Next 7 days' },
{ from: 'now', to: 'now+30d', display: 'Next 30 days' },
{ from: 'now', to: 'now+90d', display: 'Next 90 days' },
{ from: 'now', to: 'now+6M', display: 'Next 6 months' },
{ from: 'now', to: 'now+1y', display: 'Next year' },
{ from: 'now', to: 'now+2y', display: 'Next 2 years' },
{ from: 'now', to: 'now+5y', display: 'Next 5 years' },
];
const rangeIndex: any = {};
@ -90,22 +88,6 @@ each(hiddenRangeOptions, (frame: any) => {
rangeIndex[frame.from + ' to ' + frame.to] = frame;
});
export function getRelativeTimesList(timepickerSettings: any, currentDisplay: any) {
const groups = groupBy(rangeOptions, (option: any) => {
option.active = option.display === currentDisplay;
return option.section;
});
// _.each(timepickerSettings.time_options, (duration: string) => {
// let info = describeTextRange(duration);
// if (info.section) {
// groups[info.section].push(info);
// }
// });
return groups;
}
// handles expressions like
// 5m
// 5m to now/d

View File

@ -40,7 +40,6 @@ export interface TimeOption {
from: string;
to: string;
display: string;
section: number;
}
export interface TimeOptions {

View File

@ -0,0 +1,37 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import { UseState } from '../../../utils/storybook/UseState';
import { withCenteredStory } from '../../../utils/storybook/withCenteredStory';
import { RelativeTimeRangePicker } from './RelativeTimeRangePicker';
export default {
title: 'Pickers and Editors/TimePickers/RelativeTimeRangePicker',
component: RelativeTimeRangePicker,
decorators: [withCenteredStory],
parameters: {
docs: {},
},
};
export const basic = () => {
return (
<UseState
initialState={{
from: 900,
to: 0,
}}
>
{(value, updateValue) => {
return (
<RelativeTimeRangePicker
onChange={(newValue) => {
action('on selected')(newValue);
updateValue(newValue);
}}
timeRange={value}
/>
);
}}
</UseState>
);
};

View File

@ -0,0 +1,47 @@
import React, { useState } from 'react';
import { render, fireEvent, RenderResult } from '@testing-library/react';
import { RelativeTimeRangePicker } from './RelativeTimeRangePicker';
import { RelativeTimeRange } from '@grafana/data';
function setup(initial: RelativeTimeRange = { from: 900, to: 0 }): RenderResult {
const StatefulPicker: React.FC<{}> = () => {
const [value, setValue] = useState<RelativeTimeRange>(initial);
return <RelativeTimeRangePicker timeRange={value} onChange={setValue} />;
};
return render(<StatefulPicker />);
}
describe('RelativeTimePicker', () => {
it('should render the picker button with an user friendly text', () => {
const { getByText } = setup({ from: 900, to: 0 });
expect(getByText('now-15m to now')).toBeInTheDocument();
});
it('should open the picker when clicking the button', () => {
const { getByText } = setup({ from: 900, to: 0 });
fireEvent.click(getByText('now-15m to now'));
expect(getByText('Specify time range')).toBeInTheDocument();
expect(getByText('Example time ranges')).toBeInTheDocument();
});
it('should not have open picker without clicking the button', () => {
const { queryByText } = setup({ from: 900, to: 0 });
expect(queryByText('Specify time range')).toBeNull();
expect(queryByText('Example time ranges')).toBeNull();
});
it('should not be able to apply range via quick options', () => {
const { getByText, queryByText } = setup({ from: 900, to: 0 });
fireEvent.click(getByText('now-15m to now')); // open the picker
fireEvent.click(getByText('Last 30 minutes')); // select the quick range, should close picker.
expect(queryByText('Specify time range')).toBeNull();
expect(queryByText('Example time ranges')).toBeNull();
expect(getByText('now-30m to now')).toBeInTheDocument(); // new text on picker button
});
});

View File

@ -0,0 +1,191 @@
import React, { FormEvent, ReactElement, useCallback, useState } from 'react';
import { css } from '@emotion/css';
import { RelativeTimeRange, GrafanaTheme2, TimeOption } from '@grafana/data';
import { Tooltip } from '../../Tooltip/Tooltip';
import { useStyles2 } from '../../../themes';
import { Button, ButtonGroup, ToolbarButton } from '../../Button';
import { ClickOutsideWrapper } from '../../ClickOutsideWrapper/ClickOutsideWrapper';
import { TimeRangeList } from '../TimeRangePicker/TimeRangeList';
import { quickOptions } from '../rangeOptions';
import CustomScrollbar from '../../CustomScrollbar/CustomScrollbar';
import { TimePickerTitle } from '../TimeRangePicker/TimePickerTitle';
import { isRangeValid, isRelativeFormat, mapOptionToRelativeTimeRange, mapRelativeTimeRangeToOption } from './utils';
import { Field } from '../../Forms/Field';
import { Input } from '../../Input/Input';
import { InputState } from '../TimeRangePicker/TimeRangeForm';
import { Icon } from '../../Icon/Icon';
/**
* @internal
*/
export interface RelativeTimeRangePickerProps {
timeRange: RelativeTimeRange;
onChange: (timeRange: RelativeTimeRange) => void;
}
const errorMessage = 'Value not in relative time format.';
const validOptions = quickOptions.filter((o) => isRelativeFormat(o.from));
/**
* @internal
*/
export function RelativeTimeRangePicker(props: RelativeTimeRangePickerProps): ReactElement | null {
const { timeRange, onChange } = props;
const [isOpen, setIsOpen] = useState(false);
const onClose = useCallback(() => setIsOpen(false), []);
const timeOption = mapRelativeTimeRangeToOption(timeRange);
const [from, setFrom] = useState<InputState>({ value: timeOption.from, invalid: !isRangeValid(timeOption.from) });
const [to, setTo] = useState<InputState>({ value: timeOption.to, invalid: !isRangeValid(timeOption.to) });
const styles = useStyles2(getStyles(from.invalid, to.invalid));
const onChangeTimeOption = (option: TimeOption) => {
const relativeTimeRange = mapOptionToRelativeTimeRange(option);
if (!relativeTimeRange) {
return;
}
onClose();
setFrom({ ...from, value: option.from });
setTo({ ...to, value: option.to });
onChange(relativeTimeRange);
};
const onOpen = useCallback(
(event: FormEvent<HTMLButtonElement>) => {
event.stopPropagation();
event.preventDefault();
setIsOpen(!isOpen);
},
[isOpen]
);
const onApply = (event: FormEvent<HTMLButtonElement>) => {
event.preventDefault();
if (to.invalid || from.invalid) {
return;
}
const timeRange = mapOptionToRelativeTimeRange({
from: from.value,
to: to.value,
display: '',
});
if (!timeRange) {
return;
}
onChange(timeRange);
setIsOpen(false);
};
return (
<ButtonGroup className={styles.container}>
<Tooltip content="Choose time range" placement="bottom">
<ToolbarButton aria-label="TimePicker Open Button" onClick={onOpen} icon="clock-nine" isOpen={isOpen}>
<span data-testid="picker-button-label" className={styles.container}>
{timeOption.from} to {timeOption.to}
</span>
</ToolbarButton>
</Tooltip>
{isOpen && (
<ClickOutsideWrapper includeButtonPress={false} onClick={onClose}>
<div className={styles.content}>
<div className={styles.body}>
<CustomScrollbar className={styles.leftSide} hideHorizontalTrack>
<TimeRangeList
title="Example time ranges"
options={validOptions}
onChange={onChangeTimeOption}
value={timeOption}
/>
</CustomScrollbar>
<div className={styles.rightSide}>
<div className={styles.title}>
<TimePickerTitle>Specify time range</TimePickerTitle>
<div className={styles.description}>
Specify a relative time range, for more information see{' '}
<a href="https://grafana.com/docs/grafana/latest/dashboards/time-range-controls/">
docs <Icon name="external-link-alt" />
</a>
.
</div>
</div>
<Field label="From" invalid={from.invalid} error={errorMessage}>
<Input
onClick={(event) => event.stopPropagation()}
onBlur={() => setFrom({ ...from, invalid: !isRangeValid(from.value) })}
onChange={(event) => setFrom({ ...from, value: event.currentTarget.value })}
value={from.value}
/>
</Field>
<Field label="To" invalid={to.invalid} error={errorMessage}>
<Input
onClick={(event) => event.stopPropagation()}
onBlur={() => setTo({ ...to, invalid: !isRangeValid(to.value) })}
onChange={(event) => setTo({ ...to, value: event.currentTarget.value })}
value={to.value}
/>
</Field>
<Button aria-label="TimePicker submit button" onClick={onApply}>
Apply time range
</Button>
</div>
</div>
</div>
</ClickOutsideWrapper>
)}
</ButtonGroup>
);
}
const getStyles = (fromInvalid: boolean, toInvalid: boolean) => (theme: GrafanaTheme2) => {
let bodyHeight = 250;
const errorHeight = theme.spacing.gridSize * 4;
if (fromInvalid && toInvalid) {
bodyHeight += errorHeight * 2;
} else if (fromInvalid || toInvalid) {
bodyHeight += errorHeight;
}
return {
container: css`
position: relative;
display: flex;
vertical-align: middle;
`,
content: css`
background: ${theme.colors.background.primary};
box-shadow: ${theme.shadows.z3};
position: absolute;
z-index: ${theme.zIndex.dropdown};
width: 500px;
top: 116%;
border-radius: 2px;
border: 1px solid ${theme.colors.border.weak};
left: 0;
white-space: normal;
`,
body: css`
display: flex;
height: ${bodyHeight}px;
`,
description: css`
color: ${theme.colors.text.secondary};
font-size: ${theme.typography.size.sm};
`,
leftSide: css`
width: 50% !important;
border-right: 1px solid ${theme.colors.border.medium};
`,
rightSide: css`
width: 50%;
padding: ${theme.spacing(1)};
`,
title: css`
margin-bottom: ${theme.spacing(1)};
`,
};
};

View File

@ -0,0 +1,108 @@
import { isRangeValid, isRelativeFormat, mapOptionToRelativeTimeRange, mapRelativeTimeRangeToOption } from './utils';
describe('utils', () => {
describe('mapRelativeTimeRangeToOption', () => {
it('should map relative time range from minutes to time option', () => {
const relativeTimeRange = { from: 600, to: 0 };
const timeOption = mapRelativeTimeRangeToOption(relativeTimeRange);
expect(timeOption).toEqual({ from: 'now-10m', to: 'now', display: 'now-10m to now' });
});
it('should map relative time range from one hour to time option', () => {
const relativeTimeRange = { from: 3600, to: 0 };
const timeOption = mapRelativeTimeRangeToOption(relativeTimeRange);
expect(timeOption).toEqual({ from: 'now-1h', to: 'now', display: 'now-1h to now' });
});
it('should map relative time range from hours to time option', () => {
const relativeTimeRange = { from: 7200, to: 0 };
const timeOption = mapRelativeTimeRangeToOption(relativeTimeRange);
expect(timeOption).toEqual({ from: 'now-2h', to: 'now', display: 'now-2h to now' });
});
it('should handle two relative ranges', () => {
const relativeTimeRange = { from: 600, to: 300 };
const timeOption = mapRelativeTimeRangeToOption(relativeTimeRange);
expect(timeOption).toEqual({ from: 'now-10m', to: 'now-5m', display: 'now-10m to now-5m' });
});
it('should handle two relative ranges with single/multiple units', () => {
const relativeTimeRange = { from: 6000, to: 300 };
const timeOption = mapRelativeTimeRangeToOption(relativeTimeRange);
expect(timeOption).toEqual({
from: 'now-100m',
to: 'now-5m',
display: 'now-100m to now-5m',
});
});
});
describe('mapOptionToRelativeTimeRange', () => {
it('should map simple case', () => {
const timeOption = { from: 'now-10m', to: 'now', display: 'asdfasdf' };
const relativeTimeRange = mapOptionToRelativeTimeRange(timeOption);
expect(relativeTimeRange).toEqual({ from: 600, to: 0 });
});
it('should map advanced case', () => {
const timeOption = { from: 'now-1d', to: 'now-12h', display: 'asdfasdf' };
const relativeTimeRange = mapOptionToRelativeTimeRange(timeOption);
expect(relativeTimeRange).toEqual({ from: 86400, to: 43200 });
});
});
describe('isRelativeFormat', () => {
it('should consider now as a relative format', () => {
expect(isRelativeFormat('now')).toBe(true);
});
it('should consider now-10s as a relative format', () => {
expect(isRelativeFormat('now-10s')).toBe(true);
});
it('should consider now-2000m as a relative format', () => {
expect(isRelativeFormat('now-2000m')).toBe(true);
});
it('should consider now-112334h as a relative format', () => {
expect(isRelativeFormat('now-112334h')).toBe(true);
});
it('should consider now-12d as a relative format', () => {
expect(isRelativeFormat('now-12d')).toBe(true);
});
it('should consider now-53w as a relative format', () => {
expect(isRelativeFormat('now-53w')).toBe(true);
});
it('should consider 123123123 as a relative format', () => {
expect(isRelativeFormat('123123123')).toBe(false);
});
});
describe('isRangeValid', () => {
it('should consider now as a valid relative format', () => {
expect(isRangeValid('now')).toBe(true);
});
it('should consider now-90d as a valid relative format', () => {
expect(isRangeValid('now-90d')).toBe(true);
});
it('should consider now-90000000d as an invalid relative format', () => {
expect(isRangeValid('now-90000000d')).toBe(false);
});
it('should consider now-11111111111s as an invalid relative format', () => {
expect(isRangeValid('now-11111111111s')).toBe(false);
});
});
});

View File

@ -0,0 +1,83 @@
import { RelativeTimeRange, TimeOption } from '@grafana/data';
const regex = /^now$|^now\-(\d{1,10})([wdhms])$/;
export const mapOptionToRelativeTimeRange = (option: TimeOption): RelativeTimeRange | undefined => {
return {
from: relativeToSeconds(option.from),
to: relativeToSeconds(option.to),
};
};
export const mapRelativeTimeRangeToOption = (range: RelativeTimeRange): TimeOption => {
const from = secondsToRelativeFormat(range.from);
const to = secondsToRelativeFormat(range.to);
return { from, to, display: `${from} to ${to}` };
};
export const isRangeValid = (relative: string, now = Date.now()): boolean => {
if (!isRelativeFormat(relative)) {
return false;
}
const seconds = relativeToSeconds(relative);
if (seconds > Math.ceil(now / 1000)) {
return false;
}
return true;
};
export const isRelativeFormat = (format: string): boolean => {
return regex.test(format);
};
const relativeToSeconds = (relative: string): number => {
const match = regex.exec(relative);
if (!match || match.length !== 3) {
return 0;
}
const [, value, unit] = match;
const parsed = parseInt(value, 10);
if (isNaN(parsed)) {
return 0;
}
return parsed * units[unit];
};
const units: Record<string, number> = {
w: 604800,
d: 86400,
h: 3600,
m: 60,
s: 1,
};
const secondsToRelativeFormat = (seconds: number): string => {
if (seconds <= 0) {
return 'now';
}
if (seconds >= units.w && seconds % units.w === 0) {
return `now-${seconds / units.w}w`;
}
if (seconds >= units.d && seconds % units.d === 0) {
return `now-${seconds / units.d}d`;
}
if (seconds >= units.h && seconds % units.h === 0) {
return `now-${seconds / units.h}h`;
}
if (seconds >= units.m && seconds % units.m === 0) {
return `now-${seconds / units.m}m`;
}
return `now-${seconds}s`;
};

View File

@ -1,11 +1,11 @@
import { GrafanaTheme2, isDateTime, TimeOption, TimeRange, TimeZone } from '@grafana/data';
import { GrafanaTheme2, isDateTime, rangeUtil, RawTimeRange, TimeOption, TimeRange, TimeZone } from '@grafana/data';
import { css, cx } from '@emotion/css';
import React, { memo, useState } from 'react';
import React, { memo, useMemo, useState } from 'react';
import { useMedia } from 'react-use';
import { stylesFactory, useTheme2 } from '../../../themes';
import { CustomScrollbar } from '../../CustomScrollbar/CustomScrollbar';
import { Icon } from '../../Icon/Icon';
import { mapRangeToTimeOption } from './mapper';
import { mapOptionToTimeRange, mapRangeToTimeOption } from './mapper';
import { TimePickerTitle } from './TimePickerTitle';
import { TimeRangeForm } from './TimeRangeForm';
import { TimeRangeList } from './TimeRangeList';
@ -156,6 +156,11 @@ export const TimePickerContentWithScreenSize: React.FC<PropsWithScreenSize> = (p
const theme = useTheme2();
const styles = getStyles(theme, isReversed, hideQuickRanges, isContainerTall);
const historyOptions = mapToHistoryOptions(history, timeZone);
const timeOption = useTimeOption(value.raw, otherOptions, quickOptions);
const onChangeTimeOption = (timeOption: TimeOption) => {
return onChange(mapOptionToTimeRange(timeOption));
};
return (
<div className={cx(styles.container, className)}>
@ -173,17 +178,15 @@ export const TimePickerContentWithScreenSize: React.FC<PropsWithScreenSize> = (p
<TimeRangeList
title="Relative time ranges"
options={quickOptions}
onSelect={onChange}
value={value}
timeZone={timeZone}
onChange={onChangeTimeOption}
value={timeOption}
/>
<div className={styles.spacing} />
<TimeRangeList
title="Other quick ranges"
options={otherOptions}
onSelect={onChange}
value={value}
timeZone={timeZone}
onChange={onChangeTimeOption}
value={timeOption}
/>
</>
)}
@ -210,6 +213,10 @@ const NarrowScreenForm: React.FC<FormProps> = (props) => {
const [collapsedFlag, setCollapsedFlag] = useState(!isAbsolute);
const collapsed = hideQuickRanges ? false : collapsedFlag;
const onChangeTimeOption = (timeOption: TimeOption) => {
return onChange(mapOptionToTimeRange(timeOption));
};
return (
<>
<div
@ -233,10 +240,8 @@ const NarrowScreenForm: React.FC<FormProps> = (props) => {
<TimeRangeList
title="Recently used absolute ranges"
options={historyOptions}
onSelect={onChange}
value={value}
onChange={onChangeTimeOption}
placeholderEmpty={null}
timeZone={timeZone}
/>
)}
</div>
@ -246,8 +251,12 @@ const NarrowScreenForm: React.FC<FormProps> = (props) => {
};
const FullScreenForm: React.FC<FormProps> = (props) => {
const { onChange } = props;
const theme = useTheme2();
const styles = getFullScreenStyles(theme, props.hideQuickRanges);
const onChangeTimeOption = (timeOption: TimeOption) => {
return onChange(mapOptionToTimeRange(timeOption));
};
return (
<>
@ -268,10 +277,8 @@ const FullScreenForm: React.FC<FormProps> = (props) => {
<TimeRangeList
title="Recently used absolute ranges"
options={props.historyOptions || []}
onSelect={props.onChange}
value={props.value}
onChange={onChangeTimeOption}
placeholderEmpty={<EmptyRecentList />}
timeZone={props.timeZone}
/>
</div>
)}
@ -313,3 +320,24 @@ function mapToHistoryOptions(ranges?: TimeRange[], timeZone?: TimeZone): TimeOpt
}
EmptyRecentList.displayName = 'EmptyRecentList';
const useTimeOption = (
raw: RawTimeRange,
quickOptions: TimeOption[],
otherOptions: TimeOption[]
): TimeOption | undefined => {
return useMemo(() => {
if (!rangeUtil.isRelativeTimeRange(raw)) {
return;
}
const quickOption = quickOptions.find((option) => {
return option.from === raw.from && option.to === raw.to;
});
if (quickOption) {
return quickOption;
}
return otherOptions.find((option) => {
return option.from === raw.from && option.to === raw.to;
});
}, [raw, otherOptions, quickOptions]);
};

View File

@ -24,7 +24,7 @@ interface Props {
isReversed?: boolean;
}
interface InputState {
export interface InputState {
value: string;
invalid: boolean;
}

View File

@ -1,9 +1,8 @@
import React, { ReactNode } from 'react';
import { css } from '@emotion/css';
import { TimeRange, TimeOption, TimeZone } from '@grafana/data';
import { TimeOption } from '@grafana/data';
import { TimePickerTitle } from './TimePickerTitle';
import { TimeRangeOption } from './TimeRangeOption';
import { mapOptionToTimeRange } from './mapper';
import { stylesFactory } from '../../../themes';
const getStyles = stylesFactory(() => {
@ -29,10 +28,9 @@ const getOptionsStyles = stylesFactory(() => {
interface Props {
title?: string;
options: TimeOption[];
value?: TimeRange;
onSelect: (option: TimeRange) => void;
value?: TimeOption;
onChange: (option: TimeOption) => void;
placeholderEmpty?: ReactNode;
timeZone?: TimeZone;
}
export const TimeRangeList: React.FC<Props> = (props) => {
@ -57,7 +55,7 @@ export const TimeRangeList: React.FC<Props> = (props) => {
);
};
const Options: React.FC<Props> = ({ options, value, onSelect, timeZone }) => {
const Options: React.FC<Props> = ({ options, value, onChange }) => {
const styles = getOptionsStyles();
return (
@ -68,11 +66,11 @@ const Options: React.FC<Props> = ({ options, value, onSelect, timeZone }) => {
key={keyForOption(option, index)}
value={option}
selected={isEqual(option, value)}
onSelect={(option) => onSelect(mapOptionToTimeRange(option, timeZone))}
onSelect={onChange}
/>
))}
</div>
<div className={styles.grow}></div>
<div className={styles.grow} />
</>
);
};
@ -81,9 +79,9 @@ function keyForOption(option: TimeOption, index: number): string {
return `${option.from}-${option.to}-${index}`;
}
function isEqual(x: TimeOption, y?: TimeRange): boolean {
function isEqual(x: TimeOption, y?: TimeOption): boolean {
if (!y || !x) {
return false;
}
return y.raw.from === x.from && y.raw.to === x.to;
return y.from === x.from && y.to === x.to;
}

View File

@ -11,7 +11,6 @@ export const mapRangeToTimeOption = (range: TimeRange, timeZone?: TimeZone): Tim
return {
from,
to,
section: 3,
display: `${from} to ${to}`,
};
};

View File

@ -1,37 +1,37 @@
import { TimeOption } from '@grafana/data';
export const quickOptions: TimeOption[] = [
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3 },
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3 },
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3 },
{ from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3 },
{ from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3 },
{ from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3 },
{ from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3 },
{ from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3 },
{ from: 'now-2d', to: 'now', display: 'Last 2 days', section: 3 },
{ from: 'now-7d', to: 'now', display: 'Last 7 days', section: 3 },
{ from: 'now-30d', to: 'now', display: 'Last 30 days', section: 3 },
{ from: 'now-90d', to: 'now', display: 'Last 90 days', section: 3 },
{ from: 'now-6M', to: 'now', display: 'Last 6 months', section: 3 },
{ from: 'now-1y', to: 'now', display: 'Last 1 year', section: 3 },
{ from: 'now-2y', to: 'now', display: 'Last 2 years', section: 3 },
{ from: 'now-5y', to: 'now', display: 'Last 5 years', section: 3 },
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes' },
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes' },
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes' },
{ from: 'now-1h', to: 'now', display: 'Last 1 hour' },
{ from: 'now-3h', to: 'now', display: 'Last 3 hours' },
{ from: 'now-6h', to: 'now', display: 'Last 6 hours' },
{ from: 'now-12h', to: 'now', display: 'Last 12 hours' },
{ from: 'now-24h', to: 'now', display: 'Last 24 hours' },
{ from: 'now-2d', to: 'now', display: 'Last 2 days' },
{ from: 'now-7d', to: 'now', display: 'Last 7 days' },
{ from: 'now-30d', to: 'now', display: 'Last 30 days' },
{ from: 'now-90d', to: 'now', display: 'Last 90 days' },
{ from: 'now-6M', to: 'now', display: 'Last 6 months' },
{ from: 'now-1y', to: 'now', display: 'Last 1 year' },
{ from: 'now-2y', to: 'now', display: 'Last 2 years' },
{ from: 'now-5y', to: 'now', display: 'Last 5 years' },
];
export const otherOptions: TimeOption[] = [
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday', section: 3 },
{ from: 'now-2d/d', to: 'now-2d/d', display: 'Day before yesterday', section: 3 },
{ from: 'now-7d/d', to: 'now-7d/d', display: 'This day last week', section: 3 },
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week', section: 3 },
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month', section: 3 },
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year', section: 3 },
{ from: 'now/d', to: 'now/d', display: 'Today', section: 3 },
{ from: 'now/d', to: 'now', display: 'Today so far', section: 3 },
{ from: 'now/w', to: 'now/w', display: 'This week', section: 3 },
{ from: 'now/w', to: 'now', display: 'This week so far', section: 3 },
{ from: 'now/M', to: 'now/M', display: 'This month', section: 3 },
{ from: 'now/M', to: 'now', display: 'This month so far', section: 3 },
{ from: 'now/y', to: 'now/y', display: 'This year', section: 3 },
{ from: 'now/y', to: 'now', display: 'This year so far', section: 3 },
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday' },
{ from: 'now-2d/d', to: 'now-2d/d', display: 'Day before yesterday' },
{ from: 'now-7d/d', to: 'now-7d/d', display: 'This day last week' },
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week' },
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month' },
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year' },
{ from: 'now/d', to: 'now/d', display: 'Today' },
{ from: 'now/d', to: 'now', display: 'Today so far' },
{ from: 'now/w', to: 'now/w', display: 'This week' },
{ from: 'now/w', to: 'now', display: 'This week so far' },
{ from: 'now/M', to: 'now/M', display: 'This month' },
{ from: 'now/M', to: 'now', display: 'This month so far' },
{ from: 'now/y', to: 'now/y', display: 'This year' },
{ from: 'now/y', to: 'now', display: 'This year so far' },
];

View File

@ -190,6 +190,7 @@ export { Checkbox } from './Forms/Checkbox';
export { TextArea } from './TextArea/TextArea';
export { FileUpload } from './FileUpload/FileUpload';
export { TimeRangeInput } from './TimePicker/TimeRangeInput';
export { RelativeTimeRangePicker } from './TimePicker/RelativeTimeRangePicker/RelativeTimeRangePicker';
export { Card, Props as CardProps, getCardStyles } from './Card/Card';
export { CardContainer, CardContainerProps } from './Card/CardContainer';
export { FormattedValueDisplay } from './FormattedValueDisplay/FormattedValueDisplay';

View File

@ -1,15 +1,6 @@
import { rangeUtil, dateTime } from '@grafana/data';
import { keys } from 'lodash';
describe('rangeUtil', () => {
describe('Can get range grouped list of ranges', () => {
it('when custom settings should return default range list', () => {
const groups: any = rangeUtil.getRelativeTimesList({ time_options: [] }, 'Last 5 minutes');
expect(keys(groups).length).toBe(4);
expect(groups[3][0].active).toBe(true);
});
});
describe('Can get range text described', () => {
it('should handle simple old expression with only amount and unit', () => {
const info = rangeUtil.describeTextRange('5m');

View File

@ -1,10 +1,17 @@
import React, { PureComponent } from 'react';
import React, { PureComponent, ReactNode } from 'react';
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
import { DataQuery, DataSourceInstanceSettings, rangeUtil, PanelData, TimeRange } from '@grafana/data';
import {
DataQuery,
DataSourceInstanceSettings,
getDefaultRelativeTimeRange,
PanelData,
RelativeTimeRange,
} from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { GrafanaQuery } from 'app/types/unified-alerting-dto';
import { RelativeTimeRangePicker } from '@grafana/ui';
interface Props {
// The query configuration
@ -30,7 +37,7 @@ export class AlertingQueryRows extends PureComponent<Props, State> {
this.props.onQueriesChange(this.props.queries.filter((item) => item.model !== query));
};
onChangeTimeRange(timeRange: TimeRange, index: number) {
onChangeTimeRange(timeRange: RelativeTimeRange, index: number) {
const { queries, onQueriesChange } = this.props;
onQueriesChange(
queries.map((item, itemIndex) => {
@ -39,7 +46,7 @@ export class AlertingQueryRows extends PureComponent<Props, State> {
}
return {
...item,
relativeTimeRange: rangeUtil.timeRangeToRelative(timeRange),
relativeTimeRange: timeRange,
};
})
);
@ -84,6 +91,7 @@ export class AlertingQueryRows extends PureComponent<Props, State> {
}
return {
...item,
refId: query.refId,
model: {
...item.model,
...query,
@ -155,16 +163,7 @@ export class AlertingQueryRows extends PureComponent<Props, State> {
data={data}
query={query.model}
onChange={(query) => this.onChangeQuery(query, index)}
timeRange={
!isExpressionQuery(query.model) && query.relativeTimeRange
? rangeUtil.relativeToTimeRange(query.relativeTimeRange)
: undefined
}
onChangeTimeRange={
!isExpressionQuery(query.model)
? (timeRange) => this.onChangeTimeRange(timeRange, index)
: undefined
}
renderHeaderExtras={() => this.renderTimePicker(query, index)}
onRemoveQuery={this.onRemoveQuery}
onAddQuery={(duplicate) => this.onDuplicateQuery(duplicate, query)}
onRunQuery={this.props.onRunQueries}
@ -180,4 +179,17 @@ export class AlertingQueryRows extends PureComponent<Props, State> {
</DragDropContext>
);
}
renderTimePicker(query: GrafanaQuery, index: number): ReactNode {
if (isExpressionQuery(query.model)) {
return null;
}
return (
<RelativeTimeRangePicker
timeRange={query.relativeTimeRange ?? getDefaultRelativeTimeRange()}
onChange={(range) => this.onChangeTimeRange(range, index)}
/>
);
}
}

View File

@ -1,5 +1,5 @@
// Libraries
import React, { PureComponent } from 'react';
import React, { PureComponent, ReactNode } from 'react';
import classNames from 'classnames';
import { has, cloneDeep } from 'lodash';
// Utils & Services
@ -35,10 +35,9 @@ interface Props {
queries: DataQuery[];
id: string;
index: number;
timeRange?: TimeRange;
dataSource: DataSourceInstanceSettings;
onChangeDataSource?: (dsSettings: DataSourceInstanceSettings) => void;
onChangeTimeRange?: (timeRange: TimeRange) => void;
renderHeaderExtras?: () => ReactNode;
onAddQuery: (query: DataQuery) => void;
onRemoveQuery: (query: DataQuery) => void;
onChange: (query: DataQuery) => void;
@ -304,20 +303,19 @@ export class QueryEditorRow extends PureComponent<Props, State> {
};
renderHeader = (props: QueryOperationRowRenderProps) => {
const { query, dataSource, onChangeDataSource, onChange, queries, onChangeTimeRange, timeRange } = this.props;
const { query, dataSource, onChangeDataSource, onChange, queries, renderHeaderExtras } = this.props;
return (
<QueryEditorRowHeader
query={query}
queries={queries}
onChangeTimeRange={onChangeTimeRange}
timeRange={timeRange}
onChangeDataSource={onChangeDataSource}
dataSource={dataSource}
disabled={query.hide}
onClick={(e) => this.onToggleEditMode(e, props)}
onChange={onChange}
collapsedText={!props.isOpen ? this.renderCollapsedText() : null}
renderExtras={renderHeaderExtras}
/>
);
};

View File

@ -1,7 +1,7 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { Props, QueryEditorRowHeader } from './QueryEditorRowHeader';
import { DataSourceInstanceSettings, dateTime } from '@grafana/data';
import { DataSourceInstanceSettings } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => {
@ -58,41 +58,6 @@ describe('QueryEditorRowHeader', () => {
expect(screen.queryByLabelText(selectors.components.DataSourcePicker.container)).toBeNull();
});
it('should show time range picker when callback and value is passed', async () => {
renderScenario({
onChangeTimeRange: () => {},
timeRange: {
from: dateTime(),
to: dateTime(),
raw: { from: 'now', to: 'now' },
},
});
expect(screen.queryByLabelText(selectors.components.TimePicker.openButton)).not.toBeNull();
});
it('should not show time range picker when no value is passed', async () => {
renderScenario({
onChangeTimeRange: () => {},
timeRange: undefined,
});
expect(screen.queryByLabelText(selectors.components.DataSourcePicker.container)).toBeNull();
});
it('should not show time range picker when no callback is passed', async () => {
renderScenario({
onChangeTimeRange: undefined,
timeRange: {
from: dateTime(),
to: dateTime(),
raw: { from: 'now', to: 'now' },
},
});
expect(screen.queryByLabelText(selectors.components.DataSourcePicker.container)).toBeNull();
});
});
function renderScenario(overrides: Partial<Props>) {

View File

@ -1,26 +1,24 @@
import React, { useState } from 'react';
import React, { ReactNode, useState } from 'react';
import { css, cx } from '@emotion/css';
import { DataQuery, DataSourceInstanceSettings, GrafanaTheme, TimeRange } from '@grafana/data';
import { DataQuery, DataSourceInstanceSettings, GrafanaTheme } from '@grafana/data';
import { DataSourcePicker } from '@grafana/runtime';
import { Icon, Input, FieldValidationMessage, TimeRangeInput, useStyles } from '@grafana/ui';
import { Icon, Input, FieldValidationMessage, useStyles } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { ExpressionDatasourceUID } from '../../expressions/ExpressionDatasource';
export interface Props {
query: DataQuery;
queries: DataQuery[];
disabled?: boolean;
timeRange?: TimeRange;
dataSource: DataSourceInstanceSettings;
renderExtras?: () => ReactNode;
onChangeDataSource?: (settings: DataSourceInstanceSettings) => void;
onChangeTimeRange?: (timeRange: TimeRange) => void;
onChange: (query: DataQuery) => void;
onClick: (e: React.MouseEvent) => void;
collapsedText: string | null;
}
export const QueryEditorRowHeader: React.FC<Props> = (props) => {
const { dataSource, onChangeDataSource, disabled, query, queries, onClick, onChange, collapsedText } = props;
const { query, queries, onClick, onChange, collapsedText, renderExtras, disabled } = props;
const styles = useStyles(getStyles);
const [isEditing, setIsEditing] = useState<boolean>(false);
@ -112,8 +110,8 @@ export const QueryEditorRowHeader: React.FC<Props> = (props) => {
{validationError && <FieldValidationMessage horizontal>{validationError}</FieldValidationMessage>}
</>
)}
<PickerRenderer {...props} />
{dataSource && !onChangeDataSource && <em className={styles.contextInfo}> ({dataSource.name})</em>}
{renderDataSource(props, styles)}
{renderExtras && <div className={styles.itemWrapper}>{renderExtras()}</div>}
{disabled && <em className={styles.contextInfo}>Disabled</em>}
{collapsedText && (
@ -125,22 +123,16 @@ export const QueryEditorRowHeader: React.FC<Props> = (props) => {
);
};
const PickerRenderer: React.FC<Props> = (props) => {
const { onChangeTimeRange, timeRange, onChangeDataSource, dataSource } = props;
const styles = useStyles(getStyles);
const renderDataSource = (props: Props, styles: ReturnType<typeof getStyles>): ReactNode => {
const { dataSource, onChangeDataSource } = props;
if (!onChangeTimeRange && !onChangeDataSource) {
return null;
}
if (dataSource.uid === ExpressionDatasourceUID) {
return null;
if (!onChangeDataSource) {
return <em className={styles.contextInfo}>({dataSource.name})</em>;
}
return (
<div className={styles.pickerWrapper}>
{onChangeDataSource && <DataSourcePicker current={dataSource.name} onChange={onChangeDataSource} />}
{onChangeTimeRange && timeRange && <TimeRangeInput onChange={onChangeTimeRange} value={timeRange} />}
<div className={styles.itemWrapper}>
<DataSourcePicker current={dataSource.name} onChange={onChangeDataSource} />
</div>
);
};
@ -222,9 +214,9 @@ const getStyles = (theme: GrafanaTheme) => {
color: ${theme.colors.textWeak};
padding-left: 10px;
`,
pickerWrapper: css`
itemWrapper: css`
display: flex;
margin-left: 8px;
margin-left: 4px;
`,
};
};