feat(composer): blocks multiple sending & lots of tests

Summary: fix in composer sending

Test Plan: edgehill --test

Reviewers: bengotow

Reviewed By: bengotow

Differential Revision: https://review.inboxapp.com/D1286
This commit is contained in:
Evan Morikawa 2015-03-12 17:48:56 -04:00
parent 818bca892c
commit 6a03c6a034
10 changed files with 395 additions and 56 deletions

View file

@ -72,7 +72,7 @@ AccountSidebarStore = Reflux.createStore
# Remove this when JOIN query speed is fixed! # Remove this when JOIN query speed is fixed!
_populateUnreadCountsDebounced: _.debounce -> _populateUnreadCountsDebounced: _.debounce ->
@_populateUnreadCounts() @_populateUnreadCounts()
, 750 , 2000
_refetchFromAPI: -> _refetchFromAPI: ->
namespace = NamespaceStore.current() namespace = NamespaceStore.current()

View file

@ -31,6 +31,7 @@ ComposerView = React.createClass
bcc: [] bcc: []
body: "" body: ""
subject: "" subject: ""
isSending: DraftStore.sendingState(@props.localId)
state state
getComponentRegistryState: -> getComponentRegistryState: ->
@ -42,6 +43,7 @@ ComposerView = React.createClass
# @_checkForKnownFrames() # @_checkForKnownFrames()
componentDidMount: -> componentDidMount: ->
@_draftStoreUnlisten = DraftStore.listen @_onSendingStateChanged
@keymap_unsubscriber = atom.commands.add '.composer-outer-wrap', { @keymap_unsubscriber = atom.commands.add '.composer-outer-wrap', {
'composer:show-and-focus-bcc': @_showAndFocusBcc 'composer:show-and-focus-bcc': @_showAndFocusBcc
'composer:show-and-focus-cc': @_showAndFocusCc 'composer:show-and-focus-cc': @_showAndFocusCc
@ -59,6 +61,7 @@ ComposerView = React.createClass
componentWillUnmount: -> componentWillUnmount: ->
@_teardownForDraft() @_teardownForDraft()
@_draftStoreUnlisten() if @_draftStoreUnlisten
@keymap_unsubscriber.dispose() @keymap_unsubscriber.dispose()
componentWillUpdate: -> componentWillUpdate: ->
@ -114,6 +117,10 @@ ComposerView = React.createClass
_renderComposer: -> _renderComposer: ->
<div className="composer-inner-wrap" onDragOver={@_onDragNoop} onDragLeave={@_onDragNoop} onDragEnd={@_onDragNoop} onDrop={@_onDrop}> <div className="composer-inner-wrap" onDragOver={@_onDragNoop} onDragLeave={@_onDragNoop} onDragEnd={@_onDragNoop} onDrop={@_onDrop}>
<div className="composer-cover"
style={display: (if @state.isSending then "block" else "none")}>
</div>
<div className="composer-action-bar-wrap"> <div className="composer-action-bar-wrap">
<div className="composer-action-bar-content"> <div className="composer-action-bar-content">
<button className="btn btn-toolbar pull-right btn-trash" <button className="btn btn-toolbar pull-right btn-trash"
@ -127,6 +134,7 @@ ComposerView = React.createClass
onClick={@_popoutComposer}><RetinaImg name="toolbar-popout.png"/></button> onClick={@_popoutComposer}><RetinaImg name="toolbar-popout.png"/></button>
<button className="btn btn-toolbar btn-send" <button className="btn btn-toolbar btn-send"
ref="sendButton"
onClick={@_sendDraft}><RetinaImg name="toolbar-send.png" /></button> onClick={@_sendDraft}><RetinaImg name="toolbar-send.png" /></button>
{@_footerComponents()} {@_footerComponents()}
</div> </div>
@ -277,6 +285,7 @@ ComposerView = React.createClass
Actions.composePopoutDraft @props.localId Actions.composePopoutDraft @props.localId
_sendDraft: (options = {}) -> _sendDraft: (options = {}) ->
return if @state.isSending
draft = @_proxy.draft() draft = @_proxy.draft()
remote = require('remote') remote = require('remote')
dialog = remote.require('dialog') dialog = remote.require('dialog')
@ -293,7 +302,7 @@ ComposerView = React.createClass
warnings = [] warnings = []
if draft.subject.length is 0 if draft.subject.length is 0
warnings.push('without a subject line') warnings.push('without a subject line')
if draft.body.toLowerCase().indexOf('attachment') != -1 and draft.files?.length is 0 if (draft.files ? []).length is 0 and draft.body.toLowerCase().indexOf('attach') >= 0
warnings.push('without an attachment') warnings.push('without an attachment')
if warnings.length > 0 and not options.force if warnings.length > 0 and not options.force
@ -334,6 +343,9 @@ ComposerView = React.createClass
@_precalcComposerCss = @_precalcComposerCss =
minHeight: mheight - INLINE_COMPOSER_OTHER_HEIGHT minHeight: mheight - INLINE_COMPOSER_OTHER_HEIGHT
_onSendingStateChanged: ->
@setState isSending: DraftStore.sendingState(@props.localId)

View file

@ -4,9 +4,11 @@ proxyquire = require "proxyquire"
React = require "react/addons" React = require "react/addons"
ReactTestUtils = React.addons.TestUtils ReactTestUtils = React.addons.TestUtils
{Contact, {Actions,
Contact,
Message, Message,
Namespace, Namespace,
DraftStore,
DatabaseStore, DatabaseStore,
InboxTestUtils, InboxTestUtils,
NamespaceStore} = require "inbox-exports" NamespaceStore} = require "inbox-exports"
@ -28,9 +30,9 @@ textFieldStub = (className) ->
render: -> <div className={className}>{@props.children}</div> render: -> <div className={className}>{@props.children}</div>
focus: -> focus: ->
draftStoreProxyStub = (localId) -> draftStoreProxyStub = (localId, returnedDraft) ->
listen: -> -> listen: -> ->
draft: -> new Message() draft: -> (returnedDraft ? new Message(draft: true))
changes: changes:
add: -> add: ->
commit: -> commit: ->
@ -41,8 +43,6 @@ searchContactStub = (email) ->
ComposerView = proxyquire "../lib/composer-view.cjsx", ComposerView = proxyquire "../lib/composer-view.cjsx",
"./file-uploads.cjsx": reactStub("file-uploads") "./file-uploads.cjsx": reactStub("file-uploads")
"./draft-store-proxy": draftStoreProxyStub
"./composer-participants.cjsx": reactStub("composer-participants")
"./participants-text-field.cjsx": textFieldStub("") "./participants-text-field.cjsx": textFieldStub("")
"inbox-exports": "inbox-exports":
ContactStore: ContactStore:
@ -51,6 +51,7 @@ ComposerView = proxyquire "../lib/composer-view.cjsx",
listen: -> -> listen: -> ->
findViewByName: (component) -> reactStub(component) findViewByName: (component) -> reactStub(component)
findAllViewsByRole: (role) -> [reactStub('a'),reactStub('b')] findAllViewsByRole: (role) -> [reactStub('a'),reactStub('b')]
DraftStore: DraftStore
beforeEach -> beforeEach ->
# The NamespaceStore isn't set yet in the new window, populate it first. # The NamespaceStore isn't set yet in the new window, populate it first.
@ -78,71 +79,250 @@ describe "A blank composer view", ->
expect(ReactTestUtils.isCompositeComponentWithType @composer, ComposerView).toBe true expect(ReactTestUtils.isCompositeComponentWithType @composer, ComposerView).toBe true
describe "testing keyboard inputs", -> describe "testing keyboard inputs", ->
beforeEach ->
spyOn(@composer, "_sendDraft")
InboxTestUtils.loadKeymap "internal_packages/composer/keymaps/composer.cson"
it "sends the draft on cmd-enter", ->
InboxTestUtils.keyPress("cmd-enter", @composer.getDOMNode())
expect(@composer._sendDraft).toHaveBeenCalled()
it "does not send the draft on enter if the button isn't in focus", ->
InboxTestUtils.keyPress("enter", @composer.getDOMNode())
expect(@composer._sendDraft).not.toHaveBeenCalled()
it "sends the draft on enter when the button is in focus", ->
sendBtn = ReactTestUtils.findRenderedDOMComponentWithClass(@composer, "btn-send")
InboxTestUtils.keyPress("enter", sendBtn.getDOMNode())
expect(@composer._sendDraft).toHaveBeenCalled()
it "shows and focuses on bcc field", -> it "shows and focuses on bcc field", ->
it "shows and focuses on cc field", -> it "shows and focuses on cc field", ->
it "shows and focuses on bcc field when already open", -> it "shows and focuses on bcc field when already open", ->
describe "should show subject", -> describe "populated composer", ->
# This will setup the mocks necessary to make the composer element (once
# mounted) think it's attached to the given draft. This mocks out the
# proxy system used by the composer.
DRAFT_LOCAL_ID = "local-123"
useDraft = (draftAttributes={}) ->
@draft = new Message _.extend({draft: true}, draftAttributes)
spyOn(DraftStore, "sessionForLocalId").andCallFake (localId) =>
return draftStoreProxyStub(localId, @draft)
useFullDraft = ->
useDraft.call @,
from: [u1]
to: [u2]
cc: [u3, u4]
bcc: [u5]
subject: "Test Message 1"
body: "Hello <b>World</b><br/> This is a test"
makeComposer = ->
@composer = ReactTestUtils.renderIntoDocument(
<ComposerView localId={DRAFT_LOCAL_ID} />
)
describe "When displaying info from a draft", ->
beforeEach ->
useFullDraft.apply(@)
makeComposer.call(@)
it "attaches the draft to the proxy", ->
expect(@draft).toBeDefined()
expect(@composer._proxy.draft()).toBe @draft
it "set the state based on the draft", ->
expect(@composer.state.from).toBeUndefined()
expect(@composer.state.to).toEqual [u2]
expect(@composer.state.cc).toEqual [u3, u4]
expect(@composer.state.bcc).toEqual [u5]
expect(@composer.state.subject).toEqual "Test Message 1"
expect(@composer.state.body).toEqual "Hello <b>World</b><br/> This is a test"
describe "when deciding whether or not to show the subject", ->
it "shows the subject when the subject is empty", -> it "shows the subject when the subject is empty", ->
msg = new Message useDraft.call @, subject: ""
subject: "" makeComposer.call @
spyOn(@composer._proxy, "draft").andReturn msg
expect(@composer._shouldShowSubject()).toBe true expect(@composer._shouldShowSubject()).toBe true
it "shows the subject when the subject looks like a fwd", -> it "shows the subject when the subject looks like a fwd", ->
msg = new Message useDraft.call @, subject: "Fwd: This is the message"
subject: "Fwd: This is the message" makeComposer.call @
spyOn(@composer._proxy, "draft").andReturn msg
expect(@composer._shouldShowSubject()).toBe true expect(@composer._shouldShowSubject()).toBe true
it "shows the subject when the subject looks like a fwd", -> it "shows the subject when the subject looks like a fwd", ->
msg = new Message useDraft.call @, subject: "fwd foo"
subject: "fwd foo" makeComposer.call @
spyOn(@composer._proxy, "draft").andReturn msg
expect(@composer._shouldShowSubject()).toBe true expect(@composer._shouldShowSubject()).toBe true
it "doesn't show subject when subject has fwd text in it", -> it "doesn't show subject when subject has fwd text in it", ->
msg = new Message useDraft.call @, subject: "Trick fwd"
subject: "Trick fwd" makeComposer.call @
spyOn(@composer._proxy, "draft").andReturn msg
expect(@composer._shouldShowSubject()).toBe false expect(@composer._shouldShowSubject()).toBe false
it "doesn't show the subject otherwise", -> it "doesn't show the subject otherwise", ->
msg = new Message useDraft.call @, subject: "Foo bar baz"
subject: "Foo bar Baz" makeComposer.call @
spyOn(@composer._proxy, "draft").andReturn msg
expect(@composer._shouldShowSubject()).toBe false expect(@composer._shouldShowSubject()).toBe false
describe "When composing a new message", -> describe "when deciding whether or not to show cc and bcc", ->
it "Can add someone in the to field", -> it "doesn't show cc when there's no one to cc", ->
useDraft.call @, cc: []
makeComposer.call @
expect(@composer.state.showcc).toBe false
it "Can add someone in the cc field", -> it "shows cc when populated", ->
useDraft.call @, cc: [u1,u2]
makeComposer.call @
expect(@composer.state.showcc).toBe true
it "Can add someone in the bcc field", -> it "doesn't show bcc when there's no one to bcc", ->
useDraft.call @, bcc: []
makeComposer.call @
expect(@composer.state.showbcc).toBe false
describe "When replying to a message", -> it "shows bcc when populated", ->
useDraft.call @, bcc: [u2,u3]
makeComposer.call @
expect(@composer.state.showbcc).toBe true
describe "When replying all to a message", -> describe "When sending a message", ->
beforeEach ->
remote = require('remote')
@dialog = remote.require('dialog')
spyOn(remote, "getCurrentWindow")
spyOn(@dialog, "showMessageBox")
spyOn(Actions, "sendDraft")
DraftStore._sendingState = {}
describe "When forwarding a message", -> it "shows a warning if there are no recipients", ->
useDraft.call @, subject: "no recipients"
makeComposer.call(@)
@composer._sendDraft()
expect(Actions.sendDraft).not.toHaveBeenCalled()
expect(@dialog.showMessageBox).toHaveBeenCalled()
dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1]
expect(dialogArgs.buttons).toEqual ['Edit Message']
describe "When changing the subject of a message", -> it "shows a warning if there's no subject", ->
useDraft.call @, to: [u1], subject: ""
makeComposer.call(@)
@composer._sendDraft()
expect(Actions.sendDraft).not.toHaveBeenCalled()
expect(@dialog.showMessageBox).toHaveBeenCalled()
dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1]
expect(dialogArgs.buttons).toEqual ['Cancel', 'Send Anyway']
it "doesn't show a warning if requirements are satisfied", ->
useFullDraft.apply(@); makeComposer.call(@)
@composer._sendDraft()
expect(Actions.sendDraft).toHaveBeenCalled()
expect(@dialog.showMessageBox).not.toHaveBeenCalled()
describe "Checking for attachments", ->
warn = (body) ->
useDraft.call @, subject: "Subject", to: [u1], body: body
makeComposer.call(@); @composer._sendDraft()
expect(Actions.sendDraft).not.toHaveBeenCalled()
expect(@dialog.showMessageBox).toHaveBeenCalled()
dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1]
expect(dialogArgs.buttons).toEqual ['Cancel', 'Send Anyway']
noWarn = (body) ->
useDraft.call @, subject: "Subject", to: [u1], body: "Sup yo"
makeComposer.call(@); @composer._sendDraft()
expect(Actions.sendDraft).toHaveBeenCalled()
expect(@dialog.showMessageBox).not.toHaveBeenCalled()
it "warns", -> warn.call(@, "Check out the attached file")
it "warns", -> warn.call(@, "I've added an attachment")
it "warns", -> warn.call(@, "I'm going to attach the file")
it "doesn't warn", -> noWarn.call(@, "sup yo")
it "doesn't warn", -> noWarn.call(@, "Look at the file")
it "doesn't show a warning if you've attached a file", ->
useDraft.call @,
subject: "Subject"
to: [u1]
body: "Check out attached file"
files: [{filename:"abc"}]
makeComposer.call(@); @composer._sendDraft()
expect(Actions.sendDraft).toHaveBeenCalled()
expect(@dialog.showMessageBox).not.toHaveBeenCalled()
it "bypasses the warning if force bit is set", ->
useDraft.call @, to: [u1], subject: ""
makeComposer.call(@)
@composer._sendDraft(force: true)
expect(Actions.sendDraft).toHaveBeenCalled()
expect(@dialog.showMessageBox).not.toHaveBeenCalled()
it "sends when you click the send button", ->
useFullDraft.apply(@); makeComposer.call(@)
sendBtn = @composer.refs.sendButton.getDOMNode()
ReactTestUtils.Simulate.click sendBtn
expect(Actions.sendDraft).toHaveBeenCalledWith(DRAFT_LOCAL_ID)
expect(Actions.sendDraft.calls.length).toBe 1
simulateDraftStore = ->
DraftStore._sendingState[DRAFT_LOCAL_ID] = true
DraftStore.trigger()
it "doesn't send twice if you double click", ->
useFullDraft.apply(@); makeComposer.call(@)
sendBtn = @composer.refs.sendButton.getDOMNode()
ReactTestUtils.Simulate.click sendBtn
simulateDraftStore()
ReactTestUtils.Simulate.click sendBtn
expect(Actions.sendDraft).toHaveBeenCalledWith(DRAFT_LOCAL_ID)
expect(Actions.sendDraft.calls.length).toBe 1
it "disables the composer once sending has started", ->
useFullDraft.apply(@); makeComposer.call(@)
sendBtn = @composer.refs.sendButton.getDOMNode()
cover = ReactTestUtils.findRenderedDOMComponentWithClass(@composer, "composer-cover")
expect(cover.getDOMNode().style.display).toBe "none"
ReactTestUtils.Simulate.click sendBtn
simulateDraftStore()
expect(cover.getDOMNode().style.display).toBe "block"
expect(@composer.state.isSending).toBe true
it "re-enables the composer if sending threw an error", ->
useFullDraft.apply(@); makeComposer.call(@)
sendBtn = @composer.refs.sendButton.getDOMNode()
ReactTestUtils.Simulate.click sendBtn
simulateDraftStore()
expect(@composer.state.isSending).toBe true
Actions.sendDraftError("oh no")
DraftStore._sendingState[DRAFT_LOCAL_ID] = false
DraftStore.trigger()
expect(@composer.state.isSending).toBe false
describe "when sending a message with keyboard inputs", ->
beforeEach ->
useFullDraft.apply(@)
makeComposer.call(@)
spyOn(@composer, "_sendDraft")
InboxTestUtils.loadKeymap "internal_packages/composer/keymaps/composer.cson"
it "sends the draft on cmd-enter", ->
InboxTestUtils.keyPress("cmd-enter", @composer.getDOMNode())
expect(@composer._sendDraft).toHaveBeenCalled()
it "does not send the draft on enter if the button isn't in focus", ->
InboxTestUtils.keyPress("enter", @composer.getDOMNode())
expect(@composer._sendDraft).not.toHaveBeenCalled()
it "sends the draft on enter when the button is in focus", ->
sendBtn = ReactTestUtils.findRenderedDOMComponentWithClass(@composer, "btn-send")
InboxTestUtils.keyPress("enter", sendBtn.getDOMNode())
expect(@composer._sendDraft).toHaveBeenCalled()
it "doesn't let you send twice", ->
sendBtn = ReactTestUtils.findRenderedDOMComponentWithClass(@composer, "btn-send")
InboxTestUtils.keyPress("enter", sendBtn.getDOMNode())
expect(@composer._sendDraft).toHaveBeenCalled()
describe "When composing a new message", ->
it "Can add someone in the to field", ->
it "Can add someone in the cc field", ->
it "Can add someone in the bcc field", ->
describe "When replying to a message", ->
describe "When replying all to a message", ->
describe "When forwarding a message", ->
describe "When changing the subject of a message", ->

