diff --git a/CHANGELOG.md b/CHANGELOG.md index 19a20ee52cc..f334039e324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## [`master`](https://github.com/elastic/eui/tree/master) - Added `DisambiguateSet` and `ExclusiveUnion` utility types ([#1368](https://github.com/elastic/eui/pull/1368)) +- Added `EuiSuperDatePicker` component ([#1351](https://github.com/elastic/eui/pull/1351)) **Bug fixes** diff --git a/package.json b/package.json index a90f48f28b0..75d156b0970 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "@babel/preset-env": "^7.1.0", "@babel/preset-react": "^7.0.0", "@babel/preset-typescript": "^7.1.0", + "@elastic/datemath": "^5.0.2", "@elastic/eslint-config-kibana": "^0.15.0", "@types/classnames": "^2.2.6", "@types/enzyme": "^3.1.13", @@ -170,6 +171,7 @@ "yo": "^2.0.0" }, "peerDependencies": { + "@elastic/datemath": "^5.0.2", "moment": "^2.13.0", "prop-types": "^15.5.0", "react": "^16.3", diff --git a/src-docs/src/theme_dark.scss b/src-docs/src/theme_dark.scss index 97d4ef0207c..f09a1aede5c 100644 --- a/src-docs/src/theme_dark.scss +++ b/src-docs/src/theme_dark.scss @@ -1,4 +1,3 @@ @import '../../src/theme_dark'; @import './components/guide_components'; @import './views/header/global_filter_group'; -@import './views/date_picker/global_date_picker'; diff --git a/src-docs/src/theme_k6_dark.scss b/src-docs/src/theme_k6_dark.scss index a3738416b1d..99e708a00f1 100644 --- a/src-docs/src/theme_k6_dark.scss +++ b/src-docs/src/theme_k6_dark.scss @@ -1,4 +1,3 @@ @import '../../src/theme_k6_dark'; @import './components/guide_components'; @import './views/header/global_filter_group'; -@import './views/date_picker/global_date_picker'; diff --git a/src-docs/src/theme_k6_light.scss b/src-docs/src/theme_k6_light.scss index 6fe9d55fe4b..544617f335d 100644 --- a/src-docs/src/theme_k6_light.scss +++ b/src-docs/src/theme_k6_light.scss @@ -1,4 +1,3 @@ @import '../../src/theme_k6_light'; @import './components/guide_components'; @import './views/header/global_filter_group'; -@import './views/date_picker/global_date_picker'; diff --git a/src-docs/src/theme_light.scss b/src-docs/src/theme_light.scss index bd607c9114f..782fc422f63 100644 --- a/src-docs/src/theme_light.scss +++ b/src-docs/src/theme_light.scss @@ -1,4 +1,3 @@ @import '../../src/theme_light'; @import './components/guide_components'; @import './views/header/global_filter_group'; -@import './views/date_picker/global_date_picker'; diff --git a/src-docs/src/views/date_picker/date_picker_example.js b/src-docs/src/views/date_picker/date_picker_example.js index 7843e3e30d6..b326f755eaa 100644 --- a/src-docs/src/views/date_picker/date_picker_example.js +++ b/src-docs/src/views/date_picker/date_picker_example.js @@ -11,7 +11,7 @@ import { EuiLink, EuiDatePicker, EuiDatePickerRange, - EuiCallOut, + EuiSuperDatePicker, } from '../../../../src/components'; import DatePicker from './date_picker'; @@ -58,9 +58,9 @@ import Utc from './utc'; const utcSource = require('!!raw-loader!./utc'); const utcHtml = renderToHtml(Utc); -import GlobalDatePicker from './global_date_picker'; -const globalDatePickerSource = require('!!raw-loader!./global_date_picker'); -const globalDatePickerHtml = renderToHtml(GlobalDatePicker); +import SuperDatePicker from './super_date_picker'; +const superDatePickerSource = require('!!raw-loader!./super_date_picker'); +const superDatePickerHtml = renderToHtml(SuperDatePicker); export const DatePickerExample = { title: 'DatePicker', @@ -266,24 +266,24 @@ export const DatePickerExample = { ), demo: , }, { - title: 'Global date picker', + title: 'Super date picker', source: [{ type: GuideSectionTypes.JS, - code: globalDatePickerSource, + code: superDatePickerSource, }, { type: GuideSectionTypes.HTML, - code: globalDatePickerHtml, + code: superDatePickerHtml, }], text: (
- -

- This documents a visual pattern for the eventual replacement of Kibana's - global date/time picker. It uses all EUI components with some custom styles. -

-
+

