mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-18 22:18:01 +08:00
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:
parent
a8a0154c44
commit
0fd2d107b2
23 changed files with 959 additions and 317 deletions
|
@ -2,7 +2,8 @@
|
|||
"extends": "airbnb",
|
||||
"globals": {
|
||||
"NylasEnv": false,
|
||||
"$n": false
|
||||
"$n": false,
|
||||
"waitsForPromise": false
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
106
internal_packages/send-later/spec/send-later-popover-spec.jsx
Normal file
106
internal_packages/send-later/spec/send-later-popover-spec.jsx
Normal 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()
|
||||
});
|
||||
});
|
||||
});
|
68
internal_packages/send-later/spec/send-later-store-spec.es6
Normal file
68
internal_packages/send-later/spec/send-later-store-spec.es6
Normal 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()
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
|
|
|
@ -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'});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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
|
||||
|
|
133
internal_packages/thread-snooze/spec/snooze-store-spec.es6
Normal file
133
internal_packages/thread-snooze/spec/snooze-store-spec.es6
Normal 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()
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
})
|
232
internal_packages/thread-snooze/spec/snooze-utils-spec.es6
Normal file
232
internal_packages/thread-snooze/spec/snooze-utils-spec.es6
Normal 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)
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
|
@ -63,11 +63,5 @@
|
|||
input {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
em {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
71
spec/components/date-input-spec.jsx
Normal file
71
spec/components/date-input-spec.jsx
Normal 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()
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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', ()=> {
|
||||
|
|
70
src/components/date-input.jsx
Normal file
70
src/components/date-input.jsx
Normal 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
|
|
@ -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,
|
||||
|
|
|
@ -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 = {
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
9
static/components/date-input.less
Normal file
9
static/components/date-input.less
Normal 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;
|
||||
}
|
||||
}
|
|
@ -31,3 +31,4 @@
|
|||
@import "components/editable-list";
|
||||
@import "components/outline-view";
|
||||
@import "components/fixed-popover";
|
||||
@import "components/date-input";
|
||||
|
|
Loading…
Reference in a new issue