feat(offline-mode, undo-redo): Tasks handle network errors better and retry, undo/redo based on tasks

Summary:
This diff does a couple things:

- Undo redo with a new undo/redo store that maintains it's own queue of undo/redo tasks. This queue is separate from the TaskQueue because not all tasks should be considered for undo history! Right now just the AddRemoveTagsTask is undoable.

- NylasAPI.makeRequest now returns a promise which resolves with the result or rejects with an error. For things that still need them, there's still `success` and `error` callbacks. I also added `started:(req) ->` which allows you to get the underlying request.

- Aborting a NylasAPI request now makes it call it's error callback / promise reject.

- You can now run code after perform local has completed using this syntax:

```
    task = new AddRemoveTagsTask(focused, ['archive'], ['inbox'])
    task.waitForPerformLocal().then ->
      Actions.setFocus(collection: 'thread', item: nextFocus)
      Actions.setCursorPosition(collection: 'thread', item: nextKeyboard)
    Actions.queueTask(task)
```

- In specs, you can now use `advanceClock` to get through a Promise.then/catch/finally. Turns out it was using something low level and not using setTimeout(0).

- The TaskQueue uses promises better and defers a lot of the complexity around queueState for performLocal/performRemote to a task subclass called APITask. APITask implements "perform" and breaks it into "performLocal" and "performRemote".

- All tasks either resolve or reject. They're always removed from the queue, unless they resolve with Task.Status.Retry, which means they internally did a .catch (err) => Promise.resolve(Task.Status.Retry) and they want to be run again later.

- API tasks retry until they succeed or receive a NylasAPI.PermanentErrorCode (400,404,500), in which case they revert and finish.

- The AddRemoveTags Task can now take more than one thread! This is super cool because you can undo/redo a bulk action and also because we'll probably have a bulk tag modification API endpoint soon.

Getting undo / redo working revealed that the thread versioning system we built isn't working because the server was incrementing things by more than 1 at a time. Now we count the number of unresolved "optimistic" changes we've made to a given model, and only accept the server's version of it once the number of optimistic changes is back at zero.

Known Issues:

- AddRemoveTagsTasks aren't dependent on each other, so if you (undo/redo x lots) and then come back online, all the tasks try to add / remove all the tags at the same time. To fix this we can either allow the tasks to be merged together into a minimal set or make them block on each other.

- When Offline, you still get errors in the console for GET requests. Need to catch these and display an offline status bar.

- The metadata tasks haven't been updated yet to the new API. Wanted to get it reviewed first!

Test Plan: All the tests still pass!

Reviewers: evan

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D1694
This commit is contained in:
Ben Gotow 2015-07-07 13:38:53 -04:00
parent af6f3c80b2
commit 45bb16561f
38 changed files with 1124 additions and 1197 deletions

View file

@ -13,6 +13,7 @@ Exports =
# The Task Queue
Task: require '../src/flux/tasks/task'
TaskQueue: require '../src/flux/stores/task-queue'
UndoRedoStore: require '../src/flux/stores/undo-redo-store'
# Tasks
CreateMetadataTask: require '../src/flux/tasks/create-metadata-task'

View file

@ -260,10 +260,14 @@ class ComposerView extends React.Component
_renderBody: =>
if @props.mode is "inline"
@_renderBodyContenteditable()
<span>
{@_renderBodyContenteditable()}
{@_renderAttachments()}
</span>
else
<ScrollRegion className="compose-body-scroll" ref="scrollregion" getScrollbar={ => @refs.scrollbar }>
{@_renderBodyContenteditable()}
{@_renderAttachments()}
</ScrollRegion>
_renderBodyContenteditable: =>
@ -281,30 +285,23 @@ class ComposerView extends React.Component
return <div></div> unless @props.localId
<div className="composer-footer-region">
<div className="attachments-area">
{@_renderNonImageAttachmentsAndUploads()}
{@_renderImageAttachmentsAndUploads()}
</div>
<InjectedComponentSet
matching={role: "Composer:Footer"}
exposedProps={draftLocalId:@props.localId, threadId: @props.threadId}/>
</div>
_renderNonImageAttachmentsAndUploads: ->
@_nonImages().map (fileOrUpload) =>
if fileOrUpload.object is "file"
@_attachmentComponent(fileOrUpload)
else
<FileUpload key={fileOrUpload.uploadId}
uploadData={fileOrUpload} />
_renderAttachments: ->
renderSubset = (arr, attachmentRole, UploadComponent) =>
arr.map (fileOrUpload) =>
if fileOrUpload.object is "file"
@_attachmentComponent(fileOrUpload, attachmentRole)
else
<UploadComponent key={fileOrUpload.uploadId} uploadData={fileOrUpload} />
_renderImageAttachmentsAndUploads: ->
@_images().map (fileOrUpload) =>
if fileOrUpload.object is "file"
@_attachmentComponent(fileOrUpload, "Attachment:Image")
else
<ImageFileUpload key={fileOrUpload.uploadId}
uploadData={fileOrUpload} />
<div className="attachments-area">
{renderSubset(@_nonImages(), 'Attachment', FileUpload)}
{renderSubset(@_images(), 'Attachment:Image', ImageFileUpload)}
</div>
_attachmentComponent: (file, role="Attachment") =>
targetPath = FileUploadStore.linkedUpload(file)?.filePath
@ -317,8 +314,10 @@ class ComposerView extends React.Component
targetPath: targetPath
messageLocalId: @props.localId
if role is "Attachment" then className = "non-image-attachment attachment-file-wrap"
else className = "image-attachment-file-wrap"
if role is "Attachment"
className = "non-image-attachment attachment-file-wrap"
else
className = "image-attachment-file-wrap"
<InjectedComponent key={file.id}
matching={role: role}

View file

@ -222,19 +222,21 @@ describe "populated composer", ->
describe "if the draft has not yet loaded", ->
it "should set _focusOnUpdate and focus after the next render", ->
useDraft.call(@)
makeComposer.call(@)
@draft = new Message(draft: true, body: "")
proxy = draftStoreProxyStub(DRAFT_LOCAL_ID, @draft)
proxyResolve = null
spyOn(DraftStore, "sessionForLocalId").andCallFake ->
new Promise (resolve, reject) ->
proxyResolve = resolve
proxy = @composer._proxy
@composer._proxy = null
makeComposer.call(@)
spyOn(@composer.refs['contentBody'], 'focus')
@composer.focus()
advanceClock(1000)
expect(@composer.refs['contentBody'].focus).not.toHaveBeenCalled()
@composer._proxy = proxy
@composer._onDraftChanged()
proxyResolve(proxy)
advanceClock(1000)
expect(@composer.refs['contentBody'].focus).toHaveBeenCalled()
@ -550,7 +552,7 @@ describe "populated composer", ->
fileSize: 1024
spyOn(Actions, "fetchFile")
spyOn(FileUploadStore, "linkedUpload")
spyOn(FileUploadStore, "linkedUpload").andReturn null
spyOn(FileUploadStore, "uploadsForMessage").andReturn [@up1, @up2]
useDraft.call @, files: [@file1, @file2]
@ -569,10 +571,9 @@ describe "populated composer", ->
els = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@composer, InjectedComponent, matching: role: "Attachment:Image")
expect(els.length).toBe 1
it 'renders the non image upload as a FileUpload', ->
els = ReactTestUtils.scryRenderedDOMComponentsWithClass(@composer, "file-upload")
expect(els.length).toBe 1
it 'renders the uploads with the correct components', ->
el = ReactTestUtils.findRenderedDOMComponentWithClass(@composer, 'file-upload')
expect(el).toBeDefined()
it 'renders the image upload as an ImageFileUpload', ->
els = ReactTestUtils.scryRenderedDOMComponentsWithClass(@composer, "image-file-upload")
expect(els.length).toBe 1
el = ReactTestUtils.findRenderedDOMComponentWithClass(@composer, 'image-file-upload')
expect(el).toBeDefined()

View file

