mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-11-11 01:54:40 +08:00
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:
parent
a31c2808a9
commit
e4889b390f
22 changed files with 735 additions and 813 deletions
|
@ -4,3 +4,4 @@ module.exports =
|
|||
# Models
|
||||
Menu: require '../src/components/menu'
|
||||
Popover: require '../src/components/popover'
|
||||
TokenizingTextField: require '../src/components/tokenizing-text-field'
|
|
@ -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'
|
|
@ -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>
|
|
@ -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()
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
107
internal_packages/composer/lib/participants-text-field.cjsx
Normal file
107
internal_packages/composer/lib/participants-text-field.cjsx
Normal 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())
|
||||
|
|
@ -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()
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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",
|
||||
|
|
162
spec-inbox/components/tokenizing-text-field-spec.cjsx
Normal file
162
spec-inbox/components/tokenizing-text-field-spec.cjsx
Normal 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)
|
||||
|
|
@ -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', {
|
||||
|
|
253
src/components/tokenizing-text-field.cjsx
Normal file
253
src/components/tokenizing-text-field.cjsx
Normal 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
|
||||
|
|
@ -70,6 +70,7 @@ class Message extends Model
|
|||
constructor: ->
|
||||
super
|
||||
@body ||= ""
|
||||
@subject ||= ""
|
||||
@to ||= []
|
||||
@cc ||= []
|
||||
@bcc ||= []
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) ->
|
||||
|
|
|
@ -28,3 +28,4 @@
|
|||
|
||||
@import "./components/popover";
|
||||
@import "./components/menu";
|
||||
@import "./components/tokenizing-text-field";
|
||||
|
|
83
static/components/tokenizing-text-field.less
Normal file
83
static/components/tokenizing-text-field.less
Normal 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%);
|
||||
}
|
Loading…
Reference in a new issue