refactor(participants): Use DragDropMixin, Menu

Summary:
This diff replaces the participant text fields with ones based on TokenizingTextField, a new core
component that handles autocompletion, drag and drop of tokens, etc.

Fix large queries overloading SQLite size limits

New general purpose tokenized text field with token selection, copy paste, etc

Move pre-send logic to the view since DraftStore requests from db, which may not have settled

Tests - still a WIP

Support for contextual menus instead of X

Test Plan: Run new tests. Needs many more, but have higher priority things to fix

Reviewers: evan

Reviewed By: evan

Differential Revision: https://review.inboxapp.com/D1142
This commit is contained in:
Ben Gotow 2015-02-06 14:46:30 -08:00
parent a31c2808a9
commit e4889b390f
22 changed files with 735 additions and 813 deletions

View file

@ -4,3 +4,4 @@ module.exports =
# Models
Menu: require '../src/components/menu'
Popover: require '../src/components/popover'
TokenizingTextField: require '../src/components/tokenizing-text-field'

View file

@ -1,32 +0,0 @@
# We need the "input" specifier on the css class to have the specificity
# required to prevent the default `native!` behavior from happening.
# The default state. There's no input in the text field and there are no
# autocomplete suggestions
'.autocomplete input':
'backspace': 'native!'
'tab': 'native!'
',': 'native!'
# When the text in the input field looks like an emalil
'.autocomplete.autocomplete-looks-like-raw-email input':
'space': 'participants:add-raw-email'
# When there is some text in the input field, but we have no suggestion
'.autocomplete.autocomplete-no-suggestions input':
',': 'participants:add-raw-email'
'tab': 'participants:add-raw-email'
'enter': 'participants:add-raw-email'
'escape': 'participants:cancel'
# When we found a name to suggest
'.autocomplete.autocomplete-with-suggestion input':
',': 'participants:add-suggestion'
'tab': 'participants:add-suggestion'
'enter': 'participants:add-suggestion'
'up': 'participants:move-up'
'down': 'participants:move-down'
'escape': 'participants:cancel'
'.autocomplete.autocomplete-empty input':
'backspace': 'participants:remove'

View file

@ -1,10 +0,0 @@
React = require 'react'
_ = require 'underscore-plus'
module.exports = ComposerParticipant = React.createClass
render: ->
<span className={!_.isEmpty(@props.participant.name) and "hasName" or ""}>
<span className="name">{@props.participant.name}</span>
<span className="email">{@props.participant.email}</span>
<button className="remove" onClick={=> @props.onRemove(@props.participant)} ><i className="fa fa-remove"></i></button>
</span>

View file

@ -1,236 +0,0 @@
React = require 'react/addons'
_ = require 'underscore-plus'
{CompositeDisposable} = require 'event-kit'
{Contact, ContactStore} = require 'inbox-exports'
ComposerParticipant = require './composer-participant.cjsx'
module.exports =
ComposerParticipants = React.createClass
getInitialState: ->
completions: []
selectedIndex: 0
currentEmail: ""
componentDidMount: ->
input = @refs.autocomplete.getDOMNode()
check = (fn) -> (event) ->
# Wrapper to guard against events triggering on the wrong element
fn(event) if event.target == input
@subscriptions = new CompositeDisposable()
@subscriptions.add atom.commands.add '.autocomplete',
'participants:move-up': (event) =>
@_onShiftSelectedIndex(-1)
event.preventDefault()
'participants:move-down': (event) =>
@_onShiftSelectedIndex(1)
event.preventDefault()
@subscriptions.add atom.commands.add '.autocomplete-with-suggestion',
'participants:add-suggestion': check @_onAddSuggestion
@subscriptions.add atom.commands.add '.autocomplete-no-suggestions',
'participants:add-raw-email': check @_onAddRawEmail
@subscriptions.add atom.commands.add '.autocomplete-empty',
'participants:remove': check @_onRemoveParticipant
@subscriptions.add atom.commands.add '.autocomplete',
'participants:cancel': check @_onParticipantsCancel
componentWillUnmount: ->
@subscriptions?.dispose()
componentDidUpdate: ->
input = @refs.autocomplete.getDOMNode()
# Absolutely place the completions field under the input
comp = @refs.completions.getDOMNode()
comp.style.top = input.offsetHeight + input.offsetTop + 6 + "px"
# Measure the width of the text in the input
measure = @refs.measure.getDOMNode()
measure.innerText = @_getInputValue()
measure.style.color = 'red'
measure.style.top = input.offsetTop + "px"
measure.style.left = input.offsetLeft + "px"
width = measure.offsetWidth
input.style.width = "calc(4px + #{width}px)"
render: ->
<span className={@_containerClasses()}
onClick={@_focusOnInput}>
<div className="participants-label">{"#{@props.placeholder}:"}</div>
<ul className="participants">
{@_currentParticipants()}
<span className={@state.focus and "hasFocus" or ""}>
<input name="add"
type="text"
ref="autocomplete"
onBlur={@_onBlur}
onFocus={@_onFocus}
onChange={@_onChange}
disabled={@props.disabled}
tabIndex={@props.tabIndex}
value={@state.currentEmail} />
<span ref="measure" style={
position: 'absolute'
visibility: 'hidden'
}/>
</span>
</ul>
<ul className="completions" ref='completions' style={@_completionsDisplay()}>
{@state.completions.map (p, i) =>
# Add a `seen` class if this participant is already in this field.
# We use CSS to grey it out.
# Add a `selected` class for the current selection.
# We use this instead of :hover so we can update selection with
# either mouse or keyboard.
classes = (_.compact [
p.email in _.pluck(@props.participants, 'email') and "seen",
(i+1) == @state.selectedIndex and 'selected'
]).join " "
<li
onMouseOver={=> @setState {selectedIndex: i+1}}
onMouseOut={=> @setState {selectedIndex: 0}}
onMouseDown={=> @_onMouseDown(p)}
onMouseUp={=> @_onMouseUp(p)}
key={"li-#{p.id}"}
className={classes}
><ComposerParticipant key={p.id} participant={p}/></li>}
</ul>
</span>
_currentParticipants: ->
@props.participants?.map (participant) =>
<li key={"participant-li-#{participant.id}"}
className={@_participantHoverClass(participant)}>
<ComposerParticipant key={"participant-#{participant.id}"}
participant={participant}
onRemove={@props.participantFunctions.remove}/>
</li>
_participantHoverClass: (participant) ->
React.addons.classSet
"hover": @_selected()?.email is participant.email
_containerClasses: ->
React.addons.classSet
"autocomplete": true
"increase-css-specificity": true
"autocomplete-empty": @state.currentEmail.trim().length is 0
"autocomplete-no-suggestions": @_noSuggestions()
"autocomplete-with-suggestion": @state.completions.length > 0
"autocomplete-looks-like-raw-email": @_looksLikeRawEmail()
_noSuggestions: ->
@state.completions.length is 0 and @state.currentEmail.trim().length > 0
_onBlur: ->
if @_cancelBlur then return
@_onAddRawEmail() if @_looksLikeRawEmail()
@setState
focus: false
selectedIndex: 0
_onParticipantsCancel: ->
@setState focus: false
@_clearSuggestions()
@refs.autocomplete.getDOMNode().blur()
_onFocus: ->
@_reloadSuggestions()
@setState focus: true
_onMouseDown: ->
@_cancelBlur = true
_onMouseUp: (participant) ->
@_cancelBlur = false
if participant?
@_addParticipant(participant)
# since the controlled input hasn't re-rendered yet, but we're
# going to fire a focus
@refs.autocomplete.getDOMNode().value = ""
@_focusOnInput()
_completionsDisplay: ->
if @state.completions.length > 0 and @state.focus
display: "initial"
else
display: "none"
_focusOnInput: ->
@refs.autocomplete.getDOMNode().focus()
_selected: ->
if @state.selectedIndex > 0 and @state.selectedIndex <= @state.completions.length
@state.completions[@state.selectedIndex - 1]
else
undefined
_onChange: (event) ->
@_reloadSuggestions()
_looksLikeRawEmail: ->
emailIsh = /.+@.+\..+/.test(@state.currentEmail.trim())
@state.completions.length is 0 and emailIsh
_onShiftSelectedIndex: (count) ->
newIndex = @state.selectedIndex + count
mod = @state.completions.length + 1
if (newIndex < 1)
newIndex = mod - (1 - (newIndex % mod))
else
if newIndex % mod is 0
newIndex = 1
else
newIndex = newIndex % mod
@setState
selectedIndex: newIndex
_onAddSuggestion: ->
participant = @_selected()
@_addParticipant(participant) if participant
_onAddRawEmail: ->
participants = (ContactStore.searchContacts(@_getInputValue()) ? [])
if participants[0]
@_addParticipant(participants[0])
else
newParticipant = new Contact(email: @_getInputValue())
@_addParticipant(newParticipant)
_addParticipant: (participant) ->
return if participant.email in _.pluck(@props.participants, 'email')
@props.participantFunctions.add participant
@_clearSuggestions()
_onRemoveParticipant: ->
if @props.participants.length > 0
@_removeParticipant _.last(@props.participants)
_removeParticipant: (participant) ->
@props.participantFunctions.remove participant
_clearSuggestions: ->
@setState
completions: []
selectedIndex: 0
currentEmail: ""
_reloadSuggestions: ->
val = @_getInputValue()
if val.length is 0 then completions = []
else completions = ContactStore.searchContacts val
@setState
completions: completions
currentEmail: val
selectedIndex: 1
_getInputValue: ->
(@refs.autocomplete.getDOMNode().value ? "").trimLeft()

