fix(contact-chips): Contact chips are editable and have much better style

Summary:
The TokenizingTextField has several new optional props, including onEdit, which enables
editing of the tokens and tokenIsInvalid, which allows you to make tokens red, while still
accepting them as tokens.

When you go to send a message with invalid recipients it won't let you until you remove/
edit them.

Hotloading

Edit chips, keymappings not through command registry, 7 new tests for editing chips

Test Plan: Run 7 new tests

Reviewers: evan

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D1825
This commit is contained in:
Ben Gotow 2015-08-03 13:06:28 -07:00
parent 5843c260f3
commit b7e314ac62
18 changed files with 479 additions and 247 deletions

View file

@ -5,6 +5,7 @@ _ = require 'underscore'
File,
Actions,
DraftStore,
ContactStore,
UndoManager,
FileUploadStore,
QuotedHTMLParser,
@ -582,12 +583,19 @@ class ComposerView extends React.Component
remote = require('remote')
dialog = remote.require('dialog')
if [].concat(draft.to, draft.cc, draft.bcc).length is 0
allRecipients = [].concat(draft.to, draft.cc, draft.bcc)
for contact in allRecipients
if not ContactStore.isValidContact(contact)
dealbreaker = "#{contact.email} is not a valid email address - please remove or edit it before sending."
if allRecipients.length is 0
dealbreaker = 'You need to provide one or more recipients before sending the message.'
if dealbreaker
dialog.showMessageBox(remote.getCurrentWindow(), {
type: 'warning',
buttons: ['Edit Message'],
message: 'Cannot Send',
detail: 'You need to provide one or more recipients before sending the message.'
detail: dealbreaker
})
return

View file

@ -40,11 +40,13 @@ class ParticipantsTextField extends React.Component
ref="textField"
tokens={@props.participants[@props.field]}
tokenKey={ (p) -> p.email }
tokenIsValid={ (p) -> ContactStore.isValidContact(p) }
tokenNode={@_tokenNode}
onRequestCompletions={ (input) -> ContactStore.searchContacts(input) }
completionNode={@_completionNode}
onAdd={@_add}
onRemove={@_remove}
onEdit={@_edit}
onEmptied={@props.onEmptied}
onTokenAction={@_showContextMenu}
tabIndex={@props.tabIndex}
@ -71,6 +73,20 @@ class ParticipantsTextField extends React.Component
<span className="participant-primary">{p.email}</span>
</div>
_tokensForString: (string, options = {}) =>
# If the input is a string, parse out email addresses and build
# an array of contact objects. For each email address wrapped in
# parentheses, look for a preceding name, if one exists.
if string.length is 0
return []
contacts = ContactStore.parseContactsInString(string, options)
if contacts.length > 0
return contacts
else
# If no contacts are returned, treat the entire string as a single
# (malformed) contact object.
return [new Contact(email: string, name: null)]
_remove: (values) =>
field = @props.field
@ -81,22 +97,26 @@ class ParticipantsTextField extends React.Component
false
@props.change(updates)
_edit: (token, replacementString) =>
field = @props.field
tokenIndex = @props.participants[field].indexOf(token)
replacements = @_tokensForString(replacementString)
updates = {}
updates[field] = [].concat(@props.participants[field])
updates[field].splice(tokenIndex, 1, replacements...)
@props.change(updates)
_add: (values, options={}) =>
# If the input is a string, parse out email addresses and build
# an array of contact objects. For each email address wrapped in
# parentheses, look for a preceding name, if one exists.
if _.isString(values)
values = ContactStore.parseContactsInString(values, options)
values = @_tokensForString(values, options)
# Safety check: remove anything from the incoming values that isn't
# a Contact. We should never receive anything else in the values array.
values = _.compact _.map values, (value) ->
if value instanceof Contact
return value
else
return null
values = _.filter values, (value) -> value instanceof Contact
updates = {}
for field in Object.keys(@props.participants)

View file

@ -14,6 +14,7 @@ ReactTestUtils = React.addons.TestUtils
NylasTestUtils,
NamespaceStore,
FileUploadStore,
ContactStore,
ComponentRegistry} = require "nylas-exports"
{InjectedComponent} = require 'nylas-component-kit'
@ -58,12 +59,16 @@ draftStoreProxyStub = (localId, returnedDraft) ->
searchContactStub = (email) ->
_.filter(users, (u) u.email.toLowerCase() is email.toLowerCase())
isValidContactStub = (contact) ->
contact.email.indexOf('@') > 0
ComposerView = proxyquire "../lib/composer-view",
"./file-upload": reactStub("file-upload")
"./image-file-upload": reactStub("image-file-upload")
"nylas-exports":
ContactStore:
searchContacts: (email) -> searchContactStub
searchContacts: searchContactStub
isValidContact: isValidContactStub
DraftStore: DraftStore
beforeEach ->
@ -335,13 +340,26 @@ describe "populated composer", ->
spyOn(@dialog, "showMessageBox")
spyOn(Actions, "sendDraft")
it "shows a warning if there are no recipients", ->
it "shows an error if there are no recipients", ->
useDraft.call @, subject: "no recipients"
makeComposer.call(@)
@composer._sendDraft()
expect(Actions.sendDraft).not.toHaveBeenCalled()
expect(@dialog.showMessageBox).toHaveBeenCalled()
dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1]
expect(dialogArgs.detail).toEqual("You need to provide one or more recipients before sending the message.")
expect(dialogArgs.buttons).toEqual ['Edit Message']
it "shows an error if a recipient is invalid", ->
useDraft.call @,
subject: 'hello world!'
to: [new Contact(email: 'lol', name: 'lol')]
makeComposer.call(@)
@composer._sendDraft()
expect(Actions.sendDraft).not.toHaveBeenCalled()
expect(@dialog.showMessageBox).toHaveBeenCalled()
dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1]
expect(dialogArgs.detail).toEqual("lol is not a valid email address - please remove or edit it before sending.")
expect(dialogArgs.buttons).toEqual ['Edit Message']
describe "empty body warning", ->

