Dashboard: new updated time picker (#20931)

* Dashboard: started to implement new time picker.

* TimePicker: working in full screen (except calendar).

* TimePicker: first draft on narrow screen variant.

* TimePicker: small adjustments to the narrow design.

* TimePicker: enabled range selection and started to style calendar.

* TimePicker: applied some more styling.

* Calendar: added so the calendar range selection is styled properly.

* Calendar: added styling for timepicker calendar in narrow screen.

* TimePicker: made it possible to select range from calendar.

* TimePicker: made the calendar have previous selected value.

* TimePicker: moved calendar to be able to update form state.

* TimePicker: calendar is now displayed onFocus or onClick.

* TimePicker: calendar will be closed if click outside input.

* Calendar: fixed the styling of the calendar in narrow screen.

* Calendar: made it work properly with narrow screen.

* TimePicker: connected recent to absolute time range.

* TimePicker: changed the label on recent ranges.

* TimePicker: cleaned up the range list and options.

* TimePicker: some more cleaning up.

* TimePicker: cleaned up the calendar a bit.

* TimePicker: some more refactorings.

* TimePicker: refactorings.

* TimePicker: styled modal properly.

* TimePicker: empty recent list.

* TimePicker: width when calendar in full screen.

* TimePicker: will validate input value.

* TimePicker: removed unused code.

* TimePicker: positioning with emotion instead of sass.

* Calendar: Made sure we send the dates in the correct order to the calendar.

* TimePicker: fixed theme.

* TimePicker: fixed positioning of the content.

* TimePicker: positioning of narrow.

* TimePicker: added some simple tets.

* TimePicker: fixed issue with invalid and added error message.

* TimePicker: added history.

* TimePicker: cleaned up snapshot data.

* TimePicker: fixed so we keep the quick values in the input.

* TimePicker: fixed the missing styling on UTC.

* TimePicker: added missing caret icon.

* TimePicker: fixed formatting on recent time ranges.

* TimePicker: added missing -.

* TimePicker: refactorings after feedback.

* TimePicker: renamed reserved prop name.

* TimePicker: added missing onChange call.

* TimePicker: removed alternative return type.

* TimePicker: fixed the sorting order on the recent list.

* TimePicker: added useCallback for the onEvent functions.

* TimePicker: moving away from default export.

* TimePicker: used the isMathString instead of private function.

* TimePicker: minor refactoring simplify the code.

* TimePicker: Added empty container that will expand when less then 4 recent searches.

* TimePicker: changed the top to be absolute relative to the container.

* TimePicker: updated snapshots for failing tests.

* Fixed shadow

* Move it down a bit

* added some more tests.

* Fixed so we change the anchor point of the time picker in really small screens.

* removed memo.

* fixed snapshot.

* Make sure that we always use the correct timeZone when formatting output.

* Fixed form background.

* Some minor fixes after demo.

* Making sure that empty info box is centered.

* updated snapshots for timepicker after css changes.

* fixed so we don't overflow when input validation error.

* adjusted final things on the time picker.

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Marcus Andersson 2019-12-20 15:31:58 +01:00 committed by GitHub
parent 104c2e3636
commit 587e4009f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 2525 additions and 782 deletions

View File

@ -78,14 +78,14 @@ export const Field: React.FC<FieldProps> = ({
)}
<div>
{React.cloneElement(children, { invalid, disabled, loading })}
{error && !horizontal && (
{invalid && error && !horizontal && (
<div className={styles.fieldValidationWrapper}>
<FieldValidationMessage>{error}</FieldValidationMessage>
</div>
)}
</div>
{error && horizontal && (
{invalid && error && horizontal && (
<div className={cx(styles.fieldValidationWrapper, styles.fieldValidationWrapperHorizontal)}>
<FieldValidationMessage>{error}</FieldValidationMessage>
</div>

View File

@ -115,7 +115,7 @@ export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDe
input: cx(
getFocusStyle(theme),
sharedInputStyle(theme),
sharedInputStyle(theme, invalid),
css`
label: input-input;
position: relative;
@ -211,8 +211,8 @@ export const Input: FC<Props> = props => {
*/
const [prefixRect, prefixRef] = useClientRect<HTMLDivElement>();
const [suffixRect, suffixRef] = useClientRect<HTMLDivElement>();
const theme = useTheme();
const theme = useTheme();
const styles = getInputStyles({ theme, invalid: !!invalid });
return (

View File

@ -1,13 +1,17 @@
import { getFormStyles } from './getFormStyles';
import { Label } from './Label';
import { Input } from './Input/Input';
import { Form } from './Form';
import { Field } from './Field';
import { Button } from './Button';
const Forms = {
getFormStyles,
Label: Label,
Input: Input,
Button: Button,
Label,
Input,
Form,
Field,
Button,
};
export default Forms;

View File

@ -4,12 +4,12 @@ import { action } from '@storybook/addon-actions';
import { TimePicker } from './TimePicker';
import { UseState } from '../../utils/storybook/UseState';
import { withRightAlignedStory } from '../../utils/storybook/withRightAlignedStory';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { TimeFragment, dateTime } from '@grafana/data';
const TimePickerStories = storiesOf('UI/TimePicker', module);
TimePickerStories.addDecorator(withRightAlignedStory);
TimePickerStories.addDecorator(withCenteredStory);
TimePickerStories.add('default', () => {
return (
@ -25,16 +25,6 @@ TimePickerStories.add('default', () => {
<TimePicker
timeZone="browser"
value={value}
selectOptions={[
{ 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 },
]}
onChange={timeRange => {
action('onChange fired')(timeRange);
updateValue(timeRange);

View File

@ -0,0 +1,46 @@
import React from 'react';
import { shallow } from 'enzyme';
import { UnthemedTimePicker } from './TimePicker';
import { dateTime, TimeRange } from '@grafana/data';
import dark from './../../themes/dark';
const from = '2019-12-17T07:48:27.433Z';
const to = '2019-12-18T07:48:27.433Z';
const value: TimeRange = {
from: dateTime(from),
to: dateTime(to),
raw: { from: dateTime(from), to: dateTime(to) },
};
describe('TimePicker', () => {
it('renders buttons correctly', () => {
const wrapper = shallow(
<UnthemedTimePicker
onChange={value => {}}
value={value}
onMoveBackward={() => {}}
onMoveForward={() => {}}
onZoom={() => {}}
theme={dark}
/>
);
expect(wrapper).toMatchSnapshot();
});
it('renders content correctly after beeing open', () => {
const wrapper = shallow(
<UnthemedTimePicker
onChange={value => {}}
value={value}
onMoveBackward={() => {}}
onMoveForward={() => {}}
onZoom={() => {}}
theme={dark}
/>
);
wrapper.find('button[aria-label="TimePicker Open Button"]').simulate('click', new Event('click'));
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -1,60 +1,23 @@
// Libraries
import React, { PureComponent, createRef } from 'react';
import React, { PureComponent, memo, FormEvent } from 'react';
import { css } from 'emotion';
import classNames from 'classnames';
// Components
import { ButtonSelect } from '../Select/ButtonSelect';
import { Tooltip } from '../Tooltip/Tooltip';
import { TimePickerPopover } from './TimePickerPopover';
import { TimePickerContent } from './TimePickerContent/TimePickerContent';
import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper';
// Utils & Services
import { isDateTime, DateTime, rangeUtil } from '@grafana/data';
import { rawToTimeRange } from './time';
import { stylesFactory } from '../../themes/stylesFactory';
import { withTheme } from '../../themes/ThemeContext';
import { withTheme, useTheme } from '../../themes/ThemeContext';
// Types
import { TimeRange, TimeOption, TimeZone, TIME_FORMAT, SelectableValue, dateMath, GrafanaTheme } from '@grafana/data';
import { isDateTime, DateTime, rangeUtil, GrafanaTheme, TIME_FORMAT } from '@grafana/data';
import { TimeRange, TimeOption, TimeZone, dateMath } from '@grafana/data';
import { Themeable } from '../../types';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
timePickerSynced: css`
label: timePickerSynced;
border-color: ${theme.colors.orangeDark};
background-image: none;
background-color: transparent;
color: ${theme.colors.orangeDark};
&:focus,
:hover {
color: ${theme.colors.orangeDark};
background-image: none;
background-color: transparent;
}
`,
noRightBorderStyle: css`
label: noRightBorderStyle;
border-right: 0;
`,
};
});
export interface Props extends Themeable {
hideText?: boolean;
value: TimeRange;
selectOptions: TimeOption[];
timeZone?: TimeZone;
timeSyncButton?: JSX.Element;
isSynced?: boolean;
onChange: (timeRange: TimeRange) => void;
onMoveBackward: () => void;
onMoveForward: () => void;
onZoom: () => void;
}
export const defaultSelectOptions: TimeOption[] = [
const quickOptions: TimeOption[] = [
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3 },
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3 },
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3 },
@ -71,6 +34,9 @@ export const defaultSelectOptions: TimeOption[] = [
{ 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 },
];
const otherOptions: TimeOption[] = [
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday', section: 3 },
{ from: 'now-2d/d', to: 'now-2d/d', display: 'Day before yesterday', section: 3 },
{ from: 'now-7d/d', to: 'now-7d/d', display: 'This day last week', section: 3 },
@ -87,69 +53,79 @@ export const defaultSelectOptions: TimeOption[] = [
{ from: 'now/y', to: 'now', display: 'This year so far', section: 3 },
];
const defaultZoomOutTooltip = () => {
return (
<>
Time range zoom out <br /> CTRL+Z
</>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
container: css`
position: relative;
display: flex;
flex-flow: column nowrap;
`,
buttons: css`
display: flex;
`,
caretIcon: css`
margin-left: 3px;
i {
font-size: ${theme.typography.size.md};
}
`,
};
});
const getLabelStyles = stylesFactory((theme: GrafanaTheme) => {
return {
container: css`
display: inline-block;
`,
utc: css`
color: ${theme.colors.orange};
font-size: 75%;
padding: 3px;
font-weight: ${theme.typography.weight.semibold};
`,
};
});
export interface Props extends Themeable {
hideText?: boolean;
value: TimeRange;
timeZone?: TimeZone;
timeSyncButton?: JSX.Element;
isSynced?: boolean;
onChange: (timeRange: TimeRange) => void;
onMoveBackward: () => void;
onMoveForward: () => void;
onZoom: () => void;
history?: TimeRange[];
}
export interface State {
isCustomOpen: boolean;
isOpen: boolean;
}
class UnThemedTimePicker extends PureComponent<Props, State> {
pickerTriggerRef = createRef<HTMLDivElement>();
export class UnthemedTimePicker extends PureComponent<Props, State> {
state: State = {
isCustomOpen: false,
isOpen: false,
};
mapTimeOptionsToSelectableValues = (selectOptions: TimeOption[]) => {
const options = selectOptions.map(timeOption => {
return {
label: timeOption.display,
value: timeOption,
};
});
options.unshift({
label: 'Custom time range',
value: { from: 'custom', to: 'custom', display: 'Custom', section: 1 },
});
return options;
onChange = (timeRange: TimeRange) => {
this.props.onChange(timeRange);
this.setState({ isOpen: false });
};
onSelectChanged = (item: SelectableValue<TimeOption>) => {
const { onChange, timeZone } = this.props;
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));
}
onOpen = (event: FormEvent<HTMLButtonElement>) => {
const { isOpen } = this.state;
event.stopPropagation();
this.setState({ isOpen: !isOpen });
};
onCustomChange = (timeRange: TimeRange) => {
const { onChange } = this.props;
onChange(timeRange);
this.setState({ isCustomOpen: false });
};
onCloseCustom = () => {
this.setState({ isCustomOpen: false });
onClose = () => {
this.setState({ isOpen: false });
};
render() {
const {
selectOptions: selectTimeOptions,
value,
onMoveBackward,
onMoveForward,
@ -158,56 +134,48 @@ class UnThemedTimePicker extends PureComponent<Props, State> {
timeSyncButton,
isSynced,
theme,
hideText,
history,
} = this.props;
const { isOpen } = this.state;
const styles = getStyles(theme);
const { isCustomOpen } = this.state;
const options = this.mapTimeOptionsToSelectableValues(selectTimeOptions);
const currentOption = options.find(item => isTimeOptionEqualToTimeRange(item.value, value));
const isUTC = timeZone === 'utc';
const adjustedTime = (time: DateTime) => (isUTC ? time.utc() : time.local()) || null;
const adjustedTimeRange = {
to: dateMath.isMathString(value.raw.to) ? value.raw.to : adjustedTime(value.to),
from: dateMath.isMathString(value.raw.from) ? value.raw.from : adjustedTime(value.from),
};
const rangeString = rangeUtil.describeTimeRange(adjustedTimeRange);
const label = !hideText ? (
<>
{isCustomOpen && <span>Custom time range</span>}
{!isCustomOpen && <span>{rangeString}</span>}
{isUTC && <span className="time-picker-utc">UTC</span>}
</>
) : (
''
);
const hasAbsolute = isDateTime(value.raw.from) || isDateTime(value.raw.to);
return (
<div className="time-picker" ref={this.pickerTriggerRef}>
<div className="time-picker-buttons">
<div className={styles.container}>
<div className={styles.buttons}>
{hasAbsolute && (
<button className="btn navbar-button navbar-button--tight" onClick={onMoveBackward}>
<i className="fa fa-chevron-left" />
</button>
)}
<ButtonSelect
className={classNames('time-picker-button-select', {
['explore-active-button-glow']: timeSyncButton && isSynced,
[`btn--radius-right-0 ${styles.noRightBorderStyle}`]: timeSyncButton,
[styles.timePickerSynced]: timeSyncButton ? isSynced : null,
})}
value={currentOption}
label={label}
options={options}
maxMenuHeight={600}
onChange={this.onSelectChanged}
iconClass={classNames('fa fa-clock-o fa-fw', isSynced && timeSyncButton && 'icon-brand-gradient')}
tooltipContent={<TimePickerTooltipContent timeRange={value} />}
/>
<div>
<Tooltip content={<TimePickerTooltip timeRange={value} />} placement="bottom">
<button
aria-label="TimePicker Open Button"
className="btn navbar-button navbar-button--zoom"
onClick={this.onOpen}
>
<i className={classNames('fa fa-clock-o fa-fw', isSynced && timeSyncButton && 'icon-brand-gradient')} />
<TimePickerButtonLabel {...this.props} />
<span className={styles.caretIcon}>
{isOpen ? <i className="fa fa-caret-up fa-fw" /> : <i className="fa fa-caret-down fa-fw" />}
</span>
</button>
</Tooltip>
{isOpen && (
<ClickOutsideWrapper onClick={this.onClose}>
<TimePickerContent
timeZone={timeZone}
value={value}
onChange={this.onChange}
otherOptions={otherOptions}
quickOptions={quickOptions}
history={history}
/>
</ClickOutsideWrapper>
)}
</div>
{timeSyncButton}
@ -217,24 +185,24 @@ class UnThemedTimePicker extends PureComponent<Props, State> {
</button>
)}
<Tooltip content={defaultZoomOutTooltip} placement="bottom">
<Tooltip content={ZoomOutTooltip} 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 }) => (
const ZoomOutTooltip = () => (
<>
Time range zoom out <br /> CTRL+Z
</>
);
const TimePickerTooltip = ({ timeRange }: { timeRange: TimeRange }) => (
<>
{timeRange.from.format(TIME_FORMAT)}
<div className="text-center">to</div>
@ -242,8 +210,36 @@ const TimePickerTooltipContent = ({ timeRange }: { timeRange: TimeRange }) => (
</>
);
function isTimeOptionEqualToTimeRange(option: TimeOption, range: TimeRange): boolean {
return range.raw.from === option.from && range.raw.to === option.to;
}
const TimePickerButtonLabel = memo<Props>(props => {
const theme = useTheme();
const styles = getLabelStyles(theme);
const isUTC = props.timeZone === 'utc';
export const TimePicker = withTheme(UnThemedTimePicker);
if (props.hideText) {
return null;
}
return (
<span className={styles.container}>
<span>{formattedRange(props.value, isUTC)}</span>
{isUTC && <span className={styles.utc}>UTC</span>}
</span>
);
});
const formattedRange = (value: TimeRange, isUTC: boolean) => {
const adjustedTimeRange = {
to: dateMath.isMathString(value.raw.to) ? value.raw.to : adjustedTime(value.to, isUTC),
from: dateMath.isMathString(value.raw.from) ? value.raw.from : adjustedTime(value.from, isUTC),
};
return rangeUtil.describeTimeRange(adjustedTimeRange);
};
const adjustedTime = (time: DateTime, isUTC: boolean) => {
if (isUTC) {
return time.utc() || null;
}
return time.local() || null;
};
export const TimePicker = withTheme(UnthemedTimePicker);

View File

@ -1,29 +0,0 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { TimePickerCalendar } from './TimePickerCalendar';
import { UseState } from '../../utils/storybook/UseState';
import { TimeFragment } from '@grafana/data';
const TimePickerCalendarStories = storiesOf('UI/TimePicker/TimePickerCalendar', module);
TimePickerCalendarStories.addDecorator(withCenteredStory);
TimePickerCalendarStories.add('default', () => (
<UseState initialState={'now-6h' as TimeFragment}>
{(value, updateValue) => {
return (
<TimePickerCalendar
timeZone="browser"
value={value}
onChange={timeRange => {
action('onChange fired')(timeRange);
updateValue(timeRange);
}}
/>
);
}}
</UseState>
));

View File

@ -1,61 +0,0 @@
import React, { PureComponent } from 'react';
import Calendar from 'react-calendar/dist/entry.nostyle';
import { TimeFragment, TimeZone, TIME_FORMAT } from '@grafana/data';
import { DateTime, dateTime, toUtc } from '@grafana/data';
import { stringToDateTimeType } from './time';
export interface Props {
value: TimeFragment;
roundup?: boolean;
timeZone?: TimeZone;
onChange: (value: DateTime) => void;
}
export class TimePickerCalendar extends PureComponent<Props> {
onCalendarChange = (date: Date | Date[]) => {
const { onChange, timeZone } = this.props;
if (Array.isArray(date)) {
return;
}
let newDate = dateTime(date);
if (timeZone === 'utc') {
newDate = toUtc(newDate.format(TIME_FORMAT));
}
onChange(newDate);
};
onDrilldown = (props: any) => {
// this is to prevent clickout side wrapper from triggering when drilling down
if (window.event) {
// @ts-ignore
window.event.stopPropagation();
}
};
render() {
const { value, roundup, timeZone } = this.props;
let date = stringToDateTimeType(value, roundup, timeZone);
if (!date.isValid()) {
date = dateTime();
}
return (
<Calendar
value={date.toDate()}
next2Label={null}
prev2Label={null}
className="time-picker-calendar"
tileClassName="time-picker-calendar-tile"
onChange={this.onCalendarChange}
onDrillDown={this.onDrilldown}
nextLabel={<span className="fa fa-angle-right" />}
prevLabel={<span className="fa fa-angle-left" />}
/>
);
}
}

View File

@ -0,0 +1,305 @@
import React, { memo } from 'react';
import { css, cx } from 'emotion';
import Calendar from 'react-calendar/dist/entry.nostyle';
import { GrafanaTheme, dateTime, TIME_FORMAT } from '@grafana/data';
import { stringToDateTimeType } from '../time';
import { useTheme, stylesFactory } from '../../../themes';
import { TimePickerTitle } from './TimePickerTitle';
import Forms from '../../Forms';
import { Portal } from '../../Portal/Portal';
import { getThemeColors } from './colors';
import { ClickOutsideWrapper } from '../../ClickOutsideWrapper/ClickOutsideWrapper';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const colors = getThemeColors(theme);
return {
container: css`
top: 0;
position: absolute;
right: 546px;
box-shadow: 0px 0px 20px ${colors.shadow};
background-color: ${colors.background};
z-index: -1;
&:after {
display: block;
background-color: ${colors.background};
width: 19px;
height: 381px;
content: ' ';
position: absolute;
top: 0;
right: -19px;
border-left: 1px solid ${colors.border};
}
`,
modal: css`
position: fixed;
top: 20%;
width: 100%;
z-index: ${theme.zIndex.modal};
`,
content: css`
margin: 0 auto;
width: 268px;
`,
backdrop: css`
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: #202226;
opacity: 0.7;
z-index: ${theme.zIndex.modalBackdrop};
text-align: center;
`,
};
});
const getFooterStyles = stylesFactory((theme: GrafanaTheme) => {
const colors = getThemeColors(theme);
return {
container: css`
background-color: ${colors.background};
display: flex;
justify-content: center;
padding: 10px;
align-items: stretch;
`,
apply: css`
margin-right: 4px;
width: 100%;
justify-content: center;
`,
};
});
const getBodyStyles = stylesFactory((theme: GrafanaTheme) => {
const colors = getThemeColors(theme);
return {
title: css`
color: ${theme.colors.text}
background-color: ${colors.background};
line-height: 21px;
font-size: ${theme.typography.size.md};
border: 1px solid transparent;
&:hover {
position: relative;
}
`,
body: css`
z-index: ${theme.zIndex.modal};
background-color: ${colors.background};
width: 268px;
.react-calendar__navigation__label,
.react-calendar__navigation__arrow,
.react-calendar__navigation {
padding-top: 4px;
background-color: inherit;
color: ${theme.colors.text};
border: 0;
font-weight: ${theme.typography.weight.semibold};
}
.react-calendar__month-view__weekdays {
background-color: inherit;
text-align: center;
color: ${theme.colors.blueShade};
abbr {
border: 0;
text-decoration: none;
cursor: default;
display: block;
padding: 4px 0 4px 0;
}
}
.react-calendar__month-view__days {
background-color: inherit;
}
.react-calendar__tile,
.react-calendar__tile--now {
margin-bottom: 4px;
background-color: inherit;
}
.react-calendar__navigation__label,
.react-calendar__navigation > button:focus,
.time-picker-calendar-tile:focus {
outline: 0;
}
.react-calendar__tile--active,
.react-calendar__tile--active:hover {
color: ${theme.colors.white};
font-weight: ${theme.typography.weight.semibold};
background: ${theme.colors.blue95};
box-shadow: none;
border: 0px;
}
.react-calendar__tile--rangeEnd,
.react-calendar__tile--rangeStart {
padding: 0;
border: 0px;
color: ${theme.colors.white};
font-weight: ${theme.typography.weight.semibold};
background: ${theme.colors.blue95};
abbr {
background-color: ${theme.colors.blue77};
border-radius: 100px;
display: block;
padding: 2px 7px 3px;
}
}
.react-calendar__tile--rangeStart {
border-top-left-radius: 20px;
border-bottom-left-radius: 20px;
}
.react-calendar__tile--rangeEnd {
border-top-right-radius: 20px;
border-bottom-right-radius: 20px;
}
`,
};
});
const getHeaderStyles = stylesFactory((theme: GrafanaTheme) => {
const colors = getThemeColors(theme);
return {
container: css`
background-color: ${colors.background};
display: flex;
justify-content: space-between;
padding: 7px;
`,
close: css`
cursor: pointer;
font-size: ${theme.typography.size.lg};
`,
};
});
interface Props {
isOpen: boolean;
from: string;
to: string;
onClose: () => void;
onApply: () => void;
onChange: (from: string, to: string) => void;
isFullscreen: boolean;
}
export const TimePickerCalendar = memo<Props>(props => {
const theme = useTheme();
const styles = getStyles(theme);
const { isOpen, isFullscreen } = props;
if (!isOpen) {
return null;
}
if (isFullscreen) {
return (
<ClickOutsideWrapper onClick={props.onClose}>
<div className={styles.container}>
<Body {...props} />
</div>
</ClickOutsideWrapper>
);
}
return (
<Portal>
<div className={styles.modal} onClick={event => event.stopPropagation()}>
<div className={styles.content}>
<Header {...props} />
<Body {...props} />
<Footer {...props} />
</div>
</div>
<div className={styles.backdrop} onClick={event => event.stopPropagation()} />
</Portal>
);
});
const Header = memo<Props>(({ onClose }) => {
const theme = useTheme();
const styles = getHeaderStyles(theme);
return (
<div className={styles.container}>
<TimePickerTitle>Select a time range</TimePickerTitle>
<i className={cx(styles.close, 'fa', 'fa-times')} onClick={onClose} />
</div>
);
});
const Body = memo<Props>(props => {
const theme = useTheme();
const styles = getBodyStyles(theme);
const { from, to, onChange } = props;
return (
<Calendar
selectRange={true}
next2Label={null}
prev2Label={null}
className={styles.body}
tileClassName={styles.title}
value={inputToValue(from, to)}
nextLabel={<span className="fa fa-angle-right" />}
prevLabel={<span className="fa fa-angle-left" />}
onChange={value => valueToInput(value, onChange)}
locale="en"
/>
);
});
const Footer = memo<Props>(({ onClose, onApply }) => {
const theme = useTheme();
const styles = getFooterStyles(theme);
return (
<div className={styles.container}>
<Forms.Button className={styles.apply} onClick={onApply}>
Apply time range
</Forms.Button>
<Forms.Button variant="secondary" onClick={onClose}>
Cancel
</Forms.Button>
</div>
);
});
function inputToValue(from: string, to: string): Date[] {
const fromAsDateTime = stringToDateTimeType(from);
const toAsDateTime = stringToDateTimeType(to);
const fromAsDate = fromAsDateTime.isValid() ? fromAsDateTime.toDate() : new Date();
const toAsDate = toAsDateTime.isValid() ? toAsDateTime.toDate() : new Date();
if (fromAsDate > toAsDate) {
return [toAsDate, fromAsDate];
}
return [fromAsDate, toAsDate];
}
function valueToInput(value: Date | Date[], onChange: (from: string, to: string) => void): void {
const [from, to] = value;
const fromAsString = dateTime(from).format(TIME_FORMAT);
const toAsString = dateTime(to).format(TIME_FORMAT);
return onChange(fromAsString, toAsString);
}

View File

@ -0,0 +1,49 @@
import React from 'react';
import { shallow } from 'enzyme';
import { TimePickerContentWithScreenSize } from './TimePickerContent';
import { dateTime, TimeRange } from '@grafana/data';
describe('TimePickerContent', () => {
it('renders correctly in full screen', () => {
const value = createTimeRange('2019-12-17T07:48:27.433Z', '2019-12-18T07:48:27.433Z');
const wrapper = shallow(
<TimePickerContentWithScreenSize onChange={value => {}} timeZone="utc" value={value} isFullscreen={true} />
);
expect(wrapper).toMatchSnapshot();
});
it('renders correctly in narrow screen', () => {
const value = createTimeRange('2019-12-17T07:48:27.433Z', '2019-12-18T07:48:27.433Z');
const wrapper = shallow(
<TimePickerContentWithScreenSize onChange={value => {}} timeZone="utc" value={value} isFullscreen={false} />
);
expect(wrapper).toMatchSnapshot();
});
it('renders recent absolute ranges correctly', () => {
const value = createTimeRange('2019-12-17T07:48:27.433Z', '2019-12-18T07:48:27.433Z');
const history = [
createTimeRange('2019-12-17T07:48:27.433Z', '2019-12-18T07:48:27.433Z'),
createTimeRange('2019-10-17T07:48:27.433Z', '2019-10-18T07:48:27.433Z'),
];
const wrapper = shallow(
<TimePickerContentWithScreenSize
onChange={value => {}}
timeZone="utc"
value={value}
isFullscreen={true}
history={history}
/>
);
expect(wrapper).toMatchSnapshot();
});
});
function createTimeRange(from: string, to: string): TimeRange {
return {
from: dateTime(from),
to: dateTime(to),
raw: { from: dateTime(from), to: dateTime(to) },
};
}

View File

@ -0,0 +1,279 @@
import React, { useState, memo } from 'react';
import { useMedia } from 'react-use';
import { css } from 'emotion';
import { useTheme, stylesFactory } from '../../../themes';
import { GrafanaTheme, TimeOption, TimeRange, TimeZone, isDateTime } from '@grafana/data';
import { TimePickerTitle } from './TimePickerTitle';
import { TimeRangeForm } from './TimeRangeForm';
import { CustomScrollbar } from '../../CustomScrollbar/CustomScrollbar';
import { TimeRangeList } from './TimeRangeList';
import { mapRangeToTimeOption } from './mapper';
import { getThemeColors } from './colors';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const colors = getThemeColors(theme);
return {
container: css`
display: flex;
background: ${colors.background};
box-shadow: 0px 0px 20px ${colors.shadow};
position: absolute;
z-index: ${theme.zIndex.modal};
width: 546px;
height: 381px;
top: 116%;
margin-left: -322px;
@media only screen and (max-width: ${theme.breakpoints.lg}) {
width: 218px;
margin-left: 6px;
}
@media only screen and (max-width: ${theme.breakpoints.sm}) {
width: 264px;
margin-left: -100px;
}
`,
leftSide: css`
display: flex;
flex-direction: column;
border-right: 1px solid ${colors.border};
width: 60%;
overflow: hidden;
@media only screen and (max-width: ${theme.breakpoints.lg}) {
display: none;
}
`,
rightSide: css`
width: 40% !important;
@media only screen and (max-width: ${theme.breakpoints.lg}) {
width: 100% !important;
}
`,
spacing: css`
margin-top: 16px;
`,
};
});
const getNarrowScreenStyles = stylesFactory((theme: GrafanaTheme) => {
const colors = getThemeColors(theme);
return {
header: css`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid ${colors.border};
padding: 7px 9px 7px 9px;
`,
body: css`
border-bottom: 1px solid ${colors.border};
background: ${colors.formBackground};
box-shadow: inset 0px 2px 2px ${colors.shadow};
`,
form: css`
padding: 7px 9px 7px 9px;
`,
};
});
const getFullScreenStyles = stylesFactory((theme: GrafanaTheme) => {
return {
container: css`
padding-top: 9px;
padding-left: 11px;
padding-right: 20%;
`,
title: css`
margin-bottom: 11px;
`,
recent: css`
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
`,
};
});
const getEmptyListStyles = stylesFactory((theme: GrafanaTheme) => {
const colors = getThemeColors(theme);
return {
container: css`
background-color: ${colors.formBackground};
padding: 12px;
margin: 12px;
a,
span {
font-size: 13px;
}
`,
link: css`
color: ${theme.colors.linkExternal};
`,
};
});
interface Props {
value: TimeRange;
onChange: (timeRange: TimeRange) => void;
timeZone?: TimeZone;
quickOptions?: TimeOption[];
otherOptions?: TimeOption[];
history?: TimeRange[];
}
interface PropsWithScreenSize extends Props {
isFullscreen: boolean;
}
interface FormProps extends Omit<Props, 'history'> {
visible: boolean;
historyOptions?: TimeOption[];
}
export const TimePickerContentWithScreenSize: React.FC<PropsWithScreenSize> = props => {
const theme = useTheme();
const styles = getStyles(theme);
const historyOptions = mapToHistoryOptions(props.history, props.timeZone);
const { quickOptions = [], otherOptions = [], isFullscreen } = props;
return (
<div className={styles.container}>
<div className={styles.leftSide}>
<FullScreenForm {...props} visible={isFullscreen} historyOptions={historyOptions} />
</div>
<CustomScrollbar className={styles.rightSide}>
<NarrowScreenForm {...props} visible={!isFullscreen} historyOptions={historyOptions} />
<TimeRangeList
title="Relative time ranges"
options={quickOptions}
onSelect={props.onChange}
value={props.value}
timeZone={props.timeZone}
/>
<div className={styles.spacing} />
<TimeRangeList
title="Other quick ranges"
options={otherOptions}
onSelect={props.onChange}
value={props.value}
timeZone={props.timeZone}
/>
</CustomScrollbar>
</div>
);
};
export const TimePickerContent: React.FC<Props> = props => {
const theme = useTheme();
const isFullscreen = useMedia(`(min-width: ${theme.breakpoints.lg})`);
return <TimePickerContentWithScreenSize {...props} isFullscreen={isFullscreen} />;
};
const NarrowScreenForm: React.FC<FormProps> = props => {
if (!props.visible) {
return null;
}
const theme = useTheme();
const styles = getNarrowScreenStyles(theme);
const isAbsolute = isDateTime(props.value.raw.from) || isDateTime(props.value.raw.to);
const [collapsed, setCollapsed] = useState(isAbsolute);
return (
<>
<div className={styles.header} onClick={() => setCollapsed(!collapsed)}>
<TimePickerTitle>Absolute time range</TimePickerTitle>
{collapsed ? <i className="fa fa-caret-up" /> : <i className="fa fa-caret-down" />}
</div>
{collapsed && (
<div className={styles.body}>
<div className={styles.form}>
<TimeRangeForm
value={props.value}
onApply={props.onChange}
timeZone={props.timeZone}
isFullscreen={false}
/>
</div>
<TimeRangeList
title="Recently used absolute ranges"
options={props.historyOptions || []}
onSelect={props.onChange}
value={props.value}
placeholderEmpty={null}
timeZone={props.timeZone}
/>
</div>
)}
</>
);
};
const FullScreenForm: React.FC<FormProps> = props => {
if (!props.visible) {
return null;
}
const theme = useTheme();
const styles = getFullScreenStyles(theme);
return (
<>
<div className={styles.container}>
<div className={styles.title}>
<TimePickerTitle>Absolute time range</TimePickerTitle>
</div>
<TimeRangeForm value={props.value} timeZone={props.timeZone} onApply={props.onChange} isFullscreen={true} />
</div>
<div className={styles.recent}>
<TimeRangeList
title="Recently used absolute ranges"
options={props.historyOptions || []}
onSelect={props.onChange}
value={props.value}
placeholderEmpty={<EmptyRecentList />}
timeZone={props.timeZone}
/>
</div>
</>
);
};
const EmptyRecentList = memo(() => {
const theme = useTheme();
const styles = getEmptyListStyles(theme);
return (
<div className={styles.container}>
<div>
<span>
It looks like you haven't used this timer picker before. As soon as you enter some time intervals, recently
used intervals will appear here.
</span>
</div>
<div>
<a className={styles.link} href="https://grafana.com/docs/grafana/latest/reference/timerange/" target="_new">
Read the documentation
</a>
<span> to find out more about how to enter custom time ranges.</span>
</div>
</div>
);
});
function mapToHistoryOptions(ranges?: TimeRange[], timeZone?: TimeZone): TimeOption[] {
if (!Array.isArray(ranges) || ranges.length === 0) {
return [];
}
return ranges.slice(ranges.length - 4).map(range => mapRangeToTimeOption(range, timeZone));
}

View File

@ -0,0 +1,21 @@
import React, { memo, PropsWithChildren } from 'react';
import { css } from 'emotion';
import { GrafanaTheme } from '@grafana/data';
import { useTheme, stylesFactory } from '../../../themes';
const getStyle = stylesFactory((theme: GrafanaTheme) => {
return {
text: css`
font-size: ${theme.typography.size.md};
font-weight: ${theme.typography.weight.semibold};
color: ${theme.colors.formLabel};
`,
};
});
export const TimePickerTitle = memo<PropsWithChildren<{}>>(({ children }) => {
const theme = useTheme();
const styles = getStyle(theme);
return <span className={styles.text}>{children}</span>;
});

View File

@ -0,0 +1,125 @@
import React, { FormEvent, useState, useCallback } from 'react';
import { TIME_FORMAT, TimeZone, isDateTime, TimeRange, DateTime } from '@grafana/data';
import { stringToDateTimeType, isValidTimeString } from '../time';
import { mapStringsToTimeRange } from './mapper';
import { TimePickerCalendar } from './TimePickerCalendar';
import Forms from '../../Forms';
import { isMathString } from '@grafana/data/src/datetime/datemath';
interface Props {
isFullscreen: boolean;
value: TimeRange;
onApply: (range: TimeRange) => void;
timeZone?: TimeZone;
roundup?: boolean;
}
interface InputState {
value: string;
invalid: boolean;
}
const errorMessage = 'Please enter a past date or "now"';
export const TimeRangeForm: React.FC<Props> = props => {
const { value, isFullscreen = false, timeZone, roundup } = props;
const [from, setFrom] = useState<InputState>(valueToState(value.raw.from, false, timeZone));
const [to, setTo] = useState<InputState>(valueToState(value.raw.to, true, timeZone));
const [isOpen, setOpen] = useState(false);
const onOpen = useCallback(
(event: FormEvent<HTMLElement>) => {
event.preventDefault();
setOpen(true);
},
[setOpen]
);
const onFocus = useCallback(
(event: FormEvent<HTMLElement>) => {
if (!isFullscreen) {
return;
}
onOpen(event);
},
[isFullscreen, onOpen]
);
const onApply = useCallback(() => {
if (to.invalid || from.invalid) {
return;
}
props.onApply(mapStringsToTimeRange(from.value, to.value, roundup, timeZone));
}, [from, to, roundup, timeZone]);
const onChange = useCallback(
(from: string, to: string) => {
setFrom(valueToState(from, false, timeZone));
setTo(valueToState(to, true, timeZone));
},
[timeZone]
);
const icon = isFullscreen ? null : <Forms.Button icon="fa fa-calendar" variant="secondary" onClick={onOpen} />;
return (
<>
<Forms.Field label="From" invalid={from.invalid} error={errorMessage}>
<Forms.Input
onClick={event => event.stopPropagation()}
onFocus={onFocus}
onChange={event => setFrom(eventToState(event, false, timeZone))}
addonAfter={icon}
value={from.value}
/>
</Forms.Field>
<Forms.Field label="To" invalid={to.invalid} error={errorMessage}>
<Forms.Input
onClick={event => event.stopPropagation()}
onFocus={onFocus}
onChange={event => setTo(eventToState(event, true, timeZone))}
addonAfter={icon}
value={to.value}
/>
</Forms.Field>
<Forms.Button onClick={onApply}>Apply time range</Forms.Button>
<TimePickerCalendar
isFullscreen={isFullscreen}
isOpen={isOpen}
from={from.value}
to={to.value}
onApply={onApply}
onClose={() => setOpen(false)}
onChange={onChange}
/>
</>
);
};
function eventToState(event: FormEvent<HTMLInputElement>, roundup?: boolean, timeZone?: TimeZone): InputState {
return valueToState(event.currentTarget.value, roundup, timeZone);
}
function valueToState(raw: DateTime | string, roundup?: boolean, timeZone?: TimeZone): InputState {
const value = valueAsString(raw);
const invalid = !isValid(value, roundup, timeZone);
return { value, invalid };
}
function valueAsString(value: DateTime | string): string {
if (isDateTime(value)) {
return value.format(TIME_FORMAT);
}
return value;
}
function isValid(value: string, roundup?: boolean, timeZone?: TimeZone): boolean {
if (isMathString(value)) {
return isValidTimeString(value);
}
const parsed = stringToDateTimeType(value, roundup, timeZone);
return parsed.isValid();
}

View File

@ -0,0 +1,90 @@
import React, { ReactNode } from 'react';
import { css } from 'emotion';
import { TimeOption, TimeZone } from '@grafana/data';
import { TimeRange } from '@grafana/data';
import { TimePickerTitle } from './TimePickerTitle';
import { TimeRangeOption } from './TimeRangeOption';
import { mapOptionToTimeRange } from './mapper';
import { stylesFactory } from '../../../themes';
const getStyles = stylesFactory(() => {
return {
title: css`
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px 5px 9px;
`,
};
});
const getOptionsStyles = stylesFactory(() => {
return {
grow: css`
flex-grow: 1;
align-items: flex-start;
`,
};
});
interface Props {
title?: string;
options: TimeOption[];
value?: TimeRange;
onSelect: (option: TimeRange) => void;
placeholderEmpty?: ReactNode;
timeZone?: TimeZone;
}
export const TimeRangeList: React.FC<Props> = props => {
const styles = getStyles();
const { title, options, placeholderEmpty } = props;
if (typeof placeholderEmpty !== 'undefined' && options.length <= 0) {
return <>{placeholderEmpty}</>;
}
if (!title) {
return <Options {...props} />;
}
return (
<>
<div className={styles.title}>
<TimePickerTitle>{title}</TimePickerTitle>
</div>
<Options {...props} />
</>
);
};
const Options: React.FC<Props> = ({ options, value, onSelect, timeZone }) => {
const styles = getOptionsStyles();
return (
<>
<div>
{options.map((option, index) => (
<TimeRangeOption
key={keyForOption(option, index)}
value={option}
selected={isEqual(option, value)}
onSelect={option => onSelect(mapOptionToTimeRange(option, timeZone))}
/>
))}
</div>
<div className={styles.grow}></div>
</>
);
};
function keyForOption(option: TimeOption, index: number): string {
return `${option.from}-${option.to}-${index}`;
}
function isEqual(x: TimeOption, y?: TimeRange): boolean {
if (!y || !x) {
return false;
}
return y.raw.from === x.from && y.raw.to === x.to;
}

View File

@ -0,0 +1,54 @@
import React, { memo } from 'react';
import { css } from 'emotion';
import { GrafanaTheme, TimeOption } from '@grafana/data';
import { useTheme, stylesFactory, selectThemeVariant } from '../../../themes';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const background = selectThemeVariant(
{
light: theme.colors.gray7,
dark: theme.colors.dark3,
},
theme.type
);
return {
container: css`
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 9px 7px 9px;
border-left: 2px solid rgba(255, 255, 255, 0);
&:hover {
background: ${background};
border-image: linear-gradient(#f05a28 30%, #fbca0a 99%);
border-image-slice: 1;
border-style: solid;
border-top: 0;
border-right: 0;
border-bottom: 0;
border-left-width: 2px;
cursor: pointer;
}
`,
};
});
interface Props {
value: TimeOption;
selected?: boolean;
onSelect: (option: TimeOption) => void;
}
export const TimeRangeOption = memo<Props>(({ value, onSelect, selected = false }) => {
const theme = useTheme();
const styles = getStyles(theme);
return (
<div className={styles.container} onClick={() => onSelect(value)} tabIndex={-1}>
<span>{value.display}</span>
{selected ? <i className="fa fa-check" /> : null}
</div>
);
});

View File

@ -0,0 +1,344 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TimePickerContent renders correctly in full screen 1`] = `
<div
className="css-1fbt695"
>
<div
className="css-13dsoi7"
>
<FullScreenForm
historyOptions={Array []}
isFullscreen={true}
onChange={[Function]}
timeZone="utc"
value={
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
}
}
visible={true}
/>
</div>
<CustomScrollbar
autoHeightMax="100%"
autoHeightMin="0"
autoHide={false}
autoHideDuration={200}
autoHideTimeout={200}
className="css-1o1b8dr"
hideTracksWhenNotNeeded={false}
setScrollTop={[Function]}
>
<NarrowScreenForm
historyOptions={Array []}
isFullscreen={true}
onChange={[Function]}
timeZone="utc"
value={
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
}
}
visible={false}
/>
<Component
onSelect={[Function]}
options={Array []}
timeZone="utc"
title="Relative time ranges"
value={
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
}
}
/>
<div
className="css-1ogeuxc"
/>
<Component
onSelect={[Function]}
options={Array []}
timeZone="utc"
title="Other quick ranges"
value={
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
}
}
/>
</CustomScrollbar>
</div>
`;
exports[`TimePickerContent renders correctly in narrow screen 1`] = `
<div
className="css-1fbt695"
>
<div
className="css-13dsoi7"
>
<FullScreenForm
historyOptions={Array []}
isFullscreen={false}
onChange={[Function]}
timeZone="utc"
value={
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
}
}
visible={false}
/>
</div>
<CustomScrollbar
autoHeightMax="100%"
autoHeightMin="0"
autoHide={false}
autoHideDuration={200}
autoHideTimeout={200}
className="css-1o1b8dr"
hideTracksWhenNotNeeded={false}
setScrollTop={[Function]}
>
<NarrowScreenForm
historyOptions={Array []}
isFullscreen={false}
onChange={[Function]}
timeZone="utc"
value={
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
}
}
visible={true}
/>
<Component
onSelect={[Function]}
options={Array []}
timeZone="utc"
title="Relative time ranges"
value={
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
}
}
/>
<div
className="css-1ogeuxc"
/>
<Component
onSelect={[Function]}
options={Array []}
timeZone="utc"
title="Other quick ranges"
value={
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
}
}
/>
</CustomScrollbar>
</div>
`;
exports[`TimePickerContent renders recent absolute ranges correctly 1`] = `
<div
className="css-1fbt695"
>
<div
className="css-13dsoi7"
>
<FullScreenForm
history={
Array [
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
},
Object {
"from": "2019-10-17T07:48:27.433Z",
"raw": Object {
"from": "2019-10-17T07:48:27.433Z",
"to": "2019-10-18T07:48:27.433Z",
},
"to": "2019-10-18T07:48:27.433Z",
},
]
}
historyOptions={
Array [
Object {
"display": "2019-12-17 07:48:27 to 2019-12-18 07:48:27",
"from": "2019-12-17 07:48:27",
"section": 3,
"to": "2019-12-18 07:48:27",
},
Object {
"display": "2019-10-17 07:48:27 to 2019-10-18 07:48:27",
"from": "2019-10-17 07:48:27",
"section": 3,
"to": "2019-10-18 07:48:27",
},
]
}
isFullscreen={true}
onChange={[Function]}
timeZone="utc"
value={
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
}
}
visible={true}
/>
</div>
<CustomScrollbar
autoHeightMax="100%"
autoHeightMin="0"
autoHide={false}
autoHideDuration={200}
autoHideTimeout={200}
className="css-1o1b8dr"
hideTracksWhenNotNeeded={false}
setScrollTop={[Function]}
>
<NarrowScreenForm
history={
Array [
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
},
Object {
"from": "2019-10-17T07:48:27.433Z",
"raw": Object {
"from": "2019-10-17T07:48:27.433Z",
"to": "2019-10-18T07:48:27.433Z",
},
"to": "2019-10-18T07:48:27.433Z",
},
]
}
historyOptions={
Array [
Object {
"display": "2019-12-17 07:48:27 to 2019-12-18 07:48:27",
"from": "2019-12-17 07:48:27",
"section": 3,
"to": "2019-12-18 07:48:27",
},
Object {
"display": "2019-10-17 07:48:27 to 2019-10-18 07:48:27",
"from": "2019-10-17 07:48:27",
"section": 3,
"to": "2019-10-18 07:48:27",
},
]
}
isFullscreen={true}
onChange={[Function]}
timeZone="utc"
value={
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
}
}
visible={false}
/>
<Component
onSelect={[Function]}
options={Array []}
timeZone="utc"
title="Relative time ranges"
value={
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
}
}
/>
<div
className="css-1ogeuxc"
/>
<Component
onSelect={[Function]}
options={Array []}
timeZone="utc"
title="Other quick ranges"
value={
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
}
}
/>
</CustomScrollbar>
</div>
`;

View File

@ -0,0 +1,35 @@
import { GrafanaTheme } from '@grafana/data';
import { selectThemeVariant } from '../../../themes/selectThemeVariant';
export const getThemeColors = (theme: GrafanaTheme) => {
return {
border: selectThemeVariant(
{
light: theme.colors.gray4,
dark: theme.colors.gray25,
},
theme.type
),
background: selectThemeVariant(
{
dark: theme.colors.dark2,
light: theme.background.dropdown,
},
theme.type
),
shadow: selectThemeVariant(
{
light: theme.colors.gray85,
dark: theme.colors.black,
},
theme.type
),
formBackground: selectThemeVariant(
{
dark: theme.colors.gray15,
light: theme.colors.gray98,
},
theme.type
),
};
};

View File

@ -0,0 +1,95 @@
import {
TimeOption,
TimeRange,
isDateTime,
DateTime,
TimeZone,
dateMath,
dateTime,
dateTimeForTimeZone,
TIME_FORMAT,
} from '@grafana/data';
import { stringToDateTimeType } from '../time';
import { isMathString } from '@grafana/data/src/datetime/datemath';
export const mapOptionToTimeRange = (option: TimeOption, timeZone?: TimeZone): TimeRange => {
return {
from: stringToDateTime(option.from, false, timeZone),
to: stringToDateTime(option.to, true, timeZone),
raw: {
from: option.from,
to: option.to,
},
};
};
export const mapRangeToTimeOption = (range: TimeRange, timeZone?: TimeZone): TimeOption => {
const formattedFrom = stringToDateTime(range.from, false, timeZone).format(TIME_FORMAT);
const formattedTo = stringToDateTime(range.to, true, timeZone).format(TIME_FORMAT);
const from = dateTimeToString(range.from, timeZone === 'utc');
const to = dateTimeToString(range.to, timeZone === 'utc');
return {
from,
to,
section: 3,
display: `${formattedFrom} to ${formattedTo}`,
};
};
export const mapStringsToTimeRange = (from: string, to: string, roundup?: boolean, timeZone?: TimeZone): TimeRange => {
const fromDate = stringToDateTimeType(from, roundup, timeZone);
const toDate = stringToDateTimeType(to, roundup, timeZone);
if (isMathString(from) || isMathString(to)) {
return {
from: fromDate,
to: toDate,
raw: {
from,
to,
},
};
}
return {
from: fromDate,
to: toDate,
raw: {
from: fromDate,
to: toDate,
},
};
};
const stringToDateTime = (value: string | DateTime, roundUp?: boolean, timeZone?: TimeZone): DateTime => {
if (isDateTime(value)) {
if (timeZone === 'utc') {
return value.utc();
}
return value;
}
if (value.indexOf('now') !== -1) {
if (!dateMath.isValid(value)) {
return dateTime();
}
const parsed = dateMath.parse(value, roundUp, timeZone);
return parsed || dateTime();
}
return dateTimeForTimeZone(timeZone, value, TIME_FORMAT);
};
const dateTimeToString = (value: DateTime, isUtc: boolean): string => {
if (!isDateTime(value)) {
return value;
}
if (isUtc) {
return value.utc().format(TIME_FORMAT);
}
return value.format(TIME_FORMAT);
};

View File

@ -1,60 +0,0 @@
import React, { PureComponent, ChangeEvent } from 'react';
import { TimeFragment, TIME_FORMAT, TimeZone, isDateTime } from '@grafana/data';
import { Input } from '../Input/Input';
import { stringToDateTimeType, isValidTimeString } from './time';
export interface Props {
value: TimeFragment;
roundup?: boolean;
timeZone?: TimeZone;
onChange: (value: string, isValid: boolean) => void;
tabIndex?: number;
}
export class TimePickerInput extends PureComponent<Props> {
isValid = (value: string) => {
const { timeZone, roundup } = this.props;
if (value.indexOf('now') !== -1) {
const isValid = isValidTimeString(value);
return isValid;
}
const parsed = stringToDateTimeType(value, roundup, timeZone);
const isValid = parsed.isValid();
return isValid;
};
onChange = (event: ChangeEvent<HTMLInputElement>) => {
const { onChange } = this.props;
const value = event.target.value;
onChange(value, this.isValid(value));
};
valueToString = (value: TimeFragment) => {
if (isDateTime(value)) {
return value.format(TIME_FORMAT);
} else {
return value;
}
};
render() {
const { value, tabIndex } = this.props;
const valueString = this.valueToString(value);
const error = !this.isValid(valueString);
return (
<Input
type="text"
onChange={this.onChange}
onBlur={this.onChange}
hideErrorMessage={true}
value={valueString}
className={`time-picker-input${error ? '-error' : ''}`}
tabIndex={tabIndex}
/>
);
}
}

View File

@ -1,35 +0,0 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import { storiesOf } from '@storybook/react';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { TimePickerPopover } from './TimePickerPopover';
import { UseState } from '../../utils/storybook/UseState';
import { dateTime, DateTime } from '@grafana/data';
const TimePickerPopoverStories = storiesOf('UI/TimePicker/TimePickerPopover', module);
TimePickerPopoverStories.addDecorator(withCenteredStory);
TimePickerPopoverStories.add('default', () => (
<UseState
initialState={{
from: dateTime(),
to: dateTime(),
raw: { from: 'now-6h' as string | DateTime, to: 'now' as string | DateTime },
}}
>
{(value, updateValue) => {
return (
<TimePickerPopover
value={value}
timeZone="browser"
onChange={timeRange => {
action('onChange fired')(timeRange);
updateValue(timeRange);
}}
/>
);
}}
</UseState>
));

View File

@ -1,122 +0,0 @@
// Libraries
import React, { Component } from 'react';
// Components
import { TimePickerCalendar } from './TimePickerCalendar';
import { TimePickerInput } from './TimePickerInput';
import { rawToTimeRange } from './time';
// Types
import { DateTime, TimeRange, TimeZone } from '@grafana/data';
export interface Props {
value: TimeRange;
timeZone?: TimeZone;
onChange: (timeRange: TimeRange) => void;
}
export interface State {
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 = {
from: props.value.raw.from,
to: props.value.raw.to,
isFromInputValid: true,
isToInputValid: true,
};
}
onFromInputChanged = (value: string, valid: boolean) => {
this.setState({ from: value, isFromInputValid: valid });
};
onToInputChanged = (value: string, valid: boolean) => {
this.setState({ to: value, isToInputValid: valid });
};
onFromCalendarChanged = (value: DateTime) => {
this.setState({ from: value });
};
onToCalendarChanged = (value: DateTime) => {
value.set('h', 23);
value.set('m', 59);
value.set('s', 59);
this.setState({ to: value });
};
onApplyClick = () => {
const { onChange, timeZone } = this.props;
const { from, to } = this.state;
onChange(rawToTimeRange({ from, to }, timeZone));
};
render() {
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-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-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-footer">
<button type="submit" className="btn btn-success" disabled={!isValid} onClick={this.onApplyClick}>
Apply
</button>
</div>
</div>
);
}
}

View File

@ -1,266 +0,0 @@
.time-picker {
display: flex;
flex-flow: column nowrap;
.time-picker-buttons {
display: flex;
}
}
.time-picker-utc {
color: $orange;
font-size: 75%;
padding: 3px;
font-weight: $font-weight-semi-bold;
margin-left: 4px;
position: relative;
}
.time-picker-popover {
display: flex;
justify-content: space-around;
border: 1px solid $popover-border-color;
border-radius: $border-radius;
background: $popover-bg;
color: $popover-color;
box-shadow: $popover-shadow;
position: absolute;
z-index: $zindex-dropdown;
flex-direction: column;
max-width: 600px;
top: 41px;
right: 0px;
}
.time-picker-popover-body {
display: flex;
flex-flow: row nowrap;
justify-content: space-around;
padding: $space-md;
padding-bottom: 0;
}
.time-picker-popover-title {
font-size: $font-size-md;
font-weight: $font-weight-semi-bold;
}
.time-picker-popover-body-custom-ranges:first-child {
margin-right: $space-md;
}
.time-picker-popover-body-custom-ranges-input {
display: flex;
flex-flow: row nowrap;
align-items: center;
margin-bottom: $space-sm;
.time-picker-input-error {
box-shadow: inset 0 0px 5px $red;
}
}
.time-picker-popover-footer {
display: flex;
flex-flow: row nowrap;
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 $input-border-color;
color: $black;
width: 260px;
.react-calendar__navigation__label,
.react-calendar__navigation__arrow,
.react-calendar__navigation {
color: $input-color;
background-color: $input-label-bg;
border: 0;
}
.react-calendar__month-view__weekdays {
background-color: $input-bg;
text-align: center;
abbr {
border: 0;
text-decoration: none;
cursor: default;
color: $orange;
font-weight: $font-weight-semi-bold;
display: block;
padding: 4px 0 0 0;
}
}
.time-picker-calendar-tile {
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;
position: relative;
}
}
.react-calendar__month-view__days {
background-color: $calendar-bg-days;
}
.react-calendar__tile--now {
background-color: $calendar-bg-now;
}
.react-calendar__navigation__label,
.react-calendar__navigation > button:focus,
.time-picker-calendar-tile:focus {
outline: 0;
}
.react-calendar__tile--now {
border-radius: $border-radius;
}
.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;
}
}
.time-picker-popover-custom-range-label {
padding-right: $space-xs;
}
@include media-breakpoint-down(md) {
.time-picker-popover {
.time-picker-popover-title {
font-size: $font-size-md;
}
.time-picker-popover-body {
padding: $space-sm;
display: flex;
flex-flow: column nowrap;
}
.time-picker-popover-body-custom-ranges:first-child {
margin-right: 0;
margin-bottom: $space-sm;
}
.time-picker-popover-footer {
display: flex;
flex-flow: row nowrap;
justify-content: flex-end;
margin-top: $spacer;
}
}
.time-picker-calendar {
width: 320px;
}
}
.time-picker-button-select {
.select-button-value {
display: none;
@include media-breakpoint-up(sm) {
display: inline-block;
max-width: 100px;
overflow: hidden;
}
@include media-breakpoint-up(md) {
display: inline-block;
max-width: 150px;
overflow: hidden;
}
@include media-breakpoint-up(lg) {
max-width: 300px;
}
}
}
// special rules for when within explore toolbar in split
.explore-toolbar.splitted {
.time-picker-button-select {
.select-button-value {
display: none;
@include media-breakpoint-up(xl) {
display: inline-block;
max-width: 100px;
}
@media only screen and (max-width: 1545px) {
max-width: 300px;
}
}
}
}
.dashboard-timepicker-wrapper {
position: relative;
display: flex;
// this is to align popover position with explore ( .explore-toolbar-content-item class)
padding: 2px 2px;
.gf-form-select-box__menu {
right: 0;
left: unset;
}
}

View File

@ -0,0 +1,818 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TimePicker renders buttons correctly 1`] = `
<div
className="css-16ba5ut"
>
<div
className="css-vyoujf"
>
<button
className="btn navbar-button navbar-button--tight"
onClick={[Function]}
>
<i
className="fa fa-chevron-left"
/>
</button>
<div>
<Component
content={
<TimePickerTooltip
timeRange={
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
}
}
/>
}
placement="bottom"
>
<button
aria-label="TimePicker Open Button"
className="btn navbar-button navbar-button--zoom"
onClick={[Function]}
>
<i
className="fa fa-clock-o fa-fw"
/>
<Component
onChange={[Function]}
onMoveBackward={[Function]}
onMoveForward={[Function]}
onZoom={[Function]}
theme={
Object {
"background": Object {
"dropdown": "#1f1f20",
"pageHeader": "linear-gradient(90deg, #292a2d, #000000)",
"scrollbar": "#343436",
"scrollbar2": "#343436",
},
"border": Object {
"radius": Object {
"lg": "5px",
"md": "3px",
"sm": "2px",
},
"width": Object {
"sm": "1px",
},
},
"breakpoints": Object {
"lg": "992px",
"md": "769px",
"sm": "544px",
"xl": "1200px",
"xs": "0",
},
"colors": Object {
"black": "#000000",
"blue": "#33b5e5",
"blue77": "#1f60c4",
"blue85": "#3274d9",
"blue95": "#5794f2",
"blueBase": "#3274d9",
"blueFaint": "#041126",
"blueLight": "#5794f2",
"blueShade": "#1f60c4",
"body": "#d8d9da",
"bodyBg": "#161719",
"brandDanger": "#e02f44",
"brandPrimary": "#eb7b18",
"brandSuccess": "#299c46",
"brandWarning": "#eb7b18",
"critical": "#e02f44",
"dark1": "#141414",
"dark10": "#424345",
"dark2": "#161719",
"dark3": "#1f1f20",
"dark4": "#212124",
"dark5": "#222426",
"dark6": "#262628",
"dark7": "#292a2d",
"dark8": "#2f2f32",
"dark9": "#343436",
"formCheckboxBg": "#222426",
"formCheckboxBgChecked": "#5794f2",
"formCheckboxBgCheckedHover": "#3274d9",
"formCheckboxCheckmark": "#343b40",
"formDescription": "#9fa7b3",
"formFocusOutline": "#1f60c4",
"formInputBg": "#202226",
"formInputBgDisabled": "#141619",
"formInputBorder": "#343b40",
"formInputBorderActive": "#5794f2",
"formInputBorderHover": "#464c54",
"formInputBorderInvalid": "#e02f44",
"formInputDisabledText": "#9fa7b3",
"formInputText": "#c7d0d9",
"formInputTextStrong": "#c7d0d9",
"formInputTextWhite": "#ffffff",
"formLabel": "#9fa7b3",
"formLegend": "#c7d0d9",
"formSwitchBg": "#343b40",
"formSwitchBgActive": "#5794f2",
"formSwitchBgActiveHover": "#3274d9",
"formSwitchBgDisabled": "#343b40",
"formSwitchBgHover": "#464c54",
"formSwitchDot": "#202226",
"formValidationMessageBg": "#e02f44",
"formValidationMessageText": "#ffffff",
"gray05": "#0b0c0e",
"gray1": "#555555",
"gray10": "#141619",
"gray15": "#202226",
"gray2": "#8e8e8e",
"gray25": "#343b40",
"gray3": "#b3b3b3",
"gray33": "#464c54",
"gray4": "#d8d9da",
"gray5": "#ececec",
"gray6": "#f4f5f8",
"gray7": "#fbfbfb",
"gray70": "#9fa7b3",
"gray85": "#c7d0d9",
"gray95": "#e9edf2",
"gray98": "#f7f8fa",
"grayBlue": "#212327",
"greenBase": "#299c46",
"greenShade": "#23843b",
"headingColor": "#d8d9da",
"inputBlack": "#09090b",
"link": "#d8d9da",
"linkDisabled": "#8e8e8e",
"linkExternal": "#33b5e5",
"linkHover": "#ffffff",
"online": "#299c46",
"orange": "#eb7b18",
"orangeDark": "#ff780a",
"pageBg": "#161719",
"pageHeaderBorder": "#343436",
"purple": "#9933cc",
"queryGreen": "#74e680",
"queryKeyword": "#66d9ef",
"queryOrange": "#eb7b18",
"queryPurple": "#fe85fc",
"queryRed": "#e02f44",
"red": "#d44a3a",
"red88": "#e02f44",
"redBase": "#e02f44",
"redShade": "#c4162a",
"text": "#d8d9da",
"textEmphasis": "#ececec",
"textFaint": "#222426",
"textStrong": "#ffffff",
"textWeak": "#8e8e8e",
"variable": "#32d1df",
"warn": "#f79520",
"white": "#ffffff",
"yellow": "#ecbb13",
},
"height": Object {
"lg": "48px",
"md": "32px",
"sm": "24px",
},
"isDark": true,
"isLight": false,
"name": "Grafana Dark",
"panelHeaderHeight": 28,
"panelPadding": 8,
"shadow": Object {
"pageHeader": "inset 0px -4px 14px #1f1f20",
},
"spacing": Object {
"d": "14px",
"formButtonHeight": 32,
"formFieldsetMargin": "16px",
"formInputAffixPaddingHorizontal": "4px",
"formInputHeight": "32px",
"formInputMargin": "16px",
"formInputPaddingHorizontal": "8px",
"formLabelMargin": "0 0 4px 0",
"formLabelPadding": "0 0 0 2px",
"formLegendMargin": "0 0 16px 0",
"formMargin": "32px",
"formSpacingBase": 8,
"formValidationMessageMargin": "4px 0 0 0",
"formValidationMessagePadding": "4px 8px",
"gutter": "30px",
"insetSquishMd": "4px 8px",
"lg": "24px",
"md": "16px",
"sm": "8px",
"xl": "32px",
"xs": "4px",
"xxs": "2px",
},
"type": "dark",
"typography": Object {
"fontFamily": Object {
"monospace": "Menlo, Monaco, Consolas, 'Courier New', monospace",
"sansSerif": "'Roboto', 'Helvetica Neue', Arial, sans-serif",
},
"heading": Object {
"h1": "28px",
"h2": "24px",
"h3": "21px",
"h4": "18px",
"h5": "16px",
"h6": "14px",
},
"lineHeight": Object {
"lg": 1.5,
"md": 1.3333333333333333,
"sm": 1.1,
"xs": 1,
},
"link": Object {
"decoration": "none",
"hoverDecoration": "none",
},
"size": Object {
"base": "14px",
"lg": "18px",
"md": "14px",
"sm": "12px",
"xs": "10px",
},
"weight": Object {
"bold": 600,
"light": 300,
"regular": 400,
"semibold": 500,
},
},
"zIndex": Object {
"dropdown": "1000",
"modal": "1050",
"modalBackdrop": "1040",
"navbarFixed": "1020",
"sidemenu": "1025",
"tooltip": "1030",
"typeahead": "1060",
},
}
}
value={
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
}
}
/>
<span
className="css-6x26ye"
>
<i
className="fa fa-caret-down fa-fw"
/>
</span>
</button>
</Component>
</div>
<button
className="btn navbar-button navbar-button--tight"
onClick={[Function]}
>
<i
className="fa fa-chevron-right"
/>
</button>
<Component
content={[Function]}
placement="bottom"
>
<button
className="btn navbar-button navbar-button--zoom"
onClick={[Function]}
>
<i
className="fa fa-search-minus"
/>
</button>
</Component>
</div>
</div>
`;
exports[`TimePicker renders content correctly after beeing open 1`] = `
<div
className="css-16ba5ut"
>
<div
className="css-vyoujf"
>
<button
className="btn navbar-button navbar-button--tight"
onClick={[Function]}
>
<i
className="fa fa-chevron-left"
/>
</button>
<div>
<Component
content={
<TimePickerTooltip
timeRange={
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
}
}
/>
}
placement="bottom"
>
<button
aria-label="TimePicker Open Button"
className="btn navbar-button navbar-button--zoom"
onClick={[Function]}
>
<i
className="fa fa-clock-o fa-fw"
/>
<Component
onChange={[Function]}
onMoveBackward={[Function]}
onMoveForward={[Function]}
onZoom={[Function]}
theme={
Object {
"background": Object {
"dropdown": "#1f1f20",
"pageHeader": "linear-gradient(90deg, #292a2d, #000000)",
"scrollbar": "#343436",
"scrollbar2": "#343436",
},
"border": Object {
"radius": Object {
"lg": "5px",
"md": "3px",
"sm": "2px",
},
"width": Object {
"sm": "1px",
},
},
"breakpoints": Object {
"lg": "992px",
"md": "769px",
"sm": "544px",
"xl": "1200px",
"xs": "0",
},
"colors": Object {
"black": "#000000",
"blue": "#33b5e5",
"blue77": "#1f60c4",
"blue85": "#3274d9",
"blue95": "#5794f2",
"blueBase": "#3274d9",
"blueFaint": "#041126",
"blueLight": "#5794f2",
"blueShade": "#1f60c4",
"body": "#d8d9da",
"bodyBg": "#161719",
"brandDanger": "#e02f44",
"brandPrimary": "#eb7b18",
"brandSuccess": "#299c46",
"brandWarning": "#eb7b18",
"critical": "#e02f44",
"dark1": "#141414",
"dark10": "#424345",
"dark2": "#161719",
"dark3": "#1f1f20",
"dark4": "#212124",
"dark5": "#222426",
"dark6": "#262628",
"dark7": "#292a2d",
"dark8": "#2f2f32",
"dark9": "#343436",
"formCheckboxBg": "#222426",
"formCheckboxBgChecked": "#5794f2",
"formCheckboxBgCheckedHover": "#3274d9",
"formCheckboxCheckmark": "#343b40",
"formDescription": "#9fa7b3",
"formFocusOutline": "#1f60c4",
"formInputBg": "#202226",
"formInputBgDisabled": "#141619",
"formInputBorder": "#343b40",
"formInputBorderActive": "#5794f2",
"formInputBorderHover": "#464c54",
"formInputBorderInvalid": "#e02f44",
"formInputDisabledText": "#9fa7b3",
"formInputText": "#c7d0d9",
"formInputTextStrong": "#c7d0d9",
"formInputTextWhite": "#ffffff",
"formLabel": "#9fa7b3",
"formLegend": "#c7d0d9",
"formSwitchBg": "#343b40",
"formSwitchBgActive": "#5794f2",
"formSwitchBgActiveHover": "#3274d9",
"formSwitchBgDisabled": "#343b40",
"formSwitchBgHover": "#464c54",
"formSwitchDot": "#202226",
"formValidationMessageBg": "#e02f44",
"formValidationMessageText": "#ffffff",
"gray05": "#0b0c0e",
"gray1": "#555555",
"gray10": "#141619",
"gray15": "#202226",
"gray2": "#8e8e8e",
"gray25": "#343b40",
"gray3": "#b3b3b3",
"gray33": "#464c54",
"gray4": "#d8d9da",
"gray5": "#ececec",
"gray6": "#f4f5f8",
"gray7": "#fbfbfb",
"gray70": "#9fa7b3",
"gray85": "#c7d0d9",
"gray95": "#e9edf2",
"gray98": "#f7f8fa",
"grayBlue": "#212327",
"greenBase": "#299c46",
"greenShade": "#23843b",
"headingColor": "#d8d9da",
"inputBlack": "#09090b",
"link": "#d8d9da",
"linkDisabled": "#8e8e8e",
"linkExternal": "#33b5e5",
"linkHover": "#ffffff",
"online": "#299c46",
"orange": "#eb7b18",
"orangeDark": "#ff780a",
"pageBg": "#161719",
"pageHeaderBorder": "#343436",
"purple": "#9933cc",
"queryGreen": "#74e680",
"queryKeyword": "#66d9ef",
"queryOrange": "#eb7b18",
"queryPurple": "#fe85fc",
"queryRed": "#e02f44",
"red": "#d44a3a",
"red88": "#e02f44",
"redBase": "#e02f44",
"redShade": "#c4162a",
"text": "#d8d9da",
"textEmphasis": "#ececec",
"textFaint": "#222426",
"textStrong": "#ffffff",
"textWeak": "#8e8e8e",
"variable": "#32d1df",
"warn": "#f79520",
"white": "#ffffff",
"yellow": "#ecbb13",
},
"height": Object {
"lg": "48px",
"md": "32px",
"sm": "24px",
},
"isDark": true,
"isLight": false,
"name": "Grafana Dark",
"panelHeaderHeight": 28,
"panelPadding": 8,
"shadow": Object {
"pageHeader": "inset 0px -4px 14px #1f1f20",
},
"spacing": Object {
"d": "14px",
"formButtonHeight": 32,
"formFieldsetMargin": "16px",
"formInputAffixPaddingHorizontal": "4px",
"formInputHeight": "32px",
"formInputMargin": "16px",
"formInputPaddingHorizontal": "8px",
"formLabelMargin": "0 0 4px 0",
"formLabelPadding": "0 0 0 2px",
"formLegendMargin": "0 0 16px 0",
"formMargin": "32px",
"formSpacingBase": 8,
"formValidationMessageMargin": "4px 0 0 0",
"formValidationMessagePadding": "4px 8px",
"gutter": "30px",
"insetSquishMd": "4px 8px",
"lg": "24px",
"md": "16px",
"sm": "8px",
"xl": "32px",
"xs": "4px",
"xxs": "2px",
},
"type": "dark",
"typography": Object {
"fontFamily": Object {
"monospace": "Menlo, Monaco, Consolas, 'Courier New', monospace",
"sansSerif": "'Roboto', 'Helvetica Neue', Arial, sans-serif",
},
"heading": Object {
"h1": "28px",
"h2": "24px",
"h3": "21px",
"h4": "18px",
"h5": "16px",
"h6": "14px",
},
"lineHeight": Object {
"lg": 1.5,
"md": 1.3333333333333333,
"sm": 1.1,
"xs": 1,
},
"link": Object {
"decoration": "none",
"hoverDecoration": "none",
},
"size": Object {
"base": "14px",
"lg": "18px",
"md": "14px",
"sm": "12px",
"xs": "10px",
},
"weight": Object {
"bold": 600,
"light": 300,
"regular": 400,
"semibold": 500,
},
},
"zIndex": Object {
"dropdown": "1000",
"modal": "1050",
"modalBackdrop": "1040",
"navbarFixed": "1020",
"sidemenu": "1025",
"tooltip": "1030",
"typeahead": "1060",
},
}
}
value={
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
}
}
/>
<span
className="css-6x26ye"
>
<i
className="fa fa-caret-up fa-fw"
/>
</span>
</button>
</Component>
<ClickOutsideWrapper
onClick={[Function]}
>
<Component
onChange={[Function]}
otherOptions={
Array [
Object {
"display": "Yesterday",
"from": "now-1d/d",
"section": 3,
"to": "now-1d/d",
},
Object {
"display": "Day before yesterday",
"from": "now-2d/d",
"section": 3,
"to": "now-2d/d",
},
Object {
"display": "This day last week",
"from": "now-7d/d",
"section": 3,
"to": "now-7d/d",
},
Object {
"display": "Previous week",
"from": "now-1w/w",
"section": 3,
"to": "now-1w/w",
},
Object {
"display": "Previous month",
"from": "now-1M/M",
"section": 3,
"to": "now-1M/M",
},
Object {
"display": "Previous year",
"from": "now-1y/y",
"section": 3,
"to": "now-1y/y",
},
Object {
"display": "Today",
"from": "now/d",
"section": 3,
"to": "now/d",
},
Object {
"display": "Today so far",
"from": "now/d",
"section": 3,
"to": "now",
},
Object {
"display": "This week",
"from": "now/w",
"section": 3,
"to": "now/w",
},
Object {
"display": "This week so far",
"from": "now/w",
"section": 3,
"to": "now",
},
Object {
"display": "This month",
"from": "now/M",
"section": 3,
"to": "now/M",
},
Object {
"display": "This month so far",
"from": "now/M",
"section": 3,
"to": "now",
},
Object {
"display": "This year",
"from": "now/y",
"section": 3,
"to": "now/y",
},
Object {
"display": "This year so far",
"from": "now/y",
"section": 3,
"to": "now",
},
]
}
quickOptions={
Array [
Object {
"display": "Last 5 minutes",
"from": "now-5m",
"section": 3,
"to": "now",
},
Object {
"display": "Last 15 minutes",
"from": "now-15m",
"section": 3,
"to": "now",
},
Object {
"display": "Last 30 minutes",
"from": "now-30m",
"section": 3,
"to": "now",
},
Object {
"display": "Last 1 hour",
"from": "now-1h",
"section": 3,
"to": "now",
},
Object {
"display": "Last 3 hours",
"from": "now-3h",
"section": 3,
"to": "now",
},
Object {
"display": "Last 6 hours",
"from": "now-6h",
"section": 3,
"to": "now",
},
Object {
"display": "Last 12 hours",
"from": "now-12h",
"section": 3,
"to": "now",
},
Object {
"display": "Last 24 hours",
"from": "now-24h",
"section": 3,
"to": "now",
},
Object {
"display": "Last 2 days",
"from": "now-2d",
"section": 3,
"to": "now",
},
Object {
"display": "Last 7 days",
"from": "now-7d",
"section": 3,
"to": "now",
},
Object {
"display": "Last 30 days",
"from": "now-30d",
"section": 3,
"to": "now",
},
Object {
"display": "Last 90 days",
"from": "now-90d",
"section": 3,
"to": "now",
},
Object {
"display": "Last 6 months",
"from": "now-6M",
"section": 3,
"to": "now",
},
Object {
"display": "Last 1 year",
"from": "now-1y",
"section": 3,
"to": "now",
},
Object {
"display": "Last 2 years",
"from": "now-2y",
"section": 3,
"to": "now",
},
Object {
"display": "Last 5 years",
"from": "now-5y",
"section": 3,
"to": "now",
},
]
}
value={
Object {
"from": "2019-12-17T07:48:27.433Z",
"raw": Object {
"from": "2019-12-17T07:48:27.433Z",
"to": "2019-12-18T07:48:27.433Z",
},
"to": "2019-12-18T07:48:27.433Z",
}
}
/>
</ClickOutsideWrapper>
</div>
<button
className="btn navbar-button navbar-button--tight"
onClick={[Function]}
>
<i
className="fa fa-chevron-right"
/>
</button>
<Component
content={[Function]}
placement="bottom"
>
<button
className="btn navbar-button navbar-button--zoom"
onClick={[Function]}
>
<i
className="fa fa-search-minus"
/>
</button>
</Component>
</div>
</div>
`;

View File

@ -12,6 +12,5 @@
@import 'Table/TableInputCSV';
@import 'ThresholdsEditor/ThresholdsEditor';
@import 'TimePicker/TimeOfDayPicker';
@import 'TimePicker/TimePicker';
@import 'Tooltip/Tooltip';
@import 'ValueMappingsEditor/ValueMappingsEditor';

View File

@ -0,0 +1,37 @@
import React, { PureComponent } from 'react';
import store from '../../store';
export interface Props<T> {
storageKey: string;
defaultValue?: T;
children: (value: T, onSaveToStore: (value: T) => void) => React.ReactNode;
}
interface State<T> {
value: T;
}
export class LocalStorageValueProvider<T> extends PureComponent<Props<T>, State<T>> {
constructor(props: Props<T>) {
super(props);
const { storageKey, defaultValue } = props;
this.state = {
value: store.getObject(storageKey, defaultValue),
};
}
onSaveToStore = (value: T) => {
const { storageKey } = this.props;
store.setObject(storageKey, value);
this.setState({ value });
};
render() {
const { children } = this.props;
const { value } = this.state;
return <>{children(value, this.onSaveToStore)}</>;
}
}

View File

@ -0,0 +1 @@
export { LocalStorageValueProvider } from './LocalStorageValueProvider';

View File

@ -0,0 +1,42 @@
import React from 'react';
import { LocalStorageValueProvider } from '../LocalStorageValueProvider';
import { TimeRange, isDateTime } from '@grafana/data';
import { Props as TimePickerProps, TimePicker } from '@grafana/ui/src/components/TimePicker/TimePicker';
const LOCAL_STORAGE_KEY = 'grafana.dashboard.timepicker.history';
interface Props extends Omit<TimePickerProps, 'history' | 'theme'> {}
export const TimePickerWithHistory: React.FC<Props> = props => {
return (
<LocalStorageValueProvider<TimeRange[]> storageKey={LOCAL_STORAGE_KEY} defaultValue={[]}>
{(values, onSaveToStore) => {
return (
<TimePicker
{...props}
history={values}
onChange={value => {
onAppendToHistory(value, values, onSaveToStore);
props.onChange(value);
}}
/>
);
}}
</LocalStorageValueProvider>
);
};
function onAppendToHistory(toAppend: TimeRange, values: TimeRange[], onSaveToStore: (values: TimeRange[]) => void) {
if (!isAbsolute(toAppend)) {
return;
}
const toStore = limit([toAppend, ...values]);
onSaveToStore(toStore);
}
function isAbsolute(value: TimeRange): boolean {
return isDateTime(value.raw.from) || isDateTime(value.raw.to);
}
function limit(value: TimeRange[]): TimeRange[] {
return value.slice(0, 4);
}

View File

@ -1,30 +1,40 @@
// Libaries
import React, { Component } from 'react';
import { dateMath } from '@grafana/data';
import { dateMath, GrafanaTheme } from '@grafana/data';
import { css } from 'emotion';
// Types
import { DashboardModel } from '../../state';
import { LocationState, CoreEvents } from 'app/types';
import { TimeRange, TimeOption, RawTimeRange } from '@grafana/data';
import { TimeRange } from '@grafana/data';
// State
import { updateLocation } from 'app/core/actions';
// Components
import { TimePicker, RefreshPicker } from '@grafana/ui';
import { RefreshPicker, withTheme, stylesFactory, Themeable } from '@grafana/ui';
import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePickerWithHistory';
// Utils & Services
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { defaultSelectOptions } from '@grafana/ui/src/components/TimePicker/TimePicker';
export interface Props {
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
container: css`
position: relative;
display: flex;
padding: 2px 2px;
`,
};
});
export interface Props extends Themeable {
$injector: any;
dashboard: DashboardModel;
updateLocation: typeof updateLocation;
location: LocationState;
}
export class DashNavTimeControls extends Component<Props> {
class UnthemedDashNavTimeControls extends Component<Props> {
timeSrv: TimeSrv = getTimeSrv();
$rootScope = this.props.$injector.get('$rootScope');
@ -83,37 +93,22 @@ export class DashNavTimeControls extends Component<Props> {
this.$rootScope.appEvent(CoreEvents.zoomOut, 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 { dashboard, theme } = this.props;
const intervals = dashboard.timepicker.refresh_intervals;
const timePickerValue = this.timeSrv.timeRange();
const timeZone = dashboard.getTimezone();
const styles = getStyles(theme);
return (
<div className="dashboard-timepicker-wrapper">
<TimePicker
<div className={styles.container}>
<TimePickerWithHistory
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}
@ -126,3 +121,5 @@ export class DashNavTimeControls extends Component<Props> {
);
}
}
export const DashNavTimeControls = withTheme(UnthemedDashNavTimeControls);

View File

@ -3,16 +3,15 @@ import React, { Component } from 'react';
// Types
import { ExploreId } from 'app/types';
import { TimeRange, TimeOption, TimeZone, RawTimeRange, dateTimeForTimeZone } from '@grafana/data';
import { TimeRange, TimeZone, RawTimeRange, dateTimeForTimeZone } from '@grafana/data';
// State
// Components
import { TimePicker } from '@grafana/ui';
import { TimeSyncButton } from './TimeSyncButton';
import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePickerWithHistory';
// Utils & Services
import { defaultSelectOptions } from '@grafana/ui/src/components/TimePicker/TimePicker';
import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker';
export interface Props {
@ -56,35 +55,25 @@ export class ExploreTimeControls extends Component<Props> {
onChangeTime(nextTimeRange);
};
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 { range, timeZone, splitted, syncedTimes, onChangeTimeSync, hideText } = this.props;
const timeSyncButton = splitted ? <TimeSyncButton onClick={onChangeTimeSync} isSynced={syncedTimes} /> : undefined;
const timePickerCommonProps = {
value: range,
onChange: this.onChangeTimePicker,
timeZone,
onMoveBackward: this.onMoveBack,
onMoveForward: this.onMoveForward,
onZoom: this.onZoom,
selectOptions: this.setActiveTimeOption(defaultSelectOptions, range.raw),
hideText,
};
return <TimePicker {...timePickerCommonProps} timeSyncButton={timeSyncButton} isSynced={syncedTimes} />;
return (
<TimePickerWithHistory
{...timePickerCommonProps}
timeSyncButton={timeSyncButton}
isSynced={syncedTimes}
onChange={this.onChangeTimePicker}
/>
);
}
}