fix(*): can select participant with space if it's an email

Summary:
Fixes T2272

If the label is too long, the unread icons will now ellipsis truncate the
text correctly instead of being pushed off of the side.

I made the max-width of the sidebar 17px wider to allow for Karim's french
"Inbox" translation.

Fixes T2266

Added some more protection to places where filenames could be blank when
downloading.

This was partially fixed by Rob's D1685

Fixes T2258

You can now finish selecting a participant by pressing `space`, but only
when the participant looks like an email address.

When you do this we directly add the email address to the chip instead of
looking up a contact. This allows you to send just to the email instead of
adding a potentially erroneous name into the input field

Test Plan: edgehill --test

Reviewers: bengotow

Reviewed By: bengotow

Maniphest Tasks: T2272, T2266, T2258

Differential Revision: https://phab.nylas.com/D1729
This commit is contained in:
Evan Morikawa 2015-07-13 10:25:30 -04:00
parent 1e146f6781
commit bda80df05c
11 changed files with 87 additions and 20 deletions

View file

@ -21,9 +21,9 @@ class AccountSidebarTagItem extends React.Component
'selected': @props.select
<div className={coontainerClass} onClick={@_onClick} id={@props.item.id}>
{unread}
<RetinaImg name={"#{@props.item.id}.png"} fallback={'folder.png'} mode={RetinaImg.Mode.ContentIsMask} />
<span className="name"> {@props.item.name}</span>
{unread}
</div>
_onClick: (event) =>

View file

@ -12,7 +12,7 @@ class AccountSidebar extends React.Component
@containerRequired: false
@containerStyles:
minWidth: 165
maxWidth: 190
maxWidth: 207
constructor: (@props) ->
@state = @_getStateFromStores()

View file

@ -77,13 +77,13 @@ class ParticipantsTextField extends React.Component
false
@props.change(updates)
_add: (values) =>
_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)
values = ContactStore.parseContactsInString(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.

View file

@ -14,7 +14,7 @@ class ActivitySidebar extends React.Component
@containerRequired: false
@containerStyles:
minWidth: 165
maxWidth: 190
maxWidth: 207
constructor: (@props) ->
@state = @_getStateFromStores()

View file

@ -154,6 +154,27 @@ describe 'TokenizingTextField', ->
ReactTestUtils.Simulate.mouseDown(React.findDOMNode(menuItem))
expect(@propAdd).toHaveBeenCalledWith([participant4])
it "manually enters whatever's in the field when the user presses the space bar as long as it looks like an email", ->
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc@foo.com '}})
advanceClock(10)
expect(@propAdd).toHaveBeenCalledWith("abc@foo.com", skipNameLookup: true)
it "doesn't sumbmit if it looks like an email but has no space at the end", ->
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc@foo.com'}})
advanceClock(10)
expect(@propCompletionsForInput.calls[0].args[0]).toBe('abc@foo.com')
expect(@propAdd).not.toHaveBeenCalled()
it "allows spaces if what's currently being entered doesn't look like an email", ->
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'ab'}})
advanceClock(10)
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'ab '}})
advanceClock(10)
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'ab c'}})
advanceClock(10)
expect(@propCompletionsForInput.calls[2].args[0]).toBe('ab c')
expect(@propAdd).not.toHaveBeenCalled()
['enter', ','].forEach (key) ->
describe "when the user presses #{key}", ->
describe "and there is an completion available", ->
@ -168,7 +189,7 @@ describe 'TokenizingTextField', ->
@completions = []
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}})
NylasTestUtils.keyPress(key, @renderedInput)
expect(@propAdd).toHaveBeenCalledWith('abc')
expect(@propAdd).toHaveBeenCalledWith('abc', {})
describe "when the user presses tab", ->
describe "and there is an completion available", ->
@ -183,7 +204,7 @@ describe 'TokenizingTextField', ->
ReactTestUtils.Simulate.focus(@renderedInput)
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'text'}})
ReactTestUtils.Simulate.blur(@renderedInput)
expect(@propAdd).toHaveBeenCalledWith('text')
expect(@propAdd).toHaveBeenCalledWith('text', {})
it 'should clear the entered text', ->
ReactTestUtils.Simulate.focus(@renderedInput)

View file

@ -87,3 +87,22 @@ describe "ContactStore", ->
it "can return more than 5 if requested", ->
results = ContactStore.searchContacts("fi", limit: 6)
expect(results.length).toBe 6
describe 'parseContactsInString', ->
testCases =
"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")]
"Evan (evan@nylas.com)": [new Contact(name: "Evan", email: "evan@nylas.com")]
"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")
]
_.forEach testCases, (value, key) ->
it "works for #{key}", ->
testContacts = ContactStore.parseContactsInString(key).map (c) -> c.nameEmail()
expectedContacts = value.map (c) -> c.nameEmail()
expect(testContacts).toEqual expectedContacts

