mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-03 06:32:06 +08:00
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:
parent
af6f3c80b2
commit
45bb16561f
38 changed files with 1124 additions and 1197 deletions
|
@ -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'
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -147,7 +147,7 @@
|
|||
cursor: text;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
padding: 0 8px;
|
||||
margin: 0 8px;
|
||||
|
||||
.quoted-text-control {
|
||||
position: absolute;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'])
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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: ->
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) ->
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: ->
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -85,7 +85,6 @@ MetadataStore = Reflux.createStore
|
|||
else
|
||||
DatabaseStore.persistModels(metadata).then(resolve).catch(reject)
|
||||
error: (apiError) ->
|
||||
apiError.notifyConsole()
|
||||
reject(apiError)
|
||||
|
||||
_onDBChanged: (change) ->
|
||||
|
|
|
@ -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()
|
||||
|
|
47
src/flux/stores/undo-redo-store.coffee
Normal file
47
src/flux/stores/undo-redo-store.coffee
Normal 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()
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
Task = require './task'
|
||||
{APIError} = require '../errors'
|
||||
DatabaseStore = require '../stores/database-store'
|
||||
AddRemoveTagsTask = require './add-remove-tags'
|
||||
Message = require '../models/message'
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue