From 4d3e5c4938b3ad8c30d483708281da01cc6ef4b6 Mon Sep 17 00:00:00 2001 From: Ben Gotow Date: Tue, 31 Mar 2015 15:54:16 -0700 Subject: [PATCH] fix(participants): Paste contacts with names, and lots of specs Summary: - You can now paste Ben Imposter (imposter@nilas.com). - You can now paste an email and we look up a matching name in the Contact Store Test Plan: Run glorious new specs Reviewers: evan Reviewed By: evan Differential Revision: https://review.inboxapp.com/D1378 --- .../composer/lib/participants-text-field.cjsx | 38 ++++- .../spec/participants-text-field-spec.cjsx | 140 ++++++++++++++++++ .../tokenizing-text-field-spec.cjsx | 4 +- src/components/tokenizing-text-field.cjsx | 3 +- src/flux/models/utils.coffee | 2 + 5 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 internal_packages/composer/spec/participants-text-field-spec.cjsx diff --git a/internal_packages/composer/lib/participants-text-field.cjsx b/internal_packages/composer/lib/participants-text-field.cjsx index afdd021b1..f6b327ab2 100644 --- a/internal_packages/composer/lib/participants-text-field.cjsx +++ b/internal_packages/composer/lib/participants-text-field.cjsx @@ -2,6 +2,7 @@ React = require 'react' _ = require 'underscore-plus' {Contact, + Utils, ContactStore} = require 'inbox-exports' {TokenizingTextField, Menu} = require 'ui-components' @@ -82,11 +83,44 @@ ParticipantsTextField = React.createClass @props.change(updates) _add: (values) -> + # 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) + detected = [] + while (match = Utils.emailRegex.exec(values)) + email = match[0] + name = null + + hasLeadingParen = values[match.index-1] in ['(','<'] + hasTrailingParen = values[match.index+email.length] in [')','>'] + + if hasLeadingParen and hasTrailingParen + nameStart = 0 + for char in ['>', ')', ',', '\n', '\r'] + i = values.lastIndexOf(char, match.index) + nameStart = i+1 if i+1 > nameStart + name = values.substr(nameStart, match.index - 1 - nameStart).trim() + + if not name or name.length is 0 + # 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 = ContactStore.searchContacts(email, {limit:1})[0] + if existing and existing.name + name = existing.name + else + name = email + + detected.push(new Contact({email, name})) + values = detected + + # 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 if /.+@.+\..+/.test(value) - return new Contact(email: value.trim(), name: value.trim()) else return null diff --git a/internal_packages/composer/spec/participants-text-field-spec.cjsx b/internal_packages/composer/spec/participants-text-field-spec.cjsx new file mode 100644 index 000000000..3fe57c7f6 --- /dev/null +++ b/internal_packages/composer/spec/participants-text-field-spec.cjsx @@ -0,0 +1,140 @@ +_ = require 'underscore-plus' +React = require 'react/addons' +ReactTestUtils = React.addons.TestUtils +proxyquire = require 'proxyquire' + +{InboxTestUtils, + Namespace, + NamespaceStore, + ContactStore, + Contact, + Utils, +} = require 'inbox-exports' + +ParticipantsTextField = proxyquire '../lib/participants-text-field', + 'inbox-exports': {Contact, ContactStore} + +participant1 = new Contact + email: 'ben@nilas.com' +participant2 = new Contact + email: 'ben@example.com' + name: 'ben' +participant3 = new Contact + email: 'ben@inboxapp.com' + name: 'Duplicate email' +participant4 = new Contact + email: 'ben@elsewhere.com', + name: 'ben again' +participant5 = new Contact + email: 'evan@elsewhere.com', + name: 'EVAN' + +describe 'ParticipantsTextField', -> + InboxTestUtils.loadKeymap() + + beforeEach -> + @propChange = jasmine.createSpy('change') + + @fieldName = 'to' + @tabIndex = '100' + @participants = + to: [participant1, participant2] + cc: [participant3] + bcc: [] + + @renderedField = ReactTestUtils.renderIntoDocument( + + ) + @renderedInput = ReactTestUtils.findRenderedDOMComponentWithTag(@renderedField, 'input').getDOMNode() + + @expectInputToYield = (input, expected) -> + ReactTestUtils.Simulate.change(@renderedInput, {target: {value: input}}) + InboxTestUtils.keyPress('enter', @renderedInput) + + reviver = (k,v) -> + return undefined if k in ["id", "object"] + return v + found = @propChange.mostRecentCall.args[0] + found = JSON.parse(JSON.stringify(found), reviver) + expected = JSON.parse(JSON.stringify(expected), reviver) + expect(found).toEqual(expected) + + it 'renders into the document', -> + expect(ReactTestUtils.isCompositeComponentWithType @renderedField, ParticipantsTextField).toBe(true) + + it 'applies the tabIndex provided to the inner input', -> + expect(@renderedInput.tabIndex/1).toBe(@tabIndex/1) + + describe "inserting participant text", -> + it "should fire onChange with an updated participants hash", -> + @expectInputToYield 'abc@abc.com', + to: [participant1, participant2, new Contact(name: 'abc@abc.com', email: 'abc@abc.com')] + cc: [participant3] + bcc: [] + + it "should remove added participants from other fields", -> + @expectInputToYield participant3.email, + to: [participant1, participant2, new Contact(name: participant3.email, email: participant3.email)] + cc: [] + bcc: [] + + it "should use the name of an existing contact in the ContactStore if possible", -> + spyOn(ContactStore, 'searchContacts').andCallFake (val, options) -> + return [participant3] if val is participant3.email + return [] + + @expectInputToYield participant3.email, + to: [participant1, participant2, participant3] + cc: [] + bcc: [] + + it "should not allow the same contact to appear multiple times", -> + spyOn(ContactStore, 'searchContacts').andCallFake (val, options) -> + return [participant2] if val is participant2.email + return [] + + @expectInputToYield participant2.email, + to: [participant1, participant2] + cc: [participant3] + bcc: [] + + describe "when text contains Name (Email) formatted data", -> + it "should correctly parse it into named Contact objects", -> + newContact1 = new Contact(name:'Ben Imposter', email:'imposter@nilas.com') + newContact2 = new Contact(name:'Nilas Team', email:'feedback@nilas.com') + + inputs = [ + "Ben Imposter , Nilas Team ", + "\n\nbla\nBen Imposter (imposter@nilas.com), Nilas Team (feedback@nilas.com)", + "Hello world! I like cheese. \rBen Imposter (imposter@nilas.com)\nNilas Team (feedback@nilas.com)", + "Ben ImposterNilas Team (feedback@nilas.com)" + ] + + for input in inputs + @expectInputToYield input, + to: [participant1, participant2, newContact1, newContact2] + cc: [participant3] + bcc: [] + + describe "when text contains emails mixed with garbage text", -> + it "should still parse out emails into Contact objects", -> + newContact1 = new Contact(name:'garbage-man@nilas.com', email:'garbage-man@nilas.com') + newContact2 = new Contact(name:'recycling-guy@nilas.com', email:'recycling-guy@nilas.com') + + inputs = [ + "Hello world I real. \n asd. garbage-man@nilas.com—he's cool Also 'recycling-guy@nilas.com'!", + "garbage-man@nilas.com|recycling-guy@nilas.com", + "garbage-man@nilas.com1WHOA I REALLY HATE DATA,recycling-guy@nilas.com", + "nils.com garbage-man@nilas.com @nilas.com nope@.com nope!recycling-guy@nilas.com HOLLA AT recycling-guy@nilas." + ] + + for input in inputs + @expectInputToYield input, + to: [participant1, participant2, newContact1, newContact2] + cc: [participant3] + bcc: [] diff --git a/spec-inbox/components/tokenizing-text-field-spec.cjsx b/spec-inbox/components/tokenizing-text-field-spec.cjsx index 3fa05920d..1cfe6ffc9 100644 --- a/spec-inbox/components/tokenizing-text-field-spec.cjsx +++ b/spec-inbox/components/tokenizing-text-field-spec.cjsx @@ -143,7 +143,7 @@ describe 'TokenizingTextField', -> @completions = [] ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}}) InboxTestUtils.keyPress(key, @renderedInput) - expect(@propAdd).toHaveBeenCalledWith(['abc']) + expect(@propAdd).toHaveBeenCalledWith('abc') describe "when the user presses tab", -> describe "and there is an completion available", -> @@ -158,7 +158,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) diff --git a/src/components/tokenizing-text-field.cjsx b/src/components/tokenizing-text-field.cjsx index efc617d6d..b14a446ff 100644 --- a/src/components/tokenizing-text-field.cjsx +++ b/src/components/tokenizing-text-field.cjsx @@ -187,8 +187,7 @@ TokenizingTextField = React.createClass _addInputValue: (input) -> input ?= @state.inputValue - values = input.split(/[, \n\r><]/) - @props.add(values) + @props.add(input) @_clearInput() _selectToken: (token) -> diff --git a/src/flux/models/utils.coffee b/src/flux/models/utils.coffee index de8bf94e3..e3db0446e 100644 --- a/src/flux/models/utils.coffee +++ b/src/flux/models/utils.coffee @@ -204,6 +204,8 @@ 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 + emailHasCommonDomain: (email="") -> domain = _.last(email.toLowerCase().trim().split("@")) return (Utils.commonDomains[domain] ? false)