diff --git a/build/config/eslint.json b/build/config/eslint.json index 992ea2ddc..54f00c671 100644 --- a/build/config/eslint.json +++ b/build/config/eslint.json @@ -2,7 +2,8 @@ "extends": "airbnb", "globals": { "NylasEnv": false, - "$n": false + "$n": false, + "waitsForPromise": false }, "env": { "browser": true, diff --git a/internal_packages/send-later/lib/main.js b/internal_packages/send-later/lib/main.js index bbed9857c..580c07422 100644 --- a/internal_packages/send-later/lib/main.js +++ b/internal_packages/send-later/lib/main.js @@ -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() { diff --git a/internal_packages/send-later/lib/send-later-popover.jsx b/internal_packages/send-later/lib/send-later-popover.jsx index d62ebd6eb..59de3c7e0 100644 --- a/internal_packages/send-later/lib/send-later-popover.jsx +++ b/internal_packages/send-later/lib/send-later-popover.jsx @@ -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 = ( - {DateUtils.format(this.state.inputDate, DATE_FORMAT_LONG)} - ); - } - - return ( -
- - {dateInterpretation} -
- ) - } + 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 = [
, - this.renderCustomTimeSection(), + , ]; if (this.state.scheduledDate) { diff --git a/internal_packages/send-later/lib/send-later-store.js b/internal_packages/send-later/lib/send-later-store.js index 07ea9bd1c..2d8532b2d 100644 --- a/internal_packages/send-later/lib/send-later-store.js +++ b/internal_packages/send-later/lib/send-later-store.js @@ -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 diff --git a/internal_packages/send-later/spec/send-later-popover-spec.jsx b/internal_packages/send-later/spec/send-later-popover-spec.jsx new file mode 100644 index 000000000..478f877b3 --- /dev/null +++ b/internal_packages/send-later/spec/send-later-popover-spec.jsx @@ -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(); + 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() + }); + }); +}); diff --git a/internal_packages/send-later/spec/send-later-store-spec.es6 b/internal_packages/send-later/spec/send-later-store-spec.es6 new file mode 100644 index 000000000..4290bd372 --- /dev/null +++ b/internal_packages/send-later/spec/send-later-store-spec.es6 @@ -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() + }) + }) + }); + }); +}); diff --git a/internal_packages/send-later/stylesheets/send-later.less b/internal_packages/send-later/stylesheets/send-later.less index 026ddb788..ebd3ea30b 100644 --- a/internal_packages/send-later/stylesheets/send-later.less +++ b/internal_packages/send-later/stylesheets/send-later.less @@ -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; diff --git a/internal_packages/thread-snooze/lib/main.js b/internal_packages/thread-snooze/lib/main.js index 6b2775db6..947c7e74b 100644 --- a/internal_packages/thread-snooze/lib/main.js +++ b/internal_packages/thread-snooze/lib/main.js @@ -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'}); diff --git a/internal_packages/thread-snooze/lib/snooze-mail-label.jsx b/internal_packages/thread-snooze/lib/snooze-mail-label.jsx index f855224b0..14332b2cb 100644 --- a/internal_packages/thread-snooze/lib/snooze-mail-label.jsx +++ b/internal_packages/thread-snooze/lib/snooze-mail-label.jsx @@ -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 = ( { - 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 (
+ onClick={this.onSnooze.bind(this, date, itemLabel)}> - {label} + {itemLabel}
) }; @@ -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 ( -
- - {formatted} -
- ); - }; - render() { - const {inputDate} = this.state; const rows = SnoozeOptions.map(this.renderRow); return (
{rows} - {this.renderInputRow(inputDate)} +
); } diff --git a/internal_packages/thread-snooze/lib/snooze-store.js b/internal_packages/thread-snooze/lib/snooze-store.js index feae7cdd3..cc747e87f 100644 --- a/internal_packages/thread-snooze/lib/snooze-store.js +++ b/internal_packages/thread-snooze/lib/snooze-store.js @@ -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}`); diff --git a/internal_packages/thread-snooze/lib/snooze-utils.js b/internal_packages/thread-snooze/lib/snooze-utils.js index 03b65c0f4..14b2a8048 100644 --- a/internal_packages/thread-snooze/lib/snooze-utils.js +++ b/internal_packages/thread-snooze/lib/snooze-utils.js @@ -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 diff --git a/internal_packages/thread-snooze/spec/snooze-store-spec.es6 b/internal_packages/thread-snooze/spec/snooze-store-spec.es6 new file mode 100644 index 000000000..395f7a0c7 --- /dev/null +++ b/internal_packages/thread-snooze/spec/snooze-store-spec.es6 @@ -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() + }) + }) + }); + }); +}) diff --git a/internal_packages/thread-snooze/spec/snooze-utils-spec.es6 b/internal_packages/thread-snooze/spec/snooze-utils-spec.es6 new file mode 100644 index 000000000..4df78a4ec --- /dev/null +++ b/internal_packages/thread-snooze/spec/snooze-utils-spec.es6 @@ -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) + }) + }) + }); + }); +}); diff --git a/internal_packages/thread-snooze/stylesheets/snooze-popover.less b/internal_packages/thread-snooze/stylesheets/snooze-popover.less index 90c6cf9bf..94c374d77 100644 --- a/internal_packages/thread-snooze/stylesheets/snooze-popover.less +++ b/internal_packages/thread-snooze/stylesheets/snooze-popover.less @@ -63,11 +63,5 @@ input { margin-bottom: 3px; } - - em { - font-size: 0.9em; - opacity: 0.62; - } - } } diff --git a/spec/components/date-input-spec.jsx b/spec/components/date-input-spec.jsx new file mode 100644 index 000000000..3a8adadf3 --- /dev/null +++ b/spec/components/date-input-spec.jsx @@ -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(); + 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() + }); + }); +}); diff --git a/spec/components/editable-list-spec.jsx b/spec/components/editable-list-spec.jsx index 3e79e65bd..e31e98d63 100644 --- a/spec/components/editable-list-spec.jsx +++ b/spec/components/editable-list-spec.jsx @@ -12,7 +12,11 @@ const {TestUtils: { const makeList = (items = [], props = {})=> { - return renderIntoDocument(); + const list = renderIntoDocument(); + if (props.initialState) { + list.setState(props.initialState) + } + return list }; describe('EditableList', ()=> { diff --git a/src/components/date-input.jsx b/src/components/date-input.jsx new file mode 100644 index 000000000..c10b3f026 --- /dev/null +++ b/src/components/date-input.jsx @@ -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 = ( + + {DateUtils.format(this.state.inputDate, this.props.dateFormat)} + + ); + } + const {className} = this.props + const classes = classnames({ + "nylas-date-input": true, + [className]: className != null, + }) + + return ( +
+ + {dateInterpretation} +
+ ) + } +} + +export default DateInput diff --git a/src/components/editable-list.jsx b/src/components/editable-list.jsx index 2440232f7..4cd760efb 100644 --- a/src/components/editable-list.jsx +++ b/src/components/editable-list.jsx @@ -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, diff --git a/src/date-utils.es6 b/src/date-utils.es6 index 5905d6ae3..0d6d8da03 100644 --- a/src/date-utils.es6 +++ b/src/date-utils.es6 @@ -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 = { diff --git a/src/global/nylas-component-kit.coffee b/src/global/nylas-component-kit.coffee index e8e7d0a41..a0610c6a6 100644 --- a/src/global/nylas-component-kit.coffee +++ b/src/global/nylas-component-kit.coffee @@ -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' diff --git a/static/components/date-input.less b/static/components/date-input.less new file mode 100644 index 000000000..ffee83e1e --- /dev/null +++ b/static/components/date-input.less @@ -0,0 +1,9 @@ +@import "ui-variables"; + +.nylas-date-input { + .date-interpretation { + color: @text-color-subtle; + font-size: @font-size-small; + opacity: 0.6; + } +} diff --git a/static/index.less b/static/index.less index ed763b84a..65e4a1d94 100644 --- a/static/index.less +++ b/static/index.less @@ -31,3 +31,4 @@ @import "components/editable-list"; @import "components/outline-view"; @import "components/fixed-popover"; +@import "components/date-input";