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; }