feat(scheduler): add new date & time picker

Summary: Adds a date and time picker to the new event creator

Test Plan: todo

Reviewers: bengotow, juan

Reviewed By: bengotow, juan

Differential Revision: https://phab.nylas.com/D2842
This commit is contained in:
Evan Morikawa 2016-04-05 18:42:19 -07:00
parent a3fe0f4d71
commit bb318bf69c
13 changed files with 594 additions and 128 deletions

View file

@ -1,11 +1,15 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import moment from 'moment-timezone' import moment from 'moment-timezone'
import {RetinaImg} from 'nylas-component-kit' import {
RetinaImg,
DatePicker,
TimePicker,
TabGroupRegion,
} from 'nylas-component-kit'
import {PLUGIN_ID} from '../scheduler-constants' import {PLUGIN_ID} from '../scheduler-constants'
import ProposedTimeList from './proposed-time-list' import ProposedTimeList from './proposed-time-list'
import EventDatetimeInput from './event-datetime-input'
import { import {
Utils, Utils,
@ -103,28 +107,79 @@ export default class NewEventCard extends React.Component {
}); });
} }
_eventStart() {
return moment.unix(this.props.event.start || moment().unix())
}
_eventEnd() {
return moment.unix(this.props.event.end || moment().unix())
}
_onChangeDay = (newTimestamp) => {
const newDay = moment(newTimestamp)
const start = this._eventStart()
const end = this._eventEnd()
start.year(newDay.year())
end.year(newDay.year())
start.dayOfYear(newDay.dayOfYear())
end.dayOfYear(newDay.dayOfYear())
this.props.onChange({start: start.unix(), end: end.unix()})
}
_onChangeStartTime = (newTimestamp) => {
const newTime = moment(newTimestamp)
const start = this._eventStart()
const end = this._eventEnd()
start.hour(newTime.hour())
start.minute(newTime.minute())
let newEnd = moment(end)
if (end.isSameOrBefore(start)) {
const leftInDay = moment(start).endOf('day').diff(start)
const move = Math.min(leftInDay, moment.duration(1, 'hour').asMilliseconds());
newEnd = moment(start).add(move, 'ms')
}
this.props.onChange({start: start.unix(), end: newEnd.unix()})
}
_onChangeEndTime = (newTimestamp) => {
const newTime = moment(newTimestamp)
const start = this._eventStart()
const end = this._eventEnd()
end.hour(newTime.hour())
end.minute(newTime.minute())
let newStart = moment(start)
if (start.isSameOrAfter(end)) {
const sinceDay = end.diff(moment(end).startOf('day'))
const move = Math.min(sinceDay, moment.duration(1, 'hour').asMilliseconds());
newStart = moment(end).subtract(move, 'ms');
}
this.props.onChange({end: end.unix(), start: newStart.unix()})
}
_renderTimePicker() { _renderTimePicker() {
const metadata = this.props.draft.metadataForPluginId(PLUGIN_ID); const metadata = this.props.draft.metadataForPluginId(PLUGIN_ID);
if (metadata && metadata.proposals) { if (metadata && metadata.proposals) {
return <ProposedTimeList event={this.props.event} proposals={metadata.proposals} /> return <ProposedTimeList event={this.props.event} proposals={metadata.proposals} />
} }
const startVal = (this.props.event.start) * 1000;
const endVal = (this.props.event.end) * 1000;
return ( return (
<div className="row time"> <div className="row time">
{this._renderIcon("ic-eventcard-time@2x.png")} {this._renderIcon("ic-eventcard-time@2x.png")}
<span> <span>
<EventDatetimeInput name="start" <TimePicker value={startVal} onChange={this._onChangeStartTime} />
value={this.props.event.start} to
onChange={ date => this.props.onChange({start: date}) } <TimePicker value={endVal} relativeTo={startVal}
/> onChange={this._onChangeEndTime}
-
<EventDatetimeInput name="end"
reversed
value={this.props.event.end}
onChange={ date => this.props.onChange({end: date}) }
/> />
<span className="timezone"> <span className="timezone">
{moment().tz(Utils.timeZone).format("z")} {moment().tz(Utils.timeZone).format("z")}
</span> </span>
&nbsp;
on
&nbsp;
<DatePicker value={startVal} onChange={this._onChangeDay} />
</span> </span>
</div> </div>
) )
@ -169,6 +224,7 @@ export default class NewEventCard extends React.Component {
return ( return (
<div className="new-event-card"> <div className="new-event-card">
<TabGroupRegion>
<div className="remove-button" onClick={this.props.onRemove}></div> <div className="remove-button" onClick={this.props.onRemove}></div>
<div className="row title"> <div className="row title">
{this._renderIcon("ic-eventcard-description@2x.png")} {this._renderIcon("ic-eventcard-description@2x.png")}
@ -214,6 +270,7 @@ export default class NewEventCard extends React.Component {
onChange={ e => this.props.onChange({description: e.target.value}) } onChange={ e => this.props.onChange({description: e.target.value}) }
/> />
</div> </div>
</TabGroupRegion>
</div> </div>
) )
} }

View file

@ -10,6 +10,10 @@ import {
import {RetinaImg} from 'nylas-component-kit' import {RetinaImg} from 'nylas-component-kit'
import {PLUGIN_ID, PLUGIN_NAME} from '../scheduler-constants' import {PLUGIN_ID, PLUGIN_NAME} from '../scheduler-constants'
import moment from 'moment'
// moment-round upon require patches `moment` with new functions.
require('moment-round')
export default class SchedulerComposerButton extends React.Component { export default class SchedulerComposerButton extends React.Component {
static displayName = "SchedulerComposerButton"; static displayName = "SchedulerComposerButton";
@ -78,8 +82,15 @@ editable calendar with your account provider.`);
return; return;
} }
const start = moment().ceil(30, 'minutes');
const end = moment(start).add(1, 'hour');
// TODO Have a default calendar config // TODO Have a default calendar config
const event = new Event({calendarId: cals[0].id}); const event = new Event({
end: end.unix(),
start: start.unix(),
calendarId: cals[0].id,
});
this._session.changes.add({events: [event]}); this._session.changes.add({events: [event]});
this._session.changes.commit() this._session.changes.commit()
}) })

View file

@ -55,7 +55,16 @@
color: #A9A9A9; color: #A9A9A9;
.timezone { .timezone {
font-size: @font-size-smaller; font-size: 10px;
font-weight: 600;
}
}
.row.time {
.time-picker {
text-align: center;
}
.time-picker-wrap {
margin-right: 5px;
} }
} }
.datetime-input-container { .datetime-input-container {
@ -79,81 +88,6 @@
} }
} }
input[type=date], input[type=time] {
display: inline-block;
width: initial;
margin: 0 3px;
margin-right: -9px;
padding: 0;
border: 1px solid rgba(0,0,0,0);
color: @text-color;
}
input[type=date]::-webkit-clear-button {
display: none;
}
input[type=date]::-webkit-inner-spin-button {
padding: 0;
margin: 0;
margin-left: -12px;
}
input[type=date]::-webkit-calendar-picker-indicator {
padding-left: 0;
padding-right: 0;
margin: 0;
display: none;
}
input[type=date]::-webkit-datetime-edit {
margin-right: -10px;
}
input[type=date]::-webkit-datetime-edit-fields-wrapper {
border: 1px solid #EEE;
border-radius: 3px;
padding: 0 2px;
}
input[type=date]:hover,
input[type=date]:active,
input[type=date]:focus {
background: #FFF;
border: 1px solid #EEE;
}
input[type=date]:hover::-webkit-datetime-edit-fields-wrapper,
input[type=date]:active::-webkit-datetime-edit-fields-wrapper,
input[type=date]:focus::-webkit-datetime-edit-fields-wrapper {
border: 1px solid rgba(0,0,0,0);
}
input[type=time]::-webkit-datetime-edit {
padding-right: 5px;
}
input[type=time]::-webkit-clear-button {
display: none;
}
input[type=time]::-webkit-inner-spin-button {
padding: 0;
margin: 0;
margin-left: -3px;
}
input[type=time]::-webkit-datetime-edit-fields-wrapper {
border: 1px solid #EEE;
border-radius: 3px;
padding: 0 2px;
}
input[type=time]:hover,
input[type=time]:focus {
background: #FFF;
border: 1px solid #EEE;
}
input[type=time]:hover::-webkit-datetime-edit-fields-wrapper,
input[type=time]:focus::-webkit-datetime-edit-fields-wrapper {
border: 1px solid rgba(0,0,0,0);
}
input[type=text] { input[type=text] {
border-radius: 0; border-radius: 0;
display: inline-block; display: inline-block;

View file

@ -0,0 +1,94 @@
import React from 'react'
import moment from 'moment'
import classnames from 'classnames'
import {DateUtils} from 'nylas-exports'
import {MiniMonthView} from 'nylas-component-kit'
export default class DatePicker extends React.Component {
static displayName = "DatePicker";
static propTypes = {
value: React.PropTypes.number,
onChange: React.PropTypes.func,
}
static contextTypes = {
parentTabGroup: React.PropTypes.object,
}
static defaultProps = {
value: moment().valueOf(),
onChange: () => {},
}
constructor(props) {
super(props);
this.state = {focused: false}
}
_moveDay(numDays) {
const val = moment(this.props.value)
const day = val.dayOfYear();
this.props.onChange(val.dayOfYear(day + numDays).valueOf())
}
_onKeyDown = (event) => {
if (event.key === "ArrowLeft") {
this._moveDay(-1)
} else if (event.key === "ArrowRight") {
this._moveDay(1)
} else if (event.key === "ArrowUp") {
this._moveDay(-7)
} else if (event.key === "ArrowDown") {
this._moveDay(7)
} else if (event.key === "Enter") {
this.context.parentTabGroup.shiftFocus(1);
}
}
_onFocus = () => {
this.setState({focused: true})
}
_onBlur = () => {
this.setState({focused: false})
}
_onSelectDay = (newTimestamp) => {
this.props.onChange(newTimestamp)
this.context.parentTabGroup.shiftFocus(1);
}
_renderMiniMonthView() {
if (this.state.focused) {
return (
<div className="mini-month-view-wrap">
<MiniMonthView
onChange={this._onSelectDay}
value={this.props.value}
/>
</div>
)
}
return false
}
render() {
const className = classnames({
'day-text': true,
focused: this.state.focused,
})
const dayTxt = moment(this.props.value).format(DateUtils.DATE_FORMAT_llll_NO_TIME)
return (
<div tabIndex={0} className="date-picker"
onKeyDown={this._onKeyDown} onFocus={this._onFocus}
onBlur={this._onBlur}
>
<div className={className}>{dayTxt}</div>
{this._renderMiniMonthView()}
</div>
)
}
}

View file

@ -0,0 +1,117 @@
import _ from 'underscore'
import React from 'react'
import moment from 'moment'
import classnames from 'classnames'
export default class MiniMonthView extends React.Component {
static displayName = "MiniMonthView";
static propTypes = {
value: React.PropTypes.number,
onChange: React.PropTypes.func,
}
static defaultProps = {
value: moment().valueOf(),
onChange: () => {},
}
constructor(props) {
super(props);
this.today = moment()
this.state = this._stateFromProps(props)
}
componentWillReceiveProps(newProps) {
this.setState(this._stateFromProps(newProps))
}
_stateFromProps(props) {
const m = moment(props.value);
return {
shownYear: m.year(),
shownMonth: m.month(),
}
}
_shownMonthMoment() {
return moment([this.state.shownYear, this.state.shownMonth])
}
_changeMonth = (by) => {
const newMonth = this.state.shownMonth + by;
const newMoment = this._shownMonthMoment().month(newMonth);
this.setState({
shownYear: newMoment.year(),
shownMonth: newMoment.month(),
})
}
_renderLegend() {
const weekdayGen = moment([2016]);
const legendEls = [];
for (let i = 0; i < 7; i++) {
const dayStr = weekdayGen.weekday(i).format("dd"); // Locale aware!
legendEls.push(<span key={i} className="weekday">{dayStr}</span>)
}
return <div className="legend">{legendEls}</div>;
}
_onClickDay = (event) => {
const newVal = moment(parseInt(event.target.dataset.timestamp, 10)).valueOf()
this.props.onChange(newVal)
}
_isSameDay(m1, m2) {
return m1.dayOfYear() === m2.dayOfYear() && m1.year() === m2.year()
}
_renderDays() {
const dayIter = this._shownMonthMoment().date(1);
const startWeek = dayIter.week();
const curMonth = this.state.shownMonth;
const weekEls = []
const valDay = moment(this.props.value)
for (let week = startWeek; week < 5 + startWeek; week++) {
dayIter.week(week); // Locale aware!
const dayEls = []
for (let weekday = 0; weekday < 7; weekday++) {
dayIter.weekday(weekday); // Locale aware!
const dayStr = dayIter.format("D");
const className = classnames({
day: true,
today: this._isSameDay(dayIter, this.today),
"cur-day": this._isSameDay(dayIter, valDay),
"cur-month": dayIter.month() === curMonth,
})
dayEls.push(
<div className={className} key={`${week}-${weekday}`}
data-timestamp={dayIter.valueOf()}
>{dayStr}</div>
)
}
weekEls.push(<div className="week" key={week}>{dayEls}</div>)
}
return (
<div className="day-grid" onClick={this._onClickDay}>{weekEls}</div>
)
}
render() {
return (
<div className="mini-month-view">
<div className="header">
<div className="btn btn-icon"
onClick={_.partial(this._changeMonth, -1)}
>&lsaquo;</div>
<span className="title">{this._shownMonthMoment().format("MMMM YYYY")}</span>
<div className="btn btn-icon"
onClick={_.partial(this._changeMonth, 1)}
>&rsaquo;</div>
</div>
{this._renderLegend()}
{this._renderDays()}
</div>
)
}
}

View file

@ -14,7 +14,7 @@ class TabGroupRegion extends React.Component
return return
shiftFocus: (dir) => shiftFocus: (dir) =>
nodes = ReactDOM.findDOMNode(@).querySelectorAll('input, [contenteditable], [tabIndex]') nodes = ReactDOM.findDOMNode(@).querySelectorAll('input, textarea, [contenteditable], [tabIndex]')
current = document.activeElement current = document.activeElement
idx = Array.from(nodes).indexOf(current) idx = Array.from(nodes).indexOf(current)
@ -27,10 +27,15 @@ class TabGroupRegion extends React.Component
continue if nodes[idx].tabIndex is -1 continue if nodes[idx].tabIndex is -1
nodes[idx].focus() nodes[idx].focus()
if nodes[idx].nodeName is 'INPUT' and nodes[idx].type is "text" if @_shouldSelectEnd(nodes[idx])
nodes[idx].setSelectionRange(nodes[idx].value.length, nodes[idx].value.length) nodes[idx].setSelectionRange(nodes[idx].value.length, nodes[idx].value.length)
return return
_shouldSelectEnd: (node) ->
node.nodeName is "INPUT" and
node.type is "text" and
"no-select-end" not in node.classList
getChildContext: => getChildContext: =>
parentTabGroup: @ parentTabGroup: @

View file

@ -0,0 +1,128 @@
import React from 'react'
import ReactDOM from 'react-dom'
import moment from 'moment'
import classnames from 'classnames'
export default class TimePicker extends React.Component {
static displayName = "TimePicker";
static propTypes = {
value: React.PropTypes.number,
onChange: React.PropTypes.func,
relativeTo: React.PropTypes.number, // TODO For `renderTimeOptions`
}
static contextTypes = {
parentTabGroup: React.PropTypes.object,
}
static defaultProps = {
value: moment().valueOf(),
onChange: () => {},
}
constructor(props) {
super(props);
this.state = {
focused: false,
rawText: this._valToTimeString(props.value),
}
}
componentWillReceiveProps(newProps) {
this.setState({rawText: this._valToTimeString(newProps.value)})
}
_valToTimeString(value) {
return moment(value).format("LT")
}
_onKeyDown = (event) => {
if (event.key === "ArrowUp") {
// TODO: When `renderTimeOptions` is implemented
} else if (event.key === "ArrowDown") {
// TODO: When `renderTimeOptions` is implemented
} else if (event.key === "Enter") {
this.context.parentTabGroup.shiftFocus(1);
}
}
_onFocus = () => {
this.setState({focused: true});
const el = ReactDOM.findDOMNode(this.refs.input);
el.setSelectionRange(0, el.value.length)
}
_onBlur = () => {
this.setState({focused: false})
this._saveIfValid(this.state.rawText)
}
_onRawTextChange = (event) => {
this.setState({rawText: event.target.value});
}
_saveIfValid(rawText = "") {
// Locale-aware am/pm parsing!!
const parsedMoment = moment(rawText, "h:ma");
if (parsedMoment.isValid()) {
if (this._shouldAddTwelve(rawText) && parsedMoment.hour() < 12) {
parsedMoment.add(12, 'hours');
}
this.props.onChange(parsedMoment.valueOf())
}
}
/**
* If you're going to punch only "2" into the time field, you probably
* mean 2pm instead of 2am. The regex explicitly checks for only digits
* (no meridiem indicators) and very basic use cases.
*/
_shouldAddTwelve(rawText) {
const simpleDigitMatch = rawText.match(/^(\d{1,2})(:\d{1,2})?$/);
if (simpleDigitMatch && simpleDigitMatch.length > 0) {
const hr = parseInt(simpleDigitMatch[1], 10);
if (hr <= 7) {
// If you're going to punch in "2" into the time field, you
// probably mean 2pm, not 2am.
return true
}
}
return false
}
// TODO
_renderTimeOptions() {
// TODO: When you select a time a dropdown will additionally show
// letting you pick from preset times. The `relativeTo` prop will give
// you relative times
const opts = []
if (this.state.focused) {
return (
<div className="time-options">{opts}</div>
)
}
return false
}
render() {
const className = classnames({
"time-picker": true,
"no-select-end": true,
invalid: !moment(this.state.rawText, "h:ma").isValid(),
})
return (
<div className="time-picker-wrap">
<input className={className}
type="text"
ref="input"
value={this.state.rawText}
onChange={this._onRawTextChange}
onKeyDown={this._onKeyDown} onFocus={this._onFocus}
onBlur={this._onBlur}
/>
{this._renderTimeOptions()}
</div>
)
}
}

View file

@ -82,6 +82,8 @@ const DateUtils = {
// Localized format: MMM D, h:mmA // Localized format: MMM D, h:mmA
DATE_FORMAT_SHORT: moment.localeData().longDateFormat('lll').replace(yearRegex, ''), DATE_FORMAT_SHORT: moment.localeData().longDateFormat('lll').replace(yearRegex, ''),
DATE_FORMAT_llll_NO_TIME: moment.localeData().longDateFormat("llll").replace(/h:mm/, "").replace(" A", ""),
format(momentDate, formatString) { format(momentDate, formatString) {
if (!momentDate) return null; if (!momentDate) return null;
return momentDate.format(formatString); return momentDate.format(formatString);

View file

@ -34,6 +34,7 @@ class NylasComponentKit
@load "ListTabular", 'list-tabular' @load "ListTabular", 'list-tabular'
@load "DraggableImg", 'draggable-img' @load "DraggableImg", 'draggable-img'
@load "NylasCalendar", 'nylas-calendar/nylas-calendar' @load "NylasCalendar", 'nylas-calendar/nylas-calendar'
@load "MiniMonthView", 'nylas-calendar/mini-month-view'
@load "EventedIFrame", 'evented-iframe' @load "EventedIFrame", 'evented-iframe'
@load "ButtonDropdown", 'button-dropdown' @load "ButtonDropdown", 'button-dropdown'
@load "Contenteditable", 'contenteditable/contenteditable' @load "Contenteditable", 'contenteditable/contenteditable'
@ -52,6 +53,8 @@ class NylasComponentKit
@load "OutlineViewItem", "outline-view-item" @load "OutlineViewItem", "outline-view-item"
@load "OutlineView", "outline-view" @load "OutlineView", "outline-view"
@load "DateInput", "date-input" @load "DateInput", "date-input"
@load "DatePicker", "date-picker"
@load "TimePicker", "time-picker"
@load "ScrollRegion", 'scroll-region' @load "ScrollRegion", 'scroll-region'
@load "ResizableRegion", 'resizable-region' @load "ResizableRegion", 'resizable-region'

View file

@ -0,0 +1,23 @@
@import 'ui-variables';
.date-picker {
display: inline-block;
position: relative;
z-index: 999;
.day-text {
&:hover {
color: @text-color-link-hover;
}
&.focused {
color: @text-color-link-hover;
}
color: @text-color-link;
}
.mini-month-view-wrap {
position: absolute;
top: 20px;
left: 0;
width: 225px;
height: 225px;
}
}

View file

@ -255,3 +255,81 @@
order: 0; order: 0;
} }
} }
.mini-month-view {
width: 100%;
height: 100%;
min-width: 200px;
min-height: 200px;
text-align: center;
display: flex;
flex-direction: column;
background: @background-primary;
border: 1px solid @border-color-divider;
border-radius: @border-radius-base;
.header {
display: flex;
background: @background-secondary;
padding: 4px 0 3px 0;
.title {
padding-top: 3px;
color: @text-color;
flex: 1;
}
border-bottom: 1px solid @border-color-divider;
.btn.btn-icon {
line-height: 27px;
height: 27px;
margin-top: -1px;
margin-right: 0;
&:active {
background: transparent;
}
}
}
.legend {
display: flex;
.weekday {
flex: 1;
}
padding: 3px 0;
border-bottom: 1px solid @border-color-divider;
}
.day-grid {
display: flex;
flex-direction: column;
flex: 1;
.week {
flex: 1;
display: flex;
min-height: 28px;
}
.day {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
min-height: 28px;
color: @text-color-very-subtle;
&.cur-month {
color: @text-color;
}
&:hover {
background: rgba(0,0,0,0.05);
}
&.today {
border: 1px solid @accent-primary;
}
&.cur-day {
background: @accent-primary;
color: @text-color-inverse;
&:hover {
background: darken(@accent-primary, 5%);
}
}
}
}
}

View file

@ -0,0 +1,12 @@
@import "ui-variables";
.time-picker-wrap {
display: inline-block;
width: 5.5em;
input {
width: 5.5em;
&.invalid {
background: fade(@color-error, 10%);
}
}
}

View file

@ -35,3 +35,5 @@
@import "components/date-input"; @import "components/date-input";
@import "components/nylas-calendar"; @import "components/nylas-calendar";
@import "components/empty-list-state"; @import "components/empty-list-state";
@import "components/date-picker";
@import "components/time-picker";