From 45bb16561f9945e45dddd597b1e2b152ccb66bce Mon Sep 17 00:00:00 2001 From: Ben Gotow Date: Tue, 7 Jul 2015 13:38:53 -0400 Subject: [PATCH] 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 --- exports/nylas-exports.coffee | 1 + .../composer/lib/composer-view.cjsx | 41 +- .../composer/spec/composer-view-spec.cjsx | 27 +- .../composer/stylesheets/composer.less | 2 +- .../developer-bar/lib/developer-bar-task.cjsx | 2 +- .../spec/message-toolbar-items-spec.cjsx | 4 +- .../stylesheets/message-list.less | 1 + .../thread-list/lib/empty-state.cjsx | 1 - .../thread-list/lib/thread-list-store.coffee | 40 +- spec-nylas/stores/draft-store-spec.coffee | 2 +- spec-nylas/stores/task-queue-spec.coffee | 412 +++++------------- spec-nylas/tasks/add-remove-tags-spec.coffee | 54 ++- spec-nylas/tasks/file-upload-task-spec.coffee | 116 +++-- .../tasks/mark-message-read-spec.coffee | 63 +-- spec-nylas/tasks/send-draft-spec.coffee | 85 +--- spec-nylas/tasks/syncback-draft-spec.coffee | 11 +- spec-nylas/tasks/task-spec.coffee | 183 +++++--- spec/spec-helper.coffee | 9 + src/atom.coffee | 6 + src/command-registry.coffee | 3 + src/flux/errors.coffee | 3 - src/flux/nylas-api.coffee | 141 ++++-- src/flux/stores/file-download-store.coffee | 25 +- src/flux/stores/metadata-store.coffee | 1 - src/flux/stores/task-queue.coffee | 187 +++----- src/flux/stores/undo-redo-store.coffee | 47 ++ src/flux/tasks/add-remove-tags.coffee | 137 ++++-- src/flux/tasks/create-metadata-task.coffee | 51 +-- src/flux/tasks/destroy-draft.coffee | 73 ++-- src/flux/tasks/destroy-metadata-task.coffee | 32 +- src/flux/tasks/file-upload-task.coffee | 123 +++--- src/flux/tasks/mark-message-read.coffee | 54 +-- src/flux/tasks/mark-thread-read.coffee | 1 + src/flux/tasks/send-draft.coffee | 60 +-- src/flux/tasks/syncback-draft.coffee | 134 ++---- src/flux/tasks/task.coffee | 182 +++++--- src/tasks/ship-logs-task.coffee | 2 - static/components/spinner.less | 5 +- 38 files changed, 1124 insertions(+), 1197 deletions(-) create mode 100644 src/flux/stores/undo-redo-store.coffee diff --git a/exports/nylas-exports.coffee b/exports/nylas-exports.coffee index 97ef50c66..1bf9c1b90 100644 --- a/exports/nylas-exports.coffee +++ b/exports/nylas-exports.coffee @@ -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' diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index f4a4c7f88..108a1dec3 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -260,10 +260,14 @@ class ComposerView extends React.Component _renderBody: => if @props.mode is "inline" - @_renderBodyContenteditable() + + {@_renderBodyContenteditable()} + {@_renderAttachments()} + else @refs.scrollbar }> {@_renderBodyContenteditable()} + {@_renderAttachments()} _renderBodyContenteditable: => @@ -281,30 +285,23 @@ class ComposerView extends React.Component return
unless @props.localId
-
- {@_renderNonImageAttachmentsAndUploads()} - {@_renderImageAttachmentsAndUploads()} -
- _renderNonImageAttachmentsAndUploads: -> - @_nonImages().map (fileOrUpload) => - if fileOrUpload.object is "file" - @_attachmentComponent(fileOrUpload) - else - + _renderAttachments: -> + renderSubset = (arr, attachmentRole, UploadComponent) => + arr.map (fileOrUpload) => + if fileOrUpload.object is "file" + @_attachmentComponent(fileOrUpload, attachmentRole) + else + - _renderImageAttachmentsAndUploads: -> - @_images().map (fileOrUpload) => - if fileOrUpload.object is "file" - @_attachmentComponent(fileOrUpload, "Attachment:Image") - else - +
+ {renderSubset(@_nonImages(), 'Attachment', FileUpload)} + {renderSubset(@_images(), 'Attachment:Image', ImageFileUpload)} +
_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" 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() diff --git a/internal_packages/composer/stylesheets/composer.less b/internal_packages/composer/stylesheets/composer.less index 5f962e984..0f3344016 100644 --- a/internal_packages/composer/stylesheets/composer.less +++ b/internal_packages/composer/stylesheets/composer.less @@ -147,7 +147,7 @@ cursor: text; overflow: auto; position: relative; - padding: 0 8px; + margin: 0 8px; .quoted-text-control { position: absolute; diff --git a/internal_packages/developer-bar/lib/developer-bar-task.cjsx b/internal_packages/developer-bar/lib/developer-bar-task.cjsx index b384d3ecb..2691b6f77 100644 --- a/internal_packages/developer-bar/lib/developer-bar-task.cjsx +++ b/internal_packages/developer-bar/lib/developer-bar-task.cjsx @@ -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 diff --git a/internal_packages/message-list/spec/message-toolbar-items-spec.cjsx b/internal_packages/message-list/spec/message-toolbar-items-spec.cjsx index 0ec03b9e3..140affb7d 100644 --- a/internal_packages/message-list/spec/message-toolbar-items-spec.cjsx +++ b/internal_packages/message-list/spec/message-toolbar-items-spec.cjsx @@ -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']) diff --git a/internal_packages/message-list/stylesheets/message-list.less b/internal_packages/message-list/stylesheets/message-list.less index d382b8f0d..f43671f52 100644 --- a/internal_packages/message-list/stylesheets/message-list.less +++ b/internal_packages/message-list/stylesheets/message-list.less @@ -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; diff --git a/internal_packages/thread-list/lib/empty-state.cjsx b/internal_packages/thread-list/lib/empty-state.cjsx index b3b91117e..e951f9b9c 100644 --- a/internal_packages/thread-list/lib/empty-state.cjsx +++ b/internal_packages/thread-list/lib/empty-state.cjsx @@ -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: -> diff --git a/internal_packages/thread-list/lib/thread-list-store.coffee b/internal_packages/thread-list/lib/thread-list-store.coffee index d9ad6f7e7..0383ff33b 100644 --- a/internal_packages/thread-list/lib/thread-list-store.coffee +++ b/internal_packages/thread-list/lib/thread-list-store.coffee @@ -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() diff --git a/spec-nylas/stores/draft-store-spec.coffee b/spec-nylas/stores/draft-store-spec.coffee index 2f5628b64..4028f45ba 100644 --- a/spec-nylas/stores/draft-store-spec.coffee +++ b/spec-nylas/stores/draft-store-spec.coffee @@ -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) diff --git a/spec-nylas/stores/task-queue-spec.coffee b/spec-nylas/stores/task-queue-spec.coffee index 1489cd517..ad84cf195 100644 --- a/spec-nylas/stores/task-queue-spec.coffee +++ b/spec-nylas/stores/task-queue-spec.coffee @@ -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 diff --git a/spec-nylas/tasks/add-remove-tags-spec.coffee b/spec-nylas/tasks/add-remove-tags-spec.coffee index fea7265cd..931ceaaba 100644 --- a/spec-nylas/tasks/add-remove-tags-spec.coffee +++ b/spec-nylas/tasks/add-remove-tags-spec.coffee @@ -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']) diff --git a/spec-nylas/tasks/file-upload-task-spec.coffee b/spec-nylas/tasks/file-upload-task-spec.coffee index b01052e0d..d938d6382 100644 --- a/spec-nylas/tasks/file-upload-task-spec.coffee +++ b/spec-nylas/tasks/file-upload-task-spec.coffee @@ -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 diff --git a/spec-nylas/tasks/mark-message-read-spec.coffee b/spec-nylas/tasks/mark-message-read-spec.coffee index 025b59094..a93283b8b 100644 --- a/spec-nylas/tasks/mark-message-read-spec.coffee +++ b/spec-nylas/tasks/mark-message-read-spec.coffee @@ -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() diff --git a/spec-nylas/tasks/send-draft-spec.coffee b/spec-nylas/tasks/send-draft-spec.coffee index 8a00b0408..6524603d6 100644 --- a/spec-nylas/tasks/send-draft-spec.coffee +++ b/spec-nylas/tasks/send-draft-spec.coffee @@ -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) diff --git a/spec-nylas/tasks/syncback-draft-spec.coffee b/spec-nylas/tasks/syncback-draft-spec.coffee index e428f8b8c..6e8b2f2ff 100644 --- a/spec-nylas/tasks/syncback-draft-spec.coffee +++ b/spec-nylas/tasks/syncback-draft-spec.coffee @@ -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) diff --git a/spec-nylas/tasks/task-spec.coffee b/spec-nylas/tasks/task-spec.coffee index 8b89dbc06..56bce54b6 100644 --- a/spec-nylas/tasks/task-spec.coffee +++ b/spec-nylas/tasks/task-spec.coffee @@ -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 diff --git a/spec/spec-helper.coffee b/spec/spec-helper.coffee index 139293166..42c3d0cfc 100644 --- a/spec/spec-helper.coffee +++ b/spec/spec-helper.coffee @@ -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) -> diff --git a/src/atom.coffee b/src/atom.coffee index 3368a89b8..28032cd26 100644 --- a/src/atom.coffee +++ b/src/atom.coffee @@ -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() diff --git a/src/command-registry.coffee b/src/command-registry.coffee index b49167f50..7ce745b09 100644 --- a/src/command-registry.coffee +++ b/src/command-registry.coffee @@ -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 diff --git a/src/flux/errors.coffee b/src/flux/errors.coffee index 3f020bcd1..7d89c2ce4 100644 --- a/src/flux/errors.coffee +++ b/src/flux/errors.coffee @@ -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: -> diff --git a/src/flux/nylas-api.coffee b/src/flux/nylas-api.coffee index a8d9f5112..6123fe969 100644 --- a/src/flux/nylas-api.coffee +++ b/src/flux/nylas-api.coffee @@ -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/// - # /namespace//?thread_id= + # Handles: /namespace/// # _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() diff --git a/src/flux/stores/file-download-store.coffee b/src/flux/stores/file-download-store.coffee index b33eaa419..e58ed30b1 100644 --- a/src/flux/stores/file-download-store.coffee +++ b/src/flux/stores/file-download-store.coffee @@ -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() diff --git a/src/flux/stores/metadata-store.coffee b/src/flux/stores/metadata-store.coffee index 507b449ee..c4b4e5f29 100644 --- a/src/flux/stores/metadata-store.coffee +++ b/src/flux/stores/metadata-store.coffee @@ -85,7 +85,6 @@ MetadataStore = Reflux.createStore else DatabaseStore.persistModels(metadata).then(resolve).catch(reject) error: (apiError) -> - apiError.notifyConsole() reject(apiError) _onDBChanged: (change) -> diff --git a/src/flux/stores/task-queue.coffee b/src/flux/stores/task-queue.coffee index 8498fc4b3..204cedcc3 100644 --- a/src/flux/stores/task-queue.coffee +++ b/src/flux/stores/task-queue.coffee @@ -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() diff --git a/src/flux/stores/undo-redo-store.coffee b/src/flux/stores/undo-redo-store.coffee new file mode 100644 index 000000000..018ca8519 --- /dev/null +++ b/src/flux/stores/undo-redo-store.coffee @@ -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() diff --git a/src/flux/tasks/add-remove-tags.coffee b/src/flux/tasks/add-remove-tags.coffee index fa263f9ca..37e2ef5fa 100644 --- a/src/flux/tasks/add-remove-tags.coffee +++ b/src/flux/tasks/add-remove-tags.coffee @@ -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) diff --git a/src/flux/tasks/create-metadata-task.coffee b/src/flux/tasks/create-metadata-task.coffee index 081624cde..320cf533e 100644 --- a/src/flux/tasks/create-metadata-task.coffee +++ b/src/flux/tasks/create-metadata-task.coffee @@ -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() diff --git a/src/flux/tasks/destroy-draft.coffee b/src/flux/tasks/destroy-draft.coffee index 2f3cead56..5824a813e 100644 --- a/src/flux/tasks/destroy-draft.coffee +++ b/src/flux/tasks/destroy-draft.coffee @@ -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) diff --git a/src/flux/tasks/destroy-metadata-task.coffee b/src/flux/tasks/destroy-metadata-task.coffee index e7b563c57..92136714f 100644 --- a/src/flux/tasks/destroy-metadata-task.coffee +++ b/src/flux/tasks/destroy-metadata-task.coffee @@ -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() diff --git a/src/flux/tasks/file-upload-task.coffee b/src/flux/tasks/file-upload-task.coffee index 95195f08b..50dff4449 100644 --- a/src/flux/tasks/file-upload-task.coffee +++ b/src/flux/tasks/file-upload-task.coffee @@ -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 diff --git a/src/flux/tasks/mark-message-read.coffee b/src/flux/tasks/mark-message-read.coffee index 973f15788..68ba91e7f 100644 --- a/src/flux/tasks/mark-message-read.coffee +++ b/src/flux/tasks/mark-message-read.coffee @@ -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) diff --git a/src/flux/tasks/mark-thread-read.coffee b/src/flux/tasks/mark-thread-read.coffee index 3fe8551e5..05188920e 100644 --- a/src/flux/tasks/mark-thread-read.coffee +++ b/src/flux/tasks/mark-thread-read.coffee @@ -1,4 +1,5 @@ Task = require './task' +{APIError} = require '../errors' DatabaseStore = require '../stores/database-store' AddRemoveTagsTask = require './add-remove-tags' Message = require '../models/message' diff --git a/src/flux/tasks/send-draft.coffee b/src/flux/tasks/send-draft.coffee index 169212f02..255c279e4 100644 --- a/src/flux/tasks/send-draft.coffee +++ b/src/flux/tasks/send-draft.coffee @@ -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) diff --git a/src/flux/tasks/syncback-draft.coffee b/src/flux/tasks/syncback-draft.coffee index 5a5c26fd1..523460c70 100644 --- a/src/flux/tasks/syncback-draft.coffee +++ b/src/flux/tasks/syncback-draft.coffee @@ -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) diff --git a/src/flux/tasks/task.coffee b/src/flux/tasks/task.coffee index 75b443b70..1117dbf70 100644 --- a/src/flux/tasks/task.coffee +++ b/src/flux/tasks/task.coffee @@ -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 diff --git a/src/tasks/ship-logs-task.coffee b/src/tasks/ship-logs-task.coffee index 7c82a3fb3..da8cdc502 100644 --- a/src/tasks/ship-logs-task.coffee +++ b/src/tasks/ship-logs-task.coffee @@ -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) diff --git a/static/components/spinner.less b/static/components/spinner.less index edc1fc2a7..4d1d0ad84 100644 --- a/static/components/spinner.less +++ b/static/components/spinner.less @@ -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); }