View file

@ -1,9 +1,7 @@
React = require 'react'
_ = require 'underscore-plus'
React = require 'react'
{Actions,
ContactStore,
FileUploadStore,
ComponentRegistry} = require 'inbox-exports'
@ -11,7 +9,7 @@ FileUploads = require './file-uploads.cjsx'
DraftStoreProxy = require './draft-store-proxy'
ContenteditableToolbar = require './contenteditable-toolbar.cjsx'
ContenteditableComponent = require './contenteditable-component.cjsx'
ComposerParticipants = require './composer-participants.cjsx'
ParticipantsTextField = require './participants-text-field.cjsx'
# The ComposerView is a unique React component because it (currently) is a
@ -89,10 +87,9 @@ ComposerView = React.createClass
return <div></div> if @state.body == undefined
<div className="composer-inner-wrap">
<div className="composer-header">
<div className="composer-title">
{@_composerTitle()}
Compose Message
</div>
<div className="composer-header-actions">
<span
@ -113,33 +110,25 @@ ComposerView = React.createClass
</div>
</div>
<div className="compose-participants-wrap">
<ComposerParticipants name="to"
tabIndex="101"
participants={@state.to}
participantFunctions={@_participantFunctions('to')}
placeholder="To" />
</div>
<ParticipantsTextField
field='to'
change={@_proxy.changes.add}
participants={to: @state['to'], cc: @state['cc'], bcc: @state['bcc']}
tabIndex='102'/>
<div className="compose-participants-wrap"
style={display: @state.showcc and 'initial' or 'none'}>
<ComposerParticipants name="cc"
tabIndex="102"
disabled={not @state.showcc}
participants={@state.cc}
participantFunctions={@_participantFunctions('cc')}
placeholder="Cc" />
</div>
<ParticipantsTextField
field='cc'
visible={@state.showcc}
change={@_proxy.changes.add}
participants={to: @state['to'], cc: @state['cc'], bcc: @state['bcc']}
tabIndex='103'/>
<div className="compose-participants-wrap"
style={display: @state.showcc and 'initial' or 'none'}>
<ComposerParticipants name="bcc"
tabIndex="103"
disabled={not @state.showcc}
participants={@state.bcc}
participantFunctions={@_participantFunctions('bcc')}
placeholder="Bcc" />
</div>
<ParticipantsTextField
field='bcc'
visible={@state.showcc}
change={@_proxy.changes.add}
participants={to: @state['to'], cc: @state['cc'], bcc: @state['bcc']}
tabIndex='104'/>
<div className="compose-subject-wrap"
style={display: @state.showsubject and 'initial' or 'none'}>
@ -180,14 +169,17 @@ ComposerView = React.createClass
</div>
</div>
# TODO, in the future this will be smarter and say useful things like
# "Reply" or "Reply All" or "Reply + New Person1, New Person2"
_composerTitle: -> "Compose Message"
_footerComponents: ->
(@state.FooterComponents ? []).map (Component) =>
<Component draftLocalId={@props.localId} />
_fileComponents: ->
MessageAttachment = @state.MessageAttachment
(@state.files ? []).map (file) =>
<MessageAttachment file={file}
removable={true}
messageLocalId={@props.localId} />
_onDraftChanged: ->
draft = @_proxy.draft()
state =
@ -206,9 +198,6 @@ ComposerView = React.createClass
@setState(state)
_popoutComposer: ->
Actions.composePopoutDraft @props.localId
_onComposeBodyClick: ->
@refs.scribe.focus()
@ -218,19 +207,40 @@ ComposerView = React.createClass
_onChangeBody: (event) ->
@_proxy.changes.add(body: event.target.value)
_participantFunctions: (field) ->
remove: (participant) =>
updates = {}
updates[field] = _.without(@state[field], participant)
@_proxy.changes.add(updates)
_popoutComposer: ->
Actions.composePopoutDraft @props.localId
add: (participant) =>
updates = {}
updates[field] = _.union (@state[field] ? []), [participant]
@_proxy.changes.add(updates)
""
_sendDraft: (options = {}) ->
draft = @_proxy.draft()
remote = require('remote')
dialog = remote.require('dialog')
if [].concat(draft.to, draft.cc, draft.bcc).length is 0
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.'
})
return
warnings = []
if draft.subject.length is 0
warnings.push('without a subject line')
if draft.body.toLowerCase().indexOf('attachment') != -1 and draft.files?.length is 0
warnings.push('without an attachment')
if warnings.length > 0 and not options.force
dialog.showMessageBox remote.getCurrentWindow(), {
type: 'warning',
buttons: ['Cancel', 'Send Anyway'],
message: 'Are you sure?',
detail: "Send #{warnings.join(' and ')}?"
}, (response) =>
if response is 1 # button array index 1
@_sendDraft({force: true})
return
_sendDraft: ->
@_proxy.changes.commit()
Actions.sendDraft(@props.localId)
@ -239,10 +249,3 @@ ComposerView = React.createClass
_attachFile: ->
Actions.attachFile({messageLocalId: @props.localId})
_fileComponents: ->
MessageAttachment = @state.MessageAttachment
(@state.files ? []).map (file) =>
<MessageAttachment file={file}
removable={true}
messageLocalId={@props.localId} />

View file

@ -5,11 +5,11 @@ ContenteditableToolbar = React.createClass
render: ->
style =
display: @state.show and 'initial' or 'none'
<div className="scribe-toolbar-wrap" onBlur={@onBlur}>
<div className="compose-toolbar-wrap" onBlur={@onBlur}>
<button className="btn btn-icon btn-formatting"
onClick={=> @setState { show: !@state.show }}
><i className="fa fa-font"></i></button>
<div ref="toolbar" className="scribe-toolbar" style={style}>
<div ref="toolbar" className="compose-toolbar" style={style}>
<button className="btn btn-bold" onClick={@onClick} data-command-name="bold"><strong>B</strong></button>
<button className="btn btn-italic" onClick={@onClick} data-command-name="italic"><em>I</em></button>
<button className="btn btn-underline" onClick={@onClick} data-command-name="underline"><span style={'textDecoration': 'underline'}>U</span></button>

View file

@ -18,14 +18,14 @@ class DraftChangeSet
@_pending = {}
@_timer = null
add: (changes, immediate) ->
add: (changes, immediate) =>
@_pending = _.extend(@_pending, changes)
@_onChange()
if immediate
@commit()
else
clearTimeout(@_timer) if @_timer
@_timer = setTimeout(@commit, 750)
@_timer = setTimeout(@commit, 5000)
commit: =>
@_pending.localId = @localId
@ -33,7 +33,7 @@ class DraftChangeSet
Actions.saveDraft(@_pending)
@_pending = {}
applyToModel: (model) ->
applyToModel: (model) =>
model.fromJSON(@_pending) if model
model

View file

@ -0,0 +1,107 @@
React = require 'react'
_ = require 'underscore-plus'
{Contact,
ContactStore} = require 'inbox-exports'
{TokenizingTextField} = require 'ui-components'
module.exports =
ParticipantsTextField = React.createClass
displayName: 'ParticipantsTextField'
propTypes:
# The tab index of the ParticipantsTextField
tabIndex: React.PropTypes.string,
# The name of the field, used for both display purposes and also
# to modify the `participants` provided.
field: React.PropTypes.string,
# Whether or not the field should be visible. Defaults to true.
visible: React.PropTypes.bool
# An object containing arrays of participants. Typically, this is
# {to: [], cc: [], bcc: []}. Each ParticipantsTextField needs all of
# the values, because adding an element to one field may remove it
# from another.
participants: React.PropTypes.object.isRequired,
# The function to call with an updated `participants` object when
# changes are made.
change: React.PropTypes.func.isRequired,
getDefaultProps: ->
visible: true
render: ->
<div className="compose-participants-wrap" style={display: @props.visible and 'inline' or 'none'}>
<TokenizingTextField
prompt={@props.field}
tabIndex={@props.tabIndex}
tokens={@props.participants[@props.field]}
tokenKey={ (p) -> p.email }
tokenContent={@_componentForParticipant}
completionsForInput={ (input) -> ContactStore.searchContacts(input) }
completionContent={ (p) -> "#{p.name} (#{p.email})" }
add={@_add}
remove={@_remove}
showMenu={@_showContextMenu} />
</div>
_componentForParticipant: (p) ->
if p.name?.length > 0
content = p.name
else
content = p.email
<div className="participant">
<span>{content}</span>
</div>
_remove: (participant) ->
field = @props.field
updates = {}
updates[field] = _.reject @props.participants[field], (p) ->
p.email is participant.email
@props.change(updates)
_add: (value) ->
if _.isString(value)
value = value.trim()
return unless /.+@.+\..+/.test(value)
value = new Contact(email: value, name: value)
updates = {}
# first remove the participant from all the fields. This ensures
# that drag and drop isn't "drag and copy." and you can't have the
# same recipient in multiple places.
for otherField in Object.keys(@props.participants)
updates[otherField] = _.reject @props.participants[otherField], (p) ->
p.email is value.email
# add the participant to field
field = @props.field
updates[field] = _.union (updates[field] ? []), [value]
@props.change(updates)
""
_showContextMenu: (participant) ->
remote = require('remote')
Menu = remote.require('menu')
MenuItem = remote.require('menu-item')
menu = new Menu()
menu.append(new MenuItem(
label: participant.email
click: -> require('clipboard').writeText(participant.email)
))
menu.append(new MenuItem(
type: 'separator'
))
menu.append(new MenuItem(
label: 'Remove',
click: => @_remove(participant)
))
menu.popup(remote.getCurrentWindow())

View file

