feat(metadata): Switch to storing metadata on models

Summary:
 - Adds a class ModelWithMetadata which models can now extend from
 - Instances of this class can query metadata for a plugin via
   `obj.metadataForPluginId(pluginId)`
 - To observe changes on metadata it is sufficient to observe database changes on
   the model. e.g.:
   `DatabaseStore.findAll(Thread,
   [Thread.attributes.pluginMetadata.contains(pluginId)])`
 - To set metadata a new action has been created: Actions.setMetadata
 - Adds a helper observable in nylas-observables to query for models with
   metadata
 - Merges CreateModelTask and UpdateModelTask into SyncbackModelTask
 - Update SendDraftTask ans SynbackDraftTask to handle metadata changes

Test Plan: - Unit tests

Reviewers: drew, evan, bengotow

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D2575
This commit is contained in:
Juan Tejada 2016-02-17 14:54:43 -08:00
parent 458b0fbe91
commit 0217e5a509
33 changed files with 802 additions and 1262 deletions

View file

@ -3,7 +3,7 @@ import EmojiActions from './emoji-actions'
const emoji = require('node-emoji');
class EmojiPicker extends React.Component {
static displayName = "EmojiPicker"
static displayName = "EmojiPicker";
static propTypes = {
emojiOptions: React.PropTypes.array,
selectedEmoji: React.PropTypes.string,

View file

@ -40,7 +40,7 @@ class EmojisComposerExtension extends ContenteditableExtension {
sel.focusOffset + triggerWord.length);
}
}
}
};
static toolbarComponentConfig = ({toolbarState}) => {
const sel = toolbarState.selectionSnapshot;
@ -62,14 +62,14 @@ class EmojisComposerExtension extends ContenteditableExtension {
}
}
return null;
}
};
static editingActions = () => {
return [{
action: EmojiActions.selectEmoji,
callback: EmojisComposerExtension._onSelectEmoji,
}]
}
};
static onKeyDown = ({editor, event}) => {
const sel = editor.currentSelection()
@ -104,7 +104,7 @@ class EmojisComposerExtension extends ContenteditableExtension {
actionArg: {emojiChar: emoji.get(selectedEmoji)}});
}
}
}
};
static _findEmojiOptions(sel) {
if (sel.anchorNode &&
@ -161,7 +161,7 @@ class EmojisComposerExtension extends ContenteditableExtension {
}
editor.insertText(emojiChar);
}
}
};
static _emojiPickerWidth(emojiOptions) {
let maxLength = 0;

View file

@ -100,7 +100,7 @@ class NylasLongConnection
@withCursor (cursor) =>
return if @state is NylasLongConnection.State.Ended
console.log("Delta Connection: Starting for account #{@_accountId}, token #{token}, with cursor #{cursor}")
options = url.parse("#{@_api.APIRoot}/delta/streaming?cursor=#{cursor}&exclude_folders=false")
options = url.parse("#{@_api.APIRoot}/delta/streaming?cursor=#{cursor}&exclude_folders=false&exclude_metadata=false")
options.auth = "#{token}:"
if @_api.APIRoot.indexOf('https') is -1

View file

@ -76,6 +76,14 @@ class NylasSyncWorkerPool
{create, modify, destroy} = @_clusterDeltas(deltas)
# Remove any metadata deltas. These have to be handled at the end, since metadata
# is stored within the object that it points to (which may not exist yet)
metadata = []
for deltas in [create, modify]
if deltas['metadata']
metadata = metadata.concat(_.values(deltas['metadata']))
delete deltas['metadata']
# Apply all the deltas to create objects. Gets promises for handling
# each type of model in the `create` hash, waits for them all to resolve.
create[type] = NylasAPI._handleModelResponse(_.values(dict)) for type, dict of create
@ -85,17 +93,19 @@ class NylasSyncWorkerPool
modify[type] = NylasAPI._handleModelResponse(_.values(dict)) for type, dict of modify
Promise.props(modify).then (modified) =>
# Now that we've persisted creates/updates, fire an action
# that allows other parts of the app to update based on new models
# (notifications)
if _.flatten(_.values(created)).length > 0
MailRulesProcessor.processMessages(created['message'] ? []).finally =>
Actions.didPassivelyReceiveNewModels(created)
Promise.all(@_handleDeltaMetadata(metadata)).then =>
# Apply all of the deletions
destroyPromises = destroy.map(@_handleDeltaDeletion)
Promise.settle(destroyPromises).then =>
Actions.longPollProcessedDeltas()
# Now that we've persisted creates/updates, fire an action
# that allows other parts of the app to update based on new models
# (notifications)
if _.flatten(_.values(created)).length > 0
MailRulesProcessor.processMessages(created['message'] ? []).finally =>
Actions.didPassivelyReceiveNewModels(created)
# Apply all of the deletions
destroyPromises = destroy.map(@_handleDeltaDeletion)
Promise.settle(destroyPromises).then =>
Actions.longPollProcessedDeltas()
_clusterDeltas: (deltas) ->
# Group deltas by object type so we can mutate the cache efficiently.
@ -118,6 +128,15 @@ class NylasSyncWorkerPool
{create, modify, destroy}
_handleDeltaMetadata: (metadata) =>
metadata.map (metadatum) =>
klass = NylasAPI._apiObjectToClassMap[metadatum.object_type]
DatabaseStore.inTransaction (t) =>
t.find(klass, metadatum.object_id).then (model) ->
return Promise.resolve() unless model
model.applyPluginMetadata(metadatum.application_id, metadatum.value)
t.persistModel(model)
_handleDeltaDeletion: (delta) =>
klass = NylasAPI._apiObjectToClassMap[delta.object]
return unless klass

View file

@ -197,7 +197,6 @@ describe "NylasAPI", ->
"message": require('../src/flux/models/message')
"contact": require('../src/flux/models/contact')
"calendar": require('../src/flux/models/calendar')
"metadata": require('../src/flux/models/metadata')
verifyUpdateHappened = (klass, responseModels) ->
changedModels = DatabaseTransaction.prototype.persistModels.calls[0].args[0]

View file

@ -1,168 +0,0 @@
import {
Task,
NylasAPI,
APIError,
CreateModelTask,
DatabaseTransaction } from 'nylas-exports'
describe("CreateModelTask", () => {
beforeEach(() => {
spyOn(DatabaseTransaction.prototype, "persistModel")
});
it("constructs without error", () => {
const t = new CreateModelTask()
expect(t._rememberedToCallSuper).toBe(true)
});
describe("performLocal", () => {
it("throws if basic fields are missing", () => {
const t = new CreateModelTask()
try {
t.performLocal()
throw new Error("Shouldn't succeed");
} catch (e) {
expect(e.message).toMatch(/^Must pass.*/)
}
});
it("throws if `requiredFields` are missing", () => {
const accountId = "a123"
const modelName = "Metadata"
const endpoint = "/endpoint"
const data = {foo: "bar"}
const requiredFields = ["stuff"]
const t = new CreateModelTask({accountId, modelName, data, endpoint, requiredFields})
try {
t.performLocal()
throw new Error("Shouldn't succeed");
} catch (e) {
expect(e.message).toMatch(/^Must pass data field.*/)
}
});
it("throws if the model name can't be found", () => {
const accountId = "a123"
const modelName = "dne"
const endpoint = "/endpoint"
const data = {stuff: "bar"}
const requiredFields = ["stuff"]
const t = new CreateModelTask({accountId, modelName, data, endpoint, requiredFields})
try {
t.performLocal()
throw new Error("Shouldn't succeed");
} catch (e) {
expect(e.message).toMatch(/^Couldn't find the class for.*/)
}
});
it("persists the new model properly", () => {
const persistFn = DatabaseTransaction.prototype.persistModel
const accountId = "a123"
const modelName = "Metadata"
const endpoint = "/endpoint"
const data = {value: "bar"}
const requiredFields = ["value"]
const t = new CreateModelTask({accountId, modelName, data, endpoint, requiredFields})
window.waitsForPromise(() => {
return t.performLocal().then(() => {
expect(persistFn).toHaveBeenCalled()
const model = persistFn.calls[0].args[0]
expect(model.constructor.name).toBe(modelName)
expect(model.value).toBe("bar")
});
});
});
});
describe("performRemote", () => {
const accountId = "a123"
const modelName = "Metadata"
const endpoint = "/endpoint"
const data = {value: "bar"}
beforeEach(() => {
this.task = new CreateModelTask({accountId, modelName, data, endpoint})
});
const performRemote = (fn) => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
return this.task.performRemote().then(fn)
});
});
}
it("makes a POST request to the Nylas API", () => {
spyOn(NylasAPI, "makeRequest").andReturn(Promise.resolve())
performRemote(() => {
const opts = NylasAPI.makeRequest.calls[0].args[0]
expect(opts.method).toBe("POST")
expect(opts.body.value).toBe("bar")
})
});
it("marks task success on API success", () => {
spyOn(NylasAPI, "makeRequest").andReturn(Promise.resolve())
performRemote((status) => {
expect(status).toBe(Task.Status.Success)
})
});
it("retries on non permanent errors", () => {
spyOn(NylasAPI, "makeRequest").andCallFake(() => {
return Promise.reject(new APIError({statusCode: 429}))
})
performRemote((status) => {
expect(status).toBe(Task.Status.Retry)
})
});
it("fails on permanent errors", () => {
const err = new APIError({statusCode: 500})
spyOn(NylasAPI, "makeRequest").andCallFake(() => {
return Promise.reject(err)
})
performRemote((status) => {
expect(status).toEqual([Task.Status.Failed, err])
})
});
it("fails on other thrown errors", () => {
const err = new Error("foo")
spyOn(NylasAPI, "makeRequest").andCallFake(() => {
return Promise.reject(err)
})
performRemote((status) => {
expect(status).toEqual([Task.Status.Failed, err])
})
});
});
describe("undo", () => {
const accountId = "a123"
const modelName = "Metadata"
const endpoint = "/endpoint"
const data = {key: "foo", value: "bar"}
beforeEach(() => {
this.task = new CreateModelTask({accountId, modelName, data, endpoint})
});
it("indicates it's undoable", () => {
expect(this.task.canBeUndone()).toBe(true)
expect(this.task.isUndo()).toBe(false)
});
it("creates the appropriate DestroyModelTask", () => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
const undoTask = this.task.createUndoTask()
expect(undoTask.constructor.name).toBe("DestroyModelTask")
expect(undoTask.clientId).toBe(this.task.model.clientId)
expect(undoTask.isUndo()).toBe(true)
});
});
});
});
});

View file

@ -1,5 +1,6 @@
import {
Metadata,
Task,
Model,
NylasAPI,
DatabaseStore,
DestroyModelTask,
@ -7,7 +8,7 @@ import {
describe("DestroyModelTask", () => {
beforeEach(() => {
this.existingModel = new Metadata({key: "foo", value: "bar"})
this.existingModel = new Model()
this.existingModel.clientId = "local-123"
this.existingModel.serverId = "server-123"
spyOn(DatabaseTransaction.prototype, "unpersistModel")
@ -18,7 +19,7 @@ describe("DestroyModelTask", () => {
this.defaultArgs = {
clientId: "local-123",
accountId: "a123",
modelName: "Metadata",
modelName: "Model",
endpoint: "/endpoint",
}
});
@ -91,16 +92,13 @@ describe("DestroyModelTask", () => {
});
}
it("throws an error if the serverId is undefined", () => {
it("skips request if the serverId is undefined", () => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
this.task.serverId = null
try {
this.task.performRemote()
throw new Error("Should fail")
} catch (err) {
expect(err.message).toMatch(/^Need a serverId.*/)
}
return this.task.performRemote().then((status)=> {
expect(status).toEqual(Task.Status.Continue)
})
});
});
});
@ -115,26 +113,4 @@ describe("DestroyModelTask", () => {
})
});
});
describe("undo", () => {
beforeEach(() => {
this.task = new DestroyModelTask(this.defaultArgs)
});
it("indicates it's undoable", () => {
expect(this.task.canBeUndone()).toBe(true)
expect(this.task.isUndo()).toBe(false)
});
it("creates the appropriate CreateModelTask", () => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
const undoTask = this.task.createUndoTask()
expect(undoTask.constructor.name).toBe("CreateModelTask")
expect(undoTask.data).toBe(this.task.oldModel)
expect(undoTask.isUndo()).toBe(true)
});
});
});
});
});

View file