@ -147,7 +147,7 @@
cursor: text;
overflow: auto;
position: relative;
padding: 0 8px;
margin: 0 8px;
.quoted-text-control {
position: absolute;

View file

@ -49,7 +49,7 @@ class DeveloperBarTask extends React.Component
"task-local-error": qs.localError
"task-remote-error": qs.remoteError
"task-is-processing": qs.isProcessing
"task-success": qs.performedLocal and qs.performedRemote
"task-success": qs.localComplete and qs.remoteComplete
module.exports = DeveloperBarTask

View file

@ -26,7 +26,7 @@ describe "MessageToolbarItem starring", ->
starButton = React.findDOMNode(messageToolbarItems.refs.starButton)
TestUtils.Simulate.click starButton
expect(Actions.queueTask.mostRecentCall.args[0].thread).toBe(test_thread)
expect(Actions.queueTask.mostRecentCall.args[0].threadsOrIds).toEqual([test_thread])
expect(Actions.queueTask.mostRecentCall.args[0].tagIdsToAdd).toEqual(['starred'])
expect(Actions.queueTask.mostRecentCall.args[0].tagIdsToRemove).toEqual([])
@ -39,6 +39,6 @@ describe "MessageToolbarItem starring", ->
starButton = React.findDOMNode(messageToolbarItems.refs.starButton)
TestUtils.Simulate.click starButton
expect(Actions.queueTask.mostRecentCall.args[0].thread).toBe(test_thread_starred)
expect(Actions.queueTask.mostRecentCall.args[0].threadsOrIds).toEqual([test_thread_starred])
expect(Actions.queueTask.mostRecentCall.args[0].tagIdsToAdd).toEqual([])
expect(Actions.queueTask.mostRecentCall.args[0].tagIdsToRemove).toEqual(['starred'])

View file

@ -78,6 +78,7 @@
max-width: @message-max-width;
margin: 11px auto 10px auto;
padding: 0 20px;
-webkit-user-select: text;
}
.message-subject {
font-size: @font-size-large;

View file

@ -87,7 +87,6 @@ class EmptyState extends React.Component
@_worker = NylasAPI.workerForNamespace(namespace)
@_workerUnlisten() if @_workerUnlisten
@_workerUnlisten = @_worker.listen(@_onChange, @)
console.log(@_worker)
@setState(syncing: @_worker.busy())
componentWillUnmount: ->

View file

@ -21,7 +21,6 @@ module.exports =
ThreadListStore = Reflux.createStore
init: ->
@_resetInstanceVars()
@_afterViewUpdate = []
@listenTo Actions.searchQueryCommitted, @_onSearchCommitted
@listenTo Actions.selectLayoutMode, @_autofocusForLayoutMode
@ -53,8 +52,6 @@ ThreadListStore = Reflux.createStore
@_viewUnlisten = view.listen ->
@trigger(@)
fn() for fn in @_afterViewUpdate
@_afterViewUpdate = []
@_autofocusForLayoutMode()
,@
@ -110,21 +107,20 @@ ThreadListStore = Reflux.createStore
@_view.invalidateMetadataFor(threadIds)
_onToggleStarSelection: ->
selected = @_view.selection.items()
selectedThreads = @_view.selection.items()
focusedId = FocusedContentStore.focusedId('thread')
keyboardId = FocusedContentStore.keyboardCursorId('thread')
oneAlreadyStarred = false
for thread in selected
for thread in selectedThreads
if thread.hasTagId('starred')
oneAlreadyStarred = true
for thread in selected
if oneAlreadyStarred
task = new AddRemoveTagsTask(thread, [], ['starred'])
else
task = new AddRemoveTagsTask(thread, ['starred'], [])
Actions.queueTask(task)
if oneAlreadyStarred
task = new AddRemoveTagsTask(selectedThreads, [], ['starred'])
else
task = new AddRemoveTagsTask(selectedThreads, ['starred'], [])
Actions.queueTask(task)
_onToggleStarFocused: ->
focused = FocusedContentStore.focused('thread')
@ -140,18 +136,19 @@ ThreadListStore = Reflux.createStore
@_archiveAndShiftBy('auto')
_onArchiveSelection: ->
selected = @_view.selection.items()
selectedThreads = @_view.selection.items()
selectedThreadIds = selectedThreads.map (thread) -> thread.id
focusedId = FocusedContentStore.focusedId('thread')
keyboardId = FocusedContentStore.keyboardCursorId('thread')
for thread in selected
task = new AddRemoveTagsTask(thread, ['archive'], ['inbox'])
Actions.queueTask(task)
if thread.id is focusedId
task = new AddRemoveTagsTask(selectedThreads, ['archive'], ['inbox'])
task.waitForPerformLocal().then =>
if focusedId in selectedThreadIds
Actions.setFocus(collection: 'thread', item: null)
if thread.id is keyboardId
if keyboardId in selectedThreadIds
Actions.setCursorPosition(collection: 'thread', item: null)
Actions.queueTask(task)
@_view.selection.clear()
_onArchiveAndPrev: ->
@ -181,10 +178,6 @@ ThreadListStore = Reflux.createStore
index = Math.min(Math.max(index + offset, 0), @_view.count() - 1)
nextKeyboard = nextFocus = @_view.get(index)
# Archive the current thread
task = new AddRemoveTagsTask(focused, ['archive'], ['inbox'])
Actions.queueTask(task)
# Remove the current thread from selection
@_view.selection.remove(focused)
@ -194,9 +187,12 @@ ThreadListStore = Reflux.createStore
if layoutMode is 'list' and not explicitOffset
nextFocus = null
@_afterViewUpdate.push ->
# Archive the current thread
task = new AddRemoveTagsTask(focused, ['archive'], ['inbox'])
task.waitForPerformLocal().then ->
Actions.setFocus(collection: 'thread', item: nextFocus)
Actions.setCursorPosition(collection: 'thread', item: nextKeyboard)
Actions.queueTask(task)
_autofocusForLayoutMode: ->
layoutMode = WorkspaceStore.layoutMode()

View file

@ -520,7 +520,7 @@ describe "DraftStore", ->
it "sets the sending state when sending", ->
spyOn(atom, "isMainWindow").andReturn true
spyOn(TaskQueue, "_update")
spyOn(TaskQueue, "_updateSoon")
spyOn(Actions, "queueTask").andCallThrough()
runs ->
DraftStore._onSendDraft(draftLocalId)

View file

@ -9,7 +9,7 @@ Task = require '../../src/flux/tasks/task'
TimeoutError} = require '../../src/flux/errors'
class TaskSubclassA extends Task
constructor: (val) -> @aProp = val # forgot to call super
constructor: (val) -> @aProp = val; super
class TaskSubclassB extends Task
constructor: (val) -> @bProp = val; super
@ -17,77 +17,20 @@ class TaskSubclassB extends Task
describe "TaskQueue", ->
makeUnstartedTask = (task) ->
TaskQueue._initializeTask(task)
return task
task
makeLocalStarted = (task) ->
TaskQueue._initializeTask(task)
makeProcessing = (task) ->
task.queueState.isProcessing = true
return task
makeLocalFailed = (task) ->
TaskQueue._initializeTask(task)
task.queueState.performedLocal = Date.now()
return task
makeRemoteStarted = (task) ->
TaskQueue._initializeTask(task)
task.queueState.isProcessing = true
task.queueState.remoteAttempts = 1
task.queueState.performedLocal = Date.now()
return task
makeRemoteSuccess = (task) ->
TaskQueue._initializeTask(task)
task.queueState.remoteAttempts = 1
task.queueState.performedLocal = Date.now()
task.queueState.performedRemote = Date.now()
return task
makeRemoteFailed = (task) ->
TaskQueue._initializeTask(task)
task.queueState.remoteAttempts = 1
task.queueState.performedLocal = Date.now()
return task
task
beforeEach ->
@task = new Task()
@unstartedTask = makeUnstartedTask(new Task())
@localStarted = makeLocalStarted(new Task())
@localFailed = makeLocalFailed(new Task())
@remoteStarted = makeRemoteStarted(new Task())
@remoteSuccess = makeRemoteSuccess(new Task())
@remoteFailed = makeRemoteFailed(new Task())
@processingTask = makeProcessing(new Task())
unstartedTask = (task) ->
taks.queueState.shouldRetry = false
taks.queueState.isProcessing = false
taks.queueState.remoteAttempts = 0
taks.queueState.perfomredLocal = false
taks.queueState.performedRemote = false
taks.queueState.notifiedOffline = false
startedTask = (task) ->
taks.queueState.shouldRetry = false
taks.queueState.isProcessing = true
taks.queueState.remoteAttempts = 0
taks.queueState.perfomredLocal = false
taks.queueState.performedRemote = false
taks.queueState.notifiedOffline = false
localTask = (task) ->
taks.queueState.shouldRetry = false
taks.queueState.isProcessing = true
taks.queueState.remoteAttempts = 0
taks.queueState.perfomredLocal = false
taks.queueState.performedRemote = false
taks.queueState.notifiedOffline = false
localSpy = (task) ->
spyOn(task, "performLocal").andCallFake -> Promise.resolve()
remoteSpy = (task) ->
spyOn(task, "performRemote").andCallFake -> Promise.resolve()
afterEach ->
# Flush any throttled or debounced updates
advanceClock(1000)
describe "findTask", ->
beforeEach ->
@ -111,287 +54,152 @@ describe "TaskQueue", ->
expect(TaskQueue.findTask(TaskSubclassB, {bProp: 'B3'})).toEqual(null)
describe "enqueue", ->
beforeEach ->
spyOn(@unstartedTask, 'runLocal').andCallFake =>
@unstartedTask.queueState.localComplete = true
Promise.resolve()
it "makes sure you've queued a real task", ->
expect( -> TaskQueue.enqueue("asamw")).toThrow()
it "adds it to the queue", ->
TaskQueue.enqueue(@task)
expect(TaskQueue._queue.length).toBe 1
spyOn(TaskQueue, '_processQueue').andCallFake ->
TaskQueue.enqueue(@unstartedTask)
advanceClock()
expect(TaskQueue._queue.length).toBe(1)
it "immediately calls runLocal", ->
TaskQueue.enqueue(@unstartedTask)
expect(@unstartedTask.runLocal).toHaveBeenCalled()
it "notifies the queue should be processed", ->
spyOn(TaskQueue, "_processTask")
spyOn(TaskQueue, "_processQueue").andCallThrough()
spyOn(TaskQueue, "_processTask")
TaskQueue.enqueue(@task)
TaskQueue.enqueue(@unstartedTask)
advanceClock()
expect(TaskQueue._processQueue).toHaveBeenCalled()
expect(TaskQueue._processTask).toHaveBeenCalledWith(@task)
expect(TaskQueue._processTask.calls.length).toBe 1
expect(TaskQueue._processTask).toHaveBeenCalledWith(@unstartedTask)
expect(TaskQueue._processTask.calls.length).toBe(1)
it "ensures all tasks have an id", ->
TaskQueue.enqueue(new TaskSubclassA())
TaskQueue.enqueue(new TaskSubclassB())
expect(isTempId(TaskQueue._queue[0].id)).toBe true
expect(isTempId(TaskQueue._queue[1].id)).toBe true
it "throws an exception if the task does not have a queueState", ->
task = new TaskSubclassA()
task.queueState = undefined
expect( => TaskQueue.enqueue(task)).toThrow()
it "dequeues Obsolete tasks", ->
it "throws an exception if the task does not have an ID", ->
task = new TaskSubclassA()
task.id = undefined
expect( => TaskQueue.enqueue(task)).toThrow()
it "dequeues obsolete tasks", ->
task = new TaskSubclassA()
spyOn(TaskQueue, '_dequeueObsoleteTasks').andCallFake ->
TaskQueue.enqueue(task)
expect(TaskQueue._dequeueObsoleteTasks).toHaveBeenCalled()
describe "_dequeueObsoleteTasks", ->
it "should dequeue tasks based on `shouldDequeueOtherTask`", ->
class KillsTaskA extends Task
constructor: ->
shouldDequeueOtherTask: (other) -> other instanceof TaskSubclassA
performRemote: -> new Promise (resolve, reject) ->
taskToDie = makeRemoteFailed(new TaskSubclassA())
otherTask = new Task()
otherTask.queueState.localComplete = true
obsoleteTask = new TaskSubclassA()
obsoleteTask.queueState.localComplete = true
replacementTask = new KillsTaskA()
replacementTask.queueState.localComplete = true
spyOn(TaskQueue, "dequeue").andCallThrough()
TaskQueue._queue = [taskToDie, @remoteFailed]
TaskQueue.enqueue(new KillsTaskA())
expect(TaskQueue._queue.length).toBe 2
expect(TaskQueue.dequeue).toHaveBeenCalledWith(taskToDie, silent: true)
expect(TaskQueue.dequeue.calls.length).toBe 1
spyOn(TaskQueue, 'dequeue').andCallThrough()
TaskQueue._queue = [obsoleteTask, otherTask]
TaskQueue._dequeueObsoleteTasks(replacementTask)
expect(TaskQueue._queue.length).toBe(1)
expect(TaskQueue.dequeue).toHaveBeenCalledWith(obsoleteTask)
expect(TaskQueue.dequeue.calls.length).toBe(1)
describe "dequeue", ->
beforeEach ->
TaskQueue._queue = [@unstartedTask,
@localStarted,
@remoteStarted,
@remoteFailed]
TaskQueue._queue = [@unstartedTask, @processingTask]
it "grabs the task by object", ->
found = TaskQueue._parseArgs(@remoteStarted)
expect(found).toBe @remoteStarted
found = TaskQueue._resolveTaskArgument(@unstartedTask)
expect(found).toBe @unstartedTask
it "grabs the task by id", ->
found = TaskQueue._parseArgs(@remoteStarted.id)
expect(found).toBe @remoteStarted
found = TaskQueue._resolveTaskArgument(@unstartedTask.id)
expect(found).toBe @unstartedTask
it "throws an error if the task isn't found", ->
expect( -> TaskQueue.dequeue("bad")).toThrow()
it "calls cleanup on dequeued tasks", ->
spyOn(@remoteStarted, "cleanup")
TaskQueue.dequeue(@remoteStarted, silent: true)
expect(@remoteStarted.cleanup).toHaveBeenCalled()
describe "with an unstarted task", ->
it "moves it from the queue", ->
TaskQueue.dequeue(@unstartedTask)
expect(TaskQueue._queue.length).toBe(1)
expect(TaskQueue._completed.length).toBe(1)
it "moves it from the queue", ->
TaskQueue.dequeue(@remoteStarted, silent: true)
expect(TaskQueue._queue.length).toBe 3
expect(TaskQueue._completed.length).toBe 1
it "notifies the queue has been updated", ->
spyOn(TaskQueue, "_processQueue")
TaskQueue.dequeue(@unstartedTask)
advanceClock(20)
expect(TaskQueue._processQueue).toHaveBeenCalled()
expect(TaskQueue._processQueue.calls.length).toBe(1)
it "marks it as no longer processing", ->
TaskQueue.dequeue(@remoteStarted, silent: true)
expect(@remoteStarted.queueState.isProcessing).toBe false
it "notifies the queue has been updated", ->
spyOn(TaskQueue, "_processQueue")
TaskQueue.dequeue(@remoteStarted)
expect(TaskQueue._processQueue).toHaveBeenCalled()
expect(TaskQueue._processQueue.calls.length).toBe 1
describe "with a processing task", ->
it "calls cancel() to allow the task to resolve or reject from runRemote()", ->
spyOn(@processingTask, 'cancel')
TaskQueue.dequeue(@processingTask)
expect(@processingTask.cancel).toHaveBeenCalled()
expect(TaskQueue._queue.length).toBe(2)
expect(TaskQueue._completed.length).toBe(0)
describe "process Task", ->
it "doesn't process processing tasks", ->
localSpy(@remoteStarted)
remoteSpy(@remoteStarted)
TaskQueue._processTask(@remoteStarted)
expect(@remoteStarted.performLocal).not.toHaveBeenCalled()
expect(@remoteStarted.performRemote).not.toHaveBeenCalled()
spyOn(@processingTask, "runRemote").andCallFake -> Promise.resolve()
TaskQueue._processTask(@processingTask)
expect(@processingTask.runRemote).not.toHaveBeenCalled()
it "doesn't process blocked tasks", ->
class BlockedByTaskA extends Task
constructor: ->
shouldWaitForTask: (other) -> other instanceof TaskSubclassA
blockedByTask = new BlockedByTaskA()
localSpy(blockedByTask)
remoteSpy(blockedByTask)
taskA = new TaskSubclassA()
otherTask = new Task()
blockedByTaskA = new BlockedByTaskA()
blockingTask = makeRemoteFailed(new TaskSubclassA())
taskA.queueState.localComplete = true
otherTask.queueState.localComplete = true
blockedByTaskA.queueState.localComplete = true
TaskQueue._queue = [blockingTask, @remoteFailed]
TaskQueue.enqueue(blockedByTask)
spyOn(taskA, "runRemote").andCallFake -> new Promise (resolve, reject) ->
spyOn(blockedByTaskA, "runRemote").andCallFake -> Promise.resolve()
expect(TaskQueue._queue.length).toBe 3
expect(blockedByTask.performLocal).not.toHaveBeenCalled()
expect(blockedByTask.performRemote).not.toHaveBeenCalled()
TaskQueue._queue = [taskA, otherTask, blockedByTaskA]
TaskQueue._processQueue()
it "doesn't block itself", ->
advanceClock()
expect(TaskQueue._queue.length).toBe(2)
expect(taskA.runRemote).toHaveBeenCalled()
expect(blockedByTaskA.runRemote).not.toHaveBeenCalled()
it "doesn't block itself, even if the shouldWaitForTask method is implemented naively", ->
class BlockingTask extends Task
constructor: ->
shouldWaitForTask: (other) -> other instanceof BlockingTask
blockedByTask = new BlockingTask()
localSpy(blockedByTask)
remoteSpy(blockedByTask)
blockedTask = new BlockingTask()
spyOn(blockedTask, "runRemote").andCallFake -> Promise.resolve()
blockingTask = makeRemoteFailed(new BlockingTask())
TaskQueue._queue = [blockingTask, @remoteFailed]
TaskQueue.enqueue(blockedByTask)
expect(TaskQueue._queue.length).toBe 3
expect(blockedByTask.performLocal).not.toHaveBeenCalled()
expect(blockedByTask.performRemote).not.toHaveBeenCalled()
TaskQueue.enqueue(blockedTask)
advanceClock()
blockedTask.runRemote.callCount > 0
it "sets the processing bit", ->
localSpy(@unstartedTask)
TaskQueue._queue = [@unstartedTask]
TaskQueue._processTask(@unstartedTask)
expect(@unstartedTask.queueState.isProcessing).toBe true
spyOn(@unstartedTask, "runRemote").andCallFake -> Promise.resolve()
task = new Task()
task.queueState.localComplete = true
TaskQueue._queue = [task]
TaskQueue._processTask(task)
expect(task.queueState.isProcessing).toBe true
it "performs local if it's a fresh task", ->
localSpy(@unstartedTask)
TaskQueue._queue = [@unstartedTask]
TaskQueue._processTask(@unstartedTask)
expect(@unstartedTask.performLocal).toHaveBeenCalled()
describe "performLocal", ->
it "on success it marks it as complete with the timestamp", ->
localSpy(@unstartedTask)
remoteSpy(@unstartedTask)
runs ->
TaskQueue.enqueue(@unstartedTask)
waitsFor =>
@unstartedTask.queueState.performedLocal isnt false
runs ->
expect(@unstartedTask.queueState.performedLocal).toBeGreaterThan 0
it "throws an error if it fails", ->
spyOn(@unstartedTask, "performLocal").andCallFake -> Promise.reject("boo")
remoteSpy(@unstartedTask)
runs ->
TaskQueue.enqueue(@unstartedTask)
waitsFor =>
@unstartedTask.queueState.isProcessing == false
runs ->
expect(@unstartedTask.queueState.localError).toBe "boo"
expect(@unstartedTask.performLocal).toHaveBeenCalled()
expect(@unstartedTask.performRemote).not.toHaveBeenCalled()
it "dequeues the task if it fails locally", ->
spyOn(@unstartedTask, "performLocal").andCallFake -> Promise.reject("boo")
remoteSpy(@unstartedTask)
runs ->
TaskQueue.enqueue(@unstartedTask)
waitsFor =>
@unstartedTask.queueState.isProcessing == false
runs ->
expect(TaskQueue._queue.length).toBe 0
expect(TaskQueue._completed.length).toBe 1
describe "performRemote", ->
beforeEach ->
localSpy(@unstartedTask)
it "performs remote properly", ->
remoteSpy(@unstartedTask)
runs ->
TaskQueue.enqueue(@unstartedTask)
waitsFor =>
@unstartedTask.queueState.performedRemote isnt false
runs ->
expect(@unstartedTask.performLocal).toHaveBeenCalled()
expect(@unstartedTask.performRemote).toHaveBeenCalled()
it "dequeues on success", ->
remoteSpy(@unstartedTask)
runs ->
TaskQueue.enqueue(@unstartedTask)
waitsFor =>
@unstartedTask.queueState.isProcessing is false and
@unstartedTask.queueState.performedRemote > 0
runs ->
expect(TaskQueue._queue.length).toBe 0
expect(TaskQueue._completed.length).toBe 1
it "notifies we're offline the first time", ->
spyOn(TaskQueue, "_isOnline").andReturn false
remoteSpy(@unstartedTask)
spyOn(@unstartedTask, "onError")
runs ->
TaskQueue.enqueue(@unstartedTask)
waitsFor =>
@unstartedTask.queueState.notifiedOffline == true
runs ->
expect(@unstartedTask.performLocal).toHaveBeenCalled()
expect(@unstartedTask.performRemote).not.toHaveBeenCalled()
expect(@unstartedTask.onError).toHaveBeenCalled()
expect(@unstartedTask.queueState.isProcessing).toBe false
expect(@unstartedTask.onError.calls[0].args[0] instanceof OfflineError).toBe true
it "doesn't notify we're offline the second+ time", ->
spyOn(TaskQueue, "_isOnline").andReturn false
localSpy(@remoteFailed)
remoteSpy(@remoteFailed)
spyOn(@remoteFailed, "onError")
@remoteFailed.queueState.notifiedOffline = true
TaskQueue._queue = [@remoteFailed]
runs ->
TaskQueue._processQueue()
waitsFor =>
@remoteFailed.queueState.isProcessing is false
runs ->
expect(@remoteFailed.performLocal).not.toHaveBeenCalled()
expect(@remoteFailed.performRemote).not.toHaveBeenCalled()
expect(@remoteFailed.onError).not.toHaveBeenCalled()
it "marks performedRemote on success", ->
remoteSpy(@unstartedTask)
runs ->
TaskQueue.enqueue(@unstartedTask)
waitsFor =>
@unstartedTask.queueState.performedRemote isnt false
runs ->
expect(@unstartedTask.queueState.performedRemote).toBeGreaterThan 0
it "on failure it notifies of the error", ->
err = new APIError
spyOn(@unstartedTask, "performRemote").andCallFake -> Promise.reject(err)
spyOn(@unstartedTask, "onError")
runs ->
TaskQueue.enqueue(@unstartedTask)
waitsFor =>
@unstartedTask.queueState.isProcessing is false
runs ->
expect(@unstartedTask.performLocal).toHaveBeenCalled()
expect(@unstartedTask.performRemote).toHaveBeenCalled()
expect(@unstartedTask.onError).toHaveBeenCalledWith(err)
it "dequeues on failure", ->
err = new APIError
spyOn(@unstartedTask, "performRemote").andCallFake -> Promise.reject(err)
runs ->
TaskQueue.enqueue(@unstartedTask)
waitsFor =>
@unstartedTask.queueState.isProcessing is false
runs ->
expect(TaskQueue._queue.length).toBe 0
expect(TaskQueue._completed.length).toBe 1
it "on failure it sets the appropriate bits", ->
err = new APIError
spyOn(@unstartedTask, "performRemote").andCallFake -> Promise.reject(err)
spyOn(@unstartedTask, "onError")
runs ->
TaskQueue.enqueue(@unstartedTask)
waitsFor =>
@unstartedTask.queueState.isProcessing is false
runs ->
expect(@unstartedTask.queueState.notifiedOffline).toBe false
expect(@unstartedTask.queueState.remoteError).toBe err
describe "under stress", ->
beforeEach ->
TaskQueue._queue = [@unstartedTask,
@remoteFailed]
it "when all tasks pass it processes all items", ->
for task in TaskQueue._queue
localSpy(task)
remoteSpy(task)
runs ->
TaskQueue.enqueue(new Task)
waitsFor ->
TaskQueue._queue.length is 0
runs ->
expect(TaskQueue._completed.length).toBe 3

