From 1dc7c03ebd2f5758e21727878911a40c4ef470c6 Mon Sep 17 00:00:00 2001 From: Ben Gotow Date: Mon, 3 Aug 2015 13:06:28 -0700 Subject: [PATCH] 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 --- .../composer/lib/composer-view.cjsx | 12 +- .../composer/lib/participants-text-field.cjsx | 36 +- .../composer/spec/composer-view-spec.cjsx | 22 +- .../spec/participants-text-field-spec.cjsx | 4 +- .../composer/stylesheets/composer.less | 13 - .../lib/Participants.cjsx | 2 +- keymaps/base.cson | 26 -- .../tokenizing-text-field-spec.cjsx | 104 ++++-- spec-nylas/stores/contact-store-spec.coffee | 17 +- src/cjsx.js | 1 + src/components/tokenizing-text-field.cjsx | 350 +++++++++++------- src/dom-utils.coffee | 10 + src/flux/models/contact.coffee | 14 +- src/flux/models/message.coffee | 2 +- src/flux/stores/contact-store.coffee | 19 +- src/flux/stores/draft-store.coffee | 15 +- src/flux/stores/focused-contacts-store.coffee | 4 +- static/components/tokenizing-text-field.less | 75 +++- 18 files changed, 479 insertions(+), 247 deletions(-) diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index 7a585c77f..c7776c962 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -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 diff --git a/internal_packages/composer/lib/participants-text-field.cjsx b/internal_packages/composer/lib/participants-text-field.cjsx index 6c87fc2ad..11ba049f7 100644 --- a/internal_packages/composer/lib/participants-text-field.cjsx +++ b/internal_packages/composer/lib/participants-text-field.cjsx @@ -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 {p.email} + _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) diff --git a/internal_packages/composer/spec/composer-view-spec.cjsx b/internal_packages/composer/spec/composer-view-spec.cjsx index 32f12ba93..5793b7fd6 100644 --- a/internal_packages/composer/spec/composer-view-spec.cjsx +++ b/internal_packages/composer/spec/composer-view-spec.cjsx @@ -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", -> diff --git a/internal_packages/composer/spec/participants-text-field-spec.cjsx b/internal_packages/composer/spec/participants-text-field-spec.cjsx index f8fd8c9fe..a3670a1d2 100644 --- a/internal_packages/composer/spec/participants-text-field-spec.cjsx +++ b/internal_packages/composer/spec/participants-text-field-spec.cjsx @@ -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"] diff --git a/internal_packages/composer/stylesheets/composer.less b/internal_packages/composer/stylesheets/composer.less index c20fe4b2f..d137bf20f 100644 --- a/internal_packages/composer/stylesheets/composer.less +++ b/internal_packages/composer/stylesheets/composer.less @@ -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 diff --git a/internal_packages/inbox-contact-elements/lib/Participants.cjsx b/internal_packages/inbox-contact-elements/lib/Participants.cjsx index 0c54b8e91..7b0a08583 100644 --- a/internal_packages/inbox-contact-elements/lib/Participants.cjsx +++ b/internal_packages/inbox-contact-elements/lib/Participants.cjsx @@ -18,7 +18,7 @@ class Participants extends React.Component render: => chips = @getParticipants().map (p) => - + {chips} diff --git a/keymaps/base.cson b/keymaps/base.cson index 16a4decb3..f2835952c 100644 --- a/keymaps/base.cson +++ b/keymaps/base.cson @@ -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' diff --git a/spec-nylas/components/tokenizing-text-field-spec.cjsx b/spec-nylas/components/tokenizing-text-field-spec.cjsx index adcf84fac..e43778b96 100644 --- a/spec-nylas/components/tokenizing-text-field-spec.cjsx +++ b/spec-nylas/components/tokenizing-text-field-spec.cjsx @@ -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) -> @propOnTokenAction = jasmine.createSpy 'tokenAction' @propCompletionNode = (p) -> @@ -58,26 +58,32 @@ describe 'TokenizingTextField', -> @tabIndex = 100 @tokens = [participant1, participant2, participant3] - @renderedField = ReactTestUtils.renderIntoDocument( - - ) - - @renderedInput = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(@renderedField, 'input')) + @rebuildRenderedField = => + @renderedField = ReactTestUtils.renderIntoDocument( + + ) + @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", -> diff --git a/spec-nylas/stores/contact-store-spec.coffee b/spec-nylas/stores/contact-store-spec.coffee index 7190ecb41..db98f21fe 100644 --- a/spec-nylas/stores/contact-store-spec.coffee +++ b/spec-nylas/stores/contact-store-spec.coffee @@ -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 ": [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) ": [new Contact(name: "spang (Christine Spang)", email: "noreply+phabricator@nilas.com")] + "spang 'Christine Spang' ": [new Contact(name: "spang 'Christine Spang'", email: "noreply+phabricator@nilas.com")] + "spang \"Christine Spang\" ": [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 , Ben ": [ 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 , spang (Christine Spang) ": [ + 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 - diff --git a/src/cjsx.js b/src/cjsx.js index e2449e026..acbe98412 100644 --- a/src/cjsx.js +++ b/src/cjsx.js @@ -33,6 +33,7 @@ var hotCompile = (function () { clearTimeout(timeout); timeout = setTimeout(function () { hotCompile(module, module.filename, true); + console.log('hot reloaded '+module.filename); }, 100); }); } diff --git a/src/components/tokenizing-text-field.cjsx b/src/components/tokenizing-text-field.cjsx index 4552b3db2..ebf5b4e09 100644 --- a/src/components/tokenizing-text-field.cjsx +++ b/src/components/tokenizing-text-field.cjsx @@ -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: => + + + + + + 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: => + @setState(editingValue: event.target.value)}/> + + _renderViewing: => classes = classNames "token": true "dragging": @state.dragging + "invalid": !@props.valid "selected": @props.selected -
- {@props.children}
- _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, " ") - 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 item.id } itemContent={@props.completionNode} headerComponents={[@_fieldComponent()]} + onFocus={@_onInputFocused} + onBlur={@_onInputBlurred} onSelect={@_addToken} /> - _fieldComponent: -> -
+ _fieldComponent: => +
{@_renderPrompt()}
{@_placeholder()} - - {@_fieldTokenComponents()} - + {@_fieldComponents()} {@_inputEl()} -
- _inputEl: -> - if @_atMaxTokens() - @_onInputFocused(noCompletions: true)} - onChange={ -> "noop" } - tabIndex={@props.tabIndex} - value="" /> - else - + _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 = '' + + + + _placeholder: => if not @state.focus and @props.placeholder? return
{@props.placeholder}
else return - _atMaxTokens: -> + _atMaxTokens: => if @props.maxTokens @props.tokens.length >= @props.maxTokens else return false - _renderPrompt: -> + _renderPrompt: => if @props.menuPrompt
{"#{@props.menuPrompt}:"}
else
- _fieldTokenComponents: -> + _fieldComponents: => @props.tokens.map (item) => + key = @props.tokenKey(item) + valid = true + if @props.tokenIsValid + valid = @props.tokenIsValid(item) + + key={key} + valid={valid} + selected={@state.selectedTokenKey is key} + onSelected={@_selectToken} + onEdited={@props.onEdit} + onAction={@props.onTokenAction || @_showDefaultTokenMenu}> {@props.tokenNode(item)} # 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 diff --git a/src/dom-utils.coffee b/src/dom-utils.coffee index a5fd9fdb7..3ad47672b 100644 --- a/src/dom-utils.coffee +++ b/src/dom-utils.coffee @@ -1,6 +1,16 @@ _ = require 'underscore' DOMUtils = + + escapeHTMLCharacters: (text) -> + map = + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + text.replace /[&<>"']/g, (m) -> map[m] + removeElements: (elements=[]) -> for el in elements try diff --git a/src/flux/models/contact.coffee b/src/flux/models/contact.coffee index 69a97fc81..a3fe63bfc 100644 --- a/src/flux/models/contact.coffee +++ b/src/flux/models/contact.coffee @@ -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 ` 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 ", 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 - messageName: -> - if @name then "#{@name} <#{@email}>" else @email - displayFirstName: -> return "You" if @email is NamespaceStore.current()?.emailAddress @firstName() diff --git a/src/flux/models/message.coffee b/src/flux/models/message.coffee index ce32d2666..635921ef1 100644 --- a/src/flux/models/message.coffee +++ b/src/flux/models/message.coffee @@ -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") diff --git a/src/flux/stores/contact-store.coffee b/src/flux/stores/contact-store.coffee index c18045dee..2470107fb 100644 --- a/src/flux/stores/contact-store.coffee +++ b/src/flux/stores/contact-store.coffee @@ -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: => diff --git a/src/flux/stores/draft-store.coffee b/src/flux/stores/draft-store.coffee index a9c15f871..416ddf287 100644 --- a/src/flux/stores/draft-store.coffee +++ b/src/flux/stores/draft-store.coffee @@ -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 = """

- #{msg.replyAttributionLine()} + #{DOMUtils.escapeHTMLCharacters(msg.replyAttributionLine())}
#{@_formatBodyForQuoting(msg.body)}
""" @@ -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 ?= [] diff --git a/src/flux/stores/focused-contacts-store.coffee b/src/flux/stores/focused-contacts-store.coffee index 19b475680..6a34dd510 100644 --- a/src/flux/stores/focused-contacts-store.coffee +++ b/src/flux/stores/focused-contacts-store.coffee @@ -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 diff --git a/static/components/tokenizing-text-field.less b/static/components/tokenizing-text-field.less index cdc5788d6..e02c81c26 100644 --- a/static/components/tokenizing-text-field.less +++ b/static/components/tokenizing-text-field.less @@ -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; }