diff --git a/exports/nylas-exports.coffee b/exports/nylas-exports.coffee
index 6a5e28907..5e5a7ad51 100644
--- a/exports/nylas-exports.coffee
+++ b/exports/nylas-exports.coffee
@@ -84,6 +84,7 @@ class NylasExports
@require "DestroyDraftTask", 'flux/tasks/destroy-draft'
@require "ChangeLabelsTask", 'flux/tasks/change-labels-task'
@require "ChangeFolderTask", 'flux/tasks/change-folder-task'
+ @require "SyncbackCategoryTask", 'flux/tasks/syncback-category-task'
@require "ChangeUnreadTask", 'flux/tasks/change-unread-task'
@require "SyncbackDraftTask", 'flux/tasks/syncback-draft'
@require "ChangeStarredTask", 'flux/tasks/change-starred-task'
diff --git a/internal_packages/category-picker/lib/category-picker.cjsx b/internal_packages/category-picker/lib/category-picker.cjsx
index 81fccfd20..c19e055d8 100644
--- a/internal_packages/category-picker/lib/category-picker.cjsx
+++ b/internal_packages/category-picker/lib/category-picker.cjsx
@@ -2,14 +2,19 @@ _ = require 'underscore'
React = require 'react'
{Utils,
+ Label,
+ Folder,
Thread,
Actions,
TaskQueue,
- CategoryStore,
AccountStore,
+ CategoryStore,
+ DatabaseStore,
WorkspaceStore,
ChangeLabelsTask,
ChangeFolderTask,
+ SyncbackCategoryTask,
+ TaskQueueStatusStore,
FocusedMailViewStore} = require 'nylas-exports'
{Menu,
@@ -106,7 +111,10 @@ class CategoryPicker extends React.Component
_renderItemContent: (item) =>
if item.divider
- return
+ return
+ else if item.newCategoryItem
+ return @_renderCreateNewItem(item)
+
if @_account?.usesLabels()
icon = @_renderCheckbox(item)
else if @_account?.usesFolders()
@@ -120,6 +128,21 @@ class CategoryPicker extends React.Component
+ _renderCreateNewItem: ({searchValue, name}) =>
+ if @_account?.usesLabels()
+ picName = "tag"
+ else if @_account?.usesFolders()
+ picName = "folder"
+
+
+
+
+ “{searchValue}” (create new)
+
+
+
_renderCheckbox: (item) ->
styles = {}
styles.backgroundColor = item.backgroundColor
@@ -176,24 +199,64 @@ class CategoryPicker extends React.Component
@refs.menu.setSelectedItem(null)
if @_account.usesLabels()
- if item.usage > 0
+ if item.newCategoryItem
+ cat = new Label
+ displayName: @state.searchValue,
+ accountId: AccountStore.current().id
+ task = new SyncbackCategoryTask
+ category: cat
+ organizationUnit: "label"
+
+ TaskQueueStatusStore.waitForPerformRemote(task).then =>
+ DatabaseStore.findBy Label, clientId: cat.clientId
+ .then (cat) =>
+ changeLabelsTask = new ChangeLabelsTask
+ labelsToAdd: [cat]
+ threads: @_threads()
+ Actions.queueTask(changeLabelsTask)
+
+ Actions.queueTask(task)
+ else if item.usage > 0
task = new ChangeLabelsTask
labelsToRemove: [item.category]
threads: @_threads()
+ Actions.queueTask(task)
else
task = new ChangeLabelsTask
labelsToAdd: [item.category]
threads: @_threads()
- Actions.queueTask(task)
+ Actions.queueTask(task)
else if @_account.usesFolders()
- task = new ChangeFolderTask
- folder: item.category
- threads: @_threads()
- if @props.thread
- Actions.moveThread(@props.thread, task)
- else if @props.items
- Actions.moveThreads(@_threads(), task)
+ if item.newCategoryItem?
+ cat = new Folder
+ displayName: @state?.searchValue,
+ accountId: AccountStore.current().id
+ task = new SyncbackCategoryTask
+ category: cat
+ organizationUnit: "folder"
+
+ TaskQueueStatusStore.waitForPerformRemote(task).then =>
+ DatabaseStore.findBy Folder, clientId: cat.clientId
+ .then (cat) =>
+ return if not cat?.serverId
+
+ changeFolderTask = new ChangeFolderTask
+ folder: cat
+ threads: @_threads()
+ if @props.thread
+ Actions.moveThread(@props.thread, changeFolderTask)
+ else if @props.items
+ Actions.moveThreads(@_threads(), changeFolderTask)
+ Actions.queueTask(task)
+ else
+ task = new ChangeFolderTask
+ folder: item.category
+ threads: @_threads()
+ if @props.thread
+ Actions.moveThread(@props.thread, task)
+ else if @props.items
+ Actions.moveThreads(@_threads(), task)
else
throw new Error("Invalid organizationUnit")
@@ -222,8 +285,8 @@ class CategoryPicker extends React.Component
@_account = AccountStore.current()
return unless @_account
- categories = [].concat(CategoryStore.getStandardCategories())
- .concat([{divider: true}])
+ categories = CategoryStore.getStandardCategories()
+ .concat([{divider: true, id: "category-divider"}])
.concat(CategoryStore.getUserCategories())
usageCount = @_categoryUsageCount(props, categories)
@@ -238,6 +301,13 @@ class CategoryPicker extends React.Component
.map(_.partial(@_itemForCategory, displayData))
.value()
+ if searchValue.length > 0
+ newItemData =
+ searchValue: searchValue
+ newCategoryItem: true
+ id: "category-create-new"
+ categoryData.push(newItemData)
+
return {categoryData, searchValue}
_categoryUsageCount: (props, categories) =>
diff --git a/internal_packages/category-picker/spec/category-picker-spec.cjsx b/internal_packages/category-picker/spec/category-picker-spec.cjsx
new file mode 100644
index 000000000..903a50699
--- /dev/null
+++ b/internal_packages/category-picker/spec/category-picker-spec.cjsx
@@ -0,0 +1,283 @@
+_ = require 'underscore'
+React = require "react/addons"
+ReactTestUtils = React.addons.TestUtils
+CategoryPicker = require '../lib/category-picker'
+{Popover} = require 'nylas-component-kit'
+
+{Utils,
+ Label,
+ Folder,
+ Thread,
+ Actions,
+ CategoryStore,
+ DatabaseStore,
+ ChangeLabelsTask,
+ ChangeFolderTask,
+ SyncbackCategoryTask,
+ FocusedMailViewStore,
+ TaskQueueStatusStore} = require 'nylas-exports'
+
+describe 'CategoryPicker', ->
+ beforeEach ->
+ CategoryStore._categoryCache = {}
+
+ afterEach ->
+ atom.testOrganizationUnit = null
+
+ setupFor = (organizationUnit) ->
+ atom.testOrganizationUnit = organizationUnit
+ klass = if organizationUnit is "label" then Label else Folder
+
+ @inboxCategory = new klass(id: 'id-123', name: 'inbox', displayName: "INBOX")
+ @archiveCategory = new klass(id: 'id-456', name: 'archive', displayName: "ArCHIVe")
+ @userCategory = new klass(id: 'id-789', name: null, displayName: "MyCategory")
+
+ spyOn(CategoryStore, "getStandardCategories").andReturn [ @inboxCategory, @archiveCategory ]
+ spyOn(CategoryStore, "getUserCategories").andReturn [ @userCategory ]
+ spyOn(CategoryStore, "getStandardCategory").andReturn @inboxCategory
+
+ # By default we're going to set to "inbox". This has implications for
+ # what categories get filtered out of the list.
+ f = FocusedMailViewStore
+ f._setMailView f._defaultMailView()
+
+ setupForCreateNew = (orgUnit = "folder") ->
+ setupFor.call(@, orgUnit)
+
+ @testThread = new Thread(id: 't1', subject: "fake")
+ @picker = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ @popover = ReactTestUtils.findRenderedComponentWithType @picker, Popover
+ @popover.open()
+
+ describe 'when using labels', ->
+ beforeEach ->
+ setupFor.call(@, "label")
+
+ describe 'when using folders', ->
+ beforeEach ->
+ setupFor.call(@, "folder")
+
+ @testThread = new Thread(id: 't1', subject: "fake")
+ @picker = ReactTestUtils.renderIntoDocument(
+
+ )
+
+ it 'lists the desired categories', ->
+ data = @picker.state.categoryData
+ # NOTE: The inbox category is not included here because it's the
+ # currently focused category, which gets filtered out of the list.
+ expect(data[0].id).toBe "id-456"
+ expect(data[0].name).toBe "archive"
+ expect(data[0].category).toBe @archiveCategory
+ expect(data[1].divider).toBe true
+ expect(data[1].id).toBe "category-divider"
+ expect(data[2].id).toBe "id-789"
+ expect(data[2].name).toBeUndefined()
+ expect(data[2].category).toBe @userCategory
+
+ xdescribe 'when picking for a single Thread', ->
+ it 'renders a picker', ->
+ expect(ReactTestUtils.isCompositeComponentWithType @picker, CategoryPicker).toBe true
+
+ it "does not include a newItem prompt if there's no search", ->
+ outData = @picker._recalculateState().categoryData
+ newItem = _.findWhere(outData, newCategoryItem: true)
+ l1 = _.findWhere(outData, id: 'id-123')
+ expect(newItem).toBeUndefined()
+ expect(l1.name).toBe "inbox"
+
+ it "includes a newItem selector with the current search term", ->
+
+ xdescribe 'when picking labels for a single Thread', ->
+ beforeEach ->
+ atom.testOrganizationUnit = "label"
+
+ describe "'create new' item", ->
+ beforeEach ->
+ setupForCreateNew.call @
+
+ afterEach -> atom.testOrganizationUnit = null
+
+ it "is not visible when the search box is empty", ->
+ count = ReactTestUtils.scryRenderedDOMComponentsWithClass(@picker, 'category-create-new').length
+ expect(count).toBe 0
+
+ it "is visible when the search box has text", ->
+ inputNode = React.findDOMNode(ReactTestUtils.scryRenderedDOMComponentsWithTag(@picker, "input")[0])
+ ReactTestUtils.Simulate.change inputNode, target: { value: "calendar" }
+ count = ReactTestUtils.scryRenderedDOMComponentsWithClass(@picker, 'category-create-new').length
+ expect(count).toBe 1
+
+ it "shows folder icon if we're using exchange", ->
+ inputNode = React.findDOMNode(ReactTestUtils.scryRenderedDOMComponentsWithTag(@picker, "input")[0])
+ ReactTestUtils.Simulate.change inputNode, target: { value: "calendar" }
+ count = ReactTestUtils.scryRenderedDOMComponentsWithClass(@picker, 'category-create-new-folder').length
+ expect(count).toBe 1
+
+ describe "'create new' item with labels", ->
+ beforeEach ->
+ setupForCreateNew.call @, "label"
+
+ it "shows label icon if we're using gmail", ->
+ inputNode = React.findDOMNode(ReactTestUtils.scryRenderedDOMComponentsWithTag(@picker, "input")[0])
+ ReactTestUtils.Simulate.change inputNode, target: { value: "calendar" }
+ count = ReactTestUtils.scryRenderedDOMComponentsWithClass(@picker, 'category-create-new-tag').length
+ expect(count).toBe 1
+
+ describe "_onSelectCategory()", ->
+ describe "using labels", ->
+ beforeEach ->
+ setupForCreateNew.call @, "label"
+ spyOn Actions, "queueTask"
+
+ it "adds a label if it was previously unused", ->
+ input = { usage: 0, newCategoryItem: undefined, category: "asdf" }
+
+ @picker._onSelectCategory input
+
+ expect(Actions.queueTask).toHaveBeenCalled()
+
+ labelsToAdd = Actions.queueTask.calls[0].args[0].labelsToAdd
+ expect(labelsToAdd.length).toBe 1
+ expect(labelsToAdd[0]).toEqual input.category
+
+ threadsToUpdate = Actions.queueTask.calls[0].args[0].threads
+ expect(threadsToUpdate).toEqual [ @testThread ]
+
+ it "removes a label if it was previously used", ->
+ input = { usage: 1, newCategoryItem: undefined, category: "asdf" }
+
+ @picker._onSelectCategory input
+
+ expect(Actions.queueTask).toHaveBeenCalled()
+
+ labelsToRemove = Actions.queueTask.calls[0].args[0].labelsToRemove
+ expect(labelsToRemove.length).toBe 1
+ expect(labelsToRemove[0]).toEqual input.category
+
+ threadsToUpdate = Actions.queueTask.calls[0].args[0].threads
+ expect(threadsToUpdate).toEqual [ @testThread ]
+
+ it "creates a new label task", ->
+ input = { newCategoryItem: true }
+
+ @picker.setState searchValue: "teSTing!"
+
+ @picker._onSelectCategory input
+
+ expect(Actions.queueTask).toHaveBeenCalled()
+
+ syncbackTask = Actions.queueTask.calls[0].args[0]
+ newCategory = syncbackTask.category
+ expect(syncbackTask.organizationUnit).toBe "label"
+ expect(newCategory.displayName).toBe "teSTing!"
+ expect(newCategory.accountId).toBe TEST_ACCOUNT_ID
+
+ it "queues a change label task after performRemote for creating it", ->
+ input = { newCategoryItem: true }
+ label = new Label(clientId: "local-123")
+
+ spyOn(TaskQueueStatusStore, "waitForPerformRemote").andCallFake (task) ->
+ expect(task instanceof SyncbackCategoryTask).toBe true
+ Promise.resolve()
+ spyOn(DatabaseStore, "findBy").andCallFake (klass, {clientId}) ->
+ expect(klass).toBe Label
+ expect(typeof clientId).toBe "string"
+ Promise.resolve label
+
+ runs ->
+ @picker.setState searchValue: "teSTing!"
+ @picker._onSelectCategory input
+
+ waitsFor -> Actions.queueTask.calls.length > 1
+
+ runs ->
+ changeLabelsTask = Actions.queueTask.calls[1].args[0]
+ expect(changeLabelsTask instanceof ChangeLabelsTask).toBe true
+ expect(changeLabelsTask.labelsToAdd).toEqual [ label ]
+ expect(changeLabelsTask.threads).toEqual [ @testThread ]
+
+ it "doesn't queue any duplicate syncback tasks", ->
+ input = { newCategoryItem: true }
+ label = new Label(clientId: "local-123")
+
+ spyOn(TaskQueueStatusStore, "waitForPerformRemote").andCallFake (task) ->
+ expect(task instanceof SyncbackCategoryTask).toBe true
+ Promise.resolve()
+ spyOn(DatabaseStore, "findBy").andCallFake (klass, {clientId}) ->
+ expect(klass).toBe Label
+ expect(typeof clientId).toBe "string"
+ Promise.resolve label
+
+ runs ->
+ @picker.setState searchValue: "teSTing!"
+ @picker._onSelectCategory input
+
+ waitsFor -> Actions.queueTask.calls.length > 1
+
+ runs ->
+ allInputs = Actions.queueTask.calls.map (c) -> c.args[0]
+ syncbackTasks = allInputs.filter (i) -> i instanceof SyncbackCategoryTask
+ expect(syncbackTasks.length).toBe 1
+
+ describe "using folders", ->
+ beforeEach ->
+ setupForCreateNew.call @, "folder"
+ spyOn Actions, "queueTask"
+ spyOn Actions, "moveThread"
+ spyOn Actions, "moveThreads"
+
+ it "moves a thread if the component has one", ->
+ input = { category: "blah" }
+ @picker._onSelectCategory input
+ expect(Actions.moveThread).toHaveBeenCalled()
+
+ args = Actions.moveThread.calls[0].args
+ expect(args[0]).toEqual @testThread
+ expect(args[1].folder).toEqual input.category
+ expect(args[1].threads).toEqual [ @testThread ]
+
+ it "moves threads if the component has no thread but has items", ->
+ @picker = ReactTestUtils.renderIntoDocument(
+
+ )
+ @popover = ReactTestUtils.findRenderedComponentWithType @picker, Popover
+ @popover.open()
+
+ input = { category: "blah" }
+ @picker._onSelectCategory input
+ expect(Actions.moveThreads).toHaveBeenCalled()
+
+ it "creates a new folder task", ->
+ input = { newCategoryItem: true }
+ folder = new Folder(clientId: "local-456", serverId: "yes.")
+
+ spyOn(TaskQueueStatusStore, "waitForPerformRemote").andCallFake (task) ->
+ expect(task instanceof SyncbackCategoryTask).toBe true
+ Promise.resolve()
+ spyOn(DatabaseStore, "findBy").andCallFake (klass, {clientId}) ->
+ expect(klass).toBe Folder
+ expect(typeof clientId).toBe "string"
+ Promise.resolve folder
+
+ runs ->
+ @picker.setState searchValue: "teSTing!"
+ @picker._onSelectCategory input
+
+ waitsFor -> Actions.moveThread.calls.length > 0
+
+ runs ->
+ changeFoldersTask = Actions.moveThread.calls[0].args[1]
+ expect(changeFoldersTask instanceof ChangeFolderTask).toBe true
+ expect(changeFoldersTask.folder).toEqual folder
+ expect(changeFoldersTask.threads).toEqual [ @testThread ]
+
+ it "closes the popover", ->
+ setupForCreateNew.call @, "folder"
+ spyOn @popover, "close"
+ spyOn Actions, "moveThread"
+ @picker._onSelectCategory { usage: 0, category: "asdf" }
+ expect(@popover.close).toHaveBeenCalled()
diff --git a/spec-nylas/tasks/syncback-category-task-spec.coffee b/spec-nylas/tasks/syncback-category-task-spec.coffee
new file mode 100644
index 000000000..006509bbe
--- /dev/null
+++ b/spec-nylas/tasks/syncback-category-task-spec.coffee
@@ -0,0 +1,59 @@
+SyncbackCategoryTask = require "../../src/flux/tasks/syncback-category-task"
+NylasAPI = require "../../src/flux/nylas-api"
+{Label, Folder, DatabaseStore} = require "nylas-exports"
+
+describe "SyncbackCategoryTask", ->
+ describe "performRemote", ->
+ pathOf = (fn) ->
+ fn.calls[0].args[0].path
+
+ accountIdOf = (fn) ->
+ fn.calls[0].args[0].accountId
+
+ nameOf = (fn) ->
+ fn.calls[0].args[0].body.display_name
+
+ makeTask = (orgUnit) ->
+ Category = if orgUnit is "label" then Label else Folder
+ category = new Category
+ displayName: "important emails"
+ accountId: "account 123"
+ clientId: "local-444"
+ new SyncbackCategoryTask
+ category: category
+ organizationUnit: orgUnit
+
+ beforeEach ->
+ spyOn(NylasAPI, "makeRequest").andCallFake ->
+ Promise.resolve(id: "server-444")
+ spyOn(DatabaseStore, "persistModel")
+
+ it "sends API req to /labels if user uses labels", ->
+ task = makeTask "label"
+ task.performRemote({})
+ expect(pathOf(NylasAPI.makeRequest)).toBe "/labels"
+
+ it "sends API req to /folders if user uses folders", ->
+ task = makeTask "folder"
+ task.performRemote({})
+ expect(pathOf(NylasAPI.makeRequest)).toBe "/folders"
+
+ it "sends the account id", ->
+ task = makeTask "label"
+ task.performRemote({})
+ expect(accountIdOf(NylasAPI.makeRequest)).toBe "account 123"
+
+ it "sends the display name in the body", ->
+ task = makeTask "label"
+ task.performRemote({})
+ expect(nameOf(NylasAPI.makeRequest)).toBe "important emails"
+
+ it "adds server id to the category, then saves the category", ->
+ waitsForPromise ->
+ task = makeTask "label"
+ task.performRemote({})
+ .then ->
+ expect(DatabaseStore.persistModel).toHaveBeenCalled()
+ model = DatabaseStore.persistModel.calls[0].args[0]
+ expect(model.clientId).toBe "local-444"
+ expect(model.serverId).toBe "server-444"
diff --git a/src/components/menu.cjsx b/src/components/menu.cjsx
index d6e4dbdff..390f79e24 100644
--- a/src/components/menu.cjsx
+++ b/src/components/menu.cjsx
@@ -16,13 +16,13 @@ class MenuItem extends React.Component
###
Public: React `props` supported by MenuItem:
- - `divider` (optional) Pass a {String} to render the menu item as a section divider.
+ - `divider` (optional) Pass a {Boolean} to render the menu item as a section divider.
- `key` (optional)
- `selected` (optional)
- `checked` (optional)
###
@propTypes:
- divider: React.PropTypes.string
+ divider: React.PropTypes.bool
key: React.PropTypes.string
selected: React.PropTypes.bool
checked: React.PropTypes.bool
diff --git a/src/flux/models/folder.coffee b/src/flux/models/folder.coffee
index 9a741424c..fd3538ffa 100644
--- a/src/flux/models/folder.coffee
+++ b/src/flux/models/folder.coffee
@@ -44,6 +44,7 @@ class Folder extends Category
@additionalSQLiteConfig:
setup: ->
- ['CREATE INDEX IF NOT EXISTS FolderNameIndex ON Folder(account_id,name)']
+ ['CREATE INDEX IF NOT EXISTS FolderNameIndex ON Folder(account_id,name)',
+ 'CREATE UNIQUE INDEX IF NOT EXISTS FolderClientIndex ON Folder(client_id)']
module.exports = Folder
diff --git a/src/flux/models/label.coffee b/src/flux/models/label.coffee
index 86c5b3909..b4a4fcb41 100644
--- a/src/flux/models/label.coffee
+++ b/src/flux/models/label.coffee
@@ -43,6 +43,7 @@ class Label extends Category
@additionalSQLiteConfig:
setup: ->
- ['CREATE INDEX IF NOT EXISTS LabelNameIndex ON Label(account_id,name)']
+ ['CREATE INDEX IF NOT EXISTS LabelNameIndex ON Label(account_id,name)',
+ 'CREATE UNIQUE INDEX IF NOT EXISTS LabelClientIndex ON Label(client_id)']
module.exports = Label
diff --git a/src/flux/stores/database-store.coffee b/src/flux/stores/database-store.coffee
index 1e1800842..80c48d57f 100644
--- a/src/flux/stores/database-store.coffee
+++ b/src/flux/stores/database-store.coffee
@@ -17,7 +17,7 @@ PriorityUICoordinator = require '../../priority-ui-coordinator'
serializeRegisteredObjects,
deserializeRegisteredObjects} = require '../models/utils'
-DatabaseVersion = 14
+DatabaseVersion = 15
DatabasePhase =
Setup: 'setup'
@@ -344,9 +344,13 @@ class DatabaseStore extends NylasStore
return Promise.resolve([])
ids = []
+ clientIds = []
for item in arr
if item instanceof klass
- continue
+ if not item.serverId
+ clientIds.push(item.clientId)
+ else
+ continue
else if _.isString(item)
ids.push(item)
else
@@ -355,9 +359,20 @@ class DatabaseStore extends NylasStore
if ids.length is 0
return Promise.resolve(arr)
- @findAll(klass).where(klass.attributes.id.in(ids)).then (models) =>
+ whereId = =>
+ klass.attributes.id.in(ids)
+
+ whereClientId = =>
+ klass.attributes.clientId.in(clientIds)
+
+ queries = {}
+ queries.modelsFromIds = @findAll(klass).where(whereId) if ids.length
+ queries.modelsFromClientIds = @findAll(klass).where(whereClientId) if clientIds.length
+
+ Promise.props(queries).then ({modelsFromIds, modelsFromClientIds}) =>
modelsById = {}
- modelsById[model.id] = model for model in models
+ modelsById[model.id] = model for model in modelsFromIds
+ modelsById[model.id] = model for model in modelsFromClientIds
arr = arr.map (item) ->
if item instanceof klass
diff --git a/src/flux/stores/task-queue-status-store.coffee b/src/flux/stores/task-queue-status-store.coffee
index dd42ee957..efb167714 100644
--- a/src/flux/stores/task-queue-status-store.coffee
+++ b/src/flux/stores/task-queue-status-store.coffee
@@ -11,7 +11,8 @@ class TaskQueueStatusStore extends NylasStore
constructor: ->
@_queue = []
- @_waiting = []
+ @_waitingLocals = []
+ @_waitingRemotes = []
@listenTo DatabaseStore, @_onChange
DatabaseStore.findJSONObject(TaskQueue.JSONObjectStorageKey).then (json) =>
@@ -21,12 +22,18 @@ class TaskQueueStatusStore extends NylasStore
_onChange: (change) =>
if change.objectClass is 'JSONObject' and change.objects[0].key is 'task-queue'
@_queue = change.objects[0].json
- @_waiting = @_waiting.filter ({taskId, resolve}) =>
+ @_waitingLocals = @_waitingLocals.filter ({taskId, resolve}) =>
task = _.findWhere(@_queue, {id: taskId})
if not task or task.queueState.localComplete
resolve()
return false
return true
+ @_waitingRemotes = @_waitingRemotes.filter ({taskId, resolve}) =>
+ task = _.findWhere(@_queue, {id: taskId})
+ if not task
+ resolve()
+ return false
+ return true
@trigger()
queue: ->
@@ -34,6 +41,10 @@ class TaskQueueStatusStore extends NylasStore
waitForPerformLocal: (task) ->
new Promise (resolve, reject) =>
- @_waiting.push({taskId: task.id, resolve: resolve})
+ @_waitingLocals.push({taskId: task.id, resolve: resolve})
+
+ waitForPerformRemote: (task) ->
+ new Promise (resolve, reject) =>
+ @_waitingRemotes.push({taskId: task.id, resolve: resolve})
module.exports = new TaskQueueStatusStore()
diff --git a/src/flux/tasks/change-folder-task.coffee b/src/flux/tasks/change-folder-task.coffee
index 509980fc6..494873aea 100644
--- a/src/flux/tasks/change-folder-task.coffee
+++ b/src/flux/tasks/change-folder-task.coffee
@@ -5,6 +5,7 @@ Thread = require '../models/thread'
Message = require '../models/message'
DatabaseStore = require '../stores/database-store'
ChangeMailTask = require './change-mail-task'
+SyncbackCategoryTask = require './syncback-category-task'
# Public: Create a new task to apply labels to a message or thread.
#
@@ -40,6 +41,8 @@ class ChangeFolderTask extends ChangeMailTask
else
return "Moved objects#{folderText}"
+ shouldWaitForTask: (other) -> other instanceof SyncbackCategoryTask
+
performLocal: ->
if not @folder
return Promise.reject(new Error("Must specify a `folder`"))
diff --git a/src/flux/tasks/change-labels-task.coffee b/src/flux/tasks/change-labels-task.coffee
index 3093ef1b8..4363a75d5 100644
--- a/src/flux/tasks/change-labels-task.coffee
+++ b/src/flux/tasks/change-labels-task.coffee
@@ -5,6 +5,7 @@ Thread = require '../models/thread'
Message = require '../models/message'
DatabaseStore = require '../stores/database-store'
ChangeMailTask = require './change-mail-task'
+SyncbackCategoryTask = require './syncback-category-task'
# Public: Create a new task to apply labels to a message or thread.
#
@@ -32,6 +33,8 @@ class ChangeLabelsTask extends ChangeMailTask
return "Removed #{@labelsToRemove[0].displayName} from #{@threads.length} #{type}"
return "Changed labels on #{@threads.length} #{type}"
+ shouldWaitForTask: (other) -> other instanceof SyncbackCategoryTask
+
performLocal: ->
if @labelsToAdd.length is 0 and @labelsToRemove.length is 0
return Promise.reject(new Error("ChangeLabelsTask: Must specify `labelsToAdd` or `labelsToRemove`"))
diff --git a/src/flux/tasks/syncback-category-task.coffee b/src/flux/tasks/syncback-category-task.coffee
new file mode 100644
index 000000000..77c14079b
--- /dev/null
+++ b/src/flux/tasks/syncback-category-task.coffee
@@ -0,0 +1,61 @@
+CategoryStore = require '../stores/category-store'
+DatabaseStore = require '../stores/database-store'
+{generateTempId} = require '../models/utils'
+Task = require './task'
+NylasAPI = require '../nylas-api'
+{APIError} = require '../errors'
+
+module.exports = class SyncbackCategoryTask extends Task
+
+ constructor: ({@category, @organizationUnit}={}) ->
+ super
+
+ label: ->
+ "Creating new #{@organizationUnit}..."
+
+ performLocal: ->
+ # When we send drafts, we don't update anything in the app until
+ # it actually succeeds. We don't want users to think messages have
+ # already sent when they haven't!
+ if not @category
+ return Promise.reject(new Error("Attempt to call SyncbackCategoryTask.performLocal without @category."))
+ else if @organizationUnit isnt "label" and @organizationUnit isnt "folder"
+ return Promise.reject(new Error("Attempt to call SyncbackCategoryTask.performLocal with @organizationUnit which wasn't 'folder' or 'label'."))
+
+ if @_shouldChangeBackwards()
+ DatabaseStore.unpersistModel @category
+ else
+ DatabaseStore.persistModel @category
+
+ performRemote: ->
+ if @organizationUnit is "label"
+ path = "/labels"
+ else if @organizationUnit is "folder"
+ path = "/folders"
+
+ NylasAPI.makeRequest
+ path: path
+ method: 'POST'
+ accountId: @category.accountId
+ body:
+ display_name: @category.displayName
+ # returnsModel must be false because we want to update the
+ # existing model rather than returning a new model.
+ returnsModel: false
+ .then (json) =>
+ # This is where we update the existing model with the newly
+ # created serverId.
+ @category.serverId = json.id
+ DatabaseStore.persistModel @category
+ .then ->
+ return Promise.resolve(Task.Status.Finished)
+ .catch APIError, (err) =>
+ if err.statusCode in NylasAPI.PermanentErrorCodes
+ @_isReverting = true
+ @performLocal().then =>
+ return Promise.resolve(Task.Status.Finished)
+ else
+ return Promise.resolve(Task.Status.Retry)
+
+ _shouldChangeBackwards: ->
+ @_isReverting