@ -1,320 +0,0 @@
_ = require 'underscore-plus'
CSON = require 'season'
React = require 'react/addons'
ReactTestUtils = React.addons.TestUtils
ComposerParticipants = require '../lib/composer-participants.cjsx'
ComposerParticipant = require '../lib/composer-participant.cjsx'
{InboxTestUtils,
Namespace,
NamespaceStore,
Contact,
ContactStore,
} = require 'inbox-exports'
me = new Namespace
name: 'Test User'
email: 'test@example.com'
provider: 'inbox'
NamespaceStore._current = me
participant1 = new Contact
email: 'zip@inboxapp.com'
participant2 = new Contact
email: 'zip@example.com'
name: 'zip'
participant3 = new Contact
email: 'zip@inboxapp.com'
name: 'Duplicate email'
participant4 = new Contact
email: 'zip@elsewhere.com',
name: 'zip again'
default_participants = [participant1, participant2]
all_participants = [participant1, participant2, participant3, participant4]
describe 'Autocomplete', ->
keymap_path = 'internal_packages/composer/keymaps/composer.cson'
keymap_file = CSON.readFileSync keymap_path
# We have to add these manually for testing
beforeEach ->
@onAdd = jasmine.createSpy 'add'
@onRemove = jasmine.createSpy 'remove'
@participants = ReactTestUtils.renderIntoDocument(
<ComposerParticipants
participants={default_participants}
participantFunctions={{
add: @onAdd
remove: @onRemove
}}
/>
)
@renderedParticipants = ReactTestUtils.scryRenderedComponentsWithType @participants, ComposerParticipant
it 'renders into the document', ->
expect(ReactTestUtils.isCompositeComponentWithType @participants, ComposerParticipants).toBe true
it 'shows the participants by default', ->
expect(@renderedParticipants.length).toBe(2)
it 'should render the participants specified', ->
expect(@renderedParticipants[0].props.participant).toEqual participant1
expect(@renderedParticipants[1].props.participant).toEqual participant2
it 'fires onRemove with a participant when the "x" is clicked', ->
button = @renderedParticipants[0].getDOMNode().querySelector('i')
expect(button).toBeDefined()
ReactTestUtils.Simulate.click(button)
expect(@onRemove).toHaveBeenCalledWith(participant1)
it 'should have one element with a "hasName" class', ->
hasname = ReactTestUtils.scryRenderedDOMComponentsWithClass(@participants, 'hasName')
expect(hasname.length).toBe(1)
describe 'the input', ->
beforeEach ->
atom.keymaps.add keymap_path, keymap_file
@input = @participants.refs.autocomplete.getDOMNode()
it 'should remove the last participant when "backspace" is pressed', ->
@input.focus()
ReactTestUtils.Simulate.focus(@input)
InboxTestUtils.keyPress 'backspace', @input
expect(@onRemove).toHaveBeenCalledWith(participant2)
it 'should not call @onRemove with no participants', ->
onRemove = jasmine.createSpy 'remove'
participants = ReactTestUtils.renderIntoDocument(
<ComposerParticipants
participants={[]}
onRemove={onRemove}
/>
)
input = participants.refs.autocomplete.getDOMNode()
InboxTestUtils.keyPress 'backspace', input
expect(onRemove).not.toHaveBeenCalled()
it 'should not bring up an autocomplete box for no input', ->
spyOn(ContactStore, 'searchContacts')
ReactTestUtils.Simulate.focus(@input)
expect(ContactStore.searchContacts).not.toHaveBeenCalled()
completions = ReactTestUtils.findRenderedDOMComponentWithClass(@participants, 'completions')
expect(completions.getDOMNode().style.display).toBe 'none'
it 'should do nothing on "tab"', ->
spyOn(@participants, "_addParticipant").andCallThrough()
InboxTestUtils.keyPress('tab', @input)
expect(@participants._addParticipant).not.toHaveBeenCalled()
it 'should do nothing on "blur"', ->
spyOn(@participants, "_addParticipant").andCallThrough()
@input.focus()
ReactTestUtils.Simulate.focus(@input)
@input.blur()
ReactTestUtils.Simulate.blur(@input)
expect(@participants._addParticipant).not.toHaveBeenCalled()
it 'should remove the last participant when "backspace" is pressed', ->
@input.focus()
ReactTestUtils.Simulate.focus(@input)
InboxTestUtils.keyPress 'backspace', @input
expect(@onRemove).toHaveBeenCalledWith(participant2)
it 'should do nothing when escape is pushed', ->
spyOn(@participants, "_addParticipant").andCallThrough()
@input.focus()
ReactTestUtils.Simulate.focus(@input)
InboxTestUtils.keyPress('escape', @input)
expect(@participants._addParticipant).not.toHaveBeenCalled()
describe 'when typing an email with no suggestions', ->
beforeEach ->
spyOn(@participants, "_addParticipant").andCallThrough()
@input.focus()
ReactTestUtils.Simulate.focus(@input)
@input.value = participant4.email
ReactTestUtils.Simulate.change(@input)
it 'has the right class', ->
nodes = ReactTestUtils.scryRenderedDOMComponentsWithClass(@participants, "autocomplete-no-suggestions")
expect(nodes.length).toBe 1
it 'should complete on "tab"', ->
InboxTestUtils.keyPress('tab', @input)
addedEmail = @participants._addParticipant.calls[0].args[0].email
expect(addedEmail).toEqual participant4.email
expect(@participants.state.currentEmail).toBe ''
it 'should complete on "enter"', ->
InboxTestUtils.keyPress('enter', @input)
addedEmail = @participants._addParticipant.calls[0].args[0].email
expect(addedEmail).toEqual participant4.email
expect(@participants.state.currentEmail).toBe ''
it 'should complete on "comma"', ->
InboxTestUtils.keyPress(',', @input)
addedEmail = @participants._addParticipant.calls[0].args[0].email
expect(addedEmail).toEqual participant4.email
expect(@participants.state.currentEmail).toBe ''
it 'should complete on "space"', ->
InboxTestUtils.keyPress('space', @input)
addedEmail = @participants._addParticipant.calls[0].args[0].email
expect(addedEmail).toEqual participant4.email
expect(@participants.state.currentEmail).toBe ''
it 'should complete on "blur"', ->
@input.blur()
ReactTestUtils.Simulate.blur(@input)
addedEmail = @participants._addParticipant.calls[0].args[0].email
expect(addedEmail).toEqual participant4.email
expect(@participants.state.currentEmail).toBe ''
it 'should clear the suggestion without adding when escape is pushed', ->
InboxTestUtils.keyPress('escape', @input)
expect(@participants._addParticipant).not.toHaveBeenCalled()
expect(@participants.state.currentEmail).toBe ''
describe 'when typing a name with no suggestions', ->
beforeEach ->
spyOn(@participants, "_addParticipant").andCallThrough()
@input.focus()
ReactTestUtils.Simulate.focus(@input)
@input.value = "Foobar"
ReactTestUtils.Simulate.change(@input)
it 'should NOT complete on "space"', ->
InboxTestUtils.keyPress('space', @input)
expect(@participants._addParticipant).not.toHaveBeenCalled()
it 'should do nothing on "blur"', ->
@input.focus()
ReactTestUtils.Simulate.focus(@input)
@input.blur()
ReactTestUtils.Simulate.blur(@input)
expect(@participants._addParticipant).not.toHaveBeenCalled()
describe 'in autocomplete mode', ->
beforeEach ->
spyOn(ContactStore, 'searchContacts').andReturn(all_participants)
spyOn(@participants, "_addParticipant").andCallThrough()
@input.focus()
ReactTestUtils.Simulate.focus(@input)
@input.value = 'z'
ReactTestUtils.Simulate.change(@input)
@completions = ReactTestUtils.findRenderedDOMComponentWithClass(@participants, 'completions')
it 'should clear the suggestion without adding when escape is pushed', ->
InboxTestUtils.keyPress('escape', @input)
expect(@participants._addParticipant).not.toHaveBeenCalled()
expect(@participants.state.currentEmail).toBe ''
it 'should query the contact store for input', ->
expect(ContactStore.searchContacts).toHaveBeenCalledWith('z')
it 'should show the completions field', ->
expect(@completions.getDOMNode().style.display).toBe 'initial'
expect(ReactTestUtils.scryRenderedComponentsWithType(@completions, ComposerParticipant).length).toBe all_participants.length
it 'should hide the completions field on blur', ->
@input.blur()
ReactTestUtils.Simulate.blur(@input)
expect(@completions.getDOMNode().style.display).toBe 'none'
expect(ReactTestUtils.scryRenderedComponentsWithType(@completions, ComposerParticipant).length).toBe all_participants.length
expect(@participants.state.selectedIndex).toBe 0
it 'should not fire when clicking an existing email in its field', ->
ReactTestUtils.Simulate.mouseUp(@completions.getDOMNode().querySelectorAll('li')[0])
expect(@onAdd).not.toHaveBeenCalled()
it 'should fire for a new email address which has been clicked', ->
ReactTestUtils.Simulate.mouseUp(@completions.getDOMNode().querySelectorAll('li')[3])
expect(@onAdd).toHaveBeenCalledWith(participant4)
it 'should start with an index of 0', ->
expect(@participants.state.selectedIndex).toEqual 1
it 'should increment the index when "down" is pressed', ->
InboxTestUtils.keyPress 'down', @input
expect(@participants.state.selectedIndex).toEqual 2
it 'should decrement the index and wrap when "up" is pressed', ->
InboxTestUtils.keyPress 'up', @input
expect(@participants.state.selectedIndex).toEqual all_participants.length
it 'should wrap when the end is reached', ->
InboxTestUtils.keyPress 'down', @input
InboxTestUtils.keyPress 'down', @input
InboxTestUtils.keyPress 'down', @input
InboxTestUtils.keyPress 'down', @input
expect(@participants.state.selectedIndex).toEqual 1
it 'should be able to select the last one', ->
InboxTestUtils.keyPress 'down', @input
InboxTestUtils.keyPress 'down', @input
InboxTestUtils.keyPress 'down', @input
expect(@participants.state.selectedIndex).toEqual 4
it 'should select an item underneath the selectedIndex with "enter"', ->
InboxTestUtils.keyPress 'up', @input
InboxTestUtils.keyPress 'enter', @input
expect(@onAdd).toHaveBeenCalledWith participant4
it 'should select an item underneath the selectedIndex with "comma"', ->
InboxTestUtils.keyPress 'up', @input
InboxTestUtils.keyPress ',', @input
expect(@onAdd).toHaveBeenCalledWith participant4
it 'should select an item underneath the selectedIndex with "tab"', ->
InboxTestUtils.keyPress 'up', @input
InboxTestUtils.keyPress 'tab', @input
expect(@onAdd).toHaveBeenCalledWith participant4
it 'should select an index using the mouse', ->
ReactTestUtils.Simulate.mouseOver(@completions.getDOMNode().querySelectorAll('li')[3])
expect(@participants.state.selectedIndex).toEqual 4
it 'should add a "seen" class to seen participants', ->
InboxTestUtils.keyPress 'down', @input
hovered = ReactTestUtils.scryRenderedDOMComponentsWithClass(@participants, "hover")
expect(hovered.length).toEqual 1
participant = ReactTestUtils.scryRenderedComponentsWithType(hovered[0], ComposerParticipant)
expect(participant?[0].props?.participant).toEqual participant2
it 'should work if two are in the same document', ->
onAdd = jasmine.createSpy 'add'
nevercalled = jasmine.createSpy 'nevercalled'
participants = ReactTestUtils.renderIntoDocument(
<div>
<ComposerParticipants
participants={default_participants}
participantFunctions={{
add: onAdd
remove: nevercalled
search: nevercalled
}}
/>
<ComposerParticipants
participants={default_participants}
participantFunctions={{
add: nevercalled
remove: nevercalled
search: nevercalled
}}
/>
</div>
)
first = ReactTestUtils.scryRenderedComponentsWithType(participants, ComposerParticipants)[0]
spyOn(ContactStore, 'searchContacts').andReturn(all_participants)
atom.keymaps.add keymap_path, keymap_file
input = first.refs.autocomplete.getDOMNode()
input.focus()
ReactTestUtils.Simulate.focus(input)
input.value = 'z'
ReactTestUtils.Simulate.change(input)
InboxTestUtils.keyPress 'up', input
InboxTestUtils.keyPress 'enter', input
expect(onAdd).toHaveBeenCalledWith participant4
expect(nevercalled).not.toHaveBeenCalled()

View file

@ -44,7 +44,7 @@
}
}
input, textarea, .scribe {
input, textarea, div[contenteditable] {
display: block;
background: inherit;
width: 100%;
@ -52,15 +52,7 @@
border: none;
}
textarea, .scribe {
min-height: 10em;
}
.scribe {
flex: 1;
}
.scribe-toolbar-wrap {
.compose-toolbar-wrap {
display: inline-block;
> button {
@ -68,7 +60,7 @@
}
}
.scribe-toolbar {
.compose-toolbar {
display: none;
box-shadow: @standard-shadow;
padding: 1px 1px 2px 1px;
@ -134,17 +126,6 @@
}
}
.autocomplete input {
display: inline-block;
width: initial;
padding: 2px 0;
margin: 0 5px 5px 0;
border: none;
min-width: 5em;
background-color:transparent;
}
// TODO FIXME DRY From stylesheets/message-list.less
.attachments-area {
padding: 0px 15px 0px 15px;
@ -206,6 +187,7 @@
content:'\2022\2022\2022';
}
}
#new-compose-button {
order: 1;
@ -217,100 +199,3 @@
.btn-variant(@action-color);
}
}
.participants-label {
color: @text-color-subtle;
float: left;
padding-top: 2px;
}
ul.participants {
display: block;
padding: 0 0 0 32px;
margin: 0;
list-style-type: none;
border-bottom: 1px solid @border-color-divider;
min-height:30px;
> li {
display: inline-block;
padding: 2px 6px;
margin: 0 5px 5px 0;
background-color: @background-color-secondary;
.hasName .email {
display: none;
}
:not(.hasName) .name {
display: none;
}
button.remove {
font-size: @minor-font-size;
background: transparent;
&:hover { color: @text-color-destructive; }
padding: 0;
margin: 0;
margin-left: 7px;
color: fadeout(@black, 75%);
border: none;
}
&.hover {
background-color: @background-color-selected;
color: @text-color-inverse;
}
&.hasFocus {
}
}
}
ul.completions {
background: white;
box-shadow: @standard-shadow;
position: absolute;
list-style-type: none;
padding: 0;
margin: 0;
left: 0;
width: 100%;
> li {
padding: 4px 15px 3px 15px;
cursor: pointer;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
&+li {
border-top: 1px solid @border-color-subtle;
}
.hasName span.email {
margin-left: 0.5em;
opacity: 0.7;
&::before {
content: '<';
}
&::after {
content: '>';
}
}
&.seen {
color: #666;
cursor: default;
}
&.selected {
background-color: @background-color-selected;
color: @text-color-inverse;
}
}
button.remove {
display: none;
}
}

View file

@ -74,7 +74,14 @@
// Inputs
@input-background-color: white;
@input-border-color: fadeout(@base-border-color, 10%);
@input-border-color-blurred: desaturate(@input-border-color, 100%);
@input-tint-color: fade(@background-color-selected, 10%);
@input-tint-color-hover: fade(@input-tint-color, 30%);
@input-tint-color-blurred: desaturate(@input-tint-color, 100%);
@input-accessory-color-hover: @light-blue;
@input-accessory-color: @cool-gray;

View file

@ -192,3 +192,32 @@
'.': 'native!'
'?': 'native!'
'/': 'native!'
'body .popover-container':
'escape': 'popover:close'
# Tokenizing Text fields
# The default state. There's no input in the text field and there are no
# autocomplete suggestions
'.tokenizing-field':
'backspace': 'native!'
'tab': 'native!'
',': 'native!'
'.tokenizing-field.empty':
'backspace': 'tokenizing-field:remove'
# When we found a name to suggest
'.tokenizing-field.has-suggestions':
',': '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)':
'tab': 'native!'
',': 'tokenizing-field:add-input-value'
'enter': 'tokenizing-field:add-input-value'
'escape': 'tokenizing-field:cancel'

View file

@ -73,6 +73,7 @@
"theorist": "^1",
"vm-compatibility-layer": "0.1.0",
"react": "^0.12.2",
"react-dnd": "^0.6.3",
"moment": "^2.8",
"raven": "0.7.2",
"request": "2.46.0",

View file

@ -0,0 +1,162 @@
_ = require 'underscore-plus'
CSON = require 'season'
React = require 'react/addons'
ReactTestUtils = React.addons.TestUtils
{InboxTestUtils,
Namespace,
NamespaceStore,
Contact,
} = require 'inbox-exports'
{TokenizingTextField} = require 'ui-components'
me = new Namespace
name: 'Test User'
email: 'test@example.com'
provider: 'inbox'
NamespaceStore._current = me
CustomToken = React.createClass
render: ->
<span>{@props.item.email}</span>
CustomSuggestion = React.createClass
render: ->
<span>{@props.item.email}</span>
participant1 = new Contact
email: 'ben@nilas.com'
participant2 = new Contact
email: 'ben@example.com'
name: 'ben'
participant3 = new Contact
email: 'ben@inboxapp.com'
name: 'Duplicate email'
participant4 = new Contact
email: 'ben@elsewhere.com',
name: 'ben again'
participant5 = new Contact
email: 'evan@elsewhere.com',
name: 'EVAN'
fdescribe 'TokenizingTextField', ->
keymap_path = 'keymaps/base.cson'
keymap_file = CSON.readFileSync(keymap_path)
atom.keymaps.add(keymap_path, keymap_file)
beforeEach ->
@completions = []
@propAdd = jasmine.createSpy 'add'
@propRemove = jasmine.createSpy 'remove'
@propTokenKey = (p) -> p.email
@propTokenContent = (p) -> <CustomToken item={p} />
@propCompletionsForInput = (input) => @completions
@propCompletionContent = (p) -> <CustomSuggestion item={p} />
spyOn(@, 'propCompletionContent').andCallThrough()
spyOn(@, 'propCompletionsForInput').andCallThrough()
@fieldName = 'to'
@tabIndex = 100
@tokens = [participant1, participant2, participant3]
@renderedField = ReactTestUtils.renderIntoDocument(
<TokenizingTextField
name={@fieldName}
tabIndex={@tabIndex}
tokens={@tokens}
tokenKey={@propTokenKey}
tokenContent={@propTokenContent}
completionsForInput={@propCompletionsForInput}
completionContent={@propCompletionContent}
add={@propAdd}
remove={@propRemove} />
)
@renderedInput = ReactTestUtils.findRenderedDOMComponentWithTag(@renderedField, 'input').getDOMNode()
it 'renders into the document', ->
expect(ReactTestUtils.isCompositeComponentWithType @renderedField, TokenizingTextField).toBe(true)
it 'applies the tabIndex provided to the inner input', ->
expect(@renderedInput.tabIndex).toBe(@tabIndex)
it 'shows the tokens provided by the tokenContent method', ->
@renderedTokens = ReactTestUtils.scryRenderedComponentsWithType(@renderedField, CustomToken)
expect(@renderedTokens.length).toBe(@tokens.length)
it 'shows the tokens in the correct order', ->
@renderedTokens = ReactTestUtils.scryRenderedComponentsWithType(@renderedField, CustomToken)
for i in [0..@tokens.length-1]
expect(@renderedTokens[i].props.item).toBe(@tokens[i])
describe "when focused", ->
it 'should receive the `focused` class', ->
expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(@renderedField, 'focused').length).toBe(0)
ReactTestUtils.Simulate.focus(@renderedInput)
expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(@renderedField, 'focused').length).toBe(1)
describe "when the user types in the input", ->
it 'should fetch completions for the text', ->
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}})
expect(@propCompletionsForInput).toHaveBeenCalledWith('abc')
it 'should display the completions', ->
@completions = [participant4, participant5]
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}})
components = ReactTestUtils.scryRenderedComponentsWithType(@renderedField, CustomSuggestion)
expect(components.length).toBe(2)
expect(components[0].props.item).toBe(participant4)
expect(components[1].props.item).toBe(participant5)
it 'should not display items with keys matching items already in the token field', ->
@completions = [participant2, participant4, participant1]
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}})
components = ReactTestUtils.scryRenderedComponentsWithType(@renderedField, CustomSuggestion)
expect(components.length).toBe(1)
expect(components[0].props.item).toBe(participant4)
['enter', ','].forEach (key) ->
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'}})
InboxTestUtils.keyPress(key, @renderedInput)
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'}})
InboxTestUtils.keyPress(key, @renderedInput)
expect(@propAdd).toHaveBeenCalledWith('abc')
describe "when the user presses tab", ->
describe "and there is an completion available", ->
it "should call add with the first completion", ->
@completions = [participant4]
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}})
InboxTestUtils.keyPress('tab', @renderedInput)
expect(@propAdd).toHaveBeenCalledWith(participant4)
describe "when blurred", ->
it 'should call add, allowing the parent component to (optionally) turn the entered text into a token', ->
ReactTestUtils.Simulate.focus(@renderedInput)
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'text'}})
ReactTestUtils.Simulate.blur(@renderedInput)
expect(@propAdd).toHaveBeenCalledWith('text')
it 'should clear the entered text', ->
ReactTestUtils.Simulate.focus(@renderedInput)
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'text'}})
ReactTestUtils.Simulate.blur(@renderedInput)
expect(@renderedInput.value).toBe('')
it 'should no longer have the `focused` class', ->
ReactTestUtils.Simulate.focus(@renderedInput)
expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(@renderedField, 'focused').length).toBe(1)
ReactTestUtils.Simulate.blur(@renderedInput)
expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(@renderedField, 'focused').length).toBe(0)

