From 07ef4060a34f557c8f0bee935e732c0963b57fbf Mon Sep 17 00:00:00 2001 From: Marcus Andersson Date: Wed, 12 May 2021 17:51:31 +0200 Subject: [PATCH] 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 --- .../grafana-data/src/datetime/rangeutil.ts | 116 +++++------ packages/grafana-data/src/types/time.ts | 1 - .../RelativeTimeRangePicker.story.tsx | 37 ++++ .../RelativeTimeRangePicker.test.tsx | 47 +++++ .../RelativeTimeRangePicker.tsx | 191 ++++++++++++++++++ .../RelativeTimeRangePicker/utils.test.ts | 108 ++++++++++ .../RelativeTimeRangePicker/utils.ts | 83 ++++++++ .../TimeRangePicker/TimePickerContent.tsx | 58 ++++-- .../TimeRangePicker/TimeRangeForm.tsx | 2 +- .../TimeRangePicker/TimeRangeList.tsx | 18 +- .../TimePicker/TimeRangePicker/mapper.ts | 1 - .../src/components/TimePicker/rangeOptions.ts | 60 +++--- packages/grafana-ui/src/components/index.ts | 1 + public/app/core/specs/rangeutil.test.ts | 9 - .../alerting/components/AlertingQueryRows.tsx | 40 ++-- .../query/components/QueryEditorRow.tsx | 10 +- .../components/QueryEditorRowHeader.test.tsx | 37 +--- .../query/components/QueryEditorRowHeader.tsx | 40 ++-- 18 files changed, 645 insertions(+), 214 deletions(-) create mode 100644 packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/RelativeTimeRangePicker.story.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/RelativeTimeRangePicker.test.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/RelativeTimeRangePicker.tsx create mode 100644 packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/utils.test.ts create mode 100644 packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/utils.ts diff --git a/packages/grafana-data/src/datetime/rangeutil.ts b/packages/grafana-data/src/datetime/rangeutil.ts index 2279ee3074e..254ae80e6dd 100644 --- a/packages/grafana-data/src/datetime/rangeutil.ts +++ b/packages/grafana-data/src/datetime/rangeutil.ts @@ -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 diff --git a/packages/grafana-data/src/types/time.ts b/packages/grafana-data/src/types/time.ts index d85ffc4a1e7..6d75574d092 100644 --- a/packages/grafana-data/src/types/time.ts +++ b/packages/grafana-data/src/types/time.ts @@ -40,7 +40,6 @@ export interface TimeOption { from: string; to: string; display: string; - section: number; } export interface TimeOptions { diff --git a/packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/RelativeTimeRangePicker.story.tsx b/packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/RelativeTimeRangePicker.story.tsx new file mode 100644 index 00000000000..dadccfb4865 --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/RelativeTimeRangePicker.story.tsx @@ -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 ( + + {(value, updateValue) => { + return ( + { + action('on selected')(newValue); + updateValue(newValue); + }} + timeRange={value} + /> + ); + }} + + ); +}; diff --git a/packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/RelativeTimeRangePicker.test.tsx b/packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/RelativeTimeRangePicker.test.tsx new file mode 100644 index 00000000000..5f29941476e --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/RelativeTimeRangePicker.test.tsx @@ -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(initial); + return ; + }; + + return render(); +} + +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 + }); +}); diff --git a/packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/RelativeTimeRangePicker.tsx b/packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/RelativeTimeRangePicker.tsx new file mode 100644 index 00000000000..5eff96bbb79 --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/RelativeTimeRangePicker.tsx @@ -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({ value: timeOption.from, invalid: !isRangeValid(timeOption.from) }); + const [to, setTo] = useState({ 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) => { + event.stopPropagation(); + event.preventDefault(); + setIsOpen(!isOpen); + }, + [isOpen] + ); + + const onApply = (event: FormEvent) => { + 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 ( + + + + + {timeOption.from} to {timeOption.to} + + + + {isOpen && ( + +
+
+ + + +
+
+ Specify time range +
+ Specify a relative time range, for more information see{' '} + + docs + + . +
+
+ + event.stopPropagation()} + onBlur={() => setFrom({ ...from, invalid: !isRangeValid(from.value) })} + onChange={(event) => setFrom({ ...from, value: event.currentTarget.value })} + value={from.value} + /> + + + event.stopPropagation()} + onBlur={() => setTo({ ...to, invalid: !isRangeValid(to.value) })} + onChange={(event) => setTo({ ...to, value: event.currentTarget.value })} + value={to.value} + /> + + +
+
+
+
+ )} +
+ ); +} + +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)}; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/utils.test.ts b/packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/utils.test.ts new file mode 100644 index 00000000000..0e68333d145 --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/utils.test.ts @@ -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); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/utils.ts b/packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/utils.ts new file mode 100644 index 00000000000..73cd02acc9b --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/RelativeTimeRangePicker/utils.ts @@ -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 = { + 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`; +}; diff --git a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerContent.tsx b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerContent.tsx index a9f900f0955..d3025309a6c 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerContent.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimePickerContent.tsx @@ -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 = (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 (
@@ -173,17 +178,15 @@ export const TimePickerContentWithScreenSize: React.FC = (p
)} @@ -210,6 +213,10 @@ const NarrowScreenForm: React.FC = (props) => { const [collapsedFlag, setCollapsedFlag] = useState(!isAbsolute); const collapsed = hideQuickRanges ? false : collapsedFlag; + const onChangeTimeOption = (timeOption: TimeOption) => { + return onChange(mapOptionToTimeRange(timeOption)); + }; + return ( <>
= (props) => { )}
@@ -246,8 +251,12 @@ const NarrowScreenForm: React.FC = (props) => { }; const FullScreenForm: React.FC = (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 = (props) => { } - timeZone={props.timeZone} />
)} @@ -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]); +}; diff --git a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeForm.tsx b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeForm.tsx index ac99a9b4926..917bf22d662 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeForm.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeForm.tsx @@ -24,7 +24,7 @@ interface Props { isReversed?: boolean; } -interface InputState { +export interface InputState { value: string; invalid: boolean; } diff --git a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeList.tsx b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeList.tsx index e4b832335ef..a918e3bc5b8 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeList.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/TimeRangeList.tsx @@ -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) => { @@ -57,7 +55,7 @@ export const TimeRangeList: React.FC = (props) => { ); }; -const Options: React.FC = ({ options, value, onSelect, timeZone }) => { +const Options: React.FC = ({ options, value, onChange }) => { const styles = getOptionsStyles(); return ( @@ -68,11 +66,11 @@ const Options: React.FC = ({ options, value, onSelect, timeZone }) => { key={keyForOption(option, index)} value={option} selected={isEqual(option, value)} - onSelect={(option) => onSelect(mapOptionToTimeRange(option, timeZone))} + onSelect={onChange} /> ))}
-
+
); }; @@ -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; } diff --git a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/mapper.ts b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/mapper.ts index 46567262600..1778ea89bfc 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/mapper.ts +++ b/packages/grafana-ui/src/components/TimePicker/TimeRangePicker/mapper.ts @@ -11,7 +11,6 @@ export const mapRangeToTimeOption = (range: TimeRange, timeZone?: TimeZone): Tim return { from, to, - section: 3, display: `${from} to ${to}`, }; }; diff --git a/packages/grafana-ui/src/components/TimePicker/rangeOptions.ts b/packages/grafana-ui/src/components/TimePicker/rangeOptions.ts index aea2b9ac03d..1c48ea551ef 100644 --- a/packages/grafana-ui/src/components/TimePicker/rangeOptions.ts +++ b/packages/grafana-ui/src/components/TimePicker/rangeOptions.ts @@ -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' }, ]; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index b7c45a0817e..2a7cf210424 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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'; diff --git a/public/app/core/specs/rangeutil.test.ts b/public/app/core/specs/rangeutil.test.ts index 60f461a3aad..2f64c3c4ed6 100644 --- a/public/app/core/specs/rangeutil.test.ts +++ b/public/app/core/specs/rangeutil.test.ts @@ -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'); diff --git a/public/app/features/alerting/components/AlertingQueryRows.tsx b/public/app/features/alerting/components/AlertingQueryRows.tsx index afb5e4a2718..249faa8abde 100644 --- a/public/app/features/alerting/components/AlertingQueryRows.tsx +++ b/public/app/features/alerting/components/AlertingQueryRows.tsx @@ -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 { 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 { } return { ...item, - relativeTimeRange: rangeUtil.timeRangeToRelative(timeRange), + relativeTimeRange: timeRange, }; }) ); @@ -84,6 +91,7 @@ export class AlertingQueryRows extends PureComponent { } return { ...item, + refId: query.refId, model: { ...item.model, ...query, @@ -155,16 +163,7 @@ export class AlertingQueryRows extends PureComponent { 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 { ); } + + renderTimePicker(query: GrafanaQuery, index: number): ReactNode { + if (isExpressionQuery(query.model)) { + return null; + } + + return ( + this.onChangeTimeRange(range, index)} + /> + ); + } } diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index 7ad508a2b04..33c4d328e5d 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -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 { }; renderHeader = (props: QueryOperationRowRenderProps) => { - const { query, dataSource, onChangeDataSource, onChange, queries, onChangeTimeRange, timeRange } = this.props; + const { query, dataSource, onChangeDataSource, onChange, queries, renderHeaderExtras } = this.props; return ( this.onToggleEditMode(e, props)} onChange={onChange} collapsedText={!props.isOpen ? this.renderCollapsedText() : null} + renderExtras={renderHeaderExtras} /> ); }; diff --git a/public/app/features/query/components/QueryEditorRowHeader.test.tsx b/public/app/features/query/components/QueryEditorRowHeader.test.tsx index 6aeea8060a0..808691fd181 100644 --- a/public/app/features/query/components/QueryEditorRowHeader.test.tsx +++ b/public/app/features/query/components/QueryEditorRowHeader.test.tsx @@ -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) { diff --git a/public/app/features/query/components/QueryEditorRowHeader.tsx b/public/app/features/query/components/QueryEditorRowHeader.tsx index 23ebf2d6fad..2368f339daf 100644 --- a/public/app/features/query/components/QueryEditorRowHeader.tsx +++ b/public/app/features/query/components/QueryEditorRowHeader.tsx @@ -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) => { - 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(false); @@ -112,9 +110,9 @@ export const QueryEditorRowHeader: React.FC = (props) => { {validationError && {validationError}} )} - - {dataSource && !onChangeDataSource && ({dataSource.name})} - {disabled && Disabled} + {renderDataSource(props, styles)} + {renderExtras &&
{renderExtras()}
} + {disabled && Disabled} {collapsedText && (
@@ -125,22 +123,16 @@ export const QueryEditorRowHeader: React.FC = (props) => { ); }; -const PickerRenderer: React.FC = (props) => { - const { onChangeTimeRange, timeRange, onChangeDataSource, dataSource } = props; - const styles = useStyles(getStyles); +const renderDataSource = (props: Props, styles: ReturnType): ReactNode => { + const { dataSource, onChangeDataSource } = props; - if (!onChangeTimeRange && !onChangeDataSource) { - return null; - } - - if (dataSource.uid === ExpressionDatasourceUID) { - return null; + if (!onChangeDataSource) { + return ({dataSource.name}); } return ( -
- {onChangeDataSource && } - {onChangeTimeRange && timeRange && } +
+
); }; @@ -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; `, }; };