feat(salesforce): load current user

Summary:
Loads the current user to pre-populate the Salesforce field in the
creators
add tabindex to tokenizing text fields

Test Plan: edgehill --test

Reviewers: bengotow

Reviewed By: bengotow

Differential Revision: https://review.inboxapp.com/D1468
This commit is contained in:
Evan Morikawa 2015-05-07 15:28:44 -07:00
parent 21a32ac57f
commit db687e584e
22 changed files with 185 additions and 73 deletions

View file

@ -24,6 +24,22 @@ class ComposerView extends React.Component
@containerRequired: false
@propTypes:
localId: React.PropTypes.string.isRequired
# Either "inline" or "fullwindow"
mode: React.PropTypes.string
# If this composer is part of an existing thread (like inline
# composers) the threadId will be handed down
threadId: React.PropTypes.string
# Sometimes when changes in the composer happens it's desirable to
# have the parent scroll to a certain location. A parent component can
# pass a callback that gets called when this composer wants to be
# scrolled to.
onRequestScrollTo: React.PropTypes.func
constructor: (@props) ->
@state =
populated: false
@ -211,7 +227,7 @@ class ComposerView extends React.Component
</div>
<InjectedComponentSet
matching={role: "Composer:Footer"}
exposedProps={draftLocalId:@props.localId}/>
exposedProps={draftLocalId:@props.localId, threadId: @props.threadId}/>
</span>
_renderActionsRegion: =>
@ -219,7 +235,7 @@ class ComposerView extends React.Component
<InjectedComponentSet className="composer-action-bar-content"
matching={role: "Composer:ActionButton"}
exposedProps={draftLocalId:@props.localId}>
exposedProps={draftLocalId:@props.localId, threadId: @props.threadId}>
<button className="btn btn-toolbar btn-trash" style={order: 100}
data-tooltip="Delete draft"

View file

@ -71,7 +71,7 @@ beforeEach ->
describe "A blank composer view", ->
beforeEach ->
@composer = ReactTestUtils.renderIntoDocument(
<ComposerView />
<ComposerView localId="test123" />
)
@composer.setState
body: ""

View file

@ -176,7 +176,7 @@ class MessageList extends React.Component
if message.draft
components.push <InjectedComponent matching={role:"Composer"}
exposedProps={mode:"inline", localId:@state.messageLocalIds[message.id], onRequestScrollTo:@_onRequestScrollToComposer}
exposedProps={mode:"inline", localId:@state.messageLocalIds[message.id], onRequestScrollTo:@_onRequestScrollToComposer, threadId:@state.currentThread.id}
ref="composerItem-#{message.id}"
key={@state.messageLocalIds[message.id]}
className={className} />

View file

@ -408,18 +408,21 @@ describe "DraftStore", ->
expect(DraftStore.sendingState(draftLocalId)).toBe false
it "resets the sending state on success", ->
DraftStore._onSendDraft(draftLocalId)
expect(DraftStore.sendingState(draftLocalId)).toBe true
DraftStore._onSendDraftSuccess(draftLocalId)
expect(DraftStore.sendingState(draftLocalId)).toBe false
expect(DraftStore.trigger).toHaveBeenCalled()
waitsForPromise ->
DraftStore._onSendDraft(draftLocalId).then ->
expect(DraftStore.sendingState(draftLocalId)).toBe true
DraftStore._onSendDraftSuccess({draftLocalId})
expect(DraftStore.sendingState(draftLocalId)).toBe false
expect(DraftStore.trigger).toHaveBeenCalled()
it "resets the sending state on error", ->
DraftStore._onSendDraft(draftLocalId)
expect(DraftStore.sendingState(draftLocalId)).toBe true
DraftStore._onSendDraftError(draftLocalId)
expect(DraftStore.sendingState(draftLocalId)).toBe false
expect(DraftStore.trigger).toHaveBeenCalled()
waitsForPromise ->
DraftStore._onSendDraft(draftLocalId).then ->
expect(DraftStore.sendingState(draftLocalId)).toBe true
DraftStore._onSendDraftError(draftLocalId)
expect(DraftStore.sendingState(draftLocalId)).toBe false
expect(DraftStore.trigger).toHaveBeenCalled()
it "closes the window if it's a popout", ->
spyOn(atom, "getWindowType").andReturn "composer"

