mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
2459a0ceb5
commit
07ef4060a3
@ -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 * as dateMath from './datemath';
|
||||||
import { isDateTime, DateTime, dateTime } from './moment_wrapper';
|
import { isDateTime, DateTime, dateTime } from './moment_wrapper';
|
||||||
@ -17,69 +17,67 @@ const spans: { [key: string]: { display: string; section?: number } } = {
|
|||||||
y: { display: 'year' },
|
y: { display: 'year' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const rangeOptions = [
|
const rangeOptions: TimeOption[] = [
|
||||||
{ from: 'now/d', to: 'now/d', display: 'Today', section: 2 },
|
{ from: 'now/d', to: 'now/d', display: 'Today' },
|
||||||
{ from: 'now/d', to: 'now', display: 'Today so far', section: 2 },
|
{ from: 'now/d', to: 'now', display: 'Today so far' },
|
||||||
{ from: 'now/w', to: 'now/w', display: 'This week', section: 2 },
|
{ from: 'now/w', to: 'now/w', display: 'This week' },
|
||||||
{ from: 'now/w', to: 'now', display: 'This week so far', section: 2 },
|
{ from: 'now/w', to: 'now', display: 'This week so far' },
|
||||||
{ from: 'now/M', to: 'now/M', display: 'This month', section: 2 },
|
{ from: 'now/M', to: 'now/M', display: 'This month' },
|
||||||
{ from: 'now/M', to: 'now', display: 'This month so far', section: 2 },
|
{ from: 'now/M', to: 'now', display: 'This month so far' },
|
||||||
{ from: 'now/y', to: 'now/y', display: 'This year', section: 2 },
|
{ from: 'now/y', to: 'now/y', display: 'This year' },
|
||||||
{ from: 'now/y', to: 'now', display: 'This year so far', section: 2 },
|
{ 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',
|
from: 'now-2d/d',
|
||||||
to: 'now-2d/d',
|
to: 'now-2d/d',
|
||||||
display: 'Day before yesterday',
|
display: 'Day before yesterday',
|
||||||
section: 1,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
from: 'now-7d/d',
|
from: 'now-7d/d',
|
||||||
to: 'now-7d/d',
|
to: 'now-7d/d',
|
||||||
display: 'This day last week',
|
display: 'This day last week',
|
||||||
section: 1,
|
|
||||||
},
|
},
|
||||||
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week', section: 1 },
|
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week' },
|
||||||
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month', section: 1 },
|
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month' },
|
||||||
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year', section: 1 },
|
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year' },
|
||||||
|
|
||||||
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3 },
|
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes' },
|
||||||
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3 },
|
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes' },
|
||||||
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3 },
|
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes' },
|
||||||
{ from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3 },
|
{ from: 'now-1h', to: 'now', display: 'Last 1 hour' },
|
||||||
{ from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3 },
|
{ from: 'now-3h', to: 'now', display: 'Last 3 hours' },
|
||||||
{ from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3 },
|
{ from: 'now-6h', to: 'now', display: 'Last 6 hours' },
|
||||||
{ from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3 },
|
{ from: 'now-12h', to: 'now', display: 'Last 12 hours' },
|
||||||
{ from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3 },
|
{ from: 'now-24h', to: 'now', display: 'Last 24 hours' },
|
||||||
{ from: 'now-2d', to: 'now', display: 'Last 2 days', section: 0 },
|
{ from: 'now-2d', to: 'now', display: 'Last 2 days' },
|
||||||
{ from: 'now-7d', to: 'now', display: 'Last 7 days', section: 0 },
|
{ from: 'now-7d', to: 'now', display: 'Last 7 days' },
|
||||||
{ from: 'now-30d', to: 'now', display: 'Last 30 days', section: 0 },
|
{ from: 'now-30d', to: 'now', display: 'Last 30 days' },
|
||||||
{ from: 'now-90d', to: 'now', display: 'Last 90 days', section: 0 },
|
{ from: 'now-90d', to: 'now', display: 'Last 90 days' },
|
||||||
{ from: 'now-6M', to: 'now', display: 'Last 6 months', section: 0 },
|
{ from: 'now-6M', to: 'now', display: 'Last 6 months' },
|
||||||
{ from: 'now-1y', to: 'now', display: 'Last 1 year', section: 0 },
|
{ from: 'now-1y', to: 'now', display: 'Last 1 year' },
|
||||||
{ from: 'now-2y', to: 'now', display: 'Last 2 years', section: 0 },
|
{ from: 'now-2y', to: 'now', display: 'Last 2 years' },
|
||||||
{ from: 'now-5y', to: 'now', display: 'Last 5 years', section: 0 },
|
{ from: 'now-5y', to: 'now', display: 'Last 5 years' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const hiddenRangeOptions = [
|
const hiddenRangeOptions: TimeOption[] = [
|
||||||
{ from: 'now', to: 'now+1m', display: 'Next minute', section: 3 },
|
{ from: 'now', to: 'now+1m', display: 'Next minute' },
|
||||||
{ from: 'now', to: 'now+5m', display: 'Next 5 minutes', section: 3 },
|
{ from: 'now', to: 'now+5m', display: 'Next 5 minutes' },
|
||||||
{ from: 'now', to: 'now+15m', display: 'Next 15 minutes', section: 3 },
|
{ from: 'now', to: 'now+15m', display: 'Next 15 minutes' },
|
||||||
{ from: 'now', to: 'now+30m', display: 'Next 30 minutes', section: 3 },
|
{ from: 'now', to: 'now+30m', display: 'Next 30 minutes' },
|
||||||
{ from: 'now', to: 'now+1h', display: 'Next hour', section: 3 },
|
{ from: 'now', to: 'now+1h', display: 'Next hour' },
|
||||||
{ from: 'now', to: 'now+3h', display: 'Next 3 hours', section: 3 },
|
{ from: 'now', to: 'now+3h', display: 'Next 3 hours' },
|
||||||
{ from: 'now', to: 'now+6h', display: 'Next 6 hours', section: 3 },
|
{ from: 'now', to: 'now+6h', display: 'Next 6 hours' },
|
||||||
{ from: 'now', to: 'now+12h', display: 'Next 12 hours', section: 3 },
|
{ from: 'now', to: 'now+12h', display: 'Next 12 hours' },
|
||||||
{ from: 'now', to: 'now+24h', display: 'Next 24 hours', section: 3 },
|
{ from: 'now', to: 'now+24h', display: 'Next 24 hours' },
|
||||||
{ from: 'now', to: 'now+2d', display: 'Next 2 days', section: 0 },
|
{ from: 'now', to: 'now+2d', display: 'Next 2 days' },
|
||||||
{ from: 'now', to: 'now+7d', display: 'Next 7 days', section: 0 },
|
{ from: 'now', to: 'now+7d', display: 'Next 7 days' },
|
||||||
{ from: 'now', to: 'now+30d', display: 'Next 30 days', section: 0 },
|
{ from: 'now', to: 'now+30d', display: 'Next 30 days' },
|
||||||
{ from: 'now', to: 'now+90d', display: 'Next 90 days', section: 0 },
|
{ from: 'now', to: 'now+90d', display: 'Next 90 days' },
|
||||||
{ from: 'now', to: 'now+6M', display: 'Next 6 months', section: 0 },
|
{ from: 'now', to: 'now+6M', display: 'Next 6 months' },
|
||||||
{ from: 'now', to: 'now+1y', display: 'Next year', section: 0 },
|
{ from: 'now', to: 'now+1y', display: 'Next year' },
|
||||||
{ from: 'now', to: 'now+2y', display: 'Next 2 years', section: 0 },
|
{ from: 'now', to: 'now+2y', display: 'Next 2 years' },
|
||||||
{ from: 'now', to: 'now+5y', display: 'Next 5 years', section: 0 },
|
{ from: 'now', to: 'now+5y', display: 'Next 5 years' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const rangeIndex: any = {};
|
const rangeIndex: any = {};
|
||||||
@ -90,22 +88,6 @@ each(hiddenRangeOptions, (frame: any) => {
|
|||||||
rangeIndex[frame.from + ' to ' + frame.to] = frame;
|
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
|
// handles expressions like
|
||||||
// 5m
|
// 5m
|
||||||
// 5m to now/d
|
// 5m to now/d
|
||||||
|
@ -40,7 +40,6 @@ export interface TimeOption {
|
|||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
display: string;
|
display: string;
|
||||||
section: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimeOptions {
|
export interface TimeOptions {
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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
|
||||||
|
});
|
||||||
|
});
|
@ -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)};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
};
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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`;
|
||||||
|
};
|
@ -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 { css, cx } from '@emotion/css';
|
||||||
import React, { memo, useState } from 'react';
|
import React, { memo, useMemo, useState } from 'react';
|
||||||
import { useMedia } from 'react-use';
|
import { useMedia } from 'react-use';
|
||||||
import { stylesFactory, useTheme2 } from '../../../themes';
|
import { stylesFactory, useTheme2 } from '../../../themes';
|
||||||
import { CustomScrollbar } from '../../CustomScrollbar/CustomScrollbar';
|
import { CustomScrollbar } from '../../CustomScrollbar/CustomScrollbar';
|
||||||
import { Icon } from '../../Icon/Icon';
|
import { Icon } from '../../Icon/Icon';
|
||||||
import { mapRangeToTimeOption } from './mapper';
|
import { mapOptionToTimeRange, mapRangeToTimeOption } from './mapper';
|
||||||
import { TimePickerTitle } from './TimePickerTitle';
|
import { TimePickerTitle } from './TimePickerTitle';
|
||||||
import { TimeRangeForm } from './TimeRangeForm';
|
import { TimeRangeForm } from './TimeRangeForm';
|
||||||
import { TimeRangeList } from './TimeRangeList';
|
import { TimeRangeList } from './TimeRangeList';
|
||||||
@ -156,6 +156,11 @@ export const TimePickerContentWithScreenSize: React.FC<PropsWithScreenSize> = (p
|
|||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
const styles = getStyles(theme, isReversed, hideQuickRanges, isContainerTall);
|
const styles = getStyles(theme, isReversed, hideQuickRanges, isContainerTall);
|
||||||
const historyOptions = mapToHistoryOptions(history, timeZone);
|
const historyOptions = mapToHistoryOptions(history, timeZone);
|
||||||
|
const timeOption = useTimeOption(value.raw, otherOptions, quickOptions);
|
||||||
|
|
||||||
|
const onChangeTimeOption = (timeOption: TimeOption) => {
|
||||||
|
return onChange(mapOptionToTimeRange(timeOption));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cx(styles.container, className)}>
|
<div className={cx(styles.container, className)}>
|
||||||
@ -173,17 +178,15 @@ export const TimePickerContentWithScreenSize: React.FC<PropsWithScreenSize> = (p
|
|||||||
<TimeRangeList
|
<TimeRangeList
|
||||||
title="Relative time ranges"
|
title="Relative time ranges"
|
||||||
options={quickOptions}
|
options={quickOptions}
|
||||||
onSelect={onChange}
|
onChange={onChangeTimeOption}
|
||||||
value={value}
|
value={timeOption}
|
||||||
timeZone={timeZone}
|
|
||||||
/>
|
/>
|
||||||
<div className={styles.spacing} />
|
<div className={styles.spacing} />
|
||||||
<TimeRangeList
|
<TimeRangeList
|
||||||
title="Other quick ranges"
|
title="Other quick ranges"
|
||||||
options={otherOptions}
|
options={otherOptions}
|
||||||
onSelect={onChange}
|
onChange={onChangeTimeOption}
|
||||||
value={value}
|
value={timeOption}
|
||||||
timeZone={timeZone}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -210,6 +213,10 @@ const NarrowScreenForm: React.FC<FormProps> = (props) => {
|
|||||||
const [collapsedFlag, setCollapsedFlag] = useState(!isAbsolute);
|
const [collapsedFlag, setCollapsedFlag] = useState(!isAbsolute);
|
||||||
const collapsed = hideQuickRanges ? false : collapsedFlag;
|
const collapsed = hideQuickRanges ? false : collapsedFlag;
|
||||||
|
|
||||||
|
const onChangeTimeOption = (timeOption: TimeOption) => {
|
||||||
|
return onChange(mapOptionToTimeRange(timeOption));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@ -233,10 +240,8 @@ const NarrowScreenForm: React.FC<FormProps> = (props) => {
|
|||||||
<TimeRangeList
|
<TimeRangeList
|
||||||
title="Recently used absolute ranges"
|
title="Recently used absolute ranges"
|
||||||
options={historyOptions}
|
options={historyOptions}
|
||||||
onSelect={onChange}
|
onChange={onChangeTimeOption}
|
||||||
value={value}
|
|
||||||
placeholderEmpty={null}
|
placeholderEmpty={null}
|
||||||
timeZone={timeZone}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -246,8 +251,12 @@ const NarrowScreenForm: React.FC<FormProps> = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FullScreenForm: React.FC<FormProps> = (props) => {
|
const FullScreenForm: React.FC<FormProps> = (props) => {
|
||||||
|
const { onChange } = props;
|
||||||
const theme = useTheme2();
|
const theme = useTheme2();
|
||||||
const styles = getFullScreenStyles(theme, props.hideQuickRanges);
|
const styles = getFullScreenStyles(theme, props.hideQuickRanges);
|
||||||
|
const onChangeTimeOption = (timeOption: TimeOption) => {
|
||||||
|
return onChange(mapOptionToTimeRange(timeOption));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -268,10 +277,8 @@ const FullScreenForm: React.FC<FormProps> = (props) => {
|
|||||||
<TimeRangeList
|
<TimeRangeList
|
||||||
title="Recently used absolute ranges"
|
title="Recently used absolute ranges"
|
||||||
options={props.historyOptions || []}
|
options={props.historyOptions || []}
|
||||||
onSelect={props.onChange}
|
onChange={onChangeTimeOption}
|
||||||
value={props.value}
|
|
||||||
placeholderEmpty={<EmptyRecentList />}
|
placeholderEmpty={<EmptyRecentList />}
|
||||||
timeZone={props.timeZone}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -313,3 +320,24 @@ function mapToHistoryOptions(ranges?: TimeRange[], timeZone?: TimeZone): TimeOpt
|
|||||||
}
|
}
|
||||||
|
|
||||||
EmptyRecentList.displayName = 'EmptyRecentList';
|
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]);
|
||||||
|
};
|
||||||
|
@ -24,7 +24,7 @@ interface Props {
|
|||||||
isReversed?: boolean;
|
isReversed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InputState {
|
export interface InputState {
|
||||||
value: string;
|
value: string;
|
||||||
invalid: boolean;
|
invalid: boolean;
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { TimeRange, TimeOption, TimeZone } from '@grafana/data';
|
import { TimeOption } from '@grafana/data';
|
||||||
import { TimePickerTitle } from './TimePickerTitle';
|
import { TimePickerTitle } from './TimePickerTitle';
|
||||||
import { TimeRangeOption } from './TimeRangeOption';
|
import { TimeRangeOption } from './TimeRangeOption';
|
||||||
import { mapOptionToTimeRange } from './mapper';
|
|
||||||
import { stylesFactory } from '../../../themes';
|
import { stylesFactory } from '../../../themes';
|
||||||
|
|
||||||
const getStyles = stylesFactory(() => {
|
const getStyles = stylesFactory(() => {
|
||||||
@ -29,10 +28,9 @@ const getOptionsStyles = stylesFactory(() => {
|
|||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
options: TimeOption[];
|
options: TimeOption[];
|
||||||
value?: TimeRange;
|
value?: TimeOption;
|
||||||
onSelect: (option: TimeRange) => void;
|
onChange: (option: TimeOption) => void;
|
||||||
placeholderEmpty?: ReactNode;
|
placeholderEmpty?: ReactNode;
|
||||||
timeZone?: TimeZone;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TimeRangeList: React.FC<Props> = (props) => {
|
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();
|
const styles = getOptionsStyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -68,11 +66,11 @@ const Options: React.FC<Props> = ({ options, value, onSelect, timeZone }) => {
|
|||||||
key={keyForOption(option, index)}
|
key={keyForOption(option, index)}
|
||||||
value={option}
|
value={option}
|
||||||
selected={isEqual(option, value)}
|
selected={isEqual(option, value)}
|
||||||
onSelect={(option) => onSelect(mapOptionToTimeRange(option, timeZone))}
|
onSelect={onChange}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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}`;
|
return `${option.from}-${option.to}-${index}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEqual(x: TimeOption, y?: TimeRange): boolean {
|
function isEqual(x: TimeOption, y?: TimeOption): boolean {
|
||||||
if (!y || !x) {
|
if (!y || !x) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return y.raw.from === x.from && y.raw.to === x.to;
|
return y.from === x.from && y.to === x.to;
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,6 @@ export const mapRangeToTimeOption = (range: TimeRange, timeZone?: TimeZone): Tim
|
|||||||
return {
|
return {
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
section: 3,
|
|
||||||
display: `${from} to ${to}`,
|
display: `${from} to ${to}`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,37 +1,37 @@
|
|||||||
import { TimeOption } from '@grafana/data';
|
import { TimeOption } from '@grafana/data';
|
||||||
|
|
||||||
export const quickOptions: TimeOption[] = [
|
export const quickOptions: TimeOption[] = [
|
||||||
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3 },
|
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes' },
|
||||||
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3 },
|
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes' },
|
||||||
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3 },
|
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes' },
|
||||||
{ from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3 },
|
{ from: 'now-1h', to: 'now', display: 'Last 1 hour' },
|
||||||
{ from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3 },
|
{ from: 'now-3h', to: 'now', display: 'Last 3 hours' },
|
||||||
{ from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3 },
|
{ from: 'now-6h', to: 'now', display: 'Last 6 hours' },
|
||||||
{ from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3 },
|
{ from: 'now-12h', to: 'now', display: 'Last 12 hours' },
|
||||||
{ from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3 },
|
{ from: 'now-24h', to: 'now', display: 'Last 24 hours' },
|
||||||
{ from: 'now-2d', to: 'now', display: 'Last 2 days', section: 3 },
|
{ from: 'now-2d', to: 'now', display: 'Last 2 days' },
|
||||||
{ from: 'now-7d', to: 'now', display: 'Last 7 days', section: 3 },
|
{ from: 'now-7d', to: 'now', display: 'Last 7 days' },
|
||||||
{ from: 'now-30d', to: 'now', display: 'Last 30 days', section: 3 },
|
{ from: 'now-30d', to: 'now', display: 'Last 30 days' },
|
||||||
{ from: 'now-90d', to: 'now', display: 'Last 90 days', section: 3 },
|
{ from: 'now-90d', to: 'now', display: 'Last 90 days' },
|
||||||
{ from: 'now-6M', to: 'now', display: 'Last 6 months', section: 3 },
|
{ from: 'now-6M', to: 'now', display: 'Last 6 months' },
|
||||||
{ from: 'now-1y', to: 'now', display: 'Last 1 year', section: 3 },
|
{ from: 'now-1y', to: 'now', display: 'Last 1 year' },
|
||||||
{ from: 'now-2y', to: 'now', display: 'Last 2 years', section: 3 },
|
{ from: 'now-2y', to: 'now', display: 'Last 2 years' },
|
||||||
{ from: 'now-5y', to: 'now', display: 'Last 5 years', section: 3 },
|
{ from: 'now-5y', to: 'now', display: 'Last 5 years' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const otherOptions: TimeOption[] = [
|
export const otherOptions: TimeOption[] = [
|
||||||
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday', section: 3 },
|
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday' },
|
||||||
{ from: 'now-2d/d', to: 'now-2d/d', display: 'Day before yesterday', section: 3 },
|
{ 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', section: 3 },
|
{ from: 'now-7d/d', to: 'now-7d/d', display: 'This day last week' },
|
||||||
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week', section: 3 },
|
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week' },
|
||||||
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month', section: 3 },
|
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month' },
|
||||||
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year', section: 3 },
|
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year' },
|
||||||
{ from: 'now/d', to: 'now/d', display: 'Today', section: 3 },
|
{ from: 'now/d', to: 'now/d', display: 'Today' },
|
||||||
{ from: 'now/d', to: 'now', display: 'Today so far', section: 3 },
|
{ from: 'now/d', to: 'now', display: 'Today so far' },
|
||||||
{ from: 'now/w', to: 'now/w', display: 'This week', section: 3 },
|
{ from: 'now/w', to: 'now/w', display: 'This week' },
|
||||||
{ from: 'now/w', to: 'now', display: 'This week so far', section: 3 },
|
{ from: 'now/w', to: 'now', display: 'This week so far' },
|
||||||
{ from: 'now/M', to: 'now/M', display: 'This month', section: 3 },
|
{ from: 'now/M', to: 'now/M', display: 'This month' },
|
||||||
{ from: 'now/M', to: 'now', display: 'This month so far', section: 3 },
|
{ from: 'now/M', to: 'now', display: 'This month so far' },
|
||||||
{ from: 'now/y', to: 'now/y', display: 'This year', section: 3 },
|
{ from: 'now/y', to: 'now/y', display: 'This year' },
|
||||||
{ from: 'now/y', to: 'now', display: 'This year so far', section: 3 },
|
{ from: 'now/y', to: 'now', display: 'This year so far' },
|
||||||
];
|
];
|
||||||
|
@ -190,6 +190,7 @@ export { Checkbox } from './Forms/Checkbox';
|
|||||||
export { TextArea } from './TextArea/TextArea';
|
export { TextArea } from './TextArea/TextArea';
|
||||||
export { FileUpload } from './FileUpload/FileUpload';
|
export { FileUpload } from './FileUpload/FileUpload';
|
||||||
export { TimeRangeInput } from './TimePicker/TimeRangeInput';
|
export { TimeRangeInput } from './TimePicker/TimeRangeInput';
|
||||||
|
export { RelativeTimeRangePicker } from './TimePicker/RelativeTimeRangePicker/RelativeTimeRangePicker';
|
||||||
export { Card, Props as CardProps, getCardStyles } from './Card/Card';
|
export { Card, Props as CardProps, getCardStyles } from './Card/Card';
|
||||||
export { CardContainer, CardContainerProps } from './Card/CardContainer';
|
export { CardContainer, CardContainerProps } from './Card/CardContainer';
|
||||||
export { FormattedValueDisplay } from './FormattedValueDisplay/FormattedValueDisplay';
|
export { FormattedValueDisplay } from './FormattedValueDisplay/FormattedValueDisplay';
|
||||||
|
@ -1,15 +1,6 @@
|
|||||||
import { rangeUtil, dateTime } from '@grafana/data';
|
import { rangeUtil, dateTime } from '@grafana/data';
|
||||||
import { keys } from 'lodash';
|
|
||||||
|
|
||||||
describe('rangeUtil', () => {
|
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', () => {
|
describe('Can get range text described', () => {
|
||||||
it('should handle simple old expression with only amount and unit', () => {
|
it('should handle simple old expression with only amount and unit', () => {
|
||||||
const info = rangeUtil.describeTextRange('5m');
|
const info = rangeUtil.describeTextRange('5m');
|
||||||
|
@ -1,10 +1,17 @@
|
|||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent, ReactNode } from 'react';
|
||||||
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
|
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 { getDataSourceSrv } from '@grafana/runtime';
|
||||||
import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow';
|
import { QueryEditorRow } from 'app/features/query/components/QueryEditorRow';
|
||||||
import { isExpressionQuery } from 'app/features/expressions/guards';
|
import { isExpressionQuery } from 'app/features/expressions/guards';
|
||||||
import { GrafanaQuery } from 'app/types/unified-alerting-dto';
|
import { GrafanaQuery } from 'app/types/unified-alerting-dto';
|
||||||
|
import { RelativeTimeRangePicker } from '@grafana/ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
// The query configuration
|
// 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));
|
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;
|
const { queries, onQueriesChange } = this.props;
|
||||||
onQueriesChange(
|
onQueriesChange(
|
||||||
queries.map((item, itemIndex) => {
|
queries.map((item, itemIndex) => {
|
||||||
@ -39,7 +46,7 @@ export class AlertingQueryRows extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
relativeTimeRange: rangeUtil.timeRangeToRelative(timeRange),
|
relativeTimeRange: timeRange,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -84,6 +91,7 @@ export class AlertingQueryRows extends PureComponent<Props, State> {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
|
refId: query.refId,
|
||||||
model: {
|
model: {
|
||||||
...item.model,
|
...item.model,
|
||||||
...query,
|
...query,
|
||||||
@ -155,16 +163,7 @@ export class AlertingQueryRows extends PureComponent<Props, State> {
|
|||||||
data={data}
|
data={data}
|
||||||
query={query.model}
|
query={query.model}
|
||||||
onChange={(query) => this.onChangeQuery(query, index)}
|
onChange={(query) => this.onChangeQuery(query, index)}
|
||||||
timeRange={
|
renderHeaderExtras={() => this.renderTimePicker(query, index)}
|
||||||
!isExpressionQuery(query.model) && query.relativeTimeRange
|
|
||||||
? rangeUtil.relativeToTimeRange(query.relativeTimeRange)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onChangeTimeRange={
|
|
||||||
!isExpressionQuery(query.model)
|
|
||||||
? (timeRange) => this.onChangeTimeRange(timeRange, index)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onRemoveQuery={this.onRemoveQuery}
|
onRemoveQuery={this.onRemoveQuery}
|
||||||
onAddQuery={(duplicate) => this.onDuplicateQuery(duplicate, query)}
|
onAddQuery={(duplicate) => this.onDuplicateQuery(duplicate, query)}
|
||||||
onRunQuery={this.props.onRunQueries}
|
onRunQuery={this.props.onRunQueries}
|
||||||
@ -180,4 +179,17 @@ export class AlertingQueryRows extends PureComponent<Props, State> {
|
|||||||
</DragDropContext>
|
</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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
// Libraries
|
// Libraries
|
||||||
import React, { PureComponent } from 'react';
|
import React, { PureComponent, ReactNode } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { has, cloneDeep } from 'lodash';
|
import { has, cloneDeep } from 'lodash';
|
||||||
// Utils & Services
|
// Utils & Services
|
||||||
@ -35,10 +35,9 @@ interface Props {
|
|||||||
queries: DataQuery[];
|
queries: DataQuery[];
|
||||||
id: string;
|
id: string;
|
||||||
index: number;
|
index: number;
|
||||||
timeRange?: TimeRange;
|
|
||||||
dataSource: DataSourceInstanceSettings;
|
dataSource: DataSourceInstanceSettings;
|
||||||
onChangeDataSource?: (dsSettings: DataSourceInstanceSettings) => void;
|
onChangeDataSource?: (dsSettings: DataSourceInstanceSettings) => void;
|
||||||
onChangeTimeRange?: (timeRange: TimeRange) => void;
|
renderHeaderExtras?: () => ReactNode;
|
||||||
onAddQuery: (query: DataQuery) => void;
|
onAddQuery: (query: DataQuery) => void;
|
||||||
onRemoveQuery: (query: DataQuery) => void;
|
onRemoveQuery: (query: DataQuery) => void;
|
||||||
onChange: (query: DataQuery) => void;
|
onChange: (query: DataQuery) => void;
|
||||||
@ -304,20 +303,19 @@ export class QueryEditorRow extends PureComponent<Props, State> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderHeader = (props: QueryOperationRowRenderProps) => {
|
renderHeader = (props: QueryOperationRowRenderProps) => {
|
||||||
const { query, dataSource, onChangeDataSource, onChange, queries, onChangeTimeRange, timeRange } = this.props;
|
const { query, dataSource, onChangeDataSource, onChange, queries, renderHeaderExtras } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryEditorRowHeader
|
<QueryEditorRowHeader
|
||||||
query={query}
|
query={query}
|
||||||
queries={queries}
|
queries={queries}
|
||||||
onChangeTimeRange={onChangeTimeRange}
|
|
||||||
timeRange={timeRange}
|
|
||||||
onChangeDataSource={onChangeDataSource}
|
onChangeDataSource={onChangeDataSource}
|
||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
disabled={query.hide}
|
disabled={query.hide}
|
||||||
onClick={(e) => this.onToggleEditMode(e, props)}
|
onClick={(e) => this.onToggleEditMode(e, props)}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
collapsedText={!props.isOpen ? this.renderCollapsedText() : null}
|
collapsedText={!props.isOpen ? this.renderCollapsedText() : null}
|
||||||
|
renderExtras={renderHeaderExtras}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import { Props, QueryEditorRowHeader } from './QueryEditorRowHeader';
|
import { Props, QueryEditorRowHeader } from './QueryEditorRowHeader';
|
||||||
import { DataSourceInstanceSettings, dateTime } from '@grafana/data';
|
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
|
||||||
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => {
|
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => {
|
||||||
@ -58,41 +58,6 @@ describe('QueryEditorRowHeader', () => {
|
|||||||
|
|
||||||
expect(screen.queryByLabelText(selectors.components.DataSourcePicker.container)).toBeNull();
|
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>) {
|
function renderScenario(overrides: Partial<Props>) {
|
||||||
|
@ -1,26 +1,24 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { ReactNode, useState } from 'react';
|
||||||
import { css, cx } from '@emotion/css';
|
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 { 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 { selectors } from '@grafana/e2e-selectors';
|
||||||
import { ExpressionDatasourceUID } from '../../expressions/ExpressionDatasource';
|
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
query: DataQuery;
|
query: DataQuery;
|
||||||
queries: DataQuery[];
|
queries: DataQuery[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
timeRange?: TimeRange;
|
|
||||||
dataSource: DataSourceInstanceSettings;
|
dataSource: DataSourceInstanceSettings;
|
||||||
|
renderExtras?: () => ReactNode;
|
||||||
onChangeDataSource?: (settings: DataSourceInstanceSettings) => void;
|
onChangeDataSource?: (settings: DataSourceInstanceSettings) => void;
|
||||||
onChangeTimeRange?: (timeRange: TimeRange) => void;
|
|
||||||
onChange: (query: DataQuery) => void;
|
onChange: (query: DataQuery) => void;
|
||||||
onClick: (e: React.MouseEvent) => void;
|
onClick: (e: React.MouseEvent) => void;
|
||||||
collapsedText: string | null;
|
collapsedText: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QueryEditorRowHeader: React.FC<Props> = (props) => {
|
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 styles = useStyles(getStyles);
|
||||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||||
@ -112,9 +110,9 @@ export const QueryEditorRowHeader: React.FC<Props> = (props) => {
|
|||||||
{validationError && <FieldValidationMessage horizontal>{validationError}</FieldValidationMessage>}
|
{validationError && <FieldValidationMessage horizontal>{validationError}</FieldValidationMessage>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<PickerRenderer {...props} />
|
{renderDataSource(props, styles)}
|
||||||
{dataSource && !onChangeDataSource && <em className={styles.contextInfo}> ({dataSource.name})</em>}
|
{renderExtras && <div className={styles.itemWrapper}>{renderExtras()}</div>}
|
||||||
{disabled && <em className={styles.contextInfo}> Disabled</em>}
|
{disabled && <em className={styles.contextInfo}>Disabled</em>}
|
||||||
|
|
||||||
{collapsedText && (
|
{collapsedText && (
|
||||||
<div className={styles.collapsedText} onClick={onClick}>
|
<div className={styles.collapsedText} onClick={onClick}>
|
||||||
@ -125,22 +123,16 @@ export const QueryEditorRowHeader: React.FC<Props> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PickerRenderer: React.FC<Props> = (props) => {
|
const renderDataSource = (props: Props, styles: ReturnType<typeof getStyles>): ReactNode => {
|
||||||
const { onChangeTimeRange, timeRange, onChangeDataSource, dataSource } = props;
|
const { dataSource, onChangeDataSource } = props;
|
||||||
const styles = useStyles(getStyles);
|
|
||||||
|
|
||||||
if (!onChangeTimeRange && !onChangeDataSource) {
|
if (!onChangeDataSource) {
|
||||||
return null;
|
return <em className={styles.contextInfo}>({dataSource.name})</em>;
|
||||||
}
|
|
||||||
|
|
||||||
if (dataSource.uid === ExpressionDatasourceUID) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.pickerWrapper}>
|
<div className={styles.itemWrapper}>
|
||||||
{onChangeDataSource && <DataSourcePicker current={dataSource.name} onChange={onChangeDataSource} />}
|
<DataSourcePicker current={dataSource.name} onChange={onChangeDataSource} />
|
||||||
{onChangeTimeRange && timeRange && <TimeRangeInput onChange={onChangeTimeRange} value={timeRange} />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -222,9 +214,9 @@ const getStyles = (theme: GrafanaTheme) => {
|
|||||||
color: ${theme.colors.textWeak};
|
color: ${theme.colors.textWeak};
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
`,
|
`,
|
||||||
pickerWrapper: css`
|
itemWrapper: css`
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-left: 8px;
|
margin-left: 4px;
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user