View file

@ -14,7 +14,16 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.composer-cover {
position: absolute;
top: -1 * @spacing-double; right: 0; bottom: 0; left: 0;
z-index: 1000;
background: rgba(255,255,255,0.7);
}
.composer-action-bar-wrap { .composer-action-bar-wrap {
position: relative;
z-index: 1;
width: 100%; width: 100%;
background: transparent; background: transparent;
border-bottom: 0; border-bottom: 0;
@ -36,6 +45,7 @@
.composer-content-wrap { .composer-content-wrap {
position: relative; position: relative;
z-index: 1;
width: 100%; width: 100%;
max-width: @compose-width; max-width: @compose-width;

View file

@ -4,6 +4,8 @@ Contact = require '../../src/flux/models/contact'
NamespaceStore = require '../../src/flux/stores/namespace-store.coffee' NamespaceStore = require '../../src/flux/stores/namespace-store.coffee'
DatabaseStore = require '../../src/flux/stores/database-store.coffee' DatabaseStore = require '../../src/flux/stores/database-store.coffee'
DraftStore = require '../../src/flux/stores/draft-store.coffee' DraftStore = require '../../src/flux/stores/draft-store.coffee'
SendDraftTask = require '../../src/flux/tasks/send-draft'
Actions = require '../../src/flux/actions'
_ = require 'underscore-plus' _ = require 'underscore-plus'
fakeThread = null fakeThread = null
@ -264,3 +266,72 @@ describe "DraftStore", ->
, (thread, message) -> , (thread, message) ->
expect(message).toEqual(fakeMessage1) expect(message).toEqual(fakeMessage1)
{} {}
describe "sending a draft", ->
draftLocalId = "local-123"
beforeEach ->
DraftStore._sendingState = {}
DraftStore._draftSessions = {}
DraftStore._draftSessions[draftLocalId] =
changes:
commit: -> Promise.resolve()
spyOn(DraftStore, "trigger")
afterEach ->
atom.state.mode = "editor" # reset to default
it "sets the sending state when sending", ->
DraftStore._onSendDraft(draftLocalId)
expect(DraftStore.sendingState(draftLocalId)).toBe true
expect(DraftStore.trigger).toHaveBeenCalled()
it "returns false if the draft hasn't been seen", ->
expect(DraftStore.sendingState(draftLocalId)).toBe false
it "resets the sending state on success", ->
DraftStore._onSendDraft(draftLocalId)
expect(DraftStore.sendingState(draftLocalId)).toBe true
DraftStore._onSendDraftSuccess(draftLocalId)
expect(DraftStore.sendingState(draftLocalId)).toBe false
expect(DraftStore.trigger).toHaveBeenCalled()
it "resets the sending state on error", ->
DraftStore._onSendDraft(draftLocalId)
expect(DraftStore.sendingState(draftLocalId)).toBe true
DraftStore._onSendDraftError(draftLocalId)
expect(DraftStore.sendingState(draftLocalId)).toBe false
expect(DraftStore.trigger).toHaveBeenCalled()
it "closes the window if it's a popout", ->
atom.state.mode = "composer"
spyOn(atom, "close")
waitsForPromise ->
DraftStore._onSendDraft(draftLocalId).then ->
expect(atom.close).toHaveBeenCalled()
it "doesn't close the window if it's inline", ->
atom.state.mode = "other"
spyOn(atom, "close")
waitsForPromise ->
DraftStore._onSendDraft(draftLocalId).then ->
expect(atom.close).not.toHaveBeenCalled()
it "queues a SendDraftTask", ->
spyOn(Actions, "queueTask")
waitsForPromise ->
DraftStore._onSendDraft(draftLocalId).then ->
expect(Actions.queueTask).toHaveBeenCalled()
task = Actions.queueTask.calls[0].args[0]
expect(task instanceof SendDraftTask).toBe true
expect(task.draftLocalId).toBe draftLocalId
expect(task.fromPopout).toBe false
it "queues a SendDraftTask with popout info", ->
atom.state.mode = "composer"
spyOn(atom, "close")
spyOn(Actions, "queueTask")
waitsForPromise ->
DraftStore._onSendDraft(draftLocalId).then ->
expect(Actions.queueTask).toHaveBeenCalled()
task = Actions.queueTask.calls[0].args[0]
expect(task.fromPopout).toBe true

View file

@ -108,13 +108,33 @@ describe "SendDraftTask", ->
to: to:
name: 'Dummy' name: 'Dummy'
email: 'dummy@inboxapp.com' email: 'dummy@inboxapp.com'
@task = new SendDraftTask(@draft) @draftLocalId = "local-123"
@task = new SendDraftTask(@draftLocalId)
spyOn(atom.inbox, 'makeRequest').andCallFake (options) -> spyOn(atom.inbox, 'makeRequest').andCallFake (options) ->
options.success() if options.success options.success() if options.success
spyOn(DatabaseStore, 'findByLocalId').andCallFake (klass, localId) => spyOn(DatabaseStore, 'findByLocalId').andCallFake (klass, localId) =>
Promise.resolve(@draft) Promise.resolve(@draft)
spyOn(DatabaseStore, 'unpersistModel').andCallFake (draft) => spyOn(DatabaseStore, 'unpersistModel').andCallFake (draft) ->
Promise.resolve() Promise.resolve()
spyOn(atom, "playSound")
spyOn(Actions, "postNotification")
spyOn(Actions, "sendDraftSuccess")
it "should unpersist when successfully sent", ->
waitsForPromise => @task.performRemote().then =>
expect(DatabaseStore.unpersistModel).toHaveBeenCalledWith(@draft)
it "should notify the draft was sent", ->
waitsForPromise => @task.performRemote().then =>
expect(Actions.sendDraftSuccess).toHaveBeenCalledWith(@draftLocalId)
it "should play a sound", ->
waitsForPromise => @task.performRemote().then ->
expect(atom.playSound).toHaveBeenCalledWith("mail_sent.ogg")
it "post a notification", ->
waitsForPromise => @task.performRemote().then ->
expect(Actions.postNotification).toHaveBeenCalled()
it "should start an API request to /send", -> it "should start an API request to /send", ->
waitsForPromise => waitsForPromise =>
@ -143,7 +163,7 @@ describe "SendDraftTask", ->
to: to:
name: 'Dummy' name: 'Dummy'
email: 'dummy@inboxapp.com' email: 'dummy@inboxapp.com'
@task = new SendDraftTask(@draft) @task = new SendDraftTask(@draftLocalId)
it "should send the draft JSON", -> it "should send the draft JSON", ->
waitsForPromise => waitsForPromise =>
@ -170,7 +190,9 @@ describe "SendDraftTask", ->
to: to:
name: 'Dummy' name: 'Dummy'
email: 'dummy@inboxapp.com' email: 'dummy@inboxapp.com'
@task = new SendDraftTask(@draft) @task = new SendDraftTask(@draft.id)
spyOn(Actions, "dequeueTask")
spyOn(Actions, "sendDraftError")
it "throws an error if the draft can't be found", -> it "throws an error if the draft can't be found", ->
spyOn(DatabaseStore, 'findByLocalId').andCallFake (klass, localId) -> spyOn(DatabaseStore, 'findByLocalId').andCallFake (klass, localId) ->
@ -192,3 +214,26 @@ describe "SendDraftTask", ->
waitsForPromise => waitsForPromise =>
@task.performRemote().catch (error) -> @task.performRemote().catch (error) ->
expect(error).toBe "DB error" expect(error).toBe "DB error"
checkError = ->
expect(Actions.sendDraftError).toHaveBeenCalled()
args = Actions.sendDraftError.calls[0].args
expect(args[0]).toBe @draft.id
expect(args[1].length).toBeGreaterThan 0
it "onAPIError notifies of the error", ->
@task.onAPIError(message: "oh no")
checkError.call(@)
it "onOtherError notifies of the error", ->
@task.onOtherError()
checkError.call(@)
it "onTimeoutError notifies of the error", ->
@task.onTimeoutError()
checkError.call(@)
it "onOfflineError notifies of the error and dequeues", ->
@task.onOfflineError()
checkError.call(@)
expect(Actions.dequeueTask).toHaveBeenCalledWith(@task)

View file

@ -21,6 +21,8 @@ globalActions = [
"multiWindowNotification", "multiWindowNotification",
# Draft actions # Draft actions
"sendDraftError",
"sendDraftSuccess",
"destroyDraftSuccess", "destroyDraftSuccess",
"destroyDraftError" "destroyDraftError"
] ]

View file

@ -100,6 +100,7 @@ class DraftStoreProxy
unlisten() for unlisten in @unlisteners unlisten() for unlisten in @unlisteners
_onDraftChanged: (change) -> _onDraftChanged: (change) ->
return if not change?
# We don't accept changes unless our draft object is loaded # We don't accept changes unless our draft object is loaded
return unless @_draft return unless @_draft

View file

@ -41,9 +41,13 @@ DraftStore = Reflux.createStore
@listenTo Actions.removeFile, @_onRemoveFile @listenTo Actions.removeFile, @_onRemoveFile
@listenTo Actions.attachFileComplete, @_onAttachFileComplete @listenTo Actions.attachFileComplete, @_onAttachFileComplete
@listenTo Actions.sendDraftError, @_onSendDraftSuccess
@listenTo Actions.sendDraftSuccess, @_onSendDraftError
@listenTo Actions.destroyDraftSuccess, @_closeWindow @listenTo Actions.destroyDraftSuccess, @_closeWindow
@_drafts = [] @_drafts = []
@_draftSessions = {} @_draftSessions = {}
@_sendingState = {}
# TODO: Doesn't work if we do window.addEventListener, but this is # TODO: Doesn't work if we do window.addEventListener, but this is
# fragile. Pending an Atom fix perhaps? # fragile. Pending an Atom fix perhaps?
@ -85,6 +89,8 @@ DraftStore = Reflux.createStore
@_draftSessions[localId] ?= new DraftStoreProxy(localId) @_draftSessions[localId] ?= new DraftStoreProxy(localId)
@_draftSessions[localId] @_draftSessions[localId]
sendingState: (draftLocalId) -> @_sendingState[draftLocalId] ? false
########### PRIVATE #################################################### ########### PRIVATE ####################################################
_onDataChanged: (change) -> _onDataChanged: (change) ->
@ -213,7 +219,9 @@ DraftStore = Reflux.createStore
# Queue the task to destroy the draft # Queue the task to destroy the draft
Actions.queueTask(new DestroyDraftTask(draftLocalId)) Actions.queueTask(new DestroyDraftTask(draftLocalId))
_onSendDraft: (draftLocalId) -> _onSendDraft: (draftLocalId) -> new Promise (resolve, reject) =>
@_sendingState[draftLocalId] = true
@trigger()
# Immediately save any pending changes so we don't save after sending # Immediately save any pending changes so we don't save after sending
save = @_draftSessions[draftLocalId]?.changes.commit() save = @_draftSessions[draftLocalId]?.changes.commit()
save.then => save.then =>
@ -223,6 +231,15 @@ DraftStore = Reflux.createStore
# Queue the task to send the draft # Queue the task to send the draft
fromPopout = atom.state.mode is "composer" fromPopout = atom.state.mode is "composer"
Actions.queueTask(new SendDraftTask(draftLocalId, fromPopout: fromPopout)) Actions.queueTask(new SendDraftTask(draftLocalId, fromPopout: fromPopout))
resolve()
_onSendDraftError: (draftLocalId) ->
@_sendingState[draftLocalId] = false
@trigger()
_onSendDraftSuccess: (draftLocalId) ->
@_sendingState[draftLocalId] = false
@trigger()
_onAttachFileComplete: ({file, messageLocalId}) -> _onAttachFileComplete: ({file, messageLocalId}) ->
@sessionForLocalId(messageLocalId).prepare().then (proxy) -> @sessionForLocalId(messageLocalId).prepare().then (proxy) ->
@ -235,4 +252,3 @@ DraftStore = Reflux.createStore
files = proxy.draft().files ? [] files = proxy.draft().files ? []
files = _.reject files, (f) -> f.id is file.id files = _.reject files, (f) -> f.id is file.id
proxy.changes.add({files}, true) proxy.changes.add({files}, true)

View file

@ -31,7 +31,7 @@ class SendDraftTask extends Task
new Promise (resolve, reject) => new Promise (resolve, reject) =>
# Fetch the latest draft data to make sure we make the request with the most # Fetch the latest draft data to make sure we make the request with the most
# recent draft version # recent draft version
DatabaseStore.findByLocalId(Message, @draftLocalId).then (draft) -> DatabaseStore.findByLocalId(Message, @draftLocalId).then (draft) =>
# The draft may have been deleted by another task. Nothing we can do. # The draft may have been deleted by another task. Nothing we can do.
return reject(new Error("We couldn't find the saved draft.")) unless draft return reject(new Error("We couldn't find the saved draft.")) unless draft
@ -47,9 +47,10 @@ class SendDraftTask extends Task
method: 'POST' method: 'POST'
body: body body: body
returnsModel: true returnsModel: true
success: -> success: =>
atom.playSound('mail_sent.ogg') atom.playSound('mail_sent.ogg')
Actions.postNotification({message: "Sent!", type: 'success'}) Actions.postNotification({message: "Sent!", type: 'success'})
Actions.sendDraftSuccess(@draftLocalId)
DatabaseStore.unpersistModel(draft).then(resolve) DatabaseStore.unpersistModel(draft).then(resolve)
error: reject error: reject
.catch(reject) .catch(reject)
@ -73,5 +74,6 @@ class SendDraftTask extends Task
Actions.dequeueTask(@) Actions.dequeueTask(@)
_notifyError: (msg) -> _notifyError: (msg) ->
Actions.sendDraftError(@draftLocalId, msg)
if @fromPopout then atom.displayComposer(@draftLocalId, error: msg) if @fromPopout then atom.displayComposer(@draftLocalId, error: msg)
@notifyErrorMessage(msg) @notifyErrorMessage(msg)