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 (