View file

@ -111,8 +111,8 @@ describe "SendDraftTask", ->
email: 'dummy@inboxapp.com'
@draftLocalId = "local-123"
@task = new SendDraftTask(@draftLocalId)
spyOn(atom.inbox, 'makeRequest').andCallFake (options) ->
options.success() if options.success
spyOn(atom.inbox, 'makeRequest').andCallFake (options) =>
options.success(@draft.toJSON()) if options.success
spyOn(DatabaseStore, 'findByLocalId').andCallFake (klass, localId) =>
Promise.resolve(@draft)
spyOn(DatabaseStore, 'unpersistModel').andCallFake (draft) ->
@ -127,7 +127,13 @@ describe "SendDraftTask", ->
it "should notify the draft was sent", ->
waitsForPromise => @task.performRemote().then =>
expect(Actions.sendDraftSuccess).toHaveBeenCalledWith(@draftLocalId)
args = Actions.sendDraftSuccess.calls[0].args[0]
expect(args.draftLocalId).toBe @draftLocalId
it "get an object back on success", ->
waitsForPromise => @task.performRemote().then =>
args = Actions.sendDraftSuccess.calls[0].args[0]
expect(args.newMessage.id).toBe @draft.id
it "should play a sound", ->
waitsForPromise => @task.performRemote().then ->

View file

@ -226,6 +226,8 @@ class Atom extends Model
@subscribe @packages.onDidActivateInitialPackages => @watchThemes()
@windowEventHandler = new WindowEventHandler
window.onbeforeunload = => @onBeforeUnload()
# Start our error reporting to the backend and attach error handlers
# to the window and the Bluebird Promise library, converting things
# back through the sourcemap as necessary.
@ -452,6 +454,7 @@ class Atom extends Model
# currently loaded
loadSettingsChanged: (loadSettings) =>
@loadSettings = loadSettings
@constructor.loadSettings = loadSettings
{width, height, windowProps} = loadSettings
@packages.windowPropsReceived(windowProps ? {})
@ -872,3 +875,7 @@ class Atom extends Model
setAutoHideMenuBar: (autoHide) ->
ipc.send('call-window-method', 'setAutoHideMenuBar', autoHide)
ipc.send('call-window-method', 'setMenuBarVisibility', !autoHide)
onBeforeUnload: ->
Actions = require './flux/actions'
Actions.unloading()

View file

@ -229,4 +229,4 @@ class Menu extends React.Component
Menu.Item = MenuItem
Menu.NameEmailItem = MenuNameEmailItem
module.exports = Menu
module.exports = Menu

View file

@ -96,8 +96,15 @@ class Popover extends React.Component
setTimeout =>
# Automatically focus the element inside us with the lowest tab index
node = React.findDOMNode(@refs.popover)
matches = _.sortBy node.querySelectorAll("[tabIndex]"), (a,b) -> a.tabIndex < b.tabIndex
matches[0].focus() if matches[0]
# _.sortBy ranks in ascending numerical order.
matches = _.sortBy node.querySelectorAll("[tabIndex], input"), (node) ->
if node.tabIndex > 0
return node.tabIndex
else if node.nodeName is "INPUT"
return 1000000
else return 1000001
matches[0]?.focus()
_onBlur: (event) =>
target = event.nativeEvent.relatedTarget
@ -106,4 +113,4 @@ class Popover extends React.Component
@setState
showing:false
module.exports = Popover
module.exports = Popover

View file

@ -17,11 +17,17 @@ StylesImpactedByZoom = [
'marginRight'
]
# We don't want to call `getLoadSettings` for each and every RetinaImg
# instance because it's a fairly expensive operation. Since the
# resourcePath can't change once the app has booted, it's safe to set the
# constant at require-time
DEFAULT_RESOURCE_PATH = atom.getLoadSettings().resourcePath
###
Public: RetinaImg wraps the DOM's standard `<img`> tag and implements a `UIImage` style
interface. Rather than specifying an image `src`, RetinaImg allows you to provide
an image name. Like UIImage on iOS, it automatically finds the best image for the current
display based on pixel density. Given `image.png`, on a Retina screen, it looks for
display based on pixel density. Given `image.png`, on a Retina screen, it looks for
`image@2x.png`, `image.png`, `image@1x.png` in that order. It uses a lookup table and caches
image names, so images generally resolve immediately.
###
@ -38,6 +44,7 @@ class RetinaImg extends React.Component
- `colorfill` (optional) Adds -webkit-mask-image and other styles, and the .colorfill CSS
class, so that setting a CSS background color will colorfill the image.
- `style` (optional) An {Object} with additional styles to apply to the image.
- `resourcePath` (options) Changes the default lookup location used to find the images.
###
@propTypes:
name: React.PropTypes.string
@ -46,6 +53,7 @@ class RetinaImg extends React.Component
selected: React.PropTypes.bool
active: React.PropTypes.bool
colorfill: React.PropTypes.bool
resourcePath: React.PropTypes.string
render: ->
path = @_pathFor(@props.name) ? @_pathFor(@props.fallback) ? ''
@ -75,7 +83,8 @@ class RetinaImg extends React.Component
name = "#{basename}-active.#{ext}"
if @props.selected is true
name = "#{basename}-selected.#{ext}"
Utils.imageNamed(name)
resourcePath = @props.resourcePath ? DEFAULT_RESOURCE_PATH
Utils.imageNamed(resourcePath, name)
module.exports = RetinaImg
module.exports = RetinaImg

View file

