feat(salesforce): associate threads with SF objects

Summary:
#### WIP! ####

This is making it all work with the association endpoint, putting
together the Salesforce Sidebar interfaces, and getting the nested
creators/updaters working.

I still need to do a bunch of UI work and actually debug the whole
workflow still

---

rename SalesforceContactStore to SalesforceSearchStore

rename SalesforceContact to SalesforceSearchResult

salesforce sidebar changes

salesforce association picker

object form store fixes

figuring out newFormItem instigators

Make SalesforceObjectFormStore declarative off SalesforceObjectStore

Make action basd handlers for SalesforceObjectStore

sidebar store create and associate

salesforce sidebar and picker fixes

association works and displays on sidebar

salesforce object form fixes

object form fixes

fix salesforce updating

Test Plan: TODO

Reviewers: bengotow

Reviewed By: bengotow

Differential Revision: https://review.inboxapp.com/D1440
This commit is contained in:
Evan Morikawa 2015-04-30 11:35:38 -07:00
parent 01938c1c78
commit 57cb02c76a
24 changed files with 394 additions and 62 deletions

View file

@ -12,6 +12,10 @@ Exports =
Task: require '../src/flux/tasks/task'
TaskQueue: require '../src/flux/stores/task-queue'
# Tasks
CreateMetadataTask: require '../src/flux/tasks/create-metadata-task'
DestroyMetadataTask: require '../src/flux/tasks/destroy-metadata-task'
# The Database
DatabaseStore: require '../src/flux/stores/database-store'
ModelView: require '../src/flux/stores/model-view'
@ -44,6 +48,7 @@ Exports =
DraftStoreExtension: require '../src/flux/stores/draft-store-extension'
MessageStore: require '../src/flux/stores/message-store'
ContactStore: require '../src/flux/stores/contact-store'
MetadataStore: require '../src/flux/stores/metadata-store'
NamespaceStore: require '../src/flux/stores/namespace-store'
AnalyticsStore: require '../src/flux/stores/analytics-store'
WorkspaceStore: require '../src/flux/stores/workspace-store'
@ -60,7 +65,7 @@ Exports =
## TODO move to inside of individual Salesforce package. See https://trello.com/c/tLAGLyeb/246-move-salesforce-models-into-individual-package-db-models-for-packages-various-refactors
SalesforceAssociation: require '../src/flux/models/salesforce-association'
SalesforceContact: require '../src/flux/models/salesforce-contact'
SalesforceSearchResult: require '../src/flux/models/salesforce-search-result'
SalesforceObject: require '../src/flux/models/salesforce-object'
SalesforceSchema: require '../src/flux/models/salesforce-schema'

View file

@ -111,10 +111,10 @@ AccountSidebarStore = Reflux.createStore
@trigger(@)
_onDataChanged: (change) ->
@populateInboxCountDebounced ?= _.debounce ->
@populateInboxCountDebounced ?= _.debounce =>
@_populateInboxCount()
, 1000
@populateDraftCountDebounced ?= _.debounce ->
@populateDraftCountDebounced ?= _.debounce =>
@_populateDraftCount()
, 1000

View file

@ -18,8 +18,7 @@ ParticipantsTextField = require './participants-text-field'
# The ComposerView is a unique React component because it (currently) is a
# singleton. Normally, the React way to do things would be to re-render the
# Composer with new props. As an alternative, we can call `setProps` to
# simulate the effect of the parent re-rendering us
# Composer with new props.
module.exports =
ComposerView = React.createClass
displayName: 'ComposerView'

View file

@ -2,26 +2,25 @@ _ = require 'underscore-plus'
React = require 'react'
{FocusedContentStore} = require 'inbox-exports'
module.exports =
MessageSubjectItem = React.createClass
displayName: 'MessageSubjectItem'
class MessageSubjectItem extends React.Component
@displayName: 'MessageSubjectItem'
getInitialState: ->
@_getStateFromStores()
constructor: (@props) ->
@state = @_getStateFromStores()
componentDidMount: ->
componentDidMount: =>
@_unsubscriber = FocusedContentStore.listen @_onChange
componentWillUnmount: ->
componentWillUnmount: =>
@_unsubscriber() if @_unsubscriber
render: ->
render: =>
<div className="message-toolbar-subject">{@state.thread?.subject}</div>
_onChange: -> _.defer =>
return unless @isMounted()
_onChange: => _.defer =>
@setState(@_getStateFromStores())
_getStateFromStores: ->
_getStateFromStores: =>
thread: FocusedContentStore.focused('thread')
module.exports = MessageSubjectItem