View file

@ -30,8 +30,6 @@ participant5 = new Contact
name: 'EVAN'
describe 'ParticipantsTextField', ->
NylasTestUtils.loadKeymap()
beforeEach ->
@propChange = jasmine.createSpy('change')
@ -54,7 +52,7 @@ describe 'ParticipantsTextField', ->
@expectInputToYield = (input, expected) ->
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: input}})
NylasTestUtils.keyPress('enter', @renderedInput)
ReactTestUtils.Simulate.keyDown(@renderedInput, {key: 'Enter', keyCode: 9})
reviver = (k,v) ->
return undefined if k in ["id", "object"]

View file

@ -207,19 +207,6 @@
padding: 0;
margin: 0;
}
.token {
background: transparent;
&.selected,
&.dragging {
.participant-secondary {
color: @text-color-inverse-very-subtle;
}
}
}
}
body.is-blurred .composer-inner-wrap .tokenizing-field .token {
background: transparent;
}
// Overrides for the full-window popout composer

View file

@ -18,7 +18,7 @@ class Participants extends React.Component
render: =>
chips = @getParticipants().map (p) =>
<ContactChip key={p.nameEmail()} clickable={@props.clickable} participant={p} />
<ContactChip key={p.toString()} clickable={@props.clickable} participant={p} />
<span>
{chips}

View file

@ -214,29 +214,3 @@
'shift-tab': 'native!'
# Tokenizing Text fields
# The default state. There's no input in the text field and there are no
# autocomplete suggestions
'.tokenizing-field input':
'backspace': 'native!'
'delete': 'native!'
'tab': 'native!'
',': 'native!'
'.tokenizing-field.empty input':
'backspace': 'tokenizing-field:remove'
'delete': 'tokenizing-field:remove'
# When we found a name to suggest
'.tokenizing-field.has-suggestions input':
',': 'tokenizing-field:add-suggestion'
'tab': 'tokenizing-field:add-suggestion'
'enter': 'tokenizing-field:add-suggestion'
'escape': 'tokenizing-field:cancel'
# When there is some text in the input field, but we have no suggestion
'.tokenizing-field:not(.has-suggestions) input':
'tab': 'native!'
',': 'tokenizing-field:add-input-value'
'enter': 'tokenizing-field:add-input-value'
'escape': 'tokenizing-field:cancel'

View file

@ -39,14 +39,14 @@ participant5 = new Contact
name: 'Michael'
describe 'TokenizingTextField', ->
NylasTestUtils.loadKeymap()
beforeEach ->
@completions = []
@propAdd = jasmine.createSpy 'add'
@propEdit = jasmine.createSpy 'edit'
@propRemove = jasmine.createSpy 'remove'
@propEmptied = jasmine.createSpy 'emptied'
@propTokenKey = jasmine.createSpy("tokenKey").andCallFake (p) -> p.email
@propTokenIsValid = jasmine.createSpy("tokenIsValid").andReturn(true)
@propTokenNode = (p) -> <CustomToken item={p} />
@propOnTokenAction = jasmine.createSpy 'tokenAction'
@propCompletionNode = (p) -> <CustomSuggestion item={p} />
@ -58,26 +58,32 @@ describe 'TokenizingTextField', ->
@tabIndex = 100
@tokens = [participant1, participant2, participant3]
@renderedField = ReactTestUtils.renderIntoDocument(
<TokenizingTextField
tokens={@tokens}
tokenKey={@propTokenKey}
tokenNode={@propTokenNode}
onRequestCompletions={@propCompletionsForInput}
completionNode={@propCompletionNode}
onAdd={@propAdd}
onRemove={@propRemove}
onEmptied={@propEmptied}
onTokenAction={@propOnTokenAction}
tabIndex={@tabIndex}
/>
)
@renderedInput = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(@renderedField, 'input'))
@rebuildRenderedField = =>
@renderedField = ReactTestUtils.renderIntoDocument(
<TokenizingTextField
tokens={@tokens}
tokenKey={@propTokenKey}
tokenNode={@propTokenNode}
tokenIsValid={@propTokenIsValid}
onRequestCompletions={@propCompletionsForInput}
completionNode={@propCompletionNode}
onAdd={@propAdd}
onEdit={@propEdit}
onRemove={@propRemove}
onEmptied={@propEmptied}
onTokenAction={@propOnTokenAction}
tabIndex={@tabIndex}
/>
)
@renderedInput = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(@renderedField, 'input'))
@rebuildRenderedField()
it 'renders into the document', ->
expect(ReactTestUtils.isCompositeComponentWithType @renderedField, TokenizingTextField).toBe(true)
it 'should render an input field', ->
expect(@renderedInput).toBeDefined()
it 'applies the tabIndex provided to the inner input', ->
expect(@renderedInput.tabIndex).toBe(@tabIndex)
@ -90,6 +96,25 @@ describe 'TokenizingTextField', ->
for i in [0..@tokens.length-1]
expect(@renderedTokens[i].props.item).toBe(@tokens[i])
describe "prop: tokenIsValid", ->
it "should be evaluated for each token when it's provided", ->
@propTokenIsValid = jasmine.createSpy("tokenIsValid").andCallFake (p) =>
if p is participant2 then true else false
@rebuildRenderedField()
@tokens = ReactTestUtils.scryRenderedComponentsWithType(@renderedField, TokenizingTextField.Token)
expect(@tokens[0].props.valid).toBe(false)
expect(@tokens[1].props.valid).toBe(true)
expect(@tokens[2].props.valid).toBe(false)
it "should default to true when not provided", ->
@propTokenIsValid = null
@rebuildRenderedField()
@tokens = ReactTestUtils.scryRenderedComponentsWithType(@renderedField, TokenizingTextField.Token)
expect(@tokens[0].props.valid).toBe(true)
expect(@tokens[1].props.valid).toBe(true)
expect(@tokens[2].props.valid).toBe(true)
describe "When the user selects a token", ->
beforeEach ->
token = ReactTestUtils.scryRenderedDOMComponentsWithClass(@renderedField, 'token')[0]
@ -175,20 +200,20 @@ describe 'TokenizingTextField', ->
expect(@propCompletionsForInput.calls[2].args[0]).toBe('ab c')
expect(@propAdd).not.toHaveBeenCalled()
['enter', ','].forEach (key) ->
[{key:'Enter', keyCode:13}, {key:',', keyCode: 188}].forEach ({key, keyCode}) ->
describe "when the user presses #{key}", ->
describe "and there is an completion available", ->
it "should call add with the first completion", ->
@completions = [participant4]
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}})
NylasTestUtils.keyPress(key, @renderedInput)
ReactTestUtils.Simulate.keyDown(@renderedInput, {key: key, keyCode: keyCode})
expect(@propAdd).toHaveBeenCalledWith([participant4])
describe "and there is NO completion available", ->
it 'should call add, allowing the parent to (optionally) turn the text into a token', ->
@completions = []
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}})
NylasTestUtils.keyPress(key, @renderedInput)
ReactTestUtils.Simulate.keyDown(@renderedInput, {key: key, keyCode: keyCode})
expect(@propAdd).toHaveBeenCalledWith('abc', {})
describe "when the user presses tab", ->
@ -196,7 +221,7 @@ describe 'TokenizingTextField', ->
it "should call add with the first completion", ->
@completions = [participant4]
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}})
NylasTestUtils.keyPress('tab', @renderedInput)
ReactTestUtils.Simulate.keyDown(@renderedInput, {key: 'Tab', keyCode: 9})
expect(@propAdd).toHaveBeenCalledWith([participant4])
describe "when blurred", ->
@ -218,12 +243,43 @@ describe 'TokenizingTextField', ->
ReactTestUtils.Simulate.blur(@renderedInput)
expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(@renderedField, 'focused').length).toBe(0)
describe "when the user double-clicks a token", ->
describe "when an onEdit prop has been provided", ->
beforeEach ->
@propEdit = jasmine.createSpy('onEdit')
@rebuildRenderedField()
it "should enter editing mode", ->
tokens = ReactTestUtils.scryRenderedComponentsWithType(@renderedField, TokenizingTextField.Token)
expect(tokens[0].state.editing).toBe(false)
ReactTestUtils.Simulate.doubleClick(React.findDOMNode(tokens[0]), {})
expect(tokens[0].state.editing).toBe(true)
it "should call onEdit to commit the new token value when the edit field is blurred", ->
tokens = ReactTestUtils.scryRenderedComponentsWithType(@renderedField, TokenizingTextField.Token)
token = tokens[0]
tokenEl = React.findDOMNode(token)
expect(token.state.editing).toBe(false)
ReactTestUtils.Simulate.doubleClick(tokenEl, {})
tokenEditInput = ReactTestUtils.findRenderedDOMComponentWithTag(token, 'input')
ReactTestUtils.Simulate.change(tokenEditInput, {target: {value: 'new tag content'}})
ReactTestUtils.Simulate.blur(tokenEditInput)
expect(@propEdit).toHaveBeenCalledWith(participant1, 'new tag content')
describe "when no onEdit prop has been provided", ->
it "should not enter editing mode", ->
@propEdit = undefined
@rebuildRenderedField()
tokens = ReactTestUtils.scryRenderedComponentsWithType(@renderedField, TokenizingTextField.Token)
expect(tokens[0].state.editing).toBe(false)
ReactTestUtils.Simulate.doubleClick(React.findDOMNode(tokens[0]), {})
expect(tokens[0].state.editing).toBe(false)
describe "When the user removes a token", ->
it "deletes with the backspace key", ->
spyOn(@renderedField, "_removeToken")
NylasTestUtils.keyPress("backspace", @renderedInput)
ReactTestUtils.Simulate.keyDown(@renderedInput, {key: 'Backspace', keyCode: 8})
expect(@renderedField._removeToken).toHaveBeenCalled()
describe "when removal is passed in a token object", ->

View file

@ -94,19 +94,30 @@ describe "ContactStore", ->
describe 'parseContactsInString', ->
testCases =
# Single contact test cases
"evan@nylas.com": [new Contact(name: "evan@nylas.com", email: "evan@nylas.com")]
"Evan Morikawa": []
"Evan Morikawa <evan@nylas.com>": [new Contact(name: "Evan Morikawa", email: "evan@nylas.com")]
"Evan Morikawa (evan@nylas.com)": [new Contact(name: "Evan Morikawa", email: "evan@nylas.com")]
"spang (Christine Spang) <noreply+phabricator@nilas.com>": [new Contact(name: "spang (Christine Spang)", email: "noreply+phabricator@nilas.com")]
"spang 'Christine Spang' <noreply+phabricator@nilas.com>": [new Contact(name: "spang 'Christine Spang'", email: "noreply+phabricator@nilas.com")]
"spang \"Christine Spang\" <noreply+phabricator@nilas.com>": [new Contact(name: "spang \"Christine Spang\"", email: "noreply+phabricator@nilas.com")]
"Evan (evan@nylas.com)": [new Contact(name: "Evan", email: "evan@nylas.com")]
# Multiple contact test cases
"Evan Morikawa <evan@nylas.com>, Ben <ben@nylas.com>": [
new Contact(name: "Evan Morikawa", email: "evan@nylas.com")
new Contact(name: "Ben", email: "ben@nylas.com")
]
"mark@nylas.com\nGleb (gleb@nylas.com)\rEvan Morikawa <evan@nylas.com>, spang (Christine Spang) <noreply+phabricator@nilas.com>": [
new Contact(name: "", email: "mark@nylas.com")
new Contact(name: "Gleb", email: "gleb@nylas.com")
new Contact(name: "Evan Morikawa", email: "evan@nylas.com")
new Contact(name: "spang (Christine Spang)", email: "noreply+phabricator@nilas.com")
]
_.forEach testCases, (value, key) ->
it "works for #{key}", ->
testContacts = ContactStore.parseContactsInString(key).map (c) -> c.nameEmail()
expectedContacts = value.map (c) -> c.nameEmail()
testContacts = ContactStore.parseContactsInString(key).map (c) -> c.toString()
expectedContacts = value.map (c) -> c.toString()
expect(testContacts).toEqual expectedContacts

View file

@ -33,6 +33,7 @@ var hotCompile = (function () {
clearTimeout(timeout);
timeout = setTimeout(function () {
hotCompile(module, module.filename, true);
console.log('hot reloaded '+module.filename);
}, 100);
});
}

View file

@ -5,48 +5,125 @@ _ = require 'underscore'
{Utils, RegExpUtils, Contact, ContactStore} = require 'nylas-exports'
RetinaImg = require './retina-img'
Token = React.createClass
displayName: "Token"
class SizeToFitInput extends React.Component
constructor: (@props) ->
@state = {}
propTypes:
componentDidMount: =>
@_sizeToFit()
componentDidUpdate: =>
@_sizeToFit()
_sizeToFit: =>
# Measure the width of the text in the input and
# resize the input field to fit.
input = React.findDOMNode(@refs.input)
measure = React.findDOMNode(@refs.measure)
measure.innerText = input.value
measure.style.top = input.offsetTop + "px"
measure.style.left = input.offsetLeft + "px"
# The 10px comes from the 7.5px left padding and 2.5px more of
# breathing room.
input.style.width = "#{measure.offsetWidth+10}px"
render: =>
<span>
<span ref="measure" style={visibility:'hidden', position: 'absolute'}></span>
<input ref="input" type="text" {...@props}/>
</span>
select: =>
React.findDOMNode(@refs.input).select()
focus: =>
React.findDOMNode(@refs.input).focus()
class Token extends React.Component
@displayName: "Token"
@propTypes:
selected: React.PropTypes.bool,
select: React.PropTypes.func.isRequired,
action: React.PropTypes.func,
valid: React.PropTypes.bool,
item: React.PropTypes.object,
onSelected: React.PropTypes.func.isRequired,
onEdited: React.PropTypes.func,
onAction: React.PropTypes.func
getInitialState: ->
{}
constructor: (@props) ->
@state =
editing: false
editingValue: @props.item.toString()
render: ->
render: =>
if @state.editing
@_renderEditing()
else
@_renderViewing()
componentDidUpdate: (prevProps, prevState) =>
if @state.editing && !prevState.editing
@refs.input.select()
componentWillReceiveProps: (props) =>
# never override the text the user is editing if they're looking at it
return if @state.editing
@setState(editingValue: @props.item.toString())
_renderEditing: =>
<SizeToFitInput
ref="input"
className="token-editing-input"
spellCheck="false"
value={@state.editingValue}
onKeyDown={@_onEditKeydown}
onBlur={@_onEditFinished}
onChange={ (event) => @setState(editingValue: event.target.value)}/>
_renderViewing: =>
classes = classNames
"token": true
"dragging": @state.dragging
"invalid": !@props.valid
"selected": @props.selected
<div onDragStart={@_onDragStart} onDragEnd={@_onDragEnd} draggable="true"
className={classes}
<div className={classes}
onDragStart={@_onDragStart}
onDragEnd={@_onDragEnd}
draggable="true"
onDoubleClick={@_onDoubleClick}
onClick={@_onSelect}>
<button className="action" onClick={@_onAction} style={marginTop: "2px"}>
<button className="action" onClick={@_onAction}>
<RetinaImg mode={RetinaImg.Mode.ContentIsMask} name="composer-caret.png" />
</button>
{@props.children}
</div>
_onDragStart: (event) ->
textValue = React.findDOMNode(@).innerText
_onDragStart: (event) =>
event.dataTransfer.setData('nylas-token-item', JSON.stringify(@props.item))
event.dataTransfer.setData('text/plain', textValue)
event.dataTransfer.setData('text/plain', @props.item.toString())
@setState(dragging: true)
_onDragEnd: (event) ->
_onDragEnd: (event) =>
@setState(dragging: false)
_onSelect: (event) ->
@props.select(@props.item)
event.preventDefault()
_onSelect: (event) =>
@props.onSelected(@props.item)
_onAction: (event) ->
@props.action(@props.item)
_onDoubleClick: (event) =>
if @props.onEdited
@setState(editing: true)
_onEditKeydown: (event) =>
if event.key in ['Escape', 'Enter']
@_onEditFinished()
_onEditFinished: (event) =>
@props.onEdited?(@props.item, @state.editingValue)
@setState(editing: false)
_onAction: (event) =>
@props.onAction(@props.item)
event.preventDefault()
###
@ -60,10 +137,14 @@ See documentation on the propTypes for usage info.
Section: Component Kit
###
TokenizingTextField = React.createClass
displayName: "TokenizingTextField"
propTypes:
class TokenizingTextField extends React.Component
@displayName: "TokenizingTextField"
# Exposed for tests
@Token: Token
@propTypes:
# An array of current tokens.
#
# A token is usually an object type like a
@ -77,8 +158,6 @@ TokenizingTextField = React.createClass
# 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
# id (key) for that object.
#
@ -86,6 +165,13 @@ TokenizingTextField = React.createClass
# unique key.
tokenKey: React.PropTypes.func.isRequired
# A function that, given a token, returns true if the token is valid
# and false if the token is invalid. Useful if your implementation of
# onAdd allows invalid tokens to be added to the field (ie malformed
# email addresses.) Optional.
#
tokenIsValid: React.PropTypes.func
# What each token looks like
#
# A function that is passed an object and should return React elements
@ -125,10 +211,6 @@ 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
@ -139,6 +221,16 @@ TokenizingTextField = React.createClass
# updates this component's `tokens` prop.
onRemove: React.PropTypes.func.isRequired
# Gets called when an existing token is double-clicked and edited.
# Do not provide this method if you want to disable editing.
#
# It's passed a token index, and the new text typed in that location.
#
# The function doesn't need to return anything, but it is generally
# responible for mutating the parent's state in a way that eventually
# updates this component's `tokens` prop.
onEdit: React.PropTypes.func
# Called when we remove and there's nothing left to remove
onEmptied: React.PropTypes.func
@ -157,134 +249,109 @@ TokenizingTextField = React.createClass
# A classSet hash applied to the Menu item
menuClassSet: React.PropTypes.object
getInitialState: ->
inputValue: ""
completions: []
selectedTokenKey: null
constructor: (@props) ->
@state =
inputValue: ""
completions: []
selectedTokenKey: null
componentDidMount: ->
input = React.findDOMNode(@refs.input)
check = (fn) -> (event) ->
return unless event.target is input
# Wrapper to guard against events triggering on the wrong element
fn(event)
@subscriptions = new CompositeDisposable()
@subscriptions.add atom.commands.add '.tokenizing-field',
'tokenizing-field:cancel': check => @_clearInput()
'tokenizing-field:remove': check => @_removeToken()
'tokenizing-field:add-suggestion': check => @_addToken(@refs.completions.getSelectedItem() || @state.completions[0])
'tokenizing-field:add-input-value': check => @_addInputValue()
componentWillUnmount: ->
@subscriptions?.dispose()
componentDidUpdate: ->
# Measure the width of the text in the input and
# resize the input field to fit.
input = React.findDOMNode(@refs.input)
measure = React.findDOMNode(@refs.measure)
measure.innerHTML = @state.inputValue.replace(/\s/g, "&nbsp;")
measure.style.top = input.offsetTop + "px"
measure.style.left = input.offsetLeft + "px"
if @_atMaxTokens()
input.style.width = "4px"
else
# The 10px comes from the 7.5px left padding and 2.5px more of
# breathing room.
input.style.width = "#{measure.offsetWidth+10}px"
render: ->
render: =>
{Menu} = require 'nylas-component-kit'
classes = classNames _.extend (@props.menuClassSet ? {}),
"tokenizing-field": true
"focused": @state.focus
"native-key-bindings": true
"focused": @state.focus
"empty": (@state.inputValue ? "").trim().length is 0
"has-suggestions": @state.completions.length > 0
<Menu className={classes} ref="completions"
items={@state.completions}
itemKey={ (item) -> item.id }
itemContent={@props.completionNode}
headerComponents={[@_fieldComponent()]}
onFocus={@_onInputFocused}
onBlur={@_onInputBlurred}
onSelect={@_addToken}
/>
_fieldComponent: ->
<div key="field-component" onClick={@focus} onDrop={@_onDrop}>
_fieldComponent: =>
<div key="field-component" onClick={@_onClick} onDrop={@_onDrop}>
{@_renderPrompt()}
<div className="tokenizing-field-input">
{@_placeholder()}
{@_fieldTokenComponents()}
{@_fieldComponents()}
{@_inputEl()}
<span ref="measure" style={
position: 'absolute'
visibility: 'hidden'
}/>
</div>
</div>
_inputEl: ->
if @_atMaxTokens()
<input type="text"
ref="input"
spellCheck="false"
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"
spellCheck="false"
onCopy={@_onCopy}
onCut={@_onCut}
onPaste={@_onPaste}
onBlur={@_onInputBlurred}
onFocus={@_onInputFocused}
onChange={@_onInputChanged}
disabled={@props.disabled}
tabIndex={@props.tabIndex}
value={@state.inputValue} />
_inputEl: =>
props =
onCopy: @_onCopy
onCut: @_onCut
onPaste: @_onPaste
onKeyDown: @_onInputKeydown
onBlur: @_onInputBlurred
onFocus: @_onInputFocused
onChange: @_onInputChanged
disabled: @props.disabled
tabIndex: @props.tabIndex
value: @state.inputValue
_placeholder: ->
# If we can't accept additional tokens, override the events that would
# enable additional items to be inserted
if @_atMaxTokens()
props.className = "noop-input"
props.onFocus = => @_onInputFocused(noCompletions: true)
props.onPaste = => 'noop-input'
props.onChange = => 'noop'
props.value = ''
<SizeToFitInput ref="input" spellCheck="false" {...props} />
_placeholder: =>
if not @state.focus and @props.placeholder?
return <div className="placeholder">{@props.placeholder}</div>
else
return <span></span>
_atMaxTokens: ->
_atMaxTokens: =>
if @props.maxTokens
@props.tokens.length >= @props.maxTokens
else return false
_renderPrompt: ->
_renderPrompt: =>
if @props.menuPrompt
<div className="tokenizing-field-label">{"#{@props.menuPrompt}:"}</div>
else
<div></div>
_fieldTokenComponents: ->
_fieldComponents: =>
@props.tokens.map (item) =>
key = @props.tokenKey(item)
valid = true
if @props.tokenIsValid
valid = @props.tokenIsValid(item)
<Token item={item}
key={@props.tokenKey(item)}
select={@_selectToken}
action={@props.onTokenAction || @_showDefaultTokenMenu}
selected={@state.selectedTokenKey is @props.tokenKey(item)}>
key={key}
valid={valid}
selected={@state.selectedTokenKey is key}
onSelected={@_selectToken}
onEdited={@props.onEdit}
onAction={@props.onTokenAction || @_showDefaultTokenMenu}>
{@props.tokenNode(item)}
</Token>
# Maintaining Input State
_onDrop: (event) ->
_onClick: (event) =>
# Don't focus if the focus is already on an input within our field,
# like an editable token's input
if event.target.tagName is 'INPUT' and React.findDOMNode(@).contains(event.target)
return
@focus()
_onDrop: (event) =>
return unless 'nylas-token-item' in event.dataTransfer.types
try
@ -296,11 +363,24 @@ TokenizingTextField = React.createClass
if model and model instanceof Contact
@_addToken(model)
_onInputFocused: ({noCompletions}={}) ->
@setState focus: true
_onInputFocused: ({noCompletions}={}) =>
@setState(focus: true)
@_refreshCompletions() unless noCompletions
_onInputChanged: (event) ->
_onInputKeydown: (event) =>
if event.key in ["Backspace", "Delete"]
@_removeToken()
else if event.key in ["Escape"]
@_refreshCompletions("", clear: true)
else if event.key in ["Tab", "Enter"] or event.keyCode is 188 # comma
if @state.completions.length > 0
@_addToken(@refs.completions.getSelectedItem() || @state.completions[0])
else
@_addInputValue()
_onInputChanged: (event) =>
val = event.target.value.trimLeft()
@setState
selectedTokenKey: null
@ -308,51 +388,52 @@ TokenizingTextField = React.createClass
# If it looks like an email, and the last character entered was a
# space, then let's add the input value.
# TODO WHY IS THIS EMAIL RELATED?
if RegExpUtils.emailRegex().test(val) and _.last(val) is " "
@_addInputValue(val[0...-1], skipNameLookup: true)
else
@_refreshCompletions(val)
_onInputBlurred: ->
if @props.clearOnBlur
@_clearInput()
else
@_addInputValue()
_onInputBlurred: (event) =>
if event.relatedTarget is React.findDOMNode(@)
return
@_addInputValue()
@_refreshCompletions("", clear: true)
@setState
selectedTokenKey: null
focus: false
_clearInput: ->
@setState inputValue: ""
_clearInput: =>
@setState(inputValue: "")
@_refreshCompletions("", clear: true)
focus: ->
React.findDOMNode(@refs.input).focus()
focus: =>
@refs.input.focus()
# Managing Tokens
_addInputValue: (input, options={}) ->
_addInputValue: (input, options={}) =>
return if @_atMaxTokens()
input ?= @state.inputValue
@props.onAdd(input, options)
@_clearInput()
_selectToken: (token) ->
_selectToken: (token) =>
@setState
selectedTokenKey: @props.tokenKey(token)
_selectedToken: ->
_selectedToken: =>
_.find @props.tokens, (t) =>
@props.tokenKey(t) is @state.selectedTokenKey
_addToken: (token) ->
_addToken: (token) =>
return unless token
@props.onAdd([token])
@_clearInput()
@focus()
_removeToken: (token = null) ->
_removeToken: (token = null) =>
if @state.inputValue.trim().length is 0 and @props.tokens.length is 0 and @props.onEmptied?
@props.onEmptied()
@ -369,7 +450,7 @@ TokenizingTextField = React.createClass
@setState
selectedTokenKey: null
_showDefaultTokenMenu: (token) ->
_showDefaultTokenMenu: (token) =>
remote = require('remote')
Menu = remote.require('menu')
MenuItem = remote.require('menu-item')
@ -379,23 +460,22 @@ TokenizingTextField = React.createClass
label: 'Remove',
click: => @_removeToken(token)
))
menu.popup(remote.getCurrentWindow())
# Copy and Paste
_onCut: (event) ->
_onCut: (event) =>
if @state.selectedTokenKey
event.clipboardData?.setData('text/plain', @props.tokenKey(@_selectedToken()))
event.preventDefault()
@_removeToken(@_selectedToken())
_onCopy: (event) ->
_onCopy: (event) =>
if @state.selectedTokenKey
event.clipboardData.setData('text/plain', @props.tokenKey(@_selectedToken()))
event.preventDefault()
_onPaste: (event) ->
_onPaste: (event) =>
data = event.clipboardData.getData('text/plain')
@_addInputValue(data)
event.preventDefault()
@ -406,7 +486,7 @@ TokenizingTextField = React.createClass
# current inputValue. Since `onRequestCompletions` can be asynchronous,
# this function will handle calling `setState` on `completions` when
# `onRequestCompletions` returns.
_refreshCompletions: (val = @state.inputValue, {clear}={}) ->
_refreshCompletions: (val = @state.inputValue, {clear}={}) =>
existingKeys = _.map(@props.tokens, @props.tokenKey)
filterTokens = (tokens) =>
_.reject tokens, (t) => @props.tokenKey(t) in existingKeys

View file

@ -1,6 +1,16 @@
_ = require 'underscore'
DOMUtils =
escapeHTMLCharacters: (text) ->
map =
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
text.replace /[&<>"']/g, (m) -> map[m]
removeElements: (elements=[]) ->
for el in elements
try

View file

@ -53,9 +53,13 @@ class Contact extends Model
'phone': Attributes.String(modelKey: 'phone')
'company': Attributes.String(modelKey: 'company')
# Used to uniquely identify a contact
nameEmail: ->
"#{(@name ? "").toLowerCase().trim()} #{@email.toLowerCase().trim()}"
# Public: Returns a string of the format `Full Name <email@address.com>` if
# the contact has a populated name, just the email address otherwise.
toString: ->
# Note: This is used as the drag-drop text of a Contact token, in the
# creation of message bylines "From Ben Gotow <ben@nylas>", and several other
# places. Change with care.
if @name and @name isnt @email then "#{@name} <#{@email}>" else @email
toJSON: ->
json = super
@ -70,10 +74,6 @@ class Contact extends Model
return "You" if @email is NamespaceStore.current()?.emailAddress
@_nameParts().join(' ')
# Full Name <email@address.com>
messageName: ->
if @name then "#{@name} &lt;#{@email}&gt;" else @email
displayFirstName: ->
return "You" if @email is NamespaceStore.current()?.emailAddress
@firstName()

View file

@ -263,7 +263,7 @@ class Message extends Model
# localized for the current user.
# ie "On Dec. 12th, 2015 at 4:00PM, Ben Gotow wrote:"
replyAttributionLine: ->
"On #{@formattedDate()}, #{@fromContact().messageName()} wrote:"
"On #{@formattedDate()}, #{@fromContact().toString()} wrote:"
formattedDate: -> moment(@date).format("MMM D YYYY, [at] h:mm a")

View file

@ -207,9 +207,18 @@ class ContactStore extends NylasStore
matches
# Public: Returns true if the contact provided is a {Contact} instance and
# contains a properly formatted email address.
#
isValidContact: (contact) =>
return false unless contact instanceof Contact
return contact.email and RegExpUtils.emailRegex().test(contact.email)
parseContactsInString: (contactString, {skipNameLookup}={}) =>
detected = []
emailRegex = RegExpUtils.emailRegex()
lastMatchEnd = 0
while (match = emailRegex.exec(contactString))
email = match[0]
name = null
@ -218,8 +227,8 @@ class ContactStore extends NylasStore
hasTrailingParen = contactString[match.index+email.length] in [')','>']
if hasLeadingParen and hasTrailingParen
nameStart = 0
for char in ['>', ')', ',', '\n', '\r']
nameStart = lastMatchEnd
for char in [',', '\n', '\r']
i = contactString.lastIndexOf(char, match.index)
nameStart = i+1 if i+1 > nameStart
name = contactString.substr(nameStart, match.index - 1 - nameStart).trim()
@ -233,7 +242,13 @@ class ContactStore extends NylasStore
else
name = email
# The "nameStart" for the next match must begin after lastMatchEnd
lastMatchEnd = match.index+email.length
if hasTrailingParen
lastMatchEnd += 1
detected.push(new Contact({email, name}))
detected
__refreshCache: =>

View file

@ -19,9 +19,9 @@ Actions = require '../actions'
TaskQueue = require './task-queue'
{subjectWithPrefix, generateTempId} = require '../models/utils'
{Listener, Publisher} = require '../modules/reflux-coffee'
CoffeeHelpers = require '../coffee-helpers'
DOMUtils = require '../../dom-utils'
###
Public: DraftStore responds to Actions that interact with Drafts and exposes
@ -258,7 +258,8 @@ class DraftStore
attributes.subject ?= subjectWithPrefix(thread.subject, 'Re:')
attributes.body ?= ""
contactStrings = (cs) -> _.invoke(cs, "messageName").join(", ")
contactsAsHtml = (cs) ->
DOMUtils.escapeHTMLCharacters(_.invoke(cs, "toString").join(", "))
if attributes.replyToMessage
msg = attributes.replyToMessage
@ -268,7 +269,7 @@ class DraftStore
attributes.body = """
<br><br><blockquote class="gmail_quote"
style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;">
#{msg.replyAttributionLine()}
#{DOMUtils.escapeHTMLCharacters(msg.replyAttributionLine())}
<br>
#{@_formatBodyForQuoting(msg.body)}
</blockquote>"""
@ -277,12 +278,12 @@ class DraftStore
if attributes.forwardMessage
msg = attributes.forwardMessage
fields = []
fields.push("From: #{contactStrings(msg.from)}") if msg.from.length > 0
fields.push("From: #{contactsAsHtml(msg.from)}") if msg.from.length > 0
fields.push("Subject: #{msg.subject}")
fields.push("Date: #{msg.formattedDate()}")
fields.push("To: #{contactStrings(msg.to)}") if msg.to.length > 0
fields.push("CC: #{contactStrings(msg.cc)}") if msg.cc.length > 0
fields.push("BCC: #{contactStrings(msg.bcc)}") if msg.bcc.length > 0
fields.push("To: #{contactsAsHtml(msg.to)}") if msg.to.length > 0
fields.push("CC: #{contactsAsHtml(msg.cc)}") if msg.cc.length > 0
fields.push("BCC: #{contactsAsHtml(msg.bcc)}") if msg.bcc.length > 0
if msg.files?.length > 0
attributes.files ?= []

View file

@ -86,11 +86,11 @@ FocusedContactsStore = Reflux.createStore
_assignScore: (contact, score=0) ->
return unless contact?.email
return if contact.email.trim().length is 0
return if @_contactScores[contact.nameEmail()]? # only assign the first time
return if @_contactScores[contact.toString()]? # only assign the first time
penalties = @_calculatePenalties(contact, score)
@_contactScores[contact.nameEmail()] =
@_contactScores[contact.toString()] =
contact: contact
score: score - penalties

View file

@ -1,5 +1,17 @@
@import "ui-variables";
@token-top:lighten(@background-secondary,0.6%);
@token-bottom:darken(@background-secondary, 2.5%);
@token-hover-top: mix(@token-top, @component-active-color, 92%);
@token-hover-bottom: mix(@token-bottom, @component-active-color, 90%);
@token-selected-top: mix(@token-top, @component-active-color, 15%);
@token-selected-bottom: mix(@token-bottom, @component-active-color, 0%);
@token-invalid-selected-top: mix(@token-top, red, 60%);
@token-invalid-selected-bottom: mix(@token-bottom, red, 55%);
.tokenizing-field {
display: block;
margin: 0;
@ -28,42 +40,83 @@
border:0;
}
.token-editing-input {
max-width: 100%;
font-size: 15px;
line-height: 17px;
padding: 2em;//0.5em @spacing-three-quarters 0.5em @spacing-three-quarters;
padding-right: 1.5em;
margin: 3px 6px 6px 1px;
}
.token {
display: inline-block;
position: relative;
color: @text-color;
padding: 0.4em @spacing-half 0.4em @spacing-half;
padding: 0.5em @spacing-three-quarters 0.5em @spacing-three-quarters;
padding-right: 1.5em;
margin: 2px 5px 3px 0;
border-radius: @border-radius-base;
font-size: 15px;
background-color: @background-secondary;
margin: 3px 6px 6px 1px;
border-radius: @border-radius-base * 0.8;
max-width: 100%;
font-size: 15px;
line-height: 17px;
background: linear-gradient(to bottom, @token-top 0%, @token-bottom 100%);
box-shadow: 0 0.5px 0 rgba(0,0,0,0.17), 0 -0.5px 0 rgba(0,0,0,0.17), 0.5px 0 0 rgba(0,0,0,0.17), -0.5px 0 0 rgba(0,0,0,0.17), 0 1px 1px rgba(0, 0, 0, 0.1);
.action {
position:absolute;
padding: 0;
padding-top: 1px;
right: 7px;
border: 0;
margin: 0;
right: 7px;
background-color: transparent;
color: @text-color-very-subtle;
img { background-color: @text-color-very-subtle; }
font-size: 10px;
}
&:hover {
background-color: darken(@background-secondary, 5%);
background: linear-gradient(to bottom, @token-hover-top 0%, @token-hover-bottom 100%);
box-shadow: 0 0.5px 0 darken(@token-hover-bottom, 35%), 0 -0.5px 0 darken(@token-hover-top, 25%), 0.5px 0 0 darken(@token-hover-bottom, 25%), -0.5px 0 0 darken(@token-hover-bottom, 25%), 0 1px 1px rgba(0, 0, 0, 0.07);
cursor: default;
}
&.invalid {
border-bottom:1px dashed red;
margin-bottom: -1px;
background: transparent;
&:hover {
box-shadow: 0 -0.5px 0 @token-invalid-selected-top, 0.5px 0 0 @token-invalid-selected-bottom, -0.5px 0 0 @token-invalid-selected-bottom, 0 1px 1px rgba(0, 0, 0, 0.07);
}
}
&.invalid.selected, &.invalid.dragging {
background: linear-gradient(to bottom, @token-invalid-selected-top 0%, @token-invalid-selected-bottom 100%);
box-shadow: inset 0 1.5px 0 rgba(255,255,255,0.3), 0 1px 1px rgba(0, 0, 0, 0.1);
border: 1px solid darken(@token-invalid-selected-bottom, 8%);
border-top: 1px solid darken(@token-invalid-selected-top, 10%);
}
&.selected,
&.dragging {
background-color: @background-tertiary;
background: linear-gradient(to bottom, @token-selected-top 0%, @token-selected-bottom 100%);
box-shadow: inset 0 1.5px 0 rgba(255,255,255,0.3), 0 1px 1px rgba(0, 0, 0, 0.1);
border: 1px solid darken(@token-selected-bottom, 8%);
border-top: 1px solid darken(@token-selected-top, 10%);
border-radius: @border-radius-base;
// Note: we switch from 0.5px borders with box shadows to a real border,
// because the 0.5px shadows can't be as dark as we want. This means
// margins / border radius change by 1px.
margin: 2px 5px 5px 0;
color: @text-color-inverse;
.action {
color: @text-color-inverse-subtle;
img { background-color: @text-color-inverse-subtle; }
}
.secondary,
.participant-secondary {
color: @text-color-inverse-subtle;
}
}
&.dragging {
cursor: -webkit-grabbing;
@ -127,6 +180,6 @@
}
}
body.is-blurred .tokenizing-field .token {
background-color: @background-secondary;
body.is-blurred .tokenizing-field .token:not(.invalid) {
background: @background-secondary;
}