View file

@ -2,7 +2,7 @@ React = require 'react/addons'
classNames = require 'classnames'
_ = require 'underscore'
{CompositeDisposable} = require 'event-kit'
{Contact, ContactStore} = require 'nylas-exports'
{Utils, Contact, ContactStore} = require 'nylas-exports'
RetinaImg = require './retina-img'
{DragDropMixin} = require 'react-dnd'
@ -303,7 +303,13 @@ TokenizingTextField = React.createClass
@setState
selectedTokenKey: null
inputValue: val
@_refreshCompletions(val)
# If it looks like an email, and the last character entered was a
# space, then let's add the input value.
if Utils.emailRegex().test(val) and _.last(val) is " "
@_addInputValue(val[0...-1], skipNameLookup: true)
else
@_refreshCompletions(val)
_onInputBlurred: ->
if @props.clearOnBlur
@ -324,10 +330,10 @@ TokenizingTextField = React.createClass
# Managing Tokens
_addInputValue: (input) ->
_addInputValue: (input, options={}) ->
return if @_atMaxTokens()
input ?= @state.inputValue
@props.onAdd(input)
@props.onAdd(input, options)
@_clearInput()
_selectToken: (token) ->

View file

@ -275,7 +275,14 @@ Utils =
toMatch = domains[0]
return _.every(domains, (domain) -> domain.length > 0 and toMatch is domain)
emailRegex: /[a-z.A-Z0-9%+_-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}/g
# It's important that the regex be wrapped in parens, otherwise
# javascript's RegExp::exec method won't find anything even when the
# regex matches!
#
# It's also imporant we return a fresh copy of the RegExp every time. A
# javascript regex is stateful and multiple functions using this method
# will cause unexpected behavior!
emailRegex: -> new RegExp(/([a-z.A-Z0-9%+_-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4})/g)
emailHasCommonDomain: (email="") ->
domain = _.last(email.toLowerCase().trim().split("@"))

View file

@ -82,23 +82,24 @@ class ContactStore
matches
parseContactsInString: (str) =>
parseContactsInString: (contactString, {skipNameLookup}={}) =>
detected = []
while (match = Utils.emailRegex.exec(str))
emailRegex = new RegExp(Utils.emailRegex())
while (match = emailRegex.exec(contactString))
email = match[0]
name = null
hasLeadingParen = str[match.index-1] in ['(','<']
hasTrailingParen = str[match.index+email.length] in [')','>']
hasLeadingParen = contactString[match.index-1] in ['(','<']
hasTrailingParen = contactString[match.index+email.length] in [')','>']
if hasLeadingParen and hasTrailingParen
nameStart = 0
for char in ['>', ')', ',', '\n', '\r']
i = str.lastIndexOf(char, match.index)
i = contactString.lastIndexOf(char, match.index)
nameStart = i+1 if i+1 > nameStart
name = str.substr(nameStart, match.index - 1 - nameStart).trim()
name = contactString.substr(nameStart, match.index - 1 - nameStart).trim()
if not name or name.length is 0
if (not name or name.length is 0) and not skipNameLookup
# Look to see if we can find a name for this email address in the ContactStore.
# Otherwise, just populate the name with the email address.
existing = @searchContacts(email, {limit:1})[0]

View file

@ -12,10 +12,14 @@ progress = require 'request-progress'
NamespaceStore = require '../stores/namespace-store'
NylasAPI = require '../nylas-api'
UNTITLED = "Untitled"
class Download
constructor: ({@fileId, @targetPath, @filename, @filesize, @progressCallback}) ->
@percent = 0
@promise = null
if (@filename ? "").trim().length is 0
@filename = UNTITLED
@
state: ->
@ -222,4 +226,10 @@ FileDownloadStore = Reflux.createStore
if not fs.existsSync(downloadDir)
downloadDir = os.tmpdir()
path.join(downloadDir, file.filename)
path.join(downloadDir, @_filename(file.filename))
# Sometimes files can have no name.
_filename: (filename="") ->
if filename.trim().length is 0
return UNTITLED
else return filename

View file

@ -5,6 +5,9 @@
// A box to hold counts of things (like number of items in a tag, or
// number of messages in a thread)
.item-count-box {
float: right;
min-width: 26px;
text-align: center;
display: inline;
font-size: @font-size-smaller;
padding: @padding-xs-vertical @padding-xs-horizontal @padding-xs-vertical @padding-xs-horizontal;