From 96da7ccb2ddd2edf78e2707df7846c2e74641e5b Mon Sep 17 00:00:00 2001 From: Juan Tejada Date: Fri, 18 Dec 2015 11:03:58 -0800 Subject: [PATCH] feat(editor-region): Add support to register components as editors Summary: - The main purpose of this is to be able to properly register the editor for the markdown plugin (and any other plugins to come) - Refactors ComposerView and Contenteditable -> - Replaces Contenteditable with an InjectedComponent for a new region role: "Composer:Editor" - Creates a new component called ComposerEditor, which is the one that is being registered by default as "Composer:Editor" - I used this class to try to standardize the props that should be passed to any would be editor Component: - Renamed a bunch of the props which (I think) had a bit of confusing names - Added a bunch of docs for these in the source file, although I feel like those docs should live elsewhere, like in the ComponentRegion docs. - In the process, I ended up pulling some stuff out of ComposerView and some stuff out of the Contenteditable, namely: - The scrolling logic to ensure that the composer is visible while typing was moved outside of the Contenteditable -- this feels more like the ComposerEditor's responsibility, especially since the Contenteditable is meant to be used in other contexts as well. - The ComposerExtensions state; it feels less awkward for me if this is inside the ComposerEditor because 1) ComposerView does less things, 2) these are actually just being passed to the Contenteditable, 3) I feel like other plugins shouldn't need to mess around with ComposerExtensions, so we shouldn't pass them to the editor. If you register an editor different from our default one, any other ComposerExtension callbacks will be disabled, which I feel is expected behavior. - I think there is still some more refactoring to be done, and I left some TODOS here and there, but I think this diff is already big enough and its a minimal set of changes to get the markdown editor working in a not so duck tapish way. - New props for InjectedComponent: - `requiredMethods`: allows you to define a collection of methods that should be implemented by any Component that registers for your desired region. - It will throw an error if these are not implemented - It will automatically pass calls made on the InjectedComponent to these methods down to the instance of the actual registered component - Would love some comments on this approach and impl - `fallback`: allows you to define a default component to use if none were registered through the ComponentRegistry - Misc: - Added a new test case for the QuotedHTMLTransformer - Tests: - They were minimally updated so that they don't break, but a big TODO is to properly refactor them. I plan to do that in an upcoming diff. Test Plan: - Unit tests Reviewers: bengotow, evan Reviewed By: evan Differential Revision: https://phab.nylas.com/D2372 --- .../composer/lib/composer-editor.jsx | 231 +++ .../composer/lib/composer-view.cjsx | 85 +- .../composer/spec/composer-view-spec.cjsx | 1259 +++++++++-------- .../composer/spec/quoted-text-spec.cjsx | 12 +- .../lib/message-item-container.cjsx | 4 +- .../message-list/lib/message-list.cjsx | 4 +- spec/quoted-html-transformer-spec.coffee | 21 +- .../contenteditable/contenteditable.cjsx | 57 +- src/components/injected-component-set.cjsx | 1 + src/components/injected-component.cjsx | 47 +- src/dom-utils.coffee | 2 +- 11 files changed, 996 insertions(+), 727 deletions(-) create mode 100644 internal_packages/composer/lib/composer-editor.jsx 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) ->