+ start and end date times are passed as strings + in either datemath format (e.g.: now, now-15m, now-15m/m) + or as absolute date in the format YYYY-MM-DDTHH:mm:ss.sssZ +

), - demo: , + demo: , + props: { EuiSuperDatePicker }, }], }; diff --git a/src-docs/src/views/date_picker/global_date_picker.js b/src-docs/src/views/date_picker/global_date_picker.js deleted file mode 100644 index 13930214175..00000000000 --- a/src-docs/src/views/date_picker/global_date_picker.js +++ /dev/null @@ -1,566 +0,0 @@ - -import React, { - Component, Fragment, -} from 'react'; -import PropTypes from 'prop-types'; - -import moment from 'moment'; -import classNames from 'classnames'; - -import { - EuiDatePicker, - EuiDatePickerRange, - EuiFormControlLayout, - EuiButtonEmpty, - EuiIcon, - EuiLink, EuiTitle, EuiFlexGrid, EuiFlexItem, - EuiPopover, - EuiSpacer, - EuiText, - EuiHorizontalRule, - EuiFlexGroup, - EuiFormRow, - EuiSelect, - EuiFieldNumber, - EuiButton, - EuiTabbedContent, - EuiForm, - EuiSwitch, - EuiToolTip, - EuiFieldText, - EuiButtonIcon, -} from '../../../../src/components'; - -const commonDates = [ - 'Today', 'Yesterday', 'This week', 'Week to date', 'This month', 'Month to date', 'This year', 'Year to date', -]; - -const relativeSelectOptions = [ - { text: 'Seconds ago', value: 'string:s' }, - { text: 'Minutes ago', value: 'string:m' }, - { text: 'Hours ago', value: 'string:h' }, - { text: 'Days ago', value: 'string:d' }, - { text: 'Weeks ago', value: 'string:w' }, - { text: 'Months ago', value: 'string:M' }, - { text: 'Years ago', value: 'string:y' }, - { text: 'Seconds from now', value: 'string:s+' }, - { text: 'Minutes from now', value: 'string:m+' }, - { text: 'Hours from now', value: 'string:h+' }, - { text: 'Days from now', value: 'string:d+' }, - { text: 'Weeks from now', value: 'string:w+' }, - { text: 'Months from now', value: 'string:M+' }, - { text: 'Years from now', value: 'string:y+' }, -]; - -class GlobalDatePopover extends Component { - constructor(props) { - super(props); - - this.tabs = [{ - id: 'absolute', - name: 'Absolute', - content: ( -
- - - - -
- ), - }, { - id: 'relative', - name: 'Relative', - content: ( - - - - - - - - - - - - - - - - - - - - - ), - }, { - id: 'now', - name: 'Now', - content: ( - -

- Setting the time to "Now" means that on every refresh - this time will be set to the time of the refresh. -

-
- ), - }]; - - this.state = { - selectedTab: this.tabs[0], - }; - } - - onTabClick = (selectedTab) => { - this.setState({ selectedTab }); - }; - - render() { - return ( - - ); - } -} - -// eslint-disable-next-line react/no-multi-comp -class GlobalDateButton extends Component { - static propTypes = { - position: PropTypes.oneOf(['start', 'end']), - isInvalid: PropTypes.bool, - needsUpdating: PropTypes.bool, - buttonOnly: PropTypes.bool, - date: PropTypes.string, - } - - constructor(props) { - super(props); - - this.state = { - isPopoverOpen: false, - }; - } - - togglePopover = () => { - this.setState({ - isPopoverOpen: !this.state.isPopoverOpen, - }); - } - - closePopover = () => { - this.setState({ - isPopoverOpen: false, - }); - } - - render() { - const { - position, - isInvalid, - needsUpdating, - date, - buttonProps, - buttonOnly, - ...rest - } = this.props; - - const { - isPopoverOpen, - } = this.state; - - const classes = classNames([ - 'euiGlobalDatePicker__dateButton', - `euiGlobalDatePicker__dateButton--${position}`, - { - 'euiGlobalDatePicker__dateButton-isSelected': isPopoverOpen, - 'euiGlobalDatePicker__dateButton-isInvalid': isInvalid, - 'euiGlobalDatePicker__dateButton-needsUpdating': needsUpdating - } - ]); - - let title = date; - if (isInvalid) { - title = `Invalid date: ${title}`; - } else if (needsUpdating) { - title = `Update needed: ${title}`; - } - - const button = ( - - ); - - return buttonOnly ? button : ( - - - - ); - } -} - -// eslint-disable-next-line react/no-multi-comp -export default class extends Component { - constructor(props) { - super(props); - - this.state = { - startDate: moment().format('MMM DD YYYY h:mm:ss.SSS'), - endDate: moment().add(11, 'd').format('MMM DD YYYY hh:mm:ss.SSS'), - isPopoverOpen: false, - showPrettyFormat: false, - showNeedsUpdate: false, - isUpdating: false, - timerIsOn: false, - recentlyUsed: [ - ['11/25/2017 00:00 AM', '11/25/2017 11:59 PM'], - ['3 hours ago', '4 minutes ago'], - 'Last 6 months', - ['06/11/2017 06:11 AM', '06/11/2017 06:11 PM'], - ], - }; - } - - setTootipRef = node => (this.tooltip = node); - - showTooltip = () => this.tooltip.showToolTip(); - hideTooltip = () => this.tooltip.hideToolTip(); - - togglePopover = (e) => { - // HACK TODO: - // this works because react listens to all events at the - // document level, and you need to interact with the native - // event's propagation to short-circuit outside click handler - // see also: https://stackoverflow.com/a/24421834 - e.nativeEvent.stopImmediatePropagation(); - - this.setState(prevState => ({ - isPopoverOpen: !prevState.isPopoverOpen, - })); - } - - togglePrettyFormat = () => { - this.setState(prevState => ({ - showPrettyFormat: !prevState.showPrettyFormat, - })); - } - - toggleNeedsUpdate = () => { - this.setState(prevState => { - - if (!prevState.showNeedsUpdate) { - clearTimeout(this.tooltipTimeout); - this.showTooltip(); - this.tooltipTimeout = setTimeout(() => { - this.hideTooltip(); - }, 10000); - } - - return ({ - showNeedsUpdate: !prevState.showNeedsUpdate, - }); - }); - } - - closePopover = () => { - this.setState({ - isPopoverOpen: false, - }); - } - - toggleTimer = () => { - this.setState(prevState => ({ - timerIsOn: !prevState.timerIsOn, - })); - } - - toggleIsUpdating = () => { - this.setState(prevState => ({ - isUpdating: !prevState.isUpdating, - })); - } - - - render() { - const quickSelectButton = ( - - - - ); - - const commonlyUsed = this.renderCommonlyUsed(commonDates); - const recentlyUsed = this.renderRecentlyUsed(this.state.recentlyUsed); - - const quickSelectPopover = ( - -
- {this.renderQuickSelect()} - - {commonlyUsed} - - {recentlyUsed} - - {this.renderTimer()} -
-
- ); - - return ( - -   -   - - - - - - - - } - endDateControl={ - - } - > - {this.state.showPrettyFormat && - - - Show dates - - } - - - - - {this.renderUpdateButton()} - - - - ); - } - - renderUpdateButton = () => { - const color = this.state.showNeedsUpdate ? 'secondary' : 'primary'; - const icon = this.state.showNeedsUpdate ? 'kqlFunction' : 'refresh'; - let text = this.state.showNeedsUpdate ? 'Update' : 'Refresh'; - - if (this.state.isUpdating) { - text = 'Updating'; - } - - return ( - - - {text} - - - ); - } - - renderQuickSelect = () => { - const firstOptions = [ - { value: 'last', text: 'Last' }, - { value: 'previous', text: 'Previous' }, - ]; - - const lastOptions = [ - { value: 'seconds', text: 'seconds' }, - { value: 'minutes', text: 'minutes' }, - { value: 'hours', text: 'hours' }, - { value: 'days', text: 'days' }, - { value: 'weeks', text: 'weeks' }, - { value: 'months', text: 'months' }, - { value: 'years', text: 'years' }, - ]; - - return ( - - - - Quick select - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Apply - - - - - ); - } - - renderCommonlyUsed = (commonDates) => { - const links = commonDates.map((date) => { - return ( - {date} - ); - }); - - return ( - - Commonly used - - - - {links} - - - - ); - } - - renderRecentlyUsed = (recentDates) => { - const links = recentDates.map((date) => { - let dateRange; - if (typeof date !== 'string') { - dateRange = `${date[0]} – ${date[1]}`; - } - - return ( - {dateRange || date} - ); - }); - - return ( - - Recently used date ranges - - - - {links} - - - - ); - } - - renderTimer = () => { - const lastOptions = [ - { value: 'minutes', text: 'minutes' }, - { value: 'hours', text: 'hours' }, - ]; - - return ( - - Refresh every - - - - - - - - - - - - - - - - {this.state.timerIsOn ? 'Stop' : 'Start'} - - - - - - ); - } - -} diff --git a/src-docs/src/views/date_picker/super_date_picker.js b/src-docs/src/views/date_picker/super_date_picker.js new file mode 100644 index 00000000000..47c0347522a --- /dev/null +++ b/src-docs/src/views/date_picker/super_date_picker.js @@ -0,0 +1,64 @@ + +import React, { Component } from 'react'; + +import { + EuiSuperDatePicker, +} from '../../../../src/components'; + +export default class extends Component { + + state = { + recentlyUsedRanges: [], + isLoading: false, + } + + onTimeChange = ({ start, end }) => { + this.setState((prevState) => { + const recentlyUsedRanges = prevState.recentlyUsedRanges.filter(recentlyUsedRange => { + const isDuplicate = recentlyUsedRange.start === start && recentlyUsedRange.end === end; + return !isDuplicate; + }); + recentlyUsedRanges.unshift({ start, end }); + return { + start, + end, + recentlyUsedRanges: recentlyUsedRanges.length > 10 ? recentlyUsedRanges.slice(0, 9) : recentlyUsedRanges, + isLoading: true, + }; + }, this.startLoading); + } + + startLoading = () => { + setTimeout( + this.stopLoading, + 1000); + } + + stopLoading = () => { + this.setState({ isLoading: false }); + } + + onRefreshChange = ({ isPaused, refreshInterval }) => { + this.setState((prevState) => { + return { + isPaused: isPaused == null ? prevState.isPaused : isPaused, + refreshInterval: refreshInterval == null ? prevState.refreshInterval : refreshInterval, + }; + }); + } + + render() { + return ( + + ); + } +} diff --git a/src/components/date_picker/_index.scss b/src/components/date_picker/_index.scss index 65219322767..6ae886348b2 100644 --- a/src/components/date_picker/_index.scss +++ b/src/components/date_picker/_index.scss @@ -1,3 +1,4 @@ // Uses some form mixins @import 'date_picker'; @import 'date_picker_range'; +@import 'super_date_picker/index'; diff --git a/src/components/date_picker/index.js b/src/components/date_picker/index.js index eb720402eec..00f6c0b638a 100644 --- a/src/components/date_picker/index.js +++ b/src/components/date_picker/index.js @@ -5,3 +5,7 @@ export { export { EuiDatePickerRange, } from './date_picker_range'; + +export { + EuiSuperDatePicker, +} from './super_date_picker'; diff --git a/src/components/date_picker/super_date_picker/__snapshots__/super_date_picker.test.js.snap b/src/components/date_picker/super_date_picker/__snapshots__/super_date_picker.test.js.snap new file mode 100644 index 00000000000..5147c25d067 --- /dev/null +++ b/src/components/date_picker/super_date_picker/__snapshots__/super_date_picker.test.js.snap @@ -0,0 +1,314 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiSuperDatePicker is rendered 1`] = ` + + + + } + > + } + iconType={false} + isCustom={true} + startDateControl={
} + > + + Last 15 minutes + + + Show dates + + + + + + + + Refresh + + + + +`; + +exports[`EuiSuperDatePicker isLoading 1`] = ` + + + + } + > + } + iconType={false} + isCustom={true} + startDateControl={
} + > + + Last 15 minutes + + + Show dates + + + + + + + + Updating + + + + +`; diff --git a/src/components/date_picker/super_date_picker/_index.scss b/src/components/date_picker/super_date_picker/_index.scss new file mode 100644 index 00000000000..6382670c4bf --- /dev/null +++ b/src/components/date_picker/super_date_picker/_index.scss @@ -0,0 +1 @@ +@import 'super_date_picker'; diff --git a/src-docs/src/views/date_picker/_global_date_picker.scss b/src/components/date_picker/super_date_picker/_super_date_picker.scss similarity index 79% rename from src-docs/src/views/date_picker/_global_date_picker.scss rename to src/components/date_picker/super_date_picker/_super_date_picker.scss index d522ce46bcb..16bdde5cf33 100644 --- a/src-docs/src/views/date_picker/_global_date_picker.scss +++ b/src/components/date_picker/super_date_picker/_super_date_picker.scss @@ -1,17 +1,15 @@ -//// GLOBAL Date picker - // sass-lint:disable no-important -.euiGlobalDatePicker__quickSelectButton { +.euiSuperDatePicker__quickSelectButton { // Override prepend border since button already lives inside another prepend border-right: none !important; - .euiGlobalDatePicker__quickSelectButtonText { + .euiSuperDatePicker__quickSelectButtonText { // Override specificity from universal and sibiling selectors margin-right: $euiSizeXS !important; } } -.euiGlobalDatePicker.euiFormControlLayout { +.euiSuperDatePicker.euiFormControlLayout { max-width: 480px; > .euiFormControlLayout__childrenWrapper { @@ -30,7 +28,7 @@ } } -.euiGlobalDatePicker__dateButton { +.euiSuperDatePicker__dateText { @include euiFormControlText; display: block; width: 100%; @@ -39,7 +37,9 @@ height: $euiFormControlHeight - 2px; word-break: break-all; transition: background $euiAnimSpeedFast ease-in; +} +.euiSuperDatePicker__dateButton { $backgroundColor: tintOrShade($euiColorSuccess, 90%, 70%); $textColor: makeHighContrastColor($euiColorSuccess, $backgroundColor); @@ -66,20 +66,20 @@ } } -.euiGlobalDatePicker__dateButton--start { +.euiSuperDatePicker__dateButton--start { text-align: right; } -.euiGlobalDatePicker__dateButton--end { +.euiSuperDatePicker__dateButton--end { text-align: left; } -.euiGlobalDatePicker__updateButton { +.euiSuperDatePicker__updateButton { // Just wide enough for all 3 states min-width: $euiButtonMinWidth + ($euiSizeXS * 1.5); } -.euiGlobalDatePicker__popoverSection { +.euiSuperDatePicker__popoverSection { @include euiScrollBar; max-height: $euiSizeM * 11; overflow: hidden; @@ -87,10 +87,10 @@ } @include euiBreakpoint('xs', 's') { - .euiGlobalDatePicker__updateButton { + .euiSuperDatePicker__updateButton { min-width: 0; - .euiGlobalDatePicker__updateButtonText { + .euiSuperDatePicker__updateButtonText { display: none; } } diff --git a/src/components/date_picker/super_date_picker/date_modes.js b/src/components/date_picker/super_date_picker/date_modes.js new file mode 100644 index 00000000000..5ab95e6b22a --- /dev/null +++ b/src/components/date_picker/super_date_picker/date_modes.js @@ -0,0 +1,30 @@ + +import dateMath from '@elastic/datemath'; +import { parseRelativeParts, toRelativeStringFromParts } from './relative_utils'; + +export const DATE_MODES = { + ABSOLUTE: 'absolute', + RELATIVE: 'relative', + NOW: 'now', +}; + +export function getDateMode(value) { + if (value === 'now') { + return DATE_MODES.NOW; + } + + if (value.includes('now')) { + return DATE_MODES.RELATIVE; + } + + return DATE_MODES.absolute; +} + +export function toAbsoluteString(value, roundUp) { + return dateMath.parse(value, { roundUp }).toISOString(); +} + + +export function toRelativeString(value) { + return toRelativeStringFromParts(parseRelativeParts(value)); +} diff --git a/src/components/date_picker/super_date_picker/date_popover/absolute_tab.js b/src/components/date_picker/super_date_picker/date_popover/absolute_tab.js new file mode 100644 index 00000000000..cd3c78fe4d4 --- /dev/null +++ b/src/components/date_picker/super_date_picker/date_popover/absolute_tab.js @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; + +import moment from 'moment'; + +import dateMath from '@elastic/datemath'; + +import { EuiDatePicker } from '../../date_picker'; +import { EuiFormRow, EuiFieldText } from '../../../form'; + +const INPUT_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss.SSS'; + +export class EuiAbsoluteTab extends Component { + + constructor(props) { + super(props); + + const valueAsMoment = dateMath.parse(props.value, { roundUp: props.roundUp }); + this.state = { + valueAsMoment, + textInputValue: valueAsMoment.format(INPUT_DATE_FORMAT), + isTextInvalid: false, + }; + } + + handleChange = (date) => { + this.props.onChange(date.toISOString()); + this.setState({ + valueAsMoment: date, + textInputValue: date.format(INPUT_DATE_FORMAT), + isTextInvalid: false, + }); + } + + handleTextChange = (evt) => { + const date = moment(evt.target.value, INPUT_DATE_FORMAT, true); + const updatedState = { + textInputValue: evt.target.value, + isTextInvalid: !date.isValid() + }; + if (date.isValid()) { + this.props.onChange(date.toISOString()); + updatedState.valueAsMoment = date; + } + + this.setState(updatedState); + } + + render() { + return ( +
+ + + + +
+ ); + } +} + +EuiAbsoluteTab.propTypes = { + dateFormat: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + roundUp: PropTypes.bool.isRequired, +}; diff --git a/src/components/date_picker/super_date_picker/date_popover/date_popover_button.js b/src/components/date_picker/super_date_picker/date_popover/date_popover_button.js new file mode 100644 index 00000000000..6619aa81333 --- /dev/null +++ b/src/components/date_picker/super_date_picker/date_popover/date_popover_button.js @@ -0,0 +1,99 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; + +import { EuiPopover } from '../../../popover'; + +import { formatTimeString } from '../pretty_duration'; +import { EuiDatePopoverContent } from './date_popover_content'; + +export class EuiDatePopoverButton extends Component { + static propTypes = { + position: PropTypes.oneOf(['start', 'end']), + isInvalid: PropTypes.bool, + needsUpdating: PropTypes.bool, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + dateFormat: PropTypes.string.isRequired, + roundUp: PropTypes.bool, + } + + state = { + isOpen: false, + } + + togglePopover = () => { + this.setState((prevState) => { + return { isOpen: !prevState.isOpen }; + }); + } + + closePopover = () => { + this.setState({ + isOpen: false, + }); + } + + render() { + const { + position, + isInvalid, + needsUpdating, + value, + buttonProps, + roundUp, + onChange, + dateFormat, + ...rest + } = this.props; + + const classes = classNames([ + 'euiSuperDatePicker__dateText', + 'euiSuperDatePicker__dateButton', + `euiSuperDatePicker__dateButton--${position}`, + { + 'euiSuperDatePicker__dateButton-isSelected': this.state.isOpen, + 'euiSuperDatePicker__dateButton-isInvalid': isInvalid, + 'euiSuperDatePicker__dateButton-needsUpdating': needsUpdating + } + ]); + + let title = value; + if (isInvalid) { + title = `Invalid date: ${title}`; + } else if (needsUpdating) { + title = `Update needed: ${title}`; + } + + const button = ( + + ); + + return ( + + + + ); + } +} diff --git a/src/components/date_picker/super_date_picker/date_popover/date_popover_content.js b/src/components/date_picker/super_date_picker/date_popover/date_popover_content.js new file mode 100644 index 00000000000..f5733e64811 --- /dev/null +++ b/src/components/date_picker/super_date_picker/date_popover/date_popover_content.js @@ -0,0 +1,97 @@ + +import PropTypes from 'prop-types'; +import React from 'react'; + +import { EuiTabbedContent } from '../../../tabs'; +import { EuiText } from '../../../text'; + +import { EuiAbsoluteTab } from './absolute_tab'; +import { EuiRelativeTab } from './relative_tab'; + +import { + getDateMode, + DATE_MODES, + toAbsoluteString, + toRelativeString, +} from '../date_modes'; + +export function EuiDatePopoverContent({ value, roundUp, onChange, dateFormat }) { + + const onTabClick = (selectedTab) => { + switch(selectedTab.id) { + case DATE_MODES.ABSOLUTE: + onChange(toAbsoluteString(value, roundUp)); + break; + case DATE_MODES.RELATIVE: + onChange(toRelativeString(value)); + break; + case DATE_MODES.NOW: + onChange('now'); + break; + } + }; + + const renderTabs = () => { + return [ + { + id: DATE_MODES.ABSOLUTE, + name: 'Absolute', + content: ( + + ), + 'data-test-subj': 'superDatePickerAbsoluteTab', + }, + { + id: DATE_MODES.RELATIVE, + name: 'Relative', + content: ( + + ), + 'data-test-subj': 'superDatePickerRelativeTab', + }, + { + id: DATE_MODES.NOW, + name: 'Now', + content: ( + +

+ Setting the time to "Now" means that on every refresh + this time will be set to the time of the refresh. +

+
+ ), + 'data-test-subj': 'superDatePickerNowTab', + } + ]; + }; + + return ( + + ); +} + +EuiDatePopoverContent.propTypes = { + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + roundUp: PropTypes.bool, + dateFormat: PropTypes.string.isRequired, +}; + +EuiDatePopoverContent.defaultProps = { + roundUp: false, +}; diff --git a/src/components/date_picker/super_date_picker/date_popover/relative_tab.js b/src/components/date_picker/super_date_picker/date_popover/relative_tab.js new file mode 100644 index 00000000000..13de817a720 --- /dev/null +++ b/src/components/date_picker/super_date_picker/date_popover/relative_tab.js @@ -0,0 +1,99 @@ + +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import dateMath from '@elastic/datemath'; + +import { EuiFlexGroup, EuiFlexItem } from '../../../flex'; +import { + EuiForm, + EuiFormRow, + EuiSelect, + EuiFieldNumber, + EuiFieldText, + EuiSwitch +} from '../../../form'; + +import { timeUnits } from '../time_units'; +import { relativeOptions } from '../relative_options'; +import { parseRelativeParts, toRelativeStringFromParts } from '../relative_utils'; + +export class EuiRelativeTab extends Component { + + constructor(props) { + super(props); + + this.state = { + ...parseRelativeParts(this.props.value), + }; + } + + onCountChange = (evt) => { + const sanitizedValue = parseInt(evt.target.value, 10); + this.setState({ + count: isNaN(sanitizedValue) ? '' : sanitizedValue, + }, this.handleChange); + } + + onUnitChange = (evt) => { + this.setState({ + unit: evt.target.value, + }, this.handleChange); + } + + onRoundChange = (evt) => { + this.setState({ + round: evt.target.checked, + }, this.handleChange); + }; + + handleChange = () => { + if (this.state.count === '') { + return; + } + this.props.onChange(toRelativeStringFromParts(this.state)); + } + + render() { + const formatedValue = dateMath.parse(this.props.value).format(this.props.dateFormat); + return ( + + + + + + + + + + + + + + + + + + + + + ); + } +} + +EuiRelativeTab.propTypes = { + dateFormat: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +}; diff --git a/src/components/date_picker/super_date_picker/index.js b/src/components/date_picker/super_date_picker/index.js new file mode 100644 index 00000000000..183a5d85800 --- /dev/null +++ b/src/components/date_picker/super_date_picker/index.js @@ -0,0 +1,5 @@ +import { + WrappedEuiSuperDatePicker, +} from './super_date_picker'; + +export { WrappedEuiSuperDatePicker as EuiSuperDatePicker }; diff --git a/src/components/date_picker/super_date_picker/pretty_duration.js b/src/components/date_picker/super_date_picker/pretty_duration.js new file mode 100644 index 00000000000..feb9fe2ff58 --- /dev/null +++ b/src/components/date_picker/super_date_picker/pretty_duration.js @@ -0,0 +1,81 @@ + +import dateMath from '@elastic/datemath'; +import moment from 'moment'; +import { timeUnits, timeUnitsPlural } from './time_units'; +import { getDateMode, DATE_MODES } from './date_modes'; +import { parseRelativeParts } from './relative_utils'; + +const ISO_FORMAT = 'YYYY-MM-DDTHH:mm:ss.SSSZ'; + +function cantLookup(timeFrom, timeTo, dateFormat) { + const displayFrom = formatTimeString(timeFrom, dateFormat); + const displayTo = formatTimeString(timeTo, dateFormat, true); + return `${displayFrom} to ${displayTo}`; +} + +function isRelativeToNow(timeFrom, timeTo) { + const fromDateMode = getDateMode(timeFrom); + const toDateMode = getDateMode(timeTo); + const isLast = fromDateMode === DATE_MODES.RELATIVE && toDateMode === DATE_MODES.NOW; + const isNext = fromDateMode === DATE_MODES.NOW && toDateMode === DATE_MODES.RELATIVE; + return isLast || isNext; +} + +export function formatTimeString(timeString, dateFormat, roundUp = false) { + const timeAsMoment = moment(timeString, ISO_FORMAT, true); + if (timeAsMoment.isValid()) { + return timeAsMoment.format(dateFormat); + } + + if (timeString === 'now') { + return 'now'; + } + + const tryParse = dateMath.parse(timeString, { roundUp: roundUp }); + if (moment.isMoment(tryParse)) { + return `~ ${tryParse.fromNow()}`; + } + + return timeString; +} + +export function prettyDuration(timeFrom, timeTo, quickRanges = [], dateFormat) { + const matchingQuickRange = quickRanges.find(({ start: quickFrom, end: quickTo }) => { + return timeFrom === quickFrom && timeTo === quickTo; + }); + if (matchingQuickRange) { + return matchingQuickRange.label; + } + + if (isRelativeToNow(timeFrom, timeTo)) { + let timeTense; + let relativeParts; + if (getDateMode(timeTo) === DATE_MODES.NOW) { + timeTense = 'Last'; + relativeParts = parseRelativeParts(timeFrom); + } else { + timeTense = 'Next'; + relativeParts = parseRelativeParts(timeTo); + } + const countTimeUnit = relativeParts.unit.substring(0, 1); + const countTimeUnitFullName = relativeParts.count > 1 ? timeUnitsPlural[countTimeUnit] : timeUnits[countTimeUnit]; + let text = `${timeTense} ${relativeParts.count} ${countTimeUnitFullName}`; + if (relativeParts.round) { + text += ` rounded to the ${timeUnits[relativeParts.roundUnit]}`; + } + return text; + } + + return cantLookup(timeFrom, timeTo, dateFormat); +} + +export function showPrettyDuration(timeFrom, timeTo, quickRanges = []) { + const matchingQuickRange = quickRanges.find(({ start: quickFrom, end: quickTo }) => { + return timeFrom === quickFrom && timeTo === quickTo; + }); + if (matchingQuickRange) { + return true; + } + + return isRelativeToNow(timeFrom, timeTo); +} diff --git a/src/components/date_picker/super_date_picker/pretty_duration.test.js b/src/components/date_picker/super_date_picker/pretty_duration.test.js new file mode 100644 index 00000000000..3f0124eea17 --- /dev/null +++ b/src/components/date_picker/super_date_picker/pretty_duration.test.js @@ -0,0 +1,69 @@ + +import { prettyDuration, showPrettyDuration } from './pretty_duration'; + +const dateFormat = 'MMMM Do YYYY, HH:mm:ss.SSS'; +const quickRanges = [ + { + start: 'now-15m', + end: 'now', + label: 'quick range 15 minutes custom display', + } +]; + +describe('prettyDuration', () => { + test('quick range', () => { + const timeFrom = 'now-15m'; + const timeTo = 'now'; + expect(prettyDuration(timeFrom, timeTo, quickRanges, dateFormat)).toBe('quick range 15 minutes custom display'); + }); + + test('last', () => { + const timeFrom = 'now-16m'; + const timeTo = 'now'; + expect(prettyDuration(timeFrom, timeTo, quickRanges, dateFormat)).toBe('Last 16 minutes'); + }); + + test('last that is rounded', () => { + const timeFrom = 'now-1M/w'; + const timeTo = 'now'; + expect(prettyDuration(timeFrom, timeTo, quickRanges, dateFormat)).toBe('Last 1 month rounded to the week'); + }); + + test('next', () => { + const timeFrom = 'now'; + const timeTo = 'now+16m'; + expect(prettyDuration(timeFrom, timeTo, quickRanges, dateFormat)).toBe('Next 16 minutes'); + }); + + test('from is in past', () => { + const timeFrom = 'now-17m'; + const timeTo = 'now-15m'; + expect(prettyDuration(timeFrom, timeTo, quickRanges, dateFormat)).toBe('~ 17 minutes ago to ~ 15 minutes ago'); + }); +}); + +describe('showPrettyDuration', () => { + test('should show pretty duration for quick range', () => { + expect(showPrettyDuration('now-15m', 'now', quickRanges)).toBe(true); + }); + + test('should show pretty duration for last', () => { + expect(showPrettyDuration('now-17m', 'now', quickRanges)).toBe(true); + }); + + test('should show pretty duration for next', () => { + expect(showPrettyDuration('now', 'now+17m', quickRanges)).toBe(true); + }); + + test('should not show pretty duration for relative to relative', () => { + expect(showPrettyDuration('now-17m', 'now-3m', quickRanges)).toBe(false); + }); + + test('should not show pretty duration for absolute to absolute', () => { + expect(showPrettyDuration('2018-01-17T18:57:57.149Z', '2018-01-17T20:00:00.000Z', quickRanges)).toBe(false); + }); + + test('should not show pretty duration for absolute to now', () => { + expect(showPrettyDuration('2018-01-17T18:57:57.149Z', 'now', quickRanges)).toBe(false); + }); +}); diff --git a/src/components/date_picker/super_date_picker/quick_select_popover/__snapshots__/quick_select.test.js.snap b/src/components/date_picker/super_date_picker/quick_select_popover/__snapshots__/quick_select.test.js.snap new file mode 100644 index 00000000000..95e30574a8e --- /dev/null +++ b/src/components/date_picker/super_date_picker/quick_select_popover/__snapshots__/quick_select.test.js.snap @@ -0,0 +1,433 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EuiSuperDatePicker is rendered 1`] = ` + + + + + + Quick select + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Apply + + + + + + +`; + +exports[`EuiSuperDatePicker prevQuickSelect 1`] = ` + + + + + + Quick select + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Apply + + + + + + +`; diff --git a/src/components/date_picker/super_date_picker/quick_select_popover/commonly_used_time_ranges.js b/src/components/date_picker/super_date_picker/quick_select_popover/commonly_used_time_ranges.js new file mode 100644 index 00000000000..a80774719e4 --- /dev/null +++ b/src/components/date_picker/super_date_picker/quick_select_popover/commonly_used_time_ranges.js @@ -0,0 +1,47 @@ + +import PropTypes from 'prop-types'; +import React, { Fragment } from 'react'; +import { commonlyUsedRangeShape } from '../types'; + +import { EuiFlexGrid, EuiFlexItem } from '../../../flex'; +import { EuiTitle } from '../../../title'; +import { EuiSpacer } from '../../../spacer'; +import { EuiLink } from '../../../link'; +import { EuiText } from '../../../text'; +import { EuiHorizontalRule } from '../../../horizontal_rule'; + +export function EuiCommonlyUsedTimeRanges({ applyTime, commonlyUsedRanges }) { + const links = commonlyUsedRanges.map(({ start, end, label }) => { + const applyCommonlyUsed = () => { + applyTime({ start, end }); + }; + return ( + + + {label} + + + ); + }); + + return ( + + Commonly used + + + + {links} + + + + + ); +} + +EuiCommonlyUsedTimeRanges.propTypes = { + applyTime: PropTypes.func.isRequired, + commonlyUsedRanges: PropTypes.arrayOf(commonlyUsedRangeShape).isRequired, +}; diff --git a/src/components/date_picker/super_date_picker/quick_select_popover/quick_select.js b/src/components/date_picker/super_date_picker/quick_select_popover/quick_select.js new file mode 100644 index 00000000000..3d06c1dfa9b --- /dev/null +++ b/src/components/date_picker/super_date_picker/quick_select_popover/quick_select.js @@ -0,0 +1,194 @@ + +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import moment from 'moment'; +import dateMath from '@elastic/datemath'; + +import { EuiButton, EuiButtonIcon } from '../../../button'; +import { EuiFlexGroup, EuiFlexItem } from '../../../flex'; +import { EuiTitle } from '../../../title'; +import { EuiSpacer } from '../../../spacer'; +import { EuiFormRow, EuiSelect, EuiFieldNumber } from '../../../form'; +import { EuiToolTip } from '../../../tool_tip'; +import { EuiHorizontalRule } from '../../../horizontal_rule'; + +import { timeUnits } from '../time_units'; + +const LAST = 'last'; +const NEXT = 'next'; + +const timeTenseOptions = [ + { value: LAST, text: 'Last' }, + { value: NEXT, text: 'Next' }, +]; +const timeUnitsOptions = Object.keys(timeUnits).map(key => { + return { value: key, text: `${timeUnits[key]}s` }; +}); + +export class EuiQuickSelect extends Component { + constructor(props) { + super(props); + + const { timeTense, timeValue, timeUnits } = this.props.prevQuickSelect; + this.state = { + timeTense: timeTense ? timeTense : LAST, + timeValue: timeValue ? timeValue : 15, + timeUnits: timeUnits ? timeUnits : 'm', + }; + } + + onTimeTenseChange = (evt) => { + this.setState({ + timeTense: evt.target.value, + }); + } + + onTimeValueChange = (evt) => { + const sanitizedValue = parseInt(evt.target.value, 10); + this.setState({ + timeValue: isNaN(sanitizedValue) ? '' : sanitizedValue, + }); + } + + onTimeUnitsChange = (evt) => { + this.setState({ + timeUnits: evt.target.value, + }); + } + + applyQuickSelect = () => { + const { + timeTense, + timeValue, + timeUnits, + } = this.state; + + if (timeTense === NEXT) { + this.props.applyTime({ + start: 'now', + end: `now+${timeValue}${timeUnits}`, + quickSelect: { ...this.state }, + }); + return; + } + + this.props.applyTime({ + start: `now-${timeValue}${timeUnits}`, + end: 'now', + quickSelect: { ...this.state }, + }); + } + + getBounds = () => { + return { + min: dateMath.parse(this.props.start), + max: dateMath.parse(this.props.end, { roundUp: true }), + }; + } + + stepForward = () => { + const { min, max } = this.getBounds(); + const diff = max.diff(min); + this.props.applyTime({ + start: moment(max).add(1, 'ms').toISOString(), + end: moment(max).add(diff + 1, 'ms').toISOString(), + keepPopoverOpen: true, + }); + } + + stepBackward = () => { + const { min, max } = this.getBounds(); + const diff = max.diff(min); + this.props.applyTime({ + start: moment(min).subtract(diff + 1, 'ms').toISOString(), + end: moment(min).subtract(1, 'ms').toISOString(), + keepPopoverOpen: true, + }); + } + + render() { + return ( + + + + Quick select + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Apply + + + + + + + ); + } +} + +EuiQuickSelect.propTypes = { + applyTime: PropTypes.func.isRequired, + start: PropTypes.string.isRequired, + end: PropTypes.string.isRequired, + prevQuickSelect: PropTypes.object, +}; + +EuiQuickSelect.defaultProps = { + prevQuickSelect: {}, +}; diff --git a/src/components/date_picker/super_date_picker/quick_select_popover/quick_select.test.js b/src/components/date_picker/super_date_picker/quick_select_popover/quick_select.test.js new file mode 100644 index 00000000000..49733a1b676 --- /dev/null +++ b/src/components/date_picker/super_date_picker/quick_select_popover/quick_select.test.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { + EuiQuickSelect, +} from './quick_select'; + +const noop = () => {}; +const defaultProps = { + applyTime: noop, + start: 'now-15m', + end: 'now', +}; +describe('EuiSuperDatePicker', () => { + test('is rendered', () => { + const component = shallow( + + ); + + expect(component) + .toMatchSnapshot(); + }); + + test('prevQuickSelect', () => { + const component = shallow( + + ); + + expect(component) + .toMatchSnapshot(); + }); +}); diff --git a/src/components/date_picker/super_date_picker/quick_select_popover/quick_select_popover.js b/src/components/date_picker/super_date_picker/quick_select_popover/quick_select_popover.js new file mode 100644 index 00000000000..889f5ba3f34 --- /dev/null +++ b/src/components/date_picker/super_date_picker/quick_select_popover/quick_select_popover.js @@ -0,0 +1,118 @@ + +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { commonlyUsedRangeShape, recentlyUsedRangeShape } from '../types'; + +import { + EuiButtonEmpty, +} from '../../../button'; + +import { + EuiIcon, +} from '../../../icon'; + +import { + EuiPopover, +} from '../../../popover'; + +import { EuiQuickSelect } from './quick_select'; +import { EuiCommonlyUsedTimeRanges } from './commonly_used_time_ranges'; +import { EuiRecentlyUsed } from './recently_used'; +import { EuiRefreshInterval } from './refresh_interval'; + +export class EuiQuickSelectPopover extends Component { + + state = { + isOpen: false, + } + + closePopover = () => { + this.setState({ isOpen: false }); + } + + togglePopover = () => { + this.setState((prevState) => ({ + isOpen: !prevState.isOpen + })); + } + + applyTime = ({ start, end, quickSelect, keepPopoverOpen = false }) => { + this.props.applyTime({ + start, + end, + }); + if (quickSelect) { + this.setState({ prevQuickSelect: quickSelect }); + } + if (!keepPopoverOpen) { + this.closePopover(); + } + } + + render() { + const quickSelectButton = ( + + + + ); + + return ( + +
+ + + + +
+
+ ); + } +} + +EuiQuickSelectPopover.propTypes = { + applyTime: PropTypes.func.isRequired, + start: PropTypes.string.isRequired, + end: PropTypes.string.isRequired, + applyRefreshInterval: PropTypes.func, + isPaused: PropTypes.bool.isRequired, + refreshInterval: PropTypes.number.isRequired, + commonlyUsedRanges: PropTypes.arrayOf(commonlyUsedRangeShape).isRequired, + dateFormat: PropTypes.string.isRequired, + recentlyUsedRanges: PropTypes.arrayOf(recentlyUsedRangeShape).isRequired, +}; diff --git a/src/components/date_picker/super_date_picker/quick_select_popover/recently_used.js b/src/components/date_picker/super_date_picker/quick_select_popover/recently_used.js new file mode 100644 index 00000000000..ab3afc75de5 --- /dev/null +++ b/src/components/date_picker/super_date_picker/quick_select_popover/recently_used.js @@ -0,0 +1,55 @@ + +import PropTypes from 'prop-types'; +import React, { Fragment } from 'react'; +import { commonlyUsedRangeShape, recentlyUsedRangeShape } from '../types'; +import { prettyDuration } from '../pretty_duration'; + +import { EuiFlexGroup, EuiFlexItem } from '../../../flex'; +import { EuiTitle } from '../../../title'; +import { EuiSpacer } from '../../../spacer'; +import { EuiLink } from '../../../link'; +import { EuiText } from '../../../text'; +import { EuiHorizontalRule } from '../../../horizontal_rule'; + +export function EuiRecentlyUsed({ applyTime, commonlyUsedRanges, dateFormat, recentlyUsedRanges }) { + if (recentlyUsedRanges.length === 0) { + return null; + } + + const links = recentlyUsedRanges.map(({ start, end }) => { + const applyRecentlyUsed = () => { + applyTime({ start, end }); + }; + return ( + + + {prettyDuration(start, end, commonlyUsedRanges, dateFormat)} + + + ); + }); + + return ( + + Recently used date ranges + + + + {links} + + + + + ); +} + +EuiRecentlyUsed.propTypes = { + applyTime: PropTypes.func.isRequired, + commonlyUsedRanges: PropTypes.arrayOf(commonlyUsedRangeShape).isRequired, + dateFormat: PropTypes.string.isRequired, + recentlyUsedRanges: PropTypes.arrayOf(recentlyUsedRangeShape), +}; + +EuiRecentlyUsed.defaultProps = { + recentlyUsedRanges: [] +}; diff --git a/src/components/date_picker/super_date_picker/quick_select_popover/refresh_interval.js b/src/components/date_picker/super_date_picker/quick_select_popover/refresh_interval.js new file mode 100644 index 00000000000..59f741b060c --- /dev/null +++ b/src/components/date_picker/super_date_picker/quick_select_popover/refresh_interval.js @@ -0,0 +1,137 @@ +import PropTypes from 'prop-types'; +import React, { Component, Fragment } from 'react'; +import { timeUnits } from '../time_units'; + +import { EuiFlexGroup, EuiFlexItem } from '../../../flex'; +import { EuiTitle } from '../../../title'; +import { EuiSpacer } from '../../../spacer'; +import { EuiFormRow, EuiSelect, EuiFieldNumber } from '../../../form'; +import { EuiButton } from '../../../button'; + +const refreshUnitsOptions = Object.keys(timeUnits) + .filter(timeUnit => { + return timeUnit === 'h' || timeUnit === 'm'; + }) + .map(timeUnit => { + return { value: timeUnit, text: `${timeUnits[timeUnit]}s` }; + }); + +const MILLISECONDS_IN_MINUTE = 1000 * 60; +const MILLISECONDS_IN_HOUR = MILLISECONDS_IN_MINUTE * 60; + +function convertMilliseconds(milliseconds) { + if (milliseconds > MILLISECONDS_IN_HOUR) { + return { + units: 'h', + value: milliseconds / MILLISECONDS_IN_HOUR + }; + } + + return { + units: 'm', + value: milliseconds / MILLISECONDS_IN_MINUTE + }; +} + +export class EuiRefreshInterval extends Component { + + constructor(props) { + super(props); + + const { value, units } = convertMilliseconds(props.refreshInterval); + this.state = { + value, + units, + }; + } + + onValueChange = (evt) => { + const sanitizedValue = parseInt(evt.target.value, 10); + this.setState({ + value: isNaN(sanitizedValue) ? '' : sanitizedValue, + }, this.applyRefreshInterval); + }; + + onUnitsChange = (evt) => { + this.setState({ + units: evt.target.value, + }, this.applyRefreshInterval); + } + + applyRefreshInterval = () => { + if (this.state.value === '') { + return; + } + + const valueInMilliSeconds = this.state.units === 'h' + ? this.state.value * MILLISECONDS_IN_HOUR + : this.state.value * MILLISECONDS_IN_MINUTE; + + this.props.applyRefreshInterval({ + refreshInterval: valueInMilliSeconds, + isPaused: valueInMilliSeconds <= 0 ? true : this.props.isPaused, + }); + } + + toogleRefresh = () => { + this.props.applyRefreshInterval({ + isPaused: !this.props.isPaused + }); + } + + render() { + if (!this.props.applyRefreshInterval) { + return null; + } + + return ( + + Refresh every + + + + + + + + + + + + + + + + {this.props.isPaused ? 'Start' : 'Stop'} + + + + + + ); + } +} + +EuiRefreshInterval.propTypes = { + applyRefreshInterval: PropTypes.func, + isPaused: PropTypes.bool.isRequired, + refreshInterval: PropTypes.number.isRequired, +}; diff --git a/src/components/date_picker/super_date_picker/relative_options.js b/src/components/date_picker/super_date_picker/relative_options.js new file mode 100644 index 00000000000..677191356cf --- /dev/null +++ b/src/components/date_picker/super_date_picker/relative_options.js @@ -0,0 +1,27 @@ + +export const relativeOptions = [ + { text: 'Seconds ago', value: 's' }, + { text: 'Minutes ago', value: 'm' }, + { text: 'Hours ago', value: 'h' }, + { text: 'Days ago', value: 'd' }, + { text: 'Weeks ago', value: 'w' }, + { text: 'Months ago', value: 'M' }, + { text: 'Years ago', value: 'y' }, + + { text: 'Seconds from now', value: 's+' }, + { text: 'Minutes from now', value: 'm+' }, + { text: 'Hours from now', value: 'h+' }, + { text: 'Days from now', value: 'd+' }, + { text: 'Weeks from now', value: 'w+' }, + { text: 'Months from now', value: 'M+' }, + { text: 'Years from now', value: 'y+' }, +]; + +export const relativeUnitsFromLargestToSmallest = relativeOptions + .filter(({ value }) => { + return !value.includes('+'); + }) + .map(({ value }) => { + return value; + }) + .reverse(); diff --git a/src/components/date_picker/super_date_picker/relative_utils.js b/src/components/date_picker/super_date_picker/relative_utils.js new file mode 100644 index 00000000000..4732080fe1b --- /dev/null +++ b/src/components/date_picker/super_date_picker/relative_utils.js @@ -0,0 +1,62 @@ + +import dateMath from '@elastic/datemath'; +import moment from 'moment'; +import _ from 'lodash'; +import { relativeUnitsFromLargestToSmallest } from './relative_options'; + +const ROUND_DELIMETER = '/'; + +export function parseRelativeParts(value) { + const matches = _.isString(value) && value.match(/now(([\-\+])([0-9]+)([smhdwMy])(\/[smhdwMy])?)?/); + + const isNow = matches && !matches[1]; + const operator = matches && matches[2]; + const count = matches && matches[3]; + const unit = matches && matches[4]; + const roundBy = matches && matches[5]; + + if (isNow) { + return { count: 0, unit: 's', round: false }; + } + + if (count && unit) { + const isRounded = roundBy ? true : false; + return { + count: parseInt(count, 10), + unit: operator === '+' ? `${unit}+` : unit, + round: isRounded, + roundUnit: isRounded ? roundBy.replace(ROUND_DELIMETER, '') : undefined, + }; + } + + const results = { count: 0, unit: 's', round: false }; + const duration = moment.duration(moment().diff(dateMath.parse(value))); + let unitOp = ''; + for (let i = 0; i < relativeUnitsFromLargestToSmallest.length; i++) { + const as = duration.as(relativeUnitsFromLargestToSmallest[i]); + if (as < 0) unitOp = '+'; + if (Math.abs(as) > 1) { + results.count = Math.round(Math.abs(as)); + results.unit = relativeUnitsFromLargestToSmallest[i] + unitOp; + results.round = false; + break; + } + } + return results; +} + +export function toRelativeStringFromParts(relativeParts) { + const count = _.get(relativeParts, `count`, 0); + const isRounded = _.get(relativeParts, `round`, false); + + if (count === 0 && !isRounded) { + return 'now'; + } + + const matches = _.get(relativeParts, `unit`, 's').match(/([smhdwMy])(\+)?/); + const unit = matches[1]; + const operator = matches && matches[2] ? matches[2] : '-'; + const round = isRounded ? `${ROUND_DELIMETER}${unit}` : ''; + + return `now${operator}${count}${unit}${round}`; +} diff --git a/src/components/date_picker/super_date_picker/relative_utils.test.js b/src/components/date_picker/super_date_picker/relative_utils.test.js new file mode 100644 index 00000000000..43fe7b048c9 --- /dev/null +++ b/src/components/date_picker/super_date_picker/relative_utils.test.js @@ -0,0 +1,121 @@ + +import { parseRelativeParts, toRelativeStringFromParts } from './relative_utils'; +import moment from 'moment'; + +describe('parseRelativeParts', () => { + describe('relative', () => { + it('should parse now', () => { + const out = parseRelativeParts('now'); + expect(out).toEqual({ + count: 0, + unit: 's', + round: false + }); + }); + + it('should parse now-2h', () => { + const out = parseRelativeParts('now-2h'); + expect(out).toEqual({ + count: 2, + unit: 'h', + round: false + }); + }); + + it('should parse now-2h/h', () => { + const out = parseRelativeParts('now-2h/h'); + expect(out).toEqual({ + count: 2, + unit: 'h', + round: true, + roundUnit: 'h', + }); + }); + + it('should parse now+10m/m', () => { + const out = parseRelativeParts('now+10m/m'); + expect(out).toEqual({ + count: 10, + unit: 'm+', + round: true, + roundUnit: 'm', + }); + }); + }); + + describe('absolute', () => { + it('should parse now', () => { + const out = parseRelativeParts(moment().toDate().toISOString()); + expect(out).toEqual({ + count: 0, + unit: 's', + round: false + }); + }); + + it('should parse 3 months ago', () => { + const out = parseRelativeParts(moment().subtract(3, 'M').toDate().toISOString()); + expect(out).toEqual({ + count: 3, + unit: 'M', + round: false + }); + }); + + it('should parse 15 minutes ago', () => { + const out = parseRelativeParts(moment().subtract(15, 'm').toDate().toISOString()); + expect(out).toEqual({ + count: 15, + unit: 'm', + round: false + }); + }); + + it('should parse 2 hours from now', () => { + const out = parseRelativeParts(moment().add(2, 'h').toDate().toISOString()); + expect(out).toEqual({ + count: 2, + unit: 'h+', + round: false + }); + }); + }); +}); + +describe('toRelativeStringFromParts', () => { + it('should convert parts to now', () => { + const out = toRelativeStringFromParts({ + count: 0, + unit: 's', + round: false + }); + expect(out).toEqual('now'); + }); + + it('should convert parts to now-2h', () => { + const out = toRelativeStringFromParts({ + count: 2, + unit: 'h', + round: false + }); + expect(out).toEqual('now-2h'); + }); + + it('should convert parts to now-2h/h', () => { + const out = toRelativeStringFromParts({ + count: 2, + unit: 'h', + round: true + }); + expect(out).toEqual('now-2h/h'); + }); + + it('should convert parts to now+10m/m', () => { + const out = toRelativeStringFromParts({ + count: 10, + unit: 'm+', + round: true + }); + expect(out).toEqual('now+10m/m'); + }); +}); diff --git a/src/components/date_picker/super_date_picker/super_date_picker.js b/src/components/date_picker/super_date_picker/super_date_picker.js new file mode 100644 index 00000000000..97756ccd20b --- /dev/null +++ b/src/components/date_picker/super_date_picker/super_date_picker.js @@ -0,0 +1,311 @@ + +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { commonlyUsedRangeShape, recentlyUsedRangeShape } from './types'; +import { prettyDuration, showPrettyDuration } from './pretty_duration'; + +import dateMath from '@elastic/datemath'; + +import { EuiQuickSelectPopover } from './quick_select_popover/quick_select_popover'; +import { EuiDatePopoverButton } from './date_popover/date_popover_button'; + +import { EuiDatePickerRange } from '../date_picker_range'; +import { EuiFormControlLayout } from '../../form'; +import { EuiButton, EuiButtonEmpty } from '../../button'; +import { EuiFlexGroup, EuiFlexItem } from '../../flex'; +import { EuiToolTip } from '../../tool_tip'; + +// EuiSuperDatePicker has state that needs to be reset when start or end change. +// Instead of using getDerivedStateFromProps, this wrapper adds a key to the component. +// When a key changes, React will create a new component instance rather than update the current one +// https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html#recommendation-fully-uncontrolled-component-with-a-key +export function WrappedEuiSuperDatePicker(props) { + return ( + + ); +} + +WrappedEuiSuperDatePicker.propTypes = { + isLoading: PropTypes.bool, + /** + * String as either datemath (e.g.: now, now-15m, now-15m/m) or + * absolute date in the format 'YYYY-MM-DDTHH:mm:ss.sssZ' + */ + start: PropTypes.string, + /** + * String as either datemath (e.g.: now, now-15m, now-15m/m) or + * absolute date in the format 'YYYY-MM-DDTHH:mm:ss.sssZ' + */ + end: PropTypes.string, + /** + * Callback for when the time changes. Called with { start, end } + */ + onTimeChange: PropTypes.func.isRequired, + isPaused: PropTypes.bool, + /** + * Refresh interval in milliseconds + */ + refreshInterval: PropTypes.number, + /** + * Callback for when the refresh interval changes. Called with { isPaused, refreshInterval } + * Supply onRefreshChange to show refresh interval inputs in quick select popover + */ + onRefreshChange: PropTypes.func, + + /** + * 'start' and 'end' must be string as either datemath (e.g.: now, now-15m, now-15m/m) or + * absolute date in the format 'YYYY-MM-DDTHH:mm:ss.sssZ' + */ + commonlyUsedRanges: PropTypes.arrayOf(commonlyUsedRangeShape), + dateFormat: PropTypes.string, + /** + * 'start' and 'end' must be string as either datemath (e.g.: now, now-15m, now-15m/m) or + * absolute date in the format 'YYYY-MM-DDTHH:mm:ss.sssZ' + */ + recentlyUsedRanges: PropTypes.arrayOf(recentlyUsedRangeShape), +}; + +WrappedEuiSuperDatePicker.defaultProps = { + start: 'now-15m', + end: 'now', + isPaused: true, + refreshInterval: 0, + commonlyUsedRanges: [ + { start: 'now/d', end: 'now/d', label: 'Today' }, + { start: 'now-1d/d', end: 'now-1d/d', label: 'Yesterday' }, + { start: 'now/w', end: 'now/w', label: 'This week' }, + { start: 'now/w', end: 'now', label: 'Week to date' }, + { start: 'now/M', end: 'now/M', label: 'This month' }, + { start: 'now/M', end: 'now', label: 'Month to date' }, + { start: 'now/y', end: 'now/y', label: 'This year' }, + { start: 'now/y', end: 'now', label: 'Year to date' }, + ], + dateFormat: 'MMM D, YYYY @ HH:mm:ss.SSS', + recentlyUsedRanges: [], +}; + +export class EuiSuperDatePicker extends Component { + + constructor(props) { + super(props); + + const { + start, + end, + commonlyUsedRanges + } = this.props; + + this.state = { + start, + end, + isInvalid: false, + hasChanged: false, + showPrettyDuration: showPrettyDuration(start, end, commonlyUsedRanges), + }; + } + + componentWillUnmount() { + this._isMounted = false; + } + + componentDidMount() { + this._isMounted = true; + } + + setTootipRef = node => (this.tooltip = node); + + showTooltip = () => { + if (!this._isMounted) { + return; + } + this.tooltip.showToolTip(); + } + hideTooltip = () => { + if (!this._isMounted) { + return; + } + this.tooltip.hideToolTip(); + } + + setTime = ({ start, end }) => { + const startMoment = dateMath.parse(start); + const endMoment = dateMath.parse(end, { roundUp: true }); + const isInvalid = (start === 'now' && end === 'now') || startMoment.isAfter(endMoment); + + if (this.tooltipTimeout) { + clearTimeout(this.tooltipTimeout); + this.hideTooltip(); + this.tooltipTimeout = null; + } + + this.setState({ + start, + end, + isInvalid, + hasChanged: true, + }); + + if (!isInvalid) { + this.showTooltip(); + this.tooltipTimeout = setTimeout(() => { + this.hideTooltip(); + }, 2000); + } + } + + setStart = (start) => { + this.setTime({ start, end: this.state.end }); + } + + setEnd = (end) => { + this.setTime({ start: this.state.start, end }); + } + + applyTime = () => { + this.props.onTimeChange({ start: this.state.start, end: this.state.end }); + } + + applyQuickTime = ({ start, end }) => { + this.props.onTimeChange({ start, end }); + } + + hidePrettyDuration = () => { + this.setState({ showPrettyDuration: false }); + } + + renderDatePickerRange = () => { + const { + start, + end, + hasChanged, + isInvalid, + } = this.state; + + if (this.state.showPrettyDuration) { + return ( + } + endDateControl={
} + > + + {prettyDuration(start, end, this.props.commonlyUsedRanges, this.props.dateFormat)} + + + Show dates + + + ); + } + + return ( + + } + endDateControl={ + + } + /> + ); + } + + renderUpdateButton = () => { + let buttonText = 'Refresh'; + if (this.state.hasChanged || this.props.isLoading) { + buttonText = this.props.isLoading ? 'Updating' : 'Update'; + } + return ( + + + {buttonText} + + + ); + } + + render() { + const quickSelect = ( + + ); + return ( + + + + + {this.renderDatePickerRange()} + + + + + {this.renderUpdateButton()} + + + + ); + } +} + +EuiSuperDatePicker.propTypes = WrappedEuiSuperDatePicker.propTypes; +EuiSuperDatePicker.defaultProps = WrappedEuiSuperDatePicker.defaultProps; + diff --git a/src/components/date_picker/super_date_picker/super_date_picker.test.js b/src/components/date_picker/super_date_picker/super_date_picker.test.js new file mode 100644 index 00000000000..5fa4f20e4ed --- /dev/null +++ b/src/components/date_picker/super_date_picker/super_date_picker.test.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { + EuiSuperDatePicker, +} from './super_date_picker'; + +const noop = () => {}; + +describe('EuiSuperDatePicker', () => { + test('is rendered', () => { + const component = shallow( + + ); + + expect(component) + .toMatchSnapshot(); + }); + + test('isLoading', () => { + const component = shallow( + + ); + + expect(component) + .toMatchSnapshot(); + }); +}); diff --git a/src/components/date_picker/super_date_picker/time_units.js b/src/components/date_picker/super_date_picker/time_units.js new file mode 100644 index 00000000000..73766803588 --- /dev/null +++ b/src/components/date_picker/super_date_picker/time_units.js @@ -0,0 +1,20 @@ + +export const timeUnits = { + s: 'second', + m: 'minute', + h: 'hour', + d: 'day', + w: 'week', + M: 'month', + y: 'year' +}; + +export const timeUnitsPlural = { + s: 'seconds', + m: 'minutes', + h: 'hours', + d: 'days', + w: 'weeks', + M: 'months', + y: 'years' +}; diff --git a/src/components/date_picker/super_date_picker/types.js b/src/components/date_picker/super_date_picker/types.js new file mode 100644 index 00000000000..7e58ad0acfa --- /dev/null +++ b/src/components/date_picker/super_date_picker/types.js @@ -0,0 +1,12 @@ +import PropTypes from 'prop-types'; + +export const commonlyUsedRangeShape = PropTypes.shape({ + start: PropTypes.string.isRequired, + end: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, +}); + +export const recentlyUsedRangeShape = PropTypes.shape({ + start: PropTypes.string.isRequired, + end: PropTypes.string.isRequired, +}); diff --git a/src/components/index.js b/src/components/index.js index abcebea19a6..a6d565280f5 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -73,6 +73,7 @@ export { export { EuiDatePicker, EuiDatePickerRange, + EuiSuperDatePicker, } from './date_picker'; export { diff --git a/yarn.lock b/yarn.lock index dc32a39eb95..28e52972457 100644 --- a/yarn.lock +++ b/yarn.lock @@ -780,6 +780,13 @@ lodash "^4.17.10" to-fast-properties "^2.0.0" +"@elastic/datemath@^5.0.2": + version "5.0.2" + resolved "https://registry.yarnpkg.com/@elastic/datemath/-/datemath-5.0.2.tgz#1e62fe7137acd6ebcde9a794ef22b91820c9e6cf" + integrity sha512-MYU7KedGPMYu3ljgrO3tY8I8rD73lvBCltd78k5avDIv/6gMbuhKXsMhkEPbb9angs9hR/2ADk0QcGbVxUBXUw== + dependencies: + tslib "^1.9.3" + "@elastic/eslint-config-kibana@^0.15.0": version "0.15.0" resolved "https://registry.yarnpkg.com/@elastic/eslint-config-kibana/-/eslint-config-kibana-0.15.0.tgz#a552793497cdfc1829c2f9b7cd7018eb008f1606" @@ -13508,7 +13515,7 @@ trim@0.0.1: dependencies: glob "^6.0.4" -tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0: +tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.9.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==