TimePicker: New time picker dropdown & custom range UI (#16811)

* feat: Add new picker to DashNavTimeControls

* chore: noImplicitAny limit reached

* chore: noImplicityAny fix

* chore: Add momentUtc helper to avoid the isUtc conditionals

* chore: Move getRaw from Explore's time picker to grafana/ui utils and rename to getRawRange

* feat: Use helper functions to convert utc to browser time

* fix: Dont Select current value when pressing tab when using Time Picker

* fix: Add tabIndex to time range inputs so tab works smoothly and prevent mouseDown event to propagate to react-select

* fix: Add spacing to custom range labels

* fix: Updated snapshot

* fix: Re-adding getRaw() temporary to fix the build

* fix: Disable scroll event in Popper when we're using the TimePicker so the popup wont "follow" the menu

* fix: Move all "Last xxxx" quick ranges to the menu and show a "UTC" text when applicable

* fix: Add zoom functionality

* feat: Add logic to mark selected option as active

* fix: Add tooltip to zoom button

* fix: lint fix after rebase

* chore: Remove old time picker from DashNav

* TimePicker: minor design update

* chore: Move all time picker quick ranges to the menu

* fix: Remove the popover border-right, since the quick ranges are gone

* chore: Remove function not in use

* Fix: Close time picker on resize event

* Fix: Remove border bottom

* Fix: Use fa icons on prev/next arrows

* Fix: Pass ref from TimePicker to TimePickerOptionGroup so the popover will align as it should

* Fix: time picker ui adjustments to get better touch area on buttons

* Fix: Dont increase line height on large screens

* TimePicker: style updates

* Fix: Add more prominent colors for selected dates and fade out dates in previous/next month

* TimePicker: style updates2

* TimePicker: Big refactorings and style changes

* Removed use of Popper not sure we need that here?
* Made active selected item in the list have the "selected" checkmark
* Changed design of popover
* Changed design of and implementation of the Custom selection in the dropdown it did not feel like a item you
could select like the rest now the list is just a normal list

* TimePicker: Refactoring & style changes

* TimePicker: use same date format everywhere

* TimePicker: Calendar style updates

* TimePicker: fixed unit test

* fixed unit test

* TimeZone: refactoring time zone type

* TimePicker: refactoring

* TimePicker: finally to UTC to work

* TimePicker: better way to handle calendar utc dates

* TimePicker: Fixed tooltip issues

* Updated snapshot

* TimePicker: moved tooltip from DashNavControls into TimePicker
This commit is contained in:
Johannes Schill 2019-06-24 14:39:59 +02:00 committed by Torkel Ödegaard
parent 0adbb001db
commit 0412a28d2e
47 changed files with 734 additions and 1819 deletions

View File

@ -1,9 +1,9 @@
import React, { PureComponent } from 'react';
import React, { PureComponent, ReactElement } from 'react';
import Select, { SelectOptionItem } from './Select';
import { PopperContent } from '../Tooltip/PopperController';
interface ButtonComponentProps {
label: string | undefined;
label: ReactElement | string | undefined;
className: string | undefined;
iconClass?: string;
}
@ -21,7 +21,8 @@ const ButtonComponent = (buttonProps: ButtonComponentProps) => (props: any) => {
<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" />
{!props.menuIsOpen && <i className="fa fa-caret-down fa-fw" />}
{props.menuIsOpen && <i className="fa fa-caret-up fa-fw" />}
</div>
</button>
);
@ -30,8 +31,8 @@ const ButtonComponent = (buttonProps: ButtonComponentProps) => (props: any) => {
export interface Props<T> {
className: string | undefined;
options: Array<SelectOptionItem<T>>;
value: SelectOptionItem<T>;
label?: string;
value?: SelectOptionItem<T>;
label?: ReactElement | string;
iconClass?: string;
components?: any;
maxMenuHeight?: number;
@ -40,6 +41,7 @@ export interface Props<T> {
isMenuOpen?: boolean;
onOpenMenu?: () => void;
onCloseMenu?: () => void;
tabSelectsValue?: boolean;
}
export class ButtonSelect<T> extends PureComponent<Props<T>> {
@ -61,6 +63,7 @@ export class ButtonSelect<T> extends PureComponent<Props<T>> {
isMenuOpen,
onOpenMenu,
onCloseMenu,
tabSelectsValue,
} = this.props;
const combinedComponents = {
...components,
@ -75,13 +78,14 @@ export class ButtonSelect<T> extends PureComponent<Props<T>> {
options={options}
onChange={this.onChange}
value={value}
isOpen={isMenuOpen}
onOpenMenu={onOpenMenu}
onCloseMenu={onCloseMenu}
maxMenuHeight={maxMenuHeight}
components={combinedComponents}
className="gf-form-select-box-button-select"
tooltipContent={tooltipContent}
isOpen={isMenuOpen}
onOpenMenu={onOpenMenu}
onCloseMenu={onCloseMenu}
tabSelectsValue={tabSelectsValue}
/>
);
}

View File

@ -53,6 +53,7 @@ export interface CommonProps<T> {
tooltipContent?: PopperContent<any>;
onOpenMenu?: () => void;
onCloseMenu?: () => void;
tabSelectsValue?: boolean;
}
export interface SelectProps<T> extends CommonProps<T> {
@ -65,26 +66,6 @@ interface AsyncProps<T> extends CommonProps<T> {
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}>
@ -107,6 +88,7 @@ export class Select<T> extends PureComponent<SelectProps<T>> {
isLoading: false,
backspaceRemovesValue: true,
maxMenuHeight: 300,
tabSelectsValue: true,
components: {
Option: SelectOption,
SingleValue,
@ -116,20 +98,6 @@ export class Select<T> extends PureComponent<SelectProps<T>> {
},
};
onOpenMenu = () => {
const { onOpenMenu } = this.props;
if (onOpenMenu) {
onOpenMenu();
}
};
onCloseMenu = () => {
const { onCloseMenu } = this.props;
if (onCloseMenu) {
onCloseMenu();
}
};
render() {
const {
defaultValue,
@ -155,6 +123,9 @@ export class Select<T> extends PureComponent<SelectProps<T>> {
isOpen,
components,
tooltipContent,
tabSelectsValue,
onCloseMenu,
onOpenMenu,
} = this.props;
let widthClass = '';
@ -164,37 +135,43 @@ export class Select<T> extends PureComponent<SelectProps<T>> {
const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className);
const selectComponents = { ...Select.defaultProps.components, ...components };
return wrapInTooltip(
<ReactSelect
classNamePrefix="gf-form-select-box"
className={selectClassNames}
components={selectComponents}
defaultValue={defaultValue}
value={value}
getOptionLabel={getOptionLabel}
getOptionValue={getOptionValue}
menuShouldScrollIntoView={false}
isSearchable={isSearchable}
onChange={onChange}
options={options}
placeholder={placeholder || 'Choose'}
styles={resetSelectStyles()}
isDisabled={isDisabled}
isLoading={isLoading}
isClearable={isClearable}
autoFocus={autoFocus}
onBlur={onBlur}
openMenuOnFocus={openMenuOnFocus}
maxMenuHeight={maxMenuHeight}
noOptionsMessage={noOptionsMessage}
isMulti={isMulti}
backspaceRemovesValue={backspaceRemovesValue}
menuIsOpen={isOpen}
onMenuOpen={this.onOpenMenu}
onMenuClose={this.onCloseMenu}
/>,
tooltipContent,
isOpen
return (
<WrapInTooltip onCloseMenu={onCloseMenu} onOpenMenu={onOpenMenu} tooltipContent={tooltipContent} isOpen={isOpen}>
{(onOpenMenuInternal, onCloseMenuInternal) => {
return (
<ReactSelect
classNamePrefix="gf-form-select-box"
className={selectClassNames}
components={selectComponents}
defaultValue={defaultValue}
value={value}
getOptionLabel={getOptionLabel}
getOptionValue={getOptionValue}
menuShouldScrollIntoView={false}
isSearchable={isSearchable}
onChange={onChange}
options={options}
placeholder={placeholder || 'Choose'}
styles={resetSelectStyles()}
isDisabled={isDisabled}
isLoading={isLoading}
isClearable={isClearable}
autoFocus={autoFocus}
onBlur={onBlur}
openMenuOnFocus={openMenuOnFocus}
maxMenuHeight={maxMenuHeight}
noOptionsMessage={noOptionsMessage}
isMulti={isMulti}
backspaceRemovesValue={backspaceRemovesValue}
menuIsOpen={isOpen}
onMenuOpen={onOpenMenuInternal}
onMenuClose={onCloseMenuInternal}
tabSelectsValue={tabSelectsValue}
/>
);
}}
</WrapInTooltip>
);
}
}
@ -239,6 +216,9 @@ export class AsyncSelect<T> extends PureComponent<AsyncProps<T>> {
maxMenuHeight,
isMulti,
tooltipContent,
onCloseMenu,
onOpenMenu,
isOpen,
} = this.props;
let widthClass = '';
@ -248,43 +228,105 @@ export class AsyncSelect<T> extends PureComponent<AsyncProps<T>> {
const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className);
return wrapInTooltip(
<ReactAsyncSelect
classNamePrefix="gf-form-select-box"
className={selectClassNames}
components={{
Option: SelectOption,
SingleValue,
IndicatorsContainer,
NoOptionsMessage,
return (
<WrapInTooltip onCloseMenu={onCloseMenu} onOpenMenu={onOpenMenu} tooltipContent={tooltipContent} isOpen={isOpen}>
{(onOpenMenuInternal, onCloseMenuInternal) => {
return (
<ReactAsyncSelect
classNamePrefix="gf-form-select-box"
className={selectClassNames}
components={{
Option: SelectOption,
SingleValue,
IndicatorsContainer,
NoOptionsMessage,
}}
defaultValue={defaultValue}
value={value}
getOptionLabel={getOptionLabel}
getOptionValue={getOptionValue}
menuShouldScrollIntoView={false}
onChange={onChange}
loadOptions={loadOptions}
isLoading={isLoading}
defaultOptions={defaultOptions}
placeholder={placeholder || 'Choose'}
styles={resetSelectStyles()}
loadingMessage={loadingMessage}
noOptionsMessage={noOptionsMessage}
isDisabled={isDisabled}
isSearchable={isSearchable}
isClearable={isClearable}
autoFocus={autoFocus}
onBlur={onBlur}
openMenuOnFocus={openMenuOnFocus}
maxMenuHeight={maxMenuHeight}
isMulti={isMulti}
backspaceRemovesValue={backspaceRemovesValue}
/>
);
}}
defaultValue={defaultValue}
value={value}
getOptionLabel={getOptionLabel}
getOptionValue={getOptionValue}
menuShouldScrollIntoView={false}
onChange={onChange}
loadOptions={loadOptions}
isLoading={isLoading}
defaultOptions={defaultOptions}
placeholder={placeholder || 'Choose'}
styles={resetSelectStyles()}
loadingMessage={loadingMessage}
noOptionsMessage={noOptionsMessage}
isDisabled={isDisabled}
isSearchable={isSearchable}
isClearable={isClearable}
autoFocus={autoFocus}
onBlur={onBlur}
openMenuOnFocus={openMenuOnFocus}
maxMenuHeight={maxMenuHeight}
isMulti={isMulti}
backspaceRemovesValue={backspaceRemovesValue}
/>,
tooltipContent,
false
</WrapInTooltip>
);
}
}
export interface TooltipWrapperProps {
children: (onOpenMenu: () => void, onCloseMenu: () => void) => React.ReactNode;
onOpenMenu?: () => void;
onCloseMenu?: () => void;
isOpen?: boolean;
tooltipContent?: PopperContent<any>;
}
export interface TooltipWrapperState {
isOpenInternal: boolean;
}
export class WrapInTooltip extends PureComponent<TooltipWrapperProps, TooltipWrapperState> {
state: TooltipWrapperState = {
isOpenInternal: false,
};
onOpenMenu = () => {
const { onOpenMenu } = this.props;
if (onOpenMenu) {
onOpenMenu();
}
this.setState({ isOpenInternal: true });
};
onCloseMenu = () => {
const { onCloseMenu } = this.props;
if (onCloseMenu) {
onCloseMenu();
}
this.setState({ isOpenInternal: false });
};
render() {
const { children, isOpen, tooltipContent } = this.props;
const { isOpenInternal } = this.state;
let showTooltip: boolean | undefined = undefined;
if (isOpenInternal || isOpen) {
showTooltip = false;
}
if (tooltipContent) {
return (
<Tooltip show={showTooltip} content={tooltipContent} placement="bottom">
<div>
{/* div needed for tooltip */}
{children(this.onOpenMenu, this.onCloseMenu)}
</div>
</Tooltip>
);
} else {
return <div>{children(this.onOpenMenu, this.onCloseMenu)}</div>;
}
}
}
export default Select;

View File

@ -53,7 +53,7 @@ $select-input-bg-disabled: $input-bg-disabled;
}
.gf-form-select-box__menu {
background: $input-bg;
background: $menu-dropdown-bg;
box-shadow: $menu-dropdown-shadow;
position: absolute;
z-index: $zindex-dropdown;

View File

@ -9,168 +9,6 @@ import { TimeFragment } from '../../types/time';
import { dateTime } from '../../utils/moment_wrapper';
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);
@ -186,20 +24,18 @@ TimePickerStories.add('default', () => {
{(value, updateValue) => {
return (
<TimePicker
isTimezoneUtc={false}
timeZone="browser"
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 },
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3 },
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3 },
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3 },
{ from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3 },
{ from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3 },
{ from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3 },
{ from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3 },
{ from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3 },
]}
popoverOptions={popoverOptions}
onChange={timeRange => {
action('onChange fired')(timeRange);
updateValue(timeRange);

View File

@ -1,275 +1,142 @@
import React, { PureComponent } from 'react';
// Libraries
import React, { PureComponent, createRef } from 'react';
// Components
import { ButtonSelect } from '../Select/ButtonSelect';
import { mapTimeOptionToTimeRange, mapTimeRangeToRangeString } from './time';
import { Props as TimePickerPopoverProps } from './TimePickerPopover';
import { TimePickerOptionGroup } from './TimePickerOptionGroup';
import { PopperContent } from '../Tooltip/PopperController';
import { Timezone } from '../../utils/datemath';
import { TimeRange, TimeOption, TimeOptions } from '../../types/time';
import { SelectOptionItem } from '../Select/Select';
import { Tooltip } from '../Tooltip/Tooltip';
import { TimePickerPopover } from './TimePickerPopover';
import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper';
// Utils & Services
import { isDateTime } from '../../utils/moment_wrapper';
import * as rangeUtil from '../../utils/rangeutil';
import { rawToTimeRange } from './time';
// Types
import { TimeRange, TimeOption, TimeZone, TIME_FORMAT } from '../../types/time';
import { SelectOptionItem } from '../Select/Select';
export interface Props {
value: TimeRange;
isTimezoneUtc: boolean;
popoverOptions: TimeOptions;
selectOptions: TimeOption[];
timezone?: Timezone;
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 },
export const defaultSelectOptions: TimeOption[] = [
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3 },
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3 },
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3 },
{ from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3 },
{ from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3 },
{ from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3 },
{ from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3 },
{ from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3 },
{ from: 'now-2d', to: 'now', display: 'Last 2 days', section: 3 },
{ from: 'now-7d', to: 'now', display: 'Last 7 days', section: 3 },
{ from: 'now-30d', to: 'now', display: 'Last 30 days', section: 3 },
{ from: 'now-90d', to: 'now', display: 'Last 90 days', section: 3 },
{ from: 'now-6M', to: 'now', display: 'Last 6 months', section: 3 },
{ from: 'now-1y', to: 'now', display: 'Last 1 year', section: 3 },
{ from: 'now-2y', to: 'now', display: 'Last 2 years', section: 3 },
{ from: 'now-5y', to: 'now', display: 'Last 5 years', section: 3 },
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday', section: 3 },
{ from: 'now-2d/d', to: 'now-2d/d', display: 'Day before yesterday', section: 3 },
{ from: 'now-7d/d', to: 'now-7d/d', display: 'This day last week', section: 3 },
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week', section: 3 },
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month', section: 3 },
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year', section: 3 },
{ from: 'now/d', to: 'now/d', display: 'Today', section: 3 },
{ from: 'now/d', to: 'now', display: 'Today so far', section: 3 },
{ from: 'now/w', to: 'now/w', display: 'This week', section: 3 },
{ from: 'now/w', to: 'now', display: 'This week so far', section: 3 },
{ from: 'now/M', to: 'now/M', display: 'This month', section: 3 },
{ from: 'now/M', to: 'now', display: 'This month so far', section: 3 },
{ from: 'now/y', to: 'now/y', display: 'This year', section: 3 },
{ from: 'now/y', to: 'now', display: 'This year so far', section: 3 },
];
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,
},
],
const defaultZoomOutTooltip = () => {
return (
<>
Time range zoom out <br /> CTRL+Z
</>
);
};
export interface State {
isMenuOpen: boolean;
isCustomOpen: boolean;
}
export class TimePicker extends PureComponent<Props, State> {
static defaultSelectOptions = defaultSelectOptions;
static defaultPopoverOptions = defaultPopoverOptions;
pickerTriggerRef = createRef<HTMLDivElement>();
state: State = {
isMenuOpen: false,
isCustomOpen: false,
};
mapTimeOptionsToSelectOptionItems = (selectOptions: TimeOption[]) => {
const { value, popoverOptions, isTimezoneUtc, timezone } = this.props;
const options = selectOptions.map(timeOption => {
return { label: timeOption.display, value: timeOption };
return {
label: timeOption.display,
value: timeOption,
};
});
const popoverProps: TimePickerPopoverProps = {
value,
options: popoverOptions,
isTimezoneUtc,
timezone,
};
options.unshift({
label: 'Custom time range',
value: { from: 'custom', to: 'custom', display: 'Custom', section: 1 },
});
return [
{
label: 'Custom',
expanded: true,
options,
onPopoverOpen: () => undefined,
onPopoverClose: (timeRange: TimeRange) => this.onPopoverClose(timeRange),
popoverProps,
},
];
return options;
};
onSelectChanged = (item: SelectOptionItem<TimeOption>) => {
const { isTimezoneUtc, onChange, timezone } = this.props;
const { onChange, timeZone } = this.props;
// @ts-ignore
onChange(mapTimeOptionToTimeRange(item.value, isTimezoneUtc, timezone));
if (item.value && item.value.from === 'custom') {
// this is to prevent the ClickOutsideWrapper from directly closing the popover
setTimeout(() => {
this.setState({ isCustomOpen: true });
}, 1);
return;
}
if (item.value) {
onChange(rawToTimeRange({ from: item.value.from, to: item.value.to }, timeZone));
}
};
onChangeMenuOpenState = (isOpen: boolean) => {
this.setState({
isMenuOpen: isOpen,
});
};
onOpenMenu = () => this.onChangeMenuOpenState(true);
onCloseMenu = () => this.onChangeMenuOpenState(false);
onPopoverClose = (timeRange: TimeRange) => {
onCustomChange = (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();
this.setState({ isCustomOpen: false });
};
onCloseCustom = () => {
this.setState({ isCustomOpen: false });
};
render() {
const {
selectOptions: selectTimeOptions,
value,
onMoveBackward,
onMoveForward,
onZoom,
tooltipContent,
} = this.props;
const { selectOptions: selectTimeOptions, value, onMoveBackward, onMoveForward, onZoom, timeZone } = this.props;
const { isCustomOpen } = this.state;
const options = this.mapTimeOptionsToSelectOptionItems(selectTimeOptions);
const rangeString = mapTimeRangeToRangeString(value);
const currentOption = options.find(item => isTimeOptionEqualToTimeRange(item.value, value));
const rangeString = rangeUtil.describeTimeRange(value.raw);
const label = (
<>
{isCustomOpen && <span>Custom time range</span>}
{!isCustomOpen && <span>{rangeString}</span>}
{timeZone === 'utc' && <span className="time-picker-utc">UTC</span>}
</>
);
const isAbsolute = isDateTime(value.raw.to);
return (
<div className="time-picker">
<div className="time-picker" ref={this.pickerTriggerRef}>
<div className="time-picker-buttons">
{isAbsolute && (
<button className="btn navbar-button navbar-button--tight" onClick={onMoveBackward}>
@ -278,27 +145,46 @@ export class TimePicker extends PureComponent<Props, State> {
)}
<ButtonSelect
className="time-picker-button-select"
value={value}
label={rangeString}
value={currentOption}
label={label}
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}
tooltipContent={<TimePickerTooltipContent timeRange={value} />}
/>
{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>
<Tooltip content={defaultZoomOutTooltip} placement="bottom">
<button className="btn navbar-button navbar-button--zoom" onClick={onZoom}>
<i className="fa fa-search-minus" />
</button>
</Tooltip>
{isCustomOpen && (
<ClickOutsideWrapper onClick={this.onCloseCustom}>
<TimePickerPopover value={value} timeZone={timeZone} onChange={this.onCustomChange} />
</ClickOutsideWrapper>
)}
</div>
</div>
);
}
}
const TimePickerTooltipContent = ({ timeRange }: { timeRange: TimeRange }) => (
<>
{timeRange.from.format(TIME_FORMAT)}
<br />
to
<br />
{timeRange.to.format(TIME_FORMAT)}
</>
);
function isTimeOptionEqualToTimeRange(option: TimeOption, range: TimeRange): boolean {
return range.raw.from === option.from && range.raw.to === option.to;
}

View File

@ -16,7 +16,7 @@ TimePickerCalendarStories.add('default', () => (
{(value, updateValue) => {
return (
<TimePickerCalendar
isTimezoneUtc={false}
timeZone="browser"
value={value}
onChange={timeRange => {
action('onChange fired')(timeRange);

View File

@ -1,45 +1,51 @@
import React, { PureComponent } from 'react';
import Calendar from 'react-calendar/dist/entry.nostyle';
import { TimeFragment } from '../../types/time';
import { Timezone } from '../../utils/datemath';
import { DateTime, dateTime, isDateTime } from '../../utils/moment_wrapper';
import { TimeFragment, TimeZone, TIME_FORMAT } from '../../types/time';
import { DateTime, dateTime, toUtc } from '../../utils/moment_wrapper';
import { stringToDateTimeType } from './time';
export interface Props {
value: TimeFragment;
isTimezoneUtc: boolean;
roundup?: boolean;
timezone?: Timezone;
timeZone?: TimeZone;
onChange: (value: DateTime) => void;
}
export class TimePickerCalendar extends PureComponent<Props> {
onCalendarChange = (date: Date | Date[]) => {
const { onChange } = this.props;
const { onChange, timeZone } = this.props;
if (Array.isArray(date)) {
return;
}
onChange(dateTime(date));
let newDate = dateTime(date);
if (timeZone === 'utc') {
newDate = toUtc(newDate.format(TIME_FORMAT));
}
onChange(newDate);
};
render() {
const { value, isTimezoneUtc, roundup, timezone } = this.props;
const dateValue = isDateTime(value)
? value.toDate()
: stringToDateTimeType(value, isTimezoneUtc, roundup, timezone).toDate();
const calendarValue = dateValue instanceof Date && !isNaN(dateValue.getTime()) ? dateValue : dateTime().toDate();
const { value, roundup, timeZone } = this.props;
let date = stringToDateTimeType(value, roundup, timeZone);
if (!date.isValid()) {
date = dateTime();
}
return (
<Calendar
value={calendarValue}
value={date.toDate()}
next2Label={null}
prev2Label={null}
className="time-picker-calendar"
tileClassName="time-picker-calendar-tile"
onChange={this.onCalendarChange}
nextLabel={<span className="fa fa-angle-right" />}
prevLabel={<span className="fa fa-angle-left" />}
/>
);
}

View File

@ -1,27 +1,27 @@
import React, { PureComponent, ChangeEvent } from 'react';
import { TimeFragment, TIME_FORMAT } from '../../types/time';
import { TimeFragment, TIME_FORMAT, TimeZone } from '../../types/time';
import { Input } from '../Input/Input';
import { stringToDateTimeType, isValidTimeString } from './time';
import { isDateTime } from '../../utils/moment_wrapper';
export interface Props {
value: TimeFragment;
isTimezoneUtc: boolean;
roundup?: boolean;
timezone?: string;
timeZone?: TimeZone;
onChange: (value: string, isValid: boolean) => void;
tabIndex?: number;
}
export class TimePickerInput extends PureComponent<Props> {
isValid = (value: string) => {
const { isTimezoneUtc } = this.props;
const { timeZone, roundup } = this.props;
if (value.indexOf('now') !== -1) {
const isValid = isValidTimeString(value);
return isValid;
}
const parsed = stringToDateTimeType(value, isTimezoneUtc);
const parsed = stringToDateTimeType(value, roundup, timeZone);
const isValid = parsed.isValid();
return isValid;
};
@ -42,7 +42,7 @@ export class TimePickerInput extends PureComponent<Props> {
};
render() {
const { value } = this.props;
const { value, tabIndex } = this.props;
const valueString = this.valueToString(value);
const error = !this.isValid(valueString);
@ -54,6 +54,7 @@ export class TimePickerInput extends PureComponent<Props> {
hideErrorMessage={true}
value={valueString}
className={`time-picker-input${error ? '-error' : ''}`}
tabIndex={tabIndex}
/>
);
}

View File

@ -1,51 +0,0 @@
import React, { ComponentType } from 'react';
import { storiesOf } from '@storybook/react';
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';
import { dateTime } from '../../utils/moment_wrapper';
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: dateTime(), to: dateTime(), 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}
/>
));

View File

@ -1,66 +0,0 @@
import React, { PureComponent, createRef } from 'react';
import { GroupProps } from 'react-select/lib/components/Group';
import { Props as TimePickerProps, TimePickerPopover } from './TimePickerPopover';
import { TimeRange } from '../../types/time';
import { Popper } from '../Tooltip/Popper';
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>
</>
);
}
}

View File

@ -5,7 +5,6 @@ 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';
import { dateTime, DateTime } from '../../utils/moment_wrapper';
const TimePickerPopoverStories = storiesOf('UI/TimePicker/TimePickerPopover', module);
@ -24,12 +23,11 @@ TimePickerPopoverStories.add('default', () => (
return (
<TimePickerPopover
value={value}
isTimezoneUtc={false}
timeZone="browser"
onChange={timeRange => {
action('onChange fired')(timeRange);
updateValue(timeRange);
}}
options={popoverOptions}
/>
);
}}

View File

@ -1,167 +1,118 @@
import React, { Component, SyntheticEvent } from 'react';
import { TimeRange, TimeOptions, TimeOption } from '../../types/time';
// Libraries
import React, { Component } from 'react';
// Components
import { TimePickerCalendar } from './TimePickerCalendar';
import { TimePickerInput } from './TimePickerInput';
import { mapTimeOptionToTimeRange } from './time';
import { Timezone } from '../../utils/datemath';
import { rawToTimeRange } from './time';
// Types
import { DateTime } from '../../utils/moment_wrapper';
import { TimeRange, TimeZone } from '../../types/time';
export interface Props {
value: TimeRange;
options: TimeOptions;
isTimezoneUtc: boolean;
timezone?: Timezone;
onChange?: (timeRange: TimeRange) => void;
timeZone?: TimeZone;
onChange: (timeRange: TimeRange) => void;
}
export interface State {
value: TimeRange;
from: DateTime | string;
to: DateTime | string;
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 };
this.state = {
from: props.value.raw.from,
to: props.value.raw.to,
isFromInputValid: true,
isToInputValid: true,
};
}
onFromInputChanged = (value: string, valid: boolean) => {
this.setState({
value: { ...this.state.value, raw: { ...this.state.value.raw, from: value } },
isFromInputValid: valid,
});
this.setState({ from: value, isFromInputValid: valid });
};
onToInputChanged = (value: string, valid: boolean) => {
this.setState({
value: { ...this.state.value, raw: { ...this.state.value.raw, to: value } },
isToInputValid: valid,
});
this.setState({ to: value, isToInputValid: valid });
};
onFromCalendarChanged = (value: DateTime) => {
this.setState({
value: { ...this.state.value, raw: { ...this.state.value.raw, from: value } },
});
this.setState({ from: value });
};
onToCalendarChanged = (value: DateTime) => {
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));
}
this.setState({ to: value });
};
onApplyClick = () => {
const { onChange } = this.props;
if (onChange) {
onChange(this.state.value);
}
const { onChange, timeZone } = this.props;
const { from, to } = this.state;
onChange(rawToTimeRange({ from, to }, timeZone));
};
render() {
const { options, isTimezoneUtc, timezone } = this.props;
const { isFromInputValid, isToInputValid, value } = this.state;
const { timeZone } = this.props;
const { isFromInputValid, isToInputValid, from, to } = 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 className="time-picker-popover-body">
<div className="time-picker-popover-body-custom-ranges">
<div className="time-picker-popover-body-custom-ranges-input">
<div className="gf-form">
<label className="gf-form-label">From</label>
<TimePickerInput
roundup={false}
timeZone={timeZone}
value={from}
onChange={this.onFromInputChanged}
tabIndex={1}
/>
</div>
</div>
<div className="time-picker-popover-body-custom-ranges-calendar">
<TimePickerCalendar
timeZone={timeZone}
roundup={false}
value={from}
onChange={this.onFromCalendarChanged}
/>
</div>
</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 className="time-picker-popover-body-custom-ranges">
<div className="time-picker-popover-body-custom-ranges-input">
<div className="gf-form">
<label className="gf-form-label">To</label>
<TimePickerInput
roundup={true}
timeZone={timeZone}
value={to}
onChange={this.onToInputChanged}
tabIndex={2}
/>
</div>
</div>
<div className="time-picker-popover-body-custom-ranges-calendar">
<TimePickerCalendar roundup={true} timeZone={timeZone} value={to} onChange={this.onToCalendarChanged} />
</div>
</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 className="time-picker-popover-footer">
<button type="submit" className="btn btn-success" disabled={!isValid} onClick={this.onApplyClick}>
Apply
</button>
</div>
</div>
);

View File

@ -6,119 +6,158 @@
display: flex;
}
}
.time-picker-popover-popper {
z-index: $zindex-timepicker-popover;
}
.time-picker-utc {
color: $orange;
font-size: 75%;
padding: 3px;
font-weight: 500;
margin-left: 4px;
position: relative;
}
.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;
background: $popover-bg;
color: $popover-color;
box-shadow: $popover-shadow;
position: absolute;
flex-direction: column;
max-width: 600px;
top: 48px;
right: 20px;
.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-body {
display: flex;
flex-flow: row nowrap;
justify-content: space-around;
padding: $space-md;
padding-bottom: 0;
}
.time-picker-popover-box-title {
font-size: $font-size-lg;
.time-picker-popover-title {
font-size: $font-size-md;
font-weight: $font-weight-semi-bold;
}
.time-picker-popover-box:first-child {
border-right: 1px ridge;
.time-picker-popover-body-custom-ranges:first-child {
margin-right: $space-md;
}
.time-picker-popover-box-body-custom-ranges:first-child {
margin-right: $spacer;
}
.time-picker-popover-box-body-custom-ranges-input {
.time-picker-popover-body-custom-ranges-input {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin: $spacer 0;
margin-bottom: $space-sm;
.our-custom-wrapper-class {
margin-left: $spacer;
width: 100%;
.time-picker-input-error {
box-shadow: inset 0 0px 5px $red;
}
.time-picker-input-error {
box-shadow: inset 0 0px 5px $red;
}
}
.time-picker-popover-box-footer {
.time-picker-popover-footer {
display: flex;
flex-flow: row nowrap;
justify-content: flex-end;
margin-top: $spacer;
justify-content: center;
padding: $space-md;
}
}
.time-picker-popover-header {
background: $popover-header-bg;
padding: $space-sm;
}
.time-picker-input {
max-width: 170px;
}
.react-calendar__navigation__label {
line-height: 31px;
padding-bottom: 0;
}
.react-calendar__navigation__arrow {
font-size: $font-size-lg;
}
$arrowPaddingToBorder: 7px;
$arrowPadding: $arrowPaddingToBorder * 3;
.react-calendar__navigation__next-button {
padding-left: $arrowPadding;
padding-right: $arrowPaddingToBorder;
}
.react-calendar__navigation__prev-button {
padding-left: $arrowPaddingToBorder;
padding-right: $arrowPadding;
}
.react-calendar__month-view__days__day--neighboringMonth abbr {
opacity: 0.35;
}
.react-calendar__month-view__days {
padding: 4px;
}
.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;
background-color: $input-label-bg;
border: 0;
}
.react-calendar__month-view__weekdays {
background-color: $popover-border-color;
background-color: $input-bg;
text-align: center;
abbr {
border: 0;
text-decoration: none;
cursor: default;
color: $popover-color;
color: $orange;
font-weight: $font-weight-semi-bold;
display: block;
padding: 4px 0 0 0;
}
}
.time-picker-calendar-tile {
color: $input-color;
background-color: $input-bg;
border: 0;
line-height: 22px;
color: $text-color;
background-color: inherit;
line-height: 26px;
font-size: $font-size-md;
border: 1px solid transparent;
border-radius: $border-radius;
&:hover {
box-shadow: $panel-editor-viz-item-shadow-hover;
background: $panel-editor-viz-item-bg-hover;
border: $panel-editor-viz-item-border-hover;
color: $text-color-strong;
}
}
button.time-picker-calendar-tile:hover {
font-weight: $font-weight-semi-bold;
.react-calendar__month-view__days {
background-color: $calendar-bg-days;
}
.react-calendar__tile--now {
background-color: $calendar-bg-now;
}
.react-calendar__navigation__label,
@ -128,47 +167,46 @@
}
.react-calendar__tile--now {
color: $orange;
border-radius: $border-radius;
}
.react-calendar__tile--active {
color: $blue;
.react-calendar__tile--active,
.react-calendar__tile--active:hover {
color: $white;
font-weight: $font-weight-semi-bold;
background: linear-gradient(0deg, $blue-base, $blue-shade);
box-shadow: none;
border: 1px solid transparent;
}
}
@media only screen and (max-width: 1116px) {
.time-picker-popover-custom-range-label {
padding-right: $space-xs;
}
@include media-breakpoint-down(md) {
.time-picker-popover {
margin-left: $spacer;
display: flex;
flex-flow: column nowrap;
max-width: 400px;
.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-title {
font-size: $font-size-md;
}
.time-picker-popover-box:first-child {
border-right: none;
border-bottom: 1px ridge;
.time-picker-popover-body {
padding: $space-sm;
display: flex;
flex-flow: column nowrap;
}
.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-body-custom-ranges:first-child {
margin-right: 0;
margin-bottom: $space-sm;
}
.time-picker-popover-box-footer {
.time-picker-popover-footer {
display: flex;
flex-flow: row nowrap;
justify-content: flex-end;
@ -177,13 +215,6 @@
}
.time-picker-calendar {
max-width: 500px;
width: 100%;
}
}
@media only screen and (max-width: 746px) {
.time-picker-popover {
margin-top: 48px;
}
}

View File

@ -1,43 +1,38 @@
import { TimeOption, TimeRange, TIME_FORMAT } from '../../types/time';
import { TimeRange, TIME_FORMAT, RawTimeRange, TimeZone } from '../../types/time';
import { describeTimeRange } from '../../utils/rangeutil';
import * as dateMath from '../../utils/datemath';
import { dateTime, DateTime, toUtc } from '../../utils/moment_wrapper';
import { isDateTime, dateTime, DateTime, toUtc } from '../../utils/moment_wrapper';
export const mapTimeOptionToTimeRange = (
timeOption: TimeOption,
isTimezoneUtc: boolean,
timezone?: dateMath.Timezone
): TimeRange => {
const fromMoment = stringToDateTimeType(timeOption.from, isTimezoneUtc, false, timezone);
const toMoment = stringToDateTimeType(timeOption.to, isTimezoneUtc, true, timezone);
export const rawToTimeRange = (raw: RawTimeRange, timeZone?: TimeZone): TimeRange => {
const from = stringToDateTimeType(raw.from, false, timeZone);
const to = stringToDateTimeType(raw.to, true, timeZone);
return { from: fromMoment, to: toMoment, raw: { from: timeOption.from, to: timeOption.to } };
return { from, to, raw };
};
export const stringToDateTimeType = (
value: string,
isTimezoneUtc: boolean,
roundUp?: boolean,
timezone?: dateMath.Timezone
): DateTime => {
export const stringToDateTimeType = (value: string | DateTime, roundUp?: boolean, timeZone?: TimeZone): DateTime => {
if (isDateTime(value)) {
return value;
}
if (value.indexOf('now') !== -1) {
if (!dateMath.isValid(value)) {
return dateTime();
}
const parsed = dateMath.parse(value, roundUp, timezone);
const parsed = dateMath.parse(value, roundUp, timeZone);
return parsed || dateTime();
}
if (isTimezoneUtc) {
if (timeZone === 'utc') {
return toUtc(value, TIME_FORMAT);
}
return dateTime(value, TIME_FORMAT);
};
export const mapTimeRangeToRangeString = (timeRange: TimeRange): string => {
return describeTimeRange(timeRange.raw);
export const mapTimeRangeToRangeString = (timeRange: RawTimeRange): string => {
return describeTimeRange(timeRange);
};
export const isValidTimeString = (text: string) => dateMath.isValid(text);

View File

@ -26,9 +26,14 @@ interface Props extends React.HTMLAttributes<HTMLDivElement> {
referenceElement: PopperJS.ReferenceObject;
wrapperClassName?: string;
renderArrow?: RenderPopperArrowFn;
eventsEnabled?: boolean;
}
class Popper extends PureComponent<Props> {
static defaultProps: Partial<Props> = {
eventsEnabled: true,
};
render() {
const {
content,
@ -39,6 +44,8 @@ class Popper extends PureComponent<Props> {
className,
wrapperClassName,
renderArrow,
referenceElement,
eventsEnabled,
} = this.props;
return (
@ -49,7 +56,8 @@ class Popper extends PureComponent<Props> {
<Portal>
<ReactPopper
placement={placement}
referenceElement={this.props.referenceElement}
referenceElement={referenceElement}
eventsEnabled={eventsEnabled}
// TODO: move modifiers config to popper controller
modifiers={{ preventOverflow: { enabled: true, boundariesElement: 'window' } }}
>

View File

@ -33,6 +33,7 @@ export { UnitPicker } from './UnitPicker/UnitPicker';
export { StatsPicker } from './StatsPicker/StatsPicker';
export { Input, InputStatus } from './Input/Input';
export { RefreshPicker } from './RefreshPicker/RefreshPicker';
export { TimePicker } from './TimePicker/TimePicker';
export { List } from './List/List';
// Renderless

View File

@ -39,6 +39,7 @@ $gray-2: ${theme.colors.gray2};
$gray-3: ${theme.colors.gray3};
$gray-4: ${theme.colors.gray4};
$gray-5: ${theme.colors.gray5};
$gray-6: ${theme.colors.gray6};
$gray-blue: ${theme.colors.grayBlue};
$input-black: #09090b;
@ -282,6 +283,7 @@ $alert-info-bg: linear-gradient(100deg, $blue-base, $blue-shade);
$popover-bg: $dark-2;
$popover-color: $text-color;
$popover-border-color: $dark-9;
$popover-header-bg: $dark-9;
$popover-shadow: 0 0 20px black;
$popover-help-bg: $btn-secondary-bg;
@ -395,4 +397,8 @@ $button-toggle-group-btn-seperator-border: 1px solid $dark-2;
$vertical-resize-handle-bg: $dark-10;
$vertical-resize-handle-dots: $gray-1;
$vertical-resize-handle-dots-hover: $gray-2;
// Calendar
$calendar-bg-days: $input-bg;
$calendar-bg-now: $dark-10;
`;

View File

@ -27,6 +27,8 @@ $black: ${theme.colors.black};
$dark-1: ${theme.colors.dark1};
$dark-2: ${theme.colors.dark2};
$dark-4: ${theme.colors.dark4};
$dark-10: ${theme.colors.dark10};
$gray-1: ${theme.colors.gray1};
$gray-2: ${theme.colors.gray2};
$gray-3: ${theme.colors.gray3};
@ -269,6 +271,7 @@ $alert-info-bg: linear-gradient(100deg, $blue-base, $blue-shade);
$popover-bg: $page-bg;
$popover-color: $text-color;
$popover-border-color: $gray-5;
$popover-header-bg: $gray-5;
$popover-shadow: 0 0 20px $white;
$popover-help-bg: $btn-secondary-bg;
@ -382,4 +385,8 @@ $button-toggle-group-btn-seperator-border: 1px solid $gray-6;
$vertical-resize-handle-bg: $gray-4;
$vertical-resize-handle-dots: $gray-3;
$vertical-resize-handle-dots-hover: $gray-2;
// Calendar
$calendar-bg-days: $white;
$calendar-bg-now: $gray-6;
`;

View File

@ -21,26 +21,17 @@ export interface IntervalValues {
intervalMs: number;
}
export interface TimeZone {
raw: string;
isUtc: boolean;
}
export type TimeZoneUtc = 'utc';
export type TimeZoneBrowser = 'browser';
export type TimeZone = TimeZoneBrowser | TimeZoneUtc | string;
export const parseTimeZone = (raw: string): TimeZone => {
return {
raw,
isUtc: raw === 'utc',
};
};
export const DefaultTimeZone = parseTimeZone('browser');
export const DefaultTimeZone: TimeZone = 'browser';
export interface TimeOption {
from: string;
to: string;
display: string;
section: number;
active: boolean;
}
export interface TimeOptions {

View File

@ -1,11 +1,10 @@
import includes from 'lodash/includes';
import isDate from 'lodash/isDate';
import { DateTime, dateTime, toUtc, ISO_8601, isDateTime, DurationUnit } from '../utils/moment_wrapper';
import { TimeZone } from '../types';
const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm', 's'];
export type Timezone = 'utc';
/**
* Parses different types input to a moment instance. There is a specific formatting language that can be used
* if text arg is string. See unit tests for examples.
@ -13,7 +12,7 @@ export type Timezone = 'utc';
* @param roundUp See parseDateMath function.
* @param timezone Only string 'utc' is acceptable here, for anything else, local timezone is used.
*/
export function parse(text: string | DateTime | Date, roundUp?: boolean, timezone?: Timezone): DateTime | undefined {
export function parse(text: string | DateTime | Date, roundUp?: boolean, timezone?: TimeZone): DateTime | undefined {
if (!text) {
return undefined;
}

View File

@ -10,6 +10,7 @@ export * from './fieldDisplay';
export * from './deprecationWarning';
export * from './logs';
export * from './labels';
export * from './labels';
export { getMappedValue } from './valueMappings';
export * from './validate';
export { getFlotPairs } from './flotPairs';

View File

@ -51,7 +51,6 @@ const rangeOptions = [
{ from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3 },
{ from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3 },
{ from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3 },
{ from: 'now-2d', to: 'now', display: 'Last 2 days', section: 0 },
{ from: 'now-7d', to: 'now', display: 'Last 7 days', section: 0 },
{ from: 'now-30d', to: 'now', display: 'Last 30 days', section: 0 },
@ -62,7 +61,7 @@ const rangeOptions = [
{ from: 'now-5y', to: 'now', display: 'Last 5 years', section: 0 },
];
const absoluteFormat = 'MMM D, YYYY HH:mm:ss';
const absoluteFormat = 'YYYY-MM-DD HH:mm:ss';
const rangeIndex: any = {};
_.each(rangeOptions, (frame: any) => {

View File

@ -72,7 +72,7 @@ describe('rangeUtil', () => {
from: dateTime([2014, 10, 10, 2, 3, 4]),
to: 'now',
});
expect(text).toBe('Nov 10, 2014 02:03:04 to a few seconds ago');
expect(text).toBe('2014-11-10 02:03:04 to a few seconds ago');
});
it('Date range with absolute to relative', () => {
@ -80,7 +80,7 @@ describe('rangeUtil', () => {
from: dateTime([2014, 10, 10, 2, 3, 4]),
to: 'now-1d',
});
expect(text).toBe('Nov 10, 2014 02:03:04 to a day ago');
expect(text).toBe('2014-11-10 02:03:04 to a day ago');
});
it('Date range with relative to absolute', () => {
@ -88,7 +88,7 @@ describe('rangeUtil', () => {
from: 'now-7d',
to: dateTime([2014, 10, 10, 2, 3, 4]),
});
expect(text).toBe('7 days ago to Nov 10, 2014 02:03:04');
expect(text).toBe('7 days ago to 2014-11-10 02:03:04');
});
it('Date range with non matching default ranges', () => {

View File

@ -366,8 +366,8 @@ export const getQueryKeys = (queries: DataQuery[], datasourceInstance: DataSourc
export const getTimeRange = (timeZone: TimeZone, rawRange: RawTimeRange): TimeRange => {
return {
from: dateMath.parse(rawRange.from, false, timeZone.raw as any),
to: dateMath.parse(rawRange.to, true, timeZone.raw as any),
from: dateMath.parse(rawRange.from, false, timeZone as any),
to: dateMath.parse(rawRange.to, true, timeZone as any),
raw: rawRange,
};
};
@ -406,8 +406,8 @@ export const getTimeRangeFromUrl = (range: RawTimeRange, timeZone: TimeZone): Ti
};
return {
from: dateMath.parse(raw.from, false, timeZone.raw as any),
to: dateMath.parse(raw.to, true, timeZone.raw as any),
from: dateMath.parse(raw.from, false, timeZone as any),
to: dateMath.parse(raw.to, true, timeZone as any),
raw,
};
};

View File

@ -3,7 +3,6 @@ import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
// Utils & Services
import { AngularComponent, getAngularLoader } from '@grafana/runtime';
import { appEvents } from 'app/core/app_events';
import { PlaylistSrv } from 'app/features/playlist/playlist_srv';
@ -36,8 +35,6 @@ export interface StateProps {
type Props = StateProps & OwnProps;
export class DashNav extends PureComponent<Props> {
timePickerEl: HTMLElement;
timepickerCmp: AngularComponent;
playlistSrv: PlaylistSrv;
constructor(props: Props) {
@ -45,21 +42,6 @@ export class DashNav extends PureComponent<Props> {
this.playlistSrv = this.props.$injector.get('playlistSrv');
}
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 };
this.timepickerCmp = loader.load(this.timePickerEl, scopeProps, template);
}
componentWillUnmount() {
if (this.timepickerCmp) {
this.timepickerCmp.destroy();
}
}
onDahboardNameClick = () => {
appEvents.emit('show-dash-search');
};
@ -187,7 +169,7 @@ export class DashNav extends PureComponent<Props> {
}
render() {
const { dashboard, onAddPanel, location } = this.props;
const { dashboard, onAddPanel, location, $injector } = this.props;
const { canStar, canSave, canShare, showSettings, isStarred } = dashboard.meta;
const { snapshot } = dashboard;
const snapshotUrl = snapshot && snapshot.originalUrl;
@ -281,8 +263,12 @@ export class DashNav extends PureComponent<Props> {
{!dashboard.timepicker.hidden && (
<div className="navbar-buttons">
<div className="gf-timepicker-nav" ref={element => (this.timePickerEl = element)} />
<DashNavTimeControls dashboard={dashboard} location={location} updateLocation={updateLocation} />
<DashNavTimeControls
$injector={$injector}
dashboard={dashboard}
location={location}
updateLocation={updateLocation}
/>
</div>
)}
</div>

View File

@ -1,20 +1,24 @@
// Libaries
import React, { Component } from 'react';
import { toUtc } from '@grafana/ui/src/utils/moment_wrapper';
// Types
import { DashboardModel } from '../../state';
import { LocationState } from 'app/types';
import { TimeRange, TimeOption } from '@grafana/ui';
// State
import { updateLocation } from 'app/core/actions';
// Components
import { RefreshPicker } from '@grafana/ui';
import { TimePicker, RefreshPicker, RawTimeRange } from '@grafana/ui';
// Utils & Services
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { defaultSelectOptions } from '@grafana/ui/src/components/TimePicker/TimePicker';
export interface Props {
$injector: any;
dashboard: DashboardModel;
updateLocation: typeof updateLocation;
location: LocationState;
@ -22,6 +26,7 @@ export interface Props {
export class DashNavTimeControls extends Component<Props> {
timeSrv: TimeSrv = getTimeSrv();
$rootScope = this.props.$injector.get('$rootScope');
get refreshParamInUrl(): string {
return this.props.location.query.refresh as string;
@ -37,17 +42,92 @@ export class DashNavTimeControls extends Component<Props> {
return Promise.resolve();
};
onMoveTimePicker = (direction: number) => {
const range = this.timeSrv.timeRange();
const timespan = (range.to.valueOf() - range.from.valueOf()) / 2;
let to: number, from: number;
if (direction === -1) {
to = range.to.valueOf() - timespan;
from = range.from.valueOf() - timespan;
} else if (direction === 1) {
to = range.to.valueOf() + timespan;
from = range.from.valueOf() + timespan;
if (to > Date.now() && range.to.valueOf() < Date.now()) {
to = Date.now();
from = range.from.valueOf();
}
} else {
to = range.to.valueOf();
from = range.from.valueOf();
}
this.timeSrv.setTime({
from: toUtc(from),
to: toUtc(to),
});
};
onMoveForward = () => this.onMoveTimePicker(1);
onMoveBack = () => this.onMoveTimePicker(-1);
onChangeTimePicker = (timeRange: TimeRange) => {
const { dashboard } = this.props;
const panel = dashboard.timepicker;
const hasDelay = panel.nowDelay && timeRange.raw.to === 'now';
const nextRange = {
from: timeRange.raw.from,
to: hasDelay ? 'now-' + panel.nowDelay : timeRange.raw.to,
};
this.timeSrv.setTime(nextRange);
};
onZoom = () => {
this.$rootScope.appEvent('zoom-out', 2);
};
setActiveTimeOption = (timeOptions: TimeOption[], rawTimeRange: RawTimeRange): TimeOption[] => {
return timeOptions.map(option => {
if (option.to === rawTimeRange.to && option.from === rawTimeRange.from) {
return {
...option,
active: true,
};
}
return {
...option,
active: false,
};
});
};
render() {
const { dashboard } = this.props;
const intervals = dashboard.timepicker.refresh_intervals;
const timePickerValue = this.timeSrv.timeRange();
const timeZone = dashboard.getTimezone();
return (
<RefreshPicker
onIntervalChanged={this.onChangeRefreshInterval}
onRefresh={this.onRefresh}
value={dashboard.refresh}
intervals={intervals}
tooltip="Refresh dashboard"
/>
<>
<TimePicker
value={timePickerValue}
onChange={this.onChangeTimePicker}
timeZone={timeZone}
onMoveBackward={this.onMoveBack}
onMoveForward={this.onMoveForward}
onZoom={this.onZoom}
selectOptions={this.setActiveTimeOption(defaultSelectOptions, timePickerValue.raw)}
/>
<RefreshPicker
onIntervalChanged={this.onChangeRefreshInterval}
onRefresh={this.onRefresh}
value={dashboard.refresh}
intervals={intervals}
tooltip="Refresh dashboard"
/>
</>
);
}
}

View File

@ -7,7 +7,7 @@ import coreModule from 'app/core/core_module';
import * as dateMath from '@grafana/ui/src/utils/datemath';
// Types
import { TimeRange, RawTimeRange } from '@grafana/ui';
import { TimeRange, RawTimeRange, TimeZone } from '@grafana/ui';
import { ITimeoutService, ILocationService } from 'angular';
import { ContextSrv } from 'app/core/services/context_srv';
import { DashboardModel } from '../state/DashboardModel';
@ -224,7 +224,7 @@ export class TimeSrv {
to: isDateTime(this.time.to) ? dateTime(this.time.to) : this.time.to,
};
const timezone = this.dashboard && this.dashboard.getTimezone();
const timezone: TimeZone = this.dashboard ? this.dashboard.getTimezone() : undefined;
return {
from: dateMath.parse(raw.from, false, timezone),

View File

@ -13,7 +13,7 @@ import sortByKeys from 'app/core/utils/sort_by_keys';
// Types
import { PanelModel, GridPos } from './PanelModel';
import { DashboardMigrator } from './DashboardMigrator';
import { TimeRange } from '@grafana/ui';
import { TimeRange, TimeZone } from '@grafana/ui';
import { UrlQueryValue } from '@grafana/runtime';
import { KIOSK_MODE_TV, DashboardMeta } from 'app/types';
import { toUtc, DateTimeInput, dateTime, isDateTime } from '@grafana/ui/src/utils/moment_wrapper';
@ -832,8 +832,8 @@ export class DashboardModel {
return this.snapshot !== undefined;
}
getTimezone() {
return this.timezone ? this.timezone : contextSrv.user.timezone;
getTimezone(): TimeZone {
return (this.timezone ? this.timezone : contextSrv.user.timezone) as TimeZone;
}
private updateSchema(old: any) {

View File

@ -216,7 +216,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
<div className="explore-toolbar-content-item timepicker">
{!isLive && (
<ClickOutsideWrapper onClick={this.onCloseTimePicker}>
<TimePicker ref={timepickerRef} range={range} isUtc={timeZone.isUtc} onChangeTime={onChangeTime} />
<TimePicker ref={timepickerRef} range={range} timeZone={timeZone} onChangeTime={onChangeTime} />
</ClickOutsideWrapper>
)}

View File

@ -129,7 +129,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
this.$el.unbind('plotselected', this.onPlotSelected);
}
onPlotSelected = (event, ranges) => {
onPlotSelected = (event: JQueryEventObject, ranges) => {
const { onChangeTime } = this.props;
if (onChangeTime) {
this.props.onChangeTime({
@ -151,7 +151,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
max: max,
label: 'Datetime',
ticks: ticks,
timezone: timeZone.raw,
timezone: timeZone,
timeformat: time_format(ticks, min, max),
},
};

View File

@ -34,8 +34,8 @@ export class GraphContainer extends PureComponent<GraphContainerProps> {
onChangeTime = (absRange: AbsoluteTimeRange) => {
const { exploreId, timeZone, changeTime } = this.props;
const range = {
from: timeZone.isUtc ? toUtc(absRange.from) : dateTime(absRange.from),
to: timeZone.isUtc ? toUtc(absRange.to) : dateTime(absRange.to),
from: timeZone === 'utc' ? toUtc(absRange.from) : dateTime(absRange.from),
to: timeZone === 'utc' ? toUtc(absRange.to) : dateTime(absRange.to),
};
changeTime(exploreId, range);

View File

@ -57,8 +57,8 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
onChangeTime = (absRange: AbsoluteTimeRange) => {
const { exploreId, timeZone, changeTime } = this.props;
const range = {
from: timeZone.isUtc ? toUtc(absRange.from) : dateTime(absRange.from),
to: timeZone.isUtc ? toUtc(absRange.to) : dateTime(absRange.to),
from: timeZone === 'utc' ? toUtc(absRange.from) : dateTime(absRange.from),
to: timeZone === 'utc' ? toUtc(absRange.to) : dateTime(absRange.to),
};
changeTime(exploreId, range);

View File

@ -29,7 +29,7 @@ const fromRaw = (rawRange: RawTimeRange): TimeRange => {
describe('<TimePicker />', () => {
it('render default values when closed and relative time range', () => {
const range = fromRaw(DEFAULT_RANGE);
const wrapper = shallow(<TimePicker range={range} />);
const wrapper = shallow(<TimePicker range={range} timeZone="browser" />);
expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours');
@ -39,7 +39,7 @@ describe('<TimePicker />', () => {
it('render default values when closed, utc and relative time range', () => {
const range = fromRaw(DEFAULT_RANGE);
const wrapper = shallow(<TimePicker range={range} isUtc />);
const wrapper = shallow(<TimePicker range={range} timeZone="utc" />);
expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours');
@ -49,7 +49,7 @@ describe('<TimePicker />', () => {
it('renders default values when open and relative range', () => {
const range = fromRaw(DEFAULT_RANGE);
const wrapper = shallow(<TimePicker range={range} isOpen />);
const wrapper = shallow(<TimePicker range={range} isOpen timeZone="browser" />);
expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours');
@ -61,7 +61,7 @@ describe('<TimePicker />', () => {
it('renders default values when open, utc and relative range', () => {
const range = fromRaw(DEFAULT_RANGE);
const wrapper = shallow(<TimePicker range={range} isOpen isUtc />);
const wrapper = shallow(<TimePicker range={range} isOpen timeZone="utc" />);
expect(wrapper.state('fromRaw')).toBe(DEFAULT_RANGE.from);
expect(wrapper.state('toRaw')).toBe(DEFAULT_RANGE.to);
expect(wrapper.find('.timepicker-rangestring').text()).toBe('Last 6 hours');
@ -91,7 +91,7 @@ describe('<TimePicker />', () => {
const expectedRangeString = rangeUtil.describeTimeRange(localRange);
const onChangeTime = sinon.spy();
const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} />);
const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} timeZone="browser" />);
expect(wrapper.state('fromRaw')).toBe(localRange.from.format(TIME_FORMAT));
expect(wrapper.state('toRaw')).toBe(localRange.to.format(TIME_FORMAT));
expect(wrapper.state('initialRange')).toBe(range.raw);
@ -118,11 +118,11 @@ describe('<TimePicker />', () => {
},
};
const onChangeTime = sinon.spy();
const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />);
const wrapper = shallow(<TimePicker range={range} timeZone="utc" isOpen onChangeTime={onChangeTime} />);
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:00');
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:01');
expect(wrapper.state('initialRange')).toBe(range.raw);
expect(wrapper.find('.timepicker-rangestring').text()).toBe('Jan 1, 1970 00:00:00 to Jan 1, 1970 00:00:01');
expect(wrapper.find('.timepicker-rangestring').text()).toBe('1970-01-01 00:00:00 to 1970-01-01 00:00:01');
expect(wrapper.find('.timepicker-from').props().value).toBe('1970-01-01 00:00:00');
expect(wrapper.find('.timepicker-to').props().value).toBe('1970-01-01 00:00:01');
@ -132,7 +132,7 @@ describe('<TimePicker />', () => {
expect(onChangeTime.getCall(0).args[0].to.valueOf()).toBe(1000);
expect(wrapper.state('isOpen')).toBeFalsy();
expect(wrapper.state('rangeString')).toBe('Jan 1, 1970 00:00:00 to Jan 1, 1970 00:00:01');
expect(wrapper.state('rangeString')).toBe('1970-01-01 00:00:00 to 1970-01-01 00:00:01');
});
it('moves ranges backward by half the range on left arrow click when utc', () => {
@ -147,7 +147,7 @@ describe('<TimePicker />', () => {
const range = fromRaw(rawRange);
const onChangeTime = sinon.spy();
const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />);
const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} timeZone="utc" />);
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:02');
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:04');
@ -176,7 +176,7 @@ describe('<TimePicker />', () => {
};
const onChangeTime = sinon.spy();
const wrapper = shallow(<TimePicker range={range} isUtc={false} isOpen onChangeTime={onChangeTime} />);
const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} timeZone="browser" />);
expect(wrapper.state('fromRaw')).toBe(localRange.from.format(TIME_FORMAT));
expect(wrapper.state('toRaw')).toBe(localRange.to.format(TIME_FORMAT));
@ -197,7 +197,7 @@ describe('<TimePicker />', () => {
};
const onChangeTime = sinon.spy();
const wrapper = shallow(<TimePicker range={range} isUtc isOpen onChangeTime={onChangeTime} />);
const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} timeZone="utc" />);
expect(wrapper.state('fromRaw')).toBe('1970-01-01 00:00:01');
expect(wrapper.state('toRaw')).toBe('1970-01-01 00:00:03');
@ -226,7 +226,7 @@ describe('<TimePicker />', () => {
};
const onChangeTime = sinon.spy();
const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} />);
const wrapper = shallow(<TimePicker range={range} isOpen onChangeTime={onChangeTime} timeZone="browser" />);
expect(wrapper.state('fromRaw')).toBe(localRange.from.format(TIME_FORMAT));
expect(wrapper.state('toRaw')).toBe(localRange.to.format(TIME_FORMAT));

View File

@ -1,12 +1,12 @@
import React, { PureComponent } from 'react';
import React, { PureComponent, ChangeEvent } from 'react';
import * as rangeUtil from '@grafana/ui/src/utils/rangeutil';
import { Input, RawTimeRange, TimeRange, TIME_FORMAT } from '@grafana/ui';
import { Input, RawTimeRange, TimeRange, TIME_FORMAT, TimeZone } from '@grafana/ui';
import { toUtc, isDateTime, dateTime } from '@grafana/ui/src/utils/moment_wrapper';
interface TimePickerProps {
isOpen?: boolean;
isUtc?: boolean;
range: TimeRange;
timeZone: TimeZone;
onChangeTime?: (range: RawTimeRange, scanning?: boolean) => void;
}
@ -22,21 +22,21 @@ interface TimePickerState {
toRaw: string;
}
const getRaw = (isUtc: boolean, range: any) => {
const getRaw = (range: any, timeZone: TimeZone) => {
const rawRange = {
from: range.raw.from,
to: range.raw.to,
};
if (isDateTime(rawRange.from)) {
if (!isUtc) {
if (timeZone === 'browser') {
rawRange.from = rawRange.from.local();
}
rawRange.from = rawRange.from.format(TIME_FORMAT);
}
if (isDateTime(rawRange.to)) {
if (!isUtc) {
if (timeZone === 'browser') {
rawRange.to = rawRange.to.local();
}
rawRange.to = rawRange.to.format(TIME_FORMAT);
@ -61,19 +61,19 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
constructor(props) {
super(props);
const { range, isUtc, isOpen } = props;
const rawRange = getRaw(props.isUtc, range);
const { range, timeZone, isOpen } = props;
const rawRange = getRaw(range, timeZone);
this.state = {
isOpen: isOpen,
isUtc: isUtc,
isUtc: timeZone === 'utc',
rangeString: rangeUtil.describeTimeRange(range.raw),
fromRaw: rawRange.from,
toRaw: rawRange.to,
initialRange: range.raw,
refreshInterval: '',
};
} //Temp solution... How do detect if ds supports table format?
}
static getDerivedStateFromProps(props: TimePickerProps, state: TimePickerState) {
if (
@ -85,7 +85,7 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
}
const { range } = props;
const rawRange = getRaw(props.isUtc, range);
const rawRange = getRaw(range, props.timeZone);
return {
...state,
@ -102,8 +102,10 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
from: toUtc(origRange.from),
to: toUtc(origRange.to),
};
const timespan = (range.to.valueOf() - range.from.valueOf()) / 2;
let to, from;
if (direction === -1) {
to = range.to.valueOf() - timespan;
from = range.from.valueOf() - timespan;
@ -116,30 +118,32 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
}
const nextTimeRange = {
from: this.props.isUtc ? toUtc(from) : dateTime(from),
to: this.props.isUtc ? toUtc(to) : dateTime(to),
from: this.props.timeZone === 'utc' ? toUtc(from) : dateTime(from),
to: this.props.timeZone === 'utc' ? toUtc(to) : dateTime(to),
};
if (onChangeTime) {
onChangeTime(nextTimeRange);
}
return nextTimeRange;
}
handleChangeFrom = e => {
handleChangeFrom = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({
fromRaw: e.target.value,
fromRaw: event.target.value,
});
};
handleChangeTo = e => {
handleChangeTo = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({
toRaw: e.target.value,
toRaw: event.target.value,
});
};
handleClickApply = () => {
const { onChangeTime, isUtc } = this.props;
const { onChangeTime, timeZone } = this.props;
let rawRange;
this.setState(
state => {
const { toRaw, fromRaw } = this.state;
@ -149,11 +153,11 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
};
if (rawRange.from.indexOf('now') === -1) {
rawRange.from = isUtc ? toUtc(rawRange.from, TIME_FORMAT) : dateTime(rawRange.from, TIME_FORMAT);
rawRange.from = timeZone === 'utc' ? toUtc(rawRange.from, TIME_FORMAT) : dateTime(rawRange.from, TIME_FORMAT);
}
if (rawRange.to.indexOf('now') === -1) {
rawRange.to = isUtc ? toUtc(rawRange.to, TIME_FORMAT) : dateTime(rawRange.to, TIME_FORMAT);
rawRange.to = timeZone === 'utc' ? toUtc(rawRange.to, TIME_FORMAT) : dateTime(rawRange.to, TIME_FORMAT);
}
const rangeString = rangeUtil.describeTimeRange(rawRange);

View File

@ -1,4 +1,3 @@
import { UserState } from 'app/types';
import { parseTimeZone } from '@grafana/ui';
export const getTimeZone = (state: UserState) => parseTimeZone(state.timeZone);
export const getTimeZone = (state: UserState) => state.timeZone;

View File

@ -115,6 +115,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned off should not ren
},
]
}
tabSelectsValue={true}
value={
Object {
"description": "Is team member",
@ -199,6 +200,7 @@ exports[`Render when feature toggle editorsCanAdmin is turned on should render p
},
]
}
tabSelectsValue={true}
value={
Object {
"description": "Is team member",

View File

@ -6,14 +6,14 @@ describe('grafana data source', () => {
describe('when executing an annotations query', () => {
let calledBackendSrvParams;
const backendSrvStub = {
get: (url, options) => {
get: (url: string, options) => {
calledBackendSrvParams = options;
return q.resolve([]);
},
};
const templateSrvStub = {
replace: val => {
replace: (val: string) => {
return val.replace('$var2', 'replaced__delimiter__replaced2').replace('$var', 'replaced');
},
};

View File

@ -123,6 +123,7 @@ exports[`Render PromQueryEditor with basic options should render 1`] = `
},
]
}
tabSelectsValue={true}
value={
Object {
"label": "1/1",
@ -176,6 +177,7 @@ exports[`Render PromQueryEditor with basic options should render 1`] = `
},
]
}
tabSelectsValue={true}
value={
Object {
"label": "Time series",

View File

@ -149,7 +149,7 @@ class GraphElement {
}
}
onPlotSelected(event, ranges) {
onPlotSelected(event: JQueryEventObject, ranges) {
if (this.panel.xaxis.mode !== 'time') {
// Skip if panel in histogram or series mode
this.plot.clearSelection();
@ -171,7 +171,7 @@ class GraphElement {
}
}
onPlotClick(event, pos, item) {
onPlotClick(event: JQueryEventObject, pos, item) {
if (this.panel.xaxis.mode !== 'time') {
// Skip if panel in histogram or series mode
return;

View File

@ -1,3 +1,5 @@
import { TimeZone } from '@grafana/ui/src/types';
export interface OrgUser {
avatarUrl: string;
email: string;
@ -46,7 +48,7 @@ export interface UsersState {
export interface UserState {
orgId: number;
timeZone: string;
timeZone: TimeZone;
}
export interface UserSession {

View File

@ -42,6 +42,7 @@ $gray-2: #8e8e8e;
$gray-3: #b3b3b3;
$gray-4: #d8d9da;
$gray-5: #ececec;
$gray-6: #f4f5f8;
$gray-blue: #212327;
$input-black: #09090b;
@ -285,6 +286,7 @@ $alert-info-bg: linear-gradient(100deg, $blue-base, $blue-shade);
$popover-bg: $dark-2;
$popover-color: $text-color;
$popover-border-color: $dark-9;
$popover-header-bg: $dark-9;
$popover-shadow: 0 0 20px black;
$popover-help-bg: $btn-secondary-bg;
@ -398,3 +400,7 @@ $button-toggle-group-btn-seperator-border: 1px solid $dark-2;
$vertical-resize-handle-bg: $dark-10;
$vertical-resize-handle-dots: $gray-1;
$vertical-resize-handle-dots-hover: $gray-2;
// Calendar
$calendar-bg-days: $input-bg;
$calendar-bg-now: $dark-10;

View File

@ -30,6 +30,8 @@ $black: #000000;
$dark-1: #1e2028;
$dark-2: #41444b;
$dark-4: #35373f;
$dark-10: #424345;
$gray-1: #52545c;
$gray-2: #767980;
$gray-3: #acb6bf;
@ -272,6 +274,7 @@ $alert-info-bg: linear-gradient(100deg, $blue-base, $blue-shade);
$popover-bg: $page-bg;
$popover-color: $text-color;
$popover-border-color: $gray-5;
$popover-header-bg: $gray-5;
$popover-shadow: 0 0 20px $white;
$popover-help-bg: $btn-secondary-bg;
@ -385,3 +388,7 @@ $button-toggle-group-btn-seperator-border: 1px solid $gray-6;
$vertical-resize-handle-bg: $gray-4;
$vertical-resize-handle-dots: $gray-3;
$vertical-resize-handle-dots-hover: $gray-2;
// Calendar
$calendar-bg-days: $white;
$calendar-bg-now: $gray-6;

View File

@ -134,7 +134,7 @@ i.navbar-page-btn__search {
align-items: center;
font-weight: $btn-font-weight;
padding: 6px $space-sm;
line-height: 16px;
line-height: 18px;
color: $text-muted;
border: 1px solid $navbar-button-border;
margin-left: $space-xs;

View File

@ -304,7 +304,7 @@
}
.graph-annotation__header {
background-color: $popover-border-color;
background: $popover-header-bg;
padding: 6px 10px;
display: flex;
}

View File

@ -252,7 +252,7 @@ $column-horizontal-spacing: 10px;
}
.logs-stats__header {
background-color: $popover-border-color;
background: $popover-header-bg;
padding: 6px 10px;
display: flex;
}

View File

@ -19,7 +19,7 @@ export function ControllerTestContext(this: any) {
getMetricSources: () => {},
get: () => {
return {
then: (callback: (a: any) => void) => {
then: (callback: (ds: any) => void) => {
callback(self.datasource);
},
};

View File

@ -1,818 +0,0 @@
/* Flot plugin for rendering pie charts.
Copyright (c) 2007-2013 IOLA and Ole Laursen.
Licensed under the MIT license.
The plugin assumes that each series has a single data value, and that each
value is a positive integer or zero. Negative numbers don't make sense for a
pie chart, and have unpredictable results. The values do NOT need to be
passed in as percentages; the plugin will calculate the total and per-slice
percentages internally.
* Created by Brian Medendorp
* Updated with contributions from btburnett3, Anthony Aragues and Xavi Ivars
The plugin supports these options:
series: {
pie: {
show: true/false
radius: 0-1 for percentage of fullsize, or a specified pixel length, or 'auto'
innerRadius: 0-1 for percentage of fullsize or a specified pixel length, for creating a donut effect
startAngle: 0-2 factor of PI used for starting angle (in radians) i.e 3/2 starts at the top, 0 and 2 have the same result
tilt: 0-1 for percentage to tilt the pie, where 1 is no tilt, and 0 is completely flat (nothing will show)
offset: {
top: integer value to move the pie up or down
left: integer value to move the pie left or right, or 'auto'
},
stroke: {
color: any hexidecimal color value (other formats may or may not work, so best to stick with something like '#FFF')
width: integer pixel width of the stroke
},
label: {
show: true/false, or 'auto'
formatter: a user-defined function that modifies the text/style of the label text
radius: 0-1 for percentage of fullsize, or a specified pixel length
background: {
color: any hexidecimal color value (other formats may or may not work, so best to stick with something like '#000')
opacity: 0-1
},
threshold: 0-1 for the percentage value at which to hide labels (if they're too small)
},
combine: {
threshold: 0-1 for the percentage value at which to combine slices (if they're too small)
color: any hexidecimal color value (other formats may or may not work, so best to stick with something like '#CCC'), if null, the plugin will automatically use the color of the first slice to be combined
label: any text value of what the combined slice should be labeled
}
highlight: {
opacity: 0-1
}
}
}
More detail and specific examples can be found in the included HTML file.
*/
(function($) {
// Maximum redraw attempts when fitting labels within the plot
var REDRAW_ATTEMPTS = 10;
// Factor by which to shrink the pie when fitting labels within the plot
var REDRAW_SHRINK = 0.95;
function init(plot) {
var canvas = null,
target = null,
maxRadius = null,
centerLeft = null,
centerTop = null,
processed = false,
options = null,
ctx = null;
// interactive variables
var highlights = [];
// add hook to determine if pie plugin in enabled, and then perform necessary operations
plot.hooks.processOptions.push(function(plot, options) {
if (options.series.pie.show) {
options.grid.show = false;
// set labels.show
if (options.series.pie.label.show == "auto") {
if (options.legend.show) {
options.series.pie.label.show = false;
} else {
options.series.pie.label.show = true;
}
}
// set radius
if (options.series.pie.radius == "auto") {
if (options.series.pie.label.show) {
options.series.pie.radius = 3/4;
} else {
options.series.pie.radius = 1;
}
}
// ensure sane tilt
if (options.series.pie.tilt > 1) {
options.series.pie.tilt = 1;
} else if (options.series.pie.tilt < 0) {
options.series.pie.tilt = 0;
}
}
});
plot.hooks.bindEvents.push(function(plot, eventHolder) {
var options = plot.getOptions();
if (options.series.pie.show) {
if (options.grid.hoverable) {
eventHolder.unbind("mousemove").mousemove(onMouseMove);
}
if (options.grid.clickable) {
eventHolder.unbind("click").click(onClick);
}
}
});
plot.hooks.processDatapoints.push(function(plot, series, data, datapoints) {
var options = plot.getOptions();
if (options.series.pie.show) {
processDatapoints(plot, series, data, datapoints);
}
});
plot.hooks.drawOverlay.push(function(plot, octx) {
var options = plot.getOptions();
if (options.series.pie.show) {
drawOverlay(plot, octx);
}
});
plot.hooks.draw.push(function(plot, newCtx) {
var options = plot.getOptions();
if (options.series.pie.show) {
draw(plot, newCtx);
}
});
function processDatapoints(plot, series, datapoints) {
if (!processed) {
processed = true;
canvas = plot.getCanvas();
target = $(canvas).parent();
options = plot.getOptions();
plot.setData(combine(plot.getData()));
}
}
function combine(data) {
var total = 0,
combined = 0,
numCombined = 0,
color = options.series.pie.combine.color,
newdata = [];
// Fix up the raw data from Flot, ensuring the data is numeric
for (var i = 0; i < data.length; ++i) {
var value = data[i].data;
// If the data is an array, we'll assume that it's a standard
// Flot x-y pair, and are concerned only with the second value.
// Note how we use the original array, rather than creating a
// new one; this is more efficient and preserves any extra data
// that the user may have stored in higher indexes.
if ($.isArray(value) && value.length == 1) {
value = value[0];
}
if ($.isArray(value)) {
// Equivalent to $.isNumeric() but compatible with jQuery < 1.7
if (!isNaN(parseFloat(value[1])) && isFinite(value[1])) {
value[1] = +value[1];
} else {
value[1] = 0;
}
} else if (!isNaN(parseFloat(value)) && isFinite(value)) {
value = [1, +value];
} else {
value = [1, 0];
}
data[i].data = [value];
}
// Sum up all the slices, so we can calculate percentages for each
for (var i = 0; i < data.length; ++i) {
total += data[i].data[0][1];
}
// Count the number of slices with percentages below the combine
// threshold; if it turns out to be just one, we won't combine.
for (var i = 0; i < data.length; ++i) {
var value = data[i].data[0][1];
if (value / total <= options.series.pie.combine.threshold) {
combined += value;
numCombined++;
if (!color) {
color = data[i].color;
}
}
}
for (var i = 0; i < data.length; ++i) {
var value = data[i].data[0][1];
if (numCombined < 2 || value / total > options.series.pie.combine.threshold) {
newdata.push({
data: [[1, value]],
color: data[i].color,
label: data[i].label,
angle: value * Math.PI * 2 / total,
percent: value / (total / 100)
});
}
}
if (numCombined > 1) {
newdata.push({
data: [[1, combined]],
color: color,
label: options.series.pie.combine.label,
angle: combined * Math.PI * 2 / total,
percent: combined / (total / 100)
});
}
return newdata;
}
function draw(plot, newCtx) {
if (!target) {
return; // if no series were passed
}
var canvasWidth = plot.getPlaceholder().width(),
canvasHeight = plot.getPlaceholder().height(),
legendWidth = target.children().filter(".legend").children().width() || 0;
ctx = newCtx;
// WARNING: HACK! REWRITE THIS CODE AS SOON AS POSSIBLE!
// When combining smaller slices into an 'other' slice, we need to
// add a new series. Since Flot gives plugins no way to modify the
// list of series, the pie plugin uses a hack where the first call
// to processDatapoints results in a call to setData with the new
// list of series, then subsequent processDatapoints do nothing.
// The plugin-global 'processed' flag is used to control this hack;
// it starts out false, and is set to true after the first call to
// processDatapoints.
// Unfortunately this turns future setData calls into no-ops; they
// call processDatapoints, the flag is true, and nothing happens.
// To fix this we'll set the flag back to false here in draw, when
// all series have been processed, so the next sequence of calls to
// processDatapoints once again starts out with a slice-combine.
// This is really a hack; in 0.9 we need to give plugins a proper
// way to modify series before any processing begins.
processed = false;
// calculate maximum radius and center point
maxRadius = Math.min(canvasWidth, canvasHeight / options.series.pie.tilt) / 2;
centerTop = canvasHeight / 2 + options.series.pie.offset.top;
centerLeft = canvasWidth / 2;
if (options.series.pie.offset.left == "auto") {
if (options.legend.position.match("w")) {
centerLeft += legendWidth / 2;
} else {
centerLeft -= legendWidth / 2;
}
} else {
centerLeft += options.series.pie.offset.left;
}
if (centerLeft < maxRadius) {
centerLeft = maxRadius;
} else if (centerLeft > canvasWidth - maxRadius) {
centerLeft = canvasWidth - maxRadius;
}
var slices = plot.getData(),
attempts = 0;
// Keep shrinking the pie's radius until drawPie returns true,
// indicating that all the labels fit, or we try too many times.
do {
if (attempts > 0) {
maxRadius *= REDRAW_SHRINK;
}
attempts += 1;
clear();
if (options.series.pie.tilt <= 0.8) {
drawShadow();
}
} while (!drawPie() && attempts < REDRAW_ATTEMPTS)
if (attempts >= REDRAW_ATTEMPTS) {
clear();
target.prepend("<div class='error'>Could not draw pie with labels contained inside canvas</div>");
}
if (plot.setSeries && plot.insertLegend) {
plot.setSeries(slices);
plot.insertLegend();
}
// we're actually done at this point, just defining internal functions at this point
function clear() {
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
target.children().filter(".pieLabel, .pieLabelBackground").remove();
}
function drawShadow() {
var shadowLeft = options.series.pie.shadow.left;
var shadowTop = options.series.pie.shadow.top;
var edge = 10;
var alpha = options.series.pie.shadow.alpha;
var radius = options.series.pie.radius > 1 ? options.series.pie.radius : maxRadius * options.series.pie.radius;
if (radius >= canvasWidth / 2 - shadowLeft || radius * options.series.pie.tilt >= canvasHeight / 2 - shadowTop || radius <= edge) {
return; // shadow would be outside canvas, so don't draw it
}
ctx.save();
ctx.translate(shadowLeft,shadowTop);
ctx.globalAlpha = alpha;
ctx.fillStyle = "#000";
// center and rotate to starting position
ctx.translate(centerLeft,centerTop);
ctx.scale(1, options.series.pie.tilt);
//radius -= edge;
for (var i = 1; i <= edge; i++) {
ctx.beginPath();
ctx.arc(0, 0, radius, 0, Math.PI * 2, false);
ctx.fill();
radius -= i;
}
ctx.restore();
}
function drawPie() {
var startAngle = Math.PI * options.series.pie.startAngle;
var radius = options.series.pie.radius > 1 ? options.series.pie.radius : maxRadius * options.series.pie.radius;
// center and rotate to starting position
ctx.save();
ctx.translate(centerLeft,centerTop);
ctx.scale(1, options.series.pie.tilt);
//ctx.rotate(startAngle); // start at top; -- This doesn't work properly in Opera
// draw slices
ctx.save();
var currentAngle = startAngle;
for (var i = 0; i < slices.length; ++i) {
slices[i].startAngle = currentAngle;
drawSlice(slices[i].angle, slices[i].color, true);
}
ctx.restore();
// draw slice outlines
if (options.series.pie.stroke.width > 0) {
ctx.save();
ctx.lineWidth = options.series.pie.stroke.width;
currentAngle = startAngle;
for (var i = 0; i < slices.length; ++i) {
drawSlice(slices[i].angle, options.series.pie.stroke.color, false);
}
ctx.restore();
}
// draw donut hole
drawDonutHole(ctx);
ctx.restore();
// Draw the labels, returning true if they fit within the plot
if (options.series.pie.label.show) {
return drawLabels();
} else return true;
function drawSlice(angle, color, fill) {
if (angle <= 0 || isNaN(angle)) {
return;
}
if (fill) {
ctx.fillStyle = color;
} else {
ctx.strokeStyle = color;
ctx.lineJoin = "round";
}
ctx.beginPath();
if (Math.abs(angle - Math.PI * 2) > 0.000000001) {
ctx.moveTo(0, 0); // Center of the pie
}
//ctx.arc(0, 0, radius, 0, angle, false); // This doesn't work properly in Opera
ctx.arc(0, 0, radius,currentAngle, currentAngle + angle / 2, false);
ctx.arc(0, 0, radius,currentAngle + angle / 2, currentAngle + angle, false);
ctx.closePath();
//ctx.rotate(angle); // This doesn't work properly in Opera
currentAngle += angle;
if (fill) {
ctx.fill();
} else {
ctx.stroke();
}
}
function drawLabels() {
var currentAngle = startAngle;
var radius = options.series.pie.label.radius > 1 ? options.series.pie.label.radius : maxRadius * options.series.pie.label.radius;
for (var i = 0; i < slices.length; ++i) {
if (slices[i].percent >= options.series.pie.label.threshold * 100) {
if (!drawLabel(slices[i], currentAngle, i)) {
return false;
}
}
currentAngle += slices[i].angle;
}
return true;
function drawLabel(slice, startAngle, index) {
if (slice.data[0][1] == 0) {
return true;
}
// format label text
var lf = options.legend.labelFormatter, text, plf = options.series.pie.label.formatter;
if (lf) {
text = lf(slice.label, slice);
} else {
text = slice.label;
}
if (plf) {
text = plf(text, slice);
}
var halfAngle = ((startAngle + slice.angle) + startAngle) / 2;
var x = centerLeft + Math.round(Math.cos(halfAngle) * radius);
var y = centerTop + Math.round(Math.sin(halfAngle) * radius) * options.series.pie.tilt;
var html = "<span class='pieLabel' id='pieLabel" + index + "' style='position:absolute;top:" + y + "px;left:" + x + "px;'>" + text + "</span>";
target.append(html);
var label = target.children("#pieLabel" + index);
var labelTop = (y - label.height() / 2);
var labelLeft = (x - label.width() / 2);
label.css("top", labelTop);
label.css("left", labelLeft);
// check to make sure that the label is not outside the canvas
if (0 - labelTop > 0 || 0 - labelLeft > 0 || canvasHeight - (labelTop + label.height()) < 0 || canvasWidth - (labelLeft + label.width()) < 0) {
return false;
}
if (options.series.pie.label.background.opacity != 0) {
// put in the transparent background separately to avoid blended labels and label boxes
var c = options.series.pie.label.background.color;
if (c == null) {
c = slice.color;
}
var pos = "top:" + labelTop + "px;left:" + labelLeft + "px;";
$("<div class='pieLabelBackground' style='position:absolute;width:" + label.width() + "px;height:" + label.height() + "px;" + pos + "background-color:" + c + ";'></div>")
.css("opacity", options.series.pie.label.background.opacity)
.insertBefore(label);
}
return true;
} // end individual label function
} // end drawLabels function
} // end drawPie function
} // end draw function
// Placed here because it needs to be accessed from multiple locations
function drawDonutHole(layer) {
if (options.series.pie.innerRadius > 0) {
// subtract the center
layer.save();
var innerRadius = options.series.pie.innerRadius > 1 ? options.series.pie.innerRadius : maxRadius * options.series.pie.innerRadius;
layer.globalCompositeOperation = "destination-out"; // this does not work with excanvas, but it will fall back to using the stroke color
layer.beginPath();
layer.fillStyle = options.series.pie.stroke.color;
layer.arc(0, 0, innerRadius, 0, Math.PI * 2, false);
layer.fill();
layer.closePath();
layer.restore();
// add inner stroke
layer.save();
layer.beginPath();
layer.strokeStyle = options.series.pie.stroke.color;
layer.arc(0, 0, innerRadius, 0, Math.PI * 2, false);
layer.stroke();
layer.closePath();
layer.restore();
// TODO: add extra shadow inside hole (with a mask) if the pie is tilted.
}
}
//-- Additional Interactive related functions --
function isPointInPoly(poly, pt) {
for(var c = false, i = -1, l = poly.length, j = l - 1; ++i < l; j = i)
((poly[i][1] <= pt[1] && pt[1] < poly[j][1]) || (poly[j][1] <= pt[1] && pt[1]< poly[i][1]))
&& (pt[0] < (poly[j][0] - poly[i][0]) * (pt[1] - poly[i][1]) / (poly[j][1] - poly[i][1]) + poly[i][0])
&& (c = !c);
return c;
}
function findNearbySlice(mouseX, mouseY) {
var slices = plot.getData(),
options = plot.getOptions(),
radius = options.series.pie.radius > 1 ? options.series.pie.radius : maxRadius * options.series.pie.radius,
x, y;
for (var i = 0; i < slices.length; ++i) {
var s = slices[i];
if (s.pie.show) {
ctx.save();
ctx.beginPath();
ctx.moveTo(0, 0); // Center of the pie
//ctx.scale(1, options.series.pie.tilt); // this actually seems to break everything when here.
ctx.arc(0, 0, radius, s.startAngle, s.startAngle + s.angle / 2, false);
ctx.arc(0, 0, radius, s.startAngle + s.angle / 2, s.startAngle + s.angle, false);
ctx.closePath();
x = mouseX - centerLeft;
y = mouseY - centerTop;
if (ctx.isPointInPath) {
if (ctx.isPointInPath(mouseX - centerLeft, mouseY - centerTop)) {
ctx.restore();
return {
datapoint: [s.percent, s.data],
dataIndex: 0,
series: s,
seriesIndex: i
};
}
} else {
// excanvas for IE doesn;t support isPointInPath, this is a workaround.
var p1X = radius * Math.cos(s.startAngle),
p1Y = radius * Math.sin(s.startAngle),
p2X = radius * Math.cos(s.startAngle + s.angle / 4),
p2Y = radius * Math.sin(s.startAngle + s.angle / 4),
p3X = radius * Math.cos(s.startAngle + s.angle / 2),
p3Y = radius * Math.sin(s.startAngle + s.angle / 2),
p4X = radius * Math.cos(s.startAngle + s.angle / 1.5),
p4Y = radius * Math.sin(s.startAngle + s.angle / 1.5),
p5X = radius * Math.cos(s.startAngle + s.angle),
p5Y = radius * Math.sin(s.startAngle + s.angle),
arrPoly = [[0, 0], [p1X, p1Y], [p2X, p2Y], [p3X, p3Y], [p4X, p4Y], [p5X, p5Y]],
arrPoint = [x, y];
// TODO: perhaps do some mathmatical trickery here with the Y-coordinate to compensate for pie tilt?
if (isPointInPoly(arrPoly, arrPoint)) {
ctx.restore();
return {
datapoint: [s.percent, s.data],
dataIndex: 0,
series: s,
seriesIndex: i
};
}
}
ctx.restore();
}
}
return null;
}
function onMouseMove(e) {
triggerClickHoverEvent("plothover", e);
}
function onClick(e) {
triggerClickHoverEvent("plotclick", e);
}
// trigger click or hover event (they send the same parameters so we share their code)
function triggerClickHoverEvent(eventname, e) {
var offset = plot.offset();
var canvasX = parseInt(e.pageX - offset.left);
var canvasY = parseInt(e.pageY - offset.top);
var item = findNearbySlice(canvasX, canvasY);
if (options.grid.autoHighlight) {
// clear auto-highlights
for (var i = 0; i < highlights.length; ++i) {
var h = highlights[i];
if (h.auto == eventname && !(item && h.series == item.series)) {
unhighlight(h.series);
}
}
}
// highlight the slice
if (item) {
highlight(item.series, eventname);
}
// trigger any hover bind events
var pos = { pageX: e.pageX, pageY: e.pageY };
target.trigger(eventname, [pos, item]);
}
function highlight(s, auto) {
//if (typeof s == "number") {
// s = series[s];
//}
var i = indexOfHighlight(s);
if (i == -1) {
highlights.push({ series: s, auto: auto });
plot.triggerRedrawOverlay();
} else if (!auto) {
highlights[i].auto = false;
}
}
function unhighlight(s) {
if (s == null) {
highlights = [];
plot.triggerRedrawOverlay();
}
//if (typeof s == "number") {
// s = series[s];
//}
var i = indexOfHighlight(s);
if (i != -1) {
highlights.splice(i, 1);
plot.triggerRedrawOverlay();
}
}
function indexOfHighlight(s) {
for (var i = 0; i < highlights.length; ++i) {
var h = highlights[i];
if (h.series == s)
return i;
}
return -1;
}
function drawOverlay(plot, octx) {
var options = plot.getOptions();
var radius = options.series.pie.radius > 1 ? options.series.pie.radius : maxRadius * options.series.pie.radius;
octx.save();
octx.translate(centerLeft, centerTop);
octx.scale(1, options.series.pie.tilt);
for (var i = 0; i < highlights.length; ++i) {
drawHighlight(highlights[i].series);
}
drawDonutHole(octx);
octx.restore();
function drawHighlight(series) {
if (series.angle <= 0 || isNaN(series.angle)) {
return;
}
//octx.fillStyle = parseColor(options.series.pie.highlight.color).scale(null, null, null, options.series.pie.highlight.opacity).toString();
octx.fillStyle = "rgba(255, 255, 255, " + options.series.pie.highlight.opacity + ")"; // this is temporary until we have access to parseColor
octx.beginPath();
if (Math.abs(series.angle - Math.PI * 2) > 0.000000001) {
octx.moveTo(0, 0); // Center of the pie
}
octx.arc(0, 0, radius, series.startAngle, series.startAngle + series.angle / 2, false);
octx.arc(0, 0, radius, series.startAngle + series.angle / 2, series.startAngle + series.angle, false);
octx.closePath();
octx.fill();
}
}
} // end init (plugin body)
// define pie specific options and their default values
var options = {
series: {
pie: {
show: false,
radius: "auto", // actual radius of the visible pie (based on full calculated radius if <=1, or hard pixel value)
innerRadius: 0, /* for donut */
startAngle: 3/2,
tilt: 1,
shadow: {
left: 5, // shadow left offset
top: 15, // shadow top offset
alpha: 0.02 // shadow alpha
},
offset: {
top: 0,
left: "auto"
},
stroke: {
color: "#fff",
width: 1
},
label: {
show: "auto",
formatter: function(label, slice) {
return "<div style='font-size:x-small;text-align:center;padding:2px;color:" + slice.color + ";'>" + label + "<br/>" + Math.round(slice.percent) + "%</div>";
}, // formatter function
radius: 1, // radius at which to place the labels (based on full calculated radius if <=1, or hard pixel value)
background: {
color: null,
opacity: 0
},
threshold: 0 // percentage at which to hide the label (i.e. the slice is too narrow)
},
combine: {
threshold: -1, // percentage at which to combine little slices into one larger slice
color: null, // color to give the new slice (auto-generated if null)
label: "Other" // label to give the new slice
},
highlight: {
//color: "#fff", // will add this functionality once parseColor is available
opacity: 0.5
}
}
}
};
$.plot.plugins.push({
init: init,
options: options,
name: "pie",
version: "1.1"
});
})(jQuery);