mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
committed by
Torkel Ödegaard
parent
d23f50ab23
commit
406ef962fc
@@ -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)) {
|
||||
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
});
|
||||
@@ -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<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<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>
|
||||
);
|
||||
});
|
||||
88
packages/grafana-ui/src/components/Select/ButtonSelect.tsx
Normal file
88
packages/grafana-ui/src/components/Select/ButtonSelect.tsx
Normal file
@@ -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 (
|
||||
<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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
300
packages/grafana-ui/src/components/TimePicker/TimePicker.tsx
Normal file
300
packages/grafana-ui/src/components/TimePicker/TimePicker.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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>
|
||||
));
|
||||
@@ -0,0 +1,46 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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' : ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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', () => (
|
||||
<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}
|
||||
/>
|
||||
));
|
||||
@@ -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<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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', () => (
|
||||
<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>
|
||||
));
|
||||
@@ -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<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
189
packages/grafana-ui/src/components/TimePicker/_TimePicker.scss
Normal file
189
packages/grafana-ui/src/components/TimePicker/_TimePicker.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
44
packages/grafana-ui/src/components/TimePicker/time.ts
Normal file
44
packages/grafana-ui/src/components/TimePicker/time.ts
Normal file
@@ -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);
|
||||
@@ -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';
|
||||
|
||||
8
packages/grafana-ui/src/utils/object.ts
Normal file
8
packages/grafana-ui/src/utils/object.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const objRemoveUndefined = (obj: any) => {
|
||||
return Object.keys(obj).reduce((acc: any, key) => {
|
||||
if (obj[key] !== undefined) {
|
||||
acc[key] = obj[key];
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
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>;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ exports[`TeamPicker renders correctly 1`] = `
|
||||
<div
|
||||
className="user-picker"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="css-0 gf-form-input gf-form-input--form-dropdown"
|
||||
onKeyDown={[Function]}
|
||||
@@ -88,4 +89,5 @@ exports[`TeamPicker renders correctly 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -4,6 +4,7 @@ exports[`UserPicker renders correctly 1`] = `
|
||||
<div
|
||||
className="user-picker"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="css-0 gf-form-input gf-form-input--form-dropdown"
|
||||
onKeyDown={[Function]}
|
||||
@@ -88,4 +89,5 @@ exports[`UserPicker renders correctly 1`] = `
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
{!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,
|
||||
|
||||
@@ -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<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 };
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<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>
|
||||
@@ -7,7 +6,7 @@
|
||||
<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"> Refresh every {{ctrl.dashboard.refresh}}</span>
|
||||
<!-- <span ng-show="ctrl.dashboard.refresh" class="text-warning"> Refresh every {{ctrl.dashboard.refresh}}</span> -->
|
||||
</button>
|
||||
|
||||
<button class="btn navbar-button navbar-button--tight" ng-click='ctrl.move(1)' ng-if="ctrl.isAbsolute">
|
||||
@@ -18,10 +17,9 @@
|
||||
<i class="fa fa-search-minus"></i>
|
||||
</button>
|
||||
|
||||
<button class="btn navbar-button navbar-button--refresh" ng-click="ctrl.timeSrv.refreshDashboard()">
|
||||
<!-- <button class="btn navbar-button navbar-button--refresh" ng-click="ctrl.timeSrv.refreshDashboard()">
|
||||
<i class="fa fa-refresh"></i>
|
||||
</button>
|
||||
</div>
|
||||
</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,7 +136,8 @@ export class TimeSrv {
|
||||
);
|
||||
}
|
||||
|
||||
// update url
|
||||
// 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;
|
||||
@@ -144,6 +146,7 @@ export class TimeSrv {
|
||||
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[];
|
||||
@@ -240,6 +249,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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
@@ -173,6 +176,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.
|
||||
*/
|
||||
@@ -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,8 +571,9 @@ 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(
|
||||
const tableQueriesPromise =
|
||||
(ignoreUIState || showingTable) && supportsTable
|
||||
? dispatch(
|
||||
runQueriesForType(
|
||||
exploreId,
|
||||
'Table',
|
||||
@@ -569,12 +583,13 @@ export function runQueries(exploreId: ExploreId, ignoreUIState = false): ThunkRe
|
||||
instant: true,
|
||||
valueWithRefId: true,
|
||||
},
|
||||
(data: any) => data[0]
|
||||
(data: any[]) => data[0]
|
||||
)
|
||||
);
|
||||
}
|
||||
if ((ignoreUIState || showingGraph) && supportsGraph) {
|
||||
dispatch(
|
||||
)
|
||||
: undefined;
|
||||
const typeQueriesPromise =
|
||||
(ignoreUIState || showingGraph) && supportsGraph
|
||||
? dispatch(
|
||||
runQueriesForType(
|
||||
exploreId,
|
||||
'Graph',
|
||||
@@ -586,13 +601,16 @@ export function runQueries(exploreId: ExploreId, ignoreUIState = false): ThunkRe
|
||||
},
|
||||
makeTimeSeriesList
|
||||
)
|
||||
);
|
||||
}
|
||||
if ((ignoreUIState || showingLogs) && supportsLogs) {
|
||||
dispatch(runQueriesForType(exploreId, 'Logs', { interval, format: 'logs' }));
|
||||
}
|
||||
)
|
||||
: 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,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -175,6 +178,16 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
|
||||
return { ...state, range: action.payload.range };
|
||||
},
|
||||
})
|
||||
.addMapper({
|
||||
filter: changeRefreshIntervalAction,
|
||||
mapper: (state, action): ExploreItemState => {
|
||||
const { refreshInterval } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
refreshInterval: refreshInterval,
|
||||
};
|
||||
},
|
||||
})
|
||||
.addMapper({
|
||||
filter: clearQueriesAction,
|
||||
mapper: (state): ExploreItemState => {
|
||||
@@ -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,6 +13,7 @@ Array [
|
||||
>
|
||||
Aggregation
|
||||
</label>
|
||||
<div>
|
||||
<div
|
||||
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
|
||||
onKeyDown={[Function]}
|
||||
@@ -97,6 +98,7 @@ Array [
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
|
||||
@@ -13,6 +13,7 @@ Array [
|
||||
>
|
||||
Service
|
||||
</span>
|
||||
<div>
|
||||
<div
|
||||
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
|
||||
onKeyDown={[Function]}
|
||||
@@ -52,6 +53,7 @@ Array [
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
@@ -71,6 +73,7 @@ Array [
|
||||
>
|
||||
Metric
|
||||
</span>
|
||||
<div>
|
||||
<div
|
||||
className="css-0 gf-form-input gf-form-input--form-dropdown width-26"
|
||||
onKeyDown={[Function]}
|
||||
@@ -155,6 +158,7 @@ Array [
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
@@ -181,6 +185,7 @@ Array [
|
||||
>
|
||||
Aggregation
|
||||
</label>
|
||||
<div>
|
||||
<div
|
||||
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
|
||||
onKeyDown={[Function]}
|
||||
@@ -265,6 +270,7 @@ Array [
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
@@ -293,6 +299,7 @@ Array [
|
||||
>
|
||||
Alignment Period
|
||||
</label>
|
||||
<div>
|
||||
<div
|
||||
className="css-0 gf-form-input gf-form-input--form-dropdown width-15"
|
||||
onKeyDown={[Function]}
|
||||
@@ -381,6 +388,7 @@ Array [
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="gf-form gf-form--grow"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
22
yarn.lock
22
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user