Commit 406ef962 by Johannes Schill Committed by Torkel Ödegaard

Explore & Dashboard: New Refresh picker (#16505)

* Added RefreshButton

* Added RefreshSelect

* Added RefreshSelectButton

* Added RefreshPicker

* Removed the magic string Paused

* Minor style changes and using Off instead of Pause

* Added HeadlessSelect

* Added HeadlessSelect story

* Added SelectButton

* Removed RefreshSelectButton

* Added TimePicker and moved ClickOutsideWrapper to ui/components

* Added TimePickerPopOver

* Added react-calendar

* Missed yarn lock file

* Added inputs to popover

* Added TimePicker and RefreshPicker to DashNav

* Moved TimePicker and RefreshPicker to app/core

* Added react-calendar to app and removed from ui/components

* Fixed PopOver onClick

* Moved everything back to ui components because of typings problems

* Exporing RefreshPicker and TimePicker

* Added Apply and inputs

* Added typings

* Added TimePickerInput and logic

* Fixed parsing of string to Moments

* Fixed range string

* Styling and connecting the calendars and inputs

* Changed Calendar styling

* Added backward forward and zoom

* Fixed responsive styles

* Moved TimePicker and RefreshPicker into app core

* Renamed menuIsOpen to isOpen

* Changed from className={} to className=""

* Moved Popover to TimePickerOptionGroup

* Renamed all PopOver to Popover

* Renamed popOver to popover and some minor refactorings

* Renamed files with git mv

* Added ButtonSelect and refactored RefreshPicker

* Refactored TimePicker to use new ButtonSelect

* Removed HeadlessSelect as suggested

* fix: Fix typings and misc errors after rebase

* wip: Enable time picker on dashboard and add tooltip

* Merge branch 'master' into hugoh/new-timepicker-and-unified-component

# Conflicts:
#	packages/grafana-ui/package.json
#	packages/grafana-ui/src/components/Input/Input.test.tsx
#	packages/grafana-ui/src/components/Input/Input.tsx
#	packages/grafana-ui/src/utils/validate.ts
#	public/app/features/dashboard/panel_editor/QueryOptions.tsx
#	yarn.lock

* fix: Snapshot update

* Move TimePicker default options into the TimePicker as statics, pass the tooltipContent down the line when wanted and wrap the button in a tooltip element

* fix: Override internal state prop if we provide one in a prop

* Updated snapshots

* Let dashnav control refreshPicker state

* feat: Add a stringToMs function

* wip: RefreshPicker

* wip: Move RefreshPicker to @grafana/ui

* wip: Move TimePicker to @grafana/ui

* wip: Remove comments

* wip: Add refreshPicker to explore

* wip: Use default intervals if the prop is missing

* wip: Nicer way of setting defaults

* fix: Control the select component

* wip: Add onMoveForward/onMoveBack

* Remove code related to the new time picker and refresh picker from dashnav

* Fix: Typings after merge

* chore: Minor fix after merge

* chore: Remove _.map usage

* chore: Moved refresh-picker logic out of the refresh picker since it will work a little differently in explore and dashboards until we have replaced the TimeSrv

* feat: Add an Interval component to @grafana/ui

* chore: Remove intervalId from redux state and move setInterval logic from ExploreToolbar to its own Interval component

* feat: Add refreshInterval to Explore's URL state

* feat: Pick up refreshInterval from url on page load

* fix: Set default refreshInterval when no value can be retained from URL

* fix: Update test initial state with refreshInterval

* fix: Handle URLs before RefreshPicker

* fix: Move RefreshInterval to url position 3 since the segments can take multiple positions

* fix: A better way of detecting urls without RefreshInterval in Explore

* chore: Some Explore typings

* fix: Attach refresh picker to interval picker

* chore: Sass fix for refresh button border radius

* fix: Remove refreshInterval from URL

* fix: Intervals now start when previous interval is finished

* fix: Use clearTimeout instead of clearInterval

* fix: Make sure there's a delay set before adding a timeout when we have slow explore queries

* wip: Add refresh picker to dashboard

* feat: Add util for removing keys with empty values

* feat: RefreshPicker in dashboards and tmp rem out old RefreshPicker

* fix: Remove the jumpy:ness in the refreshpicker

* Changed placement and made it hide when your in dashboard settings

* chore: Move logic related to refresh picker out of DashNav to its own component

* feat: Add tooltip to refreshpicker

* fix: Fix bug with refreshpicker not updating when setting to 'off'

* fix: Make it possible to override refresh intervals using the dashboard intervals

* chore: Change name of Interval to SetInterval to align with ecmascripts naming since its basically the same but declarative and async

* fix: Use default intervals when auto refresh is empty in dashboard settings

* fix: Hide time/interval picker when hidden is true on the model, such as on the home dashboard

* fix: Interval picker will have to handle location changes since timeSrv wont

* RefreshPicker: Refactoring refresh picker

* RefreshPicker: minor refactoring
parent d23f50ab
......@@ -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",
......
......@@ -22,7 +22,7 @@ export class ClickOutsideWrapper extends PureComponent<Props, State> {
window.removeEventListener('click', this.onOutsideClick, false);
}
onOutsideClick = event => {
onOutsideClick = (event: any) => {
const domNode = ReactDOM.findDOMNode(this) as Element;
if (!domNode || !domNode.contains(event.target)) {
......
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 (
<UseState initialState={'1h'}>
{(value, updateValue) => {
return (
<RefreshPicker
tooltip="Hello world"
value={value}
intervals={['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d']}
onIntervalChanged={interval => {
action('onIntervalChanged fired')(interval);
}}
onRefresh={() => {
action('onRefresh fired')();
}}
/>
);
}}
</UseState>
);
});
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<Props> {
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 (
<div className={cssClasses}>
<div className="refresh-picker-buttons">
<Tooltip placement="top" content={tooltip}>
<button className="btn btn--radius-right-0 navbar-button navbar-button--refresh" onClick={onRefresh}>
<i className="fa fa-refresh" />
</button>
</Tooltip>
<ButtonSelect
className="navbar-button--attached btn--radius-left-0"
value={selectedValue}
label={selectedValue.label}
options={options}
onChange={this.onChangeSelect}
maxMenuHeight={380}
/>
</div>
</div>
);
}
}
.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;
}
}
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<SelectOptionItem>('Selected Value:', intialState);
const options = object<SelectOptionItem[]>('Options:', [
intialState,
{ label: 'Another label', value: 'Another value' },
]);
return (
<UseState initialState={value}>
{(value, updateValue) => {
return (
<ButtonSelect
value={value}
options={options}
onChange={value => {
action('onChanged fired')(value);
updateValue(value);
}}
label={value.label ? value.label : ''}
className="refresh-select"
iconClass={text('iconClass', 'fa fa-clock-o fa-fw')}
/>
);
}}
</UseState>
);
});
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 (
<button
ref={props.innerRef}
className={`btn navbar-button navbar-button--tight ${className}`}
onClick={props.selectProps.menuIsOpen ? props.selectProps.onMenuClose : props.selectProps.onMenuOpen}
onBlur={props.selectProps.onMenuClose}
>
<div className="select-button">
{iconClass && <i className={`select-button-icon ${iconClass}`} />}
<span className="select-button-value">{label ? label : ''}</span>
<i className="fa fa-caret-down fa-fw" />
</div>
</button>
);
};
export interface Props {
className: string | undefined;
options: SelectOptionItem[];
value: SelectOptionItem;
label?: string;
iconClass?: string;
components?: any;
maxMenuHeight?: number;
onChange: (item: SelectOptionItem) => void;
tooltipContent?: PopperContent<any>;
isMenuOpen?: boolean;
onOpenMenu?: () => void;
onCloseMenu?: () => void;
}
export class ButtonSelect extends PureComponent<Props> {
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 (
<Select
autoFocus
backspaceRemovesValue={false}
isClearable={false}
isSearchable={false}
options={options}
onChange={this.onChange}
defaultValue={value}
maxMenuHeight={maxMenuHeight}
components={combinedComponents}
className="gf-form-select-box-button-select"
tooltipContent={tooltipContent}
isOpen={isMenuOpen}
onOpenMenu={onOpenMenu}
onCloseMenu={onCloseMenu}
/>
);
}
}
......@@ -17,6 +17,8 @@ import IndicatorsContainer from './IndicatorsContainer';
import NoOptionsMessage from './NoOptionsMessage';
import resetSelectStyles from './resetSelectStyles';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { PopperContent } from '@grafana/ui/src/components/Tooltip/PopperController';
import { Tooltip } from '@grafana/ui';
export interface SelectOptionItem {
label?: string;
......@@ -26,7 +28,7 @@ export interface SelectOptionItem {
[key: string]: any;
}
interface CommonProps {
export interface CommonProps {
defaultValue?: any;
getOptionLabel?: (item: SelectOptionItem) => string;
getOptionValue?: (item: SelectOptionItem) => string;
......@@ -42,13 +44,18 @@ interface CommonProps {
openMenuOnFocus?: boolean;
onBlur?: () => void;
maxMenuHeight?: number;
isLoading: boolean;
isLoading?: boolean;
noOptionsMessage?: () => string;
isMulti?: boolean;
backspaceRemovesValue: boolean;
backspaceRemovesValue?: boolean;
isOpen?: boolean;
components?: any;
tooltipContent?: PopperContent<any>;
onOpenMenu?: () => void;
onCloseMenu?: () => void;
}
interface SelectProps {
export interface SelectProps {
options: SelectOptionItem[];
}
......@@ -58,6 +65,26 @@ interface AsyncProps {
loadingMessage?: () => string;
}
const wrapInTooltip = (
component: React.ReactElement,
tooltipContent: PopperContent<any> | undefined,
isMenuOpen: boolean | undefined
) => {
const showTooltip = isMenuOpen ? false : undefined;
if (tooltipContent) {
return (
<Tooltip show={showTooltip} content={tooltipContent} placement="bottom">
<div>
{/* div needed for tooltip */}
{component}
</div>
</Tooltip>
);
} else {
return <div>{component}</div>;
}
};
export const MenuList = (props: any) => {
return (
<components.MenuList {...props}>
......@@ -81,6 +108,28 @@ export class Select extends PureComponent<CommonProps & SelectProps> {
isLoading: false,
backspaceRemovesValue: true,
maxMenuHeight: 300,
menuIsOpen: false,
components: {
Option: SelectOption,
SingleValue,
IndicatorsContainer,
MenuList,
Group: SelectOptionGroup,
},
};
onOpenMenu = () => {
const { onOpenMenu } = this.props;
if (onOpenMenu) {
onOpenMenu();
}
};
onCloseMenu = () => {
const { onCloseMenu } = this.props;
if (onCloseMenu) {
onCloseMenu();
}
};
render() {
......@@ -105,6 +154,9 @@ export class Select extends PureComponent<CommonProps & SelectProps> {
onBlur,
maxMenuHeight,
noOptionsMessage,
isOpen,
components,
tooltipContent,
} = this.props;
let widthClass = '';
......@@ -113,18 +165,12 @@ export class Select extends PureComponent<CommonProps & SelectProps> {
}
const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className);
return (
const selectComponents = { ...Select.defaultProps.components, ...components };
return wrapInTooltip(
<ReactSelect
classNamePrefix="gf-form-select-box"
className={selectClassNames}
components={{
Option: SelectOption,
SingleValue,
IndicatorsContainer,
MenuList,
Group: SelectOptionGroup,
}}
components={selectComponents}
defaultValue={defaultValue}
value={value}
getOptionLabel={getOptionLabel}
......@@ -145,7 +191,12 @@ export class Select extends PureComponent<CommonProps & SelectProps> {
noOptionsMessage={noOptionsMessage}
isMulti={isMulti}
backspaceRemovesValue={backspaceRemovesValue}
/>
menuIsOpen={isOpen}
onMenuOpen={this.onOpenMenu}
onMenuClose={this.onCloseMenu}
/>,
tooltipContent,
isOpen
);
}
}
......@@ -190,6 +241,7 @@ export class AsyncSelect extends PureComponent<CommonProps & AsyncProps> {
openMenuOnFocus,
maxMenuHeight,
isMulti,
tooltipContent,
} = this.props;
let widthClass = '';
......@@ -199,7 +251,7 @@ export class AsyncSelect extends PureComponent<CommonProps & AsyncProps> {
const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className);
return (
return wrapInTooltip(
<ReactAsyncSelect
classNamePrefix="gf-form-select-box"
className={selectClassNames}
......@@ -231,7 +283,9 @@ export class AsyncSelect extends PureComponent<CommonProps & AsyncProps> {
maxMenuHeight={maxMenuHeight}
isMulti={isMulti}
backspaceRemovesValue={backspaceRemovesValue}
/>
/>,
tooltipContent,
false
);
}
}
......
......@@ -6,7 +6,7 @@ import { components } from '@torkelo/react-select';
import { OptionProps } from 'react-select/lib/components/Option';
// https://github.com/JedWatson/react-select/issues/3038
interface ExtendedOptionProps extends OptionProps<any> {
export interface ExtendedOptionProps extends OptionProps<any> {
data: {
description?: string;
imgUrl?: string;
......
......@@ -189,3 +189,7 @@ $select-input-bg-disabled: $input-bg-disabled;
padding-right: 2px;
}
}
.gf-form-select-box-button-select {
height: auto;
}
import { PureComponent } from 'react';
import { stringToMs } from '../../utils/string';
interface Props {
func: () => any; // TODO
interval: string;
}
export class SetInterval extends PureComponent<Props> {
private intervalId = 0;
componentDidMount() {
this.addInterval();
}
componentDidUpdate(prevProps: Props) {
const { interval } = this.props;
if (interval !== prevProps.interval) {
this.clearInterval();
this.addInterval();
}
}
componentWillUnmount() {
this.clearInterval();
}
addInterval = () => {
const { func, interval } = this.props;
if (interval) {
func().then(() => {
if (interval) {
this.intervalId = window.setTimeout(() => {
this.addInterval();
}, stringToMs(interval));
}
});
}
};
clearInterval = () => {
window.clearTimeout(this.intervalId);
};
render() {
return null;
}
}
import React from 'react';
import moment, { Moment } from 'moment';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { TimePicker } from './TimePicker';
import { UseState } from '../../utils/storybook/UseState';
import { withRighAlignedStory } from '../../utils/storybook/withRightAlignedStory';
const TimePickerStories = storiesOf('UI/TimePicker', module);
export const popoverOptions = {
'0': [
{
from: 'now-2d',
to: 'now',
display: 'Last 2 days',
section: 0,
active: false,
},
{
from: 'now-7d',
to: 'now',
display: 'Last 7 days',
section: 0,
active: false,
},
{
from: 'now-30d',
to: 'now',
display: 'Last 30 days',
section: 0,
active: false,
},
{
from: 'now-90d',
to: 'now',
display: 'Last 90 days',
section: 0,
active: false,
},
{
from: 'now-6M',
to: 'now',
display: 'Last 6 months',
section: 0,
active: false,
},
{
from: 'now-1y',
to: 'now',
display: 'Last 1 year',
section: 0,
active: false,
},
{
from: 'now-2y',
to: 'now',
display: 'Last 2 years',
section: 0,
active: false,
},
{
from: 'now-5y',
to: 'now',
display: 'Last 5 years',
section: 0,
active: false,
},
],
'1': [
{
from: 'now-1d/d',
to: 'now-1d/d',
display: 'Yesterday',
section: 1,
active: false,
},
{
from: 'now-2d/d',
to: 'now-2d/d',
display: 'Day before yesterday',
section: 1,
active: false,
},
{
from: 'now-7d/d',
to: 'now-7d/d',
display: 'This day last week',
section: 1,
active: false,
},
{
from: 'now-1w/w',
to: 'now-1w/w',
display: 'Previous week',
section: 1,
active: false,
},
{
from: 'now-1M/M',
to: 'now-1M/M',
display: 'Previous month',
section: 1,
active: false,
},
{
from: 'now-1y/y',
to: 'now-1y/y',
display: 'Previous year',
section: 1,
active: false,
},
],
'2': [
{
from: 'now/d',
to: 'now/d',
display: 'Today',
section: 2,
active: true,
},
{
from: 'now/d',
to: 'now',
display: 'Today so far',
section: 2,
active: false,
},
{
from: 'now/w',
to: 'now/w',
display: 'This week',
section: 2,
active: false,
},
{
from: 'now/w',
to: 'now',
display: 'This week so far',
section: 2,
active: false,
},
{
from: 'now/M',
to: 'now/M',
display: 'This month',
section: 2,
active: false,
},
{
from: 'now/M',
to: 'now',
display: 'This month so far',
section: 2,
active: false,
},
{
from: 'now/y',
to: 'now/y',
display: 'This year',
section: 2,
active: false,
},
{
from: 'now/y',
to: 'now',
display: 'This year so far',
section: 2,
active: false,
},
],
};
TimePickerStories.addDecorator(withRighAlignedStory);
TimePickerStories.add('default', () => {
return (
<UseState
initialState={{
from: moment(),
to: moment(),
raw: { from: 'now-6h' as string | Moment, to: 'now' as string | Moment },
}}
>
{(value, updateValue) => {
return (
<TimePicker
isTimezoneUtc={false}
value={value}
tooltipContent="TimePicker tooltip"
selectOptions={[
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3, active: false },
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3, active: false },
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3, active: false },
{ from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3, active: false },
{ from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3, active: false },
{ from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3, active: false },
{ from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3, active: false },
{ from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3, active: false },
]}
popoverOptions={popoverOptions}
onChange={timeRange => {
action('onChange fired')(timeRange);
updateValue(timeRange);
}}
onMoveBackward={() => {
action('onMoveBackward fired')();
}}
onMoveForward={() => {
action('onMoveForward fired')();
}}
onZoom={() => {
action('onZoom fired')();
}}
/>
);
}}
</UseState>
);
});
import React, { PureComponent } from 'react';
import moment from 'moment';
import { TimeRange, TimeOptions, TimeOption, SelectOptionItem } from '@grafana/ui';
import { ButtonSelect } from '@grafana/ui/src/components/Select/ButtonSelect';
import { mapTimeOptionToTimeRange, mapTimeRangeToRangeString } from './time';
import { Props as TimePickerPopoverProps } from './TimePickerPopover';
import { TimePickerOptionGroup } from './TimePickerOptionGroup';
import { PopperContent } from '@grafana/ui/src/components/Tooltip/PopperController';
import { Timezone } from '../../../../../public/app/core/utils/datemath';
export interface Props {
value: TimeRange;
isTimezoneUtc: boolean;
popoverOptions: TimeOptions;
selectOptions: TimeOption[];
timezone?: Timezone;
onChange: (timeRange: TimeRange) => void;
onMoveBackward: () => void;
onMoveForward: () => void;
onZoom: () => void;
tooltipContent?: PopperContent<any>;
}
const defaultSelectOptions = [
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3, active: false },
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3, active: false },
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3, active: false },
{ from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3, active: false },
{ from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3, active: false },
{ from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3, active: false },
{ from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3, active: false },
{ from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3, active: false },
];
const defaultPopoverOptions = {
'0': [
{
from: 'now-2d',
to: 'now',
display: 'Last 2 days',
section: 0,
active: false,
},
{
from: 'now-7d',
to: 'now',
display: 'Last 7 days',
section: 0,
active: false,
},
{
from: 'now-30d',
to: 'now',
display: 'Last 30 days',
section: 0,
active: false,
},
{
from: 'now-90d',
to: 'now',
display: 'Last 90 days',
section: 0,
active: false,
},
{
from: 'now-6M',
to: 'now',
display: 'Last 6 months',
section: 0,
active: false,
},
{
from: 'now-1y',
to: 'now',
display: 'Last 1 year',
section: 0,
active: false,
},
{
from: 'now-2y',
to: 'now',
display: 'Last 2 years',
section: 0,
active: false,
},
{
from: 'now-5y',
to: 'now',
display: 'Last 5 years',
section: 0,
active: false,
},
],
'1': [
{
from: 'now-1d/d',
to: 'now-1d/d',
display: 'Yesterday',
section: 1,
active: false,
},
{
from: 'now-2d/d',
to: 'now-2d/d',
display: 'Day before yesterday',
section: 1,
active: false,
},
{
from: 'now-7d/d',
to: 'now-7d/d',
display: 'This day last week',
section: 1,
active: false,
},
{
from: 'now-1w/w',
to: 'now-1w/w',
display: 'Previous week',
section: 1,
active: false,
},
{
from: 'now-1M/M',
to: 'now-1M/M',
display: 'Previous month',
section: 1,
active: false,
},
{
from: 'now-1y/y',
to: 'now-1y/y',
display: 'Previous year',
section: 1,
active: false,
},
],
'2': [
{
from: 'now/d',
to: 'now/d',
display: 'Today',
section: 2,
active: true,
},
{
from: 'now/d',
to: 'now',
display: 'Today so far',
section: 2,
active: false,
},
{
from: 'now/w',
to: 'now/w',
display: 'This week',
section: 2,
active: false,
},
{
from: 'now/w',
to: 'now',
display: 'This week so far',
section: 2,
active: false,
},
{
from: 'now/M',
to: 'now/M',
display: 'This month',
section: 2,
active: false,
},
{
from: 'now/M',
to: 'now',
display: 'This month so far',
section: 2,
active: false,
},
{
from: 'now/y',
to: 'now/y',
display: 'This year',
section: 2,
active: false,
},
{
from: 'now/y',
to: 'now',
display: 'This year so far',
section: 2,
active: false,
},
],
};
export interface State {
isMenuOpen: boolean;
}
export class TimePicker extends PureComponent<Props, State> {
static defaultSelectOptions = defaultSelectOptions;
static defaultPopoverOptions = defaultPopoverOptions;
state: State = {
isMenuOpen: false,
};
mapTimeOptionsToSelectOptionItems = (selectOptions: TimeOption[]) => {
const { value, popoverOptions, isTimezoneUtc, timezone } = this.props;
const options = selectOptions.map(timeOption => {
return { label: timeOption.display, value: timeOption };
});
const popoverProps: TimePickerPopoverProps = {
value,
options: popoverOptions,
isTimezoneUtc,
timezone,
};
return [
{
label: 'Custom',
expanded: true,
options,
onPopoverOpen: () => undefined,
onPopoverClose: (timeRange: TimeRange) => this.onPopoverClose(timeRange),
popoverProps,
},
];
};
onSelectChanged = (item: SelectOptionItem) => {
const { isTimezoneUtc, onChange, timezone } = this.props;
onChange(mapTimeOptionToTimeRange(item.value, isTimezoneUtc, timezone));
};
onChangeMenuOpenState = (isOpen: boolean) => {
this.setState({
isMenuOpen: isOpen,
});
};
onOpenMenu = () => this.onChangeMenuOpenState(true);
onCloseMenu = () => this.onChangeMenuOpenState(false);
onPopoverClose = (timeRange: TimeRange) => {
const { onChange } = this.props;
onChange(timeRange);
// Here we should also close the Select but no sure how to solve this without introducing state in this component
// Edit: State introduced
this.onCloseMenu();
};
render() {
const {
selectOptions: selectTimeOptions,
value,
onMoveBackward,
onMoveForward,
onZoom,
tooltipContent,
} = this.props;
const options = this.mapTimeOptionsToSelectOptionItems(selectTimeOptions);
const rangeString = mapTimeRangeToRangeString(value);
const isAbsolute = moment.isMoment(value.raw.to);
return (
<div className="time-picker">
<div className="time-picker-buttons">
{isAbsolute && (
<button className="btn navbar-button navbar-button--tight" onClick={onMoveBackward}>
<i className="fa fa-chevron-left" />
</button>
)}
<ButtonSelect
className="time-picker-button-select"
value={value}
label={rangeString}
options={options}
onChange={this.onSelectChanged}
components={{ Group: TimePickerOptionGroup }}
iconClass={'fa fa-clock-o fa-fw'}
tooltipContent={tooltipContent}
isMenuOpen={this.state.isMenuOpen}
onOpenMenu={this.onOpenMenu}
onCloseMenu={this.onCloseMenu}
/>
{isAbsolute && (
<button className="btn navbar-button navbar-button--tight" onClick={onMoveForward}>
<i className="fa fa-chevron-right" />
</button>
)}
<button className="btn navbar-button navbar-button--zoom" onClick={onZoom}>
<i className="fa fa-search-minus" />
</button>
</div>
</div>
);
}
}
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { Moment } from 'moment';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { TimePickerCalendar } from './TimePickerCalendar';
import { UseState } from '../../utils/storybook/UseState';
const TimePickerCalendarStories = storiesOf('UI/TimePicker/TimePickerCalendar', module);
TimePickerCalendarStories.addDecorator(withCenteredStory);
TimePickerCalendarStories.add('default', () => (
<UseState initialState={'now-6h' as string | Moment}>
{(value, updateValue) => {
return (
<TimePickerCalendar
isTimezoneUtc={false}
value={value}
onChange={timeRange => {
action('onChange fired')(timeRange);
updateValue(timeRange);
}}
/>
);
}}
</UseState>
));
import React, { PureComponent } from 'react';
import Calendar from 'react-calendar/dist/entry.nostyle';
import moment, { Moment } from 'moment';
import { TimeFragment } from '@grafana/ui';
import { Timezone } from '../../../../../public/app/core/utils/datemath';
import { stringToMoment } from './time';
export interface Props {
value: TimeFragment;
isTimezoneUtc: boolean;
roundup?: boolean;
timezone?: Timezone;
onChange: (value: Moment) => void;
}
export class TimePickerCalendar extends PureComponent<Props> {
onCalendarChange = (date: Date | Date[]) => {
const { onChange } = this.props;
if (Array.isArray(date)) {
return;
}
onChange(moment(date));
};
render() {
const { value, isTimezoneUtc, roundup, timezone } = this.props;
const dateValue = moment.isMoment(value)
? value.toDate()
: stringToMoment(value, isTimezoneUtc, roundup, timezone).toDate();
const calendarValue = dateValue instanceof Date && !isNaN(dateValue.getTime()) ? dateValue : moment().toDate();
return (
<Calendar
value={calendarValue}
next2Label={null}
prev2Label={null}
className="time-picker-calendar"
tileClassName="time-picker-calendar-tile"
onChange={this.onCalendarChange}
/>
);
}
}
import React, { PureComponent, ChangeEvent } from 'react';
import moment from 'moment';
import { TimeFragment, TIME_FORMAT, Input } from '@grafana/ui';
import { stringToMoment, isValidTimeString } from './time';
export interface Props {
value: TimeFragment;
isTimezoneUtc: boolean;
roundup?: boolean;
timezone?: string;
onChange: (value: string, isValid: boolean) => void;
}
export class TimePickerInput extends PureComponent<Props> {
isValid = (value: string) => {
const { isTimezoneUtc } = this.props;
if (value.indexOf('now') !== -1) {
const isValid = isValidTimeString(value);
return isValid;
}
const parsed = stringToMoment(value, isTimezoneUtc);
const isValid = parsed.isValid();
return isValid;
};
onChange = (event: ChangeEvent<HTMLInputElement>) => {
const { onChange } = this.props;
const value = event.target.value;
onChange(value, this.isValid(value));
};
valueToString = (value: TimeFragment) => {
if (moment.isMoment(value)) {
return value.format(TIME_FORMAT);
} else {
return value;
}
};
render() {
const { value } = this.props;
const valueString = this.valueToString(value);
const error = !this.isValid(valueString);
return (
<Input
type="text"
onChange={this.onChange}
onBlur={this.onChange}
hideErrorMessage={true}
value={valueString}
className={`time-picker-input${error ? '-error' : ''}`}
/>
);
}
}
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', () => (
<TimePickerOptionGroup
clearValue={() => {}}
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<any>}
data={data}
/>
));
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<any> {
data: DataProps;
}
interface State {
isPopoverOpen: boolean;
}
export class TimePickerOptionGroup extends PureComponent<Props, State> {
pickerTriggerRef = createRef<HTMLDivElement>();
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 (
<>
<div className="gf-form-select-box__option-group">
<div className="gf-form-select-box__option-group__header" ref={this.pickerTriggerRef} onClick={this.onClick}>
<span className="flex-grow-1">{label}</span>
<i className="fa fa-calendar fa-fw" />
</div>
{children}
</div>
<div>
{this.pickerTriggerRef.current && (
<Popper
show={isPopoverOpen}
content={popoverElement}
referenceElement={this.pickerTriggerRef.current}
placement={'left-start'}
wrapperClassName="time-picker-popover-popper"
/>
)}
</div>
</>
);
}
}
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', () => (
<UseState
initialState={{
from: moment(),
to: moment(),
raw: { from: 'now-6h' as string | Moment, to: 'now' as string | Moment },
}}
>
{(value, updateValue) => {
return (
<TimePickerPopover
value={value}
isTimezoneUtc={false}
onChange={timeRange => {
action('onChange fired')(timeRange);
updateValue(timeRange);
}}
options={popoverOptions}
/>
);
}}
</UseState>
));
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<Props, State> {
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 (
<div className={TimePickerPopover.popoverClassName}>
<div className="time-picker-popover-box">
<div className="time-picker-popover-box-header">
<span className="time-picker-popover-box-title">Quick ranges</span>
</div>
<div className="time-picker-popover-box-body">
{Object.keys(options).map(key => {
return (
<ul key={`popover-quickranges-${key}`}>
{options[key].map(timeOption => (
<li
key={`popover-timeoption-${timeOption.from}-${timeOption.to}`}
className={timeOption.active ? 'active' : ''}
>
<a
onClick={(event: SyntheticEvent) => {
event.preventDefault();
this.onTimeOptionClick(timeOption);
}}
>
{timeOption.display}
</a>
</li>
))}
</ul>
);
})}
</div>
</div>
<div className="time-picker-popover-box">
<div className="time-picker-popover-box-header">
<span className="time-picker-popover-box-title">Custom range</span>
</div>
<div className="time-picker-popover-box-body">
<div className="time-picker-popover-box-body-custom-ranges">
<div className="time-picker-popover-box-body-custom-ranges-input">
<span>From:</span>
<TimePickerInput
isTimezoneUtc={isTimezoneUtc}
roundup={false}
timezone={timezone}
value={value.raw.from}
onChange={this.onFromInputChanged}
/>
</div>
<div className="time-picker-popover-box-body-custom-ranges-calendar">
<TimePickerCalendar
isTimezoneUtc={isTimezoneUtc}
roundup={false}
timezone={timezone}
value={value.raw.from}
onChange={this.onFromCalendarChanged}
/>
</div>
</div>
<div className="time-picker-popover-box-body-custom-ranges">
<div className="time-picker-popover-box-body-custom-ranges-input">
<span>To:</span>
<TimePickerInput
isTimezoneUtc={isTimezoneUtc}
roundup={true}
timezone={timezone}
value={value.raw.to}
onChange={this.onToInputChanged}
/>
</div>
<div className="time-picker-popover-box-body-custom-ranges-calendar">
<TimePickerCalendar
isTimezoneUtc={isTimezoneUtc}
roundup={true}
timezone={timezone}
value={value.raw.to}
onChange={this.onToCalendarChanged}
/>
</div>
</div>
</div>
<div className="time-picker-popover-box-footer">
<button
type="submit"
className="btn gf-form-btn btn-success"
disabled={!isValid}
onClick={this.onApplyClick}
>
Apply
</button>
</div>
</div>
</div>
);
}
}
.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;
}
}
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);
......@@ -13,11 +13,18 @@ export const Tooltip = ({ children, theme, ...controllerProps }: TooltipProps) =
return (
<PopperController {...controllerProps}>
{(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 && (
<Popper
{...popperProps}
{...payloadProps}
onMouseEnter={showPopper}
onMouseLeave={hidePopper}
referenceElement={tooltipTriggerRef.current}
......
......@@ -12,3 +12,5 @@
@import 'EmptySearchResult/EmptySearchResult';
@import 'FormField/FormField';
@import 'BarGauge/BarGauge';
@import 'RefreshPicker/RefreshPicker';
@import 'TimePicker/TimePicker';
......@@ -12,6 +12,7 @@ export { Select, AsyncSelect, SelectOptionItem } from './Select/Select';
export { IndicatorsContainer } from './Select/IndicatorsContainer';
export { NoOptionsMessage } from './Select/NoOptionsMessage';
export { default as resetSelectStyles } from './Select/resetSelectStyles';
export { ButtonSelect } from './Select/ButtonSelect';
// Forms
export { FormLabel } from './FormLabel/FormLabel';
......@@ -31,6 +32,10 @@ export { PieChart, PieChartType } from './PieChart/PieChart';
export { UnitPicker } from './UnitPicker/UnitPicker';
export { StatsPicker } from './StatsPicker/StatsPicker';
export { Input, InputStatus } from './Input/Input';
export { RefreshPicker } from './RefreshPicker/RefreshPicker';
// Renderless
export { SetInterval } from './SetInterval/SetInterval';
export { Table } from './Table/Table';
export { TableInputCSV } from './Table/TableInputCSV';
......@@ -41,6 +46,6 @@ export { Gauge } from './Gauge/Gauge';
export { Graph } from './Graph/Graph';
export { BarGauge } from './BarGauge/BarGauge';
export { VizRepeater } from './VizRepeater/VizRepeater';
export { ClickOutsideWrapper } from './ClickOutsideWrapper/ClickOutsideWrapper';
export * from './SingleStatShared/shared';
export { CallToActionCard } from './CallToActionCard/CallToActionCard';
......@@ -171,6 +171,7 @@ $zindex-tooltip: ${theme.zIndex.tooltip};
$zindex-modal-backdrop: ${theme.zIndex.modalBackdrop};
$zindex-modal: ${theme.zIndex.modal};
$zindex-typeahead: ${theme.zIndex.typeahead};
$zindex-timepicker-popover: 1070;
// Buttons
//
......
......@@ -15,3 +15,19 @@ export interface IntervalValues {
interval: string; // 10s,5m
intervalMs: number;
}
export interface TimeOption {
from: string;
to: string;
display: string;
section: number;
active: boolean;
}
export interface TimeOptions {
[key: string]: TimeOption[];
}
export type TimeFragment = string | Moment;
export const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
......@@ -12,3 +12,4 @@ export * from './logs';
export * from './labels';
export { getMappedValue } from './valueMappings';
export * from './validate';
export * from './object';
export const objRemoveUndefined = (obj: any) => {
return Object.keys(obj).reduce((acc: any, key) => {
if (obj[key] !== undefined) {
acc[key] = obj[key];
}
return acc;
}, {});
};
import React from 'react';
import { RenderFunction } from '@storybook/react';
const RightAlignedStory: React.FunctionComponent<{}> = ({ children }) => {
return (
<div
style={{
height: '100vh ',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-end',
marginRight: '20px',
}}
>
{children}
</div>
);
};
export const withRighAlignedStory = (story: RenderFunction) => <RightAlignedStory>{story()}</RightAlignedStory>;
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();
});
});
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),
};
}
......@@ -4,86 +4,88 @@ exports[`TeamPicker renders correctly 1`] = `
<div
className="user-picker"
>
<div
className="css-0 gf-form-input gf-form-input--form-dropdown"
onKeyDown={[Function]}
>
<div>
<div
className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
className="css-0 gf-form-input gf-form-input--form-dropdown"
onKeyDown={[Function]}
>
<div
className="css-0 gf-form-select-box__value-container"
className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
>
<div
className="css-0 gf-form-select-box__placeholder"
>
Select a team
</div>
<div
className="css-0"
className="css-0 gf-form-select-box__value-container"
>
<div
className="gf-form-select-box__input"
style={
Object {
"display": "inline-block",
}
}
className="css-0 gf-form-select-box__placeholder"
>
Select a team
</div>
<div
className="css-0"
>
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
className="gf-form-select-box__input"
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
"display": "inline-block",
}
}
>
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div>
</div>
</div>
</div>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
</div>
</div>
</div>
</div>
......
......@@ -4,86 +4,88 @@ exports[`UserPicker renders correctly 1`] = `
<div
className="user-picker"
>
<div
className="css-0 gf-form-input gf-form-input--form-dropdown"
onKeyDown={[Function]}
>
<div>
<div
className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
className="css-0 gf-form-input gf-form-input--form-dropdown"
onKeyDown={[Function]}
>
<div
className="css-0 gf-form-select-box__value-container"
className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
>
<div
className="css-0 gf-form-select-box__placeholder"
>
Select user
</div>
<div
className="css-0"
className="css-0 gf-form-select-box__value-container"
>
<div
className="gf-form-select-box__input"
style={
Object {
"display": "inline-block",
}
}
className="css-0 gf-form-select-box__placeholder"
>
Select user
</div>
<div
className="css-0"
>
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
className="gf-form-select-box__input"
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
"display": "inline-block",
}
}
>
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div>
</div>
</div>
</div>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
</div>
</div>
</div>
</div>
......
// @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;
......
......@@ -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' }],
......
......@@ -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;
......
// @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') {
......
......@@ -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<Props> {
timePickerEl: HTMLElement;
timepickerCmp: AngularComponent;
......@@ -39,7 +47,6 @@ export class DashNav extends PureComponent<Props> {
componentDidMount() {
const loader = getAngularLoader();
const template =
'<gf-time-picker class="gf-timepicker-nav" dashboard="dashboard" ng-if="!dashboard.timepicker.hidden" />';
const scopeProps = { dashboard: this.props.dashboard };
......@@ -161,12 +168,10 @@ export class DashNav extends PureComponent<Props> {
}
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 (
<div className="navbar">
{this.isInFullscreenOrSettings && this.renderBackButton()}
......@@ -255,13 +260,20 @@ export class DashNav extends PureComponent<Props> {
/>
</div>
<div className="gf-timepicker-nav" ref={element => (this.timePickerEl = element)} />
{!dashboard.timepicker.hidden && (
<div className="navbar-buttons">
<DashNavTimeControls dashboard={dashboard} location={location} updateLocation={updateLocation} />
<div className="gf-timepicker-nav" ref={element => (this.timePickerEl = element)} />
</div>
)}
</div>
);
}
}
const mapStateToProps = () => ({});
const mapStateToProps = (state: StoreState) => ({
location: state.location,
});
const mapDispatchToProps = {
updateLocation,
......
// 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<Props> {
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 (
<RefreshPicker
onIntervalChanged={this.onChangeRefreshInterval}
onRefresh={this.onRefresh}
value={dashboard.refresh}
intervals={intervals}
tooltip="Refresh dashboard"
/>
);
}
}
......@@ -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 };
}),
};
......
<div class="navbar-buttons">
<button class="btn navbar-button navbar-button--tight" ng-click='ctrl.move(-1)' ng-if="ctrl.isAbsolute">
<i class="fa fa-chevron-left"></i>
</button>
<button class="btn navbar-button navbar-button--tight" ng-click='ctrl.move(-1)' ng-if="ctrl.isAbsolute">
<i class="fa fa-chevron-left"></i>
</button>
<button bs-tooltip="ctrl.tooltip" data-placement="bottom" ng-click="ctrl.openDropdown()" class="btn navbar-button gf-timepicker-nav-btn">
<i class="fa fa-clock-o"></i>
<span ng-bind="ctrl.rangeString"></span>
<span ng-show="ctrl.isUtc" class="gf-timepicker-utc">UTC</span>
<span ng-show="ctrl.dashboard.refresh" class="text-warning">&nbsp; Refresh every {{ctrl.dashboard.refresh}}</span>
</button>
<button bs-tooltip="ctrl.tooltip" data-placement="bottom" ng-click="ctrl.openDropdown()" class="btn navbar-button gf-timepicker-nav-btn">
<i class="fa fa-clock-o"></i>
<span ng-bind="ctrl.rangeString"></span>
<span ng-show="ctrl.isUtc" class="gf-timepicker-utc">UTC</span>
<!-- <span ng-show="ctrl.dashboard.refresh" class="text-warning">&nbsp; Refresh every {{ctrl.dashboard.refresh}}</span> -->
</button>
<button class="btn navbar-button navbar-button--tight" ng-click='ctrl.move(1)' ng-if="ctrl.isAbsolute">
<i class="fa fa-chevron-right"></i>
</button>
<button class="btn navbar-button navbar-button--tight" ng-click='ctrl.move(1)' ng-if="ctrl.isAbsolute">
<i class="fa fa-chevron-right"></i>
</button>
<button class="btn navbar-button navbar-button--zoom" bs-tooltip="'Time range zoom out <br> CTRL+Z'" data-placement="bottom" ng-click='ctrl.zoom(2)'>
<i class="fa fa-search-minus"></i>
</button>
<button class="btn navbar-button navbar-button--zoom" bs-tooltip="'Time range zoom out <br> CTRL+Z'" data-placement="bottom" ng-click='ctrl.zoom(2)'>
<i class="fa fa-search-minus"></i>
</button>
<button class="btn navbar-button navbar-button--refresh" ng-click="ctrl.timeSrv.refreshDashboard()">
<i class="fa fa-refresh"></i>
</button>
</div>
<!-- <button class="btn navbar-button navbar-button--refresh" ng-click="ctrl.timeSrv.refreshDashboard()">
<i class="fa fa-refresh"></i>
</button> -->
<div ng-if="ctrl.isOpen" class="gf-timepicker-dropdown">
<div class="popover-box">
......@@ -75,7 +73,7 @@
<datepicker ng-model="ctrl.absolute.toJs" class="gf-timepicker-component" show-weeks="false" starting-day="ctrl.firstDayOfWeek" ng-change="ctrl.absoluteToChanged()"></datepicker>
</div>
<label class="small">Refreshing every:</label>
<!-- <label class="small">Refreshing every:</label>
<div class="gf-form-inline">
<div class="gf-form max-width-28">
<select ng-model="ctrl.refresh.value" class="gf-form-input input-medium" ng-options="f.value as f.text for f in ctrl.refresh.options"></select>
......@@ -83,7 +81,7 @@
<div class="gf-form">
<button type="submit" class="btn gf-form-btn btn-primary" ng-click="ctrl.applyCustom();" ng-disabled="!timeForm.$valid">Apply</button>
</div>
</div>
</div> -->
</form>
</div>
</div>
......
......@@ -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;
......
......@@ -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() {
......
......@@ -117,7 +117,6 @@ export class Explore extends React.PureComponent<ExploreProps> {
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(
......
......@@ -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<Props, {}> {
constructor(props) {
constructor(props: Props) {
super(props);
}
......@@ -77,23 +86,32 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
};
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<Props, {}> {
)}
</div>
{splitted && (
<a className="explore-toolbar-header-close" onClick={() => this.props.closeSplit(exploreId)}>
<a className="explore-toolbar-header-close" onClick={() => closeSplit(exploreId)}>
<i className="fa fa-times fa-fw" />
</a>
)}
......@@ -133,7 +151,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
{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<Props, {}> {
) : null}
<div className="explore-toolbar-content-item timepicker">
<ClickOutsideWrapper onClick={this.onCloseTimePicker}>
<TimePicker ref={timepickerRef} range={range} onChangeTime={this.props.onChangeTime} />
<TimePicker ref={timepickerRef} range={range} onChangeTime={onChangeTime} />
</ClickOutsideWrapper>
<RefreshPicker
onIntervalChanged={this.onChangeRefreshInterval}
onRefresh={this.onRunQuery}
value={refreshInterval}
tooltip="Refresh"
/>
{refreshInterval && <SetInterval func={this.onRunQuery} interval={refreshInterval} />}
</div>
<div className="explore-toolbar-content-item">
<button className="btn navbar-button navbar-button--no-icon" onClick={this.onClearAll}>
Clear All
......@@ -169,7 +196,14 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps => {
const splitted = state.explore.split;
const exploreItem = state.explore[exploreId];
const { datasourceInstance, datasourceMissing, exploreDatasources, queryTransactions, range } = exploreItem;
const {
datasourceInstance,
datasourceMissing,
exploreDatasources,
queryTransactions,
range,
refreshInterval,
} = exploreItem;
const selectedDatasource = datasourceInstance
? exploreDatasources.find(datasource => datasource.name === datasourceInstance.name)
: undefined;
......@@ -182,13 +216,15 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
range,
selectedDatasource,
splitted,
refreshInterval,
};
};
const mapDispatchToProps: DispatchProps = {
changeDatasource,
changeRefreshInterval,
clearAll: clearQueries,
runQuery: runQueries,
runQueries,
closeSplit: splitClose,
split: splitOpen,
};
......
......@@ -66,10 +66,19 @@ export interface ChangeTimePayload {
range: TimeRange;
}
export interface ChangeRefreshIntervalPayload {
exploreId: ExploreId;
refreshInterval: string;
}
export interface ClearQueriesPayload {
exploreId: ExploreId;
}
export interface ClearRefreshIntervalPayload {
exploreId: ExploreId;
}
export interface HighlightLogsExpressionPayload {
exploreId: ExploreId;
expressions: string[];
......@@ -241,6 +250,13 @@ export const changeSizeAction = actionCreatorFactory<ChangeSizePayload>('explore
export const changeTimeAction = actionCreatorFactory<ChangeTimePayload>('explore/CHANGE_TIME').create();
/**
* Change the time range of Explore. Usually called from the Timepicker or a graph interaction.
*/
export const changeRefreshIntervalAction = actionCreatorFactory<ChangeRefreshIntervalPayload>(
'explore/CHANGE_REFRESH_INTERVAL'
).create();
/**
* Clear all queries and results.
*/
export const clearQueriesAction = actionCreatorFactory<ClearQueriesPayload>('explore/CLEAR_QUERIES').create();
......
......@@ -35,7 +35,12 @@ const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
const eventBridge = {} as Emitter;
const ui = { dedupStrategy: LogsDedupStrategy.none, showingGraph: false, showingLogs: false, showingTable: false };
const range = { from: 'now', to: 'now' };
const urlState: ExploreUrlState = { datasource: 'some-datasource', queries: [], range, ui };
const urlState: ExploreUrlState = {
datasource: 'some-datasource',
queries: [],
range,
ui,
};
const updateDefaults = makeInitialUpdateState();
const update = { ...updateDefaults, ...updateOverides };
const initialState = {
......@@ -50,6 +55,10 @@ const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
queries: [] as DataQuery[],
range,
ui,
refreshInterval: {
label: 'Off',
value: 0,
},
},
},
};
......
......@@ -22,6 +22,7 @@ import {
import { updateLocation } from 'app/core/actions';
// Types
import { ResultGetter } from 'app/types/explore';
import { ThunkResult } from 'app/types';
import {
RawTimeRange,
......@@ -44,6 +45,8 @@ import {
import {
updateDatasourceInstanceAction,
changeQueryAction,
changeRefreshIntervalAction,
ChangeRefreshIntervalPayload,
changeSizeAction,
ChangeSizePayload,
changeTimeAction,
......@@ -164,7 +167,7 @@ export function changeSize(
}
/**
* Change the time range of Explore. Usually called from the Timepicker or a graph interaction.
* Change the time range of Explore. Usually called from the Time picker or a graph interaction.
*/
export function changeTime(exploreId: ExploreId, range: TimeRange): ThunkResult<void> {
return dispatch => {
......@@ -174,6 +177,16 @@ export function changeTime(exploreId: ExploreId, range: TimeRange): ThunkResult<
}
/**
* Change the refresh interval of Explore. Called from the Refresh picker.
*/
export function changeRefreshInterval(
exploreId: ExploreId,
refreshInterval: string
): ActionOf<ChangeRefreshIntervalPayload> {
return changeRefreshIntervalAction({ exploreId, refreshInterval });
}
/**
* Clear all queries and results.
*/
export function clearQueries(exploreId: ExploreId): ThunkResult<void> {
......@@ -526,7 +539,7 @@ export function queryTransactionSuccess(
/**
* Main action to run queries and dispatches sub-actions based on which result viewers are active
*/
export function runQueries(exploreId: ExploreId, ignoreUIState = false): ThunkResult<void> {
export function runQueries(exploreId: ExploreId, ignoreUIState = false): ThunkResult<Promise<any>> {
return (dispatch, getState) => {
const {
datasourceInstance,
......@@ -543,13 +556,13 @@ export function runQueries(exploreId: ExploreId, ignoreUIState = false): ThunkRe
if (datasourceError) {
// let's not run any queries if data source is in a faulty state
return;
return Promise.resolve();
}
if (!hasNonEmptyQuery(queries)) {
dispatch(clearQueriesAction({ exploreId }));
dispatch(stateSave()); // Remember to saves to state and update location
return;
return Promise.resolve();
}
// Some datasource's query builders allow per-query interval limits,
......@@ -558,41 +571,46 @@ export function runQueries(exploreId: ExploreId, ignoreUIState = false): ThunkRe
dispatch(runQueriesAction({ exploreId }));
// Keep table queries first since they need to return quickly
if ((ignoreUIState || showingTable) && supportsTable) {
dispatch(
runQueriesForType(
exploreId,
'Table',
{
interval,
format: 'table',
instant: true,
valueWithRefId: true,
},
(data: any) => data[0]
)
);
}
if ((ignoreUIState || showingGraph) && supportsGraph) {
dispatch(
runQueriesForType(
exploreId,
'Graph',
{
interval,
format: 'time_series',
instant: false,
maxDataPoints: containerWidth,
},
makeTimeSeriesList
)
);
}
if ((ignoreUIState || showingLogs) && supportsLogs) {
dispatch(runQueriesForType(exploreId, 'Logs', { interval, format: 'logs' }));
}
const tableQueriesPromise =
(ignoreUIState || showingTable) && supportsTable
? dispatch(
runQueriesForType(
exploreId,
'Table',
{
interval,
format: 'table',
instant: true,
valueWithRefId: true,
},
(data: any[]) => data[0]
)
)
: undefined;
const typeQueriesPromise =
(ignoreUIState || showingGraph) && supportsGraph
? dispatch(
runQueriesForType(
exploreId,
'Graph',
{
interval,
format: 'time_series',
instant: false,
maxDataPoints: containerWidth,
},
makeTimeSeriesList
)
)
: undefined;
const logsQueriesPromise =
(ignoreUIState || showingLogs) && supportsLogs
? dispatch(runQueriesForType(exploreId, 'Logs', { interval, format: 'logs' }))
: undefined;
dispatch(stateSave());
return Promise.all([tableQueriesPromise, typeQueriesPromise, logsQueriesPromise]);
};
}
......@@ -607,14 +625,13 @@ function runQueriesForType(
exploreId: ExploreId,
resultType: ResultType,
queryOptions: QueryOptions,
resultGetter?: any
resultGetter?: ResultGetter
): ThunkResult<void> {
return async (dispatch, getState) => {
const { datasourceInstance, eventBridge, queries, queryIntervals, range, scanning } = getState().explore[exploreId];
const datasourceId = datasourceInstance.meta.id;
// Run all queries concurrently
queries.forEach(async (query, rowIndex) => {
const queryPromises = queries.map(async (query, rowIndex) => {
const transaction = buildQueryTransaction(
query,
rowIndex,
......@@ -638,6 +655,8 @@ function runQueriesForType(
dispatch(queryTransactionFailure(exploreId, transaction.id, response, datasourceId));
}
});
return Promise.all(queryPromises);
};
}
......@@ -814,7 +833,6 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult<void> {
const { datasource, queries, range, ui } = urlState;
const refreshQueries = queries.map(q => ({ ...q, ...generateEmptyQuery(itemState.queries) }));
const refreshRange = { from: parseTime(range.from), to: parseTime(range.to) };
// need to refresh datasource
if (update.datasource) {
const initialQueries = ensureQueries(queries);
......
......@@ -515,6 +515,44 @@ describe('Explore reducer', () => {
});
});
describe('and refreshInterval differs', () => {
it('then it should return update refreshInterval', () => {
const { initalState, serializedUrlState } = setup();
const expectedState = {
...initalState,
left: {
...initalState.left,
update: {
...initalState.left.update,
refreshInterval: true,
},
},
};
const stateWithDifferentDataSource = {
...initalState,
left: {
...initalState.left,
urlState: {
...initalState.left.urlState,
refreshInterval: '5s',
},
},
};
reducerTester()
.givenReducer(exploreReducer, stateWithDifferentDataSource)
.whenActionIsDispatched(
updateLocation({
query: {
left: serializedUrlState,
},
path: '/explore',
})
)
.thenStateShouldEqual(expectedState);
});
});
describe('and nothing differs', () => {
fit('then it should return update ui', () => {
const { initalState, serializedUrlState } = setup();
......
......@@ -27,6 +27,7 @@ import {
changeQueryAction,
changeSizeAction,
changeTimeAction,
changeRefreshIntervalAction,
clearQueriesAction,
highlightLogsExpressionAction,
initializeExploreAction,
......@@ -67,6 +68,7 @@ export const makeInitialUpdateState = (): ExploreUpdateState => ({
range: false,
ui: false,
});
/**
* Returns a fresh Explore area state
*/
......@@ -101,10 +103,11 @@ export const makeExploreItemState = (): ExploreItemState => ({
/**
* Global Explore state that handles multiple Explore areas and the split state
*/
export const initialExploreItemState = makeExploreItemState();
export const initialExploreState: ExploreState = {
split: null,
left: makeExploreItemState(),
right: makeExploreItemState(),
left: initialExploreItemState,
right: initialExploreItemState,
};
/**
......@@ -176,6 +179,16 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
},
})
.addMapper({
filter: changeRefreshIntervalAction,
mapper: (state, action): ExploreItemState => {
const { refreshInterval } = action.payload;
return {
...state,
refreshInterval: refreshInterval,
};
},
})
.addMapper({
filter: clearQueriesAction,
mapper: (state): ExploreItemState => {
const queries = ensureQueries();
......@@ -580,7 +593,11 @@ export const updateChildRefreshState = (
const urlState = parseUrlState(queryState);
if (!state.urlState || path !== '/explore') {
// we only want to refresh when browser back/forward
return { ...state, urlState, update: { datasource: false, queries: false, range: false, ui: false } };
return {
...state,
urlState,
update: { datasource: false, queries: false, range: false, ui: false },
};
}
const datasource = _.isEqual(urlState ? urlState.datasource : '', state.urlState.datasource) === false;
......
......@@ -84,12 +84,22 @@ exports[`Render when feature toggle editorsCanAdmin is turned off should not ren
autoFocus={false}
backspaceRemovesValue={true}
className="gf-form-select-box__control--menu-right"
components={
Object {
"Group": [Function],
"IndicatorsContainer": [Function],
"MenuList": [Function],
"Option": [Function],
"SingleValue": [Function],
}
}
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={false}
maxMenuHeight={300}
menuIsOpen={false}
onChange={[Function]}
openMenuOnFocus={false}
options={
......@@ -160,12 +170,22 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p
autoFocus={false}
backspaceRemovesValue={true}
className="gf-form-select-box__control--menu-right"
components={
Object {
"Group": [Function],
"IndicatorsContainer": [Function],
"MenuList": [Function],
"Option": [Function],
"SingleValue": [Function],
}
}
isClearable={false}
isDisabled={false}
isLoading={false}
isMulti={false}
isSearchable={false}
maxMenuHeight={300}
menuIsOpen={false}
onChange={[Function]}
openMenuOnFocus={false}
options={
......
......@@ -13,86 +13,88 @@ Array [
>
Aggregation
</label>
<div
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onKeyDown={[Function]}
>
<div>
<div
className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
onKeyDown={[Function]}
>
<div
className="css-0 gf-form-select-box__value-container"
className="css-0 gf-form-select-box__control"
onMouseDown={[Function]}
onTouchEnd={[Function]}
>
<div
className="css-0 gf-form-select-box__placeholder"
>
Select Reducer
</div>
<div
className="css-0"
className="css-0 gf-form-select-box__value-container"
>
<div
className="gf-form-select-box__input"
style={
Object {
"display": "inline-block",
}
}
className="css-0 gf-form-select-box__placeholder"
>
Select Reducer
</div>
<div
className="css-0"
>
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
className="gf-form-select-box__input"
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
"display": "inline-block",
}
}
>
<input
aria-autocomplete="list"
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
disabled={false}
id="react-select-2-input"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
spellCheck="false"
style={
Object {
"background": 0,
"border": 0,
"boxSizing": "content-box",
"color": "inherit",
"fontSize": "inherit",
"opacity": 1,
"outline": 0,
"padding": 0,
"width": "1px",
}
}
tabIndex="0"
type="text"
value=""
/>
<div
style={
Object {
"height": 0,
"left": 0,
"overflow": "scroll",
"position": "absolute",
"top": 0,
"visibility": "hidden",
"whiteSpace": "pre",
}
}
>
</div>
</div>
</div>
</div>
</div>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
<div
className="css-0 gf-form-select-box__indicators"
>
<span
className="gf-form-select-box__select-arrow "
/>
</div>
</div>
</div>
</div>
......
......@@ -250,6 +250,11 @@ export interface ExploreItemState {
*/
hiddenLogLevels?: LogLevel[];
/**
* How often query should be refreshed
*/
refreshInterval?: string;
urlState: ExploreUrlState;
update: ExploreUpdateState;
......
......@@ -174,6 +174,7 @@ $zindex-tooltip: 1030;
$zindex-modal-backdrop: 1040;
$zindex-modal: 1050;
$zindex-typeahead: 1060;
$zindex-timepicker-popover: 1070;
// Buttons
//
......
......@@ -50,6 +50,16 @@
opacity: 0.65;
@include box-shadow(none);
}
&--radius-left-0 {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
&--radius-right-0 {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
// Button Sizes
......
......@@ -29,6 +29,7 @@
.navbar-button--share,
.navbar-button--settings,
.navbar-page-btn .fa-caret-down,
.refresh-picker,
.gf-timepicker-nav {
display: none;
}
......@@ -135,6 +136,16 @@
}
}
&--refresh {
padding-left: 8px;
padding-right: 8px;
}
&--attached {
margin-left: 0;
border-radius: 0 2px 2px 0;
}
&--tight {
padding: 7px 4px;
......
......@@ -7967,6 +7967,13 @@ get-stream@^4.0.0, get-stream@^4.1.0:
dependencies:
pump "^3.0.0"
get-user-locale@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/get-user-locale/-/get-user-locale-1.1.1.tgz#edff0a8bbd6aa3ed0ca30cc441e1acd111543b7f"
integrity sha512-KuA+vMhsY+rSPK8hrmOvf7xXIMTs+L06RkgZ83jawZHSEqPLafZtQ63d3waXW3r8z6EQ49I/trraNncWM+s/2g==
dependencies:
lodash.once "^4.1.1"
get-value@^2.0.3, get-value@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
......@@ -11229,6 +11236,11 @@ meow@^3.3.0, meow@^3.7.0:
redent "^1.0.0"
trim-newlines "^1.0.0"
merge-class-names@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/merge-class-names/-/merge-class-names-1.1.1.tgz#3bd2f38eb5418c464a0fef615484fdf6c8932256"
integrity sha512-+UUWBUoFw9QLY/UlBKU/xk9h6OhyG3BUDDuF2eIJcxmusWb/uedvNpZGkysqMw5b/ds+wkX7NJTDSdUuRsCNyA==
merge-deep@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/merge-deep/-/merge-deep-3.0.2.tgz#f39fa100a4f1bd34ff29f7d2bf4508fbb8d83ad2"
......@@ -13930,6 +13942,16 @@ react-addons-create-fragment@^15.5.3:
loose-envify "^1.3.1"
object-assign "^4.1.0"
react-calendar@^2.18.1:
version "2.18.1"
resolved "https://registry.yarnpkg.com/react-calendar/-/react-calendar-2.18.1.tgz#f8ef9468d8566aa0d47d9d70c88917bb2030bcb9"
integrity sha512-J3tVim1gLpnsCOaeez+z4QJB5oK6UYLJj5TSMOStSJBvkWMEcTzj7bq7yCJJCNLUg2Vd3i11gJXish0LUFhXaw==
dependencies:
get-user-locale "^1.1.1"
merge-class-names "^1.1.1"
prop-types "^15.6.0"
react-lifecycles-compat "^3.0.4"
react-clientside-effect@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.0.tgz#db823695f75e9616a5e4dd6d908e5ea627fb2516"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment