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
-
-
- _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