View file

@ -4,6 +4,7 @@ AddRemoveTagsTask = require '../../src/flux/tasks/add-remove-tags'
DatabaseStore = require '../../src/flux/stores/database-store'
Thread = require '../../src/flux/models/thread'
Tag = require '../../src/flux/models/tag'
{APIError} = require '../../src/flux/errors'
_ = require 'underscore'
testThread = null
@ -19,20 +20,19 @@ describe "AddRemoveTagsTask", ->
else
throw new Error("Not stubbed!")
describe "rollbackLocal", ->
it "should perform the opposite changes to the thread", ->
testThread = new Thread
id: 'thread-id'
tags: [
new Tag({name: 'archive', id: 'archive'})
]
task = new AddRemoveTagsTask(testThread, ['archive'], ['inbox'])
task._rollbackLocal()
waitsFor ->
DatabaseStore.persistModel.callCount > 0
runs ->
testThread = DatabaseStore.persistModel.mostRecentCall.args[0]
expect(testThread.tagIds()).toEqual(['inbox'])
describe "shouldWaitForTask", ->
it "should return true if another, older AddRemoveTagsTask involves the same threads", ->
a = new AddRemoveTagsTask(['t1', 't2', 't3'])
a.creationDate = new Date(1000)
b = new AddRemoveTagsTask(['t3', 't4', 't7'])
b.creationDate = new Date(2000)
c = new AddRemoveTagsTask(['t0', 't7'])
c.creationDate = new Date(3000)
expect(a.shouldWaitForTask(b)).toEqual(false)
expect(a.shouldWaitForTask(c)).toEqual(false)
expect(b.shouldWaitForTask(a)).toEqual(true)
expect(c.shouldWaitForTask(a)).toEqual(false)
expect(c.shouldWaitForTask(b)).toEqual(true)
describe "performLocal", ->
beforeEach ->
@ -108,16 +108,15 @@ describe "AddRemoveTagsTask", ->
expect(testThread.tagIds().length).toBe(1)
expect(testThread.tagIds()[0]).toBe('archive')
describe "performRemote", ->
beforeEach ->
testThread = new Thread
id: '1233123AEDF1'
namespaceId: 'A12ADE'
namespaceId: 'nsid'
@task = new AddRemoveTagsTask(testThread, ['archive'], ['inbox'])
it "should start an API request with the Draft JSON", ->
spyOn(NylasAPI, 'makeRequest')
spyOn(NylasAPI, 'makeRequest').andCallFake -> Promise.resolve()
@task.performLocal()
waitsFor ->
DatabaseStore.persistModel.callCount > 0
@ -130,8 +129,27 @@ describe "AddRemoveTagsTask", ->
expect(options.body.remove_tags[0]).toBe('inbox')
it "should pass returnsModel:true so that the draft is saved to the data store when returned", ->
spyOn(NylasAPI, 'makeRequest')
spyOn(NylasAPI, 'makeRequest').andCallFake -> Promise.resolve()
@task.performLocal()
@task.performRemote()
options = NylasAPI.makeRequest.mostRecentCall.args[0]
expect(options.returnsModel).toBe(true)
describe "when the server responds with a permanentErrorCode", ->
beforeEach ->
spyOn(NylasAPI, 'makeRequest').andCallFake ->
Promise.reject(new APIError(statusCode: 400, message: ''))
it "should revert the changes to the thread", ->
runs ->
testThread = new Thread
id: 'thread-id'
tags: [new Tag(name: 'inbox', id: 'inbox')]
task = new AddRemoveTagsTask(testThread, ['archive'], ['inbox'])
task.performRemote()
waitsFor ->
DatabaseStore.persistModel.callCount is 1
runs ->
testThread = DatabaseStore.persistModel.calls[0].args[0]
expect(testThread.tagIds()).toEqual(['inbox'])

View file

