mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-09-21 15:56:10 +08:00
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:
parent
458b0fbe91
commit
0217e5a509
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,6 +93,8 @@ class NylasSyncWorkerPool
|
|||
modify[type] = NylasAPI._handleModelResponse(_.values(dict)) for type, dict of modify
|
||||
Promise.props(modify).then (modified) =>
|
||||
|
||||
Promise.all(@_handleDeltaMetadata(metadata)).then =>
|
||||
|
||||
# Now that we've persisted creates/updates, fire an action
|
||||
# that allows other parts of the app to update based on new models
|
||||
# (notifications)
|
||||
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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)
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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)
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,14 +112,11 @@ describe "SendDraftTask", ->
|
|||
expect(DBt.persistModel.mostRecentCall.args[0].draft).toEqual(false)
|
||||
|
||||
it "should notify the draft was sent", ->
|
||||
waitsForPromise => @task.performRemote().then =>
|
||||
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]
|
||||
|
||||
it "should play a sound", ->
|
||||
spyOn(NylasEnv.config, "get").andReturn true
|
||||
waitsForPromise => @task.performRemote().then ->
|
||||
|
@ -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,28 +238,44 @@ describe "SendDraftTask", ->
|
|||
thrownError = new APIError(statusCode: 500, body: "err")
|
||||
spyOn(NylasAPI, 'makeRequest').andCallFake (options) =>
|
||||
Promise.reject(thrownError)
|
||||
waitsForPromise => @task.performRemote().then (status) =>
|
||||
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) =>
|
||||
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) =>
|
||||
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) =>
|
||||
waitsForPromise =>
|
||||
@task.performRemote().then (status) =>
|
||||
expect(status).toBe Task.Status.Success
|
||||
expect(@task._sendAndCreateMessage).toHaveBeenCalled()
|
||||
expect(@task._deleteRemoteDraft).toHaveBeenCalled()
|
||||
expect(@task._onSuccess).toHaveBeenCalled()
|
||||
|
@ -281,22 +295,23 @@ describe "SendDraftTask", ->
|
|||
@task = new SendDraftTask(@draft)
|
||||
@calledBody = "ERROR: The body wasn't included!"
|
||||
spyOn(DatabaseStore, "findBy").andCallFake =>
|
||||
include: (body) =>
|
||||
@calledBody = body
|
||||
Promise.resolve(@draft)
|
||||
|
||||
sharedTests()
|
||||
|
||||
it "can complete a full performRemote", -> waitsForPromise =>
|
||||
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 =>
|
||||
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 =>
|
||||
waitsForPromise =>
|
||||
@task.performRemote().then =>
|
||||
expect(DBt.persistModel).toHaveBeenCalled()
|
||||
model = DBt.persistModel.calls[0].args[0]
|
||||
expect(model.clientId).toBe @draft.clientId
|
||||
|
@ -321,9 +336,6 @@ 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)
|
||||
|
||||
sharedTests()
|
||||
|
@ -335,7 +347,8 @@ describe "SendDraftTask", ->
|
|||
|
||||
it "should make a request to delete a draft", ->
|
||||
@task.performLocal()
|
||||
waitsForPromise => @task._deleteRemoteDraft().then =>
|
||||
waitsForPromise =>
|
||||
@task._deleteRemoteDraft(@draft).then =>
|
||||
expect(NylasAPI.makeRequest).toHaveBeenCalled()
|
||||
expect(NylasAPI.makeRequest.callCount).toBe 1
|
||||
req = NylasAPI.makeRequest.calls[0].args[0]
|
||||
|
@ -350,7 +363,9 @@ describe "SendDraftTask", ->
|
|||
Promise.reject(new APIError(body: "Boo", statusCode: 500))
|
||||
|
||||
@task.performLocal()
|
||||
waitsForPromise => @task._deleteRemoteDraft().then =>
|
||||
waitsForPromise =>
|
||||
@task._deleteRemoteDraft(@draft)
|
||||
.then =>
|
||||
expect(NylasAPI.makeRequest).toHaveBeenCalled()
|
||||
expect(NylasAPI.makeRequest.callCount).toBe 1
|
||||
.catch =>
|
||||
|
|
|
@ -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) ->
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -16,6 +16,8 @@ class Bar extends Foo
|
|||
@moreStuff = stuff
|
||||
@method(stuff)
|
||||
|
||||
describe 'Utils', ->
|
||||
|
||||
describe "registeredObjectReviver / registeredObjectReplacer", ->
|
||||
beforeEach ->
|
||||
@testThread = new Thread
|
||||
|
@ -28,7 +30,7 @@ describe "registeredObjectReviver / registeredObjectReplacer", ->
|
|||
subject: 'Test 1234'
|
||||
|
||||
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"}]'
|
||||
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)
|
||||
|
@ -38,7 +40,7 @@ describe "registeredObjectReviver / registeredObjectReplacer", ->
|
|||
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"}'
|
||||
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)
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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',
|
||||
}),
|
||||
});
|
||||
}
|
97
src/flux/models/model-with-metadata.es6
Normal file
97
src/flux/models/model-with-metadata.es6
Normal 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;
|
||||
}
|
||||
|
||||
}
|
|
@ -76,7 +76,7 @@ class Model
|
|||
@clientId ?= Utils.generateTempId()
|
||||
@
|
||||
|
||||
isSaved: ->
|
||||
isSavedRemotely: ->
|
||||
@serverId?
|
||||
|
||||
clone: ->
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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...)
|
||||
|
||||
|
|
41
src/flux/stores/metadata-store.es6
Normal file
41
src/flux/stores/metadata-store.es6
Normal 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();
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
draft.version = version
|
||||
if draft.serverId isnt id
|
||||
draft.serverId = id
|
||||
draftIsNew = true
|
||||
draft.version = version
|
||||
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)
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
@ -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))) {
|
||||
if (NylasAPI.PermanentErrorCodes.includes(err.statusCode)) {
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
}
|
||||
if (err.statusCode === 409) {
|
||||
return this.handleRemoteVersionConflict(err);
|
||||
}
|
||||
return Promise.resolve(Task.Status.Retry)
|
||||
}
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
}
|
||||
|
||||
NylasEnv.reportError(err);
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
}
|
||||
};
|
||||
|
||||
handleRemoteVersionConflict = (err) => {
|
||||
return Promise.resolve([Task.Status.Failed, err])
|
||||
};
|
||||
|
||||
canBeUndone() { return false }
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue