mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-20 15:26:06 +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
|
@ -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,
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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()
|
|
||||||
|
|
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";
|
@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;
|
||||||
|
|
|
@ -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'});
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}`);
|
||||||
|
|
|
@ -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})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
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 {
|
input {
|
||||||
margin-bottom: 3px;
|
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 = {})=> {
|
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', ()=> {
|
||||||
|
|
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
|
* @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,
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
||||||
|
|
|
@ -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'
|
||||||
|
|
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/editable-list";
|
||||||
@import "components/outline-view";
|
@import "components/outline-view";
|
||||||
@import "components/fixed-popover";
|
@import "components/fixed-popover";
|
||||||
|
@import "components/date-input";
|
||||||
|
|
Loading…
Reference in a new issue