diff --git a/packages/grafana-ui/src/components/Forms/Field.tsx b/packages/grafana-ui/src/components/Forms/Field.tsx index 691a4531046..9dc6975de53 100644 --- a/packages/grafana-ui/src/components/Forms/Field.tsx +++ b/packages/grafana-ui/src/components/Forms/Field.tsx @@ -78,14 +78,14 @@ export const Field: React.FC = ({ )}
{React.cloneElement(children, { invalid, disabled, loading })} - {error && !horizontal && ( + {invalid && error && !horizontal && (
{error}
)}
- {error && horizontal && ( + {invalid && error && horizontal && (
{error}
diff --git a/packages/grafana-ui/src/components/Forms/Input/Input.tsx b/packages/grafana-ui/src/components/Forms/Input/Input.tsx index 86196564297..9b40a043a6d 100644 --- a/packages/grafana-ui/src/components/Forms/Input/Input.tsx +++ b/packages/grafana-ui/src/components/Forms/Input/Input.tsx @@ -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 => { */ const [prefixRect, prefixRef] = useClientRect(); const [suffixRect, suffixRef] = useClientRect(); - const theme = useTheme(); + const theme = useTheme(); const styles = getInputStyles({ theme, invalid: !!invalid }); return ( diff --git a/packages/grafana-ui/src/components/Forms/index.ts b/packages/grafana-ui/src/components/Forms/index.ts index 4670dda93ee..f5d66c2a706 100644 --- a/packages/grafana-ui/src/components/Forms/index.ts +++ b/packages/grafana-ui/src/components/Forms/index.ts @@ -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; diff --git a/packages/grafana-ui/src/components/TimePicker/TimePicker.story.tsx b/packages/grafana-ui/src/components/TimePicker/TimePicker.story.tsx index 59f6c97a857..49c2a9fcc71 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimePicker.story.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimePicker.story.tsx @@ -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', () => { { action('onChange fired')(timeRange); updateValue(timeRange); diff --git a/packages/grafana-ui/src/components/TimePicker/TimePicker.test.tsx b/packages/grafana-ui/src/components/TimePicker/TimePicker.test.tsx new file mode 100644 index 00000000000..c96b98f7401 --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/TimePicker.test.tsx @@ -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( + {}} + value={value} + onMoveBackward={() => {}} + onMoveForward={() => {}} + onZoom={() => {}} + theme={dark} + /> + ); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders content correctly after beeing open', () => { + const wrapper = shallow( + {}} + value={value} + onMoveBackward={() => {}} + onMoveForward={() => {}} + onZoom={() => {}} + theme={dark} + /> + ); + + wrapper.find('button[aria-label="TimePicker Open Button"]').simulate('click', new Event('click')); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/packages/grafana-ui/src/components/TimePicker/TimePicker.tsx b/packages/grafana-ui/src/components/TimePicker/TimePicker.tsx index e9660e07356..c679baa85be 100644 --- a/packages/grafana-ui/src/components/TimePicker/TimePicker.tsx +++ b/packages/grafana-ui/src/components/TimePicker/TimePicker.tsx @@ -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
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 { - pickerTriggerRef = createRef(); +export class UnthemedTimePicker extends PureComponent { 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) => { - 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) => { + 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 { 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 && Custom time range} - {!isCustomOpen && {rangeString}} - {isUTC && UTC} - - ) : ( - '' - ); const hasAbsolute = isDateTime(value.raw.from) || isDateTime(value.raw.to); return ( -
-
+
+
{hasAbsolute && ( )} - } - /> +
+ } placement="bottom"> + + + {isOpen && ( + + + + )} +
{timeSyncButton} @@ -217,24 +185,24 @@ class UnThemedTimePicker extends PureComponent { )} - + - - {isCustomOpen && ( - - - - )}
); } } -const TimePickerTooltipContent = ({ timeRange }: { timeRange: TimeRange }) => ( +const ZoomOutTooltip = () => ( + <> + Time range zoom out
CTRL+Z + +); + +const TimePickerTooltip = ({ timeRange }: { timeRange: TimeRange }) => ( <> {timeRange.from.format(TIME_FORMAT)}
to
@@ -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 => { + const theme = useTheme(); + const styles = getLabelStyles(theme); + const isUTC = props.timeZone === 'utc'; -export const TimePicker = withTheme(UnThemedTimePicker); + if (props.hideText) { + return null; + } + + return ( + + {formattedRange(props.value, isUTC)} + {isUTC && UTC} + + ); +}); + +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); diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerCalendar.story.tsx b/packages/grafana-ui/src/components/TimePicker/TimePickerCalendar.story.tsx deleted file mode 100644 index 45221b0e3f8..00000000000 --- a/packages/grafana-ui/src/components/TimePicker/TimePickerCalendar.story.tsx +++ /dev/null @@ -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', () => ( - - {(value, updateValue) => { - return ( - { - action('onChange fired')(timeRange); - updateValue(timeRange); - }} - /> - ); - }} - -)); diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerCalendar.tsx b/packages/grafana-ui/src/components/TimePicker/TimePickerCalendar.tsx deleted file mode 100644 index 04084bee796..00000000000 --- a/packages/grafana-ui/src/components/TimePicker/TimePickerCalendar.tsx +++ /dev/null @@ -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 { - 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 ( - } - prevLabel={} - /> - ); - } -} diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimePickerCalendar.tsx b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimePickerCalendar.tsx new file mode 100644 index 00000000000..cefba711628 --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimePickerCalendar.tsx @@ -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 => { + const theme = useTheme(); + const styles = getStyles(theme); + const { isOpen, isFullscreen } = props; + + if (!isOpen) { + return null; + } + + if (isFullscreen) { + return ( + +
+ +
+
+ ); + } + + return ( + +
event.stopPropagation()}> +
+
+ +
+
+
+
event.stopPropagation()} /> + + ); +}); + +const Header = memo(({ onClose }) => { + const theme = useTheme(); + const styles = getHeaderStyles(theme); + + return ( +
+ Select a time range + +
+ ); +}); + +const Body = memo(props => { + const theme = useTheme(); + const styles = getBodyStyles(theme); + const { from, to, onChange } = props; + + return ( + } + prevLabel={} + onChange={value => valueToInput(value, onChange)} + locale="en" + /> + ); +}); + +const Footer = memo(({ onClose, onApply }) => { + const theme = useTheme(); + const styles = getFooterStyles(theme); + + return ( +
+ + Apply time range + + + Cancel + +
+ ); +}); + +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); +} diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimePickerContent.test.tsx b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimePickerContent.test.tsx new file mode 100644 index 00000000000..936559926ff --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimePickerContent.test.tsx @@ -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( + {}} 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( + {}} 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( + {}} + 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) }, + }; +} diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimePickerContent.tsx b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimePickerContent.tsx new file mode 100644 index 00000000000..990c2a7a76b --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimePickerContent.tsx @@ -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 { + visible: boolean; + historyOptions?: TimeOption[]; +} + +export const TimePickerContentWithScreenSize: React.FC = props => { + const theme = useTheme(); + const styles = getStyles(theme); + const historyOptions = mapToHistoryOptions(props.history, props.timeZone); + const { quickOptions = [], otherOptions = [], isFullscreen } = props; + + return ( +
+
+ +
+ + + +
+ + +
+ ); +}; + +export const TimePickerContent: React.FC = props => { + const theme = useTheme(); + const isFullscreen = useMedia(`(min-width: ${theme.breakpoints.lg})`); + + return ; +}; + +const NarrowScreenForm: React.FC = 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 ( + <> +
setCollapsed(!collapsed)}> + Absolute time range + {collapsed ? : } +
+ {collapsed && ( +
+
+ +
+ +
+ )} + + ); +}; + +const FullScreenForm: React.FC = props => { + if (!props.visible) { + return null; + } + + const theme = useTheme(); + const styles = getFullScreenStyles(theme); + + return ( + <> +
+
+ Absolute time range +
+ +
+
+ } + timeZone={props.timeZone} + /> +
+ + ); +}; + +const EmptyRecentList = memo(() => { + const theme = useTheme(); + const styles = getEmptyListStyles(theme); + + return ( +
+
+ + 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. + +
+
+ + Read the documentation + + to find out more about how to enter custom time ranges. +
+
+ ); +}); + +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)); +} diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimePickerTitle.tsx b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimePickerTitle.tsx new file mode 100644 index 00000000000..2e37a19f2bd --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimePickerTitle.tsx @@ -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>(({ children }) => { + const theme = useTheme(); + const styles = getStyle(theme); + + return {children}; +}); diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimeRangeForm.tsx b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimeRangeForm.tsx new file mode 100644 index 00000000000..b9869ee0c38 --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimeRangeForm.tsx @@ -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 => { + const { value, isFullscreen = false, timeZone, roundup } = props; + + const [from, setFrom] = useState(valueToState(value.raw.from, false, timeZone)); + const [to, setTo] = useState(valueToState(value.raw.to, true, timeZone)); + const [isOpen, setOpen] = useState(false); + + const onOpen = useCallback( + (event: FormEvent) => { + event.preventDefault(); + setOpen(true); + }, + [setOpen] + ); + + const onFocus = useCallback( + (event: FormEvent) => { + 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 : ; + + return ( + <> + + event.stopPropagation()} + onFocus={onFocus} + onChange={event => setFrom(eventToState(event, false, timeZone))} + addonAfter={icon} + value={from.value} + /> + + + event.stopPropagation()} + onFocus={onFocus} + onChange={event => setTo(eventToState(event, true, timeZone))} + addonAfter={icon} + value={to.value} + /> + + Apply time range + + setOpen(false)} + onChange={onChange} + /> + + ); +}; + +function eventToState(event: FormEvent, 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(); +} diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimeRangeList.tsx b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimeRangeList.tsx new file mode 100644 index 00000000000..b5713e8527f --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimeRangeList.tsx @@ -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 => { + const styles = getStyles(); + const { title, options, placeholderEmpty } = props; + + if (typeof placeholderEmpty !== 'undefined' && options.length <= 0) { + return <>{placeholderEmpty}; + } + + if (!title) { + return ; + } + + return ( + <> +
+ {title} +
+ + + ); +}; + +const Options: React.FC = ({ options, value, onSelect, timeZone }) => { + const styles = getOptionsStyles(); + + return ( + <> +
+ {options.map((option, index) => ( + onSelect(mapOptionToTimeRange(option, timeZone))} + /> + ))} +
+
+ + ); +}; + +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; +} diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimeRangeOption.tsx b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimeRangeOption.tsx new file mode 100644 index 00000000000..09f9aa42f30 --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimeRangeOption.tsx @@ -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(({ value, onSelect, selected = false }) => { + const theme = useTheme(); + const styles = getStyles(theme); + + return ( +
onSelect(value)} tabIndex={-1}> + {value.display} + {selected ? : null} +
+ ); +}); diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerContent/__snapshots__/TimePickerContent.test.tsx.snap b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/__snapshots__/TimePickerContent.test.tsx.snap new file mode 100644 index 00000000000..e77c45759be --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/__snapshots__/TimePickerContent.test.tsx.snap @@ -0,0 +1,344 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TimePickerContent renders correctly in full screen 1`] = ` +
+
+ +
+ + + +
+ + +
+`; + +exports[`TimePickerContent renders correctly in narrow screen 1`] = ` +
+
+ +
+ + + +
+ + +
+`; + +exports[`TimePickerContent renders recent absolute ranges correctly 1`] = ` +
+
+ +
+ + + +
+ + +
+`; diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerContent/colors.ts b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/colors.ts new file mode 100644 index 00000000000..95232af672c --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/colors.ts @@ -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 + ), + }; +}; diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerContent/mapper.ts b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/mapper.ts new file mode 100644 index 00000000000..6a3fbc13ddd --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/TimePickerContent/mapper.ts @@ -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); +}; diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerInput.tsx b/packages/grafana-ui/src/components/TimePicker/TimePickerInput.tsx deleted file mode 100644 index 03f988c6649..00000000000 --- a/packages/grafana-ui/src/components/TimePicker/TimePickerInput.tsx +++ /dev/null @@ -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 { - 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) => { - 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 ( - - ); - } -} diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerPopover.story.tsx b/packages/grafana-ui/src/components/TimePicker/TimePickerPopover.story.tsx deleted file mode 100644 index 02d00a903dd..00000000000 --- a/packages/grafana-ui/src/components/TimePicker/TimePickerPopover.story.tsx +++ /dev/null @@ -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', () => ( - - {(value, updateValue) => { - return ( - { - action('onChange fired')(timeRange); - updateValue(timeRange); - }} - /> - ); - }} - -)); diff --git a/packages/grafana-ui/src/components/TimePicker/TimePickerPopover.tsx b/packages/grafana-ui/src/components/TimePicker/TimePickerPopover.tsx deleted file mode 100644 index 29316c3334b..00000000000 --- a/packages/grafana-ui/src/components/TimePicker/TimePickerPopover.tsx +++ /dev/null @@ -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 { - 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 ( -
-
-
-
-
- - -
-
-
- -
-
-
-
-
- - -
-
-
- -
-
-
-
- -
-
- ); - } -} diff --git a/packages/grafana-ui/src/components/TimePicker/_TimePicker.scss b/packages/grafana-ui/src/components/TimePicker/_TimePicker.scss deleted file mode 100644 index 3aaf5951ffa..00000000000 --- a/packages/grafana-ui/src/components/TimePicker/_TimePicker.scss +++ /dev/null @@ -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; - } -} diff --git a/packages/grafana-ui/src/components/TimePicker/__snapshots__/TimePicker.test.tsx.snap b/packages/grafana-ui/src/components/TimePicker/__snapshots__/TimePicker.test.tsx.snap new file mode 100644 index 00000000000..5ed7a7e82a5 --- /dev/null +++ b/packages/grafana-ui/src/components/TimePicker/__snapshots__/TimePicker.test.tsx.snap @@ -0,0 +1,818 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TimePicker renders buttons correctly 1`] = ` +
+
+ +
+ + } + placement="bottom" + > + + +
+ + + + +
+
+`; + +exports[`TimePicker renders content correctly after beeing open 1`] = ` +
+
+ +
+ + } + placement="bottom" + > + + + + + +
+ + + + +
+
+`; diff --git a/packages/grafana-ui/src/components/index.scss b/packages/grafana-ui/src/components/index.scss index 7337a21ea1e..55fa6175325 100644 --- a/packages/grafana-ui/src/components/index.scss +++ b/packages/grafana-ui/src/components/index.scss @@ -12,6 +12,5 @@ @import 'Table/TableInputCSV'; @import 'ThresholdsEditor/ThresholdsEditor'; @import 'TimePicker/TimeOfDayPicker'; -@import 'TimePicker/TimePicker'; @import 'Tooltip/Tooltip'; @import 'ValueMappingsEditor/ValueMappingsEditor'; diff --git a/public/app/core/components/LocalStorageValueProvider/LocalStorageValueProvider.tsx b/public/app/core/components/LocalStorageValueProvider/LocalStorageValueProvider.tsx new file mode 100644 index 00000000000..14bc7bbef92 --- /dev/null +++ b/public/app/core/components/LocalStorageValueProvider/LocalStorageValueProvider.tsx @@ -0,0 +1,37 @@ +import React, { PureComponent } from 'react'; +import store from '../../store'; + +export interface Props { + storageKey: string; + defaultValue?: T; + children: (value: T, onSaveToStore: (value: T) => void) => React.ReactNode; +} + +interface State { + value: T; +} + +export class LocalStorageValueProvider extends PureComponent, State> { + constructor(props: Props) { + 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)}; + } +} diff --git a/public/app/core/components/LocalStorageValueProvider/index.tsx b/public/app/core/components/LocalStorageValueProvider/index.tsx new file mode 100644 index 00000000000..e4bde7ccaea --- /dev/null +++ b/public/app/core/components/LocalStorageValueProvider/index.tsx @@ -0,0 +1 @@ +export { LocalStorageValueProvider } from './LocalStorageValueProvider'; diff --git a/public/app/core/components/TimePicker/TimePickerWithHistory.tsx b/public/app/core/components/TimePicker/TimePickerWithHistory.tsx new file mode 100644 index 00000000000..c8ad006fc6d --- /dev/null +++ b/public/app/core/components/TimePicker/TimePickerWithHistory.tsx @@ -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 {} + +export const TimePickerWithHistory: React.FC = props => { + return ( + storageKey={LOCAL_STORAGE_KEY} defaultValue={[]}> + {(values, onSaveToStore) => { + return ( + { + onAppendToHistory(value, values, onSaveToStore); + props.onChange(value); + }} + /> + ); + }} + + ); +}; +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); +} diff --git a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx index 9962183f2d2..795dd8eb242 100644 --- a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx @@ -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 { +class UnthemedDashNavTimeControls extends Component { timeSrv: TimeSrv = getTimeSrv(); $rootScope = this.props.$injector.get('$rootScope'); @@ -83,37 +93,22 @@ export class DashNavTimeControls extends Component { 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 ( -
- + { ); } } + +export const DashNavTimeControls = withTheme(UnthemedDashNavTimeControls); diff --git a/public/app/features/explore/ExploreTimeControls.tsx b/public/app/features/explore/ExploreTimeControls.tsx index db68f6044c0..6c7966a8d37 100644 --- a/public/app/features/explore/ExploreTimeControls.tsx +++ b/public/app/features/explore/ExploreTimeControls.tsx @@ -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 { 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 ? : 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 ; + return ( + + ); } }