You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@couchdb.apache.org by be...@apache.org on 2015/09/09 20:58:42 UTC

[2/2] fauxton commit: updated refs/heads/master to 8cd744a

Reactifying full page doc editor

This PR converts the Full Page Document Editor to React, including
all subcomponents. It drops the old Backbone version of the
code editor.

File uploads now have a progress bar and work locally.


Project: http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/repo
Commit: http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/commit/8cd744ac
Tree: http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/tree/8cd744ac
Diff: http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/diff/8cd744ac

Branch: refs/heads/master
Commit: 8cd744acb4f7bba17b3b578056f5e7e3199a2016
Parents: 3da9276
Author: Ben Keen <be...@gmail.com>
Authored: Mon Jun 8 15:43:59 2015 -0700
Committer: Ben Keen <be...@gmail.com>
Committed: Wed Sep 9 12:00:48 2015 -0700

----------------------------------------------------------------------
 .../components/assets/less/code-editor.less     |   7 +
 .../components/react-components.react.jsx       | 290 ++++++++-
 .../tests/stringEditModalSpec.react.jsx         |  75 +++
 .../documents/assets/less/doc-editor.less       | 181 ++++++
 app/addons/documents/assets/less/documents.less |   6 +-
 .../documents/assets/less/view-editor.less      |   1 -
 app/addons/documents/doc-editor/actions.js      | 239 +++++++
 app/addons/documents/doc-editor/actiontypes.js  |  31 +
 .../documents/doc-editor/components.react.jsx   | 498 +++++++++++++++
 app/addons/documents/doc-editor/stores.js       | 201 ++++++
 .../tests/doc-editor.componentsSpec.react.jsx   | 187 ++++++
 .../doc-editor/tests/doc-editor.storesSpec.js   |  81 +++
 .../documents/index-editor/components.react.jsx |  20 +-
 .../tests/viewIndex.componentsSpec.react.jsx    |  24 +-
 .../documents/mango/mango.components.react.jsx  |  11 +-
 app/addons/documents/routes-doc-editor.js       |  57 +-
 app/addons/documents/routes-documents.js        |   9 +-
 app/addons/documents/templates/code_editor.html |  78 ---
 .../templates/duplicate_doc_modal.html          |  36 --
 .../documents/templates/string_edit_modal.html  |  30 -
 .../documents/templates/upload_modal.html       |  42 --
 .../tests/nightwatch/createsDocument.js         |   5 +-
 .../tests/nightwatch/deletesDocuments.js        |   2 +
 .../documents/tests/nightwatch/mangoIndex.js    |   4 +-
 .../documents/tests/nightwatch/mangoQuery.js    |   4 +-
 .../tests/nightwatch/uploadAttachment.js        |   5 +-
 .../documents/tests/nightwatch/viewCreate.js    |  20 +-
 .../tests/nightwatch/viewCreateBadView.js       |   4 +-
 .../documents/tests/nightwatch/viewEdit.js      |  12 +-
 app/addons/documents/views-doceditor.js         | 621 -------------------
 app/addons/documents/views.js                   |   3 +-
 app/addons/fauxton/components.js                | 217 -------
 app/addons/fauxton/components.react.jsx         |  53 +-
 assets/less/code-editors.less                   |  53 ++
 assets/less/codeeditor.less                     | 192 ------
 assets/less/fauxton.less                        |   2 +-
 36 files changed, 1960 insertions(+), 1341 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/components/assets/less/code-editor.less
----------------------------------------------------------------------
diff --git a/app/addons/components/assets/less/code-editor.less b/app/addons/components/assets/less/code-editor.less
index 522aa74..571882e 100644
--- a/app/addons/components/assets/less/code-editor.less
+++ b/app/addons/components/assets/less/code-editor.less
@@ -22,6 +22,13 @@
     background-color: #ffffff;
   }
 