@ -69,14 +69,11 @@ describe "SendDraftTask", ->
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
options.success?(@response)
Promise.resolve(@response)
spyOn(DBt, 'unpersistModel').andCallFake (draft) ->
Promise.resolve()
spyOn(DBt, 'persistModel').andCallFake (draft) ->
Promise.resolve()
spyOn(DBt, 'unpersistModel').andReturn Promise.resolve()
spyOn(DBt, 'persistModel').andReturn Promise.resolve()
spyOn(SoundRegistry, "playSound")
spyOn(Actions, "postNotification")
spyOn(Actions, "sendDraftSuccess")
spyOn(NylasEnv, "reportError")
sharedTests = =>
it "makes a send request with the correct data", ->
@ -115,13 +112,10 @@ describe "SendDraftTask", ->
expect(DBt.persistModel.mostRecentCall.args[0].draft).toEqual(false)
it "should notify the draft was sent", ->
waitsForPromise => @task.performRemote().then =>
args = Actions.sendDraftSuccess.calls[0].args[0]
expect(args.draftClientId).toBe @draft.clientId
it "get an object back on success", ->
waitsForPromise => @task.performRemote().then =>
args = Actions.sendDraftSuccess.calls[0].args[0]
waitsForPromise =>
@task.performRemote().then =>
args = Actions.sendDraftSuccess.calls[0].args[0]
expect(args.draftClientId).toBe @draft.clientId
it "should play a sound", ->
spyOn(NylasEnv.config, "get").andReturn true
@ -143,6 +137,7 @@ describe "SendDraftTask", ->
it "notifies of a permanent error of misc error types", ->
## DB error
thrownError = null
spyOn(NylasEnv, "reportError")
jasmine.unspy(DBt, "persistModel")
spyOn(DBt, "persistModel").andCallFake =>
thrownError = new Error('db error')
@ -200,6 +195,7 @@ describe "SendDraftTask", ->
it "notifies of a permanent error on 500 errors", ->
thrownError = new APIError(statusCode: 500, body: "err")
spyOn(NylasEnv, "reportError")
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
Promise.reject(thrownError)
waitsForPromise => @task.performRemote().then (status) =>
@ -209,6 +205,7 @@ describe "SendDraftTask", ->
it "notifies us and users of a permanent error on 400 errors", ->
thrownError = new APIError(statusCode: 400, body: "err")
spyOn(NylasEnv, "reportError")
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
Promise.reject(thrownError)
waitsForPromise => @task.performRemote().then (status) =>
@ -225,6 +222,7 @@ describe "SendDraftTask", ->
describe "checking the promise chain halts on errors", ->
beforeEach ->
spyOn(NylasEnv, 'reportError')
spyOn(@task,"_sendAndCreateMessage").andCallThrough()
spyOn(@task,"_deleteRemoteDraft").andCallThrough()
spyOn(@task,"_onSuccess").andCallThrough()
@ -240,32 +238,48 @@ describe "SendDraftTask", ->
thrownError = new APIError(statusCode: 500, body: "err")
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
Promise.reject(thrownError)
waitsForPromise => @task.performRemote().then (status) =>
@expectBlockedChain()
waitsForPromise =>
@task.performRemote().then (status) =>
@expectBlockedChain()
it "halts on 400s", ->
thrownError = new APIError(statusCode: 400, body: "err")
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
Promise.reject(thrownError)
waitsForPromise => @task.performRemote().then (status) =>
@expectBlockedChain()
waitsForPromise =>
@task.performRemote().then (status) =>
@expectBlockedChain()
it "halts and retries on not permanent error codes", ->
thrownError = new APIError(statusCode: 409, body: "err")
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
Promise.reject(thrownError)
waitsForPromise =>
@task.performRemote().then (status) =>
@expectBlockedChain()
it "halts on other errors", ->
thrownError = new Error("oh no")
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
Promise.reject(thrownError)
waitsForPromise => @task.performRemote().then (status) =>
@expectBlockedChain()
waitsForPromise =>
@task.performRemote().then (status) =>
@expectBlockedChain()
it "dosn't halt on success", ->
it "doesn't halt on success", ->
# Don't spy reportError to make sure to fail the test on unexpected
# errors
jasmine.unspy(NylasEnv, 'reportError')
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
options.success?(@response)
Promise.resolve(@response)
waitsForPromise => @task.performRemote().then (status) =>
expect(@task._sendAndCreateMessage).toHaveBeenCalled()
expect(@task._deleteRemoteDraft).toHaveBeenCalled()
expect(@task._onSuccess).toHaveBeenCalled()
expect(@task._onError).not.toHaveBeenCalled()
waitsForPromise =>
@task.performRemote().then (status) =>
expect(status).toBe Task.Status.Success
expect(@task._sendAndCreateMessage).toHaveBeenCalled()
expect(@task._deleteRemoteDraft).toHaveBeenCalled()
expect(@task._onSuccess).toHaveBeenCalled()
expect(@task._onError).not.toHaveBeenCalled()
describe "with a new draft", ->
beforeEach ->
@ -281,27 +295,28 @@ describe "SendDraftTask", ->
@task = new SendDraftTask(@draft)
@calledBody = "ERROR: The body wasn't included!"
spyOn(DatabaseStore, "findBy").andCallFake =>
include: (body) =>
@calledBody = body
Promise.resolve(@draft)
Promise.resolve(@draft)
sharedTests()
it "can complete a full performRemote", -> waitsForPromise =>
@task.performRemote().then (status) ->
expect(status).toBe Task.Status.Success
it "can complete a full performRemote", ->
waitsForPromise =>
@task.performRemote().then (status) ->
expect(status).toBe Task.Status.Success
it "shouldn't attempt to delete a draft", -> waitsForPromise =>
@task._deleteRemoteDraft().then =>
expect(NylasAPI.makeRequest).not.toHaveBeenCalled()
it "shouldn't attempt to delete a draft", ->
waitsForPromise =>
@task._deleteRemoteDraft(@draft).then =>
expect(NylasAPI.makeRequest).not.toHaveBeenCalled()
it "should locally convert the draft to a message on send", ->
waitsForPromise => @task.performRemote().then =>
expect(DBt.persistModel).toHaveBeenCalled()
model = DBt.persistModel.calls[0].args[0]
expect(model.clientId).toBe @draft.clientId
expect(model.serverId).toBe @response.id
expect(model.draft).toBe false
waitsForPromise =>
@task.performRemote().then =>
expect(DBt.persistModel).toHaveBeenCalled()
model = DBt.persistModel.calls[0].args[0]
expect(model.clientId).toBe @draft.clientId
expect(model.serverId).toBe @response.id
expect(model.draft).toBe false
describe "with an existing persisted draft", ->
beforeEach ->
@ -321,10 +336,7 @@ describe "SendDraftTask", ->
@task = new SendDraftTask(@draft)
@calledBody = "ERROR: The body wasn't included!"
spyOn(DatabaseStore, "findBy").andCallFake =>
then: -> throw new Error("You must include the body!")
include: (body) =>
@calledBody = body
Promise.resolve(@draft)
Promise.resolve(@draft)
sharedTests()
@ -335,14 +347,15 @@ describe "SendDraftTask", ->
it "should make a request to delete a draft", ->
@task.performLocal()
waitsForPromise => @task._deleteRemoteDraft().then =>
expect(NylasAPI.makeRequest).toHaveBeenCalled()
expect(NylasAPI.makeRequest.callCount).toBe 1
req = NylasAPI.makeRequest.calls[0].args[0]
expect(req.path).toBe "/drafts/#{@draft.serverId}"
expect(req.accountId).toBe TEST_ACCOUNT_ID
expect(req.method).toBe "DELETE"
expect(req.returnsModel).toBe false
waitsForPromise =>
@task._deleteRemoteDraft(@draft).then =>
expect(NylasAPI.makeRequest).toHaveBeenCalled()
expect(NylasAPI.makeRequest.callCount).toBe 1
req = NylasAPI.makeRequest.calls[0].args[0]
expect(req.path).toBe "/drafts/#{@draft.serverId}"
expect(req.accountId).toBe TEST_ACCOUNT_ID
expect(req.method).toBe "DELETE"
expect(req.returnsModel).toBe false
it "should continue if the request fails", ->
jasmine.unspy(NylasAPI, "makeRequest")
@ -350,11 +363,13 @@ describe "SendDraftTask", ->
Promise.reject(new APIError(body: "Boo", statusCode: 500))
@task.performLocal()
waitsForPromise => @task._deleteRemoteDraft().then =>
expect(NylasAPI.makeRequest).toHaveBeenCalled()
expect(NylasAPI.makeRequest.callCount).toBe 1
.catch =>
throw new Error("Shouldn't fail the promise")
waitsForPromise =>
@task._deleteRemoteDraft(@draft)
.then =>
expect(NylasAPI.makeRequest).toHaveBeenCalled()
expect(NylasAPI.makeRequest.callCount).toBe 1
.catch =>
throw new Error("Shouldn't fail the promise")
it "should locally convert the existing draft to a message on send", ->
expect(@draft.clientId).toBe @draft.clientId

View file