View file

@ -87,6 +87,9 @@ Menu = React.createClass
getInitialState: ->
selectedIndex: -1
getSelectedItem: ->
@props.items[@state.selectedIndex]
componentDidMount: ->
@subscriptions = new CompositeDisposable()
@subscriptions.add atom.commands.add '.menu', {

View file

@ -0,0 +1,253 @@
React = require 'react/addons'
_ = require 'underscore-plus'
{CompositeDisposable} = require 'event-kit'
{Contact, ContactStore} = require 'inbox-exports'
{DragDropMixin} = require 'react-dnd'
Token = React.createClass
mixins: [DragDropMixin]
propTypes:
selected: React.PropTypes.bool,
select: React.PropTypes.func.isRequired,
action: React.PropTypes.func,
item: React.PropTypes.object,
configureDragDrop: (registerType) ->
registerType('token', {
dragSource:
beginDrag: ->
item: @props.item
})
render: ->
classes = React.addons.classSet
"token": true
"dragging": @getDragState('token').isDragging
"selected": @props.selected
<div {...@dragSourceFor('token')}
className={classes}
onClick={@_onSelect}>
<button className="action" onClick={@_onAction} ><i className="fa fa-chevron-down"></i></button>
{@props.children}
</div>
_onSelect: (event) ->
@props.select(@props.item)
event.preventDefault()
_onAction: (event) ->
@props.action(@props.item)
event.preventDefault()
module.exports =
TokenizingTextField = React.createClass
mixins: [DragDropMixin]
propTypes:
className: React.PropTypes.string,
prompt: React.PropTypes.string,
tokens: React.PropTypes.arrayOf(React.PropTypes.object),
tokenKey: React.PropTypes.func.isRequired,
tokenContent: React.PropTypes.func.isRequired,
completionContent: React.PropTypes.func.isRequired,
completionsForInput: React.PropTypes.func.isRequired
add: React.PropTypes.func.isRequired,
remove: React.PropTypes.func.isRequired,
showMenu: React.PropTypes.func,
configureDragDrop: (registerType) ->
registerType('token', {
dropTarget:
acceptDrop: (token) ->
@_addToken(token)
})
getInitialState: ->
completions: []
inputValue: ""
selectedTokenKey: null
componentDidMount: ->
input = @refs.input.getDOMNode()
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 = @refs.input.getDOMNode()
measure = @refs.measure.getDOMNode()
measure.innerText = @state.inputValue
measure.style.top = input.offsetTop + "px"
measure.style.left = input.offsetLeft + "px"
input.style.width = "calc(4px + #{measure.offsetWidth}px)"
render: ->
{Menu} = require 'ui-components'
classes = React.addons.classSet
"tokenizing-field": true
"focused": @state.focus
"native-key-bindings": true
"empty": @state.inputValue.trim().length is 0
"has-suggestions": @state.completions.length > 0
<Menu className={classes} ref="completions"
items={@state.completions}
itemKey={ (item) -> item.id }
itemContent={@props.completionContent}
headerComponents={[@_fieldComponent()]}
onSelect={@_addToken}
/>
_fieldComponent: ->
<div onClick={@_focusInput} {...@dropTargetFor('token')}>
<div className="tokenizing-field-label">{"#{@props.prompt}:"}</div>
<div className="tokenizing-field-input">
{@_fieldTokenComponents()}
<input type="text"
ref="input"
onCopy={@_onCopy}
onCut={@_onCut}
onPaste={@_onPaste}
onBlur={@_onInputBlurred}
onFocus={@_onInputFocused}
onChange={@_onInputChanged}
disabled={@props.disabled}
tabIndex={@props.tabIndex}
value={@state.inputValue} />
<span ref="measure" style={
position: 'absolute'
visibility: 'hidden'
}/>
</div>
</div>
_fieldTokenComponents: ->
@props.tokens.map (item) =>
<Token item={item}
key={@props.tokenKey(item)}
select={@_selectToken}
action={@props.showMenu || @_showDefaultTokenMenu}
selected={@state.selectedTokenKey is @props.tokenKey(item)}>
{@props.tokenContent(item)}
</Token>
# Maintaining Input State
_onInputFocused: ->
@setState
completions: @_getCompletions()
focus: true
_onInputChanged: (event) ->
val = event.target.value.trimLeft()
@setState
selectedTokenKey: null
completions: @_getCompletions(val)
inputValue: val
_onInputBlurred: ->
@_addInputValue()
@setState
selectedTokenKey: null
focus: false
_clearInput: ->
@setState
completions: []
inputValue: ""
_focusInput: ->
@refs.input.getDOMNode().focus()
# Managing Tokens
_addInputValue: ->
@props.add(@state.inputValue)
@_clearInput()
_selectToken: (token) ->
@setState
selectedTokenKey: @props.tokenKey(token)
_selectedToken: ->
_.find @props.tokens, (t) =>
@props.tokenKey(t) is @state.selectedTokenKey
_addToken: (token) ->
return unless token
@props.add(token)
@_clearInput()
@_focusInput()
_removeToken: (token = null) ->
if token
tokenToDelete = token
else if @state.selectedTokenKey
tokenToDelete = @_selectedToken()
else if @props.tokens.length > 0
@_selectToken(@props.tokens[@props.tokens.length - 1])
if tokenToDelete
@props.remove(tokenToDelete)
if @props.tokenKey(tokenToDelete) is @state.selectedTokenKey
@setState
selectedTokenKey: null
_showDefaultTokenMenu: (token) ->
remote = require('remote')
Menu = remote.require('menu')
MenuItem = remote.require('menu-item')
menu = new Menu()
menu.append(new MenuItem(
label: 'Remove',
click: => @_removeToken(token)
))
menu.popup(remote.getCurrentWindow())
# Copy and Paste
_onCut: (event) ->
if @state.selectedTokenKey
event.clipboardData.setData('text/plain', @props.tokenKey(@_selectedToken()))
event.preventDefault()
@_removeToken(@_selectedToken())
_onCopy: (event) ->
if @state.selectedTokenKey
event.clipboardData.setData('text/plain', @props.tokenKey(@_selectedToken()))
event.preventDefault()
_onPaste: (event) ->
data = event.clipboardData.getData('text/plain')
matchingTokens = @_getCompletions(data)
if matchingTokens.length is 1
@_addToken(matchingTokens[0])
event.preventDefault()
# Managing Suggestions
_getCompletions: (val = @state.inputValue) ->
existingKeys = _.map(@props.tokens, @props.tokenKey)
tokens = @props.completionsForInput(val)
_.reject tokens, (t) => @props.tokenKey(t) in existingKeys

View file

@ -70,6 +70,7 @@ class Message extends Model
constructor: ->
super
@body ||= ""
@subject ||= ""
@to ||= []
@cc ||= []
@bcc ||= []

View file

@ -21,6 +21,7 @@ module.exports = ContactStore = Reflux.createStore
@trigger(@)
searchContacts: (search) ->
return [] if not search or search.length is 0
search = search.toLowerCase()
matches = _.filter @_all, (contact) ->
return true if contact.email?.toLowerCase().indexOf(search) == 0

View file

@ -133,6 +133,15 @@ DatabaseStore = Reflux.createStore
writeModels: (tx, models) ->
# IMPORTANT: This method assumes that all the models you
# provide are of the same class!
# Avoid trying to write too many objects a time - sqlite can only handle
# value sets `(?,?)...` of less than SQLITE_MAX_COMPOUND_SELECT (500),
# and we don't know ahead of time whether we'll hit that or not.
if models.length > 100
@writeModels(tx, models[0..99])
@writeModels(tx, models[100..models.length])
return
klass = models[0].constructor
attributes = _.values(klass.attributes)
ids = []
@ -181,8 +190,13 @@ DatabaseStore = Reflux.createStore
joinMarks.push('(?,?)')
joinedValues.push(model.id, joined.id)
tx.execute("DELETE FROM `#{joinTable}` WHERE `id` IN ('#{ids.join("','")}')")
unless joinedValues.length is 0
tx.execute("INSERT INTO `#{joinTable}` (`id`, `value`) VALUES #{joinMarks.join(',')}", joinedValues)
# Write no more than 200 items (400 values) at once to avoid sqlite limits
for slice in [0..Math.floor(joinedValues.length / 400)] by 1
[ms, me] = [slice*200, slice*200 + 199]
[vs, ve] = [slice*400, slice*400 + 399]
tx.execute("INSERT INTO `#{joinTable}` (`id`, `value`) VALUES #{joinMarks[ms..me].join(',')}", joinedValues[vs..ve])
deleteModel: (tx, model) ->
klass = model.constructor

View file

@ -125,40 +125,9 @@ DraftStore = Reflux.createStore
task = new SaveDraftTask(draftLocalId, params)
Actions.queueTask(task)
_onSendDraft: (draftLocalId, options = {}) ->
DatabaseStore.findByLocalId(Message, draftLocalId).then (draft) =>
return unless draft
remote = require('remote')
dialog = remote.require('dialog')
if [].concat(draft.to, draft.cc, draft.bcc).length is 0
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.'
})
return
warnings = []
if draft.subject.length is 0
warnings.push('without a subject line')
if draft.body.toLowerCase().indexOf('attachment') != -1 and draft.files?.length is 0
warnings.push('without an attachment')
if warnings.length > 0 and not options.force
dialog.showMessageBox remote.getCurrentWindow(), {
type: 'warning',
buttons: ['Cancel', 'Send Anyway'],
message: 'Are you sure?',
detail: "Send #{warnings.join(' and ')}?"
}, (response) =>
if response is 1 # button array index 1
@_onSendDraft(draftLocalId, {force: true})
return
Actions.queueTask(new SendDraftTask(draftLocalId))
atom.close() if atom.state.mode is "composer"
_onSendDraft: (draftLocalId) ->
Actions.queueTask(new SendDraftTask(draftLocalId))
atom.close() if atom.state.mode is "composer"
_findDraft: (draftLocalId) ->
new Promise (resolve, reject) ->

