mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-10-02 01:14:46 +08:00
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:
parent
818bca892c
commit
6a03c6a034
10 changed files with 395 additions and 56 deletions
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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", ->
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -21,6 +21,8 @@ globalActions = [
|
||||||
"multiWindowNotification",
|
"multiWindowNotification",
|
||||||
|
|
||||||
# Draft actions
|
# Draft actions
|
||||||
|
"sendDraftError",
|
||||||
|
"sendDraftSuccess",
|
||||||
"destroyDraftSuccess",
|
"destroyDraftSuccess",
|
||||||
"destroyDraftError"
|
"destroyDraftError"
|
||||||
]
|
]
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Add table
Reference in a new issue