@ -32,8 +32,8 @@ Token = React.createClass
"dragging": @getDragState('token').isDragging
"selected": @props.selected
<div {...@dragSourceFor('token')}
className={classes}
<div {...@dragSourceFor('token')}
className={classes}
onClick={@_onSelect}>
<button className="action" onClick={@_onAction} style={marginTop: "2px"}><RetinaImg name="composer-caret.png" /></button>
{@props.children}
@ -70,6 +70,10 @@ TokenizingTextField = React.createClass
# change.
tokens: React.PropTypes.arrayOf(React.PropTypes.object)
# The maximum number of tokens allowed. When null (the default) and
# unlimited number of tokens may be given
maxTokens: React.PropTypes.number
# A unique ID for each token object
#
# A function that, given an object used for tokens, returns a unique
@ -118,6 +122,10 @@ TokenizingTextField = React.createClass
# updates this component's `tokens` prop.
onAdd: React.PropTypes.func.isRequired
# By default, when blurred, whatever is in the field is added. If this
# is true, the field will be cleared instead.
clearOnBlur: React.PropTypes.bool
# Gets called when we remove a token
#
# It's passed an array of objects (the same ones used to render
@ -186,7 +194,10 @@ TokenizingTextField = React.createClass
measure.innerText = @state.inputValue
measure.style.top = input.offsetTop + "px"
measure.style.left = input.offsetLeft + "px"
input.style.width = "calc(6px + #{measure.offsetWidth}px)"
if @_atMaxTokens()
input.style.width = "4px"
else
input.style.width = "calc(6px + #{measure.offsetWidth}px)"
render: ->
{Menu} = require 'ui-components'
@ -212,17 +223,7 @@ TokenizingTextField = React.createClass
<div className="tokenizing-field-input">
{@_fieldTokenComponents()}
<input type="text"
ref="input"
onCopy={@_onCopy}
onCut={@_onCut}
onPaste={@_onPaste}
onBlur={@_onInputBlurred}
onFocus={@_onInputFocused}
onChange={@_onInputChanged}
disabled={@props.disabled}
tabIndex={@props.tabIndex}
value={@state.inputValue} />
{@_inputEl()}
<span ref="measure" style={
position: 'absolute'
visibility: 'hidden'
@ -230,6 +231,36 @@ TokenizingTextField = React.createClass
</div>
</div>
_inputEl: ->
if @_atMaxTokens()
<input type="text"
ref="input"
className="noop-input"
onCopy={@_onCopy}
onCut={@_onCut}
onBlur={@_onInputBlurred}
onFocus={ => @_onInputFocused(noCompletions: true)}
onChange={ -> "noop" }
tabIndex={@props.tabIndex}
value="" />
else
<input type="text"
ref="input"
onCopy={@_onCopy}
onCut={@_onCut}
onPaste={@_onPaste}
onBlur={@_onInputBlurred}
onFocus={@_onInputFocused}
onChange={@_onInputChanged}
disabled={@props.disabled}
tabIndex={@props.tabIndex}
value={@state.inputValue} />
_atMaxTokens: ->
if @props.maxTokens
@props.tokens.length >= @props.maxTokens
else return false
_renderPrompt: ->
if @props.menuPrompt
<div className="tokenizing-field-label">{"#{@props.menuPrompt}:"}</div>
@ -248,9 +279,9 @@ TokenizingTextField = React.createClass
# Maintaining Input State
_onInputFocused: ->
_onInputFocused: ({noCompletions}={}) ->
@setState focus: true
@_refreshCompletions()
@_refreshCompletions() unless noCompletions
_onInputChanged: (event) ->
val = event.target.value.trimLeft()
@ -260,7 +291,11 @@ TokenizingTextField = React.createClass
@_refreshCompletions(val)
_onInputBlurred: ->
@_addInputValue()
if @props.clearOnBlur
@_clearInput()
else
@_addInputValue()
@_refreshCompletions("", clear: true)
@setState
selectedTokenKey: null
focus: false
@ -275,6 +310,7 @@ TokenizingTextField = React.createClass
# Managing Tokens
_addInputValue: (input) ->
return if @_atMaxTokens()
input ?= @state.inputValue
@props.onAdd(input)
@_clearInput()

View file

@ -55,16 +55,25 @@ class UnsafeComponent extends React.Component
element = <component key={name} {...props} />
@injected = React.render(element, node)
catch err
stack = err.stack
stackEnd = stack.indexOf('react/lib/')
if stackEnd > 0
stackEnd = stack.lastIndexOf('\n', stackEnd)
stack = stack.substr(0,stackEnd)
if atom.inDevMode()
console.error err
stack = err.stack
console.log stack
stackEnd = stack.indexOf('react/lib/')
if stackEnd > 0
stackEnd = stack.lastIndexOf('\n', stackEnd)
stack = stack.substr(0,stackEnd)
element = <div className="unsafe-component-exception">
<div className="message">{@props.component.displayName} could not be displayed.</div>
<div className="trace">{stack}</div>
</div>
element = <div className="unsafe-component-exception">
<div className="message">{@props.component.displayName} could not be displayed.</div>
<div className="trace">{stack}</div>
</div>
else
## TODO
# Add some sort of notification code here that lets us know when
# production builds are having issues!
#
element = <div></div>
@injected = React.render(element, node)

View file

@ -125,6 +125,8 @@ windowActions = [
"metadataError",
"metadataCreated",
"metadataDestroyed"
"unloading" # Tied to `window.onbeforeunload`
]
allActions = [].concat(windowActions).concat(globalActions).concat(mainWindowActions)

View file

@ -124,7 +124,6 @@ class Message extends Model
@naturalSortOrder: ->
Message.attributes.date.ascending()
constructor: ->
super
@subject ||= ""

View file

@ -135,22 +135,21 @@ Utils =
tableNameForJoin: (primaryKlass, secondaryKlass) ->
"#{primaryKlass.name}-#{secondaryKlass.name}"
imageNamed: (fullname) ->
imageNamed: (resourcePath, fullname) ->
[name, ext] = fullname.split('.')
if Utils.images is undefined
start = Date.now()
{resourcePath} = atom.getLoadSettings()
Utils.images ?= {}
if not Utils.images[resourcePath]?
imagesPath = path.join(resourcePath, 'static', 'images')
files = fs.listTreeSync(imagesPath)
Utils.images = {}
Utils.images[path.basename(file)] = file for file in files
Utils.images[resourcePath] ?= {}
Utils.images[resourcePath][path.basename(file)] = file for file in files
if window.devicePixelRatio > 1
return Utils.images["#{name}@2x.#{ext}"] ? Utils.images[fullname] ? Utils.images["#{name}@1x.#{ext}"]
return Utils.images[resourcePath]["#{name}@2x.#{ext}"] ? Utils.images[resourcePath][fullname] ? Utils.images[resourcePath]["#{name}@1x.#{ext}"]
else
return Utils.images["#{name}@1x.#{ext}"] ? Utils.images[fullname] ? Utils.images["#{name}@2x.#{ext}"]
return Utils.images[resourcePath]["#{name}@1x.#{ext}"] ? Utils.images[resourcePath][fullname] ? Utils.images[resourcePath]["#{name}@2x.#{ext}"]
subjectWithPrefix: (subject, prefix) ->
if subject.search(/fwd:/i) is 0

View file

@ -30,7 +30,7 @@ AnalyticsStore = Reflux.createStore
fileAborted: (uploadData={}) -> {fileSize: uploadData.fileSize}
fileUploaded: (uploadData={}) -> {fileSize: uploadData.fileSize}
sendDraftError: (dId, msg) -> {drafLocalId: dId, error: msg}
sendDraftSuccess: (draftLocalId) -> {draftLocalId: draftLocalId}
sendDraftSuccess: ({draftLocalId}) -> {draftLocalId: draftLocalId}
showDeveloperConsole: -> {}
composeReply: ({threadId, messageId}) -> {threadId, messageId}
composeForward: ({threadId, messageId}) -> {threadId, messageId}

View file

@ -56,6 +56,7 @@ class DraftStore
@listenTo Actions.sendDraftError, @_onSendDraftError
@listenTo Actions.sendDraftSuccess, @_onSendDraftSuccess
@listenTo Actions.unloading, @_onBeforeUnload
@_draftSessions = {}
@_sendingState = {}
@ -65,7 +66,6 @@ class DraftStore
# TODO: Doesn't work if we do window.addEventListener, but this is
# fragile. Pending an Atom fix perhaps?
window.onbeforeunload = => @_onBeforeUnload()
######### PUBLIC #######################################################
@ -389,7 +389,7 @@ class DraftStore
@_onPopoutDraftLocalId(draftLocalId, {errorMessage})
@trigger()
_onSendDraftSuccess: (draftLocalId) =>
_onSendDraftSuccess: ({draftLocalId}) =>
@_sendingState[draftLocalId] = false
@trigger()

View file

@ -14,7 +14,7 @@ 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
# SalesforceObjectStore or the SalesforceThreadAssociationStore) 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.
@ -24,7 +24,6 @@ MAX_API_RATE = 1000
module.exports =
MetadataStore = Reflux.createStore
init: ->
return unless atom.isMainWindow()
@listenTo DatabaseStore, @_onDBChanged
@listenTo NamespaceStore, @_onNamespaceChanged
@ -45,9 +44,8 @@ MetadataStore = Reflux.createStore
@_namespaceId = NamespaceStore.current()?.id
@_metadata = {}
return if atom.inSpecMode()
@_fullRefreshFromAPI()
@_refreshCacheFromDB = _.debounce(_.bind(@_refreshCacheFromDB, @), 16)
@_refreshCacheFromDB()
@ -62,10 +60,12 @@ MetadataStore = Reflux.createStore
else return null
_fullRefreshFromAPI: ->
return if not atom.isMainWindow() or atom.inSpecMode()
return unless @_namespaceId
@_apiRequest() # The lack of type will request everything!
_refreshDBFromAPI: ->
return if not atom.isMainWindow() or atom.inSpecMode()
types = Object.keys(@_typesToRefresh)
@_typesToRefresh = {}
promises = types.map (type) => @_apiRequest(type)

View file

@ -48,10 +48,13 @@ class SendDraftTask extends Task
method: 'POST'
body: body
returnsModel: true
success: =>
success: (newMessage) =>
newMessage = (new Message).fromJSON(newMessage)
atom.playSound('mail_sent.ogg')
Actions.postNotification({message: "Sent!", type: 'success'})
Actions.sendDraftSuccess(@draftLocalId)
Actions.sendDraftSuccess
draftLocalId: @draftLocalId
newMessage: newMessage
DatabaseStore.unpersistModel(draft).then(resolve)
error: reject
.catch(reject)

View file

@ -162,7 +162,7 @@ class Package
@activateConfig()
@activateStylesheets()
if @requireMainModule()
@mainModule.activate(atom.packages.getPackageState(@name) ? {})
@mainModule.activate(atom.packages.getPackageState(@name) ? {}, path.resolve(@path))
@mainActivated = true
@activateServices()
catch e
@ -385,6 +385,7 @@ class Package
return
mainModulePath = @getMainModulePath()
@mainModule = require(mainModulePath) if fs.isFileSync(mainModulePath)
return @mainModule
getMainModulePath: ->
return @mainModulePath if @resolvedMainModulePath

View file

@ -181,7 +181,7 @@ class SheetContainer extends React.Component
sheetElements = @_sheetElements()
<Flexbox direction="column">
<div name="Toolbar" style={order:0} className="sheet-toolbar">
<div name="Toolbar" style={order:0, zIndex: 2} className="sheet-toolbar">
{toolbarElements[0]}
<TimeoutTransitionGroup leaveTimeout={125}
enterTimeout={125}
@ -190,12 +190,12 @@ class SheetContainer extends React.Component
</TimeoutTransitionGroup>
</div>
<div name="Header" style={order:1}>
<div name="Header" style={order:1, zIndex: 3}>
<InjectedComponentSet matching={locations: [topSheet.Header, WorkspaceStore.Sheet.Global.Header]}
id={topSheet.id}/>
</div>
<div name="Center" style={order:2, flex: 1, position:'relative'}>
<div name="Center" style={order:2, flex: 1, position:'relative', zIndex: 1}>
{sheetElements[0]}
<TimeoutTransitionGroup leaveTimeout={125}
enterTimeout={125}
@ -204,7 +204,7 @@ class SheetContainer extends React.Component
</TimeoutTransitionGroup>
</div>
<div name="Footer" style={order:3}>
<div name="Footer" style={order:3, zIndex: 4}>
<InjectedComponentSet matching={locations: [topSheet.Footer, WorkspaceStore.Sheet.Global.Footer]}
id={topSheet.id}/>
</div>

View file

@ -27,6 +27,10 @@
.input-area {
position: relative;
flex: 2;
select {
margin-top: 9px;
}
}
.form-error {

View file

@ -78,6 +78,10 @@
position: relative;
padding-left: 2.1em;
&:hover {
cursor: text;
}
input {
display: inline-block;
width: initial;
@ -87,7 +91,14 @@
min-width: 5em;
background-color:transparent;
vertical-align:bottom;
&.noop-input {
position: absolute;
min-width: 0;
padding-left: 0;
margin-right: 0;
}
}
input:focus {
box-shadow: none;
}