test(plugins): Add snooze and send later specs

Summary:
- Also refactors the code a bit for testability and maintainability
- Fixes #1515

Test Plan: - Unit tests

Reviewers: evan, drew, bengotow

Reviewed By: bengotow

Differential Revision: https://phab.nylas.com/D2651
This commit is contained in:
Juan Tejada 2016-03-03 12:37:20 -08:00
parent a8a0154c44
commit 0fd2d107b2
23 changed files with 959 additions and 317 deletions

View file

@ -2,7 +2,8 @@
"extends": "airbnb",
"globals": {
"NylasEnv": false,
"$n": false
"$n": false,
"waitsForPromise": false
},
"env": {
"browser": true,

View file

@ -5,7 +5,9 @@ import SendLaterStore from './send-later-store'
import SendLaterStatus from './send-later-status'
export function activate() {
SendLaterStore.activate()
this.store = new SendLaterStore()
this.store.activate()
ComponentRegistry.register(SendLaterPopover, {role: 'Composer:ActionButton'})
ComponentRegistry.register(SendLaterStatus, {role: 'DraftList:DraftStatus'})
}
@ -13,7 +15,7 @@ export function activate() {
export function deactivate() {
ComponentRegistry.unregister(SendLaterPopover)
ComponentRegistry.unregister(SendLaterStatus)
SendLaterStore.deactivate()
this.store.deactivate()
}
export function serialize() {

View file

@ -2,10 +2,9 @@
import Rx from 'rx-lite'
import React, {Component, PropTypes} from 'react'
import {DateUtils, Message, DatabaseStore} from 'nylas-exports'
import {Popover, RetinaImg, Menu} from 'nylas-component-kit'
import {Popover, RetinaImg, Menu, DateInput} from 'nylas-component-kit'
import SendLaterActions from './send-later-actions'
import SendLaterStore from './send-later-store'
import {DATE_FORMAT_SHORT, DATE_FORMAT_LONG} from './send-later-constants'
import {DATE_FORMAT_SHORT, DATE_FORMAT_LONG, PLUGIN_ID} from './send-later-constants'
const SendLaterOptions = {
@ -28,7 +27,6 @@ class SendLaterPopover extends Component {
constructor(props) {
super(props)
this.state = {
inputDate: null,
scheduledDate: null,
}
}
@ -36,85 +34,52 @@ class SendLaterPopover extends Component {
componentDidMount() {
this._subscription = Rx.Observable.fromQuery(
DatabaseStore.findBy(Message, {clientId: this.props.draftClientId})
).subscribe((draft)=> {
const nextScheduledDate = SendLaterStore.getScheduledDateForMessage(draft);
if (nextScheduledDate !== this.state.scheduledDate) {
const isPopout = (NylasEnv.getWindowType() === "composer");
const isFinishedSelecting = ((this.state.scheduledDate === 'saving') && (nextScheduledDate !== null));
if (isPopout && isFinishedSelecting) {
NylasEnv.close();
}
this.setState({scheduledDate: nextScheduledDate});
}
});
).subscribe(this.onMessageChanged);
}
componentWillUnmount() {
this._subscription.dispose();
}
onSelectMenuOption = (optionKey)=> {
const date = SendLaterOptions[optionKey]();
this.onSelectDate(date, optionKey);
};
onMessageChanged = (message)=> {
if (!message) return;
const messageMetadata = message.metadataForPluginId(PLUGIN_ID) || {}
const nextScheduledDate = messageMetadata.sendLaterDate
onSelectCustomOption = (value)=> {
const date = DateUtils.futureDateFromString(value);
if (date) {
this.onSelectDate(date, "Custom");
} else {
NylasEnv.showErrorDialog(`Sorry, we can't parse ${value} as a valid date.`);
if (nextScheduledDate !== this.state.scheduledDate) {
const isComposer = NylasEnv.isComposerWindow()
const isFinishedSelecting = ((this.state.scheduledDate === 'saving') && (nextScheduledDate !== null));
if (isComposer && isFinishedSelecting) {
NylasEnv.close();
}
this.setState({scheduledDate: nextScheduledDate});
}
};
onSelectDate = (date, label)=> {
const formatted = DateUtils.format(date.utc());
SendLaterActions.sendLater(this.props.draftClientId, formatted, label);
this.setState({scheduledDate: 'saving', inputDate: null});
this.refs.popover.close();
onSelectMenuOption = (optionKey)=> {
const date = SendLaterOptions[optionKey]();
this.selectDate(date, optionKey);
};
onSelectCustomOption = (date, inputValue)=> {
if (date) {
this.selectDate(date, "Custom");
} else {
NylasEnv.showErrorDialog(`Sorry, we can't parse ${inputValue} as a valid date.`);
}
};
onCancelSendLater = ()=> {
SendLaterActions.cancelSendLater(this.props.draftClientId);
this.setState({inputDate: null});
this.refs.popover.close();
};
renderCustomTimeSection() {
const onChange = (event)=> {
this.setState({inputDate: DateUtils.futureDateFromString(event.target.value)});
}
const onKeyDown = (event)=> {
// we need to swallow these events so they don't reach the menu
// containing the text input, but only when you've typed something.
const val = event.target.value;
if ((val.length > 0) && ["Enter", "Return"].includes(event.key)) {
this.onSelectCustomOption(val);
event.stopPropagation();
}
};
let dateInterpretation = false;
if (this.state.inputDate) {
dateInterpretation = (<span className="time">
{DateUtils.format(this.state.inputDate, DATE_FORMAT_LONG)}
</span>);
}
return (
<div key="custom" className="custom-time-section">
<input
tabIndex="1"
type="text"
placeholder="Or, 'next Monday at 2PM'"
onKeyDown={onKeyDown}
onChange={onChange}/>
{dateInterpretation}
</div>
)
}
selectDate = (date, dateLabel)=> {
const formatted = DateUtils.format(date.utc());
SendLaterActions.sendLater(this.props.draftClientId, formatted, dateLabel);
this.setState({scheduledDate: 'saving'});
this.refs.popover.close();
};
renderMenuOption(optionKey) {
const date = SendLaterOptions[optionKey]();
@ -139,7 +104,7 @@ class SendLaterPopover extends Component {
);
}
let dateInterpretation = false;
let dateInterpretation;
if (scheduledDate) {
className += ' btn-enabled';
const momentDate = DateUtils.futureDateFromString(scheduledDate);
@ -163,7 +128,11 @@ class SendLaterPopover extends Component {
]
const footerComponents = [
<div key="divider" className="divider" />,
this.renderCustomTimeSection(),
<DateInput
key="custom"
className="custom-section"
dateFormat={DATE_FORMAT_LONG}
onSubmitDate={this.onSelectCustomOption} />,
];
if (this.state.scheduledDate) {

View file

@ -7,9 +7,10 @@ import {PLUGIN_ID, PLUGIN_NAME} from './send-later-constants'
class SendLaterStore extends NylasStore {
constructor(pluginId = PLUGIN_ID) {
constructor(pluginId = PLUGIN_ID, pluginName = PLUGIN_NAME) {
super()
this.pluginId = pluginId;
this.pluginName = pluginName;
}
activate() {
@ -19,19 +20,12 @@ class SendLaterStore extends NylasStore {
];
}
getScheduledDateForMessage = (message)=> {
if (!message) {
return null;
}
const metadata = message.metadataForPluginId(this.pluginId) || {};
return metadata.sendLaterDate || null;
};
setMetadata = (draftClientId, metadata)=> {
DatabaseStore.modelify(Message, [draftClientId]).then((messages)=> {
return DatabaseStore.modelify(Message, [draftClientId])
.then((messages)=> {
const {accountId} = messages[0];
NylasAPI.authPlugin(this.pluginId, PLUGIN_NAME, accountId)
return NylasAPI.authPlugin(this.pluginId, this.pluginName, accountId)
.then(()=> {
Actions.setMetadata(messages, this.pluginId, metadata);
})
@ -42,13 +36,13 @@ class SendLaterStore extends NylasStore {
});
};
recordAction(sendLaterDate, label) {
recordAction(sendLaterDate, dateLabel) {
try {
if (sendLaterDate) {
const min = Math.round(((new Date(sendLaterDate)).valueOf() - Date.now()) / 1000 / 60);
Actions.recordUserEvent("Send Later", {
sendLaterTime: min,
optionLabel: label,
optionLabel: dateLabel,
});
} else {
Actions.recordUserEvent("Send Later Cancel");
@ -58,8 +52,8 @@ class SendLaterStore extends NylasStore {
}
}
onSendLater = (draftClientId, sendLaterDate, label)=> {
this.recordAction(sendLaterDate, label)
onSendLater = (draftClientId, sendLaterDate, dateLabel)=> {
this.recordAction(sendLaterDate, dateLabel)
this.setMetadata(draftClientId, {sendLaterDate});
};
@ -73,5 +67,4 @@ class SendLaterStore extends NylasStore {
};
}
export default new SendLaterStore()
export default SendLaterStore

View file

@ -0,0 +1,106 @@
import React, {addons} from 'react/addons';
import {Rx, DatabaseStore, DateUtils} from 'nylas-exports'
import SendLaterPopover from '../lib/send-later-popover';
import SendLaterActions from '../lib/send-later-actions';
import {renderIntoDocument} from '../../../spec/nylas-test-utils'
const {findDOMNode} = React;
const {TestUtils: {
findRenderedDOMComponentWithClass,
}} = addons;
const makePopover = (props = {})=> {
const popover = renderIntoDocument(<SendLaterPopover {...props} draftClientId="1" />);
if (props.initialState) {
popover.setState(props.initialState)
}
return popover
};
describe('SendLaterPopover', ()=> {
beforeEach(()=> {
spyOn(DatabaseStore, 'findBy')
spyOn(Rx.Observable, 'fromQuery').andReturn(Rx.Observable.empty())
spyOn(DateUtils, 'format').andReturn('formatted')
spyOn(SendLaterActions, 'sendLater')
});
describe('onMessageChanged', ()=> {
it('sets scheduled date correctly', ()=> {
const popover = makePopover({initialState: {scheduledDate: 'old'}})
const message = {
metadataForPluginId: ()=> ({sendLaterDate: 'date'}),
}
spyOn(popover, 'setState')
spyOn(NylasEnv, 'isComposerWindow').andReturn(false)
popover.onMessageChanged(message)
expect(popover.setState).toHaveBeenCalledWith({scheduledDate: 'date'})
});
it('closes window if window is composer window and saving has finished', ()=> {
const popover = makePopover({initialState: {scheduledDate: 'saving'}})
const message = {
metadataForPluginId: ()=> ({sendLaterDate: 'date'}),
}
spyOn(popover, 'setState')
spyOn(NylasEnv, 'close')
spyOn(NylasEnv, 'isComposerWindow').andReturn(true)
popover.onMessageChanged(message)
expect(popover.setState).toHaveBeenCalledWith({scheduledDate: 'date'})
expect(NylasEnv.close).toHaveBeenCalled()
});
it('does nothing if new date is the same as current date', ()=> {
const popover = makePopover({initialState: {scheduledDate: 'date'}})
const message = {
metadataForPluginId: ()=> ({sendLaterDate: 'date'}),
}
spyOn(popover, 'setState')
popover.onMessageChanged(message)
expect(popover.setState).not.toHaveBeenCalled()
});
});
describe('selectDate', ()=> {
it('sets scheduled date to "saving" and dispatches action', ()=> {
const popover = makePopover()
spyOn(popover, 'setState')
spyOn(popover.refs.popover, 'close')
popover.selectDate({utc: ()=> 'utc'})
expect(SendLaterActions.sendLater).toHaveBeenCalled()
expect(popover.setState).toHaveBeenCalledWith({scheduledDate: 'saving'})
expect(popover.refs.popover.close).toHaveBeenCalled()
});
});
describe('renderButton', ()=> {
it('renders spinner if saving', ()=> {
const popover = makePopover({initialState: {scheduledDate: 'saving'}})
const button = findDOMNode(
findRenderedDOMComponentWithClass(popover, 'btn-send-later')
)
expect(button.title).toEqual('Saving send date...')
});
it('renders date if message is scheduled', ()=> {
spyOn(DateUtils, 'futureDateFromString').andReturn({fromNow: ()=> '5 minutes'})
const popover = makePopover({initialState: {scheduledDate: 'date'}})
const span = findDOMNode(findRenderedDOMComponentWithClass(popover, 'at'))
expect(span.textContent).toEqual('Sending in 5 minutes')
});
it('does not render date if message is not scheduled', ()=> {
const popover = makePopover({initialState: {scheduledDate: null}})
expect(()=> {
findRenderedDOMComponentWithClass(popover, 'at')
}).toThrow()
});
});
});

View file

@ -0,0 +1,68 @@
import {
Message,
NylasAPI,
Actions,
DatabaseStore,
} from 'nylas-exports'
import SendLaterStore from '../lib/send-later-store'
describe('SendLaterStore', ()=> {
beforeEach(()=> {
this.store = new SendLaterStore('plug-id', 'plug-name')
});
describe('setMetadata', ()=> {
beforeEach(()=> {
this.message = new Message({accountId: 123, clientId: 'c-1'})
this.metadata = {sendLaterDate: 'the future'}
spyOn(this.store, 'recordAction')
spyOn(DatabaseStore, 'modelify').andReturn(Promise.resolve([this.message]))
spyOn(NylasAPI, 'authPlugin').andReturn(Promise.resolve())
spyOn(Actions, 'setMetadata')
spyOn(NylasEnv, 'reportError')
spyOn(NylasEnv, 'showErrorDialog')
});
it('auths the plugin correctly', ()=> {
waitsForPromise(()=> {
return this.store.setMetadata('c-1', this.metadata)
.then(()=> {
expect(NylasAPI.authPlugin).toHaveBeenCalled()
expect(NylasAPI.authPlugin).toHaveBeenCalledWith(
'plug-id',
'plug-name',
123
)
})
})
});
it('sets the correct metadata', ()=> {
waitsForPromise(()=> {
return this.store.setMetadata('c-1', this.metadata)
.then(()=> {
expect(Actions.setMetadata).toHaveBeenCalledWith(
[this.message],
'plug-id',
this.metadata
)
expect(NylasEnv.reportError).not.toHaveBeenCalled()
})
})
});
it('displays dialog if an error occurs', ()=> {
jasmine.unspy(NylasAPI, 'authPlugin')
spyOn(NylasAPI, 'authPlugin').andReturn(Promise.reject(new Error('Oh no!')))
waitsForPromise(()=> {
return this.store.setMetadata('c-1', this.metadata)
.finally(()=> {
expect(Actions.setMetadata).not.toHaveBeenCalled()
expect(NylasEnv.reportError).toHaveBeenCalled()
expect(NylasEnv.showErrorDialog).toHaveBeenCalled()
})
})
});
});
});

View file

@ -1,12 +1,6 @@
@import "ui-variables";
.send-later {
.time {
font-size: @font-size-small;
opacity: 0.6;
}
.menu {
width: 250px;
.item {
@ -26,11 +20,8 @@
.divider {
border-top: 1px solid @border-color-divider;
}
.custom-time-section {
.custom-section {
padding: @padding-base-vertical * 1.5 @padding-base-horizontal;
.time {
color: @text-color-subtle;
}
}
.cancel-section {
padding: @padding-base-vertical @padding-base-horizontal;

View file

@ -8,6 +8,8 @@ import SnoozeStore from './snooze-store'
export function activate() {
this.snoozeStore = new SnoozeStore()
this.snoozeStore.activate()
ComponentRegistry.register(ToolbarSnooze, {role: 'message:Toolbar'});
ComponentRegistry.register(SnoozeQuickActionButton, {role: 'ThreadListQuickAction'});
ComponentRegistry.register(BulkThreadSnooze, {role: 'thread:BulkAction'});

View file

@ -2,7 +2,7 @@ import _ from 'underscore';
import React, {Component, PropTypes} from 'react';
import {RetinaImg, MailLabel} from 'nylas-component-kit';
import {SNOOZE_CATEGORY_NAME, PLUGIN_ID} from './snooze-constants';
import {snoozeMessage} from './snooze-utils';
import {snoozedUntilMessage} from './snooze-utils';
class SnoozeMailLabel extends Component {
@ -21,7 +21,7 @@ class SnoozeMailLabel extends Component {
if (metadata) {
// TODO this is such a hack
const {snoozeDate} = metadata;
const message = snoozeMessage(snoozeDate).replace('Snoozed', '')
const message = snoozedUntilMessage(snoozeDate).replace('Snoozed', '')
const content = (
<span className="snooze-mail-label">
<RetinaImg

View file

@ -2,7 +2,7 @@
import _ from 'underscore';
import React, {Component, PropTypes} from 'react';
import {DateUtils, Actions} from 'nylas-exports'
import {RetinaImg} from 'nylas-component-kit';
import {RetinaImg, DateInput} from 'nylas-component-kit';
import SnoozeActions from './snooze-actions'
import {DATE_FORMAT_LONG} from './snooze-constants'
@ -20,7 +20,7 @@ const SnoozeOptions = [
],
]
const SnoozeDateGenerators = {
const SnoozeDatesFactory = {
'Later today': DateUtils.laterToday,
'Tonight': DateUtils.tonight,
'Tomorrow': DateUtils.tomorrow,
@ -56,19 +56,16 @@ class SnoozePopoverBody extends Component {
constructor() {
super();
this.didSnooze = false;
this.state = {
inputDate: null,
}
}
componentWillUnmount() {
this.props.swipeCallback(this.didSnooze);
}
onSnooze(dateGenerator, label) {
const utcDate = dateGenerator().utc();
onSnooze(date, itemLabel) {
const utcDate = date.utc();
const formatted = DateUtils.format(utcDate);
SnoozeActions.snoozeThreads(this.props.threads, formatted, label);
SnoozeActions.snoozeThreads(this.props.threads, formatted, itemLabel);
this.didSnooze = true;
this.props.closePopover();
@ -77,37 +74,27 @@ class SnoozePopoverBody extends Component {
Actions.popSheet();
}
onInputChange = (event)=> {
const inputDate = DateUtils.futureDateFromString(event.target.value)
this.setState({inputDate})
};
onInputKeyDown = (event)=> {
const {value} = event.target;
if (value.length > 0 && ["Enter", "Return"].includes(event.key)) {
const inputDate = DateUtils.futureDateFromString(value);
if (inputDate) {
this.onSnooze(() => {return inputDate}, "Custom");
// Prevent onInputChange from firing
event.stopPropagation()
}
onSelectCustomDate = (date, inputValue)=> {
if (date) {
this.onSnooze(date, "Custom");
} else {
NylasEnv.showErrorDialog(`Sorry, we can't parse ${inputValue} as a valid date.`);
}
};
renderItem = (label)=> {
const dateGenerator = SnoozeDateGenerators[label];
const iconName = SnoozeIconNames[label];
renderItem = (itemLabel)=> {
const date = SnoozeDatesFactory[itemLabel]();
const iconName = SnoozeIconNames[itemLabel];
const iconPath = `nylas://thread-snooze/assets/ic-snoozepopover-${iconName}@2x.png`;
return (
<div
key={label}
key={itemLabel}
className="snooze-item"
onClick={this.onSnooze.bind(this, dateGenerator, label)}>
onClick={this.onSnooze.bind(this, date, itemLabel)}>
<RetinaImg
url={iconPath}
mode={RetinaImg.Mode.ContentIsMask} />
{label}
{itemLabel}
</div>
)
};
@ -121,32 +108,16 @@ class SnoozePopoverBody extends Component {
);
};
renderInputRow = (inputDate)=> {
let formatted = null;
if (inputDate) {
formatted = 'Snooze until ' + DateUtils.format(inputDate, DATE_FORMAT_LONG);
}
return (
<div className="snooze-input">
<input
type="text"
tabIndex="1"
placeholder="Or type a time, like 'next Monday at 2PM'"
onKeyDown={this.onInputKeyDown}
onChange={this.onInputChange}/>
<span className="input-date-value">{formatted}</span>
</div>
);
};
render() {
const {inputDate} = this.state;
const rows = SnoozeOptions.map(this.renderRow);
return (
<div className="snooze-container" tabIndex="-1">
{rows}
{this.renderInputRow(inputDate)}
<DateInput
className="snooze-input"
dateFormat={DATE_FORMAT_LONG}
onSubmitDate={this.onSelectCustomDate} />
</div>
);
}

View file

@ -1,20 +1,28 @@
/** @babel */
import _ from 'underscore';
import {Actions, NylasAPI, AccountStore} from 'nylas-exports';
import {moveThreadsToSnooze, moveThreadsFromSnooze} from './snooze-utils';
import {Actions, NylasAPI, AccountStore, CategoryStore} from 'nylas-exports';
import {
moveThreadsToSnooze,
moveThreadsFromSnooze,
getSnoozeCategoriesByAccount,
} from './snooze-utils';
import {PLUGIN_ID, PLUGIN_NAME} from './snooze-constants';
import SnoozeActions from './snooze-actions';
class SnoozeStore {
constructor(pluginId = PLUGIN_ID) {
constructor(pluginId = PLUGIN_ID, pluginName = PLUGIN_NAME) {
this.pluginId = pluginId
this.pluginName = pluginName
this.snoozeCategoriesPromise = getSnoozeCategoriesByAccount()
}
activate() {
this.unsubscribe = SnoozeActions.snoozeThreads.listen(this.onSnoozeThreads)
}
onSnoozeThreads = (threads, snoozeDate, label) => {
recordSnoozeEvent(threads, snoozeDate, label) {
try {
const min = Math.round(((new Date(snoozeDate)).valueOf() - Date.now()) / 1000 / 60);
Actions.recordUserEvent("Snooze Threads", {
@ -25,23 +33,51 @@ class SnoozeStore {
} catch (e) {
// Do nothing
}
}
groupUpdatedThreads = (threads, snoozeCategoriesByAccount) => {
const getSnoozeCategory = (accId)=> snoozeCategoriesByAccount[accId]
const {getInboxCategory} = CategoryStore
const threadsByAccountId = {}
threads.forEach((thread)=> {
const accId = thread.accountId
if (!threadsByAccountId[accId]) {
threadsByAccountId[accId] = {
threads: [thread],
snoozeCategoryId: getSnoozeCategory(accId).serverId,
returnCategoryId: getInboxCategory(accId).serverId,
}
} else {
threadsByAccountId[accId].threads.push(thread);
}
});
return Promise.resolve(threadsByAccountId);
};
onSnoozeThreads = (threads, snoozeDate, label) => {
this.recordSnoozeEvent(threads, label)
const accounts = AccountStore.accountsForItems(threads)
const promises = accounts.map((acc)=> {
return NylasAPI.authPlugin(this.pluginId, PLUGIN_NAME, acc)
return NylasAPI.authPlugin(this.pluginId, this.pluginName, acc)
})
Promise.all(promises)
return Promise.all(promises)
.then(()=> {
return moveThreadsToSnooze(threads, snoozeDate)
return moveThreadsToSnooze(threads, this.snoozeCategoriesPromise, snoozeDate)
})
.then((updatedThreads)=> {
return this.snoozeCategoriesPromise
.then(snoozeCategories => this.groupUpdatedThreads(updatedThreads, snoozeCategories))
})
.then((updatedThreadsByAccountId)=> {
_.each(updatedThreadsByAccountId, (update)=> {
const {updatedThreads, snoozeCategoryId, returnCategoryId} = update;
Actions.setMetadata(updatedThreads, this.pluginId, {snoozeDate, snoozeCategoryId, returnCategoryId})
const {snoozeCategoryId, returnCategoryId} = update;
Actions.setMetadata(update.threads, this.pluginId, {snoozeDate, snoozeCategoryId, returnCategoryId})
})
})
.catch((error)=> {
moveThreadsFromSnooze(threads)
moveThreadsFromSnooze(threads, this.snoozeCategoriesPromise)
Actions.closePopover();
NylasEnv.reportError(error);
NylasEnv.showErrorDialog(`Sorry, we were unable to save your snooze settings. ${error.message}`);

View file

@ -15,139 +15,126 @@ import {
} from 'nylas-exports';
import {SNOOZE_CATEGORY_NAME, DATE_FORMAT_SHORT} from './snooze-constants'
export function snoozeMessage(snoozeDate) {
let message = 'Snoozed'
if (snoozeDate) {
let dateFormat = DATE_FORMAT_SHORT
const date = moment(snoozeDate)
const now = moment()
const hourDifference = moment.duration(date.diff(now)).asHours()
if (hourDifference < 24) {
dateFormat = dateFormat.replace('MMM D, ', '');
}
if (date.minutes() === 0) {
dateFormat = dateFormat.replace(':mm', '');
}
const SnoozeUtils = {
message += ` until ${DateUtils.format(date, dateFormat)}`;
}
return message;
}
snoozedUntilMessage(snoozeDate, now = moment()) {
let message = 'Snoozed'
if (snoozeDate) {
let dateFormat = DATE_FORMAT_SHORT
const date = moment(snoozeDate)
const hourDifference = moment.duration(date.diff(now)).asHours()
export function createSnoozeCategory(accountId, name = SNOOZE_CATEGORY_NAME) {
const category = new Category({
displayName: name,
accountId: accountId,
})
const task = new SyncbackCategoryTask({category})
Actions.queueTask(task)
return TaskQueueStatusStore.waitForPerformRemote(task).then(()=>{
return DatabaseStore.findBy(Category, {clientId: category.clientId})
.then((updatedCat)=> {
if (updatedCat && updatedCat.isSavedRemotely()) {
return Promise.resolve(updatedCat)
if (hourDifference < 24) {
dateFormat = dateFormat.replace('MMM D, ', '');
}
return Promise.reject(new Error('Could not create Snooze category'))
if (date.minutes() === 0) {
dateFormat = dateFormat.replace(':mm', '');
}
message += ` until ${DateUtils.format(date, dateFormat)}`;
}
return message;
},
createSnoozeCategory(accountId, name = SNOOZE_CATEGORY_NAME) {
const category = new Category({
displayName: name,
accountId: accountId,
})
})
}
const task = new SyncbackCategoryTask({category})
export function whenCategoriesReady() {
const categoriesReady = ()=> CategoryStore.categories().length > 0
if (!categoriesReady()) {
return new Promise((resolve)=> {
const unsubscribe = CategoryStore.listen(()=> {
if (categoriesReady()) {
unsubscribe()
resolve()
Actions.queueTask(task)
return TaskQueueStatusStore.waitForPerformRemote(task).then(()=>{
return DatabaseStore.findBy(Category, {clientId: category.clientId})
.then((updatedCat)=> {
if (updatedCat && updatedCat.isSavedRemotely()) {
return Promise.resolve(updatedCat)
}
return Promise.reject(new Error('Could not create Snooze category'))
})
})
}
return Promise.resolve()
}
},
export function getSnoozeCategory(accountId, categoryName = SNOOZE_CATEGORY_NAME) {
return whenCategoriesReady()
.then(()=> {
const allCategories = CategoryStore.categories(accountId)
const category = _.findWhere(allCategories, {displayName: categoryName})
if (category) {
return Promise.resolve(category);
whenCategoriesReady() {
const categoriesReady = ()=> CategoryStore.categories().length > 0
if (!categoriesReady()) {
return new Promise((resolve)=> {
const unsubscribe = CategoryStore.listen(()=> {
if (categoriesReady()) {
unsubscribe()
resolve()
}
})
})
}
return createSnoozeCategory(accountId, categoryName)
})
}
return Promise.resolve()
},
export function getSnoozeCategoriesByAccount(accounts = AccountStore.accounts()) {
const categoriesByAccountId = {}
accounts.forEach(({id})=> {
if (categoriesByAccountId[id] != null) return;
categoriesByAccountId[id] = getSnoozeCategory(id)
})
return Promise.props(categoriesByAccountId)
}
export function groupProcessedThreadsByAccountId(categoriesByAccountId, threads) {
return DatabaseStore.modelify(Thread, _.pluck(threads, 'clientId')).then((updatedThreads)=> {
const threadsByAccountId = {}
updatedThreads.forEach((thread)=> {
const accId = thread.accountId
if (!threadsByAccountId[accId]) {
threadsByAccountId[accId] = {
updatedThreads: [thread],
snoozeCategoryId: categoriesByAccountId[accId].serverId,
returnCategoryId: CategoryStore.getInboxCategory(accId).serverId,
}
} else {
threadsByAccountId[accId].updatedThreads.push(thread);
getSnoozeCategory(accountId, categoryName = SNOOZE_CATEGORY_NAME) {
return SnoozeUtils.whenCategoriesReady()
.then(()=> {
const allCategories = CategoryStore.categories(accountId)
const category = _.findWhere(allCategories, {displayName: categoryName})
if (category) {
return Promise.resolve(category);
}
});
return Promise.resolve(threadsByAccountId);
})
}
export function moveThreads(threads, categoriesByAccountId, {snooze, description} = {}) {
const inbox = CategoryStore.getInboxCategory
const snoozeCat = (accId)=> categoriesByAccountId[accId]
const tasks = TaskFactory.tasksForApplyingCategories({
threads,
categoriesToRemove: snooze ? inbox : snoozeCat,
categoryToAdd: snooze ? snoozeCat : inbox,
taskDescription: description,
})
Actions.queueTasks(tasks)
const promises = tasks.map(task => TaskQueueStatusStore.waitForPerformRemote(task))
// Resolve with the updated threads
return (
Promise.all(promises).then(()=> {
return groupProcessedThreadsByAccountId(categoriesByAccountId, threads)
return SnoozeUtils.createSnoozeCategory(accountId, categoryName)
})
)
},
getSnoozeCategoriesByAccount(accounts = AccountStore.accounts()) {
const snoozeCategoriesByAccountId = {}
accounts.forEach(({id})=> {
if (snoozeCategoriesByAccountId[id] != null) return;
snoozeCategoriesByAccountId[id] = SnoozeUtils.getSnoozeCategory(id)
})
return Promise.props(snoozeCategoriesByAccountId)
},
moveThreads(threads, {snooze, getSnoozeCategory, getInboxCategory, description} = {}) {
const tasks = TaskFactory.tasksForApplyingCategories({
threads,
categoriesToRemove: snooze ? getInboxCategory : getSnoozeCategory,
categoryToAdd: snooze ? getSnoozeCategory : getInboxCategory,
taskDescription: description,
})
Actions.queueTasks(tasks)
const promises = tasks.map(task => TaskQueueStatusStore.waitForPerformRemote(task))
// Resolve with the updated threads
return (
Promise.all(promises).then(()=> {
return DatabaseStore.modelify(Thread, _.pluck(threads, 'clientId'))
})
)
},
moveThreadsToSnooze(threads, snoozeCategoriesByAccountPromise, snoozeDate) {
return snoozeCategoriesByAccountPromise
.then((snoozeCategoriesByAccountId)=> {
const getSnoozeCategory = (accId)=> snoozeCategoriesByAccountId[accId]
const {getInboxCategory} = CategoryStore
const description = SnoozeUtils.snoozedUntilMessage(snoozeDate)
return SnoozeUtils.moveThreads(
threads,
{snooze: true, getSnoozeCategory, getInboxCategory, description}
)
})
},
moveThreadsFromSnooze(threads, snoozeCategoriesByAccountPromise) {
return snoozeCategoriesByAccountPromise
.then((snoozeCategoriesByAccountId)=> {
const getSnoozeCategory = (accId)=> snoozeCategoriesByAccountId[accId]
const {getInboxCategory} = CategoryStore
const description = 'Unsnoozed';
return SnoozeUtils.moveThreads(
threads,
{snooze: false, getSnoozeCategory, getInboxCategory, description}
)
})
},
}
export function moveThreadsToSnooze(threads, snoozeDate) {
return getSnoozeCategoriesByAccount()
.then((categoriesByAccountId)=> {
const description = snoozeMessage(snoozeDate)
return moveThreads(threads, categoriesByAccountId, {snooze: true, description})
})
}
export function moveThreadsFromSnooze(threads) {
return getSnoozeCategoriesByAccount()
.then((categoriesByAccountId)=> {
const description = 'Unsnoozed';
return moveThreads(threads, categoriesByAccountId, {snooze: false, description})
})
}
export default SnoozeUtils

View file

@ -0,0 +1,133 @@
import {
AccountStore,
CategoryStore,
NylasAPI,
Thread,
Actions,
Category,
} from 'nylas-exports'
import SnoozeUtils from '../lib/snooze-utils'
import SnoozeStore from '../lib/snooze-store'
describe('SnoozeStore', ()=> {
beforeEach(()=> {
this.store = new SnoozeStore('plug-id', 'plug-name')
this.name = 'Snooze folder'
this.accounts = [{id: 123}, {id: 321}]
this.snoozeCatsByAccount = {
'123': new Category({accountId: 123, displayName: this.name, serverId: 'sn-1'}),
'321': new Category({accountId: 321, displayName: this.name, serverId: 'sn-2'}),
}
this.inboxCatsByAccount = {
'123': new Category({accountId: 123, name: 'inbox', serverId: 'in-1'}),
'321': new Category({accountId: 321, name: 'inbox', serverId: 'in-2'}),
}
this.threads = [
new Thread({accountId: 123, serverId: 's-1'}),
new Thread({accountId: 123, serverId: 's-2'}),
new Thread({accountId: 321, serverId: 's-3'}),
]
this.updatedThreadsByAccountId = {
'123': {
threads: [this.threads[0], this.threads[1]],
snoozeCategoryId: 'sn-1',
returnCategoryId: 'in-1',
},
'321': {
threads: [this.threads[2]],
snoozeCategoryId: 'sn-2',
returnCategoryId: 'in-2',
},
}
this.store.snoozeCategoriesPromise = Promise.resolve()
spyOn(this.store, 'recordSnoozeEvent')
spyOn(this.store, 'groupUpdatedThreads').andReturn(Promise.resolve(this.updatedThreadsByAccountId))
spyOn(AccountStore, 'accountsForItems').andReturn(this.accounts)
spyOn(NylasAPI, 'authPlugin').andReturn(Promise.resolve())
spyOn(SnoozeUtils, 'moveThreadsToSnooze').andReturn(Promise.resolve(this.threads))
spyOn(SnoozeUtils, 'moveThreadsFromSnooze')
spyOn(Actions, 'setMetadata')
spyOn(Actions, 'closePopover')
spyOn(NylasEnv, 'reportError')
spyOn(NylasEnv, 'showErrorDialog')
})
describe('groupUpdatedThreads', ()=> {
it('groups the threads correctly by account id, with their snooze and inbox categories', ()=> {
spyOn(CategoryStore, 'getInboxCategory').andCallFake(accId => this.inboxCatsByAccount[accId])
waitsForPromise(()=> {
return this.store.groupUpdatedThreads(this.threads, this.snoozeCatsByAccount)
.then((result)=> {
expect(result['123']).toEqual({
threads: [this.threads[0], this.threads[1]],
snoozeCategoryId: 'sn-1',
returnCategoryId: 'in-1',
})
expect(result['321']).toEqual({
threads: [this.threads[2]],
snoozeCategoryId: 'sn-2',
returnCategoryId: 'in-2',
})
})
})
});
});
describe('onSnoozeThreads', ()=> {
it('auths plugin against all present accounts', ()=> {
waitsForPromise(()=> {
return this.store.onSnoozeThreads(this.threads, 'date', 'label')
.then(()=> {
expect(NylasAPI.authPlugin).toHaveBeenCalled()
expect(NylasAPI.authPlugin.calls[0].args[2]).toEqual(this.accounts[0])
expect(NylasAPI.authPlugin.calls[1].args[2]).toEqual(this.accounts[1])
})
})
});
it('calls Actions.setMetadata with the correct metadata', ()=> {
waitsForPromise(()=> {
return this.store.onSnoozeThreads(this.threads, 'date', 'label')
.then(()=> {
expect(Actions.setMetadata).toHaveBeenCalled()
expect(Actions.setMetadata.calls[0].args).toEqual([
this.updatedThreadsByAccountId['123'].threads,
'plug-id',
{
snoozeDate: 'date',
snoozeCategoryId: 'sn-1',
returnCategoryId: 'in-1',
},
])
expect(Actions.setMetadata.calls[1].args).toEqual([
this.updatedThreadsByAccountId['321'].threads,
'plug-id',
{
snoozeDate: 'date',
snoozeCategoryId: 'sn-2',
returnCategoryId: 'in-2',
},
])
})
})
});
it('displays dialog on error', ()=> {
jasmine.unspy(SnoozeUtils, 'moveThreadsToSnooze')
spyOn(SnoozeUtils, 'moveThreadsToSnooze').andReturn(Promise.reject(new Error('Oh no!')))
waitsForPromise(()=> {
return this.store.onSnoozeThreads(this.threads, 'date', 'label')
.finally(()=> {
expect(SnoozeUtils.moveThreadsFromSnooze).toHaveBeenCalled()
expect(NylasEnv.reportError).toHaveBeenCalled()
expect(NylasEnv.showErrorDialog).toHaveBeenCalled()
})
})
});
});
})

View file

@ -0,0 +1,232 @@
import moment from 'moment'
import {
Actions,
TaskQueueStatusStore,
TaskFactory,
DatabaseStore,
Category,
Thread,
CategoryStore,
} from 'nylas-exports'
import SnoozeUtils from '../lib/snooze-utils'
const {
snoozedUntilMessage,
createSnoozeCategory,
getSnoozeCategory,
groupProcessedThreadsByAccountId,
moveThreads,
} = SnoozeUtils
describe('Snooze Utils', ()=> {
beforeEach(()=> {
this.name = 'Snoozed Folder'
this.accId = 123
spyOn(SnoozeUtils, 'whenCategoriesReady').andReturn(Promise.resolve())
})
describe('snoozedUntilMessage', ()=> {
it('returns correct message if no snooze date provided', ()=> {
expect(snoozedUntilMessage()).toEqual('Snoozed')
});
describe('when less than 24 hours from now', ()=> {
it('returns correct message if snoozeDate is on the hour of the clock', ()=> {
const now9AM = moment().hour(9).minute(0)
const tomorrowAt8 = moment(now9AM).add(1, 'day').hour(8)
const result = snoozedUntilMessage(tomorrowAt8, now9AM)
expect(result).toEqual('Snoozed until 8AM')
});
it('returns correct message if snoozeDate otherwise', ()=> {
const now9AM = moment().hour(9).minute(0)
const snooze10AM = moment(now9AM).hour(10).minute(5)
const result = snoozedUntilMessage(snooze10AM, now9AM)
expect(result).toEqual('Snoozed until 10:05AM')
});
});
describe('when more than 24 hourse from now', ()=> {
it('returns correct message if snoozeDate is on the hour of the clock', ()=> {
// Jan 1
const now9AM = moment().month(0).date(1).hour(9).minute(0)
const tomorrowAt10 = moment(now9AM).add(1, 'day').hour(10)
const result = snoozedUntilMessage(tomorrowAt10, now9AM)
expect(result).toEqual('Snoozed until Jan 2, 10AM')
});
it('returns correct message if snoozeDate otherwise', ()=> {
// Jan 1
const now9AM = moment().month(0).date(1).hour(9).minute(0)
const tomorrowAt930 = moment(now9AM).add(1, 'day').minute(30)
const result = snoozedUntilMessage(tomorrowAt930, now9AM)
expect(result).toEqual('Snoozed until Jan 2, 9:30AM')
});
});
});
describe('createSnoozeCategory', ()=> {
beforeEach(()=> {
this.category = new Category({
displayName: this.name,
accountId: this.accId,
clientId: 321,
serverId: 321,
})
spyOn(Actions, 'queueTask')
spyOn(TaskQueueStatusStore, 'waitForPerformRemote').andReturn(Promise.resolve())
spyOn(DatabaseStore, 'findBy').andReturn(Promise.resolve(this.category))
})
it('creates category with correct snooze name', ()=> {
createSnoozeCategory(this.accId, this.name)
expect(Actions.queueTask).toHaveBeenCalled()
const task = Actions.queueTask.calls[0].args[0]
expect(task.category.displayName).toEqual(this.name)
expect(task.category.accountId).toEqual(this.accId)
});
it('resolves with the updated category that has been saved to the server', ()=> {
waitsForPromise(()=> {
return createSnoozeCategory(this.accId, this.name).then((result)=> {
expect(DatabaseStore.findBy).toHaveBeenCalled()
expect(result).toBe(this.category)
})
})
});
it('rejects if the category could not be found in the database', ()=> {
this.category.serverId = null
jasmine.unspy(DatabaseStore, 'findBy')
spyOn(DatabaseStore, 'findBy').andReturn(Promise.resolve(this.category))
waitsForPromise(()=> {
return createSnoozeCategory(this.accId, this.name)
.then(()=> {
throw new Error('createSnoozeCategory should not resolve in this case!')
})
.catch((error)=> {
expect(DatabaseStore.findBy).toHaveBeenCalled()
expect(error.message).toEqual('Could not create Snooze category')
})
})
});
it('rejects if the category could not be saved to the server', ()=> {
jasmine.unspy(DatabaseStore, 'findBy')
spyOn(DatabaseStore, 'findBy').andReturn(Promise.resolve(undefined))
waitsForPromise(()=> {
return createSnoozeCategory(this.accId, this.name)
.then(()=> {
throw new Error('createSnoozeCategory should not resolve in this case!')
})
.catch((error)=> {
expect(DatabaseStore.findBy).toHaveBeenCalled()
expect(error.message).toEqual('Could not create Snooze category')
})
})
});
});
describe('getSnoozeCategory', ()=> {
it('resolves category if it exists in the category store', ()=> {
const categories = [
new Category({accountId: this.accId, name: 'inbox'}),
new Category({accountId: this.accId, displayName: this.name}),
]
spyOn(CategoryStore, 'categories').andReturn(categories)
spyOn(SnoozeUtils, 'createSnoozeCategory')
waitsForPromise(()=> {
return getSnoozeCategory(this.accountId, this.name)
.then((result)=> {
expect(SnoozeUtils.createSnoozeCategory).not.toHaveBeenCalled()
expect(result).toBe(categories[1])
})
})
});
it('creates category if it does not exist', ()=> {
const categories = [
new Category({accountId: this.accId, name: 'inbox'}),
]
const snoozeCat = new Category({accountId: this.accId, displayName: this.name})
spyOn(CategoryStore, 'categories').andReturn(categories)
spyOn(SnoozeUtils, 'createSnoozeCategory').andReturn(Promise.resolve(snoozeCat))
waitsForPromise(()=> {
return getSnoozeCategory(this.accId, this.name)
.then((result)=> {
expect(SnoozeUtils.createSnoozeCategory).toHaveBeenCalledWith(this.accId, this.name)
expect(result).toBe(snoozeCat)
})
})
});
});
describe('moveThreads', ()=> {
beforeEach(()=> {
this.description = 'Snoozin';
this.snoozeCatsByAccount = {
'123': new Category({accountId: 123, displayName: this.name, serverId: 'sr-1'}),
'321': new Category({accountId: 321, displayName: this.name, serverId: 'sr-2'}),
}
this.inboxCatsByAccount = {
'123': new Category({accountId: 123, name: 'inbox', serverId: 'sr-3'}),
'321': new Category({accountId: 321, name: 'inbox', serverId: 'sr-4'}),
}
this.threads = [
new Thread({accountId: 123}),
new Thread({accountId: 123}),
new Thread({accountId: 321}),
]
this.getInboxCat = (accId) => this.inboxCatsByAccount[accId]
this.getSnoozeCat = (accId) => this.snoozeCatsByAccount[accId]
spyOn(DatabaseStore, 'modelify').andReturn(Promise.resolve(this.threads))
spyOn(TaskFactory, 'tasksForApplyingCategories').andReturn([])
spyOn(TaskQueueStatusStore, 'waitForPerformRemote').andReturn(Promise.resolve())
spyOn(Actions, 'queueTasks')
})
it('creates the tasks to move threads correctly when snoozing', ()=> {
const snooze = true
const description = this.description
waitsForPromise(()=> {
return moveThreads(this.threads, {snooze, description, getInboxCategory: this.getInboxCat, getSnoozeCategory: this.getSnoozeCat})
.then(()=> {
expect(TaskFactory.tasksForApplyingCategories).toHaveBeenCalled()
expect(Actions.queueTasks).toHaveBeenCalled()
const taskArgs = TaskFactory.tasksForApplyingCategories.calls[0].args[0]
expect(taskArgs.threads).toBe(this.threads)
expect(taskArgs.categoriesToRemove('123')).toBe(this.inboxCatsByAccount['123'])
expect(taskArgs.categoriesToRemove('321')).toBe(this.inboxCatsByAccount['321'])
expect(taskArgs.categoryToAdd('123')).toBe(this.snoozeCatsByAccount['123'])
expect(taskArgs.categoryToAdd('321')).toBe(this.snoozeCatsByAccount['321'])
expect(taskArgs.taskDescription).toEqual(description)
})
})
});
it('creates the tasks to move threads correctly when unsnoozing', ()=> {
const snooze = false
const description = this.description
waitsForPromise(()=> {
return moveThreads(this.threads, {snooze, description, getInboxCategory: this.getInboxCat, getSnoozeCategory: this.getSnoozeCat})
.then(()=> {
expect(TaskFactory.tasksForApplyingCategories).toHaveBeenCalled()
expect(Actions.queueTasks).toHaveBeenCalled()
const taskArgs = TaskFactory.tasksForApplyingCategories.calls[0].args[0]
expect(taskArgs.threads).toBe(this.threads)
expect(taskArgs.categoryToAdd('123')).toBe(this.inboxCatsByAccount['123'])
expect(taskArgs.categoryToAdd('321')).toBe(this.inboxCatsByAccount['321'])
expect(taskArgs.categoriesToRemove('123')).toBe(this.snoozeCatsByAccount['123'])
expect(taskArgs.categoriesToRemove('321')).toBe(this.snoozeCatsByAccount['321'])
expect(taskArgs.taskDescription).toEqual(description)
})
})
});
});
});

View file

@ -63,11 +63,5 @@
input {
margin-bottom: 3px;
}
em {
font-size: 0.9em;
opacity: 0.62;
}
}
}

View file

@ -0,0 +1,71 @@
import React, {addons} from 'react/addons';
import {DateUtils} from 'nylas-exports'
import DateInput from '../../src/components/date-input';
import {renderIntoDocument} from '../nylas-test-utils'
const {findDOMNode} = React;
const {TestUtils: {
findRenderedDOMComponentWithTag,
findRenderedDOMComponentWithClass,
Simulate,
}} = addons;
const makeInput = (props = {})=> {
const input = renderIntoDocument(<DateInput {...props} dateFormat="blah" />);
if (props.initialState) {
input.setState(props.initialState)
}
return input
};
const getInputNode = (reactElement)=> {
return findDOMNode(findRenderedDOMComponentWithTag(reactElement, 'input'))
};
describe('DateInput', ()=> {
describe('onInputKeyDown', ()=> {
it('should submit the input if Enter or Escape pressed', ()=> {
const onSubmitDate = jasmine.createSpy('onSubmitDate')
const dateInput = makeInput({onSubmitDate: onSubmitDate})
const inputNode = getInputNode(dateInput)
const stopPropagation = jasmine.createSpy('stopPropagation')
const keys = ['Enter', 'Return']
inputNode.value = 'tomorrow'
spyOn(DateUtils, 'futureDateFromString').andReturn('someday')
spyOn(dateInput, 'setState')
keys.forEach((key)=> {
Simulate.keyDown(inputNode, {key, stopPropagation})
expect(stopPropagation).toHaveBeenCalled()
expect(onSubmitDate).toHaveBeenCalledWith('someday', 'tomorrow')
expect(dateInput.setState).toHaveBeenCalledWith({inputDate: null})
stopPropagation.reset()
onSubmitDate.reset()
dateInput.setState.reset()
})
});
});
describe('render', ()=> {
beforeEach(()=> {
spyOn(DateUtils, 'format').andReturn('formatted')
});
it('should render a date interpretation if a date has been inputted', ()=> {
const dateInput = makeInput({initialState: {inputDate: 'something!'}})
spyOn(dateInput, 'setState')
const dateInterpretation = findDOMNode(findRenderedDOMComponentWithClass(dateInput, 'date-interpretation'))
expect(dateInterpretation.textContent).toEqual('formatted')
});
it('should not render a date interpretation if no input date available', ()=> {
const dateInput = makeInput({initialState: {inputDate: null}})
spyOn(dateInput, 'setState')
expect(()=> {
findRenderedDOMComponentWithClass(dateInput, 'date-interpretation')
}).toThrow()
});
});
});

View file

@ -12,7 +12,11 @@ const {TestUtils: {
const makeList = (items = [], props = {})=> {
return renderIntoDocument(<EditableList {...props} items={items}></EditableList>);
const list = renderIntoDocument(<EditableList {...props} items={items} />);
if (props.initialState) {
list.setState(props.initialState)
}
return list
};
describe('EditableList', ()=> {

View file

@ -0,0 +1,70 @@
import classnames from 'classnames';
import React, {Component, PropTypes} from 'react';
import {DateUtils} from 'nylas-exports';
class DateInput extends Component {
static displayName = 'DateInput';
static propTypes = {
className: PropTypes.string,
dateFormat: PropTypes.string.isRequired,
onSubmitDate: PropTypes.func,
};
static defaultProps = {
onSubmitDate: ()=> {},
};
constructor(props) {
super(props)
this.state = {
inputDate: null,
}
}
onInputKeyDown = (event)=> {
const {key, target: {value}} = event;
if (value.length > 0 && ["Enter", "Return"].includes(key)) {
// This prevents onInputChange from being fired
event.stopPropagation();
const date = DateUtils.futureDateFromString(value);
this.props.onSubmitDate(date, value);
this.setState({inputDate: null})
}
};
onInputChange = (event)=> {
this.setState({inputDate: DateUtils.futureDateFromString(event.target.value)});
};
render() {
let dateInterpretation;
if (this.state.inputDate) {
dateInterpretation = (
<span className="date-interpretation">
{DateUtils.format(this.state.inputDate, this.props.dateFormat)}
</span>
);
}
const {className} = this.props
const classes = classnames({
"nylas-date-input": true,
[className]: className != null,
})
return (
<div className={classes}>
<input
tabIndex="1"
type="text"
placeholder="Or, 'next Monday at 2PM'"
onKeyDown={this.onInputKeyDown}
onChange={this.onInputChange}/>
{dateInterpretation}
</div>
)
}
}
export default DateInput

View file

@ -29,8 +29,6 @@ import React, {Component, PropTypes} from 'react';
* @param {object} props.createInputProps - Props object to be passed on to
* the create input element. However, keep in mind that these props can not
* override the default props that EditableList will pass to the input.
* @param {object} props.initialState - Used for testing purposes to initialize
* the component with a given state.
* @param {props.onCreateItem} props.onCreateItem
* @param {props.onDeleteItem} props.onDeleteItem
* @param {props.onSelectItem} props.onSelectItem
@ -97,7 +95,6 @@ class EditableList extends Component {
onReorderItem: PropTypes.func,
onItemEdited: PropTypes.func,
onItemCreated: PropTypes.func,
initialState: PropTypes.object,
/* Optional, if you choose to control selection externally */
selected: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
@ -117,7 +114,7 @@ class EditableList extends Component {
constructor(props) {
super(props);
this.state = props.initialState || {
this.state = {
dropInsertionIndex: -1,
editingIndex: -1,
creatingItem: false,

View file

@ -3,6 +3,37 @@ import moment from 'moment'
import chrono from 'chrono-node'
import _ from 'underscore'
// Init locale for moment
moment.locale(navigator.language)
const Hours = {
Morning: 9,
Evening: 20,
Midnight: 24,
}
const Days = {
NextMonday: 8,
ThisWeekend: 6,
}
function oclock(momentDate) {
return momentDate.minute(0).second(0)
}
function morning(momentDate, morningHour = Hours.Morning) {
return oclock(momentDate.hour(morningHour))
}
function evening(momentDate, eveningHour = Hours.Evening) {
return oclock(momentDate.hour(eveningHour))
}
function midnight(momentDate, midnightHour = Hours.Midnight) {
return oclock(momentDate.hour(midnightHour))
}
function isPastDate({year, month, day}, ref) {
const refDay = ref.getDate();
const refMonth = ref.getMonth() + 1;
@ -47,33 +78,6 @@ EnforceFutureDate.refine = (text, results)=> {
const chronoFuture = new chrono.Chrono(chrono.options.casualOption());
chronoFuture.refiners.push(EnforceFutureDate);
const Hours = {
Morning: 9,
Evening: 20,
Midnight: 24,
}
const Days = {
NextMonday: 8,
ThisWeekend: 6,
}
function oclock(momentDate) {
return momentDate.minute(0).second(0)
}
function morning(momentDate, morningHour = Hours.Morning) {
return oclock(momentDate.hour(morningHour))
}
function evening(momentDate, eveningHour = Hours.Evening) {
return oclock(momentDate.hour(eveningHour))
}
function midnight(momentDate, midnightHour = Hours.Midnight) {
return oclock(momentDate.hour(midnightHour))
}
const DateUtils = {

View file

@ -38,6 +38,7 @@ class NylasComponentKit
@load "EditableList", "editable-list"
@load "OutlineViewItem", "outline-view-item"
@load "OutlineView", "outline-view"
@load "DateInput", "date-input"
@load "ScrollRegion", 'scroll-region'
@load "ResizableRegion", 'resizable-region'

View file

@ -0,0 +1,9 @@
@import "ui-variables";
.nylas-date-input {
.date-interpretation {
color: @text-color-subtle;
font-size: @font-size-small;
opacity: 0.6;
}
}

View file

@ -31,3 +31,4 @@
@import "components/editable-list";
@import "components/outline-view";
@import "components/fixed-popover";
@import "components/date-input";