@ -2,12 +2,17 @@ proxyquire = require 'proxyquire'
_ = require 'underscore'
NylasAPI = require '../../src/flux/nylas-api'
File = require '../../src/flux/models/file'
Task = require '../../src/flux/tasks/task'
Message = require '../../src/flux/models/message'
Actions = require '../../src/flux/actions'
NamespaceStore = require "../../src/flux/stores/namespace-store"
DraftStore = require "../../src/flux/stores/draft-store"
{APIError,
OfflineError,
TimeoutError} = require '../../src/flux/errors'
FileUploadTask = proxyquire "../../src/flux/tasks/file-upload-task",
fs:
statSync: -> {size: 1234}
@ -19,6 +24,8 @@ test_file_paths = [
"/fake/file.jpg"
]
noop = ->
localId = "local-id_1234"
fake_draft = new Message
@ -43,6 +50,7 @@ describe "FileUploadTask", ->
beforeEach ->
spyOn(Date, "now").andReturn DATE
spyOn(FileUploadTask, "idGen").andReturn 3
@uploadData =
uploadId: 3
startedUploadingAt: DATE
@ -54,6 +62,23 @@ describe "FileUploadTask", ->
@task = new FileUploadTask(test_file_paths[0], localId)
@req = jasmine.createSpyObj('req', ['abort'])
@simulateRequestSuccessImmediately = false
@simulateRequestSuccess = null
@simulateRequestFailure = null
spyOn(NylasAPI, 'makeRequest').andCallFake (reqParams) =>
new Promise (resolve, reject) =>
reqParams.started?(@req)
@simulateRequestSuccess = (data) =>
reqParams.success?(data)
resolve(data)
@simulateRequestFailure = (err) =>
reqParams.error?(err)
reject(err)
if @simulateRequestSuccessImmediately
@simulateRequestSuccess(testResponse)
it "rejects if not initialized with a path name", (done) ->
waitsForPromise ->
(new FileUploadTask).performLocal().catch (err) ->
@ -80,22 +105,29 @@ describe "FileUploadTask", ->
data = _.extend @uploadData, state: "pending", bytesUploaded: 0
expect(Actions.uploadStateChanged).toHaveBeenCalledWith data
it "notifies when the file upload fails", ->
spyOn(Actions, "uploadStateChanged")
spyOn(@task, "_getBytesUploaded").andReturn(0)
@task._rollbackLocal()
data = _.extend @uploadData, state: "failed", bytesUploaded: 0
expect(Actions.uploadStateChanged).toHaveBeenCalledWith(data)
describe "when the remote API request fails with an API Error", ->
it "broadcasts uploadStateChanged", ->
runs ->
@task.performRemote().catch (err) => console.log(err)
waitsFor ->
@simulateRequestFailure
runs ->
spyOn(@task, "_getBytesUploaded").andReturn(0)
spyOn(Actions, "uploadStateChanged")
@simulateRequestFailure(new APIError())
waitsFor ->
Actions.uploadStateChanged.callCount > 0
runs ->
data = _.extend(@uploadData, {state: "failed", bytesUploaded: 0})
expect(Actions.uploadStateChanged).toHaveBeenCalledWith(data)
describe "When successfully calling remote", ->
describe "when the remote API request succeeds", ->
beforeEach ->
spyOn(Actions, "uploadStateChanged")
@req = jasmine.createSpyObj('req', ['abort'])
spyOn(NylasAPI, 'makeRequest').andCallFake (reqParams) =>
reqParams.success(testResponse) if reqParams.success
return @req
@testFiles = []
@changes = []
@simulateRequestSuccessImmediately = true
spyOn(Actions, "uploadStateChanged")
spyOn(DraftStore, "sessionForLocalId").andCallFake =>
Promise.resolve(
draft: => files: @testFiles
@ -122,23 +154,19 @@ describe "FileUploadTask", ->
expect(@changes).toEqual [equivalentFile]
describe "file upload notifications", ->
beforeEach ->
spyOn(Actions, "fileUploaded")
spyOn(@task, "_getBytesUploaded").andReturn(1000)
runs =>
@task.performRemote()
advanceClock(2000)
waitsFor ->
Actions.fileUploaded.calls.length > 0
it "correctly fires the fileUploaded action", ->
runs =>
expect(Actions.fileUploaded).toHaveBeenCalledWith
file: equivalentFile
uploadData: _.extend {}, @uploadData,
state: "completed"
bytesUploaded: 1000
spyOn(@task, "_getBytesUploaded").andReturn(1000)
spyOn(Actions, "fileUploaded")
@task.performRemote()
advanceClock()
@simulateRequestSuccess()
advanceClock()
Actions.fileUploaded.calls.length > 0
expect(Actions.fileUploaded).toHaveBeenCalledWith
file: equivalentFile
uploadData: _.extend {}, @uploadData,
state: "completed"
bytesUploaded: 1000
describe "when attaching a lot of files", ->
it "attaches them all to the draft", ->
@ -147,6 +175,7 @@ describe "FileUploadTask", ->
t3 = new FileUploadTask("3.c", localId)
t4 = new FileUploadTask("4.d", localId)
@simulateRequestSuccessImmediately = true
waitsForPromise => Promise.all([
t1.performRemote()
t2.performRemote()
@ -155,28 +184,29 @@ describe "FileUploadTask", ->
]).then =>
expect(@changes.length).toBe 4
describe "cleanup", ->
describe "cancel", ->
it "should not do anything if the request has finished", ->
req = jasmine.createSpyObj('req', ['abort'])
reqSuccess = null
spyOn(NylasAPI, 'makeRequest').andCallFake (reqParams) ->
reqSuccess = reqParams.success
req
@task.performRemote()
reqSuccess(testResponse)
@task.cleanup()
expect(req.abort).not.toHaveBeenCalled()
runs =>
@task.performRemote()
waitsFor =>
@simulateRequestSuccess
runs =>
@simulateRequestSuccess(testResponse)
waitsFor =>
@task.req is null
runs =>
@task.cancel()
expect(@req.abort).not.toHaveBeenCalled()
it "should cancel the request if it's in flight", ->
req = jasmine.createSpyObj('req', ['abort'])
spyOn(NylasAPI, 'makeRequest').andCallFake (reqParams) -> req
spyOn(Actions, "uploadStateChanged")
@task.performRemote()
@task.cleanup()
advanceClock()
@task.cancel()
advanceClock()
expect(req.abort).toHaveBeenCalled()
expect(@req.abort).toHaveBeenCalled()
data = _.extend @uploadData,
state: "aborted"
bytesUploaded: 0

View file

@ -1,5 +1,6 @@
NylasAPI = require '../../src/flux/nylas-api'
Actions = require '../../src/flux/actions'
{APIError} = require '../../src/flux/errors'
MarkMessageReadTask = require '../../src/flux/tasks/mark-message-read'
DatabaseStore = require '../../src/flux/stores/database-store'
Message = require '../../src/flux/models/message'
@ -17,34 +18,6 @@ describe "MarkMessageReadTask", ->
email: 'dummy@nylas.com'
@task = new MarkMessageReadTask(@message)
describe "_rollbackLocal", ->
beforeEach ->
spyOn(DatabaseStore, 'persistModel').andCallFake -> Promise.resolve()
it "should not mark the message as unread if it was not unread initially", ->
message = new Message
id: '1233123AEDF1'
namespaceId: 'A12ADE'
subject: 'New Message'
unread: false
to:
name: 'Dummy'
email: 'dummy@nylas.com'
@task = new MarkMessageReadTask(message)
@task.performLocal()
@task._rollbackLocal()
expect(message.unread).toBe(false)
it "should mark the message as unread", ->
@task.performLocal()
@task._rollbackLocal()
expect(@message.unread).toBe(true)
it "should trigger an action to persist the change", ->
@task.performLocal()
@task._rollbackLocal()
expect(DatabaseStore.persistModel).toHaveBeenCalled()
describe "performLocal", ->
it "should mark the message as read", ->
@task.performLocal()
@ -57,9 +30,41 @@ describe "MarkMessageReadTask", ->
describe "performRemote", ->
it "should make the PUT request to the message endpoint", ->
spyOn(NylasAPI, 'makeRequest')
spyOn(NylasAPI, 'makeRequest').andCallFake => new Promise (resolve,reject) ->
@task.performRemote()
options = NylasAPI.makeRequest.mostRecentCall.args[0]
expect(options.path).toBe("/n/#{@message.namespaceId}/messages/#{@message.id}")
expect(options.method).toBe('PUT')
expect(options.body.unread).toBe(false)
describe "when the remote API request fails", ->
beforeEach ->
spyOn(DatabaseStore, 'persistModel').andCallFake -> Promise.resolve()
spyOn(NylasAPI, 'makeRequest').andCallFake -> Promise.reject(new APIError(body: '', statusCode: 400))
it "should not mark the message as unread if it was not unread initially", ->
message = new Message
id: '1233123AEDF1'
namespaceId: 'A12ADE'
subject: 'New Message'
unread: false
to:
name: 'Dummy'
email: 'dummy@nylas.com'
@task = new MarkMessageReadTask(message)
@task.performLocal()
@task.performRemote()
advanceClock()
expect(message.unread).toBe(false)
it "should mark the message as unread", ->
@task.performLocal()
@task.performRemote()
advanceClock()
expect(@message.unread).toBe(true)
it "should trigger an action to persist the change", ->
@task.performLocal()
@task.performRemote()
advanceClock()
expect(DatabaseStore.persistModel).toHaveBeenCalled()

View file

@ -4,6 +4,7 @@ SyncbackDraftTask = require '../../src/flux/tasks/syncback-draft'
SendDraftTask = require '../../src/flux/tasks/send-draft'
DatabaseStore = require '../../src/flux/stores/database-store'
{generateTempId} = require '../../src/flux/models/utils'
{APIError} = require '../../src/flux/errors'
Message = require '../../src/flux/models/message'
TaskQueue = require '../../src/flux/stores/task-queue'
_ = require 'underscore'
@ -37,47 +38,6 @@ describe "SendDraftTask", ->
expect(@sendA.shouldWaitForTask(@saveA)).toBe(true)
describe "When on the TaskQueue", ->
beforeEach ->
TaskQueue._queue = []
TaskQueue._completed = []
@saveTask = new SyncbackDraftTask('localid-A')
@saveTaskB = new SyncbackDraftTask('localid-B')
@sendTask = new SendDraftTask('localid-A')
@tasks = [@saveTask, @saveTaskB, @sendTask]
describe "when tasks succeed", ->
beforeEach ->
for task in @tasks
spyOn(task, "performLocal").andCallFake -> Promise.resolve()
spyOn(task, "performRemote").andCallFake -> Promise.resolve()
runs ->
TaskQueue.enqueue(@saveTask, silent: true)
TaskQueue.enqueue(@saveTaskB, silent: true)
TaskQueue.enqueue(@sendTask)
waitsFor ->
@sendTask.queueState.performedRemote isnt false
it "processes all of the items", ->
runs ->
expect(TaskQueue._queue.length).toBe 0
expect(TaskQueue._completed.length).toBe 3
it "all of the tasks", ->
runs ->
expect(@saveTask.performRemote).toHaveBeenCalled()
expect(@saveTaskB.performRemote).toHaveBeenCalled()
expect(@sendTask.performRemote).toHaveBeenCalled()
it "finishes the save before sending", ->
runs ->
save = @saveTask.queueState.performedRemote
send = @sendTask.queueState.performedRemote
expect(save).toBeGreaterThan 0
expect(send).toBeGreaterThan 0
expect(save <= send).toBe true
describe "performLocal", ->
it "should throw an exception if the first parameter is not a localId", ->
badTasks = [new SendDraftTask()]
@ -113,7 +73,8 @@ describe "SendDraftTask", ->
@draftLocalId = "local-123"
@task = new SendDraftTask(@draftLocalId)
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
options.success(@draft.toJSON()) if options.success
options.success?(@draft.toJSON())
Promise.resolve(@draft.toJSON())
spyOn(DatabaseStore, 'findByLocalId').andCallFake (klass, localId) =>
Promise.resolve(@draft)
spyOn(DatabaseStore, 'unpersistModel').andCallFake (draft) ->
@ -207,12 +168,14 @@ describe "SendDraftTask", ->
it "should resend the draft without the reply_to_message_id key set", ->
@draft.id = generateTempId()
spyOn(DatabaseStore, 'findByLocalId').andCallFake => Promise.resolve(@draft)
spyOn(NylasAPI, 'makeRequest').andCallFake ({body, success, error}) ->
spyOn(NylasAPI, 'makeRequest').andCallFake ({body, success, error}) =>
if body.reply_to_message_id
err = new Error("Invalid message public id")
error(err)
err = new APIError(body: "Invalid message public id", statusCode: 400)
error?(err)
return Promise.reject(err)
else
success(body)
success?(body)
return Promise.resolve(body)
waitsForPromise =>
@task.performRemote().then =>
@ -224,18 +187,23 @@ describe "SendDraftTask", ->
it "should resend the draft without the thread_id or reply_to_message_id keys set", ->
@draft.id = generateTempId()
spyOn(DatabaseStore, 'findByLocalId').andCallFake => Promise.resolve(@draft)
spyOn(NylasAPI, 'makeRequest').andCallFake ({body, success, error}) ->
if body.thread_id
err = new Error("Invalid thread public id")
error(err)
else
success(body)
spyOn(NylasAPI, 'makeRequest').andCallFake ({body, success, error}) =>
new Promise (resolve, reject) =>
if body.thread_id
err = new APIError(body: "Invalid thread public id", statusCode: 400)
error?(err)
reject(err)
else
success?(body)
resolve(body)
waitsForPromise =>
@task.performRemote().then =>
expect(NylasAPI.makeRequest.calls.length).toBe(2)
expect(NylasAPI.makeRequest.calls[1].args[0].body.thread_id).toBe(null)
expect(NylasAPI.makeRequest.calls[1].args[0].body.reply_to_message_id).toBe(null)
.catch (err) =>
console.log(err.trace)
it "throws an error if the draft can't be found", ->
spyOn(DatabaseStore, 'findByLocalId').andCallFake (klass, localId) ->
@ -257,16 +225,3 @@ describe "SendDraftTask", ->
waitsForPromise =>
@task.performRemote().catch (error) ->
expect(error).toBe "DB error"
it "onAPIError notifies of the error", ->
@task.onAPIError(message: "oh no")
it "onOtherError notifies of the error", ->
@task.onOtherError()
it "onTimeoutError notifies of the error", ->
@task.onTimeoutError()
it "onOfflineError notifies of the error and dequeues", ->
@task.onOfflineError()
expect(Actions.dequeueTask).toHaveBeenCalledWith(@task)

View file

@ -50,7 +50,7 @@ describe "SyncbackDraftTask", ->
describe "performRemote", ->
beforeEach ->
spyOn(NylasAPI, 'makeRequest').andCallFake (opts) ->
opts.success(remoteDraft.toJSON()) if opts.success
Promise.resolve(remoteDraft.toJSON())
it "does nothing if no draft can be found in the db", ->
task = new SyncbackDraftTask("missingDraftId")
@ -108,16 +108,17 @@ describe "SyncbackDraftTask", ->
describe "When the api throws a 404 error", ->
beforeEach ->
spyOn(TaskQueue, "enqueue")
spyOn(NylasAPI, "makeRequest").andCallFake (opts) ->
opts.error(testError(opts)) if opts.error
Promise.reject(testError(opts))
it "resets the id", ->
task = new SyncbackDraftTask("remoteDraftId")
task.onAPIError(testError({}))
taskStatus = null
task.performRemote().then (status) => taskStatus = status
waitsFor ->
DatabaseStore.swapModel.calls.length > 0
runs ->
newDraft = DatabaseStore.swapModel.mostRecentCall.args[0].newModel
expect(isTempId(newDraft.id)).toBe true
expect(TaskQueue.enqueue).toHaveBeenCalled()
expect(taskStatus).toBe(Task.Status.Retry)

View file

@ -1,56 +1,127 @@
# {APIError} = require '../../src/flux/errors'
# Task = require '../../src/flux/tasks/task'
# _ = require 'underscore'
#
# describe "Task", ->
# beforeEach ->
# @task = new Task()
#
# describe "shouldRetry", ->
#
# it "should default to false if the error does not have a status code", ->
# expect(@task.shouldRetry(new Error())).toBe(false)
#
# # Should Not Retry
#
# it "should return false when the error is a 401 Unauthorized from the API", ->
# expect(@task.shouldRetry(new APIError({statusCode: 401}))).toBe(false)
#
# it "should return false when the error is a 403 Forbidden from the API", ->
# expect(@task.shouldRetry(new APIError({statusCode: 403}))).toBe(false)
#
# it "should return false when the error is a 404 Not Found from the API", ->
# expect(@task.shouldRetry(new APIError({statusCode: 404}))).toBe(false)
#
# it "should return false when the error is a 405 Method Not Allowed from the API", ->
# expect(@task.shouldRetry(new APIError({statusCode: 405}))).toBe(false)
#
# it "should return false when the error is a 406 Not Acceptable from the API", ->
# expect(@task.shouldRetry(new APIError({statusCode: 406}))).toBe(false)
#
# it "should return false when the error is a 409 Conflict from the API", ->
# expect(@task.shouldRetry(new APIError({statusCode: 409}))).toBe(false)
#
# # Should Retry
#
# it "should return true when the error is 0 Request Not Made from the API", ->
# expect(@task.shouldRetry(new APIError({statusCode: 0}))).toBe(true)
#
# it "should return true when the error is 407 Proxy Authentication Required from the API", ->
# expect(@task.shouldRetry(new APIError({statusCode: 407}))).toBe(true)
#
# it "should return true when the error is 408 Request Timeout from the API", ->
# expect(@task.shouldRetry(new APIError({statusCode: 408}))).toBe(true)
#
# it "should return true when the error is 305 Use Proxy from the API", ->
# expect(@task.shouldRetry(new APIError({statusCode: 305}))).toBe(true)
#
# it "should return true when the error is 502 Bad Gateway from the API", ->
# expect(@task.shouldRetry(new APIError({statusCode: 502}))).toBe(true)
#
# it "should return true when the error is 503 Service Unavailable from the API", ->
# expect(@task.shouldRetry(new APIError({statusCode: 503}))).toBe(true)
#
# it "should return true when the error is 504 Gateway Timeout from the API", ->
# expect(@task.shouldRetry(new APIError({statusCode: 504}))).toBe(true)
#
Actions = require '../../src/flux/actions'
TaskQueue = require '../../src/flux/stores/task-queue'
Task = require '../../src/flux/tasks/task'
{APIError,
OfflineError,
TimeoutError} = require '../../src/flux/errors'
noop = ->
describe "Task", ->
describe "initial state", ->
it "should set up queue state with additional information about local/remote", ->
task = new Task()
expect(task.queueState).toEqual({ isProcessing : false, localError : null, localComplete : false, remoteError : null, remoteAttempts : 0, remoteComplete : false })
describe "runLocal", ->
beforeEach ->
class APITestTask extends Task
performLocal: -> Promise.resolve()
performRemote: -> Promise.resolve(Task.Status.Finished)
@task = new APITestTask()
describe "when performLocal is not complete", ->
it "should run performLocal", ->
spyOn(@task, 'performLocal').andCallThrough()
@task.runLocal()
expect(@task.performLocal).toHaveBeenCalled()
describe "when performLocal rejects", ->
beforeEach ->
spyOn(@task, 'performLocal').andCallFake =>
Promise.reject(new Error("Oh no!"))
it "should save the error to the queueState", ->
@task.runLocal().catch(noop)
advanceClock()
expect(@task.performLocal).toHaveBeenCalled()
expect(@task.queueState.localComplete).toBe(false)
expect(@task.queueState.localError.message).toBe("Oh no!")
it "should reject with the error", ->
rejection = null
runs ->
@task.runLocal().catch (err) ->
rejection = err
waitsFor ->
rejection
runs ->
expect(rejection.message).toBe("Oh no!")
describe "when performLocal resolves", ->
beforeEach ->
spyOn(@task, 'performLocal').andCallFake -> Promise.resolve('Hooray')
it "should save that performLocal is complete", ->
@task.runLocal()
advanceClock()
expect(@task.queueState.localComplete).toBe(true)
it "should save that there was no performLocal error", ->
@task.runLocal()
advanceClock()
expect(@task.queueState.localError).toBe(null)
describe "runRemote", ->
beforeEach ->
@task.queueState.localComplete = true
it "should run performRemote", ->
spyOn(@task, 'performRemote').andCallThrough()
@task.runRemote()
advanceClock()
expect(@task.performRemote).toHaveBeenCalled()
describe "when performRemote resolves", ->
beforeEach ->
spyOn(@task, 'performRemote').andCallFake ->
Promise.resolve(Task.Status.Finished)
it "should save that performRemote is complete with no errors", ->
@task.runRemote()
advanceClock()
expect(@task.performRemote).toHaveBeenCalled()
expect(@task.queueState.remoteError).toBe(null)
expect(@task.queueState.remoteComplete).toBe(true)
it "should only allow the performRemote method to return a Task.Status", ->
result = null
err = null
class OKTask extends Task
performRemote: -> Promise.resolve(Task.Status.Retry)
@ok = new OKTask()
@ok.queueState.localComplete = true
@ok.runRemote().then (r) -> result = r
advanceClock()
expect(result).toBe(Task.Status.Retry)
class BadTask extends Task
performRemote: -> Promise.resolve('lalal')
@bad = new BadTask()
@bad.queueState.localComplete = true
@bad.runRemote().catch (e) -> err = e
advanceClock()
expect(err.message).toBe('performRemote returned lalal, which is not a Task.Status')
describe "when performRemote rejects", ->
beforeEach ->
@error = new APIError("Oh no!")
spyOn(@task, 'performRemote').andCallFake => Promise.reject(@error)
it "should save the error to the queueState", ->
@task.runRemote().catch(noop)
advanceClock()
expect(@task.queueState.remoteError).toBe(@error)
it "should increment the number of attempts", ->
runs ->
@task.runRemote().catch(noop)
waitsFor ->
@task.queueState.remoteAttempts == 1
runs ->
@task.runRemote().catch(noop)
waitsFor ->
@task.queueState.remoteAttempts == 2

View file

@ -97,6 +97,13 @@ ReactTestUtils.unmountAll = ->
React.unmountComponentAtNode(container)
ReactElementContainers = []
# Make Bluebird use setTimeout so that it hooks into our stubs, and you can
# advance promises using `advanceClock()`. To avoid breaking any specs that
# `dont` manually call advanceClock, call it automatically on the next tick.
Promise.setScheduler (fn) ->
setTimeout(fn, 0)
process.nextTick -> advanceClock(1)
beforeEach ->
Grim.clearDeprecations() if isCoreSpec
ComponentRegistry._clear()
@ -251,6 +258,8 @@ jasmine.restoreDeprecationsSnapshot = ->
jasmine.useRealClock = ->
jasmine.unspy(window, 'setTimeout')
jasmine.unspy(window, 'clearTimeout')
jasmine.unspy(window, 'setInterval')
jasmine.unspy(window, 'clearInterval')
jasmine.unspy(_._, 'now')
addCustomMatchers = (spec) ->

View file

@ -16,6 +16,7 @@ WindowEventHandler = require './window-event-handler'
StylesElement = require './styles-element'
Utils = require './flux/models/utils'
{APIError} = require './flux/errors'
# Essential: Atom global for dealing with packages, themes, menus, and the window.
#
@ -271,6 +272,11 @@ class Atom extends Model
error.stack = convertStackTrace(error.stack, sourceMapCache)
eventObject = {message: error.message, originalError: error}
# API Errors are a normal part of life and are logged to the API
# history panel. We ignore these errors and do not report them to Sentry.
if error instanceof APIError
return
if @inSpecMode()
console.error(error.stack)
else if @inDevMode()

View file

@ -88,6 +88,9 @@ class CommandRegistry
disposable.add @add(target, commandName, callback)
return disposable
if not callback
throw new Error("CommandRegistry:add called without a callback")
if typeof target is 'string'
@addSelectorBasedListener(target, commandName, callback)
else

View file

@ -13,9 +13,6 @@ class APIError extends Error
@name = "APIError"
@message = @body?.message ? @body ? @error?.toString?()
notifyConsole: ->
console.error("Edgehill API Error: #{@message}", @)
class OfflineError extends Error
constructor: ->

View file

@ -10,11 +10,67 @@ NylasLongConnection = require './nylas-long-connection'
{modelFromJSON, modelClassMap} = require './models/utils'
async = require 'async'
class NylasAPIOptimisticChangeTracker
constructor: ->
@_locks = {}
acceptRemoteChangesTo: (klass, id) ->
@_locks["#{klass.name}-#{id}"] is undefined
increment: (klass, id) ->
@_locks["#{klass.name}-#{id}"] ?= 0
@_locks["#{klass.name}-#{id}"] += 1
decrement: (klass, id) ->
@_locks["#{klass.name}-#{id}"] -= 1
if @_locks["#{klass.name}-#{id}"] is 0
delete @_locks["#{klass.name}-#{id}"]
print: ->
console.log("The following models are locked:")
console.log(@_locks)
class NylasAPIRequest
constructor: (@api, @options) ->
@options.method ?= 'GET'
@options.url ?= "#{@api.APIRoot}#{@options.path}" if @options.path
@options.json ?= true
@options.auth = {'user': @api.APIToken, 'pass': '', sendImmediately: true}
unless @options.method is 'GET' or @options.formData
@options.body ?= {}
@
run: ->
if atom.getLoadSettings().isSpec
return Promise.resolve()
if not @api.APIToken
return Promise.reject(new Error('Cannot make Nylas request without auth token.'))
new Promise (resolve, reject) =>
req = request @options, (error, response, body) =>
PriorityUICoordinator.settle.then =>
Actions.didMakeAPIRequest({request: @options, response: response})
if error or response.statusCode > 299
apiError = new APIError({error, response, body, requestOptions: @options})
@options.error?(apiError)
reject(apiError)
else
@options.success?(body)
resolve(body)
req.on 'abort', ->
reject(new APIError({statusCode: 0, body: 'Request Aborted'}))
@options.started?(req)
class NylasAPI
PermanentErrorCodes: [400, 404, 500]
constructor: ->
@_workers = []
@_optimisticChangeTracker = new NylasAPIOptimisticChangeTracker()
atom.config.onDidChange('env', @_onConfigChanged)
atom.config.onDidChange('nylas.token', @_onConfigChanged)
@ -107,47 +163,41 @@ class NylasAPI
# success: (body) -> callback gets passed the returned json object
# error: (apiError) -> the error callback gets passed an Nylas
# APIError object.
#
# Returns a Promise, which resolves or rejects in the success / error
# scenarios, respectively.
#
makeRequest: (options={}) ->
return if atom.getLoadSettings().isSpec
return console.log('Cannot make Nylas request without auth token.') unless @APIToken
options.method ?= 'GET'
options.url ?= "#{@APIRoot}#{options.path}" if options.path
options.json ?= true
options.auth = {'user': @APIToken, 'pass': '', sendImmediately: true}
if atom.getLoadSettings().isSpec
return Promise.resolve()
unless options.method is 'GET' or options.formData
options.body ?= {}
if not @APIToken
console.log('Cannot make Nylas request without auth token.')
return Promise.reject()
request options, (error, response, body) =>
PriorityUICoordinator.settle.then =>
Actions.didMakeAPIRequest({request: options, response: response})
if error? or response.statusCode > 299
if response and response.statusCode is 404 and options.returnsModel
@_handleModel404(options.url)
if response and response.statusCode is 401
@_handle401(options.url)
options.error?(new APIError({error, response, body}))
else
if options.json
if _.isString(body)
try
body = JSON.parse(body)
catch error
options.error?(new APIError({error, response, body}))
if options.returnsModel
@_handleModelResponse(body)
success = (body) =>
if options.beforeProcessing
body = options.beforeProcessing(body)
if options.returnsModel
@_handleModelResponse(body)
Promise.resolve(body)
if options.success
options.success(body)
error = (err) =>
if err.response
if err.response.statusCode is 404 and options.returnsModel
@_handleModel404(options.url)
if err.response.statusCode is 401
@_handle401(options.url)
Promise.reject(err)
req = new NylasAPIRequest(@, options)
req.run().then(success, error)
# If we make a request that `returnsModel` and we get a 404, we want to handle
# it intelligently and in a centralized way. This method identifies the object
# that could not be found and purges it from local cache.
#
# Handles:
#
# /namespace/<nid>/<collection>/<id>
# /namespace/<nid>/<collection>?thread_id=<id>
# Handles: /namespace/<nid>/<collection>/<id>
#
_handleModel404: (modelUrl) ->
url = require('url')
@ -164,8 +214,7 @@ class NylasAPI
DatabaseStore.find(klass, klassId).then (model) ->
DatabaseStore.unpersistModel(model) if model
_handle401: (url) ->
# Throw up a notification indicating that the user should log out and log back in
_handle401: (modelUrl) ->
Actions.postNotification
type: 'error'
tag: '401'
@ -267,14 +316,16 @@ class NylasAPI
Promise.resolve(true)
_shouldAcceptModelIfNewer: (klass, model = null) ->
new Promise (resolve, reject) ->
DatabaseStore = require './stores/database-store'
DatabaseStore.find(klass, model.id).then (existing) ->
if existing and existing.version >= model.version
resolve(false)
else
resolve(true)
_shouldAcceptModelIfNewer: (klass, model) ->
if @_optimisticChangeTracker.acceptRemoteChangesTo(klass, model.id) is false
return Promise.resolve(false)
DatabaseStore = require './stores/database-store'
DatabaseStore.find(klass, model.id).then (existing) ->
if existing and existing.version >= model.version
return Promise.resolve(false)
else
return Promise.resolve(true)
getThreads: (namespaceId, params = {}, requestOptions = {}) ->
requestSuccess = requestOptions.success
@ -298,4 +349,10 @@ class NylasAPI
qs: params
returnsModel: true
incrementOptimisticChangeCount: (klass, id) ->
@_optimisticChangeTracker.increment(klass, id)
decrementOptimisticChangeCount: (klass, id) ->
@_optimisticChangeTracker.decrement(klass, id)
module.exports = new NylasAPI()

View file

@ -80,27 +80,28 @@ class Download
else
finishedAction = action
@request = NylasAPI.makeRequest
NylasAPI.makeRequest
json: false
path: "/n/#{namespace}/files/#{@fileId}/download"
started: (req) =>
@request = req
progress(@request, {throtte: 250})
.on "progress", (progress) =>
@percent = progress.percent
@progressCallback()
.on "end", =>
# Wait for the file stream to finish writing before we resolve or reject
stream.end(streamEnded)
.pipe(stream)
success: =>
# At this point, the file stream has not finished writing to disk.
# Don't resolve yet, or the browser will load only part of the image.
onStreamEnded(resolve)
error: =>
onStreamEnded(reject)
progress(@request, {throtte: 250})
.on("progress", (progress) =>
@percent = progress.percent
@progressCallback()
)
.on("end", =>
# Wait for the file stream to finish writing before we resolve or reject
stream.end(streamEnded)
)
.pipe(stream)
abort: ->
@request?.abort()

View file

@ -85,7 +85,6 @@ MetadataStore = Reflux.createStore
else
DatabaseStore.persistModels(metadata).then(resolve).catch(reject)
error: (apiError) ->
apiError.notifyConsole()
reject(apiError)
_onDBChanged: (change) ->

View file

@ -11,7 +11,6 @@ Reflux = require 'reflux'
Actions = require '../actions'
{APIError,
OfflineError,
TimeoutError} = require '../errors'
if not atom.isMainWindow() and not atom.inSpecMode() then return
@ -79,27 +78,8 @@ class TaskQueue
@listenTo(Actions.clearDeveloperConsole, @clearCompleted)
# TODO
# @listenTo(OnlineStatusStore, @_onOnlineChange)
@_onlineStatus = true
@listenTo Actions.longPollConnected, =>
@_onlineStatus = true
@_update()
@listenTo Actions.longPollOffline, =>
@_onlineStatus = false
@_update()
_initializeTask: (task) =>
task.id ?= generateTempId()
task.queueState ?= {}
task.queueState =
localError: null
remoteError: null
isProcessing: false
remoteAttempts: 0
performedLocal: false
performedRemote: false
notifiedOffline: false
@_processQueue()
queue: =>
@_queue
@ -122,125 +102,97 @@ class TaskQueue
match = _.find @_queue, (task) -> task.constructor.name is type and _.isMatch(task, matching)
match ? null
enqueue: (task, {silent}={}) =>
enqueue: (task) =>
if not (task instanceof Task)
throw new Error("You must queue a `Task` object")
throw new Error("You must queue a `Task` instance")
if not task.id
throw new Error("Tasks must have an ID prior to being queued. Check that your Task constructor is calling `super`")
if not task.queueState
throw new Error("Tasks must have a queueState prior to being queued. Check that your Task constructor is calling `super`")
@_initializeTask(task)
@_dequeueObsoleteTasks(task)
@_queue.push(task)
@_update() if not silent
task.runLocal().then =>
@_queue.push(task)
@_updateSoon()
dequeue: (taskOrId={}, {silent}={}) =>
task = @_parseArgs(taskOrId)
dequeue: (taskOrId) =>
task = @_resolveTaskArgument(taskOrId)
if not task
throw new Error("Couldn't find task in queue to dequeue")
task.queueState.isProcessing = false
task.cleanup()
@_queue.splice(@_queue.indexOf(task), 1)
@_moveToCompleted(task)
@_update() if not silent
if task.queueState.isProcessing
# We cannot remove a task from the queue while it's running and pretend
# things have stopped. Ask the task to cancel. It's promise will resolve
# or reject, and then we'll end up back here.
task.cancel()
else
@_queue.splice(@_queue.indexOf(task), 1)
@_completed.push(task)
@_completed.shift() if @_completed.length > 1000
@_updateSoon()
dequeueAll: =>
for task in @_queue by -1
@dequeue(task, silent: true) if task?
@_update()
@dequeue(task)
dequeueMatching: ({type, matching}) =>
toDequeue = @findTask(type, matching)
task = @findTask(type, matching)
if not toDequeue
console.warn("Could not find task: #{type}", matching)
if not task
console.warn("Could not find matching task: #{type}", matching)
return
@dequeue(toDequeue, silent: true)
@_update()
@dequeue(task)
clearCompleted: =>
@_completed = []
@trigger()
# Helper Methods
_processQueue: =>
for task in @_queue by -1
@_processTask(task) if task?
continue if @_taskIsBlocked(task)
@_processTask(task)
_processTask: (task) =>
return if task.queueState.isProcessing
return if @_taskIsBlocked(task)
task.queueState.isProcessing = true
if task.queueState.performedLocal
@_performRemote(task)
else
task.performLocal().then =>
task.queueState.performedLocal = Date.now()
@_performRemote(task)
.catch @_onLocalError(task)
_performRemote: (task) =>
if @_isOnline()
task.queueState.remoteAttempts += 1
task.performRemote().then =>
task.queueState.performedRemote = Date.now()
@dequeue(task)
.catch @_onRemoteError(task)
else
@_notifyOffline(task)
_update: =>
@trigger()
@_saveQueueToDiskDebounced()
@_processQueue()
task.runRemote()
.finally =>
task.queueState.isProcessing = false
@trigger()
.then (status) =>
@dequeue(task) unless status is Task.Status.Retry
.catch (err) =>
console.warn("Task #{task.constructor.name} threw an error: #{err}.")
@dequeue(task)
_dequeueObsoleteTasks: (task) =>
for otherTask in @_queue by -1
obsolete = _.filter @_queue, (otherTask) =>
# Do not interrupt tasks which are currently processing
continue if otherTask.queueState.isProcessing
return false if otherTask.queueState.isProcessing
# Do not remove ourselves from the queue
continue if otherTask is task
return false if otherTask is task
# Dequeue tasks which our new task indicates it makes obsolete
if task.shouldDequeueOtherTask(otherTask)
@dequeue(otherTask, silent: true)
return task.shouldDequeueOtherTask(otherTask)
for otherTask in obsolete
@dequeue(otherTask)
_taskIsBlocked: (task) =>
_.any @_queue, (otherTask) ->
task.shouldWaitForTask(otherTask) and task isnt otherTask
_notifyOffline: (task) =>
task.queueState.isProcessing = false
if not task.queueState.notifiedOffline
task.queueState.notifiedOffline = true
task.onError(new OfflineError)
_onLocalError: (task) => (error) =>
task.queueState.isProcessing = false
task.queueState.localError = error
task.onError(error)
@dequeue(task)
_onRemoteError: (task) => (apiError) =>
task.queueState.isProcessing = false
task.queueState.notifiedOffline = false
task.queueState.remoteError = apiError
task.onError(apiError)
@dequeue(task)
_isOnline: => @_onlineStatus # TODO # OnlineStatusStore.isOnline()
_onOnlineChange: => @_processQueue()
_parseArgs: (taskOrId) =>
if taskOrId instanceof Task
task = _.find @_queue, (task) -> task is taskOrId
_resolveTaskArgument: (taskOrId) =>
if not taskOrId
return null
else if taskOrId instanceof Task
return _.find @_queue, (task) -> task is taskOrId
else
task = _.findWhere(@_queue, id: taskOrId)
return task
_moveToCompleted: (task) =>
@_completed.push(task)
@_completed.shift() if @_completed.length > 1000
return _.findWhere(@_queue, id: taskOrId)
_restoreQueueFromDisk: =>
{modelReviver} = require '../models/utils'
@ -250,25 +202,30 @@ class TaskQueue
# We need to set the processing bit back to false so it gets
# re-retried upon inflation
for task in queue
if task.queueState?.isProcessing
task.queueState ?= {}
task.queueState.isProcessing = false
task.queueState ?= {}
task.queueState.isProcessing = false
@_queue = queue
catch e
if not atom.inSpecMode()
console.log("Queue deserialization failed with error: #{e.toString()}")
# It's very important that we debounce saving here. When the user bulk-archives
# items, they can easily process 1000 tasks at the same moment. We can't try to
# save 1000 times! (Do not remove debounce without a plan!)
_saveQueueToDisk: =>
queueFile = path.join(atom.getConfigDirPath(), 'task-queue.json')
queueJSON = JSON.stringify((@_queue ? []))
fs.writeFile(queueFile, queueJSON)
# It's very important that we debounce saving here. When the user bulk-archives
# items, they can easily process 1000 tasks at the same moment. We can't try to
# save 1000 times! (Do not remove debounce without a plan!)
@_saveDebounced ?= _.debounce =>
queueFile = path.join(atom.getConfigDirPath(), 'task-queue.json')
queueJSON = JSON.stringify((@_queue ? []))
fs.writeFile(queueFile, queueJSON)
, 150
@_saveDebounced()
_saveQueueToDiskDebounced: =>
@__saveQueueToDiskDebounced ?= _.debounce(@_saveQueueToDisk, 150)
@__saveQueueToDiskDebounced()
_updateSoon: =>
@_updateSoonThrottled ?= _.throttle =>
@_processQueue()
@_saveQueueToDisk()
@trigger()
, 10, {leading: false}
@_updateSoonThrottled()
module.exports = new TaskQueue()

View file

@ -0,0 +1,47 @@
_ = require 'underscore'
{Listener, Publisher} = require '../modules/reflux-coffee'
CoffeeHelpers = require '../coffee-helpers'
Task = require "../tasks/task"
Actions = require '../actions'
class UndoRedoStore
@include: CoffeeHelpers.includeModule
@include Publisher
@include Listener
constructor: ->
@_undo = []
@_redo = []
@listenTo(Actions.queueTask, @_onTaskQueued)
atom.commands.add('body', {'core:undo': => @undo() })
atom.commands.add('body', {'core:redo': => @redo() })
_onTaskQueued: (task) =>
if task.canBeUndone() and not task.isUndo()
@_redo = []
@_undo.push(task)
undo: =>
topTask = @_undo.pop()
return unless topTask
Actions.queueTask(topTask.createUndoTask())
@_redo.push(topTask.createIdenticalTask())
redo: =>
redoTask = @_redo.pop()
return unless redoTask
Actions.queueTask(redoTask)
print: ->
console.log("Undo Stack")
console.log(@_undo)
console.log("Redo Stack")
console.log(@_redo)
module.exports = new UndoRedoStore()

View file

@ -1,6 +1,8 @@
Task = require './task'
{APIError} = require '../errors'
NylasAPI = require '../nylas-api'
DatabaseStore = require '../stores/database-store'
NamespaceStore = require '../stores/namespace-store'
Actions = require '../actions'
Tag = require '../models/tag'
Thread = require '../models/thread'
@ -10,71 +12,114 @@ async = require 'async'
module.exports =
class AddRemoveTagsTask extends Task
constructor: (@thread, @tagIdsToAdd = [], @tagIdsToRemove = []) ->
constructor: (@threadsOrIds, @tagIdsToAdd = [], @tagIdsToRemove = []) ->
# For backwards compatibility, allow someone to make the task with a single thread
# object or it's ID
if @threadsOrIds instanceof Thread or _.isString(@threadsOrIds)
@threadsOrIds = [@threadsOrIds]
super
label: ->
"Applying tags..."
performLocal: (versionIncrement = 1) ->
new Promise (resolve, reject) =>
if not @thread or not @thread instanceof Thread
return reject(new Error("Attempt to call AddRemoveTagsTask.performLocal without Thread"))
threadIds: ->
@threadsOrIds.map (t) -> if t instanceof Thread then t.id else t
# collect all of the models we need.
needed = {}
for id in @tagIdsToAdd
if id in ['archive', 'unread', 'inbox', 'unseen']
needed["tag-#{id}"] = new Tag(id: id, name: id)
else
needed["tag-#{id}"] = DatabaseStore.find(Tag, id)
# Undo & Redo support
Promise.props(needed).then (objs) =>
# Always apply our changes to a new copy of the thread.
# In some scenarios it may actually be frozen
thread = new Thread(@thread)
canBeUndone: ->
true
@namespaceId = thread.namespaceId
isUndo: ->
@_isUndoTask is true
# increment the thread version number
thread.version += versionIncrement
createUndoTask: ->
task = new AddRemoveTagsTask(@threadIds(), @tagIdsToRemove, @tagIdsToAdd)
task._isUndoTask = true
task
# filter the tags array to exclude tags we're removing and tags we're adding.
# Removing before adding is a quick way to make sure they're only in the set
# once. (super important)
thread.tags = _.filter thread.tags, (tag) =>
@tagIdsToRemove.indexOf(tag.id) is -1 and @tagIdsToAdd.indexOf(tag.id) is -1
# Core Behavior
# add tags in the add list
for id in @tagIdsToAdd
tag = objs["tag-#{id}"]
thread.tags.push(tag) if tag
# To ensure that complex offline actions are synced correctly, tag additions
# and removals need to be applied in order. (For example, star many threads,
# and then unstar one.)
shouldWaitForTask: (other) ->
# Only wait on other tasks that are older and also involve the same threads
return unless other instanceof AddRemoveTagsTask
otherOlder = other.creationDate < @creationDate
otherSameThreads = _.intersection(other.threadIds(), @threadIds()).length > 0
return otherOlder and otherSameThreads
DatabaseStore.persistModel(thread).then(resolve)
performLocal: ({reverting} = {}) ->
if not @threadsOrIds or not @threadsOrIds instanceof Array
return Promise.reject(new Error("Attempt to call AddRemoveTagsTask.performLocal without threads"))
# collect all of the tag models we need.
needed = {}
for id in @tagIdsToAdd
if id in ['archive', 'unread', 'inbox', 'unseen']
needed["tag-#{id}"] = new Tag(id: id, name: id)
else
needed["tag-#{id}"] = DatabaseStore.find(Tag, id)
Promise.props(needed).then (objs) =>
promises = @threadsOrIds.map (item) =>
getThread = Promise.resolve(item)
if _.isString(item)
getThread = DatabaseStore.find(Thread, item)
getThread.then (thread) =>
# Always apply our changes to a new copy of the thread.
# In some scenarios it may actually be frozen
thread = new Thread(thread)
# Mark that we are optimistically changing this model. This will prevent
# inbound delta syncs from changing it back to it's old state. Only the
# operation that changes `optimisticChangeCount` back to zero will
# apply the server's version of the model to our cache.
if reverting is true
NylasAPI.decrementOptimisticChangeCount(Thread, thread.id)
else
NylasAPI.incrementOptimisticChangeCount(Thread, thread.id)
# filter the tags array to exclude tags we're removing and tags we're adding.
# Removing before adding is a quick way to make sure they're only in the set
# once. (super important)
thread.tags = _.filter thread.tags, (tag) =>
@tagIdsToRemove.indexOf(tag.id) is -1 and @tagIdsToAdd.indexOf(tag.id) is -1
# add tags in the add list
for id in @tagIdsToAdd
tag = objs["tag-#{id}"]
thread.tags.push(tag) if tag
return DatabaseStore.persistModel(thread)
Promise.all(promises)
performRemote: ->
new Promise (resolve, reject) =>
# queue the operation to the server
nsid = NamespaceStore.current()?.id
promises = @threadIds().map (id) =>
NylasAPI.makeRequest
path: "/n/#{@namespaceId}/threads/#{@thread.id}"
path: "/n/#{nsid}/threads/#{id}"
method: 'PUT'
body:
add_tags: @tagIdsToAdd,
remove_tags: @tagIdsToRemove
returnsModel: true
success: resolve
error: reject
beforeProcessing: (body) ->
NylasAPI.decrementOptimisticChangeCount(Thread, id)
body
onAPIError: (apiError) ->
if apiError.response.statusCode is 404
# Do nothing - NylasAPI will destroy the object.
else
@_rollbackLocal()
Promise.resolve()
Promise.all(promises)
.then =>
return Promise.resolve(Task.Status.Finished)
_rollbackLocal: ->
# Run performLocal backwards to undo the tag changes
a = @tagIdsToAdd
@tagIdsToAdd = @tagIdsToRemove
@tagIdsToRemove = a
@performLocal(-1)
.catch APIError, (err) =>
if err.statusCode in NylasAPI.PermanentErrorCodes
# Run performLocal backwards to undo the tag changes
[@tagIdsToAdd, @tagIdsToRemove] = [@tagIdsToRemove, @tagIdsToAdd]
@performLocal({reverting: true}).then =>
return Promise.resolve(Task.Status.Finished)
else
return Promise.resolve(Task.Status.Retry)

View file

@ -1,5 +1,6 @@
_ = require 'underscore'
Task = require './task'
{APIError} = require '../errors'
Actions = require '../actions'
Metadata = require '../models/metadata'
EdgehillAPI = require '../edgehill-api'
@ -29,37 +30,22 @@ class CreateMetadataTask extends Task
@metadatum = new Metadata({@type, @publicId, @key, @value})
return DatabaseStore.persistModel(@metadatum)
performRemote: -> new Promise (resolve, reject) =>
EdgehillAPI.request
method: "POST"
path: "/metadata/#{NamespaceStore.current().id}/#{@type}/#{@publicId}"
body:
key: @key
value: @value
success: (args...) =>
Actions.metadataCreated @type, @metadatum
resolve(args...)
error: (apiError) ->
apiError.notifyConsole()
reject(apiError)
onAPIError: (apiError) ->
Actions.metadataError _.extend @_baseErrorData(),
errorType: "APIError"
error: apiError
Promise.resolve()
onOtherError: (otherError) ->
Actions.metadataError _.extend @_baseErrorData(),
errorType: "OtherError"
error: otherError
Promise.resolve()
onTimeoutError: (timeoutError) ->
Actions.metadataError _.extend @_baseErrorData(),
errorType: "TimeoutError"
error: timeoutError
Promise.resolve()
performRemote: ->
new Promise (resolve, reject) =>
EdgehillAPI.request
method: "POST"
path: "/metadata/#{NamespaceStore.current().id}/#{@type}/#{@publicId}"
body:
key: @key
value: @value
success: =>
Actions.metadataCreated @type, @metadatum
resolve(Task.Status.Finished)
error: (apiError) =>
Actions.metadataError _.extend @_baseErrorData(),
errorType: "APIError"
error: apiError
reject(apiError)
_baseErrorData: ->
action: "create"
@ -68,6 +54,3 @@ class CreateMetadataTask extends Task
publicId: @publicId
key: @key
value: @value
onOfflineError: (offlineError) ->
Promise.resolve()

View file

@ -1,4 +1,5 @@
Task = require './task'
{APIError} = require '../errors'
Message = require '../models/message'
DatabaseStore = require '../stores/database-store'
Actions = require '../actions'
@ -14,55 +15,47 @@ class DestroyDraftTask extends Task
shouldDequeueOtherTask: (other) ->
(other instanceof SyncbackDraftTask and other.draftLocalId is @draftLocalId) or
(other instanceof SendDraftTask and other.draftLocalId is @draftLocalId) or
(other instanceof FileUploadTask and other.draftLocalId is @draftLocalId)
(other instanceof FileUploadTask and other.messageLocalId is @draftLocalId)
shouldWaitForTask: (other) ->
(other instanceof SyncbackDraftTask and other.draftLocalId is @draftLocalId)
performLocal: ->
new Promise (resolve, reject) =>
unless @draftLocalId?
return reject(new Error("Attempt to call DestroyDraftTask.performLocal without @draftLocalId"))
unless @draftLocalId?
return Promise.reject(new Error("Attempt to call DestroyDraftTask.performLocal without @draftLocalId"))
DatabaseStore.findByLocalId(Message, @draftLocalId).then (draft) =>
return resolve() unless draft
@draft = draft
DatabaseStore.unpersistModel(draft).then(resolve)
DatabaseStore.findByLocalId(Message, @draftLocalId).then (draft) =>
return resolve() unless draft
@draft = draft
DatabaseStore.unpersistModel(draft)
performRemote: ->
new Promise (resolve, reject) =>
# We don't need to do anything if we weren't able to find the draft
# when we performed locally, or if the draft has never been synced to
# the server (id is still self-assigned)
return resolve() unless @draft
return resolve() unless @draft.isSaved()
# We don't need to do anything if we weren't able to find the draft
# when we performed locally, or if the draft has never been synced to
# the server (id is still self-assigned)
return Promise.resolve() unless @draft
return Promise.resolve() unless @draft.isSaved()
atom.inbox.makeRequest
path: "/n/#{@draft.namespaceId}/drafts/#{@draft.id}"
method: "DELETE"
body:
version: @draft.version
returnsModel: false
success: resolve
error: reject
atom.inbox.makeRequest
path: "/n/#{@draft.namespaceId}/drafts/#{@draft.id}"
method: "DELETE"
body:
version: @draft.version
returnsModel: false
onAPIError: (apiError) ->
inboxMsg = apiError.body?.message ? ""
if apiError.statusCode is 404
# Draft has already been deleted, this is not really an error
return true
else if inboxMsg.indexOf("is not a draft") >= 0
# Draft has been sent, and can't be deleted. Not much we can
# do but finish
return true
else
@_rollbackLocal()
recoverFromRemoteAPIError: (err) ->
inboxMsg = err.body?.message ? ""
onOtherError: -> Promise.resolve()
onTimeoutError: -> Promise.resolve()
onOfflineError: -> Promise.resolve()
# Draft has already been deleted, this is not really an error
if err.statusCode is 404
return Promise.resolve(Task.Status.Finished)
_rollbackLocal: (msg) ->
msg ?= "Unable to delete this draft. Restoring..."
Actions.postNotification({message: msg, type: "error"})
DatabaseStore.persistModel(@draft) if @draft?
if err.statusCode in NylasAPI.PermanentErrorCodes
Actions.postNotification({message: "Unable to delete this draft. Restoring...", type: "error"})
return DatabaseStore.persistModel(@draft)
# Draft has been sent, and can't be deleted. Not much we can do but finish
if inboxMsg.indexOf("is not a draft") >= 0
return Promise.resolve(Task.Status.Finished)
Promise.resolve(Task.Status.Retry)

View file

@ -1,5 +1,6 @@
_ = require 'underscore'
Task = require './task'
{APIError} = require '../errors'
Actions = require '../actions'
Metadata = require '../models/metadata'
EdgehillAPI = require '../edgehill-api'
@ -54,37 +55,18 @@ class DestroyMetadataTask extends Task
method: "DELETE"
path: "/metadata/#{NamespaceStore.current().id}/#{@type}/#{@publicId}"
body: body
success: (args...) =>
success: =>
Actions.metadataDestroyed(@type)
resolve(args...)
error: (apiError) ->
apiError.notifyConsole()
resolve(Task.Status.Finished)
error: (apiError) =>
Actions.metadataError _.extend @_baseErrorData(),
errorType: "APIError"
error: apiError
reject(apiError)
onAPIError: (apiError) ->
Actions.metadataError _.extend @_baseErrorData(),
errorType: "APIError"
error: apiError
Promise.resolve()
onOtherError: (otherError) ->
Actions.metadataError _.extend @_baseErrorData(),
errorType: "OtherError"
error: otherError
Promise.resolve()
onTimeoutError: (timeoutError) ->
Actions.metadataError _.extend @_baseErrorData(),
errorType: "TimeoutError"
error: timeoutError
Promise.resolve()
_baseErrorData: ->
action: "destroy"
className: @constructor.name
type: @type
publicId: @publicId
key: @key
onOfflineError: (offlineError) ->
Promise.resolve()

View file

@ -2,6 +2,7 @@ fs = require 'fs'
_ = require 'underscore'
pathUtils = require 'path'
Task = require './task'
{APIError} = require '../errors'
File = require '../models/file'
Message = require '../models/message'
Actions = require '../actions'
@ -15,14 +16,16 @@ idGen = 2
class FileUploadTask extends Task
# Necessary so that tasks always get the same ID during specs
@idGen: -> idGen
constructor: (@filePath, @messageLocalId) ->
super
@_startedUploadingAt = Date.now()
@progress = null # The progress checking timer.
@_uploadId = FileUploadTask.idGen()
idGen += 1
@progress = null # The progress checking timer.
performLocal: ->
return Promise.reject(new Error("Must pass an absolute path to upload")) unless @filePath?.length
@ -31,75 +34,55 @@ class FileUploadTask extends Task
Promise.resolve()
performRemote: ->
new Promise (resolve, reject) =>
Actions.uploadStateChanged @_uploadData("started")
@req = NylasAPI.makeRequest
path: "/n/#{@_namespaceId()}/files"
method: "POST"
json: false
formData: @_formData()
error: reject
success: (rawResponseString) =>
# The Nylas API returns the file json wrapped in an array.
#
# Since we requested `json:false` the response will come back as
# a raw string.
try
json = JSON.parse(rawResponseString)
file = (new File).fromJSON(json[0])
catch error
reject(error)
@_onRemoteSuccess(file, resolve, reject)
Actions.uploadStateChanged @_uploadData("started")
started = (req) =>
@req = req
@progress = setInterval =>
Actions.uploadStateChanged(@_uploadData("progress"))
, 250
cleanup: ->
super
# If the request is still in progress, notify observers that
# we've failed.
if @req
@req.abort()
cleanup = =>
clearInterval(@progress)
Actions.uploadStateChanged(@_uploadData("aborted"))
setTimeout =>
# To see the aborted state for a little bit
Actions.fileAborted(@_uploadData("aborted"))
, 1000
@req = null
onAPIError: (apiError) ->
@_rollbackLocal()
NylasAPI.makeRequest
path: "/n/#{@_namespaceId()}/files"
method: "POST"
json: false
formData: @_formData()
started: started
onOtherError: (otherError) ->
@_rollbackLocal()
.finally(cleanup)
.then(@performRemoteParseFile)
.then(@performRemoteAttachFile)
.then (file) =>
Actions.uploadStateChanged @_uploadData("completed")
Actions.fileUploaded(file: file, uploadData: @_uploadData("completed"))
return Promise.resolve(Task.Status.Finished)
onTimeoutError: ->
# Do nothing. It could take a while.
Promise.resolve()
.catch APIError, (err) =>
Actions.uploadStateChanged(@_uploadData("failed"))
if err.statusCode in NylasAPI.PermanentErrorCodes
msg = "There was a problem uploading this file. Please try again later."
Actions.postNotification({message: msg, type: "error"})
return Promise.reject(err)
else
return Promise.resolve(Task.Status.Retry)
onOfflineError: (offlineError) ->
msg = "You can't upload a file while you're offline."
@_rollbackLocal(msg)
_rollbackLocal: (msg) ->
clearInterval(@progress)
@req = null
msg ?= "There was a problem uploading this file. Please try again later."
Actions.postNotification({message: msg, type: "error"})
Actions.uploadStateChanged @_uploadData("failed")
_onRemoteSuccess: (file, resolve, reject) =>
clearInterval(@progress)
@req = null
performRemoteParseFile: (rawResponseString) =>
# The Nylas API returns the file json wrapped in an array.
# Since we requested `json:false` the response will come back as
# a raw string.
json = JSON.parse(rawResponseString)
file = (new File).fromJSON(json[0])
Promise.resolve(file)
performRemoteAttachFile: (file) =>
# The minute we know what file is associated with the upload, we need
# to fire an Action to notify a popout window's FileUploadStore that
# these two objects are linked. We unfortunately can't wait until
# `_attacheFileToDraft` resolves, because that will resolve after the
# `_attachFileToDraft` resolves, because that will resolve after the
# DB transaction is completed AND all of the callbacks have fired.
# Unfortunately in the callback chain is a render method which means
# that the upload will be left on the page for a split second before
@ -110,19 +93,28 @@ class FileUploadTask extends Task
# listing.
Actions.linkFileToUpload(file: file, uploadData: @_uploadData("completed"))
@_attachFileToDraft(file).then =>
Actions.uploadStateChanged @_uploadData("completed")
Actions.fileUploaded(file: file, uploadData: @_uploadData("completed"))
resolve()
.catch(reject)
_attachFileToDraft: (file) ->
DraftStore = require '../stores/draft-store'
DraftStore.sessionForLocalId(@messageLocalId).then (session) =>
files = _.clone(session.draft().files) ? []
files.push(file)
session.changes.add({files})
return session.changes.commit()
session.changes.commit().then ->
Promise.resolve(file)
cancel: ->
super
# Note: When you call cancel, we stop the request, which causes
# NylasAPI.makeRequest to reject with an error.
return unless @req
@req.abort()
clearInterval(@progress)
# To see the aborted state for a little bit
Actions.uploadStateChanged(@_uploadData("aborted"))
setTimeout(( => Actions.fileAborted(@_uploadData("aborted"))), 1000)
# Helper Methods
_formData: ->
file: # Must be named `file` as per the Nylas API spec
@ -157,6 +149,7 @@ class FileUploadTask extends Task
# http://stackoverflow.com/questions/12098713/upload-progress-request
@req?.req?.connection?._bytesDispatched ? 0
_namespaceId: -> NamespaceStore.current()?.id
_namespaceId: ->
NamespaceStore.current()?.id
module.exports = FileUploadTask

View file

@ -1,4 +1,5 @@
Task = require './task'
{APIError} = require '../errors'
DatabaseStore = require '../stores/database-store'
Actions = require '../actions'
NylasAPI = require '../nylas-api'
@ -11,37 +12,28 @@ class MarkMessageReadTask extends Task
super
performLocal: ->
new Promise (resolve, reject) =>
# update the flag on the message
@_previousUnreadState = @message.unread
@message.unread = false
# update the flag on the message
@_previousUnreadState = @message.unread
@message.unread = false
# dispatch an action to persist it
DatabaseStore.persistModel(@message).then(resolve).catch(reject)
# dispatch an action to persist it
DatabaseStore.persistModel(@message)
performRemote: ->
new Promise (resolve, reject) =>
# queue the operation to the server
NylasAPI.makeRequest {
path: "/n/#{@message.namespaceId}/messages/#{@message.id}"
method: 'PUT'
body: {
unread: false
}
returnsModel: true
success: resolve
error: reject
}
# We don't really care if this fails.
onAPIError: -> Promise.resolve()
onOtherError: -> Promise.resolve()
onTimeoutError: -> Promise.resolve()
onOfflineError: -> Promise.resolve()
_rollbackLocal: ->
new Promise (resolve, reject) =>
unless @_previousUnreadState?
reject(new Error("Cannot call rollbackLocal without previous call to performLocal"))
@message.unread = @_previousUnreadState
DatabaseStore.persistModel(@message).then(resolve).catch(reject)
# queue the operation to the server
NylasAPI.makeRequest
path: "/n/#{@message.namespaceId}/messages/#{@message.id}"
method: 'PUT'
body:
unread: false
returnsModel: true
.then =>
return Promise.resolve(Task.Status.Finished)
.catch APIError, (err) =>
if err.statusCode in NylasAPI.PermanentErrorCodes
# Run performLocal backwards to undo the tag changes
@message.unread = @_previousUnreadState
DatabaseStore.persistModel(@message).then =>
return Promise.resolve(Task.Status.Finished)
else
return Promise.resolve(Task.Status.Retry)

View file

@ -1,4 +1,5 @@
Task = require './task'
{APIError} = require '../errors'
DatabaseStore = require '../stores/database-store'
AddRemoveTagsTask = require './add-remove-tags'
Message = require '../models/message'

View file

@ -3,6 +3,7 @@
Actions = require '../actions'
DatabaseStore = require '../stores/database-store'
Message = require '../models/message'
{APIError} = require '../errors'
Task = require './task'
TaskQueue = require '../stores/task-queue'
SyncbackDraftTask = require './syncback-draft'
@ -50,61 +51,38 @@ class SendDraftTask extends Task
# Pass joined:true so the draft body is included
body = draft.toJSON(joined: true)
return @_performRemoteSend(body)
return @_send(body)
# Returns a promise which resolves when the draft is sent. There are several
# failure cases where this method may call itself, stripping bad fields out of
# the body. This promise only rejects when these changes have been tried.
_performRemoteSend: (body) ->
@_performRemoteAPIRequest(body)
_send: (body) ->
NylasAPI.makeRequest
path: "/n/#{@draft.namespaceId}/send"
method: 'POST'
body: body
returnsModel: true
.then (json) =>
message = (new Message).fromJSON(json)
atom.playSound('mail_sent.ogg')
Actions.sendDraftSuccess
draftLocalId: @draftLocalId
newMessage: message
return DatabaseStore.unpersistModel(@draft)
DatabaseStore.unpersistModel(@draft).then =>
return Promise.resolve(Task.Status.Finished)
.catch (err) =>
.catch APIError, (err) =>
if err.message?.indexOf('Invalid message public id') is 0
body.reply_to_message_id = null
return @_performRemoteSend(body)
return @_send(body)
else if err.message?.indexOf('Invalid thread') is 0
body.thread_id = null
body.reply_to_message_id = null
return @_performRemoteSend(body)
return @_send(body)
else if err.statusCode in NylasAPI.PermanentErrorCodes
msg = err.message ? "Your draft could not be sent."
Actions.composePopoutDraft(@draftLocalId, {errorMessage: msg})
return Promise.resolve(Task.Status.Finished)
else
return Promise.reject(err)
_performRemoteAPIRequest: (body) ->
new Promise (resolve, reject) =>
NylasAPI.makeRequest
path: "/n/#{@draft.namespaceId}/send"
method: 'POST'
body: body
returnsModel: true
success: resolve
error: reject
onAPIError: (apiError) ->
msg = apiError.message ? "Our server is having problems. Your message has not been sent."
@_notifyError(msg)
onOtherError: ->
msg = "We had a serious issue while sending. Your message has not been sent."
@_notifyError(msg)
onTimeoutError: ->
msg = "The server is taking an abnormally long time to respond. Your message has not been sent."
@_notifyError(msg)
onOfflineError: ->
msg = "You are offline. Your message has NOT been sent. Please send your message when you come back online."
@_notifyError(msg)
# For sending draft, we don't send when we come back online.
Actions.dequeueTask(@)
_notifyError: (msg) ->
@notifyErrorMessage(msg)
if @fromPopout
Actions.composePopoutDraft(@draftLocalId, {errorMessage: msg})
return Promise.resolve(Task.Status.Retry)

View file

@ -6,6 +6,7 @@ DatabaseStore = require '../stores/database-store'
NylasAPI = require '../nylas-api'
Task = require './task'
{APIError} = require '../errors'
Message = require '../models/message'
FileUploadTask = require './file-upload-task'
@ -17,7 +18,6 @@ class SyncbackDraftTask extends Task
constructor: (@draftLocalId) ->
super
@_saveAttempts = 0
shouldDequeueOtherTask: (other) ->
other instanceof SyncbackDraftTask and other.draftLocalId is @draftLocalId and other.creationDate < @creationDate
@ -29,99 +29,55 @@ class SyncbackDraftTask extends Task
# SyncbackDraftTask does not do anything locally. You should persist your changes
# to the local database directly or using a DraftStoreProxy, and then queue a
# SyncbackDraftTask to send those changes to the server.
console.log('in performLocal')
if not @draftLocalId?
if not @draftLocalId
errMsg = "Attempt to call FileUploadTask.performLocal without @draftLocalId"
Promise.reject(new Error(errMsg))
else
Promise.resolve()
return Promise.reject(new Error(errMsg))
Promise.resolve()
performRemote: ->
new Promise (resolve, reject) =>
DatabaseStore.findByLocalId(Message, @draftLocalId).then (draft) =>
# The draft may have been deleted by another task. Nothing we can do.
return resolve() unless draft
if draft.isSaved()
path = "/n/#{draft.namespaceId}/drafts/#{draft.id}"
method = 'PUT'
else
path = "/n/#{draft.namespaceId}/drafts"
method = 'POST'
body = draft.toJSON()
delete body['from']
initialId = draft.id
@_saveAttempts += 1
NylasAPI.makeRequest
path: path
method: method
body: body
returnsModel: false
success: (json) =>
if json.id != initialId
newDraft = (new Message).fromJSON(json)
DatabaseStore.swapModel(oldModel: draft, newModel: newDraft, localId: @draftLocalId).then(resolve)
else
DatabaseStore.persistModel(draft).then(resolve)
error: reject
onAPIError: (apiError) ->
# If we get a 404 from the server this might mean that the
# draft has been deleted from underneath us. We should retry
# again. Before we can retry we need to set the ID to a
# localID so that the next time this fires the model will
# trigger a POST instead of a PUT
if apiError.statusCode is 404
msg = "It looks like the draft you're working on got deleted from underneath you. We're creating a new draft and saving your work."
@_retrySaveAsNewDraft(msg)
else
if @_saveAttempts <= 1
msg = "We had a problem with the server. We're going to try and save your draft again."
@_retrySaveToExistingDraft(msg)
else
msg = "We're continuing to have issues saving your draft. It will be saved locally, but is failing to save on the server."
@notifyErrorMessage(msg)
onOtherError: ->
msg = "We had a serious issue trying to save your draft. Please copy the text out of the composer and try again later."
@notifyErrorMessage(msg)
onTimeoutError: ->
if @_saveAttempts <= 1
msg = "The server is taking an abnormally long time to respond. We're going to try and save your changes again."
@_retrySaveToExistingDraft(msg)
else
msg = "We're continuing to have issues saving your draft. It will be saved locally, but is failing to save on the server."
@notifyErrorMessage(msg)
onOfflineError: ->
msg = "WARNING: You are offline. Your edits are being saved locally. They will save to the server when you come back online"
@notifyErrorMessage(msg)
_retrySaveAsNewDraft: (msg) ->
TaskQueue = require '../stores/task-queue'
DatabaseStore.findByLocalId(Message, @draftLocalId).then (draft) =>
if not draft?
console.log "Couldn't find draft!", @draftLocalId
@_onOtherError()
# The draft may have been deleted by another task. Nothing we can do.
return Promise.resolve() unless draft
if draft.isSaved()
path = "/n/#{draft.namespaceId}/drafts/#{draft.id}"
method = 'PUT'
else
path = "/n/#{draft.namespaceId}/drafts"
method = 'POST'
body = draft.toJSON()
delete body['from']
initialId = draft.id
NylasAPI.makeRequest
path: path
method: method
body: body
returnsModel: false
.then (json) =>
if json.id != initialId
newDraft = (new Message).fromJSON(json)
DatabaseStore.swapModel(oldModel: draft, newModel: newDraft, localId: @draftLocalId)
else
DatabaseStore.persistModel(draft)
.catch APIError, (err) =>
if err.statusCode in NylasAPI.PermanentErrorCodes
if err.requestOptions.method is 'PUT'
return @disassociateFromRemoteID().then =>
Promise.resolve(Task.Status.Retry)
else
return Promise.resolve(Task.Status.Finished)
else
return Promise.resolve(Task.Status.Retry)
disassociateFromRemoteID: ->
DatabaseStore.findByLocalId(Message, @draftLocalId).then (draft) =>
return Promise.resolve() unless draft
newJSON = _.clone(draft.toJSON())
newJSON.id = generateTempId() unless isTempId(draft.id)
newDraft = (new Message).fromJSON(newJSON)
DatabaseStore.swapModel(oldModel: draft, newModel: newDraft, localId: @draftLocalId).then =>
TaskQueue.enqueue @
@notifyErrorMessage(msg)
_retrySaveToExistingDraft: (msg) ->
TaskQueue = require '../stores/task-queue'
DatabaseStore.findByLocalId(Message, @draftLocalId).then (draft) =>
if not draft?
console.log "Couldn't find draft!", @draftLocalId
@_onOtherError()
TaskQueue.enqueue @
@notifyErrorMessage(msg)
DatabaseStore.swapModel(oldModel: draft, newModel: newDraft, localId: @draftLocalId)

View file

@ -5,93 +5,137 @@ Actions = require '../actions'
OfflineError,
TimeoutError} = require '../errors'
# Tasks represent individual changes to the datastore that
TaskStatus =
Finished: 'finished'
Retry: 'retry'
# Public: Tasks represent individual changes to the datastore that
# alter the local cache and need to be synced back to the server.
# Tasks should optimistically modify local models and trigger
# model update actions, and also make API calls which trigger
# further model updates once they're complete.
# Subclasses implement `performLocal` and `performRemote`.
#
# `performLocal` can be called directly by whoever has access to the
# class. It can only be called once. If it is not called directly,
# `performLocal` will be invoked as soon as the task is queued. Since
# performLocal is frequently asynchronous, it is sometimes necessary to
# wait for it to finish.
# To create a new task, subclass Task and implement the following methods:
#
# - performLocal:
# Return a {Promise} that does work immediately. Must resolve or the task
# will be thrown out. Generally, you should optimistically update
# the local cache here.
#
# - performRemote:
# Do work that requires dependencies to have resolved and may need to be
# tried multiple times to succeed in case of network issues.
#
# performRemote must return a {Promise}, and it should always resolve with
# Task.Status.Finished or Task.Status.Retry. Rejections are considered
# exception cases and are logged to our server.
#
# Returning Task.Status.Retry will cause the TaskQueue to leave your task
# on the queue and run it again later. You should only return Task.Status.Retry
# if your task encountered a transient error (for example, a `0` but not a `400`).
#
# - shouldWaitForTask:
# Tasks may be arbitrarily dependent on other tasks. To ensure that
# performRemote is called at the right time, subclasses should implement
# `shouldWaitForTask(other)`. For example, the `SendDraft` task is dependent
# on the draft's files' `UploadFile` tasks completing.
#
# `performRemote` may be called after a delay, depending on internet
# connectivity and dependency resolution.
# Tasks may be arbitrarily dependent on other tasks. To ensure that
# performRemote is called at the right time, subclasses should implement
# shouldWaitForTask(other). For example, the SendDraft task is dependent
# on the draft's files' UploadFile tasks completing.
# Tasks may also implement shouldDequeueOtherTask(other). Returning true
# will cause the other event to be removed from the queue. This is useful in
# offline mode especially, when the user might Save,Save,Save,Save,Send.
# Each newly queued Save can cancel the (unstarted) save task in the queue.
# Because tasks may be queued and performed when internet is available,
# they may need to be persisted to disk. Subclasses should implement
# serialize / deserialize to convert to / from raw JSON.
#
# Tasks that need to support undo/redo should implement `canBeUndone`, `isUndo`,
# `createUndoTask`, and `createIdenticalTask`.
#
class Task
## These are commonly overridden ##
@Status: TaskStatus
constructor: ->
@_performLocalCompletePromise = new Promise (resolve, reject) =>
@_performLocalComplete = resolve
@id = generateTempId()
@creationDate = new Date()
@queueState =
isProcessing: false
localError: null
localComplete: false
remoteError: null
remoteAttempts: 0
remoteComplete: false
@
performLocal: -> Promise.resolve()
runLocal: ->
if @queueState.localComplete
return Promise.resolve()
else
@performLocal()
.then =>
@_performLocalComplete()
@queueState.localComplete = true
@queueState.localError = null
return Promise.resolve()
.catch (err) =>
@queueState.localError = err
return Promise.reject(err)
performRemote: -> Promise.resolve()
runRemote: ->
if @queueState.localComplete is false
throw new Error("runRemote called before performLocal complete, this is an assertion failure.")
if @queueState.remoteComplete
return Promise.resolve(Task.Status.Finished)
@performRemote()
.catch (err) =>
@queueState.remoteAttempts += 1
@queueState.remoteError = err
.then (status) =>
if not (status in _.values(Task.Status))
throw new Error("performRemote returned #{status}, which is not a Task.Status")
@queueState.remoteAttempts += 1
@queueState.remoteComplete = status is Task.Status.Finished
@queueState.remoteError = null
return Promise.resolve(status)
## Everything beneath here may be overridden in subclasses ##
# performLocal is called once when the task is queued. You must return
# a promise. If you resolve, the task is queued and performRemote will
# be called. If you reject, the task will not be queued.
#
performLocal: ->
Promise.resolve()
performRemote: ->
Promise.resolve(Task.Status.Finished)
waitForPerformLocal: ->
if not atom.isMainWindow()
throw new Error("waitForPerformLocal is only supported in the main window. In
secondary windows, tasks are serialized and sent to the main
window, and cannot be observed.")
@_performLocalCompletePromise
cancel: ->
# We ignore requests to cancel and carry on. Subclasses that want to support
# cancellation or dequeue requests while running should implement cancel.
canBeUndone: -> false
isUndo: -> false
createUndoTask: -> throw new Error("Unimplemented")
createIdenticalTask: ->
json = @toJSON()
delete json['queueState']
(new @.constructor).fromJSON(json)
shouldDequeueOtherTask: (other) -> false
shouldWaitForTask: (other) -> false
cleanup: -> true
abort: -> Promise.resolve()
onAPIError: (apiError) ->
msg = "We had a problem with the server. Your action was NOT completed."
Actions.postNotification({message: msg, type: "error"})
Promise.resolve()
onOtherError: (otherError) ->
msg = "Something went wrong. Please report this issue immediately."
Actions.postNotification({message: msg, type: "error"})
Promise.resolve()
onTimeoutError: (timeoutError) ->
msg = "This took too long. Check your internet connection. Your action was NOT completed."
Actions.postNotification({message: msg, type: "error"})
Promise.resolve()
onOfflineError: (offlineError) ->
msg = "WARNING: You are offline. This will complete when you come back online."
Actions.postNotification({message: msg, type: "error"})
Promise.resolve()
## Only override if you know what you're doing ##
onError: (error) ->
if error instanceof APIError
@onAPIError(error)
else if error instanceof TimeoutError
@onTimeoutError(error)
else if error instanceof OfflineError
@onOfflineError(error)
else
if error instanceof Error
console.error "Task #{@constructor.name} threw an unknown error: #{error.message}"
console.error error.stack
@onOtherError(error)
notifyErrorMessage: (msg) ->
Actions.postNotification({message: msg, type: "error"})
toJSON: ->
json = _.clone(@)
json['object'] = @constructor.name

View file

@ -40,8 +40,6 @@ module.exports = (dir, regexPattern) ->
else
AWSModulePath = 'aws-sdk'
console.log("Load AWS module from #{AWSModulePath}")
# Note: These credentials are only good for uploading to this
# specific bucket and can't be used for anything else.
AWS = require(AWSModulePath)

View file

@ -6,6 +6,7 @@
text-align: center;
opacity: 1;
-webkit-transition: opacity 0.2s linear; //transition
pointer-events: none;
}
.spinner.hidden {
@ -57,10 +58,10 @@
}
@keyframes bouncedelay {
0%, 80%, 100% {
0%, 80%, 100% {
transform: scale(0.0);
-webkit-transform: scale(0.0);
} 40% {
} 40% {
transform: scale(1.0);
-webkit-transform: scale(1.0);
}