diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 5dd006832a6..609c1df2517 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -28,6 +28,7 @@ "moment": "^2.22.2", "papaparse": "^4.6.3", "react": "^16.8.4", + "react-calendar": "^2.18.1", "react-color": "^2.17.0", "react-custom-scrollbars": "^4.2.1", "react-dom": "^16.8.4", diff --git a/public/app/core/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx similarity index 94% rename from public/app/core/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx rename to packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx index 63191862ebb..c435be0f384 100644 --- a/public/app/core/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx +++ b/packages/grafana-ui/src/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx @@ -22,7 +22,7 @@ export class ClickOutsideWrapper extends PureComponent { window.removeEventListener('click', this.onOutsideClick, false); } - onOutsideClick = event => { + onOutsideClick = (event: any) => { const domNode = ReactDOM.findDOMNode(this) as Element; if (!domNode || !domNode.contains(event.target)) { diff --git a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.story.tsx b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.story.tsx new file mode 100644 index 00000000000..5400e87694f --- /dev/null +++ b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.story.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { UseState } from '../../utils/storybook/UseState'; +import { RefreshPicker } from './RefreshPicker'; + +const RefreshSelectStories = storiesOf('UI/RefreshPicker', module); + +RefreshSelectStories.addDecorator(withCenteredStory); + +RefreshSelectStories.add('default', () => { + return ( + + {(value, updateValue) => { + return ( + { + action('onIntervalChanged fired')(interval); + }} + onRefresh={() => { + action('onRefresh fired')(); + }} + /> + ); + }} + + ); +}); diff --git a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx new file mode 100644 index 00000000000..666971af363 --- /dev/null +++ b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx @@ -0,0 +1,80 @@ +import React, { PureComponent } from 'react'; +import classNames from 'classnames'; + +import { SelectOptionItem, ButtonSelect, Tooltip } from '@grafana/ui'; + +export const offOption = { label: 'Off', value: '' }; +export const defaultIntervals = ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d']; + +export interface Props { + intervals?: string[]; + onRefresh: () => any; + onIntervalChanged: (interval: string) => void; + value?: string; + tooltip: string; +} + +export class RefreshPicker extends PureComponent { + static defaultProps = { + intervals: defaultIntervals, + }; + + constructor(props: Props) { + super(props); + } + + hasNoIntervals = () => { + const { intervals } = this.props; + // Current implementaion returns an array with length of 1 consisting of + // an empty string when auto-refresh is empty in dashboard settings + if (!intervals || intervals.length < 1 || (intervals.length === 1 && intervals[0] === '')) { + return true; + } + return false; + }; + + intervalsToOptions = (intervals: string[] = defaultIntervals): SelectOptionItem[] => { + const options = intervals.map(interval => ({ label: interval, value: interval })); + options.unshift(offOption); + return options; + }; + + onChangeSelect = (item: SelectOptionItem) => { + const { onIntervalChanged } = this.props; + if (onIntervalChanged) { + onIntervalChanged(item.value); + } + }; + + render() { + const { onRefresh, intervals, tooltip, value } = this.props; + const options = this.intervalsToOptions(this.hasNoIntervals() ? defaultIntervals : intervals); + const currentValue = value || ''; + const selectedValue = options.find(item => item.value === currentValue) || offOption; + + const cssClasses = classNames({ + 'refresh-picker': true, + 'refresh-picker--refreshing': selectedValue.label !== offOption.label, + }); + + return ( +
+
+ + + + +
+
+ ); + } +} diff --git a/packages/grafana-ui/src/components/RefreshPicker/_RefreshPicker.scss b/packages/grafana-ui/src/components/RefreshPicker/_RefreshPicker.scss new file mode 100644 index 00000000000..96367cc8611 --- /dev/null +++ b/packages/grafana-ui/src/components/RefreshPicker/_RefreshPicker.scss @@ -0,0 +1,28 @@ +.refresh-picker { + position: relative; + display: none; + + .refresh-picker-buttons { + display: flex; + } + + .gf-form-input--form-dropdown { + position: static; + } + + .gf-form-select-box__menu { + position: absolute; + left: 0; + width: 100%; + } + + &--refreshing { + .select-button-value { + color: $orange; + } + } + + @include media-breakpoint-up(md) { + display: block; + } +} diff --git a/packages/grafana-ui/src/components/Select/ButtonSelect.story.tsx b/packages/grafana-ui/src/components/Select/ButtonSelect.story.tsx new file mode 100644 index 00000000000..0dc967eadb1 --- /dev/null +++ b/packages/grafana-ui/src/components/Select/ButtonSelect.story.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { withKnobs, object, text } from '@storybook/addon-knobs'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { UseState } from '../../utils/storybook/UseState'; +import { SelectOptionItem } from './Select'; +import { ButtonSelect } from './ButtonSelect'; + +const ButtonSelectStories = storiesOf('UI/Select/ButtonSelect', module); + +ButtonSelectStories.addDecorator(withCenteredStory).addDecorator(withKnobs); + +ButtonSelectStories.add('default', () => { + const intialState: SelectOptionItem = { label: 'A label', value: 'A value' }; + const value = object('Selected Value:', intialState); + const options = object('Options:', [ + intialState, + { label: 'Another label', value: 'Another value' }, + ]); + + return ( + + {(value, updateValue) => { + return ( + { + action('onChanged fired')(value); + updateValue(value); + }} + label={value.label ? value.label : ''} + className="refresh-select" + iconClass={text('iconClass', 'fa fa-clock-o fa-fw')} + /> + ); + }} + + ); +}); diff --git a/packages/grafana-ui/src/components/Select/ButtonSelect.tsx b/packages/grafana-ui/src/components/Select/ButtonSelect.tsx new file mode 100644 index 00000000000..a6393428d73 --- /dev/null +++ b/packages/grafana-ui/src/components/Select/ButtonSelect.tsx @@ -0,0 +1,88 @@ +import React, { PureComponent } from 'react'; +import Select, { SelectOptionItem } from './Select'; +import { PopperContent } from '@grafana/ui/src/components/Tooltip/PopperController'; + +interface ButtonComponentProps { + label: string | undefined; + className: string | undefined; + iconClass?: string; +} + +const ButtonComponent = (buttonProps: ButtonComponentProps) => (props: any) => { + const { label, className, iconClass } = buttonProps; + + return ( + + ); +}; + +export interface Props { + className: string | undefined; + options: SelectOptionItem[]; + value: SelectOptionItem; + label?: string; + iconClass?: string; + components?: any; + maxMenuHeight?: number; + onChange: (item: SelectOptionItem) => void; + tooltipContent?: PopperContent; + isMenuOpen?: boolean; + onOpenMenu?: () => void; + onCloseMenu?: () => void; +} + +export class ButtonSelect extends PureComponent { + onChange = (item: SelectOptionItem) => { + const { onChange } = this.props; + onChange(item); + }; + + render() { + const { + className, + options, + value, + label, + iconClass, + components, + maxMenuHeight, + tooltipContent, + isMenuOpen, + onOpenMenu, + onCloseMenu, + } = this.props; + const combinedComponents = { + ...components, + Control: ButtonComponent({ label, className, iconClass }), + }; + return ( + + ); + } +} diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerOptionGroup.story.tsx b/packages/grafana-ui/src/components/TimePicker/TimePickerOptionGroup.story.tsx new file mode 100644 index 00000000000..e5d104d9921 --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/TimePickerOptionGroup.story.tsx @@ -0,0 +1,51 @@ +import React, { ComponentType } from 'react'; +import { storiesOf } from '@storybook/react'; +import moment from 'moment'; +import { action } from '@storybook/addon-actions'; + +import { TimePickerOptionGroup } from './TimePickerOptionGroup'; +import { TimeRange } from '../../types/time'; +import { withRighAlignedStory } from '../../utils/storybook/withRightAlignedStory'; +import { popoverOptions } from './TimePicker.story'; + +const TimePickerOptionGroupStories = storiesOf('UI/TimePicker/TimePickerOptionGroup', module); + +TimePickerOptionGroupStories.addDecorator(withRighAlignedStory); + +const data = { + isPopoverOpen: false, + onPopoverOpen: () => { + action('onPopoverOpen fired')(); + }, + onPopoverClose: (timeRange: TimeRange) => { + action('onPopoverClose fired')(timeRange); + }, + popoverProps: { + value: { from: moment(), to: moment(), raw: { from: 'now/d', to: 'now/d' } }, + options: popoverOptions, + isTimezoneUtc: false, + onChange: (timeRange: TimeRange) => { + action('onChange fired')(timeRange); + }, + }, +}; + +TimePickerOptionGroupStories.add('default', () => ( + {}} + className={''} + cx={() => {}} + getStyles={(name, props) => ({})} + getValue={() => {}} + hasValue + isMulti={false} + options={[]} + selectOption={() => {}} + selectProps={''} + setValue={(value, action) => {}} + label={'Custom'} + children={null} + Heading={(null as any) as ComponentType} + data={data} + /> +)); diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerOptionGroup.tsx b/packages/grafana-ui/src/components/TimePicker/TimePickerOptionGroup.tsx new file mode 100644 index 00000000000..a7ff0c2587b --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/TimePickerOptionGroup.tsx @@ -0,0 +1,66 @@ +import React, { PureComponent, createRef } from 'react'; +import { GroupProps } from 'react-select/lib/components/Group'; +import { Popper } from '@grafana/ui/src/components/Tooltip/Popper'; +import { Props as TimePickerProps, TimePickerPopover } from './TimePickerPopover'; +import { TimeRange } from '@grafana/ui'; + +export interface DataProps { + onPopoverOpen: () => void; + onPopoverClose: (timeRange: TimeRange) => void; + popoverProps: TimePickerProps; +} + +interface Props extends GroupProps { + data: DataProps; +} + +interface State { + isPopoverOpen: boolean; +} + +export class TimePickerOptionGroup extends PureComponent { + pickerTriggerRef = createRef(); + state: State = { isPopoverOpen: false }; + + onClick = () => { + this.setState({ isPopoverOpen: true }); + this.props.data.onPopoverOpen(); + }; + + render() { + const { children, label } = this.props; + const { isPopoverOpen } = this.state; + const { onPopoverClose } = this.props.data; + const popover = TimePickerPopover; + const popoverElement = React.createElement(popover, { + ...this.props.data.popoverProps, + onChange: (timeRange: TimeRange) => { + onPopoverClose(timeRange); + this.setState({ isPopoverOpen: false }); + }, + }); + + return ( + <> +
+
+ {label} + +
+ {children} +
+
+ {this.pickerTriggerRef.current && ( + + )} +
+ + ); + } +} diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerPopover.story.tsx b/packages/grafana-ui/src/components/TimePicker/TimePickerPopover.story.tsx new file mode 100644 index 00000000000..38f5e48bbe0 --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/TimePickerPopover.story.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import moment, { Moment } from 'moment'; + +import { storiesOf } from '@storybook/react'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { TimePickerPopover } from './TimePickerPopover'; +import { UseState } from '../../utils/storybook/UseState'; +import { popoverOptions } from './TimePicker.story'; + +const TimePickerPopoverStories = storiesOf('UI/TimePicker/TimePickerPopover', module); + +TimePickerPopoverStories.addDecorator(withCenteredStory); + +TimePickerPopoverStories.add('default', () => ( + + {(value, updateValue) => { + return ( + { + action('onChange fired')(timeRange); + updateValue(timeRange); + }} + options={popoverOptions} + /> + ); + }} + +)); diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerPopover.tsx b/packages/grafana-ui/src/components/TimePicker/TimePickerPopover.tsx new file mode 100644 index 00000000000..8966374d849 --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/TimePickerPopover.tsx @@ -0,0 +1,169 @@ +import React, { Component, SyntheticEvent } from 'react'; +import { TimeRange, TimeOptions, TimeOption } from '@grafana/ui'; +import { Moment } from 'moment'; + +import { TimePickerCalendar } from './TimePickerCalendar'; +import { TimePickerInput } from './TimePickerInput'; +import { mapTimeOptionToTimeRange } from './time'; +import { Timezone } from '../../../../../public/app/core/utils/datemath'; + +export interface Props { + value: TimeRange; + options: TimeOptions; + isTimezoneUtc: boolean; + timezone?: Timezone; + onChange?: (timeRange: TimeRange) => void; +} + +export interface State { + value: TimeRange; + isFromInputValid: boolean; + isToInputValid: boolean; +} + +export class TimePickerPopover extends Component { + static popoverClassName = 'time-picker-popover'; + constructor(props: Props) { + super(props); + this.state = { value: props.value, isFromInputValid: true, isToInputValid: true }; + } + + onFromInputChanged = (value: string, valid: boolean) => { + this.setState({ + value: { ...this.state.value, raw: { ...this.state.value.raw, from: value } }, + isFromInputValid: valid, + }); + }; + + onToInputChanged = (value: string, valid: boolean) => { + this.setState({ + value: { ...this.state.value, raw: { ...this.state.value.raw, to: value } }, + isToInputValid: valid, + }); + }; + + onFromCalendarChanged = (value: Moment) => { + this.setState({ + value: { ...this.state.value, raw: { ...this.state.value.raw, from: value } }, + }); + }; + + onToCalendarChanged = (value: Moment) => { + this.setState({ + value: { ...this.state.value, raw: { ...this.state.value.raw, to: value } }, + }); + }; + + onTimeOptionClick = (timeOption: TimeOption) => { + const { isTimezoneUtc, timezone, onChange } = this.props; + + if (onChange) { + onChange(mapTimeOptionToTimeRange(timeOption, isTimezoneUtc, timezone)); + } + }; + + onApplyClick = () => { + const { onChange } = this.props; + if (onChange) { + onChange(this.state.value); + } + }; + + render() { + const { options, isTimezoneUtc, timezone } = this.props; + const { isFromInputValid, isToInputValid, value } = this.state; + const isValid = isFromInputValid && isToInputValid; + + return ( +
+
+
+ Quick ranges +
+
+ {Object.keys(options).map(key => { + return ( + + ); + })} +
+
+
+
+ Custom range +
+
+
+
+ From: + +
+
+ +
+
+
+
+ To: + +
+
+ +
+
+
+
+ +
+
+
+ ); + } +} diff --git a/packages/grafana-ui/src/components/TimePicker/_TimePicker.scss b/packages/grafana-ui/src/components/TimePicker/_TimePicker.scss new file mode 100644 index 00000000000..ffbbd009d7c --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/_TimePicker.scss @@ -0,0 +1,189 @@ +.time-picker { + display: flex; + flex-flow: column nowrap; + + .time-picker-buttons { + display: flex; + } +} +.time-picker-popover-popper { + z-index: $zindex-timepicker-popover; +} + +.time-picker-popover { + display: flex; + flex-flow: row nowrap; + justify-content: space-around; + border: 1px solid $popover-border-color; + border-radius: $border-radius; + background-color: $popover-border-color; + color: $popover-color; + + .time-picker-popover-box { + max-width: 500px; + padding: 20px; + + ul { + padding-right: $spacer; + padding-top: $spacer; + list-style-type: none; + + li { + line-height: 22px; + display: list-item; + text-align: left; + } + + li.active { + border-bottom: 1px solid $blue; + font-weight: $font-weight-semi-bold; + } + } + + .time-picker-popover-box-body { + display: flex; + flex-flow: row nowrap; + justify-content: space-around; + } + } + + .time-picker-popover-box-title { + font-size: $font-size-lg; + font-weight: $font-weight-semi-bold; + } + + .time-picker-popover-box:first-child { + border-right: 1px ridge; + } + + .time-picker-popover-box-body-custom-ranges:first-child { + margin-right: $spacer; + } + + .time-picker-popover-box-body-custom-ranges-input { + display: flex; + flex-flow: row nowrap; + align-items: center; + margin: $spacer 0; + + .our-custom-wrapper-class { + margin-left: $spacer; + width: 100%; + + .time-picker-input-error { + box-shadow: inset 0 0px 5px $red; + } + } + } + + .time-picker-popover-box-footer { + display: flex; + flex-flow: row nowrap; + justify-content: flex-end; + margin-top: $spacer; + } +} + +.time-picker-calendar { + border: 1px solid $popover-border-color; + max-width: 220px; + color: $black; + + .react-calendar__navigation__label, + .react-calendar__navigation__arrow, + .react-calendar__navigation { + color: $input-color; + background-color: $input-bg; + border: 0; + } + + .react-calendar__month-view__weekdays { + background-color: $popover-border-color; + text-align: center; + + abbr { + border: 0; + text-decoration: none; + cursor: default; + color: $popover-color; + font-weight: $font-weight-semi-bold; + } + } + + .time-picker-calendar-tile { + color: $input-color; + background-color: $input-bg; + border: 0; + line-height: 22px; + } + + button.time-picker-calendar-tile:hover { + font-weight: $font-weight-semi-bold; + } + + .react-calendar__navigation__label, + .react-calendar__navigation > button:focus, + .time-picker-calendar-tile:focus { + outline: 0; + } + + .react-calendar__tile--now { + color: $orange; + } + + .react-calendar__tile--active { + color: $blue; + font-weight: $font-weight-semi-bold; + } +} + +@media only screen and (max-width: 1116px) { + .time-picker-popover { + margin-left: $spacer; + display: flex; + flex-flow: column nowrap; + + .time-picker-popover-box { + padding: $spacer / 2 $spacer; + + .time-picker-popover-box-title { + font-size: $font-size-md; + font-weight: $font-weight-semi-bold; + } + } + + .time-picker-popover-box:first-child { + border-right: none; + border-bottom: 1px ridge; + } + + .time-picker-popover-box:last-child { + .time-picker-popover-box-body { + display: flex; + flex-flow: column nowrap; + + .time-picker-popover-box-body-custom-ranges:first-child { + margin: 0; + } + } + } + + .time-picker-popover-box-footer { + display: flex; + flex-flow: row nowrap; + justify-content: flex-end; + margin-top: $spacer; + } + } + + .time-picker-calendar { + max-width: 500px; + width: 100%; + } +} + +@media only screen and (max-width: 746px) { + .time-picker-popover { + margin-top: 48px; + } +} diff --git a/packages/grafana-ui/src/components/TimePicker/time.ts b/packages/grafana-ui/src/components/TimePicker/time.ts new file mode 100644 index 00000000000..b9bf318ed06 --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/time.ts @@ -0,0 +1,44 @@ +import moment, { Moment } from 'moment'; +import { TimeOption, TimeRange, TIME_FORMAT } from '@grafana/ui'; + +import * as dateMath from '../../../../../public/app/core/utils/datemath'; +import { describeTimeRange } from '../../../../../public/app/core/utils/rangeutil'; + +export const mapTimeOptionToTimeRange = ( + timeOption: TimeOption, + isTimezoneUtc: boolean, + timezone?: dateMath.Timezone +): TimeRange => { + const fromMoment = stringToMoment(timeOption.from, isTimezoneUtc, false, timezone); + const toMoment = stringToMoment(timeOption.to, isTimezoneUtc, true, timezone); + + return { from: fromMoment, to: toMoment, raw: { from: timeOption.from, to: timeOption.to } }; +}; + +export const stringToMoment = ( + value: string, + isTimezoneUtc: boolean, + roundUp?: boolean, + timezone?: dateMath.Timezone +): Moment => { + if (value.indexOf('now') !== -1) { + if (!dateMath.isValid(value)) { + return moment(); + } + + const parsed = dateMath.parse(value, roundUp, timezone); + return parsed || moment(); + } + + if (isTimezoneUtc) { + return moment.utc(value, TIME_FORMAT); + } + + return moment(value, TIME_FORMAT); +}; + +export const mapTimeRangeToRangeString = (timeRange: TimeRange): string => { + return describeTimeRange(timeRange.raw); +}; + +export const isValidTimeString = (text: string) => dateMath.isValid(text); diff --git a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx index 81b33e44156..2b2a047639a 100644 --- a/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Tooltip.tsx @@ -13,11 +13,18 @@ export const Tooltip = ({ children, theme, ...controllerProps }: TooltipProps) = return ( {(showPopper, hidePopper, popperProps) => { + { + /* Override internal 'show' state if passed in as prop */ + } + const payloadProps = { + ...popperProps, + show: controllerProps.show !== undefined ? controllerProps.show : popperProps.show, + }; return ( <> {tooltipTriggerRef.current && ( { + return Object.keys(obj).reduce((acc: any, key) => { + if (obj[key] !== undefined) { + acc[key] = obj[key]; + } + return acc; + }, {}); +}; diff --git a/packages/grafana-ui/src/utils/storybook/withRightAlignedStory.tsx b/packages/grafana-ui/src/utils/storybook/withRightAlignedStory.tsx new file mode 100644 index 00000000000..85c024d0249 --- /dev/null +++ b/packages/grafana-ui/src/utils/storybook/withRightAlignedStory.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { RenderFunction } from '@storybook/react'; + +const RightAlignedStory: React.FunctionComponent<{}> = ({ children }) => { + return ( +
+ {children} +
+ ); +}; + +export const withRighAlignedStory = (story: RenderFunction) => {story()}; diff --git a/packages/grafana-ui/src/utils/string.test.ts b/packages/grafana-ui/src/utils/string.test.ts index a6b35461d78..b1a28599860 100644 --- a/packages/grafana-ui/src/utils/string.test.ts +++ b/packages/grafana-ui/src/utils/string.test.ts @@ -1,4 +1,4 @@ -import { stringToJsRegex } from '@grafana/ui'; +import { stringToJsRegex, stringToMs } from '@grafana/ui'; describe('stringToJsRegex', () => { it('should parse the valid regex value', () => { @@ -13,3 +13,41 @@ describe('stringToJsRegex', () => { }).toThrow(); }); }); + +describe('stringToMs', () => { + it('should return zero if no input', () => { + const output = stringToMs(''); + expect(output).toBe(0); + }); + + it('should return its input, as int, if no unit is supplied', () => { + const output = stringToMs('1000'); + expect(output).toBe(1000); + }); + + it('should convert 3s to 3000', () => { + const output = stringToMs('3s'); + expect(output).toBe(3000); + }); + + it('should convert 2m to 120000', () => { + const output = stringToMs('2m'); + expect(output).toBe(120000); + }); + + it('should convert 2h to 7200000', () => { + const output = stringToMs('2h'); + expect(output).toBe(7200000); + }); + + it('should convert 2d to 172800000', () => { + const output = stringToMs('2d'); + expect(output).toBe(172800000); + }); + + it('should throw on unsupported unit', () => { + expect(() => { + stringToMs('1y'); + }).toThrow(); + }); +}); diff --git a/packages/grafana-ui/src/utils/string.ts b/packages/grafana-ui/src/utils/string.ts index 12433623a6a..78a089f9fdd 100644 --- a/packages/grafana-ui/src/utils/string.ts +++ b/packages/grafana-ui/src/utils/string.ts @@ -1,3 +1,5 @@ +import { SelectOptionItem } from './../components/Select/Select'; + export function stringToJsRegex(str: string): RegExp { if (str[0] !== '/') { return new RegExp('^' + str + '$'); @@ -11,3 +13,39 @@ export function stringToJsRegex(str: string): RegExp { return new RegExp(match[1], match[2]); } + +export function stringToMs(str: string): number { + if (!str) { + return 0; + } + + const nr = parseInt(str, 10); + const unit = str.substr(String(nr).length); + const s = 1000; + const m = s * 60; + const h = m * 60; + const d = h * 24; + + switch (unit) { + case 's': + return nr * s; + case 'm': + return nr * m; + case 'h': + return nr * h; + case 'd': + return nr * d; + default: + if (!unit) { + return isNaN(nr) ? 0 : nr; + } + throw new Error('Not supported unit: ' + unit); + } +} + +export function getIntervalFromString(strInterval: string): SelectOptionItem { + return { + label: strInterval, + value: stringToMs(strInterval), + }; +} diff --git a/public/app/core/components/Select/__snapshots__/TeamPicker.test.tsx.snap b/public/app/core/components/Select/__snapshots__/TeamPicker.test.tsx.snap index f0bcfc6ba54..f118c37862e 100644 --- a/public/app/core/components/Select/__snapshots__/TeamPicker.test.tsx.snap +++ b/public/app/core/components/Select/__snapshots__/TeamPicker.test.tsx.snap @@ -4,86 +4,88 @@ exports[`TeamPicker renders correctly 1`] = `
-
+
- Select a team -
-
+ Select a team +
+
-
- + +
+ +
-
-
- +
+ +
diff --git a/public/app/core/components/Select/__snapshots__/UserPicker.test.tsx.snap b/public/app/core/components/Select/__snapshots__/UserPicker.test.tsx.snap index 37a485747dd..468dcb94b6b 100644 --- a/public/app/core/components/Select/__snapshots__/UserPicker.test.tsx.snap +++ b/public/app/core/components/Select/__snapshots__/UserPicker.test.tsx.snap @@ -4,86 +4,88 @@ exports[`UserPicker renders correctly 1`] = `
-
+
- Select user -
-
+ Select user +
+
-
- + +
+ +
-
-
- +
+ +
diff --git a/public/app/core/utils/datemath.ts b/public/app/core/utils/datemath.ts index 2f34819b2db..d385f642158 100644 --- a/public/app/core/utils/datemath.ts +++ b/public/app/core/utils/datemath.ts @@ -1,9 +1,10 @@ +// @ts-ignore import _ from 'lodash'; import moment from 'moment'; const units = ['y', 'M', 'w', 'd', 'h', 'm', 's']; -type Timezone = 'utc'; +export type Timezone = 'utc'; /** * Parses different types input to a moment instance. There is a specific formatting language that can be used @@ -88,7 +89,8 @@ export function isValid(text: string | moment.Moment): boolean { * @param time * @param roundUp If true it will round the time to endOf time unit, otherwise to startOf time unit. */ -export function parseDateMath(mathString: string, time: moment.Moment, roundUp?: boolean): moment.Moment | undefined { +// TODO: Had to revert Andrejs `time: moment.Moment` to `time: any` +export function parseDateMath(mathString: string, time: any, roundUp?: boolean): moment.Moment | undefined { const dateTime = time; let i = 0; const len = mathString.length; diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index a8872bcc53d..2f28e60fdbc 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -46,7 +46,7 @@ describe('state functions', () => { }); it('returns a valid Explore state from a compact URL parameter', () => { - const paramValue = '%5B"now-1h","now","Local",%7B"expr":"metric"%7D%5D'; + const paramValue = '%5B"now-1h","now","Local","5m",%7B"expr":"metric"%7D,"ui"%5D'; expect(parseUrlState(paramValue)).toMatchObject({ datasource: 'Local', queries: [{ expr: 'metric' }], diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 5a704422225..cfc53ae450b 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -59,7 +59,7 @@ export async function getExploreUrl( ) { let exploreDatasource = panelDatasource; let exploreTargets: DataQuery[] = panelTargets; - let url; + let url: string; // Mixed datasources need to choose only one datasource if (panelDatasource.meta.id === 'mixed' && panelTargets) { @@ -191,7 +191,12 @@ export const safeParseJson = (text: string) => { export function parseUrlState(initial: string | undefined): ExploreUrlState { const parsed = safeParseJson(initial); - const errorResult = { datasource: null, queries: [], range: DEFAULT_RANGE, ui: DEFAULT_UI_STATE }; + const errorResult = { + datasource: null, + queries: [], + range: DEFAULT_RANGE, + ui: DEFAULT_UI_STATE, + }; if (!parsed) { return errorResult; diff --git a/public/app/core/utils/rangeutil.ts b/public/app/core/utils/rangeutil.ts index 310c8ab8533..75f2b814433 100644 --- a/public/app/core/utils/rangeutil.ts +++ b/public/app/core/utils/rangeutil.ts @@ -1,3 +1,4 @@ +// @ts-ignore import _ from 'lodash'; import moment from 'moment'; @@ -5,7 +6,7 @@ import { RawTimeRange } from '@grafana/ui'; import * as dateMath from './datemath'; -const spans = { +const spans: { [key: string]: { display: string; section?: number } } = { s: { display: 'second' }, m: { display: 'minute' }, h: { display: 'hour' }, @@ -63,12 +64,12 @@ const rangeOptions = [ const absoluteFormat = 'MMM D, YYYY HH:mm:ss'; -const rangeIndex = {}; -_.each(rangeOptions, frame => { +const rangeIndex: any = {}; +_.each(rangeOptions, (frame: any) => { rangeIndex[frame.from + ' to ' + frame.to] = frame; }); -export function getRelativeTimesList(timepickerSettings, currentDisplay) { +export function getRelativeTimesList(timepickerSettings: any, currentDisplay: any) { const groups = _.groupBy(rangeOptions, (option: any) => { option.active = option.display === currentDisplay; return option.section; @@ -84,7 +85,7 @@ export function getRelativeTimesList(timepickerSettings, currentDisplay) { return groups; } -function formatDate(date) { +function formatDate(date: any) { return date.format(absoluteFormat); } @@ -144,12 +145,12 @@ export function describeTimeRange(range: RawTimeRange): string { if (moment.isMoment(range.from)) { const toMoment = dateMath.parse(range.to, true); - return formatDate(range.from) + ' to ' + toMoment.fromNow(); + return toMoment ? formatDate(range.from) + ' to ' + toMoment.fromNow() : ''; } if (moment.isMoment(range.to)) { const from = dateMath.parse(range.from, false); - return from.fromNow() + ' to ' + formatDate(range.to); + return from ? from.fromNow() + ' to ' + formatDate(range.to) : ''; } if (range.to.toString() === 'now') { diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index 453c5d1f9ac..ec7c471fed6 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -9,6 +9,7 @@ import { PlaylistSrv } from 'app/features/playlist/playlist_srv'; // Components import { DashNavButton } from './DashNavButton'; +import { DashNavTimeControls } from './DashNavTimeControls'; import { Tooltip } from '@grafana/ui'; // State @@ -16,8 +17,9 @@ import { updateLocation } from 'app/core/actions'; // Types import { DashboardModel } from '../../state'; +import { StoreState } from 'app/types'; -export interface Props { +export interface OwnProps { dashboard: DashboardModel; editview: string; isEditing: boolean; @@ -27,6 +29,12 @@ export interface Props { onAddPanel: () => void; } +export interface StateProps { + location: any; +} + +type Props = StateProps & OwnProps; + export class DashNav extends PureComponent { timePickerEl: HTMLElement; timepickerCmp: AngularComponent; @@ -39,7 +47,6 @@ export class DashNav extends PureComponent { componentDidMount() { const loader = getAngularLoader(); - const template = ''; const scopeProps = { dashboard: this.props.dashboard }; @@ -161,12 +168,10 @@ export class DashNav extends PureComponent { } render() { - const { dashboard, onAddPanel } = this.props; + const { dashboard, onAddPanel, location } = this.props; const { canStar, canSave, canShare, showSettings, isStarred } = dashboard.meta; const { snapshot } = dashboard; - const snapshotUrl = snapshot && snapshot.originalUrl; - return (
{this.isInFullscreenOrSettings && this.renderBackButton()} @@ -255,13 +260,20 @@ export class DashNav extends PureComponent { />
-
(this.timePickerEl = element)} /> + {!dashboard.timepicker.hidden && ( +
+ +
(this.timePickerEl = element)} /> +
+ )}
); } } -const mapStateToProps = () => ({}); +const mapStateToProps = (state: StoreState) => ({ + location: state.location, +}); const mapDispatchToProps = { updateLocation, diff --git a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx new file mode 100644 index 00000000000..42d3a64fc4b --- /dev/null +++ b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx @@ -0,0 +1,53 @@ +// Libaries +import React, { Component } from 'react'; + +// Types +import { DashboardModel } from '../../state'; +import { LocationState } from 'app/types'; + +// State +import { updateLocation } from 'app/core/actions'; + +// Components +import { RefreshPicker } from '@grafana/ui'; + +// Utils & Services +import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; + +export interface Props { + dashboard: DashboardModel; + updateLocation: typeof updateLocation; + location: LocationState; +} + +export class DashNavTimeControls extends Component { + timeSrv: TimeSrv = getTimeSrv(); + + get refreshParamInUrl(): string { + return this.props.location.query.refresh as string; + } + + onChangeRefreshInterval = (interval: string) => { + this.timeSrv.setAutoRefresh(interval); + this.forceUpdate(); + }; + + onRefresh = () => { + this.timeSrv.refreshDashboard(); + return Promise.resolve(); + }; + + render() { + const { dashboard } = this.props; + const intervals = dashboard.timepicker.refresh_intervals; + return ( + + ); + } +} diff --git a/public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts b/public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts index 0c388c27f8d..5b973bfcebf 100644 --- a/public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts +++ b/public/app/features/dashboard/components/TimePicker/TimePickerCtrl.ts @@ -108,7 +108,7 @@ export class TimePickerCtrl { this.timeOptions = rangeUtil.getRelativeTimesList(this.panel, this.rangeString); this.refresh = { value: this.dashboard.refresh, - options: _.map(this.panel.refresh_intervals, (interval: any) => { + options: this.panel.refresh_intervals.map((interval: any) => { return { text: interval, value: interval }; }), }; diff --git a/public/app/features/dashboard/components/TimePicker/template.html b/public/app/features/dashboard/components/TimePicker/template.html index 481082a2cf6..2ce28ee165c 100644 --- a/public/app/features/dashboard/components/TimePicker/template.html +++ b/public/app/features/dashboard/components/TimePicker/template.html @@ -1,27 +1,25 @@ - +
@@ -75,7 +73,7 @@
- +
diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx index a8a3560743f..ed1bf82b27a 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx @@ -9,7 +9,7 @@ import templateSrv from 'app/features/templating/template_srv'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; -import { ClickOutsideWrapper } from 'app/core/components/ClickOutsideWrapper/ClickOutsideWrapper'; +import { ClickOutsideWrapper } from '@grafana/ui'; export interface Props { panel: PanelModel; diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts index fb521d9b849..e54dd212539 100644 --- a/public/app/features/dashboard/services/TimeSrv.ts +++ b/public/app/features/dashboard/services/TimeSrv.ts @@ -124,6 +124,7 @@ export class TimeSrv { setAutoRefresh(interval) { this.dashboard.refresh = interval; this.cancelNextRefresh(); + if (interval) { const intervalMs = kbn.interval_to_ms(interval); @@ -135,15 +136,17 @@ export class TimeSrv { ); } - // update url - const params = this.$location.search(); - if (interval) { - params.refresh = interval; - this.$location.search(params); - } else if (params.refresh) { - delete params.refresh; - this.$location.search(params); - } + // update url inside timeout to so that a digest happens after (called from react) + this.$timeout(() => { + const params = this.$location.search(); + if (interval) { + params.refresh = interval; + this.$location.search(params); + } else if (params.refresh) { + delete params.refresh; + this.$location.search(params); + } + }); } refreshDashboard() { diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 8e577dac1e1..45deefedcce 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -117,7 +117,6 @@ export class Explore extends React.PureComponent { const initialQueries: DataQuery[] = ensureQueries(queries); const initialRange = { from: parseTime(range.from), to: parseTime(range.to) }; const width = this.el ? this.el.offsetWidth : 0; - // initialize the whole explore first time we mount and if browser history contains a change in datasource if (!initialized) { this.props.initializeExplore( diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx index 36a6e696d4d..4222b000eb9 100644 --- a/public/app/features/explore/ExploreToolbar.tsx +++ b/public/app/features/explore/ExploreToolbar.tsx @@ -3,12 +3,19 @@ import { connect } from 'react-redux'; import { hot } from 'react-hot-loader'; import { ExploreId } from 'app/types/explore'; -import { DataSourceSelectItem, RawTimeRange, TimeRange } from '@grafana/ui'; +import { DataSourceSelectItem, RawTimeRange, TimeRange, ClickOutsideWrapper } from '@grafana/ui'; import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; import { StoreState } from 'app/types/store'; -import { changeDatasource, clearQueries, splitClose, runQueries, splitOpen } from './state/actions'; +import { + changeDatasource, + clearQueries, + splitClose, + runQueries, + splitOpen, + changeRefreshInterval, +} from './state/actions'; import TimePicker from './TimePicker'; -import { ClickOutsideWrapper } from 'app/core/components/ClickOutsideWrapper/ClickOutsideWrapper'; +import { RefreshPicker, SetInterval } from '@grafana/ui'; enum IconSide { left = 'left', @@ -51,20 +58,22 @@ interface StateProps { range: RawTimeRange; selectedDatasource: DataSourceSelectItem; splitted: boolean; + refreshInterval: string; } interface DispatchProps { changeDatasource: typeof changeDatasource; clearAll: typeof clearQueries; - runQuery: typeof runQueries; + runQueries: typeof runQueries; closeSplit: typeof splitClose; split: typeof splitOpen; + changeRefreshInterval: typeof changeRefreshInterval; } type Props = StateProps & DispatchProps & OwnProps; export class UnConnectedExploreToolbar extends PureComponent { - constructor(props) { + constructor(props: Props) { super(props); } @@ -77,23 +86,32 @@ export class UnConnectedExploreToolbar extends PureComponent { }; onRunQuery = () => { - this.props.runQuery(this.props.exploreId); + return this.props.runQueries(this.props.exploreId); }; onCloseTimePicker = () => { this.props.timepickerRef.current.setState({ isOpen: false }); }; + onChangeRefreshInterval = (item: string) => { + const { changeRefreshInterval, exploreId } = this.props; + changeRefreshInterval(exploreId, item); + }; + render() { const { datasourceMissing, exploreDatasources, + closeSplit, exploreId, loading, range, selectedDatasource, splitted, timepickerRef, + refreshInterval, + onChangeTime, + split, } = this.props; return ( @@ -109,7 +127,7 @@ export class UnConnectedExploreToolbar extends PureComponent { )}
{splitted && ( - this.props.closeSplit(exploreId)}> + closeSplit(exploreId)}> )} @@ -133,7 +151,7 @@ export class UnConnectedExploreToolbar extends PureComponent { {createResponsiveButton({ splitted, title: 'Split', - onClick: this.props.split, + onClick: split, iconClassName: 'fa fa-fw fa-columns icon-margin-right', iconSide: IconSide.left, })} @@ -141,9 +159,18 @@ export class UnConnectedExploreToolbar extends PureComponent { ) : null}
- + + + + {refreshInterval && }
+