View file

@ -59,13 +59,14 @@ ArchiveButton = React.createClass
Actions.archive()
e.stopPropagation()
class MessageToolbarItems extends React.Component
@displayName: "MessageToolbarItems"
module.exports =
MessageToolbarItems = React.createClass
getInitialState: ->
threadIsSelected: FocusedContentStore.focusedId('thread')?
constructor: (@props) ->
@state =
threadIsSelected: FocusedContentStore.focusedId('thread')?
render: ->
render: =>
classes = classNames
"message-toolbar-items": true
"hidden": !@state.threadIsSelected
@ -74,14 +75,15 @@ MessageToolbarItems = React.createClass
<ArchiveButton ref="archiveButton" />
</div>
componentDidMount: ->
componentDidMount: =>
@_unsubscribers = []
@_unsubscribers.push FocusedContentStore.listen @_onChange
componentWillUnmount: ->
componentWillUnmount: =>
unsubscribe() for unsubscribe in @_unsubscribers
_onChange: -> _.defer =>
return unless @isMounted()
_onChange: => _.defer =>
@setState
threadIsSelected: FocusedContentStore.focusedId('thread')?
module.exports = MessageToolbarItems

View file

@ -21,7 +21,7 @@ class SearchBar extends React.Component
@body_unsubscriber = atom.commands.add 'body', {
'application:focus-search': @_onFocusSearch
}
@body_unsubscriber = atom.commands.add '.search-bar', {
@search_unsubscriber = atom.commands.add '.search-bar', {
'search-bar:escape-search': @_clearAndBlur
}
@ -31,6 +31,7 @@ class SearchBar extends React.Component
componentWillUnmount: =>
@unsubscribe()
@body_unsubscriber.dispose()
@search_unsubscriber.dispose()
render: =>
inputValue = @_queryToString(@state.query)

View file

@ -6,6 +6,8 @@ React = require 'react/addons'
The Tooltip component displays a consistent hovering tooltip for use when
extra context information is required.
Activate by adding a `data-tooltip="Label"` to any element
It's a global-level singleton
###

View file

@ -1,5 +1,4 @@
_ = require 'underscore-plus'
Promise = require 'bluebird'
Contact = require '../../../src/flux/models/contact'
Message = require '../../../src/flux/models/message'
Thread = require '../../../src/flux/models/thread'

View file

@ -72,7 +72,7 @@ describe 'TokenizingTextField', ->
tabIndex={@tabIndex}
/>
)
@renderedInput = ReactTestUtils.findRenderedDOMComponentWithTag(@renderedField, 'input').getDOMNode()
@renderedInput = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(@renderedField, 'input'))
it 'renders into the document', ->
expect(ReactTestUtils.isCompositeComponentWithType @renderedField, TokenizingTextField).toBe(true)

View file

@ -0,0 +1,4 @@
MetadataStore = require '../../src/flux/stores/metadata-store'
describe "MetadataStore", ->
beforeEach: ->
spyOn(atom, "isMainWindow").andReturn(true)

View file

@ -13,6 +13,7 @@ try
Atom = require '../src/atom'
Atom.configDirPath = fs.absolute('~/.inbox-spec')
window.atom = Atom.loadOrCreate()
global.Promise.longStackTraces() if atom.inDevMode()
# Show window synchronously so a focusout doesn't fire on input elements
# that are focused in the very first spec run.

View file

