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", "extends": "airbnb",
"globals": { "globals": {
"NylasEnv": false, "NylasEnv": false,
"$n": false "$n": false,
"waitsForPromise": false
}, },
"env": { "env": {
"browser": true, "browser": true,

View file

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

View file

@ -2,10 +2,9 @@
import Rx from 'rx-lite' import Rx from 'rx-lite'
import React, {Component, PropTypes} from 'react' import React, {Component, PropTypes} from 'react'
import {DateUtils, Message, DatabaseStore} from 'nylas-exports' 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 SendLaterActions from './send-later-actions'
import SendLaterStore from './send-later-store' import {DATE_FORMAT_SHORT, DATE_FORMAT_LONG, PLUGIN_ID} from './send-later-constants'
import {DATE_FORMAT_SHORT, DATE_FORMAT_LONG} from './send-later-constants'
const SendLaterOptions = { const SendLaterOptions = {
@ -28,7 +27,6 @@ class SendLaterPopover extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = { this.state = {
inputDate: null,
scheduledDate: null, scheduledDate: null,
} }
} }
@ -36,85 +34,52 @@ class SendLaterPopover extends Component {
componentDidMount() { componentDidMount() {
this._subscription = Rx.Observable.fromQuery( this._subscription = Rx.Observable.fromQuery(
DatabaseStore.findBy(Message, {clientId: this.props.draftClientId}) DatabaseStore.findBy(Message, {clientId: this.props.draftClientId})
).subscribe((draft)=> { ).subscribe(this.onMessageChanged);
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});
}
});
} }
componentWillUnmount() { componentWillUnmount() {
this._subscription.dispose(); this._subscription.dispose();
} }
onSelectMenuOption = (optionKey)=> { onMessageChanged = (message)=> {
const date = SendLaterOptions[optionKey](); if (!message) return;
this.onSelectDate(date, optionKey); const messageMetadata = message.metadataForPluginId(PLUGIN_ID) || {}
}; const nextScheduledDate = messageMetadata.sendLaterDate
onSelectCustomOption = (value)=> { if (nextScheduledDate !== this.state.scheduledDate) {
const date = DateUtils.futureDateFromString(value); const isComposer = NylasEnv.isComposerWindow()
if (date) { const isFinishedSelecting = ((this.state.scheduledDate === 'saving') && (nextScheduledDate !== null));
this.onSelectDate(date, "Custom"); if (isComposer && isFinishedSelecting) {
} else { NylasEnv.close();
NylasEnv.showErrorDialog(`Sorry, we can't parse ${value} as a valid date.`); }
this.setState({scheduledDate: nextScheduledDate});
} }
}; };
onSelectDate = (date, label)=> { onSelectMenuOption = (optionKey)=> {
const formatted = DateUtils.format(date.utc()); const date = SendLaterOptions[optionKey]();
SendLaterActions.sendLater(this.props.draftClientId, formatted, label); this.selectDate(date, optionKey);
this.setState({scheduledDate: 'saving', inputDate: null}); };
this.refs.popover.close();
onSelectCustomOption = (date, inputValue)=> {
if (date) {
this.selectDate(date, "Custom");
} else {
NylasEnv.showErrorDialog(`Sorry, we can't parse ${inputValue} as a valid date.`);
}
}; };
onCancelSendLater = ()=> { onCancelSendLater = ()=> {
SendLaterActions.cancelSendLater(this.props.draftClientId); SendLaterActions.cancelSendLater(this.props.draftClientId);
this.setState({inputDate: null});
this.refs.popover.close(); this.refs.popover.close();
}; };
renderCustomTimeSection() { selectDate = (date, dateLabel)=> {
const onChange = (event)=> { const formatted = DateUtils.format(date.utc());
this.setState({inputDate: DateUtils.futureDateFromString(event.target.value)}); SendLaterActions.sendLater(this.props.draftClientId, formatted, dateLabel);
} this.setState({scheduledDate: 'saving'});
this.refs.popover.close();
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>
)
}
renderMenuOption(optionKey) { renderMenuOption(optionKey) {
const date = SendLaterOptions[optionKey](); const date = SendLaterOptions[optionKey]();
@ -139,7 +104,7 @@ class SendLaterPopover extends Component {
); );
} }
let dateInterpretation = false; let dateInterpretation;
if (scheduledDate) { if (scheduledDate) {
className += ' btn-enabled'; className += ' btn-enabled';
const momentDate = DateUtils.futureDateFromString(scheduledDate); const momentDate = DateUtils.futureDateFromString(scheduledDate);
@ -163,7 +128,11 @@ class SendLaterPopover extends Component {
] ]
const footerComponents = [ const footerComponents = [
<div key="divider" className="divider" />, <div key="divider" className="divider" />,
this.renderCustomTimeSection(), <DateInput
key="custom"
className="custom-section"
dateFormat={DATE_FORMAT_LONG}
onSubmitDate={this.onSelectCustomOption} />,
]; ];
if (this.state.scheduledDate) { if (this.state.scheduledDate) {

View file

@ -7,9 +7,10 @@ import {PLUGIN_ID, PLUGIN_NAME} from './send-later-constants'
class SendLaterStore extends NylasStore { class SendLaterStore extends NylasStore {
constructor(pluginId = PLUGIN_ID) { constructor(pluginId = PLUGIN_ID, pluginName = PLUGIN_NAME) {
super() super()
this.pluginId = pluginId; this.pluginId = pluginId;
this.pluginName = pluginName;
} }
activate() { 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)=> { setMetadata = (draftClientId, metadata)=> {
DatabaseStore.modelify(Message, [draftClientId]).then((messages)=> { return DatabaseStore.modelify(Message, [draftClientId])
.then((messages)=> {
const {accountId} = messages[0]; const {accountId} = messages[0];
NylasAPI.authPlugin(this.pluginId, PLUGIN_NAME, accountId) return NylasAPI.authPlugin(this.pluginId, this.pluginName, accountId)
.then(()=> { .then(()=> {
Actions.setMetadata(messages, this.pluginId, metadata); Actions.setMetadata(messages, this.pluginId, metadata);
}) })
@ -42,13 +36,13 @@ class SendLaterStore extends NylasStore {
}); });
}; };
recordAction(sendLaterDate, label) { recordAction(sendLaterDate, dateLabel) {
try { try {
if (sendLaterDate) { if (sendLaterDate) {
const min = Math.round(((new Date(sendLaterDate)).valueOf() - Date.now()) / 1000 / 60); const min = Math.round(((new Date(sendLaterDate)).valueOf() - Date.now()) / 1000 / 60);
Actions.recordUserEvent("Send Later", { Actions.recordUserEvent("Send Later", {
sendLaterTime: min, sendLaterTime: min,
optionLabel: label, optionLabel: dateLabel,
}); });
} else { } else {
Actions.recordUserEvent("Send Later Cancel"); Actions.recordUserEvent("Send Later Cancel");
@ -58,8 +52,8 @@ class SendLaterStore extends NylasStore {
} }
} }
onSendLater = (draftClientId, sendLaterDate, label)=> { onSendLater = (draftClientId, sendLaterDate, dateLabel)=> {
this.recordAction(sendLaterDate, label) this.recordAction(sendLaterDate, dateLabel)
this.setMetadata(draftClientId, {sendLaterDate}); this.setMetadata(draftClientId, {sendLaterDate});
}; };
@ -73,5 +67,4 @@ class SendLaterStore extends NylasStore {
}; };
} }
export default SendLaterStore
export default new 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"; @import "ui-variables";
.send-later { .send-later {
.time {
font-size: @font-size-small;
opacity: 0.6;
}
.menu { .menu {
width: 250px; width: 250px;
.item { .item {
@ -26,11 +20,8 @@
.divider { .divider {
border-top: 1px solid @border-color-divider; border-top: 1px solid @border-color-divider;
} }
.custom-time-section { .custom-section {
padding: @padding-base-vertical * 1.5 @padding-base-horizontal; padding: @padding-base-vertical * 1.5 @padding-base-horizontal;
.time {
color: @text-color-subtle;
}
} }
.cancel-section { .cancel-section {
padding: @padding-base-vertical @padding-base-horizontal; padding: @padding-base-vertical @padding-base-horizontal;

View file

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

View file

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

View file

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

View file

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

View file

@ -15,139 +15,126 @@ import {
} from 'nylas-exports'; } from 'nylas-exports';
import {SNOOZE_CATEGORY_NAME, DATE_FORMAT_SHORT} from './snooze-constants' 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) { const SnoozeUtils = {
dateFormat = dateFormat.replace('MMM D, ', '');
}
if (date.minutes() === 0) {
dateFormat = dateFormat.replace(':mm', '');
}
message += ` until ${DateUtils.format(date, dateFormat)}`; snoozedUntilMessage(snoozeDate, now = moment()) {
} let message = 'Snoozed'
return message; 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) { if (hourDifference < 24) {
const category = new Category({ dateFormat = dateFormat.replace('MMM D, ', '');
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)
} }
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})
}
Actions.queueTask(task)
export function whenCategoriesReady() { return TaskQueueStatusStore.waitForPerformRemote(task).then(()=>{
const categoriesReady = ()=> CategoryStore.categories().length > 0 return DatabaseStore.findBy(Category, {clientId: category.clientId})
if (!categoriesReady()) { .then((updatedCat)=> {
return new Promise((resolve)=> { if (updatedCat && updatedCat.isSavedRemotely()) {
const unsubscribe = CategoryStore.listen(()=> { return Promise.resolve(updatedCat)
if (categoriesReady()) {
unsubscribe()
resolve()
} }
return Promise.reject(new Error('Could not create Snooze category'))
}) })
}) })
} },
return Promise.resolve()
}
whenCategoriesReady() {
export function getSnoozeCategory(accountId, categoryName = SNOOZE_CATEGORY_NAME) { const categoriesReady = ()=> CategoryStore.categories().length > 0
return whenCategoriesReady() if (!categoriesReady()) {
.then(()=> { return new Promise((resolve)=> {
const allCategories = CategoryStore.categories(accountId) const unsubscribe = CategoryStore.listen(()=> {
const category = _.findWhere(allCategories, {displayName: categoryName}) if (categoriesReady()) {
if (category) { unsubscribe()
return Promise.resolve(category); resolve()
}
})
})
} }
return createSnoozeCategory(accountId, categoryName) return Promise.resolve()
}) },
}
getSnoozeCategory(accountId, categoryName = SNOOZE_CATEGORY_NAME) {
export function getSnoozeCategoriesByAccount(accounts = AccountStore.accounts()) { return SnoozeUtils.whenCategoriesReady()
const categoriesByAccountId = {} .then(()=> {
accounts.forEach(({id})=> { const allCategories = CategoryStore.categories(accountId)
if (categoriesByAccountId[id] != null) return; const category = _.findWhere(allCategories, {displayName: categoryName})
categoriesByAccountId[id] = getSnoozeCategory(id) if (category) {
}) return Promise.resolve(category);
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);
} }
}); return SnoozeUtils.createSnoozeCategory(accountId, categoryName)
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)
}) })
) },
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 default SnoozeUtils
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})
})
}

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 { input {
margin-bottom: 3px; 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 = {})=> { 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', ()=> { 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 * @param {object} props.createInputProps - Props object to be passed on to
* the create input element. However, keep in mind that these props can not * the create input element. However, keep in mind that these props can not
* override the default props that EditableList will pass to the input. * 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.onCreateItem} props.onCreateItem
* @param {props.onDeleteItem} props.onDeleteItem * @param {props.onDeleteItem} props.onDeleteItem
* @param {props.onSelectItem} props.onSelectItem * @param {props.onSelectItem} props.onSelectItem
@ -97,7 +95,6 @@ class EditableList extends Component {
onReorderItem: PropTypes.func, onReorderItem: PropTypes.func,
onItemEdited: PropTypes.func, onItemEdited: PropTypes.func,
onItemCreated: PropTypes.func, onItemCreated: PropTypes.func,
initialState: PropTypes.object,
/* Optional, if you choose to control selection externally */ /* Optional, if you choose to control selection externally */
selected: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), selected: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
@ -117,7 +114,7 @@ class EditableList extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = props.initialState || { this.state = {
dropInsertionIndex: -1, dropInsertionIndex: -1,
editingIndex: -1, editingIndex: -1,
creatingItem: false, creatingItem: false,

View file

@ -3,6 +3,37 @@ import moment from 'moment'
import chrono from 'chrono-node' import chrono from 'chrono-node'
import _ from 'underscore' 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) { function isPastDate({year, month, day}, ref) {
const refDay = ref.getDate(); const refDay = ref.getDate();
const refMonth = ref.getMonth() + 1; const refMonth = ref.getMonth() + 1;
@ -47,33 +78,6 @@ EnforceFutureDate.refine = (text, results)=> {
const chronoFuture = new chrono.Chrono(chrono.options.casualOption()); const chronoFuture = new chrono.Chrono(chrono.options.casualOption());
chronoFuture.refiners.push(EnforceFutureDate); 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 = { const DateUtils = {

View file

@ -38,6 +38,7 @@ class NylasComponentKit
@load "EditableList", "editable-list" @load "EditableList", "editable-list"
@load "OutlineViewItem", "outline-view-item" @load "OutlineViewItem", "outline-view-item"
@load "OutlineView", "outline-view" @load "OutlineView", "outline-view"
@load "DateInput", "date-input"
@load "ScrollRegion", 'scroll-region' @load "ScrollRegion", 'scroll-region'
@load "ResizableRegion", 'resizable-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/editable-list";
@import "components/outline-view"; @import "components/outline-view";
@import "components/fixed-popover"; @import "components/fixed-popover";
@import "components/date-input";