diff --git a/internal_packages/composer/lib/composer-editor.jsx b/internal_packages/composer/lib/composer-editor.jsx new file mode 100644 index 000000000..701e4dcec --- /dev/null +++ b/internal_packages/composer/lib/composer-editor.jsx @@ -0,0 +1,231 @@ +import React, {Component, PropTypes} from 'react'; +import {ExtensionRegistry, DOMUtils} from 'nylas-exports'; +import {ScrollRegion, Contenteditable} from 'nylas-component-kit'; + +/** + * Renders the text editor for the composer + * Any component registering in the ComponentRegistry with the role + * 'Composer:Editor' will receive these set of props. + * + * In order for the Composer to work correctly and have a complete set of + * functionality (like file pasting), any registered editor *must* call the + * provided callbacks at the appropriate time. + * + * @param {object} props - props for ComposerEditor + * @param {string} props.body - Html string with the draft content to be + * rendered by the editor + * @param {string} props.draftClientId - Id of the draft being currently edited + * @param {object} props.initialSelectionSnapshot - Initial content selection + * that was previously saved + * @param {object} props.parentActions - Object containg helper actions + * associated with the parent container + * @param {props.parentActions.getComposerBoundingRect} props.parentActions.getComposerBoundingRect + * @param {props.parentActions.scrollTo} props.parentActions.scrollTo + * @param {props.onFocus} props.onFocus + * @param {props.onFilePaste} props.onFilePaste + * @param {props.onBodyChanged} props.onBodyChanged + * @class ComposerEditor + */ +class ComposerEditor extends Component { + static displayName = 'ComposerEditor' + + /** + * This function will return the {DOMRect} for the parent component + * @function + * @name props.parentActions.getComposerBoundingRect + */ + /** + * This function will make the screen scrollTo the desired position in the + * message list + * @function + * @name props.parentActions.scrollTo + * @param {object} options + * @param {string} options.clientId - Id of the message we want to scroll to + * @param {string} [options.positon] - If clientId is provided, this optional + * parameter will indicate what position of the message to scrollTo. See + * {ScrollRegion} + * @param {DOMRect} options.rect - Bounding rect we want to scroll to + */ + /** + * This function should be called when the editing region is focused by the user + * @callback props.onFocus + */ + /** + * This function should be called when the user pastes a file into the editing + * region + * @callback props.onFilePaste + */ + /** + * This function should be called when the body of the draft changes, i.e. + * when the editor is being typed into. It should pass in an object that looks + * like a DOM Event with the current value of the content. + * @callback props.onBodyChanged + * @param {object} event - DOMEvent-like object that contains information + * about the current value of the body + * @param {string} event.target.value - HTML string that represents the + * current content of the editor body + */ + static propTypes = { + body: PropTypes.string.isRequired, + draftClientId: PropTypes.string, + initialSelectionSnapshot: PropTypes.object, + onFocus: PropTypes.func.isRequired, + onFilePaste: PropTypes.func.isRequired, + onBodyChanged: PropTypes.func.isRequired, + parentActions: PropTypes.shape({ + scrollTo: PropTypes.func, + getComposerBoundingRect: PropTypes.func, + }), + } + + constructor(props) { + super(props); + this.state = { + extensions: ExtensionRegistry.Composer.extensions(), + }; + this._coreExtension = { + onFocus: props.onFocus, + }; + } + + componentDidMount() { + this.unsub = ExtensionRegistry.Composer.listen(this._onExtensionsChanged); + } + + componentWillUnmount() { + this.unsub(); + } + + + // Public methods + + /** + * @private + * Methods in ES6 classes should be defined using object method shorthand + * syntax (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Method_definitions), + * as opposed to arrow function syntax, if we want them to be enumerated + * on the prototype. Arrow function syntax is used to lexically bind the `this` + * value, and are specialized for non-method callbacks, where them picking up + * the this of their surrounding method or constructor is an advantage. + * See https://goo.gl/9ZMOGl for an example + * and http://www.2ality.com/2015/02/es6-classes-final.html for more info. + */ + + // TODO Get rid of these selection methods + getCurrentSelection() { + return this.refs.contenteditable.getCurrentSelection(); + } + + getPreviousSelection() { + return this.refs.contenteditable.getPreviousSelection(); + } + + focusEditor() { + this.refs.contenteditable.selectEnd(); + } + + /** + * @private + * This method was included so that the tests don't break + * TODO refactor the tests! + */ + _onDOMMutated(mutations) { + this.refs.contenteditable._onDOMMutated(mutations); + } + + + // Helpers + + _scrollToBottom = ()=> { + this.props.parentActions.scrollTo({ + clientId: this.props.draftClientId, + position: ScrollRegion.ScrollPosition.Bottom, + }); + } + + /** + * @private + * If the bottom of the container we're scrolling to is really far away + * from the contenteditable and your scroll position, we don't want to + * jump away. This can commonly happen if the composer has a very tall + * image attachment. The "send" button may be 1000px away from the bottom + * of the contenteditable. props.parentActions.scrollToBottom moves to the bottom of + * the "send" button. + */ + _bottomIsNearby = (editableNode)=> { + const parentRect = this.props.parentActions.getComposerBoundingRect(); + const selfRect = editableNode.getBoundingClientRect(); + return Math.abs(parentRect.bottom - selfRect.bottom) <= 250; + } + + /** + * @private + * As you're typing a lot of content and the cursor begins to scroll off + * to the bottom, we want to make it look like we're tracking your + * typing. + */ + _shouldScrollToBottom(selection, editableNode) { + return ( + this.props.parentActions.scrollTo != null && + DOMUtils.atEndOfContent(selection, editableNode) && + this._bottomIsNearby(editableNode) + ); + } + + /** + * @private + * When the selectionState gets set (e.g. undo-ing and + * redo-ing) we need to make sure it's visible to the user. + * + * Unfortunately, we can't use the native `scrollIntoView` because it + * naively scrolls the whole window and doesn't know not to scroll if + * it's already in view. There's a new native method called + * `scrollIntoViewIfNeeded`, but this only works when the scroll + * container is a direct parent of the requested element. In this case + * the scroll container may be many levels up. + */ + _ensureSelectionVisible = (selection, editableNode)=> { + // If our parent supports scroll, check for that + if (this._shouldScrollToBottom(selection, editableNode)) { + this._scrollToBottom(); + } else if (this.props.parentActions.scrollTo != null) { + // Don't bother computing client rects if no scroll method has been provided + const rangeInScope = DOMUtils.getRangeInScope(editableNode); + if (!rangeInScope) return; + + let rect = rangeInScope.getBoundingClientRect(); + if (DOMUtils.isEmptyBoundingRect(rect)) { + rect = DOMUtils.getSelectionRectFromDOM(selection); + } + if (rect) { + this.props.parentActions.scrollTo({rect}); + } + } + } + + + // Handlers + + _onExtensionsChanged = ()=> { + this.setState({extensions: ExtensionRegistry.Composer.extensions()}); + } + + + // Renderers + + render() { + return ( + + ); + } + +} + +export default ComposerEditor; diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index b6791a658..b607a0cb7 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -17,7 +17,6 @@ React = require 'react' {DropZone, RetinaImg, ScrollRegion, - Contenteditable, InjectedComponent, KeyCommandsRegion, FocusTrackingRegion, @@ -26,6 +25,7 @@ React = require 'react' FileUpload = require './file-upload' ImageFileUpload = require './image-file-upload' +ComposerEditor = require './composer-editor' ExpandedParticipants = require './expanded-participants' CollapsedParticipants = require './collapsed-participants' @@ -53,7 +53,7 @@ class ComposerView extends React.Component # have the parent scroll to a certain location. A parent component can # pass a callback that gets called when this composer wants to be # scrolled to. - onRequestScrollTo: React.PropTypes.func + scrollTo: React.PropTypes.func constructor: (@props) -> @state = @@ -69,7 +69,6 @@ class ComposerView extends React.Component enabledFields: [] # Gets updated in @_initiallyEnabledFields showQuotedText: false uploads: FileUploadStore.uploadsForMessage(@props.draftClientId) ? [] - composerExtensions: @_composerExtensions() componentWillMount: => @_prepareForDraft(@props.draftClientId) @@ -82,7 +81,6 @@ class ComposerView extends React.Component @_usubs = [] @_usubs.push FileUploadStore.listen @_onFileUploadStoreChange @_usubs.push AccountStore.listen @_onAccountStoreChanged - @_usubs.push ExtensionRegistry.Composer.listen @_onExtensionsChanged @_applyFieldFocus() componentWillUnmount: => @@ -101,11 +99,6 @@ class ComposerView extends React.Component @_applyFieldFocus() - ## TODO add core composer extensions to refactor callback props out of - # Contenteditable - _composerExtensions: -> - ExtensionRegistry.Composer.extensions() - _keymapHandlers: -> 'composer:send-message': => @_sendDraft() 'composer:delete-empty-draft': => @_deleteDraftIfEmpty() @@ -129,7 +122,7 @@ class ComposerView extends React.Component React.findDOMNode(@refs[@state.focusedField]).focus() if @state.focusedField is Fields.Body and not @_proxy.draftPristineBody() - @refs[Fields.Body].selectEnd() + @refs[Fields.Body].focusEditor() componentWillReceiveProps: (newProps) => @_ignoreNextTrigger = false @@ -244,7 +237,7 @@ class ComposerView extends React.Component ref="composeBody" onMouseUp={@_onMouseUpComposerBody} onMouseDown={@_onMouseDownComposerBody}> - {@_renderBody()} + {@_renderBodyRegions()} {@_renderFooterRegions()} @@ -303,29 +296,44 @@ class ComposerView extends React.Component onChange={@_onChangeSubject}/> - _renderBody: => + _renderBodyRegions: => - {@_renderBodyContenteditable()} + {@_renderEditor()} {@_renderQuotedTextControl()} {@_renderAttachments()} - _renderBodyContenteditable: -> - + _renderEditor: -> + exposedProps = + body: @_removeQuotedText(@state.body) + draftClientId: @props.draftClientId + parentActions: { + getComposerBoundingRect: @_getComposerBoundingRect + scrollTo: @props.scrollTo + } + initialSelectionSnapshot: @_recoveredSelection + onFocus: @_onEditorFocus + onFilePaste: @_onFilePaste + onBodyChanged: @_onBodyChanged - _contenteditableHandlers: => - { - onFocus: => @setState(focusedField: Fields.Body) - } + # TODO Get rid of the unecessary required methods: + # getCurrentSelection and getPreviousSelection shouldn't be needed and + # undo/redo functionality should be refactored into ComposerEditor + # _onDOMMutated is just for testing purposes, refactor the tests + + + _onEditorFocus: => + @setState(focusedField: Fields.Body) # The contenteditable decides when to request a scroll based on the # position of the cursor and its relative distance to this composer @@ -334,14 +342,6 @@ class ComposerView extends React.Component _getComposerBoundingRect: => React.findDOMNode(@refs.composerWrap).getBoundingClientRect() - _onScrollToBottom: -> - if @props.onRequestScrollTo - return => - @props.onRequestScrollTo - clientId: @_proxy.draft().clientId - position: ScrollRegion.ScrollPosition.Bottom - else return null - _removeQuotedText: (html) => if @state.showQuotedText then return html else return QuotedHTMLTransformer.removeQuotedHTML(html) @@ -487,7 +487,7 @@ class ComposerView extends React.Component _onMouseUpComposerBody: (event) => if event.target is @_mouseDownTarget - @refs[Fields.Body].selectEnd() + @refs[Fields.Body].focusEditor() @_mouseDownTarget = null # When a user focuses the composer, it's possible that no input is @@ -496,7 +496,7 @@ class ComposerView extends React.Component # erroneously trigger keyboard shortcuts. _onFocusIn: (event) => return if DOMUtils.closest(event.target, DOMUtils.inputTypes()) - @refs[Fields.Body].selectEnd() + @refs[Fields.Body].focusEditor() _onMouseMoveComposeBody: (event) => if @_mouseComposeBody is "down" then @_mouseComposeBody = "move" @@ -561,9 +561,6 @@ class ComposerView extends React.Component enabledFields.push Fields.Body return enabledFields - _onExtensionsChanged: => - @setState composerExtensions: @_composerExtensions() - # 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: => @@ -636,7 +633,7 @@ class ComposerView extends React.Component _onChangeSubject: (event) => @_addToProxy(subject: event.target.value) - _onChangeBody: (event) => + _onBodyChanged: (event) => return unless @_proxy newBody = @_showQuotedText(event.target.value) @@ -720,8 +717,8 @@ class ComposerView extends React.Component if bodyIsEmpty and not forwarded and not hasAttachment warnings.push('without a body') - # Check third party warnings added via DraftStore extensions - for extension in @state.composerExtensions + # Check third party warnings added via Composer extensions + for extension in ExtensionRegistry.Composer.extensions() continue unless extension.warningsForSending warnings = warnings.concat(extension.warningsForSending(draft)) diff --git a/internal_packages/composer/spec/composer-view-spec.cjsx b/internal_packages/composer/spec/composer-view-spec.cjsx index 42f4dd76c..bc541e6f1 100644 --- a/internal_packages/composer/spec/composer-view-spec.cjsx +++ b/internal_packages/composer/spec/composer-view-spec.cjsx @@ -19,6 +19,7 @@ ReactTestUtils = React.addons.TestUtils {InjectedComponent} = require 'nylas-component-kit' +ComposerEditor = require '../lib/composer-editor' ParticipantsTextField = require '../lib/participants-text-field' Fields = require '../lib/fields' @@ -71,679 +72,691 @@ ComposerView = proxyquire "../lib/composer-view", isValidContact: isValidContactStub DraftStore: DraftStore -describe "A blank composer view", -> +describe "ComposerView", -> + # TODO + # Extract ComposerEditor tests instead of rendering injected component + # here beforeEach -> - @composer = ReactTestUtils.renderIntoDocument( - - ) - @composer.setState - body: "" - - it 'should render into the document', -> - expect(ReactTestUtils.isCompositeComponentWithType @composer, ComposerView).toBe true - - describe "testing keyboard inputs", -> - it "shows and focuses on bcc field", -> - - it "shows and focuses on cc field", -> - - it "shows and focuses on bcc field when already open", -> - -# This will setup the mocks necessary to make the composer element (once -# mounted) think it's attached to the given draft. This mocks out the -# proxy system used by the composer. -DRAFT_CLIENT_ID = "local-123" -useDraft = (draftAttributes={}) -> - @draft = new Message _.extend({draft: true, body: ""}, draftAttributes) - draft = @draft - proxy = draftStoreProxyStub(DRAFT_CLIENT_ID, @draft) - @proxy = proxy - - - spyOn(ComposerView.prototype, "componentWillMount").andCallFake -> - # NOTE: This is called in the context of the component. - @_prepareForDraft(DRAFT_CLIENT_ID) - @_setupSession(proxy) - - # Normally when sessionForClientId resolves, it will call `_setupSession` - # and pass the new session proxy. However, in our faked - # `componentWillMount`, we manually call sessionForClientId to make this - # part of the test synchronous. We need to make the `then` block of the - # sessionForClientId do nothing so `_setupSession` is not called twice! - spyOn(DraftStore, "sessionForClientId").andCallFake -> then: -> - -useFullDraft = -> - useDraft.call @, - from: [u1] - to: [u2] - cc: [u3, u4] - bcc: [u5] - files: [f1, f2] - subject: "Test Message 1" - body: "Hello World
This is a test" - replyToMessageId: null - -makeComposer = -> - @composer = NylasTestUtils.renderIntoDocument( - - ) - -describe "populated composer", -> - beforeEach -> - @isSending = false - spyOn(DraftStore, "isSendingDraft").andCallFake => @isSending + ComposerEditor.containerRequired = false + ComponentRegistry.register(ComposerEditor, role: "Composer:Editor") afterEach -> - DraftStore._cleanupAllSessions() - NylasTestUtils.removeFromDocument(@composer) + ComposerEditor.containerRequired = undefined + ComponentRegistry.unregister(ComposerEditor) - describe "when sending a new message", -> - it 'makes a request with the message contents', -> - useDraft.call @ - makeComposer.call @ - editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@composer, 'contentEditable')) - spyOn(@proxy.changes, "add") - editableNode.innerHTML = "Hello world" - @composer.refs[Fields.Body]._onDOMMutated(["mutated"]) - expect(@proxy.changes.add).toHaveBeenCalled() - expect(@proxy.changes.add.calls.length).toBe 1 - body = @proxy.changes.add.calls[0].args[0].body - expect(body).toBe "Hello world" - - describe "when sending a reply-to message", -> + describe "A blank composer view", -> beforeEach -> - @replyBody = """
On Sep 3 2015, at 12:14 pm, Evan Morikawa <evan@evanmorikawa.com> wrote:
This is a test!
""" + @composer = ReactTestUtils.renderIntoDocument( + + ) + @composer.setState + body: "" - useDraft.call @, - from: [u1] - to: [u2] - subject: "Test Reply Message 1" - body: @replyBody + it 'should render into the document', -> + expect(ReactTestUtils.isCompositeComponentWithType @composer, ComposerView).toBe true - makeComposer.call @ - @editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@composer, 'contentEditable')) - spyOn(@proxy.changes, "add") + describe "testing keyboard inputs", -> + it "shows and focuses on bcc field", -> - it 'begins with the replying message collapsed', -> - expect(@editableNode.innerHTML).toBe "" + it "shows and focuses on cc field", -> - it 'saves the full new body, plus quoted text', -> - @editableNode.innerHTML = "Hello world" - @composer.refs[Fields.Body]._onDOMMutated(["mutated"]) - expect(@proxy.changes.add).toHaveBeenCalled() - expect(@proxy.changes.add.calls.length).toBe 1 - body = @proxy.changes.add.calls[0].args[0].body - expect(body).toBe """Hello world#{@replyBody}""" + it "shows and focuses on bcc field when already open", -> - describe "when sending a forwarded message message", -> + # This will setup the mocks necessary to make the composer element (once + # mounted) think it's attached to the given draft. This mocks out the + # proxy system used by the composer. + DRAFT_CLIENT_ID = "local-123" + useDraft = (draftAttributes={}) -> + @draft = new Message _.extend({draft: true, body: ""}, draftAttributes) + draft = @draft + proxy = draftStoreProxyStub(DRAFT_CLIENT_ID, @draft) + @proxy = proxy + + + spyOn(ComposerView.prototype, "componentWillMount").andCallFake -> + # NOTE: This is called in the context of the component. + @_prepareForDraft(DRAFT_CLIENT_ID) + @_setupSession(proxy) + + # Normally when sessionForClientId resolves, it will call `_setupSession` + # and pass the new session proxy. However, in our faked + # `componentWillMount`, we manually call sessionForClientId to make this + # part of the test synchronous. We need to make the `then` block of the + # sessionForClientId do nothing so `_setupSession` is not called twice! + spyOn(DraftStore, "sessionForClientId").andCallFake -> then: -> + + useFullDraft = -> + useDraft.call @, + from: [u1] + to: [u2] + cc: [u3, u4] + bcc: [u5] + files: [f1, f2] + subject: "Test Message 1" + body: "Hello World
This is a test" + replyToMessageId: null + + makeComposer = -> + @composer = NylasTestUtils.renderIntoDocument( + + ) + + describe "populated composer", -> beforeEach -> - @fwdBody = """

- Begin forwarded message: -

- From: Evan Morikawa <evan@evanmorikawa.com>
Subject: Test Forward Message 1
Date: Sep 3 2015, at 12:14 pm
To: Evan Morikawa <evan@nylas.com> -

+ @isSending = false + spyOn(DraftStore, "isSendingDraft").andCallFake => @isSending - This is a test! -
""" + afterEach -> + DraftStore._cleanupAllSessions() + NylasTestUtils.removeFromDocument(@composer) - useDraft.call @, - from: [u1] - to: [u2] - subject: "Fwd: Test Forward Message 1" - body: @fwdBody + describe "when sending a new message", -> + it 'makes a request with the message contents', -> + useDraft.call @ + makeComposer.call @ + editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@composer, 'contentEditable')) + spyOn(@proxy.changes, "add") + editableNode.innerHTML = "Hello world" + @composer.refs[Fields.Body]._onDOMMutated(["mutated"]) + expect(@proxy.changes.add).toHaveBeenCalled() + expect(@proxy.changes.add.calls.length).toBe 1 + body = @proxy.changes.add.calls[0].args[0].body + expect(body).toBe "Hello world" - makeComposer.call @ - @editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@composer, 'contentEditable')) - spyOn(@proxy.changes, "add") - - it 'begins with the forwarded message expanded', -> - expect(@editableNode.innerHTML).toBe @fwdBody - - it 'saves the full new body, plus forwarded text', -> - @editableNode.innerHTML = "Hello world#{@fwdBody}" - @composer.refs[Fields.Body]._onDOMMutated(["mutated"]) - expect(@proxy.changes.add).toHaveBeenCalled() - expect(@proxy.changes.add.calls.length).toBe 1 - body = @proxy.changes.add.calls[0].args[0].body - expect(body).toBe """Hello world#{@fwdBody}""" - - describe "When displaying info from a draft", -> - beforeEach -> - useFullDraft.apply(@) - makeComposer.call(@) - - it "attaches the draft to the proxy", -> - expect(@draft).toBeDefined() - expect(@composer._proxy.draft()).toBe @draft - - it "sets the basic draft state", -> - expect(@composer.state.from).toEqual [u1] - 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 World
This is a test" - - 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._shouldEnableSubject()).toBe true - - it "enables the subject when the subject looks like a fwd", -> - useDraft.call @, subject: "Fwd: This is the message" - makeComposer.call @ - expect(@composer._shouldEnableSubject()).toBe true - - it "enables the subject when the subject looks like a fwd", -> - useDraft.call @, subject: "fwd foo" - makeComposer.call @ - expect(@composer._shouldEnableSubject()).toBe true - - it "doesn't enable subject when replyToMessageId exists", -> - useDraft.call @, subject: "should hide", replyToMessageId: "some-id" - makeComposer.call @ - expect(@composer._shouldEnableSubject()).toBe false - - it "enables the subject otherwise", -> - useDraft.call @, subject: "Foo bar baz" - makeComposer.call @ - expect(@composer._shouldEnableSubject()).toBe true - - 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.enabledFields).not.toContain Fields.Cc - - it "enables cc when populated", -> - useDraft.call @, cc: [u1,u2] - makeComposer.call @ - expect(@composer.state.enabledFields).toContain Fields.Cc - - it "doesn't enable bcc when there's no one to bcc", -> - useDraft.call @, bcc: [] - makeComposer.call @ - expect(@composer.state.enabledFields).not.toContain Fields.Bcc - - it "enables bcc when populated", -> - useDraft.call @, bcc: [u2,u3] - makeComposer.call @ - expect(@composer.state.enabledFields).toContain Fields.Bcc - - 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 - - it "disables if account has no aliases", -> - spyOn(AccountStore, 'itemWithId').andCallFake -> {id: 1, aliases: []} - useDraft.call @, replyToMessageId: null, files: [] - makeComposer.call @ - expect(@composer.state.enabledFields).not.toContain Fields.From - - it "enables if it's a reply-to message", -> - aliases = ['A {id: 1, aliases: aliases} - useDraft.call @, replyToMessageId: "local-123", files: [] - makeComposer.call @ - expect(@composer.state.enabledFields).toContain Fields.From - - it "enables if requirements are met", -> - a1 = new Account() - a1.aliases = ['a1'] - spyOn(AccountStore, 'itemWithId').andCallFake -> a1 - useDraft.call @, replyToMessageId: null, files: [] - makeComposer.call @ - expect(@composer.state.enabledFields).toContain Fields.From - - describe "when enabling fields", -> - it "always enables the To and Body fields on empty composers", -> - useDraft.apply @ - makeComposer.call(@) - expect(@composer.state.enabledFields).toContain Fields.To - expect(@composer.state.enabledFields).toContain Fields.Body - - it "always enables the To and Body fields on full composers", -> - useFullDraft.apply(@) - makeComposer.call(@) - expect(@composer.state.enabledFields).toContain Fields.To - expect(@composer.state.enabledFields).toContain Fields.Body - - describe "applying the focused field", -> - beforeEach -> - useFullDraft.apply(@) - makeComposer.call(@) - @composer.setState focusedField: Fields.Cc - @body = @composer.refs[Fields.Body] - spyOn(@body, "focus") - spyOn(React, "findDOMNode").andCallThrough() - spyOn(@composer, "_applyFieldFocus").andCallThrough() - - it "can focus on the subject", -> - @composer.setState focusedField: Fields.Subject - expect(@composer._applyFieldFocus.calls.length).toBe 1 - expect(React.findDOMNode).toHaveBeenCalled() - calls = _.filter React.findDOMNode.calls, (call) -> - call.args[0].props.name == "subject" - expect(calls.length).toBe 1 - - 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(@composer._applyFieldFocus.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.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.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.enabledFields).toContain Fields.Bcc - expect(@composer.state.enabledFields).toContain Fields.Cc - - describe "When sending a message", -> - beforeEach -> - spyOn(NylasEnv, "isMainWindow").andReturn true - remote = require('remote') - @dialog = remote.require('dialog') - spyOn(remote, "getCurrentWindow") - spyOn(@dialog, "showMessageBox") - spyOn(Actions, "sendDraft") - - it "shows an error if there are no recipients", -> - useDraft.call @, subject: "no recipients" - makeComposer.call(@) - @composer._sendDraft() - expect(Actions.sendDraft).not.toHaveBeenCalled() - expect(@dialog.showMessageBox).toHaveBeenCalled() - dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] - expect(dialogArgs.detail).toEqual("You need to provide one or more recipients before sending the message.") - expect(dialogArgs.buttons).toEqual ['Edit Message'] - - it "shows an error if a recipient is invalid", -> - useDraft.call @, - subject: 'hello world!' - to: [new Contact(email: 'lol', name: 'lol')] - makeComposer.call(@) - @composer._sendDraft() - expect(Actions.sendDraft).not.toHaveBeenCalled() - expect(@dialog.showMessageBox).toHaveBeenCalled() - dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] - expect(dialogArgs.detail).toEqual("lol is not a valid email address - please remove or edit it before sending.") - expect(dialogArgs.buttons).toEqual ['Edit Message'] - - describe "empty body warning", -> - it "warns if the body of the email is still the pristine body", -> - pristineBody = "

" + describe "when sending a reply-to message", -> + beforeEach -> + @replyBody = """
On Sep 3 2015, at 12:14 pm, Evan Morikawa <evan@evanmorikawa.com> wrote:
This is a test!
""" useDraft.call @, - to: [u1] - subject: "Hello World" - body: pristineBody + from: [u1] + to: [u2] + subject: "Test Reply Message 1" + body: @replyBody + + makeComposer.call @ + @editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@composer, 'contentEditable')) + spyOn(@proxy.changes, "add") + + it 'begins with the replying message collapsed', -> + expect(@editableNode.innerHTML).toBe "" + + it 'saves the full new body, plus quoted text', -> + @editableNode.innerHTML = "Hello world" + @composer.refs[Fields.Body]._onDOMMutated(["mutated"]) + expect(@proxy.changes.add).toHaveBeenCalled() + expect(@proxy.changes.add.calls.length).toBe 1 + body = @proxy.changes.add.calls[0].args[0].body + expect(body).toBe """Hello world#{@replyBody}""" + + describe "when sending a forwarded message message", -> + beforeEach -> + @fwdBody = """

+ Begin forwarded message: +

+ From: Evan Morikawa <evan@evanmorikawa.com>
Subject: Test Forward Message 1
Date: Sep 3 2015, at 12:14 pm
To: Evan Morikawa <evan@nylas.com> +

+ + This is a test! +
""" + + useDraft.call @, + from: [u1] + to: [u2] + subject: "Fwd: Test Forward Message 1" + body: @fwdBody + + makeComposer.call @ + @editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(@composer, 'contentEditable')) + spyOn(@proxy.changes, "add") + + it 'begins with the forwarded message expanded', -> + expect(@editableNode.innerHTML).toBe @fwdBody + + it 'saves the full new body, plus forwarded text', -> + @editableNode.innerHTML = "Hello world#{@fwdBody}" + @composer.refs[Fields.Body]._onDOMMutated(["mutated"]) + expect(@proxy.changes.add).toHaveBeenCalled() + expect(@proxy.changes.add.calls.length).toBe 1 + body = @proxy.changes.add.calls[0].args[0].body + expect(body).toBe """Hello world#{@fwdBody}""" + + describe "When displaying info from a draft", -> + beforeEach -> + useFullDraft.apply(@) makeComposer.call(@) - spyOn(@composer._proxy, 'draftPristineBody').andCallFake -> pristineBody + it "attaches the draft to the proxy", -> + expect(@draft).toBeDefined() + expect(@composer._proxy.draft()).toBe @draft + it "sets the basic draft state", -> + expect(@composer.state.from).toEqual [u1] + 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 World
This is a test" + + 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._shouldEnableSubject()).toBe true + + it "enables the subject when the subject looks like a fwd", -> + useDraft.call @, subject: "Fwd: This is the message" + makeComposer.call @ + expect(@composer._shouldEnableSubject()).toBe true + + it "enables the subject when the subject looks like a fwd", -> + useDraft.call @, subject: "fwd foo" + makeComposer.call @ + expect(@composer._shouldEnableSubject()).toBe true + + it "doesn't enable subject when replyToMessageId exists", -> + useDraft.call @, subject: "should hide", replyToMessageId: "some-id" + makeComposer.call @ + expect(@composer._shouldEnableSubject()).toBe false + + it "enables the subject otherwise", -> + useDraft.call @, subject: "Foo bar baz" + makeComposer.call @ + expect(@composer._shouldEnableSubject()).toBe true + + 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.enabledFields).not.toContain Fields.Cc + + it "enables cc when populated", -> + useDraft.call @, cc: [u1,u2] + makeComposer.call @ + expect(@composer.state.enabledFields).toContain Fields.Cc + + it "doesn't enable bcc when there's no one to bcc", -> + useDraft.call @, bcc: [] + makeComposer.call @ + expect(@composer.state.enabledFields).not.toContain Fields.Bcc + + it "enables bcc when populated", -> + useDraft.call @, bcc: [u2,u3] + makeComposer.call @ + expect(@composer.state.enabledFields).toContain Fields.Bcc + + 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 + + it "disables if account has no aliases", -> + spyOn(AccountStore, 'itemWithId').andCallFake -> {id: 1, aliases: []} + useDraft.call @, replyToMessageId: null, files: [] + makeComposer.call @ + expect(@composer.state.enabledFields).not.toContain Fields.From + + it "enables if it's a reply-to message", -> + aliases = ['A {id: 1, aliases: aliases} + useDraft.call @, replyToMessageId: "local-123", files: [] + makeComposer.call @ + expect(@composer.state.enabledFields).toContain Fields.From + + it "enables if requirements are met", -> + a1 = new Account() + a1.aliases = ['a1'] + spyOn(AccountStore, 'itemWithId').andCallFake -> a1 + useDraft.call @, replyToMessageId: null, files: [] + makeComposer.call @ + expect(@composer.state.enabledFields).toContain Fields.From + + describe "when enabling fields", -> + it "always enables the To and Body fields on empty composers", -> + useDraft.apply @ + makeComposer.call(@) + expect(@composer.state.enabledFields).toContain Fields.To + expect(@composer.state.enabledFields).toContain Fields.Body + + it "always enables the To and Body fields on full composers", -> + useFullDraft.apply(@) + makeComposer.call(@) + expect(@composer.state.enabledFields).toContain Fields.To + expect(@composer.state.enabledFields).toContain Fields.Body + + describe "applying the focused field", -> + beforeEach -> + useFullDraft.apply(@) + makeComposer.call(@) + @composer.setState focusedField: Fields.Cc + @body = @composer.refs[Fields.Body] + spyOn(@body, "focus") + spyOn(React, "findDOMNode").andCallThrough() + spyOn(@composer, "_applyFieldFocus").andCallThrough() + + it "can focus on the subject", -> + @composer.setState focusedField: Fields.Subject + expect(@composer._applyFieldFocus.calls.length).toBe 1 + expect(React.findDOMNode).toHaveBeenCalled() + calls = _.filter React.findDOMNode.calls, (call) -> + call.args[0].props.name == "subject" + expect(calls.length).toBe 1 + + 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(@composer._applyFieldFocus.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.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.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.enabledFields).toContain Fields.Bcc + expect(@composer.state.enabledFields).toContain Fields.Cc + + describe "When sending a message", -> + beforeEach -> + spyOn(NylasEnv, "isMainWindow").andReturn true + remote = require('remote') + @dialog = remote.require('dialog') + spyOn(remote, "getCurrentWindow") + spyOn(@dialog, "showMessageBox") + spyOn(Actions, "sendDraft") + + it "shows an error if there are no recipients", -> + useDraft.call @, subject: "no recipients" + makeComposer.call(@) + @composer._sendDraft() + expect(Actions.sendDraft).not.toHaveBeenCalled() + expect(@dialog.showMessageBox).toHaveBeenCalled() + dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] + expect(dialogArgs.detail).toEqual("You need to provide one or more recipients before sending the message.") + expect(dialogArgs.buttons).toEqual ['Edit Message'] + + it "shows an error if a recipient is invalid", -> + useDraft.call @, + subject: 'hello world!' + to: [new Contact(email: 'lol', name: 'lol')] + makeComposer.call(@) + @composer._sendDraft() + expect(Actions.sendDraft).not.toHaveBeenCalled() + expect(@dialog.showMessageBox).toHaveBeenCalled() + dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] + expect(dialogArgs.detail).toEqual("lol is not a valid email address - please remove or edit it before sending.") + expect(dialogArgs.buttons).toEqual ['Edit Message'] + + describe "empty body warning", -> + it "warns if the body of the email is still the pristine body", -> + pristineBody = "

" + + useDraft.call @, + to: [u1] + subject: "Hello World" + body: pristineBody + makeComposer.call(@) + + spyOn(@composer._proxy, 'draftPristineBody').andCallFake -> pristineBody + + @composer._sendDraft() + expect(Actions.sendDraft).not.toHaveBeenCalled() + expect(@dialog.showMessageBox).toHaveBeenCalled() + dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] + expect(dialogArgs.buttons).toEqual ['Send Anyway', 'Cancel'] + + it "does not warn if the body of the email is all quoted text, but the email is a forward", -> + useDraft.call @, + to: [u1] + subject: "Fwd: Hello World" + body: "

This is my quoted text!
" + makeComposer.call(@) + @composer._sendDraft() + expect(Actions.sendDraft).toHaveBeenCalled() + + it "does not warn if the user has attached a file", -> + useDraft.call @, + to: [u1] + subject: "Hello World" + body: "" + files: [f1] + makeComposer.call(@) + @composer._sendDraft() + expect(Actions.sendDraft).toHaveBeenCalled() + expect(@dialog.showMessageBox).not.toHaveBeenCalled() + + it "shows a warning if there's no subject", -> + useDraft.call @, to: [u1], subject: "" + makeComposer.call(@) @composer._sendDraft() expect(Actions.sendDraft).not.toHaveBeenCalled() expect(@dialog.showMessageBox).toHaveBeenCalled() dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] expect(dialogArgs.buttons).toEqual ['Send Anyway', 'Cancel'] - it "does not warn if the body of the email is all quoted text, but the email is a forward", -> - useDraft.call @, - to: [u1] - subject: "Fwd: Hello World" - body: "

This is my quoted text!
" - makeComposer.call(@) + it "doesn't show a warning if requirements are satisfied", -> + useFullDraft.apply(@); makeComposer.call(@) @composer._sendDraft() expect(Actions.sendDraft).toHaveBeenCalled() + expect(@dialog.showMessageBox).not.toHaveBeenCalled() - it "does not warn if the user has attached a file", -> + describe "Checking for attachments", -> + warn = (body) -> + useDraft.call @, subject: "Subject", to: [u1], body: body + makeComposer.call(@); @composer._sendDraft() + expect(Actions.sendDraft).not.toHaveBeenCalled() + expect(@dialog.showMessageBox).toHaveBeenCalled() + dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] + expect(dialogArgs.buttons).toEqual ['Send Anyway', 'Cancel'] + + noWarn = (body) -> + useDraft.call @, subject: "Subject", to: [u1], body: body + makeComposer.call(@); @composer._sendDraft() + expect(Actions.sendDraft).toHaveBeenCalled() + expect(@dialog.showMessageBox).not.toHaveBeenCalled() + + it "warns", -> warn.call(@, "Check out the attached file") + it "warns", -> warn.call(@, "I've added an attachment") + it "warns", -> warn.call(@, "I'm going to attach the file") + it "warns", -> warn.call(@, "Hey attach me
sup
") + + it "doesn't warn", -> noWarn.call(@, "sup yo") + it "doesn't warn", -> noWarn.call(@, "Look at the file") + it "doesn't warn", -> noWarn.call(@, "Hey there
attach
") + + it "doesn't show a warning if you've attached a file", -> + useDraft.call @, + subject: "Subject" + to: [u1] + body: "Check out attached file" + files: [f1] + makeComposer.call(@); @composer._sendDraft() + expect(Actions.sendDraft).toHaveBeenCalled() + expect(@dialog.showMessageBox).not.toHaveBeenCalled() + + it "bypasses the warning if force bit is set", -> + useDraft.call @, to: [u1], subject: "" + makeComposer.call(@) + @composer._sendDraft(force: true) + expect(Actions.sendDraft).toHaveBeenCalled() + expect(@dialog.showMessageBox).not.toHaveBeenCalled() + + it "sends when you click the send button", -> + useFullDraft.apply(@); makeComposer.call(@) + sendBtn = React.findDOMNode(@composer.refs.sendButton) + ReactTestUtils.Simulate.click sendBtn + expect(Actions.sendDraft).toHaveBeenCalledWith(DRAFT_CLIENT_ID) + expect(Actions.sendDraft.calls.length).toBe 1 + + it "doesn't send twice if you double click", -> + useFullDraft.apply(@); makeComposer.call(@) + sendBtn = React.findDOMNode(@composer.refs.sendButton) + ReactTestUtils.Simulate.click sendBtn + @isSending = true + DraftStore.trigger() + ReactTestUtils.Simulate.click sendBtn + expect(Actions.sendDraft).toHaveBeenCalledWith(DRAFT_CLIENT_ID) + expect(Actions.sendDraft.calls.length).toBe 1 + + describe "when sending a message with keyboard inputs", -> + beforeEach -> + useFullDraft.apply(@) + makeComposer.call(@) + NylasTestUtils.loadKeymap("internal_packages/composer/keymaps/composer") + @$composer = @composer.refs.composerWrap + + it "sends the draft on cmd-enter", -> + if process.platform is "darwin" + cmdctrl = 'cmd' + else + cmdctrl = 'ctrl' + NylasTestUtils.keyDown("#{cmdctrl}-enter", React.findDOMNode(@$composer)) + expect(Actions.sendDraft).toHaveBeenCalled() + expect(Actions.sendDraft.calls.length).toBe 1 + + it "does not send the draft on enter if the button isn't in focus", -> + NylasTestUtils.keyDown("enter", React.findDOMNode(@$composer)) + expect(Actions.sendDraft).not.toHaveBeenCalled() + + it "doesn't let you send twice", -> + if process.platform is "darwin" + cmdctrl = 'cmd' + else + cmdctrl = 'ctrl' + NylasTestUtils.keyDown("#{cmdctrl}-enter", React.findDOMNode(@$composer)) + expect(Actions.sendDraft).toHaveBeenCalled() + expect(Actions.sendDraft.calls.length).toBe 1 + @isSending = true + DraftStore.trigger() + NylasTestUtils.keyDown("#{cmdctrl}-enter", React.findDOMNode(@$composer)) + expect(Actions.sendDraft).toHaveBeenCalled() + expect(Actions.sendDraft.calls.length).toBe 1 + + describe "drag and drop", -> + beforeEach -> useDraft.call @, to: [u1] subject: "Hello World" body: "" files: [f1] makeComposer.call(@) - @composer._sendDraft() - expect(Actions.sendDraft).toHaveBeenCalled() - expect(@dialog.showMessageBox).not.toHaveBeenCalled() - it "shows a warning if there's no subject", -> - useDraft.call @, to: [u1], subject: "" - makeComposer.call(@) - @composer._sendDraft() - expect(Actions.sendDraft).not.toHaveBeenCalled() - expect(@dialog.showMessageBox).toHaveBeenCalled() - dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] - expect(dialogArgs.buttons).toEqual ['Send Anyway', 'Cancel'] + describe "_shouldAcceptDrop", -> + it "should return true if the event is carrying native files", -> + event = + dataTransfer: + files:[{'pretend':'imafile'}] + types:[] + expect(@composer._shouldAcceptDrop(event)).toBe(true) - it "doesn't show a warning if requirements are satisfied", -> - useFullDraft.apply(@); makeComposer.call(@) - @composer._sendDraft() - expect(Actions.sendDraft).toHaveBeenCalled() - expect(@dialog.showMessageBox).not.toHaveBeenCalled() + it "should return true if the event is carrying a non-native file URL not on the draft", -> + event = + dataTransfer: + files:[] + types:['text/uri-list'] + spyOn(@composer, '_nonNativeFilePathForDrop').andReturn("file://one-file") + spyOn(FileUploadStore, 'linkedUpload').andReturn({filePath: "file://other-file"}) - describe "Checking for attachments", -> - warn = (body) -> - useDraft.call @, subject: "Subject", to: [u1], body: body - makeComposer.call(@); @composer._sendDraft() - expect(Actions.sendDraft).not.toHaveBeenCalled() - expect(@dialog.showMessageBox).toHaveBeenCalled() - dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] - expect(dialogArgs.buttons).toEqual ['Send Anyway', 'Cancel'] + expect(@composer.state.files.length).toBe(1) + expect(@composer._shouldAcceptDrop(event)).toBe(true) - noWarn = (body) -> - useDraft.call @, subject: "Subject", to: [u1], body: body - makeComposer.call(@); @composer._sendDraft() - expect(Actions.sendDraft).toHaveBeenCalled() - expect(@dialog.showMessageBox).not.toHaveBeenCalled() + it "should return false if the event is carrying a non-native file URL already on the draft", -> + event = + dataTransfer: + files:[] + types:['text/uri-list'] + spyOn(@composer, '_nonNativeFilePathForDrop').andReturn("file://one-file") + spyOn(FileUploadStore, 'linkedUpload').andReturn({filePath: "file://one-file"}) - it "warns", -> warn.call(@, "Check out the attached file") - it "warns", -> warn.call(@, "I've added an attachment") - it "warns", -> warn.call(@, "I'm going to attach the file") - it "warns", -> warn.call(@, "Hey attach me
sup
") + expect(@composer.state.files.length).toBe(1) + expect(@composer._shouldAcceptDrop(event)).toBe(false) - it "doesn't warn", -> noWarn.call(@, "sup yo") - it "doesn't warn", -> noWarn.call(@, "Look at the file") - it "doesn't warn", -> noWarn.call(@, "Hey there
attach
") + it "should return false otherwise", -> + event = + dataTransfer: + files:[] + types:['text/plain'] + expect(@composer._shouldAcceptDrop(event)).toBe(false) - it "doesn't show a warning if you've attached a file", -> - useDraft.call @, - subject: "Subject" - to: [u1] - body: "Check out attached file" - files: [f1] - makeComposer.call(@); @composer._sendDraft() - expect(Actions.sendDraft).toHaveBeenCalled() - expect(@dialog.showMessageBox).not.toHaveBeenCalled() + describe "_nonNativeFilePathForDrop", -> + it "should return a path in the text/nylas-file-url data", -> + event = + dataTransfer: + types: ['text/nylas-file-url'] + getData: -> "image/png:test.png:file:///Users/bengotow/Desktop/test.png" + expect(@composer._nonNativeFilePathForDrop(event)).toBe("/Users/bengotow/Desktop/test.png") - it "bypasses the warning if force bit is set", -> - useDraft.call @, to: [u1], subject: "" - makeComposer.call(@) - @composer._sendDraft(force: true) - expect(Actions.sendDraft).toHaveBeenCalled() - expect(@dialog.showMessageBox).not.toHaveBeenCalled() + it "should return a path in the text/uri-list data", -> + event = + dataTransfer: + types: ['text/uri-list'] + getData: -> "file:///Users/bengotow/Desktop/test.png" + expect(@composer._nonNativeFilePathForDrop(event)).toBe("/Users/bengotow/Desktop/test.png") - it "sends when you click the send button", -> - useFullDraft.apply(@); makeComposer.call(@) - sendBtn = React.findDOMNode(@composer.refs.sendButton) - ReactTestUtils.Simulate.click sendBtn - expect(Actions.sendDraft).toHaveBeenCalledWith(DRAFT_CLIENT_ID) - expect(Actions.sendDraft.calls.length).toBe 1 + it "should return null otherwise", -> + event = + dataTransfer: + types: ['text/plain'] + getData: -> "Hello world" + expect(@composer._nonNativeFilePathForDrop(event)).toBe(null) - it "doesn't send twice if you double click", -> - useFullDraft.apply(@); makeComposer.call(@) - sendBtn = React.findDOMNode(@composer.refs.sendButton) - ReactTestUtils.Simulate.click sendBtn - @isSending = true - DraftStore.trigger() - ReactTestUtils.Simulate.click sendBtn - expect(Actions.sendDraft).toHaveBeenCalledWith(DRAFT_CLIENT_ID) - expect(Actions.sendDraft.calls.length).toBe 1 + it "should urldecode the contents of the text/uri-list field", -> + event = + dataTransfer: + types: ['text/uri-list'] + getData: -> "file:///Users/bengotow/Desktop/Screen%20shot.png" + expect(@composer._nonNativeFilePathForDrop(event)).toBe("/Users/bengotow/Desktop/Screen shot.png") - describe "when sending a message with keyboard inputs", -> + it "should return null if text/uri-list contains a non-file path", -> + event = + dataTransfer: + types: ['text/uri-list'] + getData: -> "http://apple.com" + expect(@composer._nonNativeFilePathForDrop(event)).toBe(null) + + it "should return null if text/nylas-file-url contains a non-file path", -> + event = + dataTransfer: + types: ['text/nylas-file-url'] + getData: -> "application/json:filename.json:undefined" + expect(@composer._nonNativeFilePathForDrop(event)).toBe(null) + + describe "when scrolling to track your cursor", -> + it "it tracks when you're at the end of the text", -> + + it "it doesn't track when typing in the middle of the body", -> + + it "it doesn't track when typing in the middle of the body", -> + + describe "When composing a new message", -> + it "Can add someone in the to field", -> + + it "Can add someone in the cc field", -> + + it "Can add someone in the bcc field", -> + + describe "When replying to a message", -> + + describe "When replying all to a message", -> + + describe "When forwarding a message", -> + + describe "When changing the subject of a message", -> + + describe "A draft with files (attachments) and uploads", -> beforeEach -> - useFullDraft.apply(@) - makeComposer.call(@) - NylasTestUtils.loadKeymap("internal_packages/composer/keymaps/composer") - @$composer = @composer.refs.composerWrap + @file1 = new File + id: "f_1" + filename: "f1.pdf" + size: 1230 - it "sends the draft on cmd-enter", -> - if process.platform is "darwin" - cmdctrl = 'cmd' - else - cmdctrl = 'ctrl' - NylasTestUtils.keyDown("#{cmdctrl}-enter", React.findDOMNode(@$composer)) - expect(Actions.sendDraft).toHaveBeenCalled() - expect(Actions.sendDraft.calls.length).toBe 1 + @file2 = new File + id: "f_2" + filename: "f2.jpg" + size: 4560 - it "does not send the draft on enter if the button isn't in focus", -> - NylasTestUtils.keyDown("enter", React.findDOMNode(@$composer)) - expect(Actions.sendDraft).not.toHaveBeenCalled() + @file3 = new File + id: "f_3" + filename: "f3.png" + size: 7890 - it "doesn't let you send twice", -> - if process.platform is "darwin" - cmdctrl = 'cmd' - else - cmdctrl = 'ctrl' - NylasTestUtils.keyDown("#{cmdctrl}-enter", React.findDOMNode(@$composer)) - expect(Actions.sendDraft).toHaveBeenCalled() - expect(Actions.sendDraft.calls.length).toBe 1 - @isSending = true - DraftStore.trigger() - NylasTestUtils.keyDown("#{cmdctrl}-enter", React.findDOMNode(@$composer)) - expect(Actions.sendDraft).toHaveBeenCalled() - expect(Actions.sendDraft.calls.length).toBe 1 + @up1 = + uploadTaskId: 4 + messageClientId: DRAFT_CLIENT_ID + filePath: "/foo/bar/f4.bmp" + fileName: "f4.bmp" + fileSize: 1024 - describe "drag and drop", -> + @up2 = + uploadTaskId: 5 + messageClientId: DRAFT_CLIENT_ID + filePath: "/foo/bar/f5.zip" + fileName: "f5.zip" + fileSize: 1024 + + spyOn(Actions, "fetchFile") + spyOn(FileUploadStore, "linkedUpload").andReturn null + spyOn(FileUploadStore, "uploadsForMessage").andReturn [@up1, @up2] + + useDraft.call @, files: [@file1, @file2] + makeComposer.call @ + + it 'starts fetching attached files', -> + waitsFor -> + Actions.fetchFile.callCount == 1 + runs -> + expect(Actions.fetchFile).toHaveBeenCalled() + expect(Actions.fetchFile.calls.length).toBe(1) + expect(Actions.fetchFile.calls[0].args[0]).toBe @file2 + + it 'injects an Attachment component for non image files', -> + els = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@composer, InjectedComponent, matching: {role: "Attachment"}) + expect(els.length).toBe 1 + + it 'injects an Attachment:Image component for image files', -> + els = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@composer, InjectedComponent, matching: {role: "Attachment:Image"}) + expect(els.length).toBe 1 + + describe "when the DraftStore `isSending` isn't stubbed out", -> beforeEach -> - useDraft.call @, - to: [u1] - subject: "Hello World" - body: "" - files: [f1] + DraftStore._draftsSending = {} + + it "doesn't send twice in a popout", -> + spyOn(Actions, "queueTask") + spyOn(Actions, "sendDraft").andCallThrough() + useFullDraft.call(@) makeComposer.call(@) + @composer._sendDraft() + @composer._sendDraft() + expect(Actions.sendDraft.calls.length).toBe 1 - describe "_shouldAcceptDrop", -> - it "should return true if the event is carrying native files", -> - event = - dataTransfer: - files:[{'pretend':'imafile'}] - types:[] - expect(@composer._shouldAcceptDrop(event)).toBe(true) - - it "should return true if the event is carrying a non-native file URL not on the draft", -> - event = - dataTransfer: - files:[] - types:['text/uri-list'] - spyOn(@composer, '_nonNativeFilePathForDrop').andReturn("file://one-file") - spyOn(FileUploadStore, 'linkedUpload').andReturn({filePath: "file://other-file"}) - - expect(@composer.state.files.length).toBe(1) - expect(@composer._shouldAcceptDrop(event)).toBe(true) - - it "should return false if the event is carrying a non-native file URL already on the draft", -> - event = - dataTransfer: - files:[] - types:['text/uri-list'] - spyOn(@composer, '_nonNativeFilePathForDrop').andReturn("file://one-file") - spyOn(FileUploadStore, 'linkedUpload').andReturn({filePath: "file://one-file"}) - - expect(@composer.state.files.length).toBe(1) - expect(@composer._shouldAcceptDrop(event)).toBe(false) - - it "should return false otherwise", -> - event = - dataTransfer: - files:[] - types:['text/plain'] - expect(@composer._shouldAcceptDrop(event)).toBe(false) - - describe "_nonNativeFilePathForDrop", -> - it "should return a path in the text/nylas-file-url data", -> - event = - dataTransfer: - types: ['text/nylas-file-url'] - getData: -> "image/png:test.png:file:///Users/bengotow/Desktop/test.png" - expect(@composer._nonNativeFilePathForDrop(event)).toBe("/Users/bengotow/Desktop/test.png") - - it "should return a path in the text/uri-list data", -> - event = - dataTransfer: - types: ['text/uri-list'] - getData: -> "file:///Users/bengotow/Desktop/test.png" - expect(@composer._nonNativeFilePathForDrop(event)).toBe("/Users/bengotow/Desktop/test.png") - - it "should return null otherwise", -> - event = - dataTransfer: - types: ['text/plain'] - getData: -> "Hello world" - expect(@composer._nonNativeFilePathForDrop(event)).toBe(null) - - it "should urldecode the contents of the text/uri-list field", -> - event = - dataTransfer: - types: ['text/uri-list'] - getData: -> "file:///Users/bengotow/Desktop/Screen%20shot.png" - expect(@composer._nonNativeFilePathForDrop(event)).toBe("/Users/bengotow/Desktop/Screen shot.png") - - it "should return null if text/uri-list contains a non-file path", -> - event = - dataTransfer: - types: ['text/uri-list'] - getData: -> "http://apple.com" - expect(@composer._nonNativeFilePathForDrop(event)).toBe(null) - - it "should return null if text/nylas-file-url contains a non-file path", -> - event = - dataTransfer: - types: ['text/nylas-file-url'] - getData: -> "application/json:filename.json:undefined" - expect(@composer._nonNativeFilePathForDrop(event)).toBe(null) - - describe "when scrolling to track your cursor", -> - it "it tracks when you're at the end of the text", -> - - it "it doesn't track when typing in the middle of the body", -> - - it "it doesn't track when typing in the middle of the body", -> - - describe "When composing a new message", -> - it "Can add someone in the to field", -> - - it "Can add someone in the cc field", -> - - it "Can add someone in the bcc field", -> - - describe "When replying to a message", -> - - describe "When replying all to a message", -> - - describe "When forwarding a message", -> - - describe "When changing the subject of a message", -> - - describe "A draft with files (attachments) and uploads", -> - beforeEach -> - @file1 = new File - id: "f_1" - filename: "f1.pdf" - size: 1230 - - @file2 = new File - id: "f_2" - filename: "f2.jpg" - size: 4560 - - @file3 = new File - id: "f_3" - filename: "f3.png" - size: 7890 - - @up1 = - uploadTaskId: 4 - messageClientId: DRAFT_CLIENT_ID - filePath: "/foo/bar/f4.bmp" - fileName: "f4.bmp" - fileSize: 1024 - - @up2 = - uploadTaskId: 5 - messageClientId: DRAFT_CLIENT_ID - filePath: "/foo/bar/f5.zip" - fileName: "f5.zip" - fileSize: 1024 - - spyOn(Actions, "fetchFile") - spyOn(FileUploadStore, "linkedUpload").andReturn null - spyOn(FileUploadStore, "uploadsForMessage").andReturn [@up1, @up2] - - useDraft.call @, files: [@file1, @file2] - makeComposer.call @ - - it 'starts fetching attached files', -> - waitsFor -> - Actions.fetchFile.callCount == 1 - runs -> - expect(Actions.fetchFile).toHaveBeenCalled() - expect(Actions.fetchFile.calls.length).toBe(1) - expect(Actions.fetchFile.calls[0].args[0]).toBe @file2 - - it 'injects an Attachment component for non image files', -> - els = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@composer, InjectedComponent, matching: {role: "Attachment"}) - expect(els.length).toBe 1 - - it 'injects an Attachment:Image component for image files', -> - els = ReactTestUtils.scryRenderedComponentsWithTypeAndProps(@composer, InjectedComponent, matching: {role: "Attachment:Image"}) - expect(els.length).toBe 1 - -describe "when the DraftStore `isSending` isn't stubbed out", -> - beforeEach -> - DraftStore._draftsSending = {} - - it "doesn't send twice in a popout", -> - spyOn(Actions, "queueTask") - spyOn(Actions, "sendDraft").andCallThrough() - useFullDraft.call(@) - makeComposer.call(@) - @composer._sendDraft() - @composer._sendDraft() - expect(Actions.sendDraft.calls.length).toBe 1 - - it "doesn't send twice in the main window", -> - spyOn(Actions, "queueTask") - spyOn(Actions, "sendDraft").andCallThrough() - spyOn(NylasEnv, "isMainWindow").andReturn true - useFullDraft.call(@) - makeComposer.call(@) - @composer._sendDraft() - @composer._sendDraft() - expect(Actions.sendDraft.calls.length).toBe 1 + it "doesn't send twice in the main window", -> + spyOn(Actions, "queueTask") + spyOn(Actions, "sendDraft").andCallThrough() + spyOn(NylasEnv, "isMainWindow").andReturn true + useFullDraft.call(@) + makeComposer.call(@) + @composer._sendDraft() + @composer._sendDraft() + expect(Actions.sendDraft.calls.length).toBe 1 diff --git a/internal_packages/composer/spec/quoted-text-spec.cjsx b/internal_packages/composer/spec/quoted-text-spec.cjsx index 768f022ad..3a635766e 100644 --- a/internal_packages/composer/spec/quoted-text-spec.cjsx +++ b/internal_packages/composer/spec/quoted-text-spec.cjsx @@ -9,10 +9,18 @@ ReactTestUtils = React.addons.TestUtils Fields = require '../lib/fields' Composer = require "../lib/composer-view" -{DraftStore} = require 'nylas-exports' +ComposerEditor = require '../lib/composer-editor' + +{DraftStore, ComponentRegistry} = require 'nylas-exports' describe "Composer Quoted Text", -> beforeEach -> + # TODO + # Extract ComposerEditor tests instead of rendering injected component + # here + ComposerEditor.containerRequired = false + ComponentRegistry.register(ComposerEditor, role: "Composer:Editor") + @onChange = jasmine.createSpy('onChange') @htmlNoQuote = 'Test HTML
' @htmlWithQuote = 'Test HTML
QUOTE
' @@ -28,6 +36,8 @@ describe "Composer Quoted Text", -> afterEach -> DraftStore._cleanupAllSessions() + ComposerEditor.containerRequired = undefined + ComponentRegistry.unregister(ComposerEditor) # Must be called with the test's scope setHTML = (newHTML) -> diff --git a/internal_packages/message-list/lib/message-item-container.cjsx b/internal_packages/message-list/lib/message-item-container.cjsx index b6eb65089..94c1b7815 100644 --- a/internal_packages/message-list/lib/message-item-container.cjsx +++ b/internal_packages/message-list/lib/message-item-container.cjsx @@ -18,7 +18,7 @@ class MessageItemContainer extends React.Component collapsed: React.PropTypes.bool isLastMsg: React.PropTypes.bool isBeforeReplyArea: React.PropTypes.bool - onRequestScrollTo: React.PropTypes.func + scrollTo: React.PropTypes.func constructor: (@props) -> @state = @_getStateFromStores() @@ -62,7 +62,7 @@ class MessageItemContainer extends React.Component mode: "inline" draftClientId: @props.message.clientId threadId: @props.thread.id - onRequestScrollTo: @props.onRequestScrollTo + scrollTo: @props.scrollTo + scrollTo={@_scrollTo} /> ) if hasReplyArea @@ -381,7 +381,7 @@ class MessageList extends React.Component # # If messageId and location are defined, that means we want to scroll # smoothly to the top of a particular message. - _onChildScrollRequest: ({clientId, rect, position}={}) => + _scrollTo: ({clientId, rect, position}={}) => return if @_draftScrollInProgress if clientId messageElement = @_getMessageContainer(clientId) diff --git a/spec/quoted-html-transformer-spec.coffee b/spec/quoted-html-transformer-spec.coffee index ed5070d8a..5d29fd8dd 100644 --- a/spec/quoted-html-transformer-spec.coffee +++ b/spec/quoted-html-transformer-spec.coffee @@ -256,7 +256,6 @@ describe "QuotedHTMLTransformer", ->
""" - it 'works with these manual test cases', -> for {before, after} in tests opts = keepIfWholeBodyIsQuote: true @@ -273,6 +272,26 @@ describe "QuotedHTMLTransformer", -> expect0 = "hello

world
" expect(QuotedHTMLTransformer.removeQuotedHTML(input0)).toEqual expect0 + it 'works as expected when body tag inside the html', -> + input0 = """ +