@ -133,7 +133,8 @@ FormItem = React.createClass
refreshValidityState: -> _.defer =>
return unless @isMounted()
el = @refs.input.getDOMNode()
return unless @refs.input
el = React.findDOMNode(@refs.input)
customMsg = @props.formItemError?.message
if el.setCustomValidity?
@ -236,7 +237,10 @@ GeneratedFieldset = React.createClass
if i isnt items.length - 1 or items.length is 1
itemsWithSpacers.push(spacer: true)
<div className="row" data-row-num={rowNum} key={rowNum}>
<div className="row"
data-row-num={rowNum}
style={zIndex: 1000-rowNum}
key={rowNum}>
{_.map itemsWithSpacers, (formItemData, i) =>
if formItemData.spacer
<div className="column-spacer" data-col-num={i} key={i}>
@ -310,7 +314,7 @@ GeneratedForm = React.createClass
not Utils.isEqualReact(nextState, @state)
_onSubmit: ->
valid = @refs.form.getDOMNode().reportValidity()
valid = React.findDOMNode(@refs.form).reportValidity()
if valid
@props.onSubmit()
else

View file

@ -165,7 +165,7 @@ TokenizingTextField = React.createClass
selectedTokenKey: null
componentDidMount: ->
input = @refs.input.getDOMNode()
input = React.findDOMNode(@refs.input)
check = (fn) -> (event) ->
return unless event.target is input
# Wrapper to guard against events triggering on the wrong element
@ -187,12 +187,12 @@ TokenizingTextField = React.createClass
componentDidUpdate: ->
# Measure the width of the text in the input and
# resize the input field to fit.
input = @refs.input.getDOMNode()
measure = @refs.measure.getDOMNode()
input = React.findDOMNode(@refs.input)
measure = React.findDOMNode(@refs.measure)
measure.innerText = @state.inputValue
measure.style.top = input.offsetTop + "px"
measure.style.left = input.offsetLeft + "px"
input.style.width = "calc(4px + #{measure.offsetWidth}px)"
input.style.width = "calc(6px + #{measure.offsetWidth}px)"
render: ->
{Menu} = require 'ui-components'
@ -278,7 +278,7 @@ TokenizingTextField = React.createClass
inputValue: ""
focus: ->
@refs.input.getDOMNode().focus()
React.findDOMNode(@refs.input).focus()
# Managing Tokens
@ -296,6 +296,7 @@ TokenizingTextField = React.createClass
@props.tokenKey(t) is @state.selectedTokenKey
_addToken: (token) ->
return unless @isMounted()
return unless token
@props.onAdd([token])
@_clearInput()

View file

@ -120,7 +120,11 @@ windowActions = [
"fileDownloaded",
"popSheet",
"pushSheet"
"pushSheet",
"metadataError",
"metadataCreated",
"metadataDestroyed"
]
allActions = [].concat(windowActions).concat(globalActions).concat(mainWindowActions)

View file

@ -5,7 +5,6 @@ DatabaseStore = require './stores/database-store'
PriorityUICoordinator = require '../priority-ui-coordinator'
{modelFromJSON} = require './models/utils'
async = require 'async'
SALESFORCE_PROXY_ROOT = "/proxy/salesforce/services/data/v33.0"
class EdgehillAPI
@ -17,23 +16,21 @@ class EdgehillAPI
_onConfigChanged: =>
env = atom.config.get('env')
if env is 'development'
@APIRoot = "http://localhost:5009"
# @APIRoot = "http://localhost:5009"
@APIRoot = "https://edgehill-dev.nylas.com"
else if env is 'staging'
@APIRoot = "https://edgehill-staging.nilas.com"
@APIRoot = "https://edgehill-staging.nylas.com"
else
@APIRoot = "https://edgehill.nilas.com"
@APIRoot = "https://edgehill.nylas.com"
request: (options={}) ->
return if atom.getLoadSettings().isSpec
options.method ?= 'GET'
if options.proxyPath
options.url ?= "#{@APIRoot}#{SALESFORCE_PROXY_ROOT}/#{options.proxyPath}"
else
options.url ?= "#{@APIRoot}#{options.path}" if options.path
options.url ?= "#{@APIRoot}#{options.path}" if options.path
options.body ?= {} unless options.formData
options.json = true
auth = @getCredentials()
auth = @_getCredentials()
if auth
options.auth =
user: auth.username
@ -51,30 +48,27 @@ class EdgehillAPI
options.success(body) if options.success
urlForConnecting: (provider, email_address = '') ->
auth = @getCredentials()
auth = @_getCredentials()
root = @APIRoot
token = auth?.username
"#{root}/connect/#{provider}?login_hint=#{email_address}&token=#{token}"
getCredentials: ->
atom.config.get('edgehill.credentials')
setCredentials: (credentials) ->
atom.config.set('edgehill.credentials', credentials)
addTokens: (tokens) ->
for token in tokens
if token.provider is 'inbox'
atom.config.set('inbox.token', token.access_token)
if token.provider is 'salesforce'
atom.config.set('salesforce.token', token.access_token)
atom.config.set("#{token.provider}.token", token.access_token)
if token.user_identifier?
@setCredentials({username: token.user_identifier, password: ''})
@_setCredentials({username: token.user_identifier, password: ''})
tokenForProvider: (provider) ->
atom.config.get("#{provider}.token")
_getCredentials: ->
atom.config.get('edgehill.credentials')
_setCredentials: (credentials) ->
atom.config.set('edgehill.credentials', credentials)
_defaultErrorCallback: (apiError) ->
apiError.notifyConsole()

View file

@ -0,0 +1,34 @@
Model = require './model'
Attributes = require '../attributes'
{generateTempId} = require './utils'
Function::getter = (prop, get) ->
Object.defineProperty @prototype, prop, {get, configurable: yes}
module.exports =
class Metadata extends Model
@attributes:
'type': Attributes.String
queryable: true
modelKey: 'type'
jsonKey: 'type'
'publicId': Attributes.String
queryable: true
modelKey: 'publicId'
jsonKey: 'publicId'
'key': Attributes.String
queryable: true
modelKey: 'key'
jsonKey: 'key'
'value': Attributes.Object
modelKey: 'value'
jsonKey: 'value'
@getter 'id', ->
if @type and @publicId and @key
@id = "#{@type}/#{@publicId}/#{@key}"
else
@id = generateTempId()

View file

@ -56,12 +56,13 @@ Utils =
LocalLink = require './local-link'
Event = require './event'
Calendar = require './calendar'
Metadata = require './metadata'
## TODO move to inside of individual Salesforce package. See https://trello.com/c/tLAGLyeb/246-move-salesforce-models-into-individual-package-db-models-for-packages-various-refactors
SalesforceObject = require './salesforce-object'
SalesforceSchema = require './salesforce-schema'
SalesforceAssociation = require './salesforce-association'
SalesforceContact = require './salesforce-contact'
SalesforceSearchResult = require './salesforce-search-result'
SalesforceTask = require './salesforce-task'
SyncbackDraftTask = require '../tasks/syncback-draft'
@ -83,10 +84,11 @@ Utils =
'locallink': LocalLink
'calendar': Calendar
'event': Event
'metadata': Metadata
'salesforceschema': SalesforceSchema
'salesforceobject': SalesforceObject
'salesforceassociation': SalesforceAssociation
'salesforcecontact': SalesforceContact
'salesforcesearchresult': SalesforceSearchResult
'salesforcetask': SalesforceTask
'MarkThreadReadTask': MarkThreadReadTask

View file

@ -0,0 +1,116 @@
_ = require 'underscore-plus'
Reflux = require 'reflux'
Actions = require '../actions'
Metadata = require '../models/metadata'
EdgehillAPI = require '../edgehill-api'
DatabaseStore = require '../stores/database-store'
NamespaceStore = require '../stores/namespace-store'
CreateMetadataTask = require '../tasks/create-metadata-task'
DestroyMetadataTask = require '../tasks/destroy-metadata-task'
# TODO: This Store is like many other stores (like the
# SalesforceObjectStore or the SalesforceAssociationStore) in that it has
# to double cache data from the API and the DB with minor variation.
# There's a task to refactor these stores into something like an
# `APIBackedStore` to abstract some of the complex logic out.
MAX_API_RATE = 1000
module.exports =
MetadataStore = Reflux.createStore
init: ->
return unless atom.isMainWindow()
@listenTo DatabaseStore, @_onDBChanged
@listenTo NamespaceStore, @_onNamespaceChanged
refreshDBFromAPI = _.debounce(_.bind(@_refreshDBFromAPI, @), MAX_API_RATE)
@_typesToRefresh = {}
@listenTo Actions.metadataError, (errorData) =>
return unless errorData.type
@_typesToRefresh[errorData.type] = true
refreshDBFromAPI()
@listenTo Actions.metadataCreated, (type) =>
@_typesToRefresh[type] = true
refreshDBFromAPI()
@listenTo Actions.metadataDestroyed, (type) =>
@_typesToRefresh[type] = true
refreshDBFromAPI()
@_namespaceId = NamespaceStore.current()?.id
@_metadata = {}
return if atom.inSpecMode()
@_fullRefreshFromAPI()
@_refreshCacheFromDB = _.debounce(_.bind(@_refreshCacheFromDB, @), 16)
@_refreshCacheFromDB()
# Returns a promise that will eventually return the metadata you want
getMetadata: (type, publicId, key) ->
if type? and publicId? and key?
return @_metadata[type]?[publicId]?[key]
else if type? and publicId?
return @_metadata[type]?[publicId]
else if type?
return @_metadata[type]
else return null
_fullRefreshFromAPI: ->
return unless @_namespaceId
@_apiRequest() # The lack of type will request everything!
_refreshDBFromAPI: ->
types = Object.keys(@_typesToRefresh)
@_typesToRefresh = {}
promises = types.map (type) => @_apiRequest(type)
Promise.settle(promises)
_apiRequest: (type) ->
typePath = if type then "/#{type}/" else ""
new Promise (resolve, reject) =>
EdgehillAPI.request
path: "/metadata/#{@_namespaceId}#{typePath}"
success: (metadata) ->
metadata = metadata?.results ? []
metadata = metadata.map (metadatum) ->
metadatum.publicId = metadatum.id
return new Metadata(metadatum)
if metadata.length is 0 then resolve()
else
DatabaseStore.persistModels(metadata).then(resolve).catch(reject)
error: (apiError) ->
apiError.notifyConsole()
reject(apiError)
_onDBChanged: (change) ->
return unless change.objectClass is Metadata.name
@_refreshCacheFromDB()
_refreshCacheFromDB: ->
new Promise (resolve, reject) =>
DatabaseStore.findAll(Metadata)
.then (metadata=[]) =>
@_metadata = {}
for metadatum in metadata
@_metadata[metadatum.type] ?= {}
@_metadata[metadatum.type][metadatum.publicId] ?= {}
@_metadata[metadatum.type][metadatum.publicId][metadatum.key] = metadatum.value
@trigger()
resolve()
.catch(reject)
_onNamespaceChanged: ->
@_namespaceId = NamespaceStore.current()?.id
@_fullRefreshFromAPI()
_deleteAllMetadata: ->
DatabaseStore.findAll(Metadata).then (metadata) ->
meatdata.forEach (metadatum) ->
t = new DestroyMetadataTask(metadatum)
Actions.queueTask(t)

View file

@ -0,0 +1,72 @@
_ = require 'underscore-plus'
Task = require './task'
Actions = require '../actions'
Metadata = require '../models/metadata'
EdgehillAPI = require '../edgehill-api'
DatabaseStore = require '../stores/database-store'
NamespaceStore = require '../stores/namespace-store'
module.exports =
class CreateMetadataTask extends Task
constructor: ({@type, @publicId, @key, @value}) ->
@name = "CreateMetadataTask"
shouldDequeueOtherTask: (other) ->
@_isSameTask(other) or @_isOldDestroyTask(other)
_isSameTask: (other) ->
other instanceof CreateMetadataTask and
@type is other.type and @publicId is other.publicId and
@key is other.key and @value is other.value
_isOldDestroyTask: (other) ->
other.name is "DestroyMetadataTask"
@type is other.type and @publicId is other.publicId and @key is other.key
performLocal: ->
return Promise.reject(new Error("Must pass a type")) unless @type?
@metadatum = new Metadata({@type, @publicId, @key, @value})
return DatabaseStore.persistModel(@metadatum)
performRemote: -> new Promise (resolve, reject) =>
EdgehillAPI.request
method: "POST"
path: "/metadata/#{NamespaceStore.current().id}/#{@type}/#{@publicId}"
body:
key: @key
value: @value
success: (args...) =>
Actions.metadataCreated @type, @metadatum
resolve(args...)
error: (apiError) ->
apiError.notifyConsole()
reject(apiError)
onAPIError: (apiError) ->
Actions.metadataError _.extend @_baseErrorData(),
errorType: "APIError"
error: apiError
Promise.resolve()
onOtherError: (otherError) ->
Actions.metadataError _.extend @_baseErrorData(),
errorType: "OtherError"
error: otherError
Promise.resolve()
onTimeoutError: (timeoutError) ->
Actions.metadataError _.extend @_baseErrorData(),
errorType: "TimeoutError"
error: timeoutError
Promise.resolve()
_baseErrorData: ->
action: "create"
className: @constructor.name
type: @type
publicId: @publicId
key: @key
value: @value
onOfflineError: (offlineError) ->
Promise.resolve()

View file

@ -0,0 +1,89 @@
_ = require 'underscore-plus'
Task = require './task'
Actions = require '../actions'
Metadata = require '../models/metadata'
EdgehillAPI = require '../edgehill-api'
DatabaseStore = require '../stores/database-store'
NamespaceStore = require '../stores/namespace-store'
module.exports =
class DestroyMetadataTask extends Task
constructor: ({@type, @publicId, @key}) ->
@name = "DestroyMetadataTask"
shouldDequeueOtherTask: (other) ->
@_isSameTask(other) or @_isOldCreateTask(other)
_isSameTask: (other) ->
other instanceof DestroyMetadataTask and
@type is other.type and @publicId is other.publicId and @key is other.key
_isOldCreateTask: (other) ->
other.name is "CreateMetadataTask"
@type is other.type and @publicId is other.publicId and @key is other.key
performLocal: ->
return Promise.reject(new Error("Must pass a type")) unless @type?
return Promise.reject(new Error("Must pass an publicId")) unless @publicId?
new Promise (resolve, reject) =>
if @key?
matcher = {@type, @publicId, @key}
else
matcher = {@type, @publicId}
DatabaseStore.findAll(Metadata, matcher)
.then (models) ->
if (models ? []).length is 0
resolve()
else
Promise.settle(models.map (m) -> DatabaseStore.unpersistModel(m))
.then(resolve).catch(reject)
.catch (error) ->
console.error "Error finding Metadata to destroy", error
console.error error.stack
reject(error)
performRemote: -> new Promise (resolve, reject) =>
if @key?
body = {@key}
else
body = null
EdgehillAPI.request
method: "DELETE"
path: "/metadata/#{NamespaceStore.current().id}/#{@type}/#{@publicId}"
body: body
success: (args...) =>
Actions.metadataDestroyed(@type)
resolve(args...)
error: (apiError) ->
apiError.notifyConsole()
reject(apiError)
onAPIError: (apiError) ->
Actions.metadataError _.extend @_baseErrorData(),
errorType: "APIError"
error: apiError
Promise.resolve()
onOtherError: (otherError) ->
Actions.metadataError _.extend @_baseErrorData(),
errorType: "OtherError"
error: otherError
Promise.resolve()
onTimeoutError: (timeoutError) ->
Actions.metadataError _.extend @_baseErrorData(),
errorType: "TimeoutError"
error: timeoutError
Promise.resolve()
_baseErrorData: ->
action: "destroy"
className: @constructor.name
type: @type
publicId: @publicId
key: @key
onOfflineError: (offlineError) ->
Promise.resolve()

View file

@ -370,7 +370,8 @@ class PackageManager
@emitter.emit 'did-load-package', pack
return pack
catch error
console.warn "Failed to load package.json '#{path.basename(packagePath)}'", error.stack ? error
console.warn "Failed to load package.json '#{path.basename(packagePath)}'"
console.warn error.stack ? error
else
console.warn "Could not resolve '#{nameOrPath}' to a package path"
null

View file

@ -128,7 +128,8 @@ class Package
@requireMainModule() unless @hasActivationCommands()
catch error
console.warn "Failed to load package named '#{@name}'", error.stack ? error
console.warn "Failed to load package named '#{@name}'"
console.warn error.stack ? error
console.error(error.message, error)
this

View file

@ -7,6 +7,7 @@ require './window'
Atom = require './atom'
window.atom = Atom.loadOrCreate('editor')
global.Promise.longStackTraces() if atom.inDevMode()
atom.initialize()
atom.startRootWindow()

View file

@ -11,6 +11,7 @@ require './window'
Atom = require './atom'
window.atom = Atom.loadOrCreate()
global.Promise.longStackTraces() if atom.inDevMode()
atom.initialize()
atom.startSecondaryWindow()