View file

@ -28,3 +28,4 @@
@import "./components/popover";
@import "./components/menu";
@import "./components/tokenizing-text-field";

View file

@ -0,0 +1,83 @@
@import "ui-variables";
.tokenizing-field {
display: block;
padding: 0;
margin: 0;
border-bottom: 1px solid @border-color-divider;
min-height:30px;
position: relative;
.header-container, .footer-container {
background-color: transparent;
padding:0;
margin:0;
border:0;
}
.content-container {
position: absolute;
width: 100%;
background-color: @background-color;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
z-index: 40;
}
.token {
display: inline-block;
position: relative;
padding: 0px 16px;
margin: 0 5px 5px 0;
border-radius: 18px;
font-size: 15px;
background-color: @input-tint-color;
border: 1px solid darken(@input-tint-color, 20%);
.action {
position:absolute;
padding:2px;
right:2px;
border: 0;
margin: 0;
background-color: transparent;
color: transparent;
font-size: 12px;
}
&:hover {
background-color: @input-tint-color-hover;
.action {
color: @text-color-subtle;
}
}
&.selected,
&.dragging {
background-color: @background-color-selected;
color: @text-color-inverse;
}
}
.tokenizing-field-label {
color: @text-color-subtle;
float: left;
text-transform: capitalize;
padding-top: 2px;
}
.tokenizing-field-input {
padding-left:30px;
input {
display: inline-block;
width: initial;
padding: 2px 0;
margin: 0 5px 5px 0;
border: none;
min-width: 5em;
background-color:transparent;
}
}
}
body.is-blurred .tokenizing-field .token {
background-color: @input-tint-color-blurred;
border: 1px solid darken(@input-tint-color-blurred, 20%);
}