+  &>div {
+    height: 100%;
+    &.zen-mode-controls {
+      height: 96px;
+    }
+  }
+
   .ace_editor {
     height: 100%;
     font-size: 15px;

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/components/react-components.react.jsx
----------------------------------------------------------------------
diff --git a/app/addons/components/react-components.react.jsx b/app/addons/components/react-components.react.jsx
index c64bd3d..3dc4b25 100644
--- a/app/addons/components/react-components.react.jsx
+++ b/app/addons/components/react-components.react.jsx
@@ -73,6 +73,7 @@ function (app, FauxtonAPI, React, Components, ace, beautifyHelper) {
     getDefaultProps: function () {
       return {
         id: 'code-editor',
+        className: '',
         defaultCode: '',
         title: '',
         docLink: '',
@@ -145,10 +146,8 @@ function (app, FauxtonAPI, React, Components, ace, beautifyHelper) {
     },
 
     exitZenMode: function (content) {
-      this.setState({
-        zenModeEnabled: false,
-        code: content
-      });
+      this.setState({ zenModeEnabled: false });
+      this.getEditor().setValue(content);
     },
 
     getEditor: function () {
@@ -163,9 +162,17 @@ function (app, FauxtonAPI, React, Components, ace, beautifyHelper) {
       this.setState({ code: code });
     },
 
+    update: function () {
+      this.getEditor().setValue(this.state.code);
+    },
+
     render: function () {
+      var classes = 'control-group';
+      if (this.props.className) {
+        classes += ' ' + this.props.className;
+      }
       return (
-        <div className="control-group">
+        <div className={classes}>
           <label>
             <strong>{this.props.title + ' '}</strong>
             {this.getDocIcon()}
@@ -189,7 +196,6 @@ function (app, FauxtonAPI, React, Components, ace, beautifyHelper) {
   });
 
 
-  // a generic Ace Editor component. This should be the only place in the app that instantiates an editor
   var CodeEditor = React.createClass({
     getDefaultProps: function () {
       return {
@@ -197,12 +203,17 @@ function (app, FauxtonAPI, React, Components, ace, beautifyHelper) {
         mode: 'javascript',
         theme: 'idle_fingers',
         fontSize: 13,
+
+        // this sets the default value for the editor. On the fly changes are stored in state in this component only. To
+        // change the editor content after initial construction use CodeEditor.setValue()
         defaultCode: '',
+
         showGutter: true,
         highlightActiveLine: true,
         showPrintMargin: false,
         autoScrollEditorIntoView: true,
         autoFocus: false,
+        stringEditModalEnabled: false,
 
         // these two options create auto-resizeable code editors, with a maximum number of lines
         setHeightToLineCount: false,
@@ -214,7 +225,7 @@ function (app, FauxtonAPI, React, Components, ace, beautifyHelper) {
         // notifies users that there is unsaved changes in the editor when navigating away from the page
         notifyUnsavedChanges: false,
 
-        // an optional array of ignorable Ace editors. Lets us filter out errors based on context
+        // an optional array of ignorable Ace errors. Lets us filter out errors based on context
         ignorableErrors: [],
 
         // un-Reacty, but the code editor is a self-contained component and it's helpful to be able to tie into
@@ -224,10 +235,15 @@ function (app, FauxtonAPI, React, Components, ace, beautifyHelper) {
       };
     },
 
-    // used purely to keep track of content changes. This is only reset via an explicit clearChanges() call
     getInitialState: function () {
       return {
-        originalCode: this.props.defaultCode
+        originalCode: this.props.defaultCode,
+
+        // these are all related to the (optional) string edit modal
+        stringEditModalVisible: false,
+        stringEditIconVisible: false,
+        stringEditIconStyle: {},
+        stringEditModalDefaultString: ''
       };
     },
 
@@ -248,11 +264,12 @@ function (app, FauxtonAPI, React, Components, ace, beautifyHelper) {
       this.editor.$blockScrolling = Infinity;
 
       if (shouldUpdateCode) {
-        this.setEditorValue(props.defaultCode);
+        this.setValue(props.defaultCode);
       }
 
       this.editor.setShowPrintMargin(props.showPrintMargin);
       this.editor.autoScrollEditorIntoView = props.autoScrollEditorIntoView;
+
       this.editor.setOption('highlightActiveLine', this.props.highlightActiveLine);
 
       if (this.props.setHeightToLineCount) {
@@ -267,6 +284,7 @@ function (app, FauxtonAPI, React, Components, ace, beautifyHelper) {
       this.editor.getSession().setMode('ace/mode/' + props.mode);
       this.editor.setTheme('ace/theme/' + props.theme);
       this.editor.setFontSize(props.fontSize);
+      this.editor.getSession().setTabSize(2);
       this.editor.getSession().setUseSoftTabs(true);
 
       if (this.props.autoFocus) {
@@ -283,6 +301,13 @@ function (app, FauxtonAPI, React, Components, ace, beautifyHelper) {
     setupEvents: function () {
       this.editor.on('blur', _.bind(this.onBlur, this));
       this.editor.on('change', _.bind(this.onContentChange, this));
+
+      if (this.props.stringEditModalEnabled) {
+        this.editor.on('changeSelection', _.bind(this.showHideEditStringGutterIcon, this));
+        this.editor.getSession().on('changeBackMarker', _.bind(this.showHideEditStringGutterIcon, this));
+        this.editor.getSession().on('changeScrollTop', _.bind(this.updateEditStringGutterIconPosition, this));
+      }
+
       if (this.props.notifyUnsavedChanges) {
         $(window).on('beforeunload.editor_' + this.props.id, _.bind(this.quitWarningMsg));
         FauxtonAPI.beforeUnload('editor_' + this.props.id, _.bind(this.quitWarningMsg, this));
@@ -302,7 +327,7 @@ function (app, FauxtonAPI, React, Components, ace, beautifyHelper) {
 
     quitWarningMsg: function () {
       if (this.hasChanged()) {
-        return 'Your changes have not been saved. Click cancel to return to the document.';
+        return 'Your changes have not been saved. Click Cancel to return to the document, or OK to proceed.';
       }
     },
 
@@ -324,6 +349,10 @@ function (app, FauxtonAPI, React, Components, ace, beautifyHelper) {
     componentDidMount: function () {
       this.setupAce(this.props, true);
       this.setupEvents();
+
+      if (this.props.autoFocus) {
+        this.editor.focus();
+      }
     },
 
     componentWillUnmount: function () {
@@ -332,8 +361,7 @@ function (app, FauxtonAPI, React, Components, ace, beautifyHelper) {
     },
 
     componentWillReceiveProps: function (nextProps) {
-      var codeChanged = !_.isEqual(nextProps.defaultCode, this.getValue());
-      this.setupAce(nextProps, codeChanged);
+      this.setupAce(nextProps, false);
     },
 
     getAnnotations: function () {
@@ -361,18 +389,100 @@ function (app, FauxtonAPI, React, Components, ace, beautifyHelper) {
       }.bind(this));
     },
 
-    // ------------------
-    // TODO two things to do after full page doc editor refactor:
-    // 1. rename to hasErrors()
-    hadValidCode: function () {
-      var errors = this.getAnnotations();
-      return _.every(errors, function (error) {
+    showHideEditStringGutterIcon: function (e) {
+      if (this.hasErrors() || !this.parseLineForStringMatch()) {
+        this.setState({ stringEditIconVisible: false });
+        return false;
+      }
+
+      this.setState({
+        stringEditIconVisible: true,
+        stringEditIconStyle: {
+          top: this.getGutterIconPosition()
+        }
+      });
+
+      return true;
+    },
+
+    updateEditStringGutterIconPosition: function () {
+      if (!this.state.stringEditIconVisible) {
+        return;
+      }
+      this.setState({
+        stringEditIconStyle: {
+          top: this.getGutterIconPosition()
+        }
+      });
+    },
+
+    getGutterIconPosition: function () {
+      var rowHeight = this.getRowHeight();
+      var scrollTop = this.editor.session.getScrollTop();
+      var positionFromTop = (rowHeight * this.documentToScreenRow(this.getSelectionStart().row)) - scrollTop;
+      return positionFromTop + 'px';
+    },
+
+    parseLineForStringMatch: function () {
+      var selStart = this.getSelectionStart().row;
+      var selEnd   = this.getSelectionEnd().row;
+
+      // one JS(ON) string can't span more than one line - we edit one string, so ensure we don't select several lines
+      if (selStart >= 0 && selEnd >= 0 && selStart === selEnd && this.isRowExpanded(selStart)) {
+        var editLine = this.getLine(selStart),
+            editMatch = editLine.match(/^([ \t]*)("[a-zA-Z0-9_]*["|']: )?(["|'].*",?[ \t]*)$/);
+
+        if (editMatch) {
+          return editMatch;
+        }
+      }
+      return false;
+    },
+
+    openStringEditModal: function () {
+      var matches = this.parseLineForStringMatch();
+      var string = matches[3];
+      var lastChar = string.length - 1;
+      if (string.substring(string.length - 1) === ',') {
+        lastChar = string.length - 2;
+      }
+      string = string.substring(1, lastChar);
+
+      this.setState({ stringEditModalVisible: true });
+      this.refs.stringEditModal.setValue(string);
+    },
+
+    saveStringEditModal: function (newString) {
+      // replace the string on the selected line
+      var line = this.parseLineForStringMatch();
+      var indent = line[1] || '',
+          key = line[2] || '',
+          originalString = line[3],
+          comma = '';
+      if (originalString.substring(originalString.length - 1) === ',') {
+        comma = ',';
+      }
+      this.replaceCurrentLine(indent + key + JSON.stringify(newString) + comma + '\n');
+      this.closeStringEditModal();
+    },
+
+    closeStringEditModal: function () {
+      this.setState({
+        stringEditModalVisible: false
+      });
+    },
+
+    hasErrors: function () {
+      return !_.every(this.getAnnotations(), function (error) {
         return this.isIgnorableError(error.raw);
       }, this);
     },
-    // ------------------
 
-    setEditorValue: function (code, lineNumber) {
+    setReadOnly: function (readonly) {
+      this.editor.setReadOnly(readonly);
+    },
+
+    setValue: function (code, lineNumber) {
       lineNumber = lineNumber ? lineNumber : -1;
       this.editor.setValue(code, lineNumber);
     },
@@ -385,18 +495,145 @@ function (app, FauxtonAPI, React, Components, ace, beautifyHelper) {
       return this;
     },
 
+    getLine: function (lineNum) {
+      return this.editor.session.getLine(lineNum);
+    },
+
+    getSelectionStart: function () {
+      return this.editor.getSelectionRange().start;
+    },
+
+    getSelectionEnd: function () {
+      return this.editor.getSelectionRange().end;
+    },
+
+    getRowHeight: function () {
+      return this.editor.renderer.layerConfig.lineHeight;
+    },
+
+    isRowExpanded: function (row) {
+      return !this.editor.getSession().isRowFolded(row);
+    },
+
+    documentToScreenRow: function (row) {
+      return this.editor.getSession().documentToScreenRow(row, 0);
+    },
+
+    replaceCurrentLine: function (replacement) {
+      this.editor.getSelection().selectLine();
+      this.editor.insert(replacement);
+      this.editor.getSelection().moveCursorUp();
+    },
+
+    render: function () {
+      return (
+        <div>
+          <div ref="ace" className="js-editor" id={this.props.id}></div>
+          <button ref="stringEditIcon" className="btn string-edit" title="Edit string" disabled={!this.state.stringEditIconVisible}
+            style={this.state.stringEditIconStyle} onClick={this.openStringEditModal}>
+            <i className="icon icon-edit"></i>
+          </button>
+          <StringEditModal
+            ref="stringEditModal"
+            visible={this.state.stringEditModalVisible}
+            onSave={this.saveStringEditModal}
+            onClose={this.closeStringEditModal} />
+        </div>
+      );
+    }
+  });
+
+
+  // this appears when the cursor is over a string. It shows an icon in the gutter that opens the modal.
+  var StringEditModal = React.createClass({
+
+    propTypes: {
+      visible: React.PropTypes.bool.isRequired,
+      onClose: React.PropTypes.func.isRequired,
+      onSave: React.PropTypes.func.isRequired
+    },
+
+    getDefaultProps: function () {
+      return {
+        visible: false,
+        onClose: function () { },
+        onSave: function () { }
+      };
+    },
+
+    componentDidUpdate: function () {
+      var params = (this.props.visible) ? { show: true, backdrop: 'static', keyboard: true } : 'hide';
+      $(this.getDOMNode()).modal(params);
+
+      $(this.getDOMNode()).on('shown.bs.modal', function () {
+        this.editor.focus();
+
+        // re-opening the modal to edit a second string doesn't update the content. This forces the editor to redraw
+        // to show the latest content each time it opens
+        this.editor.resize();
+        this.editor.renderer.updateFull();
+      }.bind(this));
+    },
+
+    // ensure that if the user clicks ESC to close the window, the store gets wind of it
+    componentDidMount: function () {
+      $(this.getDOMNode()).on('hidden.bs.modal', function () {
+        this.props.onClose();
+      }.bind(this));
+
+      this.editor = ace.edit(this.refs.stringEditor.getDOMNode());
+
+      // suppresses an Ace editor error
+      this.editor.$blockScrolling = Infinity;
+
+      this.editor.setShowPrintMargin(false);
+      this.editor.setOption('highlightActiveLine', true);
+      this.editor.setTheme('ace/theme/idle_fingers');
+    },
+
+    setValue: function (val) {
+      this.editor.setValue(val, -1);
+    },
+
+    componentWillUnmount: function () {
+      $(this.getDOMNode()).off('hidden.bs.modal shown.bs.modal');
+    },
+
+    closeModal: function () {
+      this.props.onClose();
+    },
+
+    save: function () {
+      this.props.onSave(this.editor.getValue());
+    },
+
     render: function () {
       return (
-        <div ref="ace" className="js-editor" id={this.props.id}></div>
+        <div className="modal hide fade string-editor-modal" tabIndex="-1">
+          <div className="modal-header">
+            <button type="button" className="close" onClick={this.closeModal} aria-hidden="true">&times;</button>
+            <h3>Edit text <span id="string-edit-header"></span></h3>
+          </div>
+          <div className="modal-body">
+            <div id="modal-error" className="hide alert alert-error"/>
+            <div id="string-editor-wrapper"><div ref="stringEditor" className="doc-code"></div></div>
+          </div>
+          <div className="modal-footer">
+            <button className="cancel-button btn" onClick={this.closeModal}><i className="icon fonticon-circle-x"></i> Cancel</button>
+            <button id="string-edit-save-btn" onClick={this.save} className="btn btn-success save">
+              <i className="fonticon-circle-check"></i> Save
+            </button>
+          </div>
+        </div>
       );
     }
   });
 
 
   // Zen mode editing has very few options:
-  // - It covers the full screen hiding everything else.
-  // - It has two themes: light & dark (choice stored in local storage)
-  // - It has no save option, but has a 1-1 map with a <CodeEditor /> element which gets updated when the user leaves
+  // - It covers the full screen, hiding everything else
+  // - Two themes: light & dark (choice stored in local storage)
+  // - No save option, but has a 1-1 map with a <CodeEditor /> element which gets updated when the user leaves
   // - [Escape] closes the mode, as does clicking the shrink icon at the top right
   var ZenModeOverlay = React.createClass({
     getDefaultProps: function () {
@@ -456,7 +693,7 @@ function (app, FauxtonAPI, React, Components, ace, beautifyHelper) {
       app.utils.localStorageSet('zenTheme', newTheme);
     },
 
-    setEditorValue: function (code, lineNumber) {
+    setValue: function (code, lineNumber) {
       lineNumber = lineNumber ? lineNumber : -1;
       this.editor.setValue(code, lineNumber);
     },
@@ -850,6 +1087,7 @@ function (app, FauxtonAPI, React, Components, ace, beautifyHelper) {
     StyledSelect: StyledSelect,
     CodeEditorPanel: CodeEditorPanel,
     CodeEditor: CodeEditor,
+    StringEditModal: StringEditModal,
     ZenModeOverlay: ZenModeOverlay,
     Beautify: Beautify,
     PaddedBorderedBox: PaddedBorderedBox,

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/components/tests/stringEditModalSpec.react.jsx
----------------------------------------------------------------------
diff --git a/app/addons/components/tests/stringEditModalSpec.react.jsx b/app/addons/components/tests/stringEditModalSpec.react.jsx
new file mode 100644
index 0000000..a0b7539
--- /dev/null
+++ b/app/addons/components/tests/stringEditModalSpec.react.jsx
@@ -0,0 +1,75 @@
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+define([
+  'api',
+  'addons/components/react-components.react',
+  'testUtils',
+  'react'
+], function (FauxtonAPI, ReactComponents, utils, React) {
+
+  var assert = utils.assert;
+  var TestUtils = React.addons.TestUtils;
+
+  describe('String Edit Modal', function () {
+    var container, modalEl;
+    var stub = function () { };
+
+    beforeEach(function () {
+      container = document.createElement('div');
+    });
+
+    afterEach(function () {
+      React.unmountComponentAtNode(container);
+    });
+
+    describe('event methods called', function () {
+      it('onClose called by top (x)', function () {
+        var spy = sinon.spy();
+        modalEl = TestUtils.renderIntoDocument(
+          <ReactComponents.StringEditModal visible={true} onClose={spy} onSave={stub} />,
+          container
+        );
+        TestUtils.Simulate.click($(modalEl.getDOMNode()).find('.close')[0]);
+        assert.ok(spy.calledOnce);
+      });
+
+      it('onClose called by cancel button', function () {
+        var spy = sinon.spy();
+        modalEl = TestUtils.renderIntoDocument(
+          <ReactComponents.StringEditModal visible={true} onClose={spy} onSave={stub} />,
+          container
+        );
+        TestUtils.Simulate.click($(modalEl.getDOMNode()).find('.cancel-button')[0]);
+        assert.ok(spy.calledOnce);
+      });
+    });
+
+    describe('setValue / onSave', function () {
+      it('setValue ensures same content returns on saving', function () {
+        var spy = sinon.spy();
+        modalEl = TestUtils.renderIntoDocument(
+          <ReactComponents.StringEditModal visible={true} onClose={stub} onSave={spy} />,
+          container
+        );
+
+        var string = "a string!";
+
+        modalEl.setValue(string);
+        TestUtils.Simulate.click($(modalEl.getDOMNode()).find('#string-edit-save-btn')[0]);
+        assert.ok(spy.calledOnce);
+        assert.ok(spy.calledWith(string));
+      });
+    });
+
+  });
+
+});

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/documents/assets/less/doc-editor.less
----------------------------------------------------------------------
diff --git a/app/addons/documents/assets/less/doc-editor.less b/app/addons/documents/assets/less/doc-editor.less
new file mode 100644
index 0000000..3967a01
--- /dev/null
+++ b/app/addons/documents/assets/less/doc-editor.less
@@ -0,0 +1,181 @@
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+@import "../../../../../assets/less/mixins.less";
+
+
+#dashboard.doc-editor-page {
+  background-color: #4d4d4d;
+
+  #dashboard-content {
+    padding-bottom: 0;
+    top: 0;
+  }
+}
+
+#editor-container {
+  height: 100%;
+  &>div:not(.loading-lines), &>div>div {
+    height: 100%;
+  }
+}
+
+.doc-editor-page {
+  border-left: none;
+
+  .loading-lines {
+    margin-top: 30px;
+    div {
+      background-color: white;
+    }
+  }
+
+  .fixed-header {
+    height: 65px;
+    .box-shadow(none);
+  }
+
+  #breadcrumbs {
+    white-space: nowrap;
+  }
+
+  #doc-editor-actions-panel {
+    position: absolute;
+    top: 64px;
+    right: 0;
+    left: 0;
+    width: 100%;
+    background-color: #f1f1f1;
+    height: 61px;
+  }
+
+  #editor-container {
+    margin-top: 0;
+    font-size: 13px;
+
+    .ace_editor {
+      line-height: 22px;
+    }
+  }
+
+  .doc-actions-left {
+    float: left;
+    padding: 9px 0;
+    button {
+      margin: 0 10px 0 30px;
+    }
+    div {
+      display: inline-block;
+    }
+  }
+
+  .cancel-button {
+    font-size: 14px;
+  }
+
+  .bgEditorGutter {
+    width: 49px;
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    background-color: #3b3b3b;
+  }
+
+  .panel-button {
+    border: 0;
+    background-color: #f1f1f1;
+    padding: 11px;
+    color: #555555;
+
+    span:first-of-type {
+      margin-left: 4px;
+    }
+
+    .icon {
+      font-size: 18px;
+    }
+  }
+  .panel-section {
+    border-left: 1px solid #cccccc;
+    text-align: center;
+    padding: 9px 0;
+    display: inline-block;
+
+    &.open .dropdown-toggle, &:active {
+      box-shadow: none;
+      background-color: inherit;
+    }
+  }
+  .alignRight {
+    text-align: right;
+    font-size: 0; // prevents whitespace gaps between elements
+  }
+  .row-fluid.content-area {
+    background-color: #4d4d4d;
+  }
+
+  // overriding the ace editor inline styles
+  .ace_gutter-layer {
+    min-width: 49px;
+  }
+  .ace_gutter-cell {
+    min-width: 49px;
+  }
+
+  .ace_marker-layer .ace_bracket {
+    margin: 0;
+    border: 1px solid #999999;
+  }
+
+  // hide the labels on the buttons when the screen is shrunk too small,
+  @media screen and (max-width: 1000px) {
+    .panel-button span {
+      display: none;
+    }
+  }
+
+  // hides the API Url header link when the page is too small (prevents wrapping)
+  @media screen and (max-width: 835px) {
+    #api-navbar {
+      display: none;
+    }
+  }
+
+  #dashboard-content .code-region {
+    overflow-y: hidden;
+    position: absolute;
+    top: 125px;
+    height: auto;
+    width: 100%;
+    padding: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+  }
+}
+
+.upload-file-modal input[type="file"] {
+  margin-top: 10px;
+  height: inherit;
+  line-height: inherit;
+}
+
+.view-attachments-section .dropdown-menu {
+  max-width: 540px;
+  text-align: left;
+  overflow: hidden;
+}
+
+#editor-container div.string-editor-modal {
+  height: inherit;
+  max-height: 600px;
+}

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/documents/assets/less/documents.less
----------------------------------------------------------------------
diff --git a/app/addons/documents/assets/less/documents.less b/app/addons/documents/assets/less/documents.less
index 4d0725d..9ece2e5 100644
--- a/app/addons/documents/assets/less/documents.less
+++ b/app/addons/documents/assets/less/documents.less
@@ -18,7 +18,7 @@
 @import "changes.less";
 @import "sidenav.less";
 @import "index-results.less";
-
+@import "doc-editor.less";
 @import "header.less";
 
 button.beautify {
@@ -107,6 +107,10 @@ button.string-edit[disabled] {
 #string-editor-wrapper {
   height: 500px;
   width: 100%;
+
+  &>div {
+    height: 100%;
+  }
 }
 
 #keys-input {

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/documents/assets/less/view-editor.less
----------------------------------------------------------------------
diff --git a/app/addons/documents/assets/less/view-editor.less b/app/addons/documents/assets/less/view-editor.less
index 8421eff..ed31d40 100644
--- a/app/addons/documents/assets/less/view-editor.less
+++ b/app/addons/documents/assets/less/view-editor.less
@@ -16,7 +16,6 @@
   .define-view {
     padding-bottom: 70px;
   }
-
   .define-view {
     .help-link {
       margin-left: 3px;

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/documents/doc-editor/actions.js
----------------------------------------------------------------------
diff --git a/app/addons/documents/doc-editor/actions.js b/app/addons/documents/doc-editor/actions.js
new file mode 100644
index 0000000..04fc4a9
--- /dev/null
+++ b/app/addons/documents/doc-editor/actions.js
@@ -0,0 +1,239 @@
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+/* global FormData */
+
+define([
+  'app',
+  'api',
+  'addons/documents/doc-editor/actiontypes'
+],
+function (app, FauxtonAPI, ActionTypes) {
+
+  var xhr;
+
+  function initDocEditor (params) {
+    var doc = params.doc;
+
+    // ensure a clean slate
+    FauxtonAPI.dispatch({ type: ActionTypes.RESET_DOC });
+
+    doc.fetch().then(function () {
+      FauxtonAPI.dispatch({
+        type: ActionTypes.DOC_LOADED,
+        options: {
+          doc: doc
+        }
+      });
+
+      if (params.onLoaded) {
+        params.onLoaded();
+      }
+    }, function (xhr, reason, msg) {
+      if (xhr.status === 404) {
+        errorNotification('The document does not exist.');
+        FauxtonAPI.navigate(FauxtonAPI.urls('allDocs', 'app', params.database.id, ''));
+      }
+    });
+  }
+
+  function saveDoc (doc, isValidDoc, onSave) {
+    var databaseLink = doc.database.safeID();
+
+    if (isValidDoc) {
+      FauxtonAPI.addNotification({
+        msg: 'Saving document.',
+        clear: true
+      });
+
+      doc.save().then(function () {
+        onSave(doc.prettyJSON());
+        FauxtonAPI.navigate(FauxtonAPI.urls('document', 'app', databaseLink, doc.id));
+      }).fail(function (xhr) {
+        FauxtonAPI.addNotification({
+          msg: 'Save failed: ' + JSON.parse(xhr.responseText).reason,
+          type: 'error',
+          fade: false,
+          clear: true
+        });
+      });
+
+    } else if (doc.validationError && doc.validationError === 'Cannot change a documents id.') {
+      errorNotification('You cannot edit the _id of an existing document. Try this: Click \'Clone Document\', then change the _id on the clone before saving.');
+      delete doc.validationError;
+    } else {
+      errorNotification('Please fix the JSON errors and try saving again.');
+    }
+  }
+
+  function showDeleteDocModal () {
+    FauxtonAPI.dispatch({ type: ActionTypes.SHOW_DELETE_DOC_CONFIRMATION_MODAL });
+  }
+
+  function hideDeleteDocModal () {
+    FauxtonAPI.dispatch({ type: ActionTypes.HIDE_DELETE_DOC_CONFIRMATION_MODAL });
+  }
+
+  function deleteDoc (doc) {
+    var databaseName = doc.database.id;
+    doc.destroy().then(function () {
+      FauxtonAPI.addNotification({
+        msg: 'Your document has been successfully deleted.',
+        clear: true
+      });
+      FauxtonAPI.navigate(FauxtonAPI.urls('allDocs', 'app', databaseName, ''));
+    }, function () {
+      FauxtonAPI.addNotification({
+        msg: 'Failed to delete your document!',
+        type: 'error',
+        clear: true
+      });
+    });
+  }
+
+  function showCloneDocModal () {
+    FauxtonAPI.dispatch({ type: ActionTypes.SHOW_CLONE_DOC_MODAL });
+  }
+
+  function hideCloneDocModal () {
+    FauxtonAPI.dispatch({ type: ActionTypes.HIDE_CLONE_DOC_MODAL });
+  }
+
+  function cloneDoc (newId) {
+    var isDDoc = newId.match(/^_design\//),
+      removeDDocID = newId.replace(/^_design\//, ''),
+      encodedID = isDDoc ? '_design/' + app.utils.safeURLName(removeDDocID) : app.utils.safeURLName(newId);
+
+    this.hideCloneDocModal();
+    FauxtonAPI.triggerRouteEvent('duplicateDoc', encodedID);
+  }
+
+  function showUploadModal () {
+    FauxtonAPI.dispatch({ type: ActionTypes.SHOW_UPLOAD_MODAL });
+  }
+
+  function hideUploadModal () {
+    FauxtonAPI.dispatch({ type: ActionTypes.HIDE_UPLOAD_MODAL });
+  }
+
+  function uploadAttachment (params) {
+    if (params.files.length === 0) {
+      FauxtonAPI.dispatch({
+        type: ActionTypes.FILE_UPLOAD_ERROR,
+        options: {
+          error: 'Please select a file to be uploaded.'
+        }
+      });
+      return;
+    }
+
+    FauxtonAPI.dispatch({ type: ActionTypes.START_FILE_UPLOAD });
+
+    // store the xhr in parent scope to allow us to cancel any uploads if the user closes the modal
+    xhr = $.ajaxSettings.xhr();
+
+    var file = params.files[0];
+    $.ajax({
+      url: params.doc.url() + '/' + file.name + '?rev=' + params.rev,
+      type: 'PUT',
+      data: file,
+      contentType: file.type,
+      processData: false,
+      xhrFields: {
+        withCredentials: true
+      },
+      xhr: function () {
+        xhr.upload.onprogress = function (evt) {
+          var percentComplete = evt.loaded / evt.total * 100;
+          FauxtonAPI.dispatch({
+            type: ActionTypes.SET_FILE_UPLOAD_PERCENTAGE,
+            options: {
+              percent: percentComplete
+            }
+          });
+        };
+        return xhr;
+      },
+      success: function () {
+
+        // re-initialize the document editor. Only announce it's been updated when
+        initDocEditor({
+          doc: params.doc,
+          onLoaded: function () {
+            FauxtonAPI.dispatch({ type: ActionTypes.FILE_UPLOAD_SUCCESS });
+            FauxtonAPI.addNotification({
+              msg: 'Document saved successfully.',
+              type: 'success',
+              clear: true
+            });
+          }.bind(this)
+        });
+
+      },
+      error: function (resp) {
+        // cancelled uploads throw an ajax error but they don't contain a response. We don't want to publish an error
+        // event in those cases
+        if (_.isEmpty(resp.responseText)) {
+          return;
+        }
+        FauxtonAPI.dispatch({
+          type: ActionTypes.FILE_UPLOAD_ERROR,
+          options: {
+            error: JSON.parse(resp.responseText).reason
+          }
+        });
+      }
+    });
+  }
+
+  function cancelUpload () {
+    xhr.abort();
+  }
+
+  function resetUploadModal () {
+    FauxtonAPI.dispatch({ type: ActionTypes.RESET_UPLOAD_MODAL });
+  }
+
+
+  // helpers
+
+  function errorNotification (msg) {
+    FauxtonAPI.addNotification({
+      msg: msg,
+      type: 'error',
+      clear: true
+    });
+  }
+
+  return {
+    initDocEditor: initDocEditor,
+    saveDoc: saveDoc,
+
+    // clone doc
+    showCloneDocModal: showCloneDocModal,
+    hideCloneDocModal: hideCloneDocModal,
+    cloneDoc: cloneDoc,
+
+    // delete doc
+    showDeleteDocModal: showDeleteDocModal,
+    hideDeleteDocModal: hideDeleteDocModal,
+    deleteDoc: deleteDoc,
+
+    // upload modal
+    showUploadModal: showUploadModal,
+    hideUploadModal: hideUploadModal,
+    uploadAttachment: uploadAttachment,
+    cancelUpload: cancelUpload,
+    resetUploadModal: resetUploadModal
+  };
+
+});

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/documents/doc-editor/actiontypes.js
----------------------------------------------------------------------
diff --git a/app/addons/documents/doc-editor/actiontypes.js b/app/addons/documents/doc-editor/actiontypes.js
new file mode 100644
index 0000000..e669155
--- /dev/null
+++ b/app/addons/documents/doc-editor/actiontypes.js
@@ -0,0 +1,31 @@
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+define([], function () {
+  return {
+    RESET_DOC: 'RESET_DOC',
+    DOC_LOADED: 'DOC_LOADED',
+    SHOW_CLONE_DOC_MODAL: 'SHOW_CLONE_DOC_MODAL',
+    HIDE_CLONE_DOC_MODAL: 'HIDE_CLONE_DOC_MODAL',
+    SHOW_DELETE_DOC_CONFIRMATION_MODAL: 'SHOW_DELETE_DOC_CONFIRMATION_MODAL',
+    HIDE_DELETE_DOC_CONFIRMATION_MODAL: 'HIDE_DELETE_DOC_CONFIRMATION_MODAL',
+    SHOW_UPLOAD_MODAL: 'SHOW_UPLOAD_MODAL',
+    HIDE_UPLOAD_MODAL: 'HIDE_UPLOAD_MODAL',
+    RESET_UPLOAD_MODAL: 'RESET_UPLOAD_MODAL',
+    SHOW_STRING_EDIT_MODAL: 'SHOW_STRING_EDIT_MODAL',
+    HIDE_STRING_EDIT_MODAL: 'HIDE_STRING_EDIT_MODAL',
+    FILE_UPLOAD_SUCCESS: 'FILE_UPLOAD_SUCCESS',
+    FILE_UPLOAD_ERROR: 'FILE_UPLOAD_ERROR',
+    START_FILE_UPLOAD: 'START_FILE_UPLOAD',
+    SET_FILE_UPLOAD_PERCENTAGE: 'SET_FILE_UPLOAD_PERCENTAGE'
+  };
+});

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/documents/doc-editor/components.react.jsx
----------------------------------------------------------------------
diff --git a/app/addons/documents/doc-editor/components.react.jsx b/app/addons/documents/doc-editor/components.react.jsx
new file mode 100644
index 0000000..ddc7fa1
--- /dev/null
+++ b/app/addons/documents/doc-editor/components.react.jsx
@@ -0,0 +1,498 @@
+define([
+  'api',
+  'app',
+  'react',
+  'addons/documents/doc-editor/actions',
+  'addons/documents/doc-editor/stores',
+  'addons/fauxton/components.react',
+  'addons/components/react-components.react',
+  'helpers'
+], function (FauxtonAPI, app, React, Actions, Stores, FauxtonComponents, GeneralComponents, Helpers) {
+
+  var store = Stores.docEditorStore;
+
+
+  var DocEditorController = React.createClass({
+
+    getInitialState: function () {
+      return this.getStoreState();
+    },
+
+    getStoreState: function () {
+      return {
+        isLoading: store.isLoading(),
+        doc: store.getDoc(),
+        cloneDocModalVisible: store.isCloneDocModalVisible(),
+        uploadModalVisible: store.isUploadModalVisible(),
+        deleteDocModalVisible: store.isDeleteDocModalVisible(),
+        numFilesUploaded: store.getNumFilesUploaded(),
+      };
+    },
+
+    getDefaultProps: function () {
+      return {
+        database: {},
+        previousPage: '',
+        isNewDoc: false
+      };
+    },
+
+    getCodeEditor: function () {
+      if (this.state.isLoading) {
+        return (<GeneralComponents.LoadLines />);
+      }
+
+      var code = JSON.stringify(this.state.doc.attributes, null, '  ');
+      var editorCommands = [{
+        name: 'save',
+        bindKey: { win: 'Ctrl-S', mac: 'Ctrl-S' },
+        exec: this.saveDoc
+      }];
+
+      return (
+        <GeneralComponents.CodeEditor
+          id="doc-editor"
+          ref="docEditor"
+          defaultCode={code}
+          mode="json"
+          autoFocus={true}
+          editorCommands={editorCommands}
+          notifyUnsavedChanges={true}
+          stringEditModalEnabled={true}
+          change={this.onChangeDoc} />
+      );
+    },
+
+    onChangeDoc: function (doc) {
+      // super ugly, but necessary. This tells the user they can't delete the _id or _rev fields as they type and actually
+      // prevents it from being removed in the editable doc
+      var json;
+      try {
+        json = JSON.parse(doc);
+      } catch (exception) {
+        return;
+      }
+
+      var keyChecked = ['_id'];
+      if (this.state.doc.get('_rev')) {
+        keyChecked.push('_rev');
+      }
+      if (_.isEmpty(_.difference(keyChecked, _.keys(json)))) {
+        return;
+      }
+
+      this.getEditor().setReadOnly(true);
+      setTimeout(function () { this.getEditor().setReadOnly(false); }.bind(this), 400);
+
+      // extend ensures _id stays at the top of the editor doc
+      json = _.extend({ _id: this.state.doc.id, _rev: this.state.doc.get('_rev') }, json);
+      this.getEditor().setValue(JSON.stringify(json, null, '  '));
+
+      FauxtonAPI.addNotification({
+        type: 'error',
+        msg: "Cannot remove a document's id or revision.",
+        clear: true
+      });
+    },
+
+    componentDidMount: function () {
+      store.on('change', this.onChange, this);
+    },
+
+    componentWillUnmount: function () {
+      store.off('change', this.onChange);
+    },
+
+    // whenever a file is uploaded, reset the editor
+    componentWillUpdate: function (nextProps, nextState) {
+      if (this.state.numFilesUploaded !== nextState.numFilesUploaded) {
+        this.getEditor().setValue(JSON.stringify(nextState.doc.attributes, null, '  '));
+      }
+    },
+
+    onChange: function () {
+      if (this.isMounted()) {
+        this.setState(this.getStoreState());
+      }
+    },
+
+    saveDoc: function () {
+      Actions.saveDoc(this.state.doc, this.checkDocIsValid(), this.onSaveComplete);
+    },
+
+    onSaveComplete: function (json) {
+      this.getEditor().setValue(json);
+      this.getEditor().clearChanges();
+
+      // the save action updates the doc. This ensures the button row then shows the appropriate buttons
+      this.forceUpdate();
+    },
+
+    hideDeleteDocModal: function () {
+      Actions.hideDeleteDocModal();
+    },
+
+    deleteDoc: function () {
+      Actions.hideDeleteDocModal();
+      Actions.deleteDoc(this.state.doc);
+    },
+
+    getEditor: function () {
+      return (this.refs.docEditor) ? this.refs.docEditor.getEditor() : null;
+    },
+
+    checkDocIsValid: function () {
+      if (this.getEditor().hasErrors()) {
+        return false;
+      }
+      var json = JSON.parse(this.getEditor().getValue());
+      this.state.doc.clear().set(json, { validate: true });
+
+      return !this.state.doc.validationError;
+    },
+
+    clearChanges: function () {
+      this.refs.docEditor.clearChanges();
+    },
+
+    getButtonRow: function () {
+      if (this.props.isNewDoc) {
+        return false;
+      }
+      return (
+        <div>
+          <AttachmentsPanelButton doc={this.state.doc} isLoading={this.state.isLoading} />
+          <div className="doc-editor-extension-icons"></div>
+          <PanelButton title="Upload Attachment" iconClass="icon-circle-arrow-up" onClick={Actions.showUploadModal} />
+          <PanelButton title="Clone Document" iconClass="icon-repeat" onClick={Actions.showCloneDocModal} />
+          <PanelButton title="Delete" iconClass="icon-trash" onClick={Actions.showDeleteDocModal} />
+        </div>
+      );
+    },
+
+    render: function () {
+      return (
+        <div>
+          <div id="doc-editor-actions-panel">
+            <div className="doc-actions-left">
+              <button className="save-doc btn btn-success save" type="button" onClick={this.saveDoc}>
+                <i className="icon fonticon-ok-circled"></i> Save
+              </button>
+              <div>
+                <a href={this.props.previousPage} className="js-back cancel-button">Cancel</a>
+              </div>
+            </div>
+            <div className="alignRight">
+              {this.getButtonRow()}
+            </div>
+          </div>
+
+          <div className="code-region">
+            <div className="bgEditorGutter"></div>
+            <div id="editor-container" className="doc-code">{this.getCodeEditor()}</div>
+          </div>
+
+          <UploadModal
+            ref="uploadModal"
+            visible={this.state.uploadModalVisible}
+            doc={this.state.doc} />
+          <CloneDocModal
+            visible={this.state.cloneDocModalVisible}
+            onSubmit={this.clearChanges} />
+          <FauxtonComponents.ConfirmationModal
+            visible={this.state.deleteDocModalVisible}
+            text="Are you sure you want to delete this document?"
+            onClose={this.hideDeleteDocModal}
+            onSubmit={this.deleteDoc} />
+        </div>
+      );
+    }
+  });
+
+  var AttachmentsPanelButton = React.createClass({
+
+    propTypes: {
+      isLoading: React.PropTypes.bool.isRequired,
+      doc: React.PropTypes.object
+    },
+
+    getDefaultProps: function () {
+      return {
+        isLoading: true,
+        doc: {}
+      };
+    },
+
+    getAttachmentList: function () {
+      var docBaseURL = this.props.doc.url();
+      return _.map(this.props.doc.get('_attachments'), function (item, filename) {
+        var url = docBaseURL + '/' + app.utils.safeURLName(filename);
+        return (
+          <li key={filename}>
+            <a href={url} target="_blank" data-bypass="true"> <strong>{filename}</strong> -
+              <span>{item.content_type}, {Helpers.formatSize(item.length)}</span>
+            </a>
+          </li>
+        );
+      });
+    },
+
+    render: function () {
+      if (this.props.isLoading || !this.props.doc.get('_attachments')) {
+        return false;
+      }
+
+      return (
+        <div className="panel-section view-attachments-section btn-group">
+          <button className="panel-button dropdown-toggle btn" data-bypass="true" data-toggle="dropdown" title="View Attachments"
+            id="view-attachments-menu">
+            <i className="icon fonticon-picture"></i>
+            <span>View Attachments</span>{' '}
+            <span className="caret"></span>
+          </button>
+          <ul className="dropdown-menu" role="menu" aria-labelledby="view-attachments-menu">
+            {this.getAttachmentList()}
+          </ul>
+        </div>
+      );
+    }
+  });
+
+
+  var PanelButton = React.createClass({
+    propTypes: {
+      title: React.PropTypes.string.isRequired,
+      onClick: React.PropTypes.func.isRequired
+    },
+
+    getDefaultProps: function () {
+      return {
+        title: '',
+        iconClass: '',
+        onClick: function () { }
+      };
+    },
+
+    render: function () {
+      var iconClasses = 'icon ' + this.props.iconClass;
+      return (
+        <div className="panel-section">
+          <button className="panel-button upload" title={this.props.title} onClick={this.props.onClick}>
+            <i className={iconClasses}></i>
+            <span>{this.props.title}</span>
+          </button>
+        </div>
+      );
+    }
+  });
+
+
+  var UploadModal = React.createClass({
+    propTypes: {
+      visible: React.PropTypes.bool.isRequired,
+      doc: React.PropTypes.object
+    },
+
+    getInitialState: function () {
+      return this.getStoreState();
+    },
+
+    getStoreState: function () {
+      return {
+        inProgress: store.isUploadInProgress(),
+        loadPercentage: store.getUploadLoadPercentage(),
+        errorMessage: store.getFileUploadErrorMsg()
+      };
+    },
+
+    componentDidUpdate: function () {
+      var params = (this.props.visible) ? { show: true, backdrop: 'static', keyboard: true } : 'hide';
+      $(this.getDOMNode()).modal(params);
+    },
+
+    // ensure that if the user clicks ESC to close the window, the store gets wind of it
+    componentDidMount: function () {
+      $(this.getDOMNode()).on('hidden.bs.modal', function () {
+        Actions.hideUploadModal();
+      });
+    },
+
+    componentWillUnmount: function () {
+      $(this.getDOMNode()).off('hidden.bs.modal');
+    },
+
+    closeModal: function () {
+      if (this.state.inProgress) {
+        Actions.cancelUpload();
+      }
+      Actions.hideUploadModal();
+
+      // timeout needed to only clear it once the animate close effect is done, otherwise the user sees it reset
+      // as it closes, which looks bad
+      setTimeout(function () {
+        Actions.resetUploadModal();
+        this.refs.uploadForm.getDOMNode().reset();
+      }.bind(this), 1000);
+    },
+
+    upload: function () {
+      Actions.uploadAttachment({
+        doc: this.props.doc,
+        rev: this.props.doc.get('_rev'),
+        files: $(this.refs.attachments.getDOMNode())[0].files
+      });
+    },
+
+    render: function () {
+      var errorClasses = 'alert alert-error';
+      if (this.state.errorMessage === '') {
+        errorClasses += ' hide';
+      }
+      var loadIndicatorClasses = 'progress progress-info';
+      if (!this.state.inProgress) {
+        loadIndicatorClasses += ' hide';
+      }
+
+      return (
+        <div className="modal hide fade upload-file-modal" tabIndex="-1" data-js-visible={this.props.visible}>
+          <div className="modal-header">
+            <button type="button" className="close" onClick={this.closeModal} aria-hidden="true">&times;</button>
+            <h3>Upload Attachment</h3>
+          </div>
+          <div className="modal-body">
+            <div className={errorClasses}>{this.state.errorMessage}</div>
+
+            <div>
+              <form ref="uploadForm" className="form" method="post">
+                <p>
+                  Please select the file you want to upload as an attachment to this document. This creates a new
+                  revision of the document, so it's not necessary to save after uploading.
+                </p>
+                <input ref="attachments" type="file" name="_attachments" />
+                <br />
+              </form>
+
+              <div ref="loadIndicator" className={loadIndicatorClasses}>
+                <div className="bar" style={{ width: this.state.loadPercentage + '%'}}></div>
+              </div>
+            </div>
+
+          </div>
+          <div className="modal-footer">
+             <button href="#" data-bypass="true" className="btn" onClick={this.closeModal}>
+              <i className="icon fonticon-cancel-circled"></i> Cancel
+            </button>
+            <button href="#" id="upload-btn" data-bypass="true" className="btn btn-success save" onClick={this.upload}>
+              <i className="icon fonticon-ok-circled"></i> Upload
+            </button>
+          </div>
+        </div>
+      );
+    }
+  });
+
+
+  var CloneDocModal = React.createClass({
+    propTypes: {
+      visible: React.PropTypes.bool.isRequired
+    },
+
+    getInitialState: function () {
+      return {
+        uuid: null
+      };
+    },
+
+    cloneDoc: function () {
+      if (this.props.onSubmit) {
+        this.props.onSubmit();
+      }
+      Actions.cloneDoc(this.state.uuid);
+    },
+
+    componentDidUpdate: function () {
+      if (this.state.uuid === null) {
+        var uuid = new FauxtonAPI.UUID();
+        uuid.fetch().then(function () {
+          this.setState({ uuid: uuid.next() });
+        }.bind(this));
+        return;
+      }
+
+      var params = (this.props.visible) ? { show: true, backdrop: 'static', keyboard: true } : 'hide';
+      $(this.getDOMNode()).modal(params);
+      this.clearEvents();
+
+      // ensure that if the user clicks ESC to close the window, the store gets wind of it
+      $(this.getDOMNode()).on('hidden.bs.modal', function () {
+        Actions.hideCloneDocModal();
+      });
+
+      $(this.getDOMNode()).on('shown.bs.modal', function () {
+        this.focus();
+      }.bind(this));
+    },
+
+    focus: function () {
+      $(this.refs.newDocId.getDOMNode()).focus();
+    },
+
+    componentWillUnmount: function () {
+      this.clearEvents();
+    },
+
+    clearEvents: function () {
+      if (this.refs.newDocId) {
+        $(this.refs.newDocId.getDOMNode()).off('shown.bs.modal hidden.bs.modal');
+      }
+    },
+
+    closeModal: function () {
+      Actions.hideCloneDocModal();
+    },
+
+    docIDChange: function (e) {
+      this.setState({ uuid: e.target.value });
+    },
+
+    render: function () {
+      if (this.state.uuid === null) {
+        return false;
+      }
+
+      return (
+        <div className="modal hide fade clone-doc-modal" data-js-visible={this.props.visible} tabIndex="-1">
+          <div className="modal-header">
+            <button type="button" className="close" onClick={this.closeModal} aria-hidden="true">&times;</button>
+            <h3>Clone Document</h3>
+          </div>
+          <div className="modal-body">
+            <form className="form" method="post">
+              <p>
+                Set new document's ID:
+              </p>
+              <input ref="newDocId" type="text" className="input-block-level" onChange={this.docIDChange} value={this.state.uuid} />
+            </form>
+          </div>
+          <div className="modal-footer">
+            <button className="btn" onClick={this.closeModal}>
+              <i className="icon fonticon-cancel-circled"></i> Cancel
+            </button>
+            <button className="btn btn-success save" onClick={this.cloneDoc}>
+              <i className="fonticon-ok-circled"></i> Clone
+            </button>
+          </div>
+        </div>
+      );
+    }
+  });
+
+
+  return {
+    DocEditorController: DocEditorController,
+    AttachmentsPanelButton: AttachmentsPanelButton,
+    UploadModal: UploadModal,
+    CloneDocModal: CloneDocModal
+  };
+
+});

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/documents/doc-editor/stores.js
----------------------------------------------------------------------
diff --git a/app/addons/documents/doc-editor/stores.js b/app/addons/documents/doc-editor/stores.js
new file mode 100644
index 0000000..7cdff19
--- /dev/null
+++ b/app/addons/documents/doc-editor/stores.js
@@ -0,0 +1,201 @@
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+define([
+  'api',
+  'addons/documents/doc-editor/actiontypes'
+],
+
+function (FauxtonAPI, ActionTypes) {
+  var Stores = {};
+
+  Stores.DocEditorStore = FauxtonAPI.Store.extend({
+    initialize: function () {
+      this.reset();
+    },
+
+    reset: function () {
+      this._doc = null;
+      this._isLoading = true;
+      this._cloneDocModalVisible = false;
+      this._deleteDocModalVisible = false;
+      this._uploadModalVisible = false;
+
+      // file upload-related fields
+      this._numFilesUploaded = 0;
+      this._fileUploadErrorMsg = '';
+      this._uploadInProgress = false;
+      this._fileUploadLoadPercentage = 0;
+    },
+
+    isLoading: function () {
+      return this._isLoading;
+    },
+
+    docLoaded: function (options) {
+      this._isLoading = false;
+      this._doc = options.doc;
+    },
+
+    getDoc: function () {
+      return this._doc;
+    },
+
+    isCloneDocModalVisible: function () {
+      return this._cloneDocModalVisible;
+    },
+
+    showCloneDocModal: function () {
+      this._cloneDocModalVisible = true;
+    },
+
+    hideCloneDocModal: function () {
+      this._cloneDocModalVisible = false;
+    },
+
+    isDeleteDocModalVisible: function () {
+      return this._deleteDocModalVisible;
+    },
+
+    showDeleteDocModal: function () {
+      this._deleteDocModalVisible = true;
+    },
+
+    hideDeleteDocModal: function () {
+      this._deleteDocModalVisible = false;
+    },
+
+    isUploadModalVisible: function () {
+      return this._uploadModalVisible;
+    },
+
+    showUploadModal: function () {
+      this._uploadModalVisible = true;
+    },
+
+    hideUploadModal: function () {
+      this._uploadModalVisible = false;
+    },
+
+    getNumFilesUploaded: function () {
+      return this._numFilesUploaded;
+    },
+
+    getFileUploadErrorMsg: function () {
+      return this._fileUploadErrorMsg;
+    },
+
+    setFileUploadErrorMsg: function (error) {
+      this._uploadInProgress = false;
+      this._fileUploadLoadPercentage = 0;
+      this._fileUploadErrorMsg = error;
+    },
+
+    isUploadInProgress: function () {
+      return this._uploadInProgress;
+    },
+
+    getUploadLoadPercentage: function () {
+      return this._fileUploadLoadPercentage;
+    },
+
+    resetUploadModal: function () {
+      this._uploadInProgress = false;
+      this._fileUploadLoadPercentage = 0;
+      this._fileUploadErrorMsg = '';
+    },
+
+    startFileUpload: function () {
+      this._uploadInProgress = true;
+      this._fileUploadLoadPercentage = 0;
+      this._fileUploadErrorMsg = '';
+    },
+
+    dispatch: function (action) {
+      switch (action.type) {
+        case ActionTypes.RESET_DOC:
+          this.reset();
+        break;
+
+        case ActionTypes.DOC_LOADED:
+          this.docLoaded(action.options);
+          this.triggerChange();
+        break;
+
+        case ActionTypes.SHOW_CLONE_DOC_MODAL:
+          this.showCloneDocModal();
+          this.triggerChange();
+        break;
+
+        case ActionTypes.HIDE_CLONE_DOC_MODAL:
+          this.hideCloneDocModal();
+          this.triggerChange();
+        break;
+
+        case ActionTypes.SHOW_DELETE_DOC_CONFIRMATION_MODAL:
+          this.showDeleteDocModal();
+          this.triggerChange();
+        break;
+
+        case ActionTypes.HIDE_DELETE_DOC_CONFIRMATION_MODAL:
+          this.hideDeleteDocModal();
+          this.triggerChange();
+        break;
+
+        case ActionTypes.SHOW_UPLOAD_MODAL:
+          this.showUploadModal();
+          this.triggerChange();
+        break;
+
+        case ActionTypes.HIDE_UPLOAD_MODAL:
+          this.hideUploadModal();
+          this.triggerChange();
+        break;
+
+        case ActionTypes.FILE_UPLOAD_SUCCESS:
+          this._numFilesUploaded++;
+          this.triggerChange();
+        break;
+
+        case ActionTypes.FILE_UPLOAD_ERROR:
+          this.setFileUploadErrorMsg(action.options.error);
+          this.triggerChange();
+        break;
+
+        case ActionTypes.RESET_UPLOAD_MODAL:
+          this.resetUploadModal();
+          this.triggerChange();
+        break;
+
+        case ActionTypes.START_FILE_UPLOAD:
+          this.startFileUpload();
+          this.triggerChange();
+        break;
+
+        case ActionTypes.SET_FILE_UPLOAD_PERCENTAGE:
+          this._fileUploadLoadPercentage = action.options.percent;
+          this.triggerChange();
+        break;
+
+        default:
+        return;
+        // do nothing
+      }
+    }
+
+  });
+
+  Stores.docEditorStore = new Stores.DocEditorStore();
+  Stores.docEditorStore.dispatchToken = FauxtonAPI.dispatcher.register(Stores.docEditorStore.dispatch);
+
+  return Stores;
+});

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/documents/doc-editor/tests/doc-editor.componentsSpec.react.jsx
----------------------------------------------------------------------
diff --git a/app/addons/documents/doc-editor/tests/doc-editor.componentsSpec.react.jsx b/app/addons/documents/doc-editor/tests/doc-editor.componentsSpec.react.jsx
new file mode 100644
index 0000000..b1720f2
--- /dev/null
+++ b/app/addons/documents/doc-editor/tests/doc-editor.componentsSpec.react.jsx
@@ -0,0 +1,187 @@
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+define([
+  'app',
+  'api',
+  'react',
+  'addons/documents/resources',
+  'addons/documents/doc-editor/components.react',
+  'addons/documents/doc-editor/stores',
+  'addons/documents/doc-editor/actions',
+  'addons/documents/doc-editor/actiontypes',
+  'testUtils'
+], function (app, FauxtonAPI, React, Documents, Components, Stores, Actions, ActionTypes, utils) {
+  FauxtonAPI.router = new FauxtonAPI.Router([]);
+
+  var assert = utils.assert;
+  var TestUtils = React.addons.TestUtils;
+  var docJSON = {
+    _id: '_design/test-doc',
+    views: {
+      'test-view': {
+        map: 'function () {};'
+      }
+    }
+  };
+
+  var docWithAttachmentsJSON = {
+    _id: '_design/test-doc',
+    _rev: '12345',
+    blah: {
+      whatever: {
+        something: 1
+      }
+    },
+    _attachments: {
+      "one.png": {
+        "content-type": "images/png",
+        "length": 100
+      },
+      "one.zip": {
+        "content-type": "application/zip",
+        "length": 111100
+      }
+    }
+  };
+
+  var database = {
+    safeID: function () { return 'id'; }
+  };
+
+
+  describe('DocEditorController', function () {
+    var container;
+
+    beforeEach(function () {
+      container = document.createElement('div');
+    });
+
+    afterEach(function () {
+      React.unmountComponentAtNode(container);
+    });
+
+    it('loading indicator appears on load', function () {
+      var el = TestUtils.renderIntoDocument(<Components.DocEditorController />, container);
+      assert.equal($(el.getDOMNode()).find('.loading-lines').length, 1);
+    });
+
+    it('new docs do not show the button row', function () {
+      var el = TestUtils.renderIntoDocument(<Components.DocEditorController isNewDoc={true} database={database} />, container);
+
+      var doc = new Documents.Doc(docJSON, { database: database });
+      FauxtonAPI.dispatch({
+        type: ActionTypes.DOC_LOADED,
+        options: {
+          doc: doc
+        }
+      });
+
+      assert.equal($(el.getDOMNode()).find('.loading-lines').length, 0);
+      assert.equal($(el.getDOMNode()).find('.icon-circle-arrow-up').length, 0);
+      assert.equal($(el.getDOMNode()).find('.icon-repeat').length, 0);
+      assert.equal($(el.getDOMNode()).find('.icon-trash').length, 0);
+    });
+
+    it('view attachments button does not appear with no attachments', function () {
+      var el = TestUtils.renderIntoDocument(<Components.DocEditorController database={database} />, container);
+
+      var doc = new Documents.Doc(docJSON, { database: database });
+      FauxtonAPI.dispatch({
+        type: ActionTypes.DOC_LOADED,
+        options: {
+          doc: doc
+        }
+      });
+      assert.equal($(el.getDOMNode()).find('.view-attachments-section').length, 0);
+    });
+
+    it('view attachments button shows up when the doc has attachments', function () {
+      var el = TestUtils.renderIntoDocument(<Components.DocEditorController database={database} />, container);
+
+      var doc = new Documents.Doc(docWithAttachmentsJSON, { database: database });
+      FauxtonAPI.dispatch({
+        type: ActionTypes.DOC_LOADED,
+        options: {
+          doc: doc
+        }
+      });
+      assert.equal($(el.getDOMNode()).find('.view-attachments-section').length, 1);
+    });
+
+    it('view attachments dropdown contains right number of docs', function () {
+      var el = TestUtils.renderIntoDocument(<Components.DocEditorController database={database} />, container);
+
+      var doc = new Documents.Doc(docWithAttachmentsJSON, { database: database });
+      FauxtonAPI.dispatch({
+        type: ActionTypes.DOC_LOADED,
+        options: {
+          doc: doc
+        }
+      });
+      assert.equal($(el.getDOMNode()).find('.view-attachments-section .dropdown-menu li').length, 2);
+    });
+
+    it('setting deleteDocModal=true in store shows modal', function () {
+      var el = TestUtils.renderIntoDocument(<Components.DocEditorController database={database} />, container);
+      var doc = new Documents.Doc(docWithAttachmentsJSON, { database: database });
+      FauxtonAPI.dispatch({
+        type: ActionTypes.DOC_LOADED,
+        options: {
+          doc: doc
+        }
+      });
+      assert.ok($(el.getDOMNode()).find('.confirmation-modal')[0].getAttribute('data-js-visible') == 'false');
+      Actions.showDeleteDocModal();
+      assert.ok($(el.getDOMNode()).find('.confirmation-modal')[0].getAttribute('data-js-visible') == 'true');
+    });
+
+    it('setting uploadDocModal=true in store shows modal', function () {
+      var el = TestUtils.renderIntoDocument(<Components.DocEditorController database={database} />, container);
+      var doc = new Documents.Doc(docWithAttachmentsJSON, { database: database });
+      FauxtonAPI.dispatch({
+        type: ActionTypes.DOC_LOADED,
+        options: {
+          doc: doc
+        }
+      });
+      assert.ok($(el.getDOMNode()).find('.upload-file-modal')[0].getAttribute('data-js-visible') == 'false');
+      Actions.showUploadModal();
+      assert.ok($(el.getDOMNode()).find('.upload-file-modal')[0].getAttribute('data-js-visible') == 'true');
+    });
+
+  });
+
+  describe("AttachmentsPanelButton", function () {
+    var container, doc;
+
+    beforeEach(function () {
+      doc = new Documents.Doc(docWithAttachmentsJSON, { database: database });
+      container = document.createElement('div');
+    });
+
+    afterEach(function () {
+      React.unmountComponentAtNode(container);
+    });
+
+    it('does not show up when loading', function () {
+      var el = TestUtils.renderIntoDocument(<Components.AttachmentsPanelButton isLoading={true} doc={doc} />, container);
+      assert.equal($(el.getDOMNode()).find('.panel-button').length, 0);
+    });
+
+    it('shows up after loading', function () {
+      var el = TestUtils.renderIntoDocument(<Components.AttachmentsPanelButton isLoading={false} doc={doc} />, container);
+      assert.equal($(el.getDOMNode()).find('.panel-button').length, 1);
+    });
+  });
+
+});

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/documents/doc-editor/tests/doc-editor.storesSpec.js
----------------------------------------------------------------------
diff --git a/app/addons/documents/doc-editor/tests/doc-editor.storesSpec.js b/app/addons/documents/doc-editor/tests/doc-editor.storesSpec.js
new file mode 100644
index 0000000..081ab67
--- /dev/null
+++ b/app/addons/documents/doc-editor/tests/doc-editor.storesSpec.js
@@ -0,0 +1,81 @@
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy of
+// the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations under
+// the License.
+
+define([
+  'app',
+  'api',
+  'addons/documents/doc-editor/stores',
+  'testUtils'
+], function (app, FauxtonAPI, Stores, utils) {
+  FauxtonAPI.router = new FauxtonAPI.Router([]);
+
+  var assert = utils.assert;
+
+  var store = Stores.docEditorStore;
+
+  describe('DocEditorStore', function () {
+    afterEach(function () {
+      store.reset();
+    });
+
+    it('defines sensible defaults', function () {
+      assert.equal(store.isLoading(), true);
+      assert.equal(store.isCloneDocModalVisible(), false);
+      assert.equal(store.isDeleteDocModalVisible(), false);
+      assert.equal(store.isUploadModalVisible(), false);
+      assert.equal(store.getNumFilesUploaded(), 0);
+      assert.equal(store.isUploadInProgress(), false);
+      assert.equal(store.getUploadLoadPercentage(), 0);
+    });
+
+    it('docLoaded() marks loading as complete', function () {
+      store.docLoaded({ doc: {} });
+      assert.equal(store.isLoading(), false);
+    });
+
+    it('showCloneDocModal / hideCloneDocModal', function () {
+      store.showCloneDocModal();
+      assert.equal(store.isCloneDocModalVisible(), true);
+      store.hideCloneDocModal();
+      assert.equal(store.isCloneDocModalVisible(), false);
+    });
+
+    it('showDeleteDocModal / hideCloneDocModal', function () {
+      store.showDeleteDocModal();
+      assert.equal(store.isDeleteDocModalVisible(), true);
+      store.hideDeleteDocModal();
+      assert.equal(store.isDeleteDocModalVisible(), false);
+    });
+
+    it('showCloneDocModal / hideCloneDocModal', function () {
+      store.showUploadModal();
+      assert.equal(store.isUploadModalVisible(), true);
+      store.hideUploadModal();
+      assert.equal(store.isUploadModalVisible(), false);
+    });
+
+    it('reset() resets all values', function () {
+      store.docLoaded({ doc: {} });
+      store.showCloneDocModal();
+      store.showDeleteDocModal();
+      store.showUploadModal();
+
+      store.reset();
+      assert.equal(store.isLoading(), true);
+      assert.equal(store.isCloneDocModalVisible(), false);
+      assert.equal(store.isDeleteDocModalVisible(), false);
+      assert.equal(store.isUploadModalVisible(), false);
+    });
+
+  });
+
+});

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/documents/index-editor/components.react.jsx
----------------------------------------------------------------------
diff --git a/app/addons/documents/index-editor/components.react.jsx b/app/addons/documents/index-editor/components.react.jsx
index f7009a1..fd8f853 100644
--- a/app/addons/documents/index-editor/components.react.jsx
+++ b/app/addons/documents/index-editor/components.react.jsx
@@ -130,7 +130,7 @@ function (app, FauxtonAPI, React, Stores, Actions, Components, ReactComponents)
 
     componentWillUnmount: function () {
       indexEditorStore.off('change', this.onChange);
-    },
+    }
 
   });
 
@@ -154,7 +154,6 @@ function (app, FauxtonAPI, React, Stores, Actions, Components, ReactComponents)
       return _.map(this.state.reduceOptions, function (reduce, i) {
         return <option key={i} value={reduce}>{reduce}</option>;
       }, this);
-
     },
 
     getReduceValue: function () {
@@ -231,8 +230,7 @@ function (app, FauxtonAPI, React, Stores, Actions, Components, ReactComponents)
 
     componentWillUnmount: function () {
       indexEditorStore.off('change', this.onChange);
-    },
-
+    }
   });
 
   var DeleteView = React.createClass({
@@ -310,20 +308,16 @@ function (app, FauxtonAPI, React, Stores, Actions, Components, ReactComponents)
       indexEditorStore.off('change', this.onChange);
     },
 
-    hasValidCode: function () {
-      return _.every(['mapEditor', 'reduceEditor'], function (editorName) {
-        if (editorName === 'reduceEditor' && !indexEditorStore.hasCustomReduce()) {
-          return true;
-        }
-        var editor = this.refs[editorName].getEditor();
-        return editor.hadValidCode();
-      }, this);
+    hasErrors: function () {
+      var mapEditorErrors = this.refs.mapEditor.getEditor().hasErrors();
+      var customReduceErrors = (indexEditorStore.hasCustomReduce()) ? this.refs.reduceEditor.getEditor().hasErrors() : false;
+      return mapEditorErrors || customReduceErrors;
     },
 
     saveView: function (event) {
       event.preventDefault();
 
-      if (!this.hasValidCode()) {
+      if (this.hasErrors()) {
         FauxtonAPI.addNotification({
           msg:  'Please fix the Javascript errors and try again.',
           type: 'error',

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/documents/index-editor/tests/viewIndex.componentsSpec.react.jsx
----------------------------------------------------------------------
diff --git a/app/addons/documents/index-editor/tests/viewIndex.componentsSpec.react.jsx b/app/addons/documents/index-editor/tests/viewIndex.componentsSpec.react.jsx
index 4423572..67df4e5 100644
--- a/app/addons/documents/index-editor/tests/viewIndex.componentsSpec.react.jsx
+++ b/app/addons/documents/index-editor/tests/viewIndex.componentsSpec.react.jsx
@@ -65,8 +65,6 @@ define([
       });
 
       it('returns null for none', function () {
-        var store = Stores.indexEditorStore;
-
         var designDoc = {
           _id: '_design/test-doc',
           views: {
@@ -84,8 +82,6 @@ define([
       });
 
       it('returns built in for built in reduce', function () {
-        var store = Stores.indexEditorStore;
-
         var designDoc = {
           _id: '_design/test-doc',
           views: {
@@ -221,40 +217,42 @@ define([
   });
 
   describe('Editor', function () {
-    var container, editorEl;
+    var container, editorEl, sandbox;
 
     beforeEach(function () {
       container = document.createElement('div');
       $('body').append('<div id="map-function"></div>');
       $('body').append('<div id="editor"></div>');
       editorEl = TestUtils.renderIntoDocument(<Views.Editor/>, container);
+      sandbox = sinon.sandbox.create();
     });
 
     afterEach(function () {
       React.unmountComponentAtNode(container);
+      sandbox.restore();
     });
 
     it('returns false on invalid map editor code', function () {
-      var stub = sinon.stub(editorEl.refs.mapEditor.getEditor(), 'hadValidCode');
+      var stub = sandbox.stub(editorEl.refs.mapEditor.getEditor(), 'hasErrors');
       stub.returns(false);
-      assert.notOk(editorEl.hasValidCode());
+      assert.notOk(editorEl.hasErrors());
     });
 
     it('returns true on valid map editor code', function () {
-      var stub = sinon.stub(editorEl.refs.mapEditor.getEditor(), 'hadValidCode');
+      var stub = sandbox.stub(editorEl.refs.mapEditor.getEditor(), 'hasErrors');
       stub.returns(true);
-      assert.ok(editorEl.hasValidCode());
+      assert.ok(editorEl.hasErrors());
     });
 
-    it('returns true on non-custom reduce', function () {
-      var stub = sinon.stub(Stores.indexEditorStore, 'hasCustomReduce');
+    it('returns false on non-custom reduce', function () {
+      var stub = sandbox.stub(Stores.indexEditorStore, 'hasCustomReduce');
       stub.returns(false);
-      assert.ok(editorEl.hasValidCode());
+      assert.notOk(editorEl.hasErrors());
     });
 
     it('calls changeViewName on view name change', function () {
       var viewName = 'new-name';
-      var spy = sinon.spy(Actions, 'changeViewName');
+      var spy = sandbox.spy(Actions, 'changeViewName');
       var el = $(editorEl.getDOMNode()).find('#index-name')[0];
       TestUtils.Simulate.change(el, {
         target: {

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/documents/mango/mango.components.react.jsx
----------------------------------------------------------------------
diff --git a/app/addons/documents/mango/mango.components.react.jsx b/app/addons/documents/mango/mango.components.react.jsx
index 13123ed..a451e9a 100644
--- a/app/addons/documents/mango/mango.components.react.jsx
+++ b/app/addons/documents/mango/mango.components.react.jsx
@@ -100,7 +100,7 @@ function (app, FauxtonAPI, React, Stores, Actions,
     runQuery: function (event) {
       event.preventDefault();
 
-      if (!this.getMangoEditor().hasValidCode()) {
+      if (this.getMangoEditor().hasErrors()) {
         FauxtonAPI.addNotification({
           msg:  'Please fix the Javascript errors and try again.',
           type: 'error',
@@ -155,7 +155,7 @@ function (app, FauxtonAPI, React, Stores, Actions,
             {this.getIndexBox()}
             <div className="padded-box">
               <div className="control-group">
-                <ConfirmButton text={this.props.confirmbuttonText} />
+                <ConfirmButton text={this.props.confirmbuttonText} id="create-index-btn" />
               </div>
             </div>
           </form>
@@ -216,9 +216,8 @@ function (app, FauxtonAPI, React, Stores, Actions,
       return this.refs.field.getEditor();
     },
 
-    hasValidCode: function () {
-      var editor = this.getEditor();
-      return editor.hadValidCode();
+    hasErrors: function () {
+      return this.getEditor().hasErrors();
     }
   });
 
@@ -267,7 +266,7 @@ function (app, FauxtonAPI, React, Stores, Actions,
     saveQuery: function (event) {
       event.preventDefault();
 
-      if (!this.getMangoEditor().hasValidCode()) {
+      if (this.getMangoEditor().hasErrors()) {
         FauxtonAPI.addNotification({
           msg:  'Please fix the Javascript errors and try again.',
           type: 'error',

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/documents/routes-doc-editor.js
----------------------------------------------------------------------
diff --git a/app/addons/documents/routes-doc-editor.js b/app/addons/documents/routes-doc-editor.js
index bf5d28c..81e6e00 100644
--- a/app/addons/documents/routes-doc-editor.js
+++ b/app/addons/documents/routes-doc-editor.js
@@ -11,17 +11,16 @@
 // the License.
 
 define([
-  "app",
-  "api",
-
-  // Modules
-  "addons/documents/helpers",
-  "addons/documents/views",
-  "addons/documents/views-doceditor",
-  "addons/databases/base"
+  'app',
+  'api',
+  'addons/documents/helpers',
+  'addons/documents/resources',
+  'addons/databases/base',
+  'addons/documents/doc-editor/actions',
+  'addons/documents/doc-editor/components.react'
 ],
 
-function (app, FauxtonAPI, Helpers, Documents, DocEditor, Databases) {
+function (app, FauxtonAPI, Helpers, Documents, Databases, Actions, ReactComponents) {
 
 
   var DocEditorRouteObject = FauxtonAPI.RouteObject.extend({
@@ -29,22 +28,24 @@ function (app, FauxtonAPI, Helpers, Documents, DocEditor, Databases) {
     disableLoader: true,
     selectedHeader: 'Databases',
 
+    roles: ['fx_loggedIn'],
+
     initialize: function (route, masterLayout, options) {
       this.databaseName = options[0];
       this.docID = options[1] || 'new';
-
       this.database = this.database || new Databases.Model({ id: this.databaseName });
       this.doc = new Documents.Doc({ _id: this.docID }, { database: this.database });
+      this.isNewDoc = false;
+      this.wasCloned = false;
     },
 
     routes: {
-      'database/:database/:doc/code_editor': 'code_editor',
-      'database/:database/:doc': 'code_editor',
+      'database/:database/:doc/code_editor': 'codeEditor',
+      'database/:database/:doc': 'codeEditor',
       'database/:database/_design/:ddoc': 'showDesignDoc'
     },
 
     events: {
-      'route:reRenderDoc': 'reRenderDoc',
       'route:duplicateDoc': 'duplicateDoc'
     },
 
@@ -57,7 +58,7 @@ function (app, FauxtonAPI, Helpers, Documents, DocEditor, Databases) {
       ];
     },
 
-    code_editor: function (database, doc) {
+    codeEditor: function (database, doc) {
 
       // if either the database or document just changed, we need to get the latest doc/db info
       if (this.databaseName !== database) {
@@ -69,25 +70,22 @@ function (app, FauxtonAPI, Helpers, Documents, DocEditor, Databases) {
         this.doc = new Documents.Doc({ _id: this.docID }, { database: this.database });
       }
 
-      this.docView = this.setView('#dashboard-content', new DocEditor.CodeEditor({
-        model: this.doc,
+      Actions.initDocEditor({ doc: this.doc, database: this.database });
+      this.setComponent('#dashboard-content', ReactComponents.DocEditorController, {
         database: this.database,
-        previousPage: Helpers.getPreviousPageForDoc(this.database)
-      }));
+        isNewDoc: this.isNewDoc,
+        previousPage: '#/' + Helpers.getPreviousPageForDoc(this.database)
+      });
     },
 
     showDesignDoc: function (database, ddoc) {
-      this.code_editor(database, '_design/' + ddoc);
-    },
-
-    reRenderDoc: function () {
-      this.docView.forceRender();
+      this.codeEditor(database, '_design/' + ddoc);
     },
 
     duplicateDoc: function (newId) {
       var doc = this.doc,
-      docView = this.docView,
-      database = this.database;
+          database = this.database;
+
       this.docID = newId;
 
       var that = this;
@@ -95,7 +93,6 @@ function (app, FauxtonAPI, Helpers, Documents, DocEditor, Databases) {
         doc.set({ _id: newId });
         that.wasCloned = true;
 
-        docView.forceRender();
         FauxtonAPI.navigate('/database/' + database.safeID() + '/' + app.utils.safeURLName(newId), { trigger: true });
         FauxtonAPI.addNotification({
           msg: 'Document has been duplicated.'
@@ -115,14 +112,15 @@ function (app, FauxtonAPI, Helpers, Documents, DocEditor, Databases) {
     }
   });
 
+
   var NewDocEditorRouteObject = DocEditorRouteObject.extend({
     initialize: function (route, masterLayout, options) {
       var databaseName = options[0];
-
-      this.database = this.database || new Databases.Model({id: databaseName});
+      this.database = this.database || new Databases.Model({ id: databaseName });
       this.doc = new Documents.NewDoc(null, {
         database: this.database
       });
+      this.isNewDoc = true;
     },
 
     crumbs: function () {
@@ -134,7 +132,7 @@ function (app, FauxtonAPI, Helpers, Documents, DocEditor, Databases) {
     },
 
     routes: {
-      'database/:database/new': 'code_editor'
+      'database/:database/new': 'codeEditor'
     },
 
     selectedHeader: 'Databases'
@@ -145,4 +143,5 @@ function (app, FauxtonAPI, Helpers, Documents, DocEditor, Databases) {
     NewDocEditorRouteObject: NewDocEditorRouteObject,
     DocEditorRouteObject: DocEditorRouteObject
   };
+
 });

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/documents/routes-documents.js
----------------------------------------------------------------------
diff --git a/app/addons/documents/routes-documents.js b/app/addons/documents/routes-documents.js
index 09621c3..7ed5f58 100644
--- a/app/addons/documents/routes-documents.js
+++ b/app/addons/documents/routes-documents.js
@@ -19,8 +19,6 @@ define([
   'addons/documents/views',
   'addons/documents/changes/components.react',
   'addons/documents/changes/actions',
-  'addons/documents/views-doceditor',
-
   'addons/databases/base',
   'addons/documents/resources',
   'addons/fauxton/components',
@@ -35,10 +33,9 @@ define([
   'addons/documents/designdocinfo/components.react'
 ],
 
-function (app, FauxtonAPI, BaseRoute, Documents, Changes, ChangesActions, DocEditor,
-  Databases, Resources, Components, PaginationStores, IndexResultsActions,
-  IndexResultsComponents, ReactPagination, ReactHeader, ReactActions, SidebarActions,
-  DesignDocInfoActions, DesignDocInfoComponents) {
+function (app, FauxtonAPI, BaseRoute, Documents, Changes, ChangesActions, Databases, Resources, Components,
+  PaginationStores, IndexResultsActions, IndexResultsComponents, ReactPagination, ReactHeader, ReactActions,
+  SidebarActions, DesignDocInfoActions, DesignDocInfoComponents) {
 
     var DocumentsRouteObject = BaseRoute.extend({
       layout: "with_tabs_sidebar",

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/documents/templates/code_editor.html
----------------------------------------------------------------------
diff --git a/app/addons/documents/templates/code_editor.html b/app/addons/documents/templates/code_editor.html
deleted file mode 100644
index f341df3..0000000
--- a/app/addons/documents/templates/code_editor.html
+++ /dev/null
@@ -1,78 +0,0 @@
-<%/*
-Licensed under the Apache License, Version 2.0 (the "License"); you may not
-use this file except in compliance with the License. You may obtain a copy of
-the License at
-
-  http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-License for the specific language governing permissions and limitations under
-the License.
-*/%>
-
-<div id="doc-editor-actions-panel">
-  <div class="doc-actions-left">
-    <button class="save-doc btn btn-success save" type="button"><i class="icon fonticon-ok-circled"></i> Save</button>
-    <div>
-      <a href="#" class="js-back cancel-button">Cancel</a>
-    </div>
-  </div>
-
-  <div class="alignRight">
-    <button class="btn string-edit" title="Edit line" disabled="true"><i class="icon icon-edit"></i></button>
-
-    <% if (attachments) { %>
-    <div class="panel-section btn-group">
-      <button class="panel-button dropdown-toggle btn" data-bypass="true" data-toggle="dropdown" title="View Attachments"
-        id="view-attachments-menu">
-        <i class="icon fonticon-picture"></i>
-        <span>View Attachments</span>
-        <span class="caret"></span>
-      </button>
-
-      <ul class="dropdown-menu" role="menu" aria-labelledby="view-attachments-menu">
-        <%_.each(attachments, function (att) { %>
-        <li>
-          <a href="<%- att.url %>" target="_blank" data-bypass="true"> <strong> <%- att.fileName %> </strong> -
-            <span> <%- att.contentType %>, <%- formatSize(att.size)%> </span>
-          </a>
-        </li>
-        <% }) %>
-      </ul>
-    </div>
-    <% } %>
-
-    <div class="doc-editor-extension-icons"></div>
-
-    <div class="panel-section">
-      <button class="panel-button upload" title="Upload attachment">
-        <i class="icon icon-circle-arrow-up"></i>
-        <span>Upload Attachment</span>
-      </button>
-    </div>
-    <div class="panel-section">
-      <button class="panel-button duplicate" title="Clone document">
-        <i class="icon icon-repeat"></i>
-        <span>Clone Document</span>
-      </button>
-    </div>
-    <div class="panel-section">
-      <button class="panel-button delete" title="Delete">
-        <i class="icon icon-trash"></i>
-        <span>Delete</span>
-      </button>
-    </div>
-  </div>
-</div>
-
-<div class="code-region">
-  <div class="bgEditorGutter"></div>
-  <div id="editor-container" class="doc-code"><%- JSON.stringify(doc.attributes, null, "  ") %></div>
-</div>
-
-<div id="upload-modal"> </div>
-<div id="duplicate-modal"> </div>
-<div id="delete-doc-modal"> </div>
-<div id="string-edit-modal"> </div>

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/8cd744ac/app/addons/documents/templates/duplicate_doc_modal.html
----------------------------------------------------------------------
diff --git a/app/addons/documents/templates/duplicate_doc_modal.html b/app/addons/documents/templates/duplicate_doc_modal.html
deleted file mode 100644
index bab4067..0000000
--- a/app/addons/documents/templates/duplicate_doc_modal.html
+++ /dev/null
@@ -1,36 +0,0 @@
-<% /*
-Licensed under the Apache License, Version 2.0 (the "License"); you may not
-use this file except in compliance with the License. You may obtain a copy of
-the License at
-
-  http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
-WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
-License for the specific language governing permissions and limitations under
-the License.
-*/ %>
-
-<div class="modal hide fade">
-  <div class="modal-header">
-    <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-    <h3>Clone Document</h3>
-  </div>
-  <div class="modal-body">
-    <div id="modal-error" class="hide alert alert-error"/>
-    <form id="doc-duplicate" class="form" method="post">
-      <p class="help-block">
-      Set new documents ID:
-      </p>
-      <input id="dup-id" type="text" class="input-block-level" >
-    </form>
-
-  </div>
-  <div class="modal-footer">
-    <button data-dismiss="modal" class="btn"><i class="icon fonticon-cancel-circled"></i> Cancel</button>
-    <button id="duplicate-btn" class="btn btn-success save"><i class="fonticon-ok-circled"></i> Clone</button>
-  </div>
-</div>
-
-