@ -2,6 +2,7 @@ _ = require 'underscore'
{DatabaseTransaction,
SyncbackDraftTask,
SyncbackMetadataTask,
DatabaseStore,
AccountStore,
TaskQueue,
@ -32,7 +33,7 @@ remoteDraft = -> new Message _.extend {}, testData, {clientId: "local-id", serve
describe "SyncbackDraftTask", ->
beforeEach ->
spyOn(AccountStore, "accountForEmail").andCallFake (email) ->
return new Account(clientId: 'local-abc123', serverId: 'abc123')
return new Account(clientId: 'local-abc123', serverId: 'abc123', emailAddress: email)
spyOn(DatabaseStore, "run").andCallFake (query) ->
clientId = query.matcherValueForModelKey('clientId')
@ -41,8 +42,8 @@ describe "SyncbackDraftTask", ->
else if clientId is "missingDraftId" then Promise.resolve()
else return Promise.resolve()
spyOn(DatabaseTransaction.prototype, "persistModel").andCallFake ->
Promise.resolve()
spyOn(DatabaseTransaction.prototype, "persistModel").andCallFake (draft) ->
Promise.resolve(draft)
describe "queueing multiple tasks", ->
beforeEach ->
@ -148,6 +149,73 @@ describe "SyncbackDraftTask", ->
options = NylasAPI.makeRequest.mostRecentCall.args[0]
expect(options.returnsModel).toBe(false)
it "should not save metadata associated to the draft when the draft has been already saved to the api", ->
draft = remoteDraft()
draft.pluginMetadata = [{pluginId: 1, value: {a: 1}}]
task = new SyncbackDraftTask(draft.clientId)
spyOn(task, 'getLatestLocalDraft').andReturn Promise.resolve(draft)
spyOn(Actions, 'queueTask')
waitsForPromise =>
task.updateLocalDraft(draft).then =>
expect(Actions.queueTask).not.toHaveBeenCalled()
it "should save metadata associated to the draft when the draft is syncbacked for the first time", ->
draft = localDraft()
draft.pluginMetadata = [{pluginId: 1, value: {a: 1}}]
task = new SyncbackDraftTask(draft.clientId)
spyOn(task, 'getLatestLocalDraft').andReturn Promise.resolve(draft)
spyOn(Actions, 'queueTask')
waitsForPromise =>
task.updateLocalDraft(draft).then =>
metadataTask = Actions.queueTask.mostRecentCall.args[0]
expect(metadataTask instanceof SyncbackMetadataTask).toBe true
expect(metadataTask.clientId).toEqual draft.clientId
expect(metadataTask.modelClassName).toEqual 'Message'
expect(metadataTask.pluginId).toEqual 1
describe 'when `from` value does not match the account associated to the draft', ->
beforeEach ->
@serverId = 'remote123'
@draft = remoteDraft()
@draft.serverId = 'remote123'
@draft.from = [{email: 'another@email.com'}]
@task = new SyncbackDraftTask(@draft.clientId)
jasmine.unspy(AccountStore, 'accountForEmail')
spyOn(AccountStore, "accountForEmail").andReturn {id: 'other-account'}
spyOn(Actions, "queueTask")
spyOn(@task, 'getLatestLocalDraft').andReturn Promise.resolve(@draft)
it "should delete the remote draft if it was already saved", ->
waitsForPromise =>
@task.checkDraftFromMatchesAccount(@draft).then =>
expect(NylasAPI.makeRequest).toHaveBeenCalled()
params = NylasAPI.makeRequest.mostRecentCall.args[0]
expect(params.method).toEqual "DELETE"
expect(params.path).toEqual "/drafts/#{@serverId}"
it "should change the accountId and clear server fields", ->
waitsForPromise =>
@task.checkDraftFromMatchesAccount(@draft).then (updatedDraft) =>
expect(updatedDraft.serverId).toBeUndefined()
expect(updatedDraft.version).toBeUndefined()
expect(updatedDraft.threadId).toBeUndefined()
expect(updatedDraft.replyToMessageId).toBeUndefined()
expect(updatedDraft.accountId).toEqual 'other-account'
it "should syncback any metadata associated with the original draft", ->
@draft.pluginMetadata = [{pluginId: 1, value: {a: 1}}]
@task = new SyncbackDraftTask(@draft.clientId)
spyOn(@task, 'getLatestLocalDraft').andReturn Promise.resolve(@draft)
spyOn(@task, 'saveDraft').andCallFake (d) -> Promise.resolve(d)
waitsForPromise =>
@task.performRemote().then =>
metadataTask = Actions.queueTask.mostRecentCall.args[0]
expect(metadataTask instanceof SyncbackMetadataTask).toBe true
expect(metadataTask.clientId).toEqual @draft.clientId
expect(metadataTask.modelClassName).toEqual 'Message'
expect(metadataTask.pluginId).toEqual 1
describe "When the api throws errors", ->
stubAPI = (code, method) ->
spyOn(NylasAPI, "makeRequest").andCallFake (opts) ->

View file

@ -2,23 +2,22 @@ import {
Task,
NylasAPI,
APIError,
Metadata,
Model,
DatabaseStore,
SyncbackModelTask,
DatabaseTransaction } from 'nylas-exports'
class TestTask extends SyncbackModelTask {
getModelConstructor() {
return Metadata
return Model
}
}
describe("SyncbackModelTask", () => {
beforeEach(() => {
this.testModel = new Metadata({accountId: 'account-123'})
this.testModel = new Model({accountId: 'account-123'})
spyOn(DatabaseTransaction.prototype, "persistModel")
spyOn(DatabaseStore, "findBy")
.andReturn(Promise.resolve(this.testModel));
spyOn(DatabaseStore, "findBy").andReturn(Promise.resolve(this.testModel));
spyOn(NylasEnv, "reportError")
spyOn(NylasAPI, "makeRequest").andReturn(Promise.resolve({
@ -86,14 +85,14 @@ describe("SyncbackModelTask", () => {
it("gets the correct path and method for existing objects", () => {
jasmine.unspy(DatabaseStore, "findBy")
const serverModel = new Metadata({localId: 'local-123', serverId: 'server-123'})
const serverModel = new Model({clientId: 'local-123', serverId: 'server-123'})
spyOn(DatabaseStore, "findBy").andReturn(Promise.resolve(serverModel));
spyOn(this.task, "getPathAndMethod").andCallThrough();
spyOn(this.task, "getRequestData").andCallThrough();
performRemote(() => {
expect(this.task.getPathAndMethod).toHaveBeenCalled()
expect(this.task.getRequestData).toHaveBeenCalled()
const opts = NylasAPI.makeRequest.calls[0].args[0]
expect(opts.path).toBe("/test/server-123")
expect(opts.method).toBe("PUT")
@ -101,10 +100,10 @@ describe("SyncbackModelTask", () => {
});
it("gets the correct path and method for new objects", () => {
spyOn(this.task, "getPathAndMethod").andCallThrough();
spyOn(this.task, "getRequestData").andCallThrough();
performRemote(() => {
expect(this.task.getPathAndMethod).toHaveBeenCalled()
expect(this.task.getRequestData).toHaveBeenCalled()
const opts = NylasAPI.makeRequest.calls[0].args[0]
expect(opts.path).toBe("/test")
expect(opts.method).toBe("POST")
@ -114,21 +113,21 @@ describe("SyncbackModelTask", () => {
it("lets tasks override path and method", () => {
class TaskMethodAndPath extends SyncbackModelTask {
getModelConstructor() {
return Metadata
return Model
}
getPathAndMethod = () => {
getRequestData = () => {
return {
path: `/override`,
method: "DELETE",
}
}
};
}
const task = new TaskMethodAndPath({clientId: 'local-123'});
spyOn(task, "getPathAndMethod").andCallThrough();
spyOn(task, "getRequestData").andCallThrough();
spyOn(task, "getModelConstructor").andCallThrough()
window.waitsForPromise(() => {
return task.performRemote().then(() => {
expect(task.getPathAndMethod).toHaveBeenCalled()
expect(task.getRequestData).toHaveBeenCalled()
const opts = NylasAPI.makeRequest.calls[0].args[0]
expect(opts.path).toBe("/override")
expect(opts.method).toBe("DELETE")

View file

@ -1,143 +0,0 @@
import {
Metadata,
NylasAPI,
DatabaseStore,
UpdateModelTask,
DatabaseTransaction} from 'nylas-exports'
describe("UpdateModelTask", () => {
beforeEach(() => {
this.existingModel = new Metadata({key: "foo", value: "bar"})
this.existingModel.clientId = "local-123"
this.existingModel.serverId = "server-123"
spyOn(DatabaseTransaction.prototype, "persistModel")
spyOn(DatabaseStore, "findBy").andCallFake(() => {
return Promise.resolve(this.existingModel)
})
this.defaultArgs = {
clientId: "local-123",
newData: {value: "baz"},
accountId: "a123",
modelName: "Metadata",
endpoint: "/endpoint",
}
});
it("constructs without error", () => {
const t = new UpdateModelTask()
expect(t._rememberedToCallSuper).toBe(true)
});
describe("performLocal", () => {
it("throws if basic fields are missing", () => {
const t = new UpdateModelTask()
try {
t.performLocal()
throw new Error("Shouldn't succeed");
} catch (e) {
expect(e.message).toMatch(/^Must pass.*/)
}
});
it("throws if the model name can't be found", () => {
this.defaultArgs.modelName = "dne"
const t = new UpdateModelTask(this.defaultArgs)
try {
t.performLocal()
throw new Error("Shouldn't succeed");
} catch (e) {
expect(e.message).toMatch(/^Couldn't find the class for.*/)
}
});
it("throws if it can't find the object", () => {
jasmine.unspy(DatabaseStore, "findBy")
spyOn(DatabaseStore, "findBy").andCallFake(() => {
return Promise.resolve(null)
})
const t = new UpdateModelTask(this.defaultArgs)
window.waitsForPromise(() => {
return t.performLocal().then(() => {
throw new Error("Shouldn't succeed")
}).catch((err) => {
expect(err.message).toMatch(/^Couldn't find the model with clientId.*/)
});
});
});
it("persists the new existing model properly", () => {
const persistFn = DatabaseTransaction.prototype.persistModel
const t = new UpdateModelTask(this.defaultArgs)
window.waitsForPromise(() => {
return t.performLocal().then(() => {
expect(persistFn).toHaveBeenCalled()
const model = persistFn.calls[0].args[0]
expect(model).toBe(this.existingModel)
});
});
});
});
describe("performRemote", () => {
beforeEach(() => {
this.task = new UpdateModelTask(this.defaultArgs)
});
const performRemote = (fn) => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
return this.task.performRemote().then(fn)
});
});
}
it("throws an error if the serverId is undefined", () => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
this.task.serverId = null
try {
this.task.performRemote()
throw new Error("Should fail")
} catch (err) {
expect(err.message).toMatch(/^Need a serverId.*/)
}
});
});
});
it("makes a PUT request to the Nylas API", () => {
spyOn(NylasAPI, "makeRequest").andReturn(Promise.resolve())
performRemote(() => {
const opts = NylasAPI.makeRequest.calls[0].args[0]
expect(opts.method).toBe("PUT")
expect(opts.path).toBe("/endpoint/server-123")
expect(opts.body).toBe(this.defaultArgs.newData)
expect(opts.accountId).toBe(this.defaultArgs.accountId)
})
});
});
describe("undo", () => {
beforeEach(() => {
this.task = new UpdateModelTask(this.defaultArgs)
});
it("indicates it's undoable", () => {
expect(this.task.canBeUndone()).toBe(true)
expect(this.task.isUndo()).toBe(false)
});
it("creates the appropriate UpdateModelTask", () => {
window.waitsForPromise(() => {
return this.task.performLocal().then(() => {
const undoTask = this.task.createUndoTask()
expect(undoTask.constructor.name).toBe("UpdateModelTask")
expect(undoTask.newData).toBe(this.task.oldModel)
expect(undoTask.newData.value).toBe("bar")
expect(undoTask.isUndo()).toBe(true)
});
});
});
});
});

View file

@ -16,385 +16,387 @@ class Bar extends Foo
@moreStuff = stuff
@method(stuff)
describe "registeredObjectReviver / registeredObjectReplacer", ->
beforeEach ->
@testThread = new Thread
id: 'local-1'
accountId: '1'
participants: [
new Contact(id: 'local-a', name: 'Juan', email:'juan@nylas.com', accountId: '1'),
new Contact(id: 'local-b', name: 'Ben', email:'ben@nylas.com', accountId: '1')
describe 'Utils', ->
describe "registeredObjectReviver / registeredObjectReplacer", ->
beforeEach ->
@testThread = new Thread
id: 'local-1'
accountId: '1'
participants: [
new Contact(id: 'local-a', name: 'Juan', email:'juan@nylas.com', accountId: '1'),
new Contact(id: 'local-b', name: 'Ben', email:'ben@nylas.com', accountId: '1')
]
subject: 'Test 1234'
it "should serialize and de-serialize models correctly", ->
expectedString = '[{"client_id":"local-1","account_id":"1","metadata":[],"subject":"Test 1234","participants":[{"client_id":"local-a","account_id":"1","name":"Juan","email":"juan@nylas.com","thirdPartyData":{},"id":"local-a"},{"client_id":"local-b","account_id":"1","name":"Ben","email":"ben@nylas.com","thirdPartyData":{},"id":"local-b"}],"id":"local-1","__constructorName":"Thread"}]'
jsonString = JSON.stringify([@testThread], Utils.registeredObjectReplacer)
expect(jsonString).toEqual(expectedString)
revived = JSON.parse(jsonString, Utils.registeredObjectReviver)
expect(revived).toEqual([@testThread])
it "should re-inflate Models in places they're not explicitly declared types", ->
b = new JSONBlob({id: "local-ThreadsToProcess", json: [@testThread]})
jsonString = JSON.stringify(b, Utils.registeredObjectReplacer)
expectedString = '{"client_id":"local-ThreadsToProcess","server_id":"local-ThreadsToProcess","json":[{"client_id":"local-1","account_id":"1","metadata":[],"subject":"Test 1234","participants":[{"client_id":"local-a","account_id":"1","name":"Juan","email":"juan@nylas.com","thirdPartyData":{},"id":"local-a"},{"client_id":"local-b","account_id":"1","name":"Ben","email":"ben@nylas.com","thirdPartyData":{},"id":"local-b"}],"id":"local-1","__constructorName":"Thread"}],"id":"local-ThreadsToProcess","__constructorName":"JSONBlob"}'
expect(jsonString).toEqual(expectedString)
revived = JSON.parse(jsonString, Utils.registeredObjectReviver)
expect(revived).toEqual(b)
expect(revived.json[0] instanceof Thread).toBe(true)
expect(revived.json[0].participants[0] instanceof Contact).toBe(true)
describe "modelFreeze", ->
it "should freeze the object", ->
o =
a: 1
b: 2
Utils.modelFreeze(o)
expect(Object.isFrozen(o)).toBe(true)
it "should not throw an exception when nulls appear in strange places", ->
t = new Thread(participants: [new Contact(email: 'ben@nylas.com'), null], subject: '123')
Utils.modelFreeze(t)
expect(Object.isFrozen(t)).toBe(true)
expect(Object.isFrozen(t.participants[0])).toBe(true)
describe "deepClone", ->
beforeEach ->
@v1 = [1,2,3]
@v2 = [4,5,6]
@foo = new Foo(@v1)
@bar = new Bar(@v2)
@o2 = [
@foo,
{v1: @v1, v2: @v2, foo: @foo, bar: @bar, baz: "baz", fn: Foo},
"abc"
]
subject: 'Test 1234'
@o2.circular = @o2
@o2Clone = Utils.deepClone(@o2)
it "should serialize and de-serialize models correctly", ->
expectedString = '[{"client_id":"local-1","account_id":"1","subject":"Test 1234","participants":[{"client_id":"local-a","account_id":"1","name":"Juan","email":"juan@nylas.com","thirdPartyData":{},"id":"local-a"},{"client_id":"local-b","account_id":"1","name":"Ben","email":"ben@nylas.com","thirdPartyData":{},"id":"local-b"}],"id":"local-1","__constructorName":"Thread"}]'
it "makes a deep clone", ->
@v1.push(4)
@v2.push(7)
@foo.stuff = "stuff"
@bar.subMethod("stuff")
expect(@o2Clone[0].stuff).toBeUndefined()
expect(@o2Clone[1].foo.stuff).toBeUndefined()
expect(@o2Clone[1].bar.stuff).toBeUndefined()
expect(@o2Clone[1].v1.length).toBe 3
expect(@o2Clone[1].v2.length).toBe 3
expect(@o2Clone[2]).toBe "abc"
jsonString = JSON.stringify([@testThread], Utils.registeredObjectReplacer)
expect(jsonString).toEqual(expectedString)
revived = JSON.parse(jsonString, Utils.registeredObjectReviver)
expect(revived).toEqual([@testThread])
it "does not deep clone the prototype", ->
@foo.field.a = "changed under the hood"
expect(@o2Clone[0].field.a).toBe "changed under the hood"
it "should re-inflate Models in places they're not explicitly declared types", ->
b = new JSONBlob({id: "local-ThreadsToProcess", json: [@testThread]})
jsonString = JSON.stringify(b, Utils.registeredObjectReplacer)
expectedString = '{"client_id":"local-ThreadsToProcess","server_id":"local-ThreadsToProcess","json":[{"client_id":"local-1","account_id":"1","subject":"Test 1234","participants":[{"client_id":"local-a","account_id":"1","name":"Juan","email":"juan@nylas.com","thirdPartyData":{},"id":"local-a"},{"client_id":"local-b","account_id":"1","name":"Ben","email":"ben@nylas.com","thirdPartyData":{},"id":"local-b"}],"id":"local-1","__constructorName":"Thread"}],"id":"local-ThreadsToProcess","__constructorName":"JSONBlob"}'
it "clones constructors properly", ->
expect((new @o2Clone[1].fn) instanceof Foo).toBe true
expect(jsonString).toEqual(expectedString)
revived = JSON.parse(jsonString, Utils.registeredObjectReviver)
expect(revived).toEqual(b)
expect(revived.json[0] instanceof Thread).toBe(true)
expect(revived.json[0].participants[0] instanceof Contact).toBe(true)
it "clones prototypes properly", ->
expect(@o2Clone[1].foo instanceof Foo).toBe true
expect(@o2Clone[1].bar instanceof Bar).toBe true
describe "modelFreeze", ->
it "should freeze the object", ->
o =
a: 1
b: 2
Utils.modelFreeze(o)
expect(Object.isFrozen(o)).toBe(true)
it "should not throw an exception when nulls appear in strange places", ->
t = new Thread(participants: [new Contact(email: 'ben@nylas.com'), null], subject: '123')
Utils.modelFreeze(t)
expect(Object.isFrozen(t)).toBe(true)
expect(Object.isFrozen(t.participants[0])).toBe(true)
describe "deepClone", ->
beforeEach ->
@v1 = [1,2,3]
@v2 = [4,5,6]
@foo = new Foo(@v1)
@bar = new Bar(@v2)
@o2 = [
@foo,
{v1: @v1, v2: @v2, foo: @foo, bar: @bar, baz: "baz", fn: Foo},
"abc"
]
@o2.circular = @o2
@o2Clone = Utils.deepClone(@o2)
it "makes a deep clone", ->
@v1.push(4)
@v2.push(7)
@foo.stuff = "stuff"
@bar.subMethod("stuff")
expect(@o2Clone[0].stuff).toBeUndefined()
expect(@o2Clone[1].foo.stuff).toBeUndefined()
expect(@o2Clone[1].bar.stuff).toBeUndefined()
expect(@o2Clone[1].v1.length).toBe 3
expect(@o2Clone[1].v2.length).toBe 3
expect(@o2Clone[2]).toBe "abc"
it "does not deep clone the prototype", ->
@foo.field.a = "changed under the hood"
expect(@o2Clone[0].field.a).toBe "changed under the hood"
it "clones constructors properly", ->
expect((new @o2Clone[1].fn) instanceof Foo).toBe true
it "clones prototypes properly", ->
expect(@o2Clone[1].foo instanceof Foo).toBe true
expect(@o2Clone[1].bar instanceof Bar).toBe true
it "can take a customizer to edit values as we clone", ->
clone = Utils.deepClone @o2, (key, clonedValue) ->
if key is "v2"
clonedValue.push("custom value")
return clonedValue
else return clonedValue
@v2.push(7)
expect(clone[1].v2.length).toBe 4
expect(clone[1].v2[3]).toBe "custom value"
it "can take a customizer to edit values as we clone", ->
clone = Utils.deepClone @o2, (key, clonedValue) ->
if key is "v2"
clonedValue.push("custom value")
return clonedValue
else return clonedValue
@v2.push(7)
expect(clone[1].v2.length).toBe 4
expect(clone[1].v2[3]).toBe "custom value"
# Pulled equality tests from underscore
# https://github.com/jashkenas/underscore/blob/master/test/objects.js
describe "isEqual", ->
describe "custom behavior", ->
it "makes functions always equal", ->
f1 = ->
f2 = ->
expect(Utils.isEqual(f1, f2)).toBe false
expect(Utils.isEqual(f1, f2, functionsAreEqual: true)).toBe true
describe "isEqual", ->
describe "custom behavior", ->
it "makes functions always equal", ->
f1 = ->
f2 = ->
expect(Utils.isEqual(f1, f2)).toBe false
expect(Utils.isEqual(f1, f2, functionsAreEqual: true)).toBe true
it "can ignore keys in objects", ->
o1 =
foo: "bar"
arr: [1,2,3]
nest: {a: "b", c: 1, ignoreMe: 5}
ignoreMe: 123
o2 =
foo: "bar"
arr: [1,2,3]
nest: {a: "b", c: 1, ignoreMe: 10}
ignoreMe: 456
it "can ignore keys in objects", ->
o1 =
foo: "bar"
arr: [1,2,3]
nest: {a: "b", c: 1, ignoreMe: 5}
ignoreMe: 123
o2 =
foo: "bar"
arr: [1,2,3]
nest: {a: "b", c: 1, ignoreMe: 10}
ignoreMe: 456
expect(Utils.isEqual(o1, o2)).toBe false
expect(Utils.isEqual(o1, o2, ignoreKeys: ["ignoreMe"])).toBe true
expect(Utils.isEqual(o1, o2)).toBe false
expect(Utils.isEqual(o1, o2, ignoreKeys: ["ignoreMe"])).toBe true
it "passes all underscore equality tests", ->
First = ->
@value = 1
First.prototype.value = 1
it "passes all underscore equality tests", ->
First = ->
@value = 1
First.prototype.value = 1
Second = ->
@value = 1
Second.prototype.value = 2
Second = ->
@value = 1
Second.prototype.value = 2
ok = (val) -> expect(val).toBe true
ok = (val) -> expect(val).toBe true
# Basic equality and identity comparisons.
ok(Utils.isEqual(null, null), '`null` is equal to `null`')
ok(Utils.isEqual(), '`undefined` is equal to `undefined`')
# Basic equality and identity comparisons.
ok(Utils.isEqual(null, null), '`null` is equal to `null`')
ok(Utils.isEqual(), '`undefined` is equal to `undefined`')
ok(!Utils.isEqual(0, -0), '`0` is not equal to `-0`')
ok(!Utils.isEqual(-0, 0), 'Commutative equality is implemented for `0` and `-0`')
ok(!Utils.isEqual(null, undefined), '`null` is not equal to `undefined`')
ok(!Utils.isEqual(undefined, null), 'Commutative equality is implemented for `null` and `undefined`')
ok(!Utils.isEqual(0, -0), '`0` is not equal to `-0`')
ok(!Utils.isEqual(-0, 0), 'Commutative equality is implemented for `0` and `-0`')
ok(!Utils.isEqual(null, undefined), '`null` is not equal to `undefined`')
ok(!Utils.isEqual(undefined, null), 'Commutative equality is implemented for `null` and `undefined`')
# String object and primitive comparisons.
ok(Utils.isEqual('Curly', 'Curly'), 'Identical string primitives are equal')
ok(Utils.isEqual(new String('Curly'), new String('Curly')), 'String objects with identical primitive values are equal')
ok(Utils.isEqual(new String('Curly'), 'Curly'), 'String primitives and their corresponding object wrappers are equal')
ok(Utils.isEqual('Curly', new String('Curly')), 'Commutative equality is implemented for string objects and primitives')
# String object and primitive comparisons.
ok(Utils.isEqual('Curly', 'Curly'), 'Identical string primitives are equal')
ok(Utils.isEqual(new String('Curly'), new String('Curly')), 'String objects with identical primitive values are equal')
ok(Utils.isEqual(new String('Curly'), 'Curly'), 'String primitives and their corresponding object wrappers are equal')
ok(Utils.isEqual('Curly', new String('Curly')), 'Commutative equality is implemented for string objects and primitives')
ok(!Utils.isEqual('Curly', 'Larry'), 'String primitives with different values are not equal')
ok(!Utils.isEqual(new String('Curly'), new String('Larry')), 'String objects with different primitive values are not equal')
ok(!Utils.isEqual(new String('Curly'), {toString: -> 'Curly'}), 'String objects and objects with a custom `toString` method are not equal')
ok(!Utils.isEqual('Curly', 'Larry'), 'String primitives with different values are not equal')
ok(!Utils.isEqual(new String('Curly'), new String('Larry')), 'String objects with different primitive values are not equal')
ok(!Utils.isEqual(new String('Curly'), {toString: -> 'Curly'}), 'String objects and objects with a custom `toString` method are not equal')
# Number object and primitive comparisons.
ok(Utils.isEqual(75, 75), 'Identical number primitives are equal')
ok(Utils.isEqual(new Number(75), new Number(75)), 'Number objects with identical primitive values are equal')
ok(Utils.isEqual(75, new Number(75)), 'Number primitives and their corresponding object wrappers are equal')
ok(Utils.isEqual(new Number(75), 75), 'Commutative equality is implemented for number objects and primitives')
ok(!Utils.isEqual(new Number(0), -0), '`new Number(0)` and `-0` are not equal')
ok(!Utils.isEqual(0, new Number(-0)), 'Commutative equality is implemented for `new Number(0)` and `-0`')
# Number object and primitive comparisons.
ok(Utils.isEqual(75, 75), 'Identical number primitives are equal')
ok(Utils.isEqual(new Number(75), new Number(75)), 'Number objects with identical primitive values are equal')
ok(Utils.isEqual(75, new Number(75)), 'Number primitives and their corresponding object wrappers are equal')
ok(Utils.isEqual(new Number(75), 75), 'Commutative equality is implemented for number objects and primitives')
ok(!Utils.isEqual(new Number(0), -0), '`new Number(0)` and `-0` are not equal')
ok(!Utils.isEqual(0, new Number(-0)), 'Commutative equality is implemented for `new Number(0)` and `-0`')
ok(!Utils.isEqual(new Number(75), new Number(63)), 'Number objects with different primitive values are not equal')
ok(!Utils.isEqual(new Number(63), {valueOf: -> 63 }), 'Number objects and objects with a `valueOf` method are not equal')
ok(!Utils.isEqual(new Number(75), new Number(63)), 'Number objects with different primitive values are not equal')
ok(!Utils.isEqual(new Number(63), {valueOf: -> 63 }), 'Number objects and objects with a `valueOf` method are not equal')
# Comparisons involving `NaN`.
ok(Utils.isEqual(NaN, NaN), '`NaN` is equal to `NaN`')
ok(Utils.isEqual(new Object(NaN), NaN), 'Object(`NaN`) is equal to `NaN`')
ok(!Utils.isEqual(61, NaN), 'A number primitive is not equal to `NaN`')
ok(!Utils.isEqual(new Number(79), NaN), 'A number object is not equal to `NaN`')
ok(!Utils.isEqual(Infinity, NaN), '`Infinity` is not equal to `NaN`')
# Comparisons involving `NaN`.
ok(Utils.isEqual(NaN, NaN), '`NaN` is equal to `NaN`')
ok(Utils.isEqual(new Object(NaN), NaN), 'Object(`NaN`) is equal to `NaN`')
ok(!Utils.isEqual(61, NaN), 'A number primitive is not equal to `NaN`')
ok(!Utils.isEqual(new Number(79), NaN), 'A number object is not equal to `NaN`')
ok(!Utils.isEqual(Infinity, NaN), '`Infinity` is not equal to `NaN`')
# Boolean object and primitive comparisons.
ok(Utils.isEqual(true, true), 'Identical boolean primitives are equal')
ok(Utils.isEqual(new Boolean, new Boolean), 'Boolean objects with identical primitive values are equal')
ok(Utils.isEqual(true, new Boolean(true)), 'Boolean primitives and their corresponding object wrappers are equal')
ok(Utils.isEqual(new Boolean(true), true), 'Commutative equality is implemented for booleans')
ok(!Utils.isEqual(new Boolean(true), new Boolean), 'Boolean objects with different primitive values are not equal')
# Boolean object and primitive comparisons.
ok(Utils.isEqual(true, true), 'Identical boolean primitives are equal')
ok(Utils.isEqual(new Boolean, new Boolean), 'Boolean objects with identical primitive values are equal')
ok(Utils.isEqual(true, new Boolean(true)), 'Boolean primitives and their corresponding object wrappers are equal')
ok(Utils.isEqual(new Boolean(true), true), 'Commutative equality is implemented for booleans')
ok(!Utils.isEqual(new Boolean(true), new Boolean), 'Boolean objects with different primitive values are not equal')
# Common type coercions.
ok(!Utils.isEqual(new Boolean(false), true), '`new Boolean(false)` is not equal to `true`')
ok(!Utils.isEqual('75', 75), 'String and number primitives with like values are not equal')
ok(!Utils.isEqual(new Number(63), new String(63)), 'String and number objects with like values are not equal')
ok(!Utils.isEqual(75, '75'), 'Commutative equality is implemented for like string and number values')
ok(!Utils.isEqual(0, ''), 'Number and string primitives with like values are not equal')
ok(!Utils.isEqual(1, true), 'Number and boolean primitives with like values are not equal')
ok(!Utils.isEqual(new Boolean(false), new Number(0)), 'Boolean and number objects with like values are not equal')
ok(!Utils.isEqual(false, new String('')), 'Boolean primitives and string objects with like values are not equal')
ok(!Utils.isEqual(12564504e5, new Date(2009, 9, 25)), 'Dates and their corresponding numeric primitive values are not equal')
# Common type coercions.
ok(!Utils.isEqual(new Boolean(false), true), '`new Boolean(false)` is not equal to `true`')
ok(!Utils.isEqual('75', 75), 'String and number primitives with like values are not equal')
ok(!Utils.isEqual(new Number(63), new String(63)), 'String and number objects with like values are not equal')
ok(!Utils.isEqual(75, '75'), 'Commutative equality is implemented for like string and number values')
ok(!Utils.isEqual(0, ''), 'Number and string primitives with like values are not equal')
ok(!Utils.isEqual(1, true), 'Number and boolean primitives with like values are not equal')
ok(!Utils.isEqual(new Boolean(false), new Number(0)), 'Boolean and number objects with like values are not equal')
ok(!Utils.isEqual(false, new String('')), 'Boolean primitives and string objects with like values are not equal')
ok(!Utils.isEqual(12564504e5, new Date(2009, 9, 25)), 'Dates and their corresponding numeric primitive values are not equal')
# Dates.
ok(Utils.isEqual(new Date(2009, 9, 25), new Date(2009, 9, 25)), 'Date objects referencing identical times are equal')
ok(!Utils.isEqual(new Date(2009, 9, 25), new Date(2009, 11, 13)), 'Date objects referencing different times are not equal')
ok(!Utils.isEqual(new Date(2009, 11, 13), {
getTime: -> 12606876e5
}), 'Date objects and objects with a `getTime` method are not equal')
ok(!Utils.isEqual(new Date('Curly'), new Date('Curly')), 'Invalid dates are not equal')
# Dates.
ok(Utils.isEqual(new Date(2009, 9, 25), new Date(2009, 9, 25)), 'Date objects referencing identical times are equal')
ok(!Utils.isEqual(new Date(2009, 9, 25), new Date(2009, 11, 13)), 'Date objects referencing different times are not equal')
ok(!Utils.isEqual(new Date(2009, 11, 13), {
getTime: -> 12606876e5
}), 'Date objects and objects with a `getTime` method are not equal')
ok(!Utils.isEqual(new Date('Curly'), new Date('Curly')), 'Invalid dates are not equal')
# Functions.
ok(!Utils.isEqual(First, Second), 'Different functions with identical bodies and source code representations are not equal')
# Functions.
ok(!Utils.isEqual(First, Second), 'Different functions with identical bodies and source code representations are not equal')
# RegExps.
ok(Utils.isEqual(/(?:)/gim, /(?:)/gim), 'RegExps with equivalent patterns and flags are equal')
ok(Utils.isEqual(/(?:)/gi, /(?:)/ig), 'Flag order is not significant')
ok(!Utils.isEqual(/(?:)/g, /(?:)/gi), 'RegExps with equivalent patterns and different flags are not equal')
ok(!Utils.isEqual(/Moe/gim, /Curly/gim), 'RegExps with different patterns and equivalent flags are not equal')
ok(!Utils.isEqual(/(?:)/gi, /(?:)/g), 'Commutative equality is implemented for RegExps')
ok(!Utils.isEqual(/Curly/g, {source: 'Larry', global: true, ignoreCase: false, multiline: false}), 'RegExps and RegExp-like objects are not equal')
# RegExps.
ok(Utils.isEqual(/(?:)/gim, /(?:)/gim), 'RegExps with equivalent patterns and flags are equal')
ok(Utils.isEqual(/(?:)/gi, /(?:)/ig), 'Flag order is not significant')
ok(!Utils.isEqual(/(?:)/g, /(?:)/gi), 'RegExps with equivalent patterns and different flags are not equal')
ok(!Utils.isEqual(/Moe/gim, /Curly/gim), 'RegExps with different patterns and equivalent flags are not equal')
ok(!Utils.isEqual(/(?:)/gi, /(?:)/g), 'Commutative equality is implemented for RegExps')
ok(!Utils.isEqual(/Curly/g, {source: 'Larry', global: true, ignoreCase: false, multiline: false}), 'RegExps and RegExp-like objects are not equal')
# Empty arrays, array-like objects, and object literals.
ok(Utils.isEqual({}, {}), 'Empty object literals are equal')
ok(Utils.isEqual([], []), 'Empty array literals are equal')
ok(Utils.isEqual([{}], [{}]), 'Empty nested arrays and objects are equal')
ok(!Utils.isEqual({length: 0}, []), 'Array-like objects and arrays are not equal.')
ok(!Utils.isEqual([], {length: 0}), 'Commutative equality is implemented for array-like objects')
# Empty arrays, array-like objects, and object literals.
ok(Utils.isEqual({}, {}), 'Empty object literals are equal')
ok(Utils.isEqual([], []), 'Empty array literals are equal')
ok(Utils.isEqual([{}], [{}]), 'Empty nested arrays and objects are equal')
ok(!Utils.isEqual({length: 0}, []), 'Array-like objects and arrays are not equal.')
ok(!Utils.isEqual([], {length: 0}), 'Commutative equality is implemented for array-like objects')
ok(!Utils.isEqual({}, []), 'Object literals and array literals are not equal')
ok(!Utils.isEqual([], {}), 'Commutative equality is implemented for objects and arrays')
ok(!Utils.isEqual({}, []), 'Object literals and array literals are not equal')
ok(!Utils.isEqual([], {}), 'Commutative equality is implemented for objects and arrays')
# Arrays with primitive and object values.
ok(Utils.isEqual([1, 'Larry', true], [1, 'Larry', true]), 'Arrays containing identical primitives are equal')
ok(Utils.isEqual([/Moe/g, new Date(2009, 9, 25)], [/Moe/g, new Date(2009, 9, 25)]), 'Arrays containing equivalent elements are equal')
# Arrays with primitive and object values.
ok(Utils.isEqual([1, 'Larry', true], [1, 'Larry', true]), 'Arrays containing identical primitives are equal')
ok(Utils.isEqual([/Moe/g, new Date(2009, 9, 25)], [/Moe/g, new Date(2009, 9, 25)]), 'Arrays containing equivalent elements are equal')
# Multi-dimensional arrays.
a = [new Number(47), false, 'Larry', /Moe/, new Date(2009, 11, 13), ['running', 'biking', new String('programming')], {a: 47}]
b = [new Number(47), false, 'Larry', /Moe/, new Date(2009, 11, 13), ['running', 'biking', new String('programming')], {a: 47}]
ok(Utils.isEqual(a, b), 'Arrays containing nested arrays and objects are recursively compared')
# Multi-dimensional arrays.
a = [new Number(47), false, 'Larry', /Moe/, new Date(2009, 11, 13), ['running', 'biking', new String('programming')], {a: 47}]
b = [new Number(47), false, 'Larry', /Moe/, new Date(2009, 11, 13), ['running', 'biking', new String('programming')], {a: 47}]
ok(Utils.isEqual(a, b), 'Arrays containing nested arrays and objects are recursively compared')
# Overwrite the methods defined in ES 5.1 section 15.4.4.
a.forEach = a.map = a.filter = a.every = a.indexOf = a.lastIndexOf = a.some = a.reduce = a.reduceRight = null
b.join = b.pop = b.reverse = b.shift = b.slice = b.splice = b.concat = b.sort = b.unshift = null
# Overwrite the methods defined in ES 5.1 section 15.4.4.
a.forEach = a.map = a.filter = a.every = a.indexOf = a.lastIndexOf = a.some = a.reduce = a.reduceRight = null
b.join = b.pop = b.reverse = b.shift = b.slice = b.splice = b.concat = b.sort = b.unshift = null
# Array elements and properties.
ok(Utils.isEqual(a, b), 'Arrays containing equivalent elements and different non-numeric properties are equal')
a.push('White Rocks')
ok(!Utils.isEqual(a, b), 'Arrays of different lengths are not equal')
a.push('East Boulder')
b.push('Gunbarrel Ranch', 'Teller Farm')
ok(!Utils.isEqual(a, b), 'Arrays of identical lengths containing different elements are not equal')
# Array elements and properties.
ok(Utils.isEqual(a, b), 'Arrays containing equivalent elements and different non-numeric properties are equal')
a.push('White Rocks')
ok(!Utils.isEqual(a, b), 'Arrays of different lengths are not equal')
a.push('East Boulder')
b.push('Gunbarrel Ranch', 'Teller Farm')
ok(!Utils.isEqual(a, b), 'Arrays of identical lengths containing different elements are not equal')
# Sparse arrays.
ok(Utils.isEqual(Array(3), Array(3)), 'Sparse arrays of identical lengths are equal')
ok(!Utils.isEqual(Array(3), Array(6)), 'Sparse arrays of different lengths are not equal when both are empty')
# Sparse arrays.
ok(Utils.isEqual(Array(3), Array(3)), 'Sparse arrays of identical lengths are equal')
ok(!Utils.isEqual(Array(3), Array(6)), 'Sparse arrays of different lengths are not equal when both are empty')
sparse = []
sparse[1] = 5
ok(Utils.isEqual(sparse, [undefined, 5]), 'Handles sparse arrays as dense')
sparse = []
sparse[1] = 5
ok(Utils.isEqual(sparse, [undefined, 5]), 'Handles sparse arrays as dense')
# Simple objects.
ok(Utils.isEqual({a: 'Curly', b: 1, c: true}, {a: 'Curly', b: 1, c: true}), 'Objects containing identical primitives are equal')
ok(Utils.isEqual({a: /Curly/g, b: new Date(2009, 11, 13)}, {a: /Curly/g, b: new Date(2009, 11, 13)}), 'Objects containing equivalent members are equal')
ok(!Utils.isEqual({a: 63, b: 75}, {a: 61, b: 55}), 'Objects of identical sizes with different values are not equal')
ok(!Utils.isEqual({a: 63, b: 75}, {a: 61, c: 55}), 'Objects of identical sizes with different property names are not equal')
ok(!Utils.isEqual({a: 1, b: 2}, {a: 1}), 'Objects of different sizes are not equal')
ok(!Utils.isEqual({a: 1}, {a: 1, b: 2}), 'Commutative equality is implemented for objects')
ok(!Utils.isEqual({x: 1, y: undefined}, {x: 1, z: 2}), 'Objects with identical keys and different values are not equivalent')
# Simple objects.
ok(Utils.isEqual({a: 'Curly', b: 1, c: true}, {a: 'Curly', b: 1, c: true}), 'Objects containing identical primitives are equal')
ok(Utils.isEqual({a: /Curly/g, b: new Date(2009, 11, 13)}, {a: /Curly/g, b: new Date(2009, 11, 13)}), 'Objects containing equivalent members are equal')
ok(!Utils.isEqual({a: 63, b: 75}, {a: 61, b: 55}), 'Objects of identical sizes with different values are not equal')
ok(!Utils.isEqual({a: 63, b: 75}, {a: 61, c: 55}), 'Objects of identical sizes with different property names are not equal')
ok(!Utils.isEqual({a: 1, b: 2}, {a: 1}), 'Objects of different sizes are not equal')
ok(!Utils.isEqual({a: 1}, {a: 1, b: 2}), 'Commutative equality is implemented for objects')
ok(!Utils.isEqual({x: 1, y: undefined}, {x: 1, z: 2}), 'Objects with identical keys and different values are not equivalent')
# `A` contains nested objects and arrays.
a = {
name: new String('Moe Howard'),
age: new Number(77),
stooge: true,
hobbies: ['acting'],
film: {
name: 'Sing a Song of Six Pants',
release: new Date(1947, 9, 30),
stars: [new String('Larry Fine'), 'Shemp Howard'],
minutes: new Number(16),
seconds: 54
# `A` contains nested objects and arrays.
a = {
name: new String('Moe Howard'),
age: new Number(77),
stooge: true,
hobbies: ['acting'],
film: {
name: 'Sing a Song of Six Pants',
release: new Date(1947, 9, 30),
stars: [new String('Larry Fine'), 'Shemp Howard'],
minutes: new Number(16),
seconds: 54
}
}
}
# `B` contains equivalent nested objects and arrays.
b = {
name: new String('Moe Howard'),
age: new Number(77),
stooge: true,
hobbies: ['acting'],
film: {
name: 'Sing a Song of Six Pants',
release: new Date(1947, 9, 30),
stars: [new String('Larry Fine'), 'Shemp Howard'],
minutes: new Number(16),
seconds: 54
# `B` contains equivalent nested objects and arrays.
b = {
name: new String('Moe Howard'),
age: new Number(77),
stooge: true,
hobbies: ['acting'],
film: {
name: 'Sing a Song of Six Pants',
release: new Date(1947, 9, 30),
stars: [new String('Larry Fine'), 'Shemp Howard'],
minutes: new Number(16),
seconds: 54
}
}
}
ok(Utils.isEqual(a, b), 'Objects with nested equivalent members are recursively compared')
ok(Utils.isEqual(a, b), 'Objects with nested equivalent members are recursively compared')
# Instances.
ok(Utils.isEqual(new First, new First), 'Object instances are equal')
ok(!Utils.isEqual(new First, new Second), 'Objects with different constructors and identical own properties are not equal')
ok(!Utils.isEqual({value: 1}, new First), 'Object instances and objects sharing equivalent properties are not equal')
ok(!Utils.isEqual({value: 2}, new Second), 'The prototype chain of objects should not be examined')
# Instances.
ok(Utils.isEqual(new First, new First), 'Object instances are equal')
ok(!Utils.isEqual(new First, new Second), 'Objects with different constructors and identical own properties are not equal')
ok(!Utils.isEqual({value: 1}, new First), 'Object instances and objects sharing equivalent properties are not equal')
ok(!Utils.isEqual({value: 2}, new Second), 'The prototype chain of objects should not be examined')
# Circular Arrays.
(a = []).push(a)
(b = []).push(b)
ok(Utils.isEqual(a, b), 'Arrays containing circular references are equal')
a.push(new String('Larry'))
b.push(new String('Larry'))
ok(Utils.isEqual(a, b), 'Arrays containing circular references and equivalent properties are equal')
a.push('Shemp')
b.push('Curly')
ok(!Utils.isEqual(a, b), 'Arrays containing circular references and different properties are not equal')
# Circular Arrays.
(a = []).push(a)
(b = []).push(b)
ok(Utils.isEqual(a, b), 'Arrays containing circular references are equal')
a.push(new String('Larry'))
b.push(new String('Larry'))
ok(Utils.isEqual(a, b), 'Arrays containing circular references and equivalent properties are equal')
a.push('Shemp')
b.push('Curly')
ok(!Utils.isEqual(a, b), 'Arrays containing circular references and different properties are not equal')
# More circular arrays #767.
a = ['everything is checked but', 'this', 'is not']
a[1] = a
b = ['everything is checked but', ['this', 'array'], 'is not']
ok(!Utils.isEqual(a, b), 'Comparison of circular references with non-circular references are not equal')
# More circular arrays #767.
a = ['everything is checked but', 'this', 'is not']
a[1] = a
b = ['everything is checked but', ['this', 'array'], 'is not']
ok(!Utils.isEqual(a, b), 'Comparison of circular references with non-circular references are not equal')
# Circular Objects.
a = {abc: null}
b = {abc: null}
a.abc = a
b.abc = b
ok(Utils.isEqual(a, b), 'Objects containing circular references are equal')
a.def = 75
b.def = 75
ok(Utils.isEqual(a, b), 'Objects containing circular references and equivalent properties are equal')
a.def = new Number(75)
b.def = new Number(63)
ok(!Utils.isEqual(a, b), 'Objects containing circular references and different properties are not equal')
# Circular Objects.
a = {abc: null}
b = {abc: null}
a.abc = a
b.abc = b
ok(Utils.isEqual(a, b), 'Objects containing circular references are equal')
a.def = 75
b.def = 75
ok(Utils.isEqual(a, b), 'Objects containing circular references and equivalent properties are equal')
a.def = new Number(75)
b.def = new Number(63)
ok(!Utils.isEqual(a, b), 'Objects containing circular references and different properties are not equal')
# More circular objects #767.
a = {everything: 'is checked', but: 'this', is: 'not'}
a.but = a
b = {everything: 'is checked', but: {that: 'object'}, is: 'not'}
ok(!Utils.isEqual(a, b), 'Comparison of circular references with non-circular object references are not equal')
# More circular objects #767.
a = {everything: 'is checked', but: 'this', is: 'not'}
a.but = a
b = {everything: 'is checked', but: {that: 'object'}, is: 'not'}
ok(!Utils.isEqual(a, b), 'Comparison of circular references with non-circular object references are not equal')
# Cyclic Structures.
a = [{abc: null}]
b = [{abc: null}]
(a[0].abc = a).push(a)
(b[0].abc = b).push(b)
ok(Utils.isEqual(a, b), 'Cyclic structures are equal')
a[0].def = 'Larry'
b[0].def = 'Larry'
ok(Utils.isEqual(a, b), 'Cyclic structures containing equivalent properties are equal')
a[0].def = new String('Larry')
b[0].def = new String('Curly')
ok(!Utils.isEqual(a, b), 'Cyclic structures containing different properties are not equal')
# Cyclic Structures.
a = [{abc: null}]
b = [{abc: null}]
(a[0].abc = a).push(a)
(b[0].abc = b).push(b)
ok(Utils.isEqual(a, b), 'Cyclic structures are equal')
a[0].def = 'Larry'
b[0].def = 'Larry'
ok(Utils.isEqual(a, b), 'Cyclic structures containing equivalent properties are equal')
a[0].def = new String('Larry')
b[0].def = new String('Curly')
ok(!Utils.isEqual(a, b), 'Cyclic structures containing different properties are not equal')
# Complex Circular References.
a = {foo: {b: {foo: {c: {foo: null}}}}}
b = {foo: {b: {foo: {c: {foo: null}}}}}
a.foo.b.foo.c.foo = a
b.foo.b.foo.c.foo = b
ok(Utils.isEqual(a, b), 'Cyclic structures with nested and identically-named properties are equal')
# Complex Circular References.
a = {foo: {b: {foo: {c: {foo: null}}}}}
b = {foo: {b: {foo: {c: {foo: null}}}}}
a.foo.b.foo.c.foo = a
b.foo.b.foo.c.foo = b
ok(Utils.isEqual(a, b), 'Cyclic structures with nested and identically-named properties are equal')
# Chaining.
# NOTE: underscore doesn't support chaining
#
# ok(!Utils.isEqual(_({x: 1, y: undefined}).chain(), _({x: 1, z: 2}).chain()), 'Chained objects containing different values are not equal')
#
# a = _({x: 1, y: 2}).chain()
# b = _({x: 1, y: 2}).chain()
# equal(Utils.isEqual(a.isEqual(b), _(true)), true, '`isEqual` can be chained')
# Chaining.
# NOTE: underscore doesn't support chaining
#
# ok(!Utils.isEqual(_({x: 1, y: undefined}).chain(), _({x: 1, z: 2}).chain()), 'Chained objects containing different values are not equal')
#
# a = _({x: 1, y: 2}).chain()
# b = _({x: 1, y: 2}).chain()
# equal(Utils.isEqual(a.isEqual(b), _(true)), true, '`isEqual` can be chained')
# Objects without a `constructor` property
if Object.create
a = Object.create(null, {x: {value: 1, enumerable: true}})
b = {x: 1}
ok(Utils.isEqual(a, b), 'Handles objects without a constructor (e.g. from Object.create')
# Objects without a `constructor` property
if Object.create
a = Object.create(null, {x: {value: 1, enumerable: true}})
b = {x: 1}
ok(Utils.isEqual(a, b), 'Handles objects without a constructor (e.g. from Object.create')
Foo = -> @a = 1
Foo.prototype.constructor = null
Foo = -> @a = 1
Foo.prototype.constructor = null
other = {a: 1}
ok(!Utils.isEqual(new Foo, other))
other = {a: 1}
ok(!Utils.isEqual(new Foo, other))
describe "subjectWithPrefix", ->
it "should replace an existing Re:", ->
expect(Utils.subjectWithPrefix("Re: Test Case", "Fwd:")).toEqual("Fwd: Test Case")
describe "subjectWithPrefix", ->
it "should replace an existing Re:", ->
expect(Utils.subjectWithPrefix("Re: Test Case", "Fwd:")).toEqual("Fwd: Test Case")
it "should replace an existing re:", ->
expect(Utils.subjectWithPrefix("re: Test Case", "Fwd:")).toEqual("Fwd: Test Case")
it "should replace an existing re:", ->
expect(Utils.subjectWithPrefix("re: Test Case", "Fwd:")).toEqual("Fwd: Test Case")
it "should replace an existing Fwd:", ->
expect(Utils.subjectWithPrefix("Fwd: Test Case", "Re:")).toEqual("Re: Test Case")
it "should replace an existing Fwd:", ->
expect(Utils.subjectWithPrefix("Fwd: Test Case", "Re:")).toEqual("Re: Test Case")
it "should replace an existing fwd:", ->
expect(Utils.subjectWithPrefix("fwd: Test Case", "Re:")).toEqual("Re: Test Case")
it "should replace an existing fwd:", ->
expect(Utils.subjectWithPrefix("fwd: Test Case", "Re:")).toEqual("Re: Test Case")
it "should not replace Re: or Fwd: found embedded in the subject", ->
expect(Utils.subjectWithPrefix("My questions are: 123", "Fwd:")).toEqual("Fwd: My questions are: 123")
expect(Utils.subjectWithPrefix("My questions fwd: 123", "Fwd:")).toEqual("Fwd: My questions fwd: 123")
it "should not replace Re: or Fwd: found embedded in the subject", ->
expect(Utils.subjectWithPrefix("My questions are: 123", "Fwd:")).toEqual("Fwd: My questions are: 123")
expect(Utils.subjectWithPrefix("My questions fwd: 123", "Fwd:")).toEqual("Fwd: My questions fwd: 123")
it "should work if no existing prefix is present", ->
expect(Utils.subjectWithPrefix("My questions", "Fwd:")).toEqual("Fwd: My questions")
it "should work if no existing prefix is present", ->
expect(Utils.subjectWithPrefix("My questions", "Fwd:")).toEqual("Fwd: My questions")

View file

@ -1,202 +0,0 @@
import _ from 'underscore'
import Rx from 'rx-lite'
import {
Model,
Actions,
Metadata,
DatabaseStore,
SyncbackMetadataTask} from 'nylas-exports'
/**
* CloudStorage lets you associate metadata with any Nylas API object.
*
* That associated data is automatically stored in the cloud and synced
* with the local database.
*
* You can also get associated data and live-subscribe to associated data.
*
* On the Nylas API server this is backed by the `/metadata` endpoint.
*
* It is automatically locally replicated and synced with the `Metadata`
* Database table.
*
* Every interaction with the metadata service is automatically scoped
* by both your unique Plugin ID.
*
* You must asscoiate with pre-existing objects that inherit from `Model`.
* We will extract the appropriate `accountId` from those objects to
* correctly associate your data.
*
* You can observe one or more objects and treat them as live,
* asynchronous event streams. We use the `Rx.Observable` interface for
* this.
*
* Under the hood the observables are hooked up to our delta streaming
* endpoint via Database events.
*
* ## Example Usage:
*
* In /My-Plugin/lib/main.es6
*
* ```
* activate(localState, cloudStorage) {
* DatabaseStore.findBy(Thread, "some_thread_id").then((thread) => {
*
* const data = { foo: "bar" }
* cloudStorage.associateMetadata({objects: [thread], data: data})
*
* cloudStorage.getMetadata({objects: [thread]}).then((metadata) => {
* console.log(metadata[0]);
* });
*
* const observer = cloudStorage.observeMetadata({objects: [thread]})
* this.subscription = observer.subscribe((newMetadata) => {
* console.log("New Metadata!", newMetadata[0])
* });
* });
* }
*
* deactivate() {
* this.subscription.dispose()
* }
* ```
*
* A CloudStorage instance is a pre-scoped helper to allow plugins to
* interface with a server-side key-value store.
*
* When you `associateMetadata` we generate a `SyncbackMetadataTask`
* pre-scoped with the `pluginId`.
* @class CloudStorage
*/
export default class CloudStorage {
// You never need to instantiate new instances of `CloudStorage`. The
// Nylas package manager will take care of this.
constructor(pluginId) {
this.pluginId = pluginId
this.applicationId = pluginId
}
/**
* Associates one or more {Model}-inheriting objects with arbitrary
* `data`. This is automatically persisted to both the `Database` and a
* the `/metadata` endpoint on the Nylas API
*
* @param {object} props - props for `associateMetadata`
* @param {array} props.objects - an array of one or more objects to
* associate with the same metadata. These are objects pulled out of the
* Database.
* @param {object} props.data - arbitray JSON-serializable data.
*/
associateMetadata({objects, data}) {
const objectsToAssociate = this._resolveObjects(objects)
DatabaseStore.findAll(Metadata,
{objectId: _.pluck(objectsToAssociate, "id")})
.then((existingMetadata = []) => {
const metadataByObjectId = {}
for (const m of existingMetadata) {
metadataByObjectId[m.objectId] = m
}
const metadata = []
for (const objectToAssociate of objectsToAssociate) {
let metadatum = metadataByObjectId[objectToAssociate.id]
if (!metadatum) {
metadatum = this._newMetadataObject(objectToAssociate)
} else {
metadatum = this._validateMetadatum(metadatum, objectToAssociate)
}
metadatum.value = data
metadata.push(metadatum)
}
return DatabaseStore.inTransaction((t) => {
return t.persistModels(metadata).then(() => {
return this._syncbackMetadata(metadata)
})
});
})
}
/**
* Get the metadata associated with one or more objects.
*
* @param {object} props - props for `getMetadata`
* @param {array} props.objects - an array of one or more objects to
* load metadata for (if there is any)
* @returns Promise that resolves to an array of zero or more matching
* {Metadata} objects.
*/
getMetadata({objects}) {
const associatedObjects = this._resolveObjects(objects)
return DatabaseStore.findAll(Metadata,
{objectId: _.pluck(associatedObjects, "id")})
}
/**
* Observe the metadata on a set of objects via an RX.Observable
*
* @param {object} props - props for `getMetadata`
* @param {array} props.objects - an array of one or more objects to
* load metadata for (if there is any)
* @returns Rx.Observable object that you can call `subscribe` on to
* subscribe to any changes on the matching query. The onChange callback
* you pass to subscribe will be passed an array of zero or more
* matching {Metadata} objects.
*/
observeMetadata({objects}) {
const associatedObjects = this._resolveObjects(objects)
const q = DatabaseStore.findAll(Metadata,
{objectId: _.pluck(associatedObjects, "id")})
return Rx.Observable.fromQuery(q)
}
_syncbackMetadata(metadata) {
for (const metadatum of metadata) {
const task = new SyncbackMetadataTask({
clientId: metadatum.clientId,
});
Actions.queueTask(task)
}
}
_newMetadataObject(objectToAssociate) {
return new Metadata({
applicationId: this.applicationId,
objectType: this._typeFromObject(objectToAssociate),
objectId: objectToAssociate.id,
accountId: objectToAssociate.accountId,
});
}
_validateMetadatum(metadatum, objectToAssociate) {
const toMatch = {
applicationId: this.applicationId === metadatum.applicationId,
objectType: this._typeFromObject(objectToAssociate) === metadatum.objectType,
objectId: objectToAssociate.id === metadatum.objectId,
accountId: objectToAssociate.accountId === metadatum.accountId,
}
if (_.every(toMatch, (match) => {return match})) {
return metadatum
}
NylasEnv.reportError(new Error(`Metadata object ${metadatum.id} doesn't match data for associated object ${objectToAssociate.id}. Automatically correcting to match.`, toMatch))
const json = this._newMetadataObject(objectToAssociate).toJSON()
metadatum.fromJSON(json)
return metadatum
}
_typeFromObject(object) {
return object.constructor.name.toLowerCase()
}
_resolveObjects(objects) {
const isModel = (obj) => {return obj instanceof Model}
if (isModel(objects)) {
return [objects]
} else if (_.isArray(objects) && objects.length > 0 && _.every(objects, isModel)) {
return objects
}
throw new Error("Must pass one or more `Model` objects to associate")
}
}

View file

@ -75,6 +75,13 @@ class Actions
@didPassivelyReceiveNewModels: ActionScopeGlobal
@downloadStateChanged: ActionScopeGlobal
###
Public: Fired when a draft is successfully sent
*Scope: Global*
Recieves the clientId of the message that was sent
###
@sendDraftSuccess: ActionScopeGlobal
@sendToAllWindows: ActionScopeGlobal
@draftSendingFailed: ActionScopeGlobal
@ -503,6 +510,16 @@ class Actions
@deleteMailRule: ActionScopeWindow
@disableMailRule: ActionScopeWindow
###
Public: Set metadata for a specified model and pluginId.
*Scope: Window*
Receives an {Model} or {Array} of {Model}s, a plugin id, and an Object that
represents the metadata value.
###
@setMetadata: ActionScopeWindow
# Read the actions we declared on the dummy Actions object above
# and translate them into Reflux Actions

View file

@ -1,4 +1,4 @@
Model = require './model'
ModelWithMetadata = require './model-with-metadata'
Attributes = require '../attributes'
_ = require 'underscore'
@ -27,9 +27,9 @@ This class also inherits attributes from {Model}
Section: Models
###
class Account extends Model
class Account extends ModelWithMetadata
@attributes: _.extend {}, Model.attributes,
@attributes: _.extend {}, ModelWithMetadata.attributes,
'name': Attributes.String
modelKey: 'name'

View file

@ -3,12 +3,12 @@ moment = require 'moment'
File = require './file'
Utils = require './utils'
Model = require './model'
Event = require './event'
Category = require './category'
Contact = require './contact'
Attributes = require '../attributes'
AccountStore = require '../stores/account-store'
ModelWithMetadata = require './model-with-metadata'
###
Public: The Message model represents a Message object served by the Nylas Platform API.
@ -66,9 +66,9 @@ This class also inherits attributes from {Model}
Section: Models
###
class Message extends Model
class Message extends ModelWithMetadata
@attributes: _.extend {}, Model.attributes,
@attributes: _.extend {}, ModelWithMetadata.attributes,
'to': Attributes.Collection
modelKey: 'to'

View file

@ -1,60 +0,0 @@
import Model from './model'
import Attributes from '../attributes'
/**
Cloud-persisted data that is associated with a single Nylas API object
(like a `Thread`, `Message`, or `Account`).
Each Nylas API object can have exactly one `Metadata` object associated
with it. If you update the metadata object on an existing associated
Nylas API object, it will override the previous `value`
*/
export default class Metadata extends Model {
static attributes = Object.assign({}, Model.attributes, {
/*
The unique ID of the plugin that owns this Metadata item. The Nylas
N1 Database holds the Metadata objects for many of its installed
plugins.
*/
applicationId: Attributes.String({
queryable: true,
modelKey: 'applicationId',
jsonKey: 'application_id',
}),
/*
The type of Nylas API object this Metadata item is associated with.
Should be the lowercase singular classname of an object like
`thread` or `message`, or `account`
*/
objectType: Attributes.String({
queryable: true,
modelKey: 'objectType',
jsonKey: 'object_type',
}),
// The unique public ID of the Nylas API object.
objectId: Attributes.String({
queryable: true,
modelKey: 'objectId',
jsonKey: 'object_id',
}),
/*
The current version of this `Metadata` object. Note that since Nylas
API objects can only have exactly one `Metadata` object attached to
it, any action preformed on the `Metadata` of a Nylas API object
will override the existing object and bump the `version`.
*/
version: Attributes.Number({
modelKey: 'version',
jsonKey: 'version',
}),
// A generic value that can be any string-serializable object.
value: Attributes.Object({
modelKey: 'value',
jsonKey: 'value',
}),
});
}

View file

@ -0,0 +1,97 @@
import Model from './model'
import Attributes from '../attributes'
/**
Cloud-persisted data that is associated with a single Nylas API object
(like a `Thread`, `Message`, or `Account`).
*/
class PluginMetadata extends Model {
static attributes = {
pluginId: Attributes.String({
modelKey: 'pluginId',
}),
version: Attributes.Number({
modelKey: 'version',
}),
value: Attributes.Object({
modelKey: 'value',
}),
};
constructor(...args) {
super(...args)
this.version = this.version || 0;
}
get id() {
return this.pluginId
}
set id(pluginId) {
this.pluginId = pluginId
}
}
/**
Plugins can attach arbitrary JSON data to any model that subclasses
ModelWithMetadata, like {{Thread}} or {{Message}}. You must get and set
metadata using your plugin's ID, and any metadata you set overwrites the
metadata previously on the object for the given plugin id.
Reading the metadata of other plugins is discouraged and may become impossible
in the future.
*/
export default class ModelWithMetadata extends Model {
static attributes = Object.assign({}, Model.attributes, {
pluginMetadata: Attributes.Collection({
queryable: true,
itemClass: PluginMetadata,
modelKey: 'pluginMetadata',
jsonKey: 'metadata',
}),
});
static naturalSortOrder() {
return null
}
constructor(...args) {
super(...args)
this.pluginMetadata = this.pluginMetadata || [];
}
// Public accessors
metadataForPluginId(pluginId) {
const metadata = this.metadataObjectForPluginId(pluginId);
if (!metadata) {
return null;
}
return metadata.value;
}
// Private helpers
metadataObjectForPluginId(pluginId) {
return this.pluginMetadata.find(metadata => metadata.pluginId === pluginId);
}
applyPluginMetadata(pluginId, metadataValue) {
let metadata = this.metadataObjectForPluginId(pluginId);
if (!metadata) {
metadata = new PluginMetadata({pluginId});
this.pluginMetadata.push(metadata);
}
metadata.value = metadataValue;
return this;
}
setPluginMetadata(pluginMetadata) {
this.pluginMetadata = pluginMetadata.map(({pluginId, value})=> {
return new PluginMetadata({pluginId, value});
})
return this;
}
}

View file

@ -76,7 +76,7 @@ class Model
@clientId ?= Utils.generateTempId()
@
isSaved: ->
isSavedRemotely: ->
@serverId?
clone: ->

View file

@ -1,10 +1,10 @@
_ = require 'underscore'
Category = require './category'
Model = require './model'
Contact = require './contact'
Actions = require '../actions'
Attributes = require '../attributes'
ModelWithMetadata = require './model-with-metadata'
Function::getter = (prop, get) ->
Object.defineProperty @prototype, prop, {get, configurable: yes}
@ -38,9 +38,9 @@ This class also inherits attributes from {Model}
Section: Models
###
class Thread extends Model
class Thread extends ModelWithMetadata
@attributes: _.extend {}, Model.attributes,
@attributes: _.extend {}, ModelWithMetadata.attributes,
'snippet': Attributes.String
modelKey: 'snippet'

View file

@ -330,7 +330,6 @@ class NylasAPI
"message": require('./models/message')
"contact": require('./models/contact')
"calendar": require('./models/calendar')
"metadata": require('./models/metadata')
getThreads: (accountId, params = {}, requestOptions = {}) ->
requestSuccess = requestOptions.success

View file

@ -463,6 +463,9 @@ class DatabaseStore extends NylasStore
# + Other connections can read from the database, but they will not see
# pending changes.
#
# @param fn {function} callback that will be executed inside a database transaction
# Returns a {Promise} that resolves when the transaction has successfully
# completed.
inTransaction: (fn) ->
t = new DatabaseTransaction(@)
@_transactionQueue ?= new PromiseQueue(1, Infinity)

View file

@ -13,6 +13,7 @@ class DatabaseTransaction
find: (args...) => @database.find(args...)
findBy: (args...) => @database.findBy(args...)
findAll: (args...) => @database.findAll(args...)
modelify: (args...) => @database.modelify(args...)
count: (args...) => @database.count(args...)
findJSONBlob: (args...) => @database.findJSONBlob(args...)

View file

@ -0,0 +1,41 @@
import _ from 'underscore';
import NylasStore from 'nylas-store';
import Actions from '../actions';
import DatabaseStore from './database-store';
import SyncbackMetadataTask from '../tasks/syncback-metadata-task';
class MetadataStore extends NylasStore {
constructor() {
super();
this.listenTo(Actions.setMetadata, this._setMetadata);
}
_setMetadata(modelOrModels, pluginId, metadataValue) {
const models = (modelOrModels instanceof Array) ? modelOrModels : [modelOrModels];
DatabaseStore.inTransaction((t)=> {
// Get the latest version of the models from the datbaase before applying
// metadata in case other plugins also saved metadata, and we don't want
// to overwrite it
return (
t.modelify(_.pluck(models, 'clientId'))
.then((latestModels)=> {
const updatedModels = latestModels.map(m => m.applyPluginMetadata(pluginId, metadataValue));
return (
t.persistModels(updatedModels)
.then(()=> Promise.resolve(updatedModels))
)
})
)
}).then((updatedModels)=> {
updatedModels.forEach((updated)=> {
if (updated.isSavedRemotely()) {
const task = new SyncbackMetadataTask(updated.clientId, updated.constructor.name, pluginId);
Actions.queueTask(task);
}
})
});
}
}
module.exports = new MetadataStore();

View file

@ -1,76 +0,0 @@
import _ from 'underscore'
import Task from './task'
import NylasAPI from '../nylas-api'
import DatabaseStore from '../stores/database-store'
export default class CreateModelTask extends Task {
constructor({data = {}, modelName, endpoint, requiredFields = [], accountId} = {}) {
super()
this.data = data
this.endpoint = endpoint
this.modelName = modelName
this.accountId = accountId
this.requiredFields = requiredFields || []
}
shouldDequeueOtherTask(other) {
return (other instanceof CreateModelTask &&
this.modelName === other.modelName &&
this.accountId === other.accountId &&
this.endpoint === other.endpoint &&
_.isEqual(this.data, other.data))
}
getModelConstructor() {
return require('nylas-exports')[this.modelName]
}
performLocal() {
this.validateRequiredFields(["accountId", "endpoint"])
for (const field of this.requiredFields) {
if (this.data[field] === null || this.data[field] === undefined) {
throw new Error(`Must pass data field "${field}"`)
}
}
const Klass = require('nylas-exports')[this.modelName]
if (!_.isFunction(Klass)) {
throw new Error(`Couldn't find the class for ${this.modelName}`)
}
this.model = new Klass(this.data)
return DatabaseStore.inTransaction((t) => {
return t.persistModel(this.model)
});
}
performRemote() {
return NylasAPI.makeRequest({
path: this.endpoint,
method: "POST",
accountId: this.accountId,
body: this.model.toJSON(),
returnsModel: true,
}).then(() => {
return Promise.resolve(Task.Status.Success)
}).catch(this.apiErrorHandler)
}
canBeUndone() { return true }
isUndo() { return !!this._isUndoTask }
createUndoTask() {
const DestroyModelTask = require('./destroy-model-task')
const undoTask = new DestroyModelTask({
clientId: this.model.clientId,
modelName: this.modelName,
endpoint: this.endpoint,
accountId: this.accountId,
})
undoTask._isUndoTask = true
return undoTask
}
}

View file

@ -47,7 +47,7 @@ export default class DestroyModelTask extends Task {
performRemote() {
if (!this.serverId) {
throw new Error("Need a serverId to destroy remotely")
return Promise.resolve(Task.Status.Continue)
}
return NylasAPI.makeRequest({
path: `${this.endpoint}/${this.serverId}`,
@ -58,19 +58,6 @@ export default class DestroyModelTask extends Task {
}).catch(this.apiErrorHandler)
}
canBeUndone() { return true }
canBeUndone() { return false }
isUndo() { return !!this._isUndoTask }
createUndoTask() {
const CreateModelTask = require('./create-model-task')
const undoTask = new CreateModelTask({
data: this.oldModel,
modelName: this.modelName,
endpoint: this.endpoint,
accountId: this.accountId,
})
undoTask._isUndoTask = true
return undoTask
}
}

View file

@ -11,6 +11,7 @@ TaskQueue = require '../stores/task-queue'
SoundRegistry = require '../../sound-registry'
DatabaseStore = require '../stores/database-store'
AccountStore = require '../stores/account-store'
SyncbackMetadataTask = require './syncback-metadata-task'
class MultiRequestProgressMonitor
@ -63,12 +64,6 @@ class SendDraftTask extends Task
unless account
return Promise.reject(new Error("SendDraftTask - you can only send drafts from a configured account."))
if @draft.serverId
@deleteAfterSending =
accountId: @draft.accountId
serverId: @draft.serverId
version: @draft.version
if account.id isnt @draft.accountId
@draft.accountId = account.id
delete @draft.serverId
@ -134,6 +129,8 @@ class SendDraftTask extends Task
# because it's possible for the app to quit without saving state and
# need to re-upload the file.
# This function returns a promise that resolves to the draft when the draft has
# been sent successfully.
_sendAndCreateMessage: =>
NylasAPI.makeRequest
path: "/send"
@ -159,21 +156,26 @@ class SendDraftTask extends Task
return Promise.reject(err)
.then (newMessageJSON) =>
message = new Message().fromJSON(newMessageJSON)
message.clientId = @draft.clientId
message.draft = false
DatabaseStore.inTransaction (t) =>
t.persistModel(message)
@message = new Message().fromJSON(newMessageJSON)
@message.clientId = @draft.clientId
@message.draft = false
# Create new metadata objs on the message based on the existing ones in the draft
@message.setPluginMetadata(@draft.pluginMetadata)
return DatabaseStore.inTransaction (t) =>
DatabaseStore.findBy(Message, {clientId: @draft.clientId})
.then (draft) =>
t.persistModel(@message).then =>
Promise.resolve(draft)
# We DON'T need to delete the local draft because we turn it into a message
# by writing the new message into the database with the same clientId.
#
# We DO, need to make sure that the remote draft has been cleaned up.
#
_deleteRemoteDraft: =>
return Promise.resolve() unless @deleteAfterSending
{accountId, version, serverId} = @deleteAfterSending
_deleteRemoteDraft: ({accountId, version, serverId}) =>
return Promise.resolve() unless serverId
NylasAPI.makeRequest
path: "/drafts/#{serverId}"
accountId: accountId
@ -186,13 +188,19 @@ class SendDraftTask extends Task
Promise.resolve()
_onSuccess: =>
Actions.sendDraftSuccess
draftClientId: @draft.clientId
# Delete attachments from the uploads folder
for upload in @uploaded
Actions.attachmentUploaded(upload)
# Queue a task to save metadata on the message
@message.pluginMetadata.forEach((m)=>
task = new SyncbackMetadataTask(@message.clientId, @message.constructor.name, m.pluginId)
Actions.queueTask(task)
)
Actions.sendDraftSuccess draftClientId: @message.clientId
# Play the sending sound
if NylasEnv.config.get("core.sending.sounds")
SoundRegistry.playSound('send')

View file

@ -7,6 +7,7 @@ TaskQueueStatusStore = require '../stores/task-queue-status-store'
NylasAPI = require '../nylas-api'
Task = require './task'
SyncbackMetadataTask = require './syncback-metadata-task'
{APIError} = require '../errors'
Message = require '../models/message'
Account = require '../models/account'
@ -73,14 +74,23 @@ class SyncbackDraftTask extends Task
#
# The only fields we want to update from the server are the `id` and `version`.
#
draftIsNew = false
DatabaseStore.inTransaction (t) =>
@getLatestLocalDraft().then (draft) =>
# Draft may have been deleted. Oh well.
return Promise.resolve() unless draft
if draft.serverId isnt id
draft.serverId = id
draftIsNew = true
draft.version = version
draft.serverId = id
t.persistModel(draft)
.thenReturn(true)
.then (draft) =>
if draftIsNew
for {pluginId, value} in draft.pluginMetadata
task = new SyncbackMetadataTask(@draftClientId, draft.constructor.name, pluginId)
Actions.queueTask(task)
return true
getLatestLocalDraft: =>
DatabaseStore.findBy(Message, clientId: @draftClientId).include(Message.attributes.body)

View file

@ -1,14 +1,37 @@
import Metadata from '../models/metadata'
import SyncbackModelTask from './syncback-model-task'
import DatabaseObjectRegistry from '../../database-object-registry'
export default class SyncbackMetadataTask extends SyncbackModelTask {
getModelConstructor() {
return Metadata
constructor(modelClientId, modelClassName, pluginId) {
super({clientId: modelClientId});
this.modelClassName = modelClassName;
this.pluginId = pluginId;
}
getPathAndMethod = (model) => {
const path = `/metadata/${model.objectId}?client_id=${model.applicationId}`;
const method = model.serverId ? "PUT" : "POST"
return {path, method}
getModelConstructor() {
return DatabaseObjectRegistry.classMap()[this.modelClassName];
}
getRequestData = (model) => {
const metadata = model.metadataObjectForPluginId(this.pluginId);
return {
path: `/metadata/${model.id}?client_id=${this.pluginId}`,
method: 'POST',
body: {
object_id: model.serverId,
object_type: this.modelClassName.toLowerCase(),
version: metadata.version,
value: metadata.value,
},
};
};
applyRemoteChangesToModel = (model, {version}) => {
const metadata = model.metadataObjectForPluginId(this.pluginId);
metadata.version = version;
return model;
};
}

View file

@ -39,41 +39,40 @@ export default class SyncbackModelTask extends Task {
getLatestModel = () => {
return DatabaseStore.findBy(this.getModelConstructor(),
{clientId: this.clientId})
}
};
verifyModel = (model) => {
if (model) {
return Promise.resolve(model)
}
throw new Error(`Can't find a '${this.getModelConstructor().name}' model for clientId: ${this.clientId}'`)
}
};
makeRequest = (model) => {
const data = this.getPathAndMethod(model)
return NylasAPI.makeRequest({
const options = _.extend({
accountId: model.accountId,
path: data.path,
method: data.method,
body: model.toJSON(),
returnsModel: false,
})
}
}, this.getRequestData(model));
getPathAndMethod = (model) => {
if (model.serverId) {
return NylasAPI.makeRequest(options);
};
getRequestData = (model) => {
if (model.isSavedRemotely()) {
return {
path: `${this.endpoint}/${model.serverId}`,
body: model.toJSON(),
method: "PUT",
}
}
return {
path: `${this.endpoint}`,
body: model.toJSON(),
method: "POST",
}
}
};
updateLocalModel = ({version, id}) => {
updateLocalModel = (responseJSON) => {
/*
Important: There could be a significant delay between us initiating
the save and getting JSON back from the server. Our local copy of
@ -86,24 +85,36 @@ export default class SyncbackModelTask extends Task {
return this.getLatestModel().then((model) => {
// Model may have been deleted
if (!model) { return Promise.resolve() }
model.version = version
model.serverId = id
return t.persistModel(model)
const changed = this.applyRemoteChangesToModel(model, responseJSON);
return t.persistModel(changed);
})
}).thenReturn(true)
}
};
applyRemoteChangesToModel = (model, {version, id}) => {
model.version = version;
model.serverId = id;
return model;
};
handleRemoteError = (err) => {
if (err instanceof APIError) {
if (!(_.includes(NylasAPI.PermanentErrorCodes, err.statusCode))) {
return Promise.resolve(Task.Status.Retry)
if (NylasAPI.PermanentErrorCodes.includes(err.statusCode)) {
return Promise.resolve([Task.Status.Failed, err])
}
return Promise.resolve([Task.Status.Failed, err])
if (err.statusCode === 409) {
return this.handleRemoteVersionConflict(err);
}
return Promise.resolve(Task.Status.Retry)
}
NylasEnv.reportError(err);
return Promise.resolve([Task.Status.Failed, err])
}
};
handleRemoteVersionConflict = (err) => {
return Promise.resolve([Task.Status.Failed, err])
};
canBeUndone() { return false }

View file

@ -1,81 +0,0 @@
import _ from 'underscore'
import Task from './task'
import NylasAPI from '../nylas-api'
import DatabaseStore from '../stores/database-store'
export default class UpdateModelTask extends Task {
constructor({clientId, newData = {}, modelName, endpoint, accountId} = {}) {
super()
this.clientId = clientId
this.newData = newData
this.endpoint = endpoint
this.modelName = modelName
this.accountId = accountId
}
shouldDequeueOtherTask(other) {
return (other instanceof UpdateModelTask &&
this.clientId === other.clientId &&
this.modelName === other.modelName &&
this.accountId === other.accountId &&
this.endpoint === other.endpoint &&
_.isEqual(this.newData, other.newData))
}
getModelConstructor() {
return require('nylas-exports')[this.modelName]
}
performLocal() {
this.validateRequiredFields(["clientId", "accountId", "endpoint"])
const klass = this.getModelConstructor()
if (!_.isFunction(klass)) {
throw new Error(`Couldn't find the class for ${this.modelName}`)
}
return DatabaseStore.findBy(klass, {clientId: this.clientId}).then((model) => {
if (!model) {
throw new Error(`Couldn't find the model with clientId ${this.clientId}`)
}
this.serverId = model.serverId
this.oldModel = model.clone()
const updatedModel = _.extend(model, this.newData)
return DatabaseStore.inTransaction((t) => {
return t.persistModel(updatedModel)
});
});
}
performRemote() {
if (!this.serverId) {
throw new Error("Need a serverId to update remotely")
}
return NylasAPI.makeRequest({
path: `${this.endpoint}/${this.serverId}`,
method: "PUT",
accountId: this.accountId,
body: this.newData,
returnsModel: true,
}).then(() => {
return Promise.resolve(Task.Status.Success)
}).catch(this.apiErrorHandler)
}
canBeUndone() { return true }
isUndo() { return !!this._isUndoTask }
createUndoTask() {
const undoTask = new UpdateModelTask({
clientId: this.clientId,
newData: this.oldModel,
modelName: this.modelName,
endpoint: this.endpoint,
accountId: this.accountId,
})
undoTask._isUndoTask = true
return undoTask
}
}

View file

@ -73,7 +73,6 @@ class NylasExports
@require "Contact", 'flux/models/contact'
@require "Category", 'flux/models/category'
@require "Calendar", 'flux/models/calendar'
@require "Metadata", 'flux/models/metadata'
@require "JSONBlob", 'flux/models/json-blob'
@require "DatabaseObjectRegistry", "database-object-registry"
@require "MailboxPerspective", 'mailbox-perspective'
@ -104,8 +103,6 @@ class NylasExports
@require "ChangeUnreadTask", 'flux/tasks/change-unread-task'
@require "SyncbackDraftTask", 'flux/tasks/syncback-draft'
@require "ChangeStarredTask", 'flux/tasks/change-starred-task'
@require "CreateModelTask", 'flux/tasks/create-model-task'
@require "UpdateModelTask", 'flux/tasks/update-model-task'
@require "DestroyModelTask", 'flux/tasks/destroy-model-task'
@require "SyncbackModelTask", 'flux/tasks/syncback-model-task'
@require "SyncbackMetadataTask", 'flux/tasks/syncback-metadata-task'
@ -119,6 +116,7 @@ class NylasExports
@require "OutboxStore", 'flux/stores/outbox-store'
@require "AccountStore", 'flux/stores/account-store'
@require "MessageStore", 'flux/stores/message-store'
@require "MetadataStore", 'flux/stores/metadata-store'
@require "ContactStore", 'flux/stores/contact-store'
@require "CategoryStore", 'flux/stores/category-store'
@require "WorkspaceStore", 'flux/stores/workspace-store'

View file

@ -80,9 +80,6 @@ class Package
@reset()
@declaresNewDatabaseObjects = false
CloudStorage = require './cloud-storage'
@cloudStorage = new CloudStorage(@pluginId())
# TODO FIXME: Use a unique pluginID instead of just the "name"
# This needs to be included here to prevent a circular dependency error
pluginId: -> return @name
@ -187,7 +184,7 @@ class Package
@activateStylesheets()
if @requireMainModule()
localState = NylasEnv.packages.getPackageState(@name) ? {}
@mainModule.activate(localState, @cloudStorage)
@mainModule.activate(localState)
@mainActivated = true
@activateServices()
catch e