+ On Dec 16 2015, at 7:08 pm, Juan Tejada <juan@nylas.com> wrote: +
+ + + + + +

h2

+

he he hehehehehehe

+

dufjcasc

+ + +
+ """ + expect0 = "
" + expect(QuotedHTMLTransformer.removeQuotedHTML(input0)).toEqual expect0 # We have a little utility method that you can manually uncomment to diff --git a/src/components/contenteditable/contenteditable.cjsx b/src/components/contenteditable/contenteditable.cjsx index 3e409925e..2fa232c49 100644 --- a/src/components/contenteditable/contenteditable.cjsx +++ b/src/components/contenteditable/contenteditable.cjsx @@ -35,13 +35,11 @@ class Contenteditable extends React.Component # The current html state, as a string, of the contenteditable. value: React.PropTypes.string - initialSelectionSnapshot: React.PropTypes.object + # Initial content selection that was previously saved + initialSelectionSnapshot: React.PropTypes.object, # Handlers onChange: React.PropTypes.func.isRequired - # Passes an absolute top coordinate to scroll to. - onScrollTo: React.PropTypes.func - onScrollToBottom: React.PropTypes.func onFilePaste: React.PropTypes.func # A list of objects that extend {ContenteditableExtension} @@ -549,7 +547,7 @@ class Contenteditable extends React.Component endNodeIndex: DOMUtils.getNodeIndex(context, selection.focusNode) isCollapsed: selection.isCollapsed - @_ensureSelectionVisible(selection) + @_onSelectionChanged(selection) @setInnerState selection: @_selection @@ -564,54 +562,11 @@ class Contenteditable extends React.Component selection: @_selection editableFocused: true - # When the selectionState gets set by a parent (e.g. undo-ing and - # redo-ing) we need to make sure it's visible to the user. - # - # Unfortunately, we can't use the native `scrollIntoView` because it - # naively scrolls the whole window and doesn't know not to scroll if - # it's already in view. There's a new native method called - # `scrollIntoViewIfNeeded`, but this only works when the scroll - # container is a direct parent of the requested element. In this case - # the scroll container may be many levels up. - _ensureSelectionVisible: (selection) -> - # If our parent supports scroll to bottom, check for that - if @_shouldScrollToBottom(selection) - @props.onScrollToBottom() - - # Don't bother computing client rects if no scroll method has been provided - else if @props.onScrollTo - rangeInScope = DOMUtils.getRangeInScope(@_editableNode()) - return unless rangeInScope - - rect = rangeInScope.getBoundingClientRect() - if DOMUtils.isEmptyBoudingRect(rect) - rect = DOMUtils.getSelectionRectFromDOM(selection) - - if rect - @props.onScrollTo({rect}) - + _onSelectionChanged: (selection) -> + @props.onSelectionChanged(selection, @_editableNode()) # The bounding client rect has changed @setInnerState editableNode: @_editableNode() - # As you're typing a lot of content and the cursor begins to scroll off - # to the bottom, we want to make it look like we're tracking your - # typing. - _shouldScrollToBottom: (selection) -> - (@props.onScrollToBottom and - DOMUtils.atEndOfContent(selection, @_editableNode()) and - @_bottomIsNearby()) - - # If the bottom of the container we're scrolling to is really far away - # from this contenteditable and your scroll position, we don't want to - # jump away. This can commonly happen if the composer has a very tall - # image attachment. The "send" button may be 1000px away from the bottom - # of the contenteditable. props.onScrollToBottom moves to the bottom of - # the "send" button. - _bottomIsNearby: -> - parentRect = @props.getComposerBoundingRect() - selfRect = @_editableNode().getBoundingClientRect() - return Math.abs(parentRect.bottom - selfRect.bottom) <= 250 - # We use global listeners to determine whether or not dragging is # happening. This is because dragging may stop outside the scope of # this element. Note that the `dragstart` and `dragend` events don't @@ -741,7 +696,7 @@ class Contenteditable extends React.Component newEndNode, @_selection.endOffset) - @_ensureSelectionVisible(selection) + @_onSelectionChanged(selection) @_setupListeners() # This needs to be in the contenteditable area because we need to first diff --git a/src/components/injected-component-set.cjsx b/src/components/injected-component-set.cjsx index 7710fb83e..86ee538ab 100644 --- a/src/components/injected-component-set.cjsx +++ b/src/components/injected-component-set.cjsx @@ -57,6 +57,7 @@ class InjectedComponentSet extends React.Component className: React.PropTypes.string exposedProps: React.PropTypes.object containersRequired: React.PropTypes.bool + requiredMethods: React.PropTypes.arrayOf(React.PropTypes.string) @defaultProps: direction: 'row' diff --git a/src/components/injected-component.cjsx b/src/components/injected-component.cjsx index 49343419e..84a3e90a5 100644 --- a/src/components/injected-component.cjsx +++ b/src/components/injected-component.cjsx @@ -41,14 +41,28 @@ class InjectedComponent extends React.Component - `exposedProps` (optional) An {Object} with props that will be passed to each item rendered into the set. + - `requiredMethods` (options) An {Array} with a list of methods that should be + implemented by the registered component instance. If these are not implemented, + an error will be thrown. + + - `fallback` (optional) A {Component} to default to in case there are no matching + components in the ComponentRegistry + ### @propTypes: matching: React.PropTypes.object.isRequired className: React.PropTypes.string exposedProps: React.PropTypes.object + fallback: React.PropTypes.func + requiredMethods: React.PropTypes.arrayOf(React.PropTypes.string) + + @defaultProps: + requiredMethods: [] constructor: (@props) -> @state = @_getStateFromStores() + @_verifyRequiredMethods() + @_setRequiredMethods(@props.requiredMethods) componentDidMount: => @_componentUnlistener = ComponentRegistry.listen => @@ -61,6 +75,9 @@ class InjectedComponent extends React.Component if not _.isEqual(newProps.matching, @props?.matching) @setState(@_getStateFromStores(newProps)) + componentDidUpdate: => + @_setRequiredMethods(@props.requiredMethods) + render: => return
unless @state.component @@ -69,7 +86,6 @@ class InjectedComponent extends React.Component className += " registered-region-visible" if @state.visible component = @state.component - if component.containerRequired is false element = else @@ -96,6 +112,29 @@ class InjectedComponent extends React.Component # Note that our inner may not be populated, and it may not have a blur method @refs.inner.blur() if @refs.inner?.blur? + _setRequiredMethods: (methods) => + methods.forEach (method) => + Object.defineProperty(@, method, + configurable: true + enumerable: true + get: => + if @refs.inner instanceof UnsafeComponent + @refs.inner.injected[method]?.bind(@refs.inner.injected) + else + @refs.inner[method]?.bind(@refs.inner) + ) + + _verifyRequiredMethods: => + if @state.component? + component = @state.component + @props.requiredMethods.forEach (method) => + isMethodDefined = @state.component.prototype[method]? + unless isMethodDefined + throw new Error( + "#{component.name} must implement method `#{method}` when registering + for #{JSON.stringify(@props.matching)}" + ) + _getStateFromStores: (props) => props ?= @props @@ -104,8 +143,12 @@ class InjectedComponent extends React.Component console.warn("There are multiple components available for \ #{JSON.stringify(props.matching)}. is \ only rendering the first one.") + component = if components.length is 0 + @props.fallback + else + components[0] - component: components[0] + component: component visible: ComponentRegistry.showComponentRegions() module.exports = InjectedComponent diff --git a/src/dom-utils.coffee b/src/dom-utils.coffee index a36ac54de..9b37a18a3 100644 --- a/src/dom-utils.coffee +++ b/src/dom-utils.coffee @@ -277,7 +277,7 @@ DOMUtils = return (scope.contains(selection.anchorNode) and scope.contains(selection.focusNode)) - isEmptyBoudingRect: (rect) -> + isEmptyBoundingRect: (rect) -> rect.top is 0 and rect.bottom is 0 and rect.left is 0 and rect.right is 0 atEndOfContent: (selection, rootScope, containerScope) ->