mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-01-01 13:14:16 +08:00
feat(composer): participants collapse
Summary: Participants now collapse Gmail style in the composer field. New, more declarative system for how we deal with "focusedFields" on the composer. Extracted a `CollapsedParticipants` and `ExpandedParticipants` component. Test Plan: TODO Reviewers: dillon, bengotow Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D2013
This commit is contained in:
parent
95c23ed497
commit
c03a63b9ea
11 changed files with 812 additions and 361 deletions
|
@ -4,6 +4,8 @@ div[contenteditable]':
|
|||
'cmd-C' : 'composer:show-and-focus-cc'
|
||||
'ctrl-B' : 'composer:show-and-focus-bcc'
|
||||
'ctrl-C' : 'composer:show-and-focus-cc'
|
||||
'cmd-T' : 'composer:focus-to'
|
||||
'ctrl-T' : 'composer:focus-to'
|
||||
'cmd-enter' : 'composer:send-message'
|
||||
'ctrl-enter' : 'composer:send-message'
|
||||
|
||||
|
@ -13,11 +15,6 @@ div[contenteditable]':
|
|||
'.composer-outer-wrap, .composer-outer-wrap div[contenteditable]':
|
||||
'escape' : 'composer:delete-empty-draft'
|
||||
|
||||
'.composer-outer-wrap .btn.btn-send':
|
||||
'enter' : 'composer:send-message'
|
||||
'tab' : 'composer:focus-to'
|
||||
'shift-tab' : 'native!'
|
||||
|
||||
'body.platform-win32 .composer-outer-wrap *[contenteditable], body.platform-win32 .composer-outer-wrap input':
|
||||
'ctrl-z': 'composer:undo'
|
||||
'ctrl-Z': 'composer:redo'
|
||||
|
|
|
@ -25,8 +25,6 @@ class AccountContactField extends React.Component
|
|||
accounts: AccountStore.items()
|
||||
|
||||
render: =>
|
||||
return <span></span> unless @state.accounts.length > 1
|
||||
|
||||
<div className="composer-participant-field">
|
||||
<div className="composer-field-label">{"From:"}</div>
|
||||
{@_renderFromPicker()}
|
||||
|
|
99
internal_packages/composer/lib/collapsed-participants.cjsx
Normal file
99
internal_packages/composer/lib/collapsed-participants.cjsx
Normal file
|
@ -0,0 +1,99 @@
|
|||
React = require 'react'
|
||||
{Utils} = require 'nylas-exports'
|
||||
|
||||
class CollapsedParticipants extends React.Component
|
||||
@displayName: "CollapsedParticipants"
|
||||
|
||||
@propTypes:
|
||||
# Arrays of Contact objects.
|
||||
to: React.PropTypes.array
|
||||
cc: React.PropTypes.array
|
||||
bcc: React.PropTypes.array
|
||||
|
||||
# Notifies parent when the component has been clicked. This is usually
|
||||
# used to expand the participants field
|
||||
onClick: React.PropTypes.func
|
||||
|
||||
@defaultProps:
|
||||
to: []
|
||||
cc: []
|
||||
bcc: []
|
||||
|
||||
constructor: (@props={}) ->
|
||||
@state =
|
||||
numToDisplay: 999
|
||||
numRemaining: 0
|
||||
numBccRemaining: 0
|
||||
|
||||
componentDidMount: ->
|
||||
@_setNumHiddenParticipants()
|
||||
|
||||
shouldComponentUpdate: (nextProps, nextState) =>
|
||||
not Utils.isEqualReact(nextProps, @props) or
|
||||
not Utils.isEqualReact(nextState, @state)
|
||||
|
||||
componentDidUpdate: ->
|
||||
@_setNumHiddenParticipants()
|
||||
|
||||
render: ->
|
||||
contacts = @props.to.concat(@props.cc).map(@_collapsedContact)
|
||||
bcc = @props.bcc.map(@_collapsedBccContact)
|
||||
toDisplay = contacts.concat(bcc)
|
||||
toDisplay = toDisplay[0...@state.numToDisplay]
|
||||
if toDisplay.length is 0 then toDisplay = "Recipients"
|
||||
<div onClick={ => @props.onClick?()}
|
||||
ref="participantsWrap"
|
||||
className="collapsed-composer-participants">
|
||||
{@_renderNumRemaining()}
|
||||
{toDisplay}
|
||||
</div>
|
||||
|
||||
_renderNumRemaining: ->
|
||||
if @state.numRemaining is 0 and @state.numBccRemaining is 0
|
||||
return null
|
||||
else if @state.numRemaining > 0 and @state.numBccRemaining is 0
|
||||
str = "#{@state.numRemaining} more"
|
||||
else if @state.numRemaining is 0 and @state.numBccRemaining > 0
|
||||
str = "#{@state.numBccRemaining} Bcc"
|
||||
else if @state.numRemaining > 0 and @state.numBccRemaining > 0
|
||||
str = "#{@state.numRemaining + @state.numBccRemaining} more (#{@state.numBccRemaining} Bcc)"
|
||||
|
||||
return <div className="num-remaining-wrap tokenizing-field"><div className="show-more-fade"></div><div className="num-remaining token">{str}</div></div>
|
||||
|
||||
_collapsedContact: (contact) ->
|
||||
name = contact.displayName()
|
||||
<span key={contact.id}
|
||||
className="collapsed-contact regular-contact">{name}</span>
|
||||
|
||||
_collapsedBccContact: (contact, i) ->
|
||||
name = contact.displayName()
|
||||
if i is 0 then name = "Bcc: #{name}"
|
||||
<span key={contact.id}
|
||||
className="collapsed-contact bcc-contact">{name}</span>
|
||||
|
||||
_setNumHiddenParticipants: ->
|
||||
$wrap = React.findDOMNode(@refs.participantsWrap)
|
||||
$regulars = $wrap.getElementsByClassName("regular-contact")
|
||||
$bccs = $wrap.getElementsByClassName("bcc-contact")
|
||||
|
||||
availableSpace = $wrap.getBoundingClientRect().width
|
||||
numRemaining = @props.to.length + @props.cc.length
|
||||
numBccRemaining = @props.bcc.length
|
||||
numToDisplay = 0
|
||||
widthAccumulator = 0
|
||||
|
||||
for $p in $regulars
|
||||
widthAccumulator += $p.getBoundingClientRect().width
|
||||
break if widthAccumulator >= availableSpace
|
||||
numRemaining -= 1
|
||||
numToDisplay += 1
|
||||
|
||||
for $p in $bccs
|
||||
widthAccumulator += $p.getBoundingClientRect().width
|
||||
break if widthAccumulator >= availableSpace
|
||||
numBccRemaining -= 1
|
||||
numToDisplay += 1
|
||||
|
||||
@setState {numToDisplay, numRemaining, numBccRemaining}
|
||||
|
||||
module.exports = CollapsedParticipants
|
|
@ -1,32 +1,33 @@
|
|||
React = require 'react'
|
||||
_ = require 'underscore'
|
||||
React = require 'react'
|
||||
|
||||
{Utils,
|
||||
File,
|
||||
{File,
|
||||
Utils,
|
||||
Actions,
|
||||
DraftStore,
|
||||
UndoManager,
|
||||
ContactStore,
|
||||
AccountStore,
|
||||
UndoManager,
|
||||
FileUploadStore,
|
||||
QuotedHTMLParser,
|
||||
FileDownloadStore} = require 'nylas-exports'
|
||||
|
||||
{ResizableRegion,
|
||||
InjectedComponentSet,
|
||||
{DropZone,
|
||||
RetinaImg,
|
||||
ScrollRegion,
|
||||
InjectedComponent,
|
||||
FocusTrackingRegion,
|
||||
ScrollRegion,
|
||||
ButtonDropdown,
|
||||
DropZone,
|
||||
Menu,
|
||||
RetinaImg} = require 'nylas-component-kit'
|
||||
InjectedComponentSet} = require 'nylas-component-kit'
|
||||
|
||||
FileUpload = require './file-upload'
|
||||
ImageFileUpload = require './image-file-upload'
|
||||
|
||||
ExpandedParticipants = require './expanded-participants'
|
||||
CollapsedParticipants = require './collapsed-participants'
|
||||
|
||||
ContenteditableComponent = require './contenteditable-component'
|
||||
ParticipantsTextField = require './participants-text-field'
|
||||
AccountContactField = require './account-contact-field'
|
||||
|
||||
Fields = require './fields'
|
||||
|
||||
# The ComposerView is a unique React component because it (currently) is a
|
||||
# singleton. Normally, the React way to do things would be to re-render the
|
||||
|
@ -61,9 +62,8 @@ class ComposerView extends React.Component
|
|||
body: ""
|
||||
files: []
|
||||
subject: ""
|
||||
showcc: false
|
||||
showbcc: false
|
||||
showsubject: false
|
||||
focusedField: Fields.To # Gets updated in @_initiallyFocusedField
|
||||
enabledFields: [] # Gets updated in @_initiallyEnabledFields
|
||||
showQuotedText: false
|
||||
uploads: FileUploadStore.uploadsForMessage(@props.draftClientId) ? []
|
||||
|
||||
|
@ -75,26 +75,25 @@ class ComposerView extends React.Component
|
|||
not Utils.isEqualReact(nextState, @state)
|
||||
|
||||
componentDidMount: =>
|
||||
@_uploadUnlisten = FileUploadStore.listen @_onFileUploadStoreChange
|
||||
@_usubs = []
|
||||
@_usubs.push FileUploadStore.listen @_onFileUploadStoreChange
|
||||
@_usubs.push AccountStore.listen @_onAccountStoreChanged
|
||||
@_keymapUnlisten = atom.commands.add '.composer-outer-wrap', {
|
||||
'composer:show-and-focus-bcc': @_showAndFocusBcc
|
||||
'composer:show-and-focus-cc': @_showAndFocusCc
|
||||
'composer:focus-to': => @focus "textFieldTo"
|
||||
'composer:send-message': => @_sendDraft()
|
||||
'composer:delete-empty-draft': => @_deleteDraftIfEmpty()
|
||||
'composer:show-and-focus-bcc': => @_onChangeEnabledFields(show: [Fields.Bcc], focus: Fields.Bcc)
|
||||
'composer:show-and-focus-cc': => @_onChangeEnabledFields(show: [Fields.Cc], focus: Fields.Cc)
|
||||
'composer:focus-to': => @_onChangeEnabledFields(show: [Fields.To], focus: Fields.To)
|
||||
"composer:undo": @undo
|
||||
"composer:redo": @redo
|
||||
}
|
||||
if @props.mode is "fullwindow"
|
||||
# Need to delay so the component can be fully painted. Focus doesn't
|
||||
# work unless the element is on the page.
|
||||
@focus "textFieldTo"
|
||||
@_applyFocusedField()
|
||||
|
||||
componentWillUnmount: =>
|
||||
@_unmounted = true # rarf
|
||||
@_teardownForDraft()
|
||||
@_deleteDraftIfEmpty()
|
||||
@_uploadUnlisten() if @_uploadUnlisten
|
||||
usub() for usub in @_usubs
|
||||
@_keymapUnlisten.dispose() if @_keymapUnlisten
|
||||
|
||||
componentDidUpdate: =>
|
||||
|
@ -105,11 +104,15 @@ class ComposerView extends React.Component
|
|||
# re-rendering.
|
||||
@_recoveredSelection = null if @_recoveredSelection?
|
||||
|
||||
# We often can't focus until the component state has changed
|
||||
# (so the desired field exists or we have a draft)
|
||||
if @_focusOnUpdate and @_proxy
|
||||
@focus(@_focusOnUpdate.field)
|
||||
@_focusOnUpdate = false
|
||||
@_applyFocusedField()
|
||||
|
||||
_applyFocusedField: ->
|
||||
if @state.focusedField
|
||||
return unless @refs[@state.focusedField]
|
||||
if @refs[@state.focusedField].focus
|
||||
@refs[@state.focusedField].focus()
|
||||
else
|
||||
React.findDOMNode(@refs[@state.focusedField]).focus()
|
||||
|
||||
componentWillReceiveProps: (newProps) =>
|
||||
@_ignoreNextTrigger = false
|
||||
|
@ -150,9 +153,7 @@ class ComposerView extends React.Component
|
|||
render: =>
|
||||
if @props.mode is "inline"
|
||||
<FocusTrackingRegion className={@_wrapClasses()} onFocus={@focus} tabIndex="-1">
|
||||
<ResizableRegion handle={ResizableRegion.Handle.Bottom}>
|
||||
{@_renderComposer()}
|
||||
</ResizableRegion>
|
||||
{@_renderComposer()}
|
||||
</FocusTrackingRegion>
|
||||
else
|
||||
<div className={@_wrapClasses()}>
|
||||
|
@ -174,17 +175,8 @@ class ComposerView extends React.Component
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="composer-content-wrap">
|
||||
{@_renderBodyScrollbar()}
|
||||
|
||||
<div className="composer-centered">
|
||||
{@_renderFields()}
|
||||
|
||||
<div className="compose-body" ref="composeBody" onClick={@_onClickComposeBody}>
|
||||
{@_renderBody()}
|
||||
{@_renderFooterRegions()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="composer-content-wrap" onKeyDown={@_onKeyDown}>
|
||||
{@_renderScrollRegion()}
|
||||
|
||||
</div>
|
||||
<div className="composer-action-bar-wrap">
|
||||
|
@ -192,111 +184,89 @@ class ComposerView extends React.Component
|
|||
</div>
|
||||
</DropZone>
|
||||
|
||||
_renderFields: =>
|
||||
# Note: We need to physically add and remove these elements, not just hide them.
|
||||
# If they're hidden, shift-tab between fields breaks.
|
||||
fields = []
|
||||
fields.push(
|
||||
<div key="to">
|
||||
<div className="composer-participant-actions">
|
||||
<span className="header-action"
|
||||
style={display: @state.showcc and 'none' or 'inline'}
|
||||
onClick={@_showAndFocusCc}>Cc</span>
|
||||
|
||||
<span className="header-action"
|
||||
style={display: @state.showbcc and 'none' or 'inline'}
|
||||
onClick={@_showAndFocusBcc}>Bcc</span>
|
||||
|
||||
<span className="header-action"
|
||||
style={display: @state.showsubject and 'none' or 'initial'}
|
||||
onClick={@_showAndFocusSubject}>Subject</span>
|
||||
|
||||
<span className="header-action"
|
||||
data-tooltip="Popout composer"
|
||||
style={{display: ((@props.mode is "fullwindow") and 'none' or 'initial'), paddingLeft: "1.5em"}}
|
||||
onClick={@_popoutComposer}>
|
||||
<RetinaImg name="composer-popout.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
style={{position: "relative", top: "-2px"}}/>
|
||||
</span>
|
||||
</div>
|
||||
<ParticipantsTextField
|
||||
ref="textFieldTo"
|
||||
field='to'
|
||||
change={@_onChangeParticipants}
|
||||
className="composer-participant-field"
|
||||
participants={to: @state['to'], cc: @state['cc'], bcc: @state['bcc']}
|
||||
tabIndex='102'/>
|
||||
</div>
|
||||
)
|
||||
|
||||
if @state.showcc
|
||||
fields.push(
|
||||
<ParticipantsTextField
|
||||
ref="textFieldCc"
|
||||
key="cc"
|
||||
field='cc'
|
||||
change={@_onChangeParticipants}
|
||||
onEmptied={@_onEmptyCc}
|
||||
className="composer-participant-field"
|
||||
participants={to: @state['to'], cc: @state['cc'], bcc: @state['bcc']}
|
||||
tabIndex='103'/>
|
||||
)
|
||||
|
||||
if @state.showbcc
|
||||
fields.push(
|
||||
<ParticipantsTextField
|
||||
ref="textFieldBcc"
|
||||
key="bcc"
|
||||
field='bcc'
|
||||
change={@_onChangeParticipants}
|
||||
onEmptied={@_onEmptyBcc}
|
||||
className="composer-participant-field"
|
||||
participants={to: @state['to'], cc: @state['cc'], bcc: @state['bcc']}
|
||||
tabIndex='104'/>
|
||||
)
|
||||
|
||||
if @state.showsubject
|
||||
fields.push(
|
||||
<div key="subject" className="compose-subject-wrap">
|
||||
<input type="text"
|
||||
key="subject"
|
||||
name="subject"
|
||||
tabIndex="108"
|
||||
ref="textFieldSubject"
|
||||
placeholder="Subject"
|
||||
value={@state.subject}
|
||||
onChange={@_onChangeSubject}/>
|
||||
</div>
|
||||
)
|
||||
|
||||
if @state.showfrom
|
||||
fields.push(
|
||||
<AccountContactField
|
||||
key="from"
|
||||
onChange={ (me) => @_onChangeParticipants(from: [me]) }
|
||||
value={@state.from?[0]} />
|
||||
)
|
||||
|
||||
fields
|
||||
|
||||
_renderBodyScrollbar: =>
|
||||
_renderScrollRegion: ->
|
||||
if @props.mode is "inline"
|
||||
[]
|
||||
@_renderContent()
|
||||
else
|
||||
<ScrollRegion.Scrollbar ref="scrollbar" getScrollRegion={ => @refs.scrollregion } />
|
||||
<ScrollRegion className="compose-body-scroll" ref="scrollregion">
|
||||
{@_renderContent()}
|
||||
</ScrollRegion>
|
||||
|
||||
_renderContent: ->
|
||||
<div className="composer-centered">
|
||||
{if @state.focusedField in Fields.ParticipantFields
|
||||
<ExpandedParticipants to={@state.to} cc={@state.cc}
|
||||
bcc={@state.bcc}
|
||||
from={@state.from}
|
||||
ref="expandedParticipants"
|
||||
mode={@props.mode}
|
||||
focusedField={@state.focusedField}
|
||||
enabledFields={@state.enabledFields}
|
||||
onChangeParticipants={@_onChangeParticipants}
|
||||
onChangeEnabledFields={@_onChangeEnabledFields}
|
||||
/>
|
||||
else
|
||||
<CollapsedParticipants to={@state.to} cc={@state.cc}
|
||||
bcc={@state.bcc}
|
||||
onClick={@_focusParticipantField} />
|
||||
}
|
||||
|
||||
{@_renderSubject()}
|
||||
|
||||
<div className="compose-body" ref="composeBody" onClick={@_onClickComposeBody}>
|
||||
{@_renderBody()}
|
||||
{@_renderFooterRegions()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
_onKeyDown: (event) =>
|
||||
if event.key is "Tab"
|
||||
@_onTabDown(event)
|
||||
return
|
||||
|
||||
_onTabDown: (event) =>
|
||||
event.preventDefault()
|
||||
enabledFields = _.filter @state.enabledFields, (field) ->
|
||||
Fields.Order[field] >= 0
|
||||
enabledFields = _.sortBy enabledFields, (field) ->
|
||||
Fields.Order[field]
|
||||
i = enabledFields.indexOf @state.focusedField
|
||||
dir = if event.shiftKey then -1 else 1
|
||||
newI = Math.min(Math.max(i + dir, 0), enabledFields.length - 1)
|
||||
@setState focusedField: enabledFields[newI]
|
||||
event.stopPropagation()
|
||||
|
||||
_onChangeParticipantField: (focusedField) =>
|
||||
@setState {focusedField}
|
||||
@_lastFocusedParticipantField = focusedField
|
||||
|
||||
_focusParticipantField: =>
|
||||
@setState focusedField: @_lastFocusedParticipantField ? Fields.To
|
||||
|
||||
_onChangeEnabledFields: ({show, hide, focus}={}) =>
|
||||
show = show ? []; hide = hide ? []
|
||||
newFields = _.difference(@state.enabledFields.concat(show), hide)
|
||||
@setState
|
||||
enabledFields: newFields
|
||||
focusedField: (focus ? @state.focusedField)
|
||||
|
||||
_renderSubject: ->
|
||||
if Fields.Subject in @state.enabledFields
|
||||
<div key="subject-wrap" className="compose-subject-wrap">
|
||||
<input type="text"
|
||||
name="subject"
|
||||
ref={Fields.Subject}
|
||||
placeholder="Subject"
|
||||
value={@state.subject}
|
||||
onFocus={ => @setState focusedField: Fields.Subject}
|
||||
onChange={@_onChangeSubject}/>
|
||||
</div>
|
||||
|
||||
_renderBody: =>
|
||||
if @props.mode is "inline"
|
||||
<span>
|
||||
{@_renderBodyContenteditable()}
|
||||
{@_renderAttachments()}
|
||||
</span>
|
||||
else
|
||||
<ScrollRegion className="compose-body-scroll" ref="scrollregion" getScrollbar={ => @refs.scrollbar }>
|
||||
{@_renderBodyContenteditable()}
|
||||
{@_renderAttachments()}
|
||||
</ScrollRegion>
|
||||
<span>
|
||||
{@_renderBodyContenteditable()}
|
||||
{@_renderAttachments()}
|
||||
</span>
|
||||
|
||||
_renderBodyContenteditable: =>
|
||||
onScrollToBottom = null
|
||||
|
@ -304,8 +274,9 @@ class ComposerView extends React.Component
|
|||
onScrollToBottom = =>
|
||||
@props.onRequestScrollTo({clientId: @_proxy.draft().clientId})
|
||||
|
||||
<ContenteditableComponent ref="contentBody"
|
||||
<ContenteditableComponent ref={Fields.Body}
|
||||
html={@state.body}
|
||||
onFocus={ => @setState focusedField: Fields.Body}
|
||||
onChange={@_onChangeBody}
|
||||
onFilePaste={@_onFilePaste}
|
||||
style={@_precalcComposerCss}
|
||||
|
@ -313,8 +284,7 @@ class ComposerView extends React.Component
|
|||
mode={{showQuotedText: @state.showQuotedText}}
|
||||
onChangeMode={@_onChangeEditableMode}
|
||||
onScrollTo={@props.onRequestScrollTo}
|
||||
onScrollToBottom={onScrollToBottom}
|
||||
tabIndex="109" />
|
||||
onScrollToBottom={onScrollToBottom} />
|
||||
|
||||
_renderFooterRegions: =>
|
||||
return <div></div> unless @props.draftClientId
|
||||
|
@ -423,29 +393,6 @@ class ComposerView extends React.Component
|
|||
|
||||
</InjectedComponentSet>
|
||||
|
||||
# Focus the composer view. Chooses the appropriate field to start
|
||||
# focused depending on the draft type, or you can pass a field as
|
||||
# the first parameter.
|
||||
focus: (field = null) =>
|
||||
if not @_proxy
|
||||
@_focusOnUpdate = {field}
|
||||
return
|
||||
|
||||
defaultField = "contentBody"
|
||||
if @isForwardedMessage() # Note: requires _proxy
|
||||
defaultField = "textFieldTo"
|
||||
field ?= defaultField
|
||||
|
||||
if not @refs[field]
|
||||
@_focusOnUpdate = {field}
|
||||
return
|
||||
|
||||
if @refs[field].focus
|
||||
@refs[field].focus()
|
||||
else
|
||||
node = React.findDOMNode(@refs[field])
|
||||
node.focus?()
|
||||
|
||||
isForwardedMessage: =>
|
||||
return false if not @_proxy
|
||||
draft = @_proxy.draft()
|
||||
|
@ -455,7 +402,7 @@ class ComposerView extends React.Component
|
|||
# and simulate what happens when you click beneath the text *in* the
|
||||
# contentEditable.
|
||||
_onClickComposeBody: (event) =>
|
||||
@refs.contentBody.selectEnd()
|
||||
@refs[Fields.Body].selectEnd()
|
||||
|
||||
_onDraftChanged: =>
|
||||
return if @_ignoreNextTrigger
|
||||
|
@ -471,28 +418,67 @@ class ComposerView extends React.Component
|
|||
cc: draft.cc
|
||||
bcc: draft.bcc
|
||||
from: draft.from
|
||||
body: draft.body
|
||||
files: draft.files
|
||||
subject: draft.subject
|
||||
body: draft.body
|
||||
showfrom: not draft.replyToMessageId and draft.files.length is 0
|
||||
|
||||
if !@state.populated
|
||||
_.extend state,
|
||||
showcc: not _.isEmpty(draft.cc)
|
||||
showbcc: not _.isEmpty(draft.bcc)
|
||||
showsubject: @_shouldShowSubject()
|
||||
showQuotedText: @isForwardedMessage()
|
||||
populated: true
|
||||
focusedField: @_initiallyFocusedField(draft)
|
||||
enabledFields: @_initiallyEnabledFields(draft)
|
||||
showQuotedText: @isForwardedMessage()
|
||||
|
||||
# It's possible for another part of the application to manipulate the draft
|
||||
# we're displaying. If they've added a CC or BCC, make sure we show those fields.
|
||||
# We should never be hiding the field if it's populated.
|
||||
state.showcc = true if not _.isEmpty(draft.cc)
|
||||
state.showbcc = true if not _.isEmpty(draft.bcc)
|
||||
state = @_verifyEnabledFields(draft, state)
|
||||
|
||||
@setState(state)
|
||||
|
||||
_shouldShowSubject: =>
|
||||
_initiallyFocusedField: (draft) ->
|
||||
return Fields.To if draft.to.length is 0
|
||||
return Fields.Subject if (draft.subject ? "").trim().length is 0
|
||||
return Fields.Body
|
||||
|
||||
_verifyEnabledFields: (draft, state) ->
|
||||
enabledFields = @state.enabledFields.concat(state.enabledFields)
|
||||
updated = false
|
||||
if draft.cc.length > 0
|
||||
updated = true
|
||||
enabledFields.push(Fields.Cc)
|
||||
|
||||
if draft.bcc.length > 0
|
||||
updated = true
|
||||
enabledFields.push(Fields.Bcc)
|
||||
|
||||
if updated
|
||||
state.enabledFields = _.uniq(enabledFields)
|
||||
|
||||
return state
|
||||
|
||||
_initiallyEnabledFields: (draft) ->
|
||||
enabledFields = [Fields.To]
|
||||
enabledFields.push Fields.Cc if not _.isEmpty(draft.cc)
|
||||
enabledFields.push Fields.Bcc if not _.isEmpty(draft.bcc)
|
||||
enabledFields.push Fields.From if @_shouldShowFromField(draft)
|
||||
enabledFields.push Fields.Subject if @_shouldEnableSubject()
|
||||
enabledFields.push Fields.Body
|
||||
return enabledFields
|
||||
|
||||
# When the account store changes, the From field may or may not still
|
||||
# be in scope. We need to make sure to update our enabled fields.
|
||||
_onAccountStoreChanged: =>
|
||||
if @_shouldShowFromField(@_proxy?.draft())
|
||||
enabledFields = @state.enabledFields.concat [Fields.From]
|
||||
else
|
||||
enabledFields = _.without(@state.enabledFields, Fields.From)
|
||||
@setState {enabledFields}
|
||||
|
||||
_shouldShowFromField: (draft) ->
|
||||
return false unless draft
|
||||
return AccountStore.items().length > 1 and
|
||||
not draft.replyToMessageId and
|
||||
draft.files.length is 0
|
||||
|
||||
_shouldEnableSubject: =>
|
||||
return false unless @_proxy
|
||||
draft = @_proxy.draft()
|
||||
if _.isEmpty(draft.subject ? "") then return true
|
||||
|
@ -664,29 +650,6 @@ class ComposerView extends React.Component
|
|||
_attachFile: =>
|
||||
Actions.attachFile({messageClientId: @props.draftClientId})
|
||||
|
||||
_showAndFocusBcc: =>
|
||||
@setState {showbcc: true}
|
||||
@focus('textFieldBcc')
|
||||
|
||||
_showAndFocusCc: =>
|
||||
@setState {showcc: true}
|
||||
@focus('textFieldCc')
|
||||
|
||||
_showAndFocusSubject: =>
|
||||
@setState {showsubject: true}
|
||||
@focus('textFieldSubject')
|
||||
|
||||
_onEmptyCc: =>
|
||||
@setState showcc: false
|
||||
@focus('textFieldTo')
|
||||
|
||||
_onEmptyBcc: =>
|
||||
@setState showbcc: false
|
||||
if @state.showcc
|
||||
@focus('textFieldCc')
|
||||
else
|
||||
@focus('textFieldTo')
|
||||
|
||||
undo: (event) =>
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
@ -708,8 +671,8 @@ class ComposerView extends React.Component
|
|||
@_recoveredSelection = null
|
||||
|
||||
_getSelections: =>
|
||||
currentSelection: @refs.contentBody?.getCurrentSelection?()
|
||||
previousSelection: @refs.contentBody?.getPreviousSelection?()
|
||||
currentSelection: @refs[Fields.Body]?.getCurrentSelection?()
|
||||
previousSelection: @refs[Fields.Body]?.getPreviousSelection?()
|
||||
|
||||
_saveToHistory: (selections) =>
|
||||
return unless @_proxy
|
||||
|
|
|
@ -105,6 +105,7 @@ class ContenteditableComponent extends React.Component
|
|||
tabIndex={@props.tabIndex}
|
||||
style={@props.style ? {}}
|
||||
onBlur={@_onBlur}
|
||||
onFocus={@_onFocus}
|
||||
onClick={@_onClick}
|
||||
onPaste={@_onPaste}
|
||||
onInput={@_onInput}
|
||||
|
@ -204,7 +205,10 @@ class ContenteditableComponent extends React.Component
|
|||
document.execCommand("indent")
|
||||
return
|
||||
else if event.shiftKey
|
||||
if @_atTabChar() then @_removeLastCharacter()
|
||||
if @_atTabChar()
|
||||
@_removeLastCharacter()
|
||||
else if @_atBeginning()
|
||||
return # Don't stop propagation
|
||||
else
|
||||
document.execCommand("insertText", false, "\t")
|
||||
else
|
||||
|
@ -212,6 +216,7 @@ class ContenteditableComponent extends React.Component
|
|||
document.execCommand("insertText", false, "")
|
||||
else
|
||||
document.execCommand("insertText", false, "\t")
|
||||
event.stopPropagation()
|
||||
|
||||
_selectionInText: (selection) ->
|
||||
return false unless selection
|
||||
|
@ -223,6 +228,16 @@ class ContenteditableComponent extends React.Component
|
|||
return selection.anchorNode.textContent[selection.anchorOffset - 1] is "\t"
|
||||
else return false
|
||||
|
||||
_atBeginning: ->
|
||||
selection = document.getSelection()
|
||||
return false if not selection.isCollapsed
|
||||
return false if selection.anchorOffset > 0
|
||||
el = @_editableNode()
|
||||
return true if el.childNodes.length is 0
|
||||
return true if selection.anchorNode is el
|
||||
firstChild = el.childNodes[0]
|
||||
return selection.anchorNode is firstChild
|
||||
|
||||
_removeLastCharacter: ->
|
||||
selection = document.getSelection()
|
||||
if @_selectionInText(selection)
|
||||
|
@ -341,6 +356,9 @@ class ContenteditableComponent extends React.Component
|
|||
@_hideToolbar()
|
||||
, 50
|
||||
|
||||
_onFocus: (event) =>
|
||||
@props.onFocus?(event)
|
||||
|
||||
_editableNode: =>
|
||||
React.findDOMNode(@refs.contenteditable)
|
||||
|
||||
|
|
172
internal_packages/composer/lib/expanded-participants.cjsx
Normal file
172
internal_packages/composer/lib/expanded-participants.cjsx
Normal file
|
@ -0,0 +1,172 @@
|
|||
_ = require 'underscore'
|
||||
React = require 'react'
|
||||
AccountContactField = require './account-contact-field'
|
||||
ParticipantsTextField = require './participants-text-field'
|
||||
{RetinaImg} = require 'nylas-component-kit'
|
||||
|
||||
Fields = require './fields'
|
||||
|
||||
class ExpandedParticipants extends React.Component
|
||||
@displayName: "ExpandedParticipants"
|
||||
|
||||
@propTypes:
|
||||
# Arrays of Contact objects.
|
||||
to: React.PropTypes.array
|
||||
cc: React.PropTypes.array
|
||||
bcc: React.PropTypes.array
|
||||
from: React.PropTypes.array
|
||||
|
||||
# Either "fullwindow" or "inline"
|
||||
mode: React.PropTypes.string
|
||||
|
||||
# The field that should be focused
|
||||
focusedField: React.PropTypes.string
|
||||
|
||||
# An enum array of visible fields. Can be any constant in the `Fields`
|
||||
# dict. We are passed these as props instead of holding it as state
|
||||
# since this component is frequently unmounted and re-mounted every
|
||||
# time it is displayed
|
||||
enabledFields: React.PropTypes.array
|
||||
|
||||
# Callback for when a user changes which fields should be visible
|
||||
onChangeEnabledFields: React.PropTypes.func
|
||||
|
||||
# Callback for the participants change
|
||||
onChangeParticipants: React.PropTypes.func
|
||||
|
||||
@defaultProps:
|
||||
to: []
|
||||
cc: []
|
||||
bcc: []
|
||||
from: []
|
||||
enabledFields: []
|
||||
|
||||
constructor: (@props={}) ->
|
||||
|
||||
componentDidMount: =>
|
||||
@_applyFocusedField()
|
||||
|
||||
componentDidUpdate: ->
|
||||
@_applyFocusedField()
|
||||
|
||||
render: ->
|
||||
<div className="expanded-participants"
|
||||
ref="participantWrap">
|
||||
{@_renderFields()}
|
||||
</div>
|
||||
|
||||
_applyFocusedField: ->
|
||||
if @props.focusedField
|
||||
return unless @refs[@props.focusedField]
|
||||
if @refs[@props.focusedField].focus
|
||||
@refs[@props.focusedField].focus()
|
||||
else
|
||||
React.findDOMNode(@refs[@props.focusedField]).focus()
|
||||
|
||||
_renderFields: =>
|
||||
# Note: We need to physically add and remove these elements, not just hide them.
|
||||
# If they're hidden, shift-tab between fields breaks.
|
||||
fields = []
|
||||
fields.push(
|
||||
<div key="to">
|
||||
<div className="composer-participant-actions">
|
||||
{if Fields.Cc not in @props.enabledFields
|
||||
<span className="header-action show-cc"
|
||||
onClick={@_showAndFocusCc}>Cc</span>
|
||||
}
|
||||
|
||||
{ if Fields.Bcc not in @props.enabledFields
|
||||
<span className="header-action show-bcc"
|
||||
onClick={@_showAndFocusBcc}>Bcc</span>
|
||||
}
|
||||
|
||||
{ if Fields.Subject not in @props.enabledFields
|
||||
<span className="header-action show-subject"
|
||||
onClick={@_showAndFocusSubject}>Subject</span>
|
||||
}
|
||||
|
||||
{ if @props.mode is "inline"
|
||||
<span className="header-action show-popout"
|
||||
data-tooltip="Popout composer"
|
||||
style={paddingLeft: "1.5em"}
|
||||
onClick={@_popoutComposer}>
|
||||
<RetinaImg name="composer-popout.png"
|
||||
mode={RetinaImg.Mode.ContentIsMask}
|
||||
style={{position: "relative", top: "-2px"}}/>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<ParticipantsTextField
|
||||
ref={Fields.To}
|
||||
field='to'
|
||||
change={@props.onChangeParticipants}
|
||||
className="composer-participant-field to-field"
|
||||
participants={to: @props['to'], cc: @props['cc'], bcc: @props['bcc']} />
|
||||
</div>
|
||||
)
|
||||
|
||||
if Fields.Cc in @props.enabledFields
|
||||
fields.push(
|
||||
<ParticipantsTextField
|
||||
ref={Fields.Cc}
|
||||
key="cc"
|
||||
field='cc'
|
||||
change={@props.onChangeParticipants}
|
||||
onEmptied={@_onEmptyCc}
|
||||
className="composer-participant-field cc-field"
|
||||
participants={to: @props['to'], cc: @props['cc'], bcc: @props['bcc']} />
|
||||
)
|
||||
|
||||
if Fields.Bcc in @props.enabledFields
|
||||
fields.push(
|
||||
<ParticipantsTextField
|
||||
ref={Fields.Bcc}
|
||||
key="bcc"
|
||||
field='bcc'
|
||||
change={@props.onChangeParticipants}
|
||||
onEmptied={@_onEmptyBcc}
|
||||
className="composer-participant-field bcc-field"
|
||||
participants={to: @props['to'], cc: @props['cc'], bcc: @props['bcc']} />
|
||||
)
|
||||
|
||||
if Fields.From in @props.enabledFields
|
||||
fields.push(
|
||||
<AccountContactField
|
||||
key="from"
|
||||
ref={Fields.From}
|
||||
onChange={ (me) => @props.onChangeParticipants(from: [me]) }
|
||||
value={@props.from?[0]} />
|
||||
)
|
||||
|
||||
fields
|
||||
|
||||
_showAndFocusBcc: =>
|
||||
@props.onChangeEnabledFields
|
||||
show: [Fields.Bcc]
|
||||
focus: Fields.Bcc
|
||||
|
||||
_showAndFocusCc: =>
|
||||
@props.onChangeEnabledFields
|
||||
show: [Fields.Cc]
|
||||
focus: Fields.Cc
|
||||
|
||||
_showAndFocusSubject: =>
|
||||
@props.onChangeEnabledFields
|
||||
show: [Fields.Subject]
|
||||
focus: Fields.Subject
|
||||
|
||||
_onEmptyCc: =>
|
||||
@props.onChangeEnabledFields
|
||||
hide: [Fields.Cc]
|
||||
focus: Fields.To
|
||||
|
||||
_onEmptyBcc: =>
|
||||
if Fields.Cc in @props.enabledFields
|
||||
focus = Fields.Cc
|
||||
else
|
||||
focus = Fields.To
|
||||
@props.onChangeEnabledFields
|
||||
hide: [Fields.Bcc]
|
||||
focus: focus
|
||||
|
||||
module.exports = ExpandedParticipants
|
17
internal_packages/composer/lib/fields.cjsx
Normal file
17
internal_packages/composer/lib/fields.cjsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
Fields =
|
||||
To: "textFieldTo"
|
||||
Cc: "textFieldCc"
|
||||
Bcc: "textFieldBcc"
|
||||
From: "fromField"
|
||||
Subject: "textFieldSubject"
|
||||
Body: "contentBody"
|
||||
Fields.ParticipantFields = [Fields.To, Fields.Cc, Fields.Bcc, Fields.From]
|
||||
|
||||
Fields.Order =
|
||||
"textFieldTo": 1
|
||||
"textFieldCc": 2
|
||||
"textFieldBcc": 3
|
||||
"fromField": -1 # Not selectable
|
||||
"textFieldSubject": 5
|
||||
"contentBody": 6
|
||||
module.exports = Fields
|
|
@ -0,0 +1,50 @@
|
|||
_ = require "underscore"
|
||||
React = require "react/addons"
|
||||
Fields = require '../lib/fields'
|
||||
ReactTestUtils = React.addons.TestUtils
|
||||
CollapsedParticipants = require '../lib/collapsed-participants'
|
||||
|
||||
{Contact} = require 'nylas-exports'
|
||||
|
||||
describe "CollapsedParticipants", ->
|
||||
makeField = (props={}) ->
|
||||
@onClick = jasmine.createSpy("onClick")
|
||||
props.onClick = @onClick
|
||||
@fields = ReactTestUtils.renderIntoDocument(
|
||||
<CollapsedParticipants {...props} />
|
||||
)
|
||||
|
||||
it "fires callback when clicked", ->
|
||||
makeField.call(@)
|
||||
ReactTestUtils.Simulate.click React.findDOMNode(@fields)
|
||||
expect(@onClick).toHaveBeenCalled()
|
||||
expect(@onClick.calls.length).toBe 1
|
||||
|
||||
numStr = ->
|
||||
React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(@fields, "num-remaining")).innerHTML
|
||||
|
||||
it "doesn't render num remaining when nothing remains", ->
|
||||
makeField.call(@)
|
||||
els = ReactTestUtils.scryRenderedDOMComponentsWithClass(@fields, "num-remaining")
|
||||
expect(els.length).toBe 0
|
||||
|
||||
it "renders num remaining when remaining with no bcc", ->
|
||||
makeField.call(@)
|
||||
spyOn(@fields, "_setNumHiddenParticipants")
|
||||
@fields.setState numRemaining: 10, numBccRemaining: 0
|
||||
str = numStr.call(@)
|
||||
expect(str).toBe "10 more"
|
||||
|
||||
it "renders num remaining when only bcc", ->
|
||||
makeField.call(@)
|
||||
spyOn(@fields, "_setNumHiddenParticipants")
|
||||
@fields.setState numRemaining: 0, numBccRemaining: 5
|
||||
str = numStr.call(@)
|
||||
expect(str).toBe "5 Bcc"
|
||||
|
||||
it "renders num remaining when both remaining andj bcc", ->
|
||||
makeField.call(@)
|
||||
spyOn(@fields, "_setNumHiddenParticipants")
|
||||
@fields.setState numRemaining: 10, numBccRemaining: 5
|
||||
str = numStr.call(@)
|
||||
expect(str).toBe "15 more (5 Bcc)"
|
|
@ -20,6 +20,7 @@ ReactTestUtils = React.addons.TestUtils
|
|||
{InjectedComponent} = require 'nylas-component-kit'
|
||||
|
||||
ParticipantsTextField = require '../lib/participants-text-field'
|
||||
Fields = require '../lib/fields'
|
||||
|
||||
u1 = new Contact(name: "Christine Spang", email: "spang@nylas.com")
|
||||
u2 = new Contact(name: "Michael Grinich", email: "mg@nylas.com")
|
||||
|
@ -27,7 +28,8 @@ u3 = new Contact(name: "Evan Morikawa", email: "evan@nylas.com")
|
|||
u4 = new Contact(name: "Zoë Leiper", email: "zip@nylas.com")
|
||||
u5 = new Contact(name: "Ben Gotow", email: "ben@nylas.com")
|
||||
|
||||
file = new File(id: 'file_1_id', filename: 'a.png', contentType: 'image/png', size: 10, object: "file")
|
||||
f1 = new File(id: 'file_1_id', filename: 'a.png', contentType: 'image/png', size: 10, object: "file")
|
||||
f2 = new File(id: 'file_2_id', filename: 'b.pdf', contentType: '', size: 999999, object: "file")
|
||||
|
||||
users = [u1, u2, u3, u4, u5]
|
||||
|
||||
|
@ -69,18 +71,6 @@ ComposerView = proxyquire "../lib/composer-view",
|
|||
isValidContact: isValidContactStub
|
||||
DraftStore: DraftStore
|
||||
|
||||
beforeEach ->
|
||||
# The AccountStore isn't set yet in the new window, populate it first.
|
||||
AccountStore.populateItems().then ->
|
||||
draft = new Message
|
||||
from: [AccountStore.current().me()]
|
||||
date: (new Date)
|
||||
draft: true
|
||||
accountId: AccountStore.current().id
|
||||
|
||||
DatabaseStore.persistModel(draft).then ->
|
||||
return draft
|
||||
|
||||
describe "A blank composer view", ->
|
||||
beforeEach ->
|
||||
@composer = ReactTestUtils.renderIntoDocument(
|
||||
|
@ -128,6 +118,7 @@ useFullDraft = ->
|
|||
to: [u2]
|
||||
cc: [u3, u4]
|
||||
bcc: [u5]
|
||||
files: [f1, f2]
|
||||
subject: "Test Message 1"
|
||||
body: "Hello <b>World</b><br/> This is a test"
|
||||
replyToMessageId: null
|
||||
|
@ -221,180 +212,174 @@ describe "populated composer", ->
|
|||
expect(@draft).toBeDefined()
|
||||
expect(@composer._proxy.draft()).toBe @draft
|
||||
|
||||
it "set the state based on the draft", ->
|
||||
it "sets the basic draft state", ->
|
||||
expect(@composer.state.from).toEqual [u1]
|
||||
expect(@composer.state.showfrom).toEqual true
|
||||
expect(@composer.state.to).toEqual [u2]
|
||||
expect(@composer.state.cc).toEqual [u3, u4]
|
||||
expect(@composer.state.bcc).toEqual [u5]
|
||||
expect(@composer.state.subject).toEqual "Test Message 1"
|
||||
expect(@composer.state.files).toEqual [f1, f2]
|
||||
expect(@composer.state.body).toEqual "Hello <b>World</b><br/> This is a test"
|
||||
|
||||
describe "when deciding whether or not to show the subject", ->
|
||||
it "shows the subject when the subject is empty", ->
|
||||
it "sets first-time initial state about focused fields", ->
|
||||
expect(@composer.state.populated).toBe true
|
||||
expect(@composer.state.focusedField).toBeDefined()
|
||||
expect(@composer.state.enabledFields).toBeDefined()
|
||||
|
||||
it "sets first-time initial state about showing quoted text", ->
|
||||
expect(@composer.state.showQuotedText).toBe false
|
||||
|
||||
describe "deciding which field is initially focused", ->
|
||||
it "focuses the To field if there's nobody in the 'to' field", ->
|
||||
useDraft.call @
|
||||
makeComposer.call @
|
||||
expect(@composer.state.focusedField).toBe Fields.To
|
||||
|
||||
it "focuses the subject if there's no subject already", ->
|
||||
useDraft.call @, to: [u1]
|
||||
makeComposer.call @
|
||||
expect(@composer.state.focusedField).toBe Fields.Subject
|
||||
|
||||
it "focuses the body otherwise", ->
|
||||
useDraft.call @, to: [u1], subject: "Yo"
|
||||
makeComposer.call @
|
||||
expect(@composer.state.focusedField).toBe Fields.Body
|
||||
|
||||
describe "when deciding whether or not to enable the subject", ->
|
||||
it "enables the subject when the subject is empty", ->
|
||||
useDraft.call @, subject: ""
|
||||
makeComposer.call @
|
||||
expect(@composer._shouldShowSubject()).toBe true
|
||||
expect(@composer._shouldEnableSubject()).toBe true
|
||||
|
||||
it "shows the subject when the subject looks like a fwd", ->
|
||||
it "enables the subject when the subject looks like a fwd", ->
|
||||
useDraft.call @, subject: "Fwd: This is the message"
|
||||
makeComposer.call @
|
||||
expect(@composer._shouldShowSubject()).toBe true
|
||||
expect(@composer._shouldEnableSubject()).toBe true
|
||||
|
||||
it "shows the subject when the subject looks like a fwd", ->
|
||||
it "enables the subject when the subject looks like a fwd", ->
|
||||
useDraft.call @, subject: "fwd foo"
|
||||
makeComposer.call @
|
||||
expect(@composer._shouldShowSubject()).toBe true
|
||||
expect(@composer._shouldEnableSubject()).toBe true
|
||||
|
||||
it "doesn't show subject when replyToMessageId exists", ->
|
||||
it "doesn't enable subject when replyToMessageId exists", ->
|
||||
useDraft.call @, subject: "should hide", replyToMessageId: "some-id"
|
||||
makeComposer.call @
|
||||
expect(@composer._shouldShowSubject()).toBe false
|
||||
expect(@composer._shouldEnableSubject()).toBe false
|
||||
|
||||
it "shows the subject otherwise", ->
|
||||
it "enables the subject otherwise", ->
|
||||
useDraft.call @, subject: "Foo bar baz"
|
||||
makeComposer.call @
|
||||
expect(@composer._shouldShowSubject()).toBe true
|
||||
expect(@composer._shouldEnableSubject()).toBe true
|
||||
|
||||
describe "when deciding whether or not to show cc and bcc", ->
|
||||
it "doesn't show cc when there's no one to cc", ->
|
||||
describe "when deciding whether or not to enable cc and bcc", ->
|
||||
it "doesn't enable cc when there's no one to cc", ->
|
||||
useDraft.call @, cc: []
|
||||
makeComposer.call @
|
||||
expect(@composer.state.showcc).toBe false
|
||||
expect(@composer.state.enabledFields).not.toContain Fields.Cc
|
||||
|
||||
it "shows cc when populated", ->
|
||||
it "enables cc when populated", ->
|
||||
useDraft.call @, cc: [u1,u2]
|
||||
makeComposer.call @
|
||||
expect(@composer.state.showcc).toBe true
|
||||
expect(@composer.state.enabledFields).toContain Fields.Cc
|
||||
|
||||
it "doesn't show bcc when there's no one to bcc", ->
|
||||
it "doesn't enable bcc when there's no one to bcc", ->
|
||||
useDraft.call @, bcc: []
|
||||
makeComposer.call @
|
||||
expect(@composer.state.showbcc).toBe false
|
||||
expect(@composer.state.enabledFields).not.toContain Fields.Bcc
|
||||
|
||||
it "shows bcc when populated", ->
|
||||
it "enables bcc when populated", ->
|
||||
useDraft.call @, bcc: [u2,u3]
|
||||
makeComposer.call @
|
||||
expect(@composer.state.showbcc).toBe true
|
||||
expect(@composer.state.enabledFields).toContain Fields.Bcc
|
||||
|
||||
describe "when focus() is called", ->
|
||||
describe "if a field name is provided", ->
|
||||
it "should focus that field", ->
|
||||
useDraft.call(@, cc: [u2])
|
||||
makeComposer.call(@)
|
||||
spyOn(@composer.refs['textFieldCc'], 'focus')
|
||||
@composer.focus('textFieldCc')
|
||||
advanceClock(1000)
|
||||
expect(@composer.refs['textFieldCc'].focus).toHaveBeenCalled()
|
||||
describe "when deciding whether or not to enable the from field", ->
|
||||
it "disables if there's no draft", ->
|
||||
useDraft.call @
|
||||
makeComposer.call @
|
||||
expect(@composer._shouldShowFromField()).toBe false
|
||||
|
||||
describe "if the draft is a forward", ->
|
||||
it "should focus the to field", ->
|
||||
useDraft.call(@, {subject: 'Fwd: This is a test'})
|
||||
makeComposer.call(@)
|
||||
spyOn(@composer.refs['textFieldTo'], 'focus')
|
||||
@composer.focus()
|
||||
advanceClock(1000)
|
||||
expect(@composer.refs['textFieldTo'].focus).toHaveBeenCalled()
|
||||
it "disables if there's 1 account item", ->
|
||||
spyOn(AccountStore, 'items').andCallFake -> [{id: 1}]
|
||||
useDraft.call @, replyToMessageId: null, files: []
|
||||
makeComposer.call @
|
||||
expect(@composer.state.enabledFields).not.toContain Fields.From
|
||||
|
||||
describe "if the draft is a normal message", ->
|
||||
it "should focus on the body", ->
|
||||
useDraft.call(@)
|
||||
makeComposer.call(@)
|
||||
spyOn(@composer.refs['contentBody'], 'focus')
|
||||
@composer.focus()
|
||||
advanceClock(1000)
|
||||
expect(@composer.refs['contentBody'].focus).toHaveBeenCalled()
|
||||
it "disables if it's a reply-to message", ->
|
||||
spyOn(AccountStore, 'items').andCallFake -> [{id: 1}, {id: 2}]
|
||||
useDraft.call @, replyToMessageId: "local-123", files: []
|
||||
makeComposer.call @
|
||||
expect(@composer.state.enabledFields).not.toContain Fields.From
|
||||
|
||||
describe "if the draft has not yet loaded", ->
|
||||
it "should set _focusOnUpdate and focus after the next render", ->
|
||||
@draft = new Message(draft: true, body: "")
|
||||
proxy = draftStoreProxyStub(DRAFT_CLIENT_ID, @draft)
|
||||
proxyResolve = null
|
||||
spyOn(DraftStore, "sessionForClientId").andCallFake ->
|
||||
new Promise (resolve, reject) ->
|
||||
proxyResolve = resolve
|
||||
it "disables if there are attached files", ->
|
||||
spyOn(AccountStore, 'items').andCallFake -> [{id: 1}, {id: 2}]
|
||||
useDraft.call @, replyToMessageId: null, files: [f1]
|
||||
makeComposer.call @
|
||||
expect(@composer.state.enabledFields).not.toContain Fields.From
|
||||
|
||||
makeComposer.call(@)
|
||||
it "enables if requirements are met", ->
|
||||
a1 = new Account()
|
||||
a2 = new Account()
|
||||
spyOn(AccountStore, 'items').andCallFake -> [a1, a2]
|
||||
useDraft.call @, replyToMessageId: null, files: []
|
||||
makeComposer.call @
|
||||
expect(@composer.state.enabledFields).toContain Fields.From
|
||||
|
||||
spyOn(@composer.refs['contentBody'], 'focus')
|
||||
@composer.focus()
|
||||
advanceClock(1000)
|
||||
expect(@composer.refs['contentBody'].focus).not.toHaveBeenCalled()
|
||||
|
||||
proxyResolve(proxy)
|
||||
|
||||
advanceClock(1000)
|
||||
expect(@composer.refs['contentBody'].focus).toHaveBeenCalled()
|
||||
|
||||
describe "when emptying cc/bcc fields", ->
|
||||
|
||||
it "focuses on to when bcc is emptied and there's no cc field", ->
|
||||
useDraft.call(@, bcc: [u1])
|
||||
describe "when enabling fields", ->
|
||||
it "always enables the To and Body fields on empty composers", ->
|
||||
useDraft.apply @
|
||||
makeComposer.call(@)
|
||||
spyOn(@composer.refs['textFieldTo'], 'focus')
|
||||
spyOn(@composer.refs['textFieldBcc'], 'focus')
|
||||
expect(@composer.state.enabledFields).toContain Fields.To
|
||||
expect(@composer.state.enabledFields).toContain Fields.Body
|
||||
|
||||
bcc = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@composer, ParticipantsTextField, field: "bcc")[0]
|
||||
@draft.bcc = []
|
||||
bcc.props.onEmptied()
|
||||
|
||||
expect(@composer.state.showbcc).toBe false
|
||||
advanceClock(1000)
|
||||
expect(@composer.refs['textFieldTo'].focus).toHaveBeenCalled()
|
||||
expect(@composer.refs['textFieldCc']).not.toBeDefined()
|
||||
expect(@composer.refs['textFieldBcc']).not.toBeDefined()
|
||||
|
||||
it "focuses on cc when bcc is emptied and cc field is available", ->
|
||||
useDraft.call(@, cc: [u2], bcc: [u1])
|
||||
it "always enables the To and Body fields on full composers", ->
|
||||
useFullDraft.apply(@)
|
||||
makeComposer.call(@)
|
||||
spyOn(@composer.refs['textFieldTo'], 'focus')
|
||||
spyOn(@composer.refs['textFieldCc'], 'focus')
|
||||
spyOn(@composer.refs['textFieldBcc'], 'focus')
|
||||
expect(@composer.state.enabledFields).toContain Fields.To
|
||||
expect(@composer.state.enabledFields).toContain Fields.Body
|
||||
|
||||
bcc = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@composer, ParticipantsTextField, field: "bcc")[0]
|
||||
@draft.bcc = []
|
||||
bcc.props.onEmptied()
|
||||
expect(@composer.state.showbcc).toBe false
|
||||
advanceClock(1000)
|
||||
expect(@composer.refs['textFieldTo'].focus).not.toHaveBeenCalled()
|
||||
expect(@composer.refs['textFieldCc'].focus).toHaveBeenCalled()
|
||||
expect(@composer.refs['textFieldBcc']).not.toBeDefined()
|
||||
|
||||
it "focuses on to when cc is emptied", ->
|
||||
useDraft.call(@, cc: [u1], bcc: [u2])
|
||||
describe "applying the focused field", ->
|
||||
beforeEach ->
|
||||
useFullDraft.apply(@)
|
||||
makeComposer.call(@)
|
||||
spyOn(@composer.refs['textFieldTo'], 'focus')
|
||||
spyOn(@composer.refs['textFieldCc'], 'focus')
|
||||
spyOn(@composer.refs['textFieldBcc'], 'focus')
|
||||
@composer.setState focusedField: Fields.Cc
|
||||
@body = @composer.refs[Fields.Body]
|
||||
spyOn(@body, "focus")
|
||||
spyOn(React, "findDOMNode").andCallThrough()
|
||||
|
||||
cc = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@composer, ParticipantsTextField, field: "cc")[0]
|
||||
@draft.cc = []
|
||||
cc.props.onEmptied()
|
||||
expect(@composer.state.showcc).toBe false
|
||||
advanceClock(1000)
|
||||
expect(@composer.refs['textFieldTo'].focus).toHaveBeenCalled()
|
||||
expect(@composer.refs['textFieldCc']).not.toBeDefined()
|
||||
expect(@composer.refs['textFieldBcc'].focus).not.toHaveBeenCalled()
|
||||
it "can focus on the subject", ->
|
||||
@composer.setState focusedField: Fields.Subject
|
||||
expect(React.findDOMNode).toHaveBeenCalled()
|
||||
expect(React.findDOMNode.calls.length).toBe 3
|
||||
|
||||
it "can focus on the body", ->
|
||||
@composer.setState focusedField: Fields.Body
|
||||
expect(@body.focus).toHaveBeenCalled()
|
||||
expect(@body.focus.calls.length).toBe 1
|
||||
|
||||
it "ignores focuses to participant fields", ->
|
||||
@composer.setState focusedField: Fields.To
|
||||
expect(@body.focus).not.toHaveBeenCalled()
|
||||
expect(React.findDOMNode.calls.length).toBe 1
|
||||
|
||||
describe "when participants are added during a draft update", ->
|
||||
it "shows the cc fields and bcc fields to ensure participants are never hidden", ->
|
||||
useDraft.call(@, cc: [], bcc: [])
|
||||
makeComposer.call(@)
|
||||
expect(@composer.state.showbcc).toBe(false)
|
||||
expect(@composer.state.showcc).toBe(false)
|
||||
expect(@composer.state.enabledFields).not.toContain Fields.Bcc
|
||||
expect(@composer.state.enabledFields).not.toContain Fields.Cc
|
||||
|
||||
# Simulate a change event fired by the DraftStoreProxy
|
||||
@draft.cc = [u1]
|
||||
@composer._onDraftChanged()
|
||||
|
||||
expect(@composer.state.showbcc).toBe(false)
|
||||
expect(@composer.state.showcc).toBe(true)
|
||||
expect(@composer.state.enabledFields).not.toContain Fields.Bcc
|
||||
expect(@composer.state.enabledFields).toContain Fields.Cc
|
||||
|
||||
# Simulate a change event fired by the DraftStoreProxy
|
||||
@draft.bcc = [u2]
|
||||
@composer._onDraftChanged()
|
||||
expect(@composer.state.showbcc).toBe(true)
|
||||
expect(@composer.state.showcc).toBe(true)
|
||||
expect(@composer.state.enabledFields).toContain Fields.Bcc
|
||||
expect(@composer.state.enabledFields).toContain Fields.Cc
|
||||
|
||||
describe "When sending a message", ->
|
||||
beforeEach ->
|
||||
|
@ -459,7 +444,7 @@ describe "populated composer", ->
|
|||
to: [u1]
|
||||
subject: "Hello World"
|
||||
body: ""
|
||||
files: [file]
|
||||
files: [f1]
|
||||
makeComposer.call(@)
|
||||
@composer._sendDraft()
|
||||
expect(Actions.sendDraft).toHaveBeenCalled()
|
||||
|
@ -509,7 +494,7 @@ describe "populated composer", ->
|
|||
subject: "Subject"
|
||||
to: [u1]
|
||||
body: "Check out attached file"
|
||||
files: [file]
|
||||
files: [f1]
|
||||
makeComposer.call(@); @composer._sendDraft()
|
||||
expect(Actions.sendDraft).toHaveBeenCalled()
|
||||
expect(@dialog.showMessageBox).not.toHaveBeenCalled()
|
||||
|
@ -542,26 +527,23 @@ describe "populated composer", ->
|
|||
beforeEach ->
|
||||
useFullDraft.apply(@)
|
||||
makeComposer.call(@)
|
||||
spyOn(@composer, "_sendDraft")
|
||||
NylasTestUtils.loadKeymap("internal_packages/composer/keymaps/composer")
|
||||
|
||||
it "sends the draft on cmd-enter", ->
|
||||
NylasTestUtils.keyPress("cmd-enter", React.findDOMNode(@composer))
|
||||
expect(@composer._sendDraft).toHaveBeenCalled()
|
||||
expect(Actions.sendDraft).toHaveBeenCalled()
|
||||
|
||||
it "does not send the draft on enter if the button isn't in focus", ->
|
||||
NylasTestUtils.keyPress("enter", React.findDOMNode(@composer))
|
||||
expect(@composer._sendDraft).not.toHaveBeenCalled()
|
||||
|
||||
it "sends the draft on enter when the button is in focus", ->
|
||||
sendBtn = ReactTestUtils.findRenderedDOMComponentWithClass(@composer, "btn-send")
|
||||
NylasTestUtils.keyPress("enter", React.findDOMNode(sendBtn))
|
||||
expect(@composer._sendDraft).toHaveBeenCalled()
|
||||
expect(Actions.sendDraft).not.toHaveBeenCalled()
|
||||
|
||||
it "doesn't let you send twice", ->
|
||||
sendBtn = ReactTestUtils.findRenderedDOMComponentWithClass(@composer, "btn-send")
|
||||
NylasTestUtils.keyPress("enter", React.findDOMNode(sendBtn))
|
||||
expect(@composer._sendDraft).toHaveBeenCalled()
|
||||
NylasTestUtils.keyPress("cmd-enter", React.findDOMNode(@composer))
|
||||
@isSending.state = true
|
||||
DraftStore.trigger()
|
||||
NylasTestUtils.keyPress("cmd-enter", React.findDOMNode(@composer))
|
||||
expect(Actions.sendDraft).toHaveBeenCalled()
|
||||
expect(Actions.sendDraft.calls.length).toBe 1
|
||||
|
||||
describe "drag and drop", ->
|
||||
beforeEach ->
|
||||
|
@ -569,7 +551,7 @@ describe "populated composer", ->
|
|||
to: [u1]
|
||||
subject: "Hello World"
|
||||
body: ""
|
||||
files: [file]
|
||||
files: [f1]
|
||||
makeComposer.call(@)
|
||||
|
||||
describe "_shouldAcceptDrop", ->
|
||||
|
|
108
internal_packages/composer/spec/expanded-participants-spec.cjsx
Normal file
108
internal_packages/composer/spec/expanded-participants-spec.cjsx
Normal file
|
@ -0,0 +1,108 @@
|
|||
_ = require "underscore"
|
||||
React = require "react/addons"
|
||||
Fields = require '../lib/fields'
|
||||
ReactTestUtils = React.addons.TestUtils
|
||||
AccountContactField = require '../lib/account-contact-field'
|
||||
ExpandedParticipants = require '../lib/expanded-participants'
|
||||
|
||||
describe "ExpandedParticipants", ->
|
||||
makeField = (props={}) ->
|
||||
@onChangeParticipants = jasmine.createSpy("onChangeParticipants")
|
||||
@onChangeEnabledFields = jasmine.createSpy("onChangeEnabledFields")
|
||||
props.onChangeParticipants = @onChangeParticipants
|
||||
props.onChangeEnabledFields = @onChangeEnabledFields
|
||||
@fields = ReactTestUtils.renderIntoDocument(
|
||||
<ExpandedParticipants {...props} />
|
||||
)
|
||||
|
||||
it "always renders to field", ->
|
||||
makeField.call(@)
|
||||
el = ReactTestUtils.findRenderedDOMComponentWithClass(@fields, "to-field")
|
||||
expect(el).toBeDefined()
|
||||
|
||||
it "renders cc when enabled", ->
|
||||
makeField.call(@, enabledFields: [Fields.Cc])
|
||||
el = ReactTestUtils.findRenderedDOMComponentWithClass(@fields, "cc-field")
|
||||
expect(el).toBeDefined()
|
||||
|
||||
it "renders bcc when enabled", ->
|
||||
makeField.call(@, enabledFields: [Fields.Bcc])
|
||||
el = ReactTestUtils.findRenderedDOMComponentWithClass(@fields, "bcc-field")
|
||||
expect(el).toBeDefined()
|
||||
|
||||
it "renders from when enabled", ->
|
||||
makeField.call(@, enabledFields: [Fields.From])
|
||||
el = ReactTestUtils.findRenderedComponentWithType(@fields, AccountContactField)
|
||||
expect(el).toBeDefined()
|
||||
|
||||
it "renders all 'show' fields", ->
|
||||
makeField.call(@)
|
||||
showCc = ReactTestUtils.findRenderedDOMComponentWithClass(@fields, "show-cc")
|
||||
showBcc = ReactTestUtils.findRenderedDOMComponentWithClass(@fields, "show-bcc")
|
||||
showSubject = ReactTestUtils.findRenderedDOMComponentWithClass(@fields, "show-subject")
|
||||
expect(showCc).toBeDefined()
|
||||
expect(showBcc).toBeDefined()
|
||||
expect(showSubject).toBeDefined()
|
||||
|
||||
it "hides show cc if it's enabled", ->
|
||||
makeField.call(@, enabledFields: [Fields.Cc])
|
||||
els = ReactTestUtils.scryRenderedDOMComponentsWithClass(@fields, "show-cc")
|
||||
expect(els.length).toBe 0
|
||||
|
||||
it "hides show bcc if it's enabled", ->
|
||||
makeField.call(@, enabledFields: [Fields.Bcc])
|
||||
els = ReactTestUtils.scryRenderedDOMComponentsWithClass(@fields, "show-bcc")
|
||||
expect(els.length).toBe 0
|
||||
|
||||
it "hides show subject if it's enabled", ->
|
||||
makeField.call(@, enabledFields: [Fields.Subject])
|
||||
els = ReactTestUtils.scryRenderedDOMComponentsWithClass(@fields, "show-subject")
|
||||
expect(els.length).toBe 0
|
||||
|
||||
it "renders popout composer in the inline mode", ->
|
||||
makeField.call(@, mode: "inline")
|
||||
els = ReactTestUtils.scryRenderedDOMComponentsWithClass(@fields, "show-popout")
|
||||
expect(els.length).toBe 1
|
||||
|
||||
it "doesn't render popout composer in the fullwindow mode", ->
|
||||
makeField.call(@, mode: "fullwindow")
|
||||
els = ReactTestUtils.scryRenderedDOMComponentsWithClass(@fields, "show-popout")
|
||||
expect(els.length).toBe 0
|
||||
|
||||
it "shows and focuses cc when clicked", ->
|
||||
makeField.call(@)
|
||||
el = ReactTestUtils.findRenderedDOMComponentWithClass(@fields, "show-cc")
|
||||
ReactTestUtils.Simulate.click(React.findDOMNode(el))
|
||||
expect(@onChangeEnabledFields).toHaveBeenCalledWith show: [Fields.Cc], focus: Fields.Cc
|
||||
|
||||
it "shows and focuses bcc when clicked", ->
|
||||
makeField.call(@)
|
||||
el = ReactTestUtils.findRenderedDOMComponentWithClass(@fields, "show-bcc")
|
||||
ReactTestUtils.Simulate.click(React.findDOMNode(el))
|
||||
expect(@onChangeEnabledFields).toHaveBeenCalledWith show: [Fields.Bcc], focus: Fields.Bcc
|
||||
|
||||
it "shows subject when clicked", ->
|
||||
makeField.call(@)
|
||||
el = ReactTestUtils.findRenderedDOMComponentWithClass(@fields, "show-subject")
|
||||
ReactTestUtils.Simulate.click(React.findDOMNode(el))
|
||||
expect(@onChangeEnabledFields).toHaveBeenCalledWith show: [Fields.Subject], focus: Fields.Subject
|
||||
|
||||
it "empties cc and focuses on to field", ->
|
||||
makeField.call(@, enabledFields: [Fields.Cc, Fields.Bcc, Fields.Subject])
|
||||
@fields.refs[Fields.Cc].props.onEmptied()
|
||||
expect(@onChangeEnabledFields).toHaveBeenCalledWith hide: [Fields.Cc], focus: Fields.To
|
||||
|
||||
it "empties bcc and focuses on to field", ->
|
||||
makeField.call(@, enabledFields: [Fields.Cc, Fields.Bcc, Fields.Subject])
|
||||
@fields.refs[Fields.Bcc].props.onEmptied()
|
||||
expect(@onChangeEnabledFields).toHaveBeenCalledWith hide: [Fields.Bcc], focus: Fields.Cc
|
||||
|
||||
it "empties bcc and focuses on cc field", ->
|
||||
makeField.call(@, enabledFields: [Fields.Bcc, Fields.Subject])
|
||||
@fields.refs[Fields.Bcc].props.onEmptied()
|
||||
expect(@onChangeEnabledFields).toHaveBeenCalledWith hide: [Fields.Bcc], focus: Fields.To
|
||||
|
||||
it "notifies when participants change", ->
|
||||
makeField.call(@, enabledFields: [Fields.Cc, Fields.Bcc, Fields.Subject])
|
||||
@fields.refs[Fields.Cc].props.change()
|
||||
expect(@onChangeParticipants).toHaveBeenCalled()
|
|
@ -77,7 +77,7 @@
|
|||
width: 100%;
|
||||
max-width: @compose-width;
|
||||
margin: 0 auto;
|
||||
margin-top:@spacing-standard;
|
||||
padding-top:@spacing-standard;
|
||||
}
|
||||
.text-actions {
|
||||
text-align: right;
|
||||
|
@ -125,6 +125,49 @@
|
|||
display: block;
|
||||
}
|
||||
|
||||
.collapsed-composer-participants {
|
||||
position: relative;
|
||||
margin: 0 23px;
|
||||
border-bottom: 1px solid @border-color-divider;
|
||||
flex-shrink:0;
|
||||
color: @text-color-very-subtle;
|
||||
padding: 16px 0 10px 0;
|
||||
|
||||
.collapsed-contact {
|
||||
padding-right: 0.25em;
|
||||
&:after {
|
||||
content: ","
|
||||
}
|
||||
&:last-child:after {
|
||||
content: ""
|
||||
}
|
||||
}
|
||||
|
||||
.num-remaining.token {
|
||||
color: @text-color;
|
||||
color: rgba(0,0,0,0.6);
|
||||
padding-right: 12px;
|
||||
margin-left: 0;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.num-remaining-wrap {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
top: 9px;
|
||||
.show-more-fade {
|
||||
position: absolute;
|
||||
width: 220px;
|
||||
height: 37px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
background: linear-gradient(to right, fade(@background-primary, 0%) 0%, fade(@background-primary, 100%) 40%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.compose-subject-wrap {
|
||||
position: relative;
|
||||
margin: 0 23px;
|
||||
|
@ -149,6 +192,10 @@
|
|||
|
||||
.compose-body-scroll {
|
||||
position:initial;
|
||||
.scroll-region-content .scroll-region-content-inner {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.compose-body {
|
||||
|
@ -275,7 +322,7 @@
|
|||
margin: 0 8+@spacing-standard;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid @border-color-divider;
|
||||
min-height: 48px;
|
||||
min-height: 49px;
|
||||
|
||||
.button-dropdown {
|
||||
margin-left: 10px;
|
||||
|
|
Loading…
Reference in a new issue