diff --git a/package.json b/package.json index bf13bc8a3..38d917832 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,13 @@ "draft-js-utils": "^1.2.0", "fast-isnumeric": "^1.1.1", "immutability-helper": "^2.7.1", - "plotly-icons": "1.2.2", + "plotly-icons": "1.2.3", "plotly.js": "1.43.1", "prop-types": "^15.5.10", "raf": "^3.4.0", "react-color": "^2.13.8", "react-colorscales": "0.7.2", + "react-day-picker": "^7.2.4", "react-dropzone": "^5.0.1", "react-plotly.js": "^2.2.0", "react-rangeslider": "^2.2.0", diff --git a/scripts/translationKeys/combined-translation-keys.txt b/scripts/translationKeys/combined-translation-keys.txt index b13c4e37c..2848b9cc4 100644 --- a/scripts/translationKeys/combined-translation-keys.txt +++ b/scripts/translationKeys/combined-translation-keys.txt @@ -221,7 +221,7 @@ Distributions Divergence // react-chart-editor: /components/fields/derived.js:597 Diverging // react-chart-editor: /default_panels/StyleLayoutPanel.js:30 Double-click on legend to isolate one trace // plotly.js: components/legend/handle_click.js:89 -Double-click to zoom back out // plotly.js: plots/cartesian/dragbox.js:1040 +Double-click to zoom back out // plotly.js: plots/cartesian/dragbox.js:1041 Download plot // plotly.js: components/modebar/buttons.js:55 Download plot as a png // plotly.js: components/modebar/buttons.js:54 Drag // react-chart-editor: /default_panels/StyleLayoutPanel.js:90 diff --git a/src/components/fields/AxisRangeValue.js b/src/components/fields/AxisRangeValue.js index 48a78a7c7..42825c822 100644 --- a/src/components/fields/AxisRangeValue.js +++ b/src/components/fields/AxisRangeValue.js @@ -1,6 +1,6 @@ import Field from './Field'; import {UnconnectedNumeric} from './Numeric'; -import {UnconnectedText} from './Text'; +import {UnconnectedDateTimePicker} from './DateTimePicker'; import PropTypes from 'prop-types'; import React, {Component} from 'react'; import {connectToContainer} from 'lib'; @@ -9,7 +9,7 @@ export class UnconnectedAxisRangeValue extends Component { render() { return this.props.multiValued || (this.props.fullContainer && this.props.fullContainer.type === 'date') ? ( - + ) : ( ); diff --git a/src/components/fields/DateTimePicker.js b/src/components/fields/DateTimePicker.js new file mode 100644 index 000000000..1cddef6f4 --- /dev/null +++ b/src/components/fields/DateTimePicker.js @@ -0,0 +1,28 @@ +import Field from './Field'; +import Picker from '../widgets/DateTimePicker'; +import PropTypes from 'prop-types'; +import React, {Component} from 'react'; +import {connectToContainer} from 'lib'; + +export class UnconnectedDateTimePicker extends Component { + render() { + return ( + + + + ); + } +} + +UnconnectedDateTimePicker.propTypes = { + fullValue: PropTypes.string, + updatePlot: PropTypes.func, + placeholder: PropTypes.string, + ...Field.propTypes, +}; + +export default connectToContainer(UnconnectedDateTimePicker); diff --git a/src/components/fields/NumericOrDate.js b/src/components/fields/NumericOrDate.js index cd4fc3706..2f72a6c20 100644 --- a/src/components/fields/NumericOrDate.js +++ b/src/components/fields/NumericOrDate.js @@ -1,20 +1,20 @@ import Field from './Field'; import {UnconnectedNumeric} from './Numeric'; -import {UnconnectedText} from './Text'; import PropTypes from 'prop-types'; import React, {Component} from 'react'; import {connectToContainer} from 'lib'; import {isDateTime} from 'plotly.js/src/lib'; import {isJSDate} from 'plotly.js/src/lib/dates'; +import {UnconnectedDateTimePicker} from './DateTimePicker'; export class UnconnectedNumericOrDate extends Component { render() { + const date = typeof this.props.fullValue === 'string' && this.props.fullValue.split(' ')[0]; const fullValueIsDate = - typeof this.props.fullValue === 'string' && - (isDateTime(this.props.fullValue) || isJSDate(this.props.fullValue)); + typeof this.props.fullValue === 'string' && date && (isDateTime(date) || isJSDate(date)); return fullValueIsDate ? ( - + ) : ( ); diff --git a/src/components/fields/index.js b/src/components/fields/index.js index 6322e6b2b..35ca099ed 100644 --- a/src/components/fields/index.js +++ b/src/components/fields/index.js @@ -33,6 +33,8 @@ import MultiColorPicker from './MultiColorPicker'; import RectanglePositioner from './RectanglePositioner'; import LocationSelector from './LocationSelector'; import AxisInterval from './AxisInterval'; +import DateTimePicker from './DateTimePicker'; + import { AnnotationArrowRef, AnnotationRef, @@ -128,4 +130,5 @@ export { HovermodeDropdown, AxisInterval, NumericOrDate, + DateTimePicker, }; diff --git a/src/components/widgets/DateTimePicker.js b/src/components/widgets/DateTimePicker.js new file mode 100644 index 000000000..fc0dcd1c3 --- /dev/null +++ b/src/components/widgets/DateTimePicker.js @@ -0,0 +1,301 @@ +import 'react-day-picker/lib/style.css'; +import {CalendarMultiselectIcon} from 'plotly-icons'; +import {ms2DateTime, dateTime2ms, isDateTime} from 'plotly.js/src/lib/dates'; +import DayPicker from 'react-day-picker'; +import PropTypes from 'prop-types'; +import React, {Component} from 'react'; +import TextInput from './TextInput'; +import Dropdown from './Dropdown'; + +const testDate = '2000-01-01'; +const testTime = '00:00'; +const datePlaceholder = 'yyyy-mm-dd'; +const timePlaceholder = 'hh:mm:ss.xxx'; + +export default class DateTimePicker extends Component { + constructor(props, context) { + super(props, context); + const {time, date} = this.parsePlotlyJSDateTime(props.value); + const isValidTime = isDateTime(testDate + ' ' + time) || ['', timePlaceholder].includes(time); + const isValidDate = isDateTime(date + ' ' + testTime) || ['', datePlaceholder].includes(date); + + this.state = { + calendarOpen: false, + dateInputClassName: isValidDate + ? 'datetimepicker-container-date-input' + : 'datetimepicker-container-date-input +error', + timeInputClassName: isValidTime + ? 'datetimepicker-container-time-input' + : 'datetimepicker-container-time-input +error', + timeValue: time, + dateValue: date, + AMPM: this.getAMPM(date, time, context.localize), + }; + + this.toPlotlyJSDate = this.toPlotlyJSDate.bind(this); + this.onMonthChange = this.onMonthChange.bind(this); + this.onYearChange = this.onYearChange.bind(this); + this.onTimeChange = this.onTimeChange.bind(this); + this.onDateChange = this.onDateChange.bind(this); + this.onTimeUpdate = this.onTimeUpdate.bind(this); + this.onDateUpdate = this.onDateUpdate.bind(this); + } + + toPlotlyJSDate(value) { + const ms = dateTime2ms(value); + return ms2DateTime(ms); + } + + getYearOptions(current) { + // eslint-disable-next-line + const base = 5; + const yearAsNumber = parseInt(current, 10); + + const lastFive = new Array(base).fill(0).map((year, index) => { + const newOption = yearAsNumber - (base - index); + return {label: newOption, value: newOption}; + }); + + const nextFive = new Array(base).fill(0).map((year, index) => { + const newOption = yearAsNumber + (index + 1); + return {label: newOption, value: newOption}; + }); + + return lastFive.concat([{label: current, value: current}]).concat(nextFive); + } + + getMonthOptions() { + const _ = this.context.localize; + return [ + {label: _('January'), value: 0}, + {label: _('February'), value: 1}, + {label: _('March'), value: 2}, + {label: _('April'), value: 3}, + {label: _('May'), value: 4}, + {label: _('June'), value: 5}, + {label: _('July'), value: 6}, + {label: _('August'), value: 7}, + {label: _('September'), value: 8}, + {label: _('October'), value: 9}, + {label: _('November'), value: 10}, + {label: _('December'), value: 11}, + ]; + } + + onMonthChange(value) { + const currentDateInJS = new Date(this.getAdjustedPlotlyJSDateTime(this.props.value)); + currentDateInJS.setMonth(value); + const plotlyJSDate = this.toPlotlyJSDate(currentDateInJS); + + if (isDateTime(plotlyJSDate)) { + this.props.onChange(plotlyJSDate); + } + + const {time, date} = this.parsePlotlyJSDateTime(plotlyJSDate); + this.setState({ + timeValue: time, + dateValue: date, + }); + } + + onYearChange(value) { + const currentDateInJS = new Date(this.getAdjustedPlotlyJSDateTime(this.props.value)); + currentDateInJS.setFullYear(value); + const plotlyJSDate = this.toPlotlyJSDate(currentDateInJS); + + if (isDateTime(plotlyJSDate)) { + this.props.onChange(plotlyJSDate); + } + + const {time, date} = this.parsePlotlyJSDateTime(plotlyJSDate); + this.setState({ + timeValue: time, + dateValue: date, + }); + } + + parsePlotlyJSDateTime(value) { + const parsed = value.split(' '); + return {date: parsed[0], time: parsed[1] ? parsed[1] : ''}; + } + + getAMPM(date, time, _) { + const plotlyJSDateTime = date + ' ' + time; + const isValidDateTime = isDateTime(plotlyJSDateTime); + const JSDate = new Date(this.getAdjustedPlotlyJSDateTime(plotlyJSDateTime)); + const localeTime = JSDate.toLocaleTimeString('en-US').split(' '); + + const parsedTime = time.split(':').reduce((timeArray, timePart) => { + const parsed = timePart.split('.'); + return timeArray.concat(parsed); + }, []); + + const isNoon = + parsedTime[0] === '12' && parsedTime.slice(1).every(part => parseInt(part, 10) === 0); + + return !isValidDateTime || time === '' || JSDate.toDateString() === 'Invalid Date' + ? '' + : localeTime[1] === 'PM' + ? isNoon + ? _('noon') + : 'PM' + : 'AM'; + } + + adjustedTime(time) { + if (time.toString().length <= 2) { + return time + ':00'; + } + return time; + } + + onTimeChange(value) { + const {date: currentDate} = this.parsePlotlyJSDateTime(this.props.value); + const isValidTime = isDateTime(testDate + ' ' + value); + + this.setState({ + timeInputClassName: + isValidTime || value === '' + ? 'datetimepicker-container-time-input' + : 'datetimepicker-container-time-input +error', + timeValue: value, + AMPM: this.getAMPM(currentDate, value, this.context.localize), + }); + } + + onDateChange(value) { + const isValidDate = isDateTime(value + ' ' + testTime); + this.setState({ + dateInputClassName: + isValidDate || value === '' + ? 'datetimepicker-container-date-input' + : 'datetimepicker-container-date-input +error', + dateValue: value, + }); + } + + onTimeUpdate(value) { + const {time: currentTime, date: currentDate} = this.parsePlotlyJSDateTime(this.props.value); + const isValidTime = isDateTime(testDate + ' ' + value); + + if (value === '') { + this.setState({ + timeInputClassName: 'datetimepicker-container-time-input', + timeValue: currentTime, + AMPM: this.getAMPM(currentDate, currentTime, this.context.localize), + }); + return; + } + + if (isValidTime) { + this.props.onChange(currentDate + ' ' + value); + } + } + + onDateUpdate(value) { + const {date: currentDate, time: currentTime} = this.parsePlotlyJSDateTime(this.props.value); + const isValidDate = isDateTime(value + ' ' + testTime); + + if (isValidDate) { + this.props.onChange(value + ' ' + currentTime); + return; + } + + if (value === '') { + this.setState({ + dateValue: currentDate, + dateInputClassName: 'datetimepicker-container-date-input', + }); + return; + } + } + + getAdjustedPlotlyJSDateTime(dateTimeString) { + const {date, time} = this.parsePlotlyJSDateTime(dateTimeString); + return date + ' ' + this.adjustedTime(time); + } + + render() { + const JSDate = new Date( + this.getAdjustedPlotlyJSDateTime(this.state.dateValue + ' ' + testTime) + ); + const isValidJSDate = JSDate.toDateString() !== 'Invalid Date'; + const currentYear = isValidJSDate ? JSDate.getFullYear() : new Date().getFullYear(); + const currentMonth = isValidJSDate ? JSDate.getMonth() : new Date().getMonth(); + + return ( +
+ +
+ this.setState({calendarOpen: !this.state.calendarOpen})} + className={ + this.state.calendarOpen + ? 'datetimepicker-date-icon--selected' + : 'datetimepicker-date-icon' + } + /> +
+ {this.state.calendarOpen ? ( +
+ {this.state.calendarOpen ? ( +
+
+ + +
+ { + const plotlyDate = this.toPlotlyJSDate(value).split(' ')[0]; + this.onDateChange(plotlyDate); + this.onDateUpdate(plotlyDate); + }} + /> +
+ ) : null} +
+ ) : null} +
+ + {this.state.AMPM} +
+
+ ); + } +} + +DateTimePicker.propTypes = { + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +}; + +DateTimePicker.contextTypes = { + localize: PropTypes.func, +}; diff --git a/src/components/widgets/Dropdown.js b/src/components/widgets/Dropdown.js index 3de20f193..0f9e85de8 100644 --- a/src/components/widgets/Dropdown.js +++ b/src/components/widgets/Dropdown.js @@ -57,6 +57,7 @@ class Dropdown extends Component { const dropdownContainerClass = classnames('dropdown-container', { 'dropdown--dark': this.props.backgroundDark, + [this.props.className]: this.props.className, }); return ( diff --git a/src/components/widgets/NumericInput.js b/src/components/widgets/NumericInput.js index 2557172cc..1c4904103 100644 --- a/src/components/widgets/NumericInput.js +++ b/src/components/widgets/NumericInput.js @@ -26,7 +26,7 @@ export default class NumericInput extends Component { getNumericInputClassName(value) { return isNumeric(value) || value === '' ? `numeric-input__number ${this.props.editableClassName ? this.props.editableClassName : ''}` - : `numeric-input__number--error ${ + : `numeric-input__number +error ${ this.props.editableClassName ? this.props.editableClassName : '' }`; } diff --git a/src/components/widgets/TextInput.js b/src/components/widgets/TextInput.js index fe27ad287..82cecd301 100644 --- a/src/components/widgets/TextInput.js +++ b/src/components/widgets/TextInput.js @@ -18,7 +18,7 @@ export default class TextInput extends Component { render() { return (