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/02/23 16:52:31 UTC

fauxton commit: updated refs/heads/master to 392dc52

Repository: couchdb-fauxton
Updated Branches:
  refs/heads/master 8cbf95d34 -> 392dc5231


Changes page Filters tab moved to React

This updates the Filter tab and content on the Changes page
to use React. It also ports over Components.FilterView and
Components.FilterViewItem found in fauxton/components


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

Branch: refs/heads/master
Commit: 392dc52318395788c2af26f1752f46d96fa8e24f
Parents: 8cbf95d
Author: Ben Keen <be...@gmail.com>
Authored: Tue Feb 10 17:04:23 2015 -0800
Committer: Ben Keen <be...@gmail.com>
Committed: Mon Feb 23 07:52:10 2015 -0800

----------------------------------------------------------------------
 app/addons/documents/assets/less/changes.less   |  41 ++-
 app/addons/documents/changes/actions.js         |  48 ++++
 app/addons/documents/changes/actiontypes.js     |  20 ++
 .../documents/changes/components.react.jsx      | 285 +++++++++++++++++++
 app/addons/documents/changes/stores.js          | 100 +++++++
 app/addons/documents/routes-documents.js        |   8 +-
 .../documents/templates/changes_header.html     |  31 --
 .../tests/changes.componentsSpec.react.jsx      | 185 ++++++++++++
 .../documents/tests/changes.storesSpec.js       |  73 +++++
 app/addons/documents/views-changes.js           |  24 +-
 app/addons/fauxton/components.js                |  72 -----
 assets/less/animations.less                     |   9 +
 assets/less/formstyles.less                     |   5 +
 13 files changed, 777 insertions(+), 124 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/392dc523/app/addons/documents/assets/less/changes.less
----------------------------------------------------------------------
diff --git a/app/addons/documents/assets/less/changes.less b/app/addons/documents/assets/less/changes.less
index ebba21d..a1e1d2d 100644
--- a/app/addons/documents/assets/less/changes.less
+++ b/app/addons/documents/assets/less/changes.less
@@ -1,3 +1,5 @@
+@import "../../../../../assets/less/animations.less";
+
 /** used in changes.html **/
 .change-box {
   margin: 0 20px 20px 20px;
@@ -32,7 +34,7 @@
 }
 
 .changes-header {
-  padding-top: 30px;
+  padding: 30px;
   height: 160px;
   border-bottom: 1px solid #999999;
   .label {
@@ -44,3 +46,40 @@
     margin: 0;
   }
 }
+
+.changes-tab-content {
+  padding-top: 70px;
+}
+
+#dashboard-upper-content .changes-header-section .tab-content {
+  padding: 0px;
+  overflow: hidden;
+}
+
+.keyframes(slideDownChangesFilter, {
+  opacity: 0;
+  height: 0px;
+},
+{
+  opacity: 1;
+  height: 160px;
+});
+
+.keyframes(slideUpChangesFilter, {
+  opacity: 1;
+  height: 160px;
+},
+{
+  opacity: 0;
+  height: 0px;
+});
+
+
+.toggleChangesFilter-enter {
+  .animation(slideDownChangesFilter 1s both);
+}
+
+.toggleChangesFilter-leave {
+  .animation(slideUpChangesFilter 1s both);
+}
+

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/392dc523/app/addons/documents/changes/actions.js
----------------------------------------------------------------------
diff --git a/app/addons/documents/changes/actions.js b/app/addons/documents/changes/actions.js
new file mode 100644
index 0000000..bcc6002
--- /dev/null
+++ b/app/addons/documents/changes/actions.js
@@ -0,0 +1,48 @@
+// 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/changes/actiontypes'
+],
+function (app, FauxtonAPI, ActionTypes) {
+
+  return {
+    toggleTabVisibility: function () {
+      FauxtonAPI.dispatch({
+        type: ActionTypes.TOGGLE_CHANGES_TAB_VISIBILITY
+      });
+    },
+
+    addFilter: function (filter) {
+      FauxtonAPI.dispatch({
+        type: ActionTypes.ADD_CHANGES_FILTER_ITEM,
+        filter: filter
+      });
+
+      // TODO for backward compatibility. Remove later.
+      FauxtonAPI.triggerRouteEvent('changesFilterAdd', filter);
+    },
+
+    removeFilter: function (filter) {
+      FauxtonAPI.dispatch({
+        type: ActionTypes.REMOVE_CHANGES_FILTER_ITEM,
+        filter: filter
+      });
+
+      // TODO for backward compatibility. Remove later.
+      FauxtonAPI.triggerRouteEvent('changesFilterRemove', filter);
+    }
+  };
+
+});

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/392dc523/app/addons/documents/changes/actiontypes.js
----------------------------------------------------------------------
diff --git a/app/addons/documents/changes/actiontypes.js b/app/addons/documents/changes/actiontypes.js
new file mode 100644
index 0000000..c76b844
--- /dev/null
+++ b/app/addons/documents/changes/actiontypes.js
@@ -0,0 +1,20 @@
+// 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 {
+    TOGGLE_CHANGES_TAB_VISIBILITY: 'TOGGLE_CHANGES_TAB_VISIBILITY',
+    ADD_CHANGES_FILTER_ITEM: 'ADD_CHANGES_FILTER_ITEM',
+    REMOVE_CHANGES_FILTER_ITEM: 'REMOVE_CHANGES_FILTER_ITEM',
+    UPDATE_CHANGES_FILTER: 'UPDATE_CHANGES_FILTER'
+  };
+});

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/392dc523/app/addons/documents/changes/components.react.jsx
----------------------------------------------------------------------
diff --git a/app/addons/documents/changes/components.react.jsx b/app/addons/documents/changes/components.react.jsx
new file mode 100644
index 0000000..977319e
--- /dev/null
+++ b/app/addons/documents/changes/components.react.jsx
@@ -0,0 +1,285 @@
+// 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([
+  'react',
+  'addons/documents/changes/actions',
+  'addons/documents/changes/stores'
+], function (React, Actions, Stores) {
+
+  var ReactCSSTransitionGroup = React.addons.CSSTransitionGroup;
+
+
+  // the top-level component for the Changes Filter section. Handles hiding/showing
+  var ChangesHeader = React.createClass({
+    getInitialState: function () {
+      return {
+        showTabContent: Stores.changesHeaderStore.isTabVisible()
+      };
+    },
+
+    onChange: function () {
+      this.setState({
+        showTabContent: Stores.changesHeaderStore.isTabVisible()
+      });
+    },
+
+    componentDidMount: function () {
+      Stores.changesHeaderStore.on('change', this.onChange, this);
+    },
+
+    componentWillUnmount: function () {
+      Stores.changesHeaderStore.off('change', this.onChange);
+    },
+
+    toggleFilterSection: function () {
+      Actions.toggleTabVisibility();
+    },
+
+    render: function () {
+      var tabContent = '';
+      if (this.state.showTabContent) {
+        tabContent = <ChangesFilter key="changesFilterSection" />;
+      }
+
+      return (
+        <div className="changes-header-section">
+          <ChangesHeaderTab onToggle={this.toggleFilterSection} />
+          <ReactCSSTransitionGroup transitionName="toggleChangesFilter" component="div" className="changes-tab-content">
+            {tabContent}
+          </ReactCSSTransitionGroup>
+        </div>
+      );
+    }
+  });
+
+
+  var ChangesHeaderTab = React.createClass({
+    propTypes: {
+      onToggle: React.PropTypes.func.isRequired
+    },
+
+    render: function () {
+      return (
+        <div className="dashboard-upper-menu">
+          <ul className="nav nav-tabs" id="db-views-tabs-nav">
+            <li>
+              <a href="#filter" onClick={this.props.onToggle} data-bypass="true" data-toggle="tab">
+                <i className="fonticon fonticon-plus"></i> Filter
+              </a>
+            </li>
+          </ul>
+        </div>
+      );
+    }
+  });
+
+
+  var ChangesFilter = React.createClass({
+    getStoreState: function () {
+      return {
+        filters: Stores.changesFilterStore.getFilters()
+      };
+    },
+
+    onChange: function () {
+      this.setState(this.getStoreState());
+    },
+
+    componentDidMount: function () {
+      Stores.changesFilterStore.on('change', this.onChange, this);
+    },
+
+    componentWillUnmount: function () {
+      Stores.changesFilterStore.off('change', this.onChange);
+    },
+
+    getInitialState: function () {
+      return this.getStoreState();
+    },
+
+    removeFilter: function (label) {
+      Actions.removeFilter(label);
+    },
+
+    getFilters: function () {
+      return _.map(this.state.filters, function (filter) {
+        return <Filter key={filter} label={filter} removeFilter={this.removeFilter} />;
+      }, this);
+    },
+
+    addFilter: function (newFilter) {
+      if (_.isEmpty(newFilter)) {
+        return;
+      }
+      Actions.addFilter(newFilter);
+    },
+
+    hasFilter: function (filter) {
+      return Stores.changesFilterStore.hasFilter(filter);
+    },
+
+    render: function () {
+      var filters = this.getFilters();
+
+      return (
+        <div className="tab-content">
+          <div className="tab-pane active" ref="filterTab">
+            <div className="changes-header js-filter">
+              <AddFilterForm tooltip={this.props.tooltip} filter={this.state.filter} addFilter={this.addFilter}
+                hasFilter={this.hasFilter} />
+              <ul className="filter-list">{filters}</ul>
+            </div>
+          </div>
+        </div>
+      );
+    }
+  });
+
+
+  var AddFilterForm = React.createClass({
+    propTypes: {
+      addFilter: React.PropTypes.func.isRequired,
+      hasFilter: React.PropTypes.func.isRequired
+    },
+
+    getInitialState: function () {
+      return {
+        filter: '',
+        error: false
+      };
+    },
+
+    getDefaultProps: function () {
+      return {
+        tooltip: ''
+      };
+    },
+
+    submitForm: function (e) {
+      e.preventDefault();
+      e.stopPropagation();
+
+      if (this.props.hasFilter(this.state.filter)) {
+        this.setState({ error: true });
+
+        // Yuck. This removes the class after the effect has completed so it can occur again. The
+        // other option is to use jQuery to add the flash. This seemed slightly less crumby
+        var component = this;
+        setTimeout(function () {
+          component.setState({ error: false });
+        }, 1000);
+      } else {
+        this.props.addFilter(this.state.filter);
+        this.setState({ filter: '', error: false });
+      }
+    },
+
+    componentDidMount: function () {
+      this.focusFilterField();
+    },
+
+    componentDidUpdate: function () {
+      this.focusFilterField();
+    },
+
+    focusFilterField: function () {
+      this.refs.addItem.getDOMNode().focus();
+    },
+
+    onChangeFilter: function (e) {
+      this.setState({ filter: e.target.value });
+    },
+
+    inputClassNames: function () {
+      var className = 'js-changes-filter-field';
+      if (this.state.error) {
+        className += ' errorHighlight';
+      }
+      return className;
+    },
+
+    render: function () {
+      return (
+        <form className="form-inline js-filter-form" onSubmit={this.submitForm}>
+          <fieldset>
+            <input type="text" ref="addItem" className={this.inputClassNames()} placeholder="Type a filter"
+              onChange={this.onChangeFilter} value={this.state.filter} />
+            <button type="submit" className="btn btn-primary">Filter</button>
+            <div className="help-block">
+              <strong ref="helpText">e.g. _design or document ID</strong>
+              {' '}
+              <FilterTooltip tooltip={this.props.tooltip} />
+            </div>
+          </fieldset>
+        </form>
+      );
+    }
+  });
+
+
+  var FilterTooltip = React.createClass({
+    componentDidMount: function () {
+      if (this.props.tooltip) {
+        $(this.refs.tooltip.getDOMNode()).tooltip();
+      }
+    },
+
+    render: function () {
+      if (!this.props.tooltip) {
+        return false;
+      }
+      return (
+        <i ref="tooltip" className="icon icon-question-sign js-filter-tooltip" data-toggle="tooltip"
+          data-original-title={this.props.tooltip}></i>
+      );
+    }
+  });
+
+
+  var Filter = React.createClass({
+    propTypes: {
+      label: React.PropTypes.string.isRequired,
+      removeFilter: React.PropTypes.func.isRequired
+    },
+
+    removeFilter: function (e) {
+      e.preventDefault();
+      this.props.removeFilter(this.props.label);
+    },
+
+    render: function () {
+      return (
+        <li>
+          <span className="label label-info">{this.props.label}</span>
+          <a href="#" className="label label-info js-remove-filter" onClick={this.removeFilter} data-bypass="true">&times;</a>
+        </li>
+      );
+    }
+  });
+
+
+  return {
+    renderHeader: function (el) {
+      React.render(<ChangesHeader />, el);
+    },
+    removeHeader: function (el) {
+      React.unmountComponentAtNode(el);
+    },
+
+    // exposed for testing purposes only
+    ChangesHeader: ChangesHeader,
+    ChangesHeaderTab: ChangesHeaderTab,
+    ChangesFilter: ChangesFilter
+  };
+
+});

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/392dc523/app/addons/documents/changes/stores.js
----------------------------------------------------------------------
diff --git a/app/addons/documents/changes/stores.js b/app/addons/documents/changes/stores.js
new file mode 100644
index 0000000..e36b392
--- /dev/null
+++ b/app/addons/documents/changes/stores.js
@@ -0,0 +1,100 @@
+// 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/changes/actiontypes'
+], function (FauxtonAPI, ActionTypes) {
+
+  var Stores = {};
+
+
+  // tracks the state of the header (open/closed)
+  var ChangesHeaderStore = FauxtonAPI.Store.extend({
+    initialize: function () {
+      this.reset();
+    },
+
+    reset: function () {
+      this._tabVisible = false;
+    },
+
+    toggleTabVisibility: function () {
+      this._tabVisible = !this._tabVisible;
+    },
+
+    isTabVisible: function () {
+      return this._tabVisible;
+    },
+
+    dispatch: function (action) {
+
+      // can I use an if-statement for a single item?
+      switch (action.type) {
+        case ActionTypes.TOGGLE_CHANGES_TAB_VISIBILITY:
+          this.toggleTabVisibility();
+          this.triggerChange();
+          break;
+      }
+    }
+  });
+
+  Stores.changesHeaderStore = new ChangesHeaderStore();
+  Stores.changesHeaderStore.dispatchToken = FauxtonAPI.dispatcher.register(Stores.changesHeaderStore.dispatch);
+
+
+  // tracks the list of filters
+  var ChangesFilterStore = FauxtonAPI.Store.extend({
+    initialize: function () {
+      this.reset();
+    },
+
+    reset: function () {
+      this._filters = [];
+    },
+
+    addFilter: function (filter) {
+      this._filters.push(filter);
+    },
+
+    removeFilter: function (filter) {
+      this._filters = _.without(this._filters, filter);
+    },
+
+    getFilters: function () {
+      return this._filters;
+    },
+
+    hasFilter: function (filter) {
+      return _.contains(this._filters, filter);
+    },
+
+    dispatch: function (action) {
+      switch (action.type) {
+        case ActionTypes.ADD_CHANGES_FILTER_ITEM:
+          this.addFilter(action.filter);
+          this.triggerChange();
+          break;
+        case ActionTypes.REMOVE_CHANGES_FILTER_ITEM:
+          this.removeFilter(action.filter);
+          this.triggerChange();
+          break;
+      }
+    }
+  });
+
+  Stores.changesFilterStore = new ChangesFilterStore();
+  Stores.changesFilterStore.dispatchToken = FauxtonAPI.dispatcher.register(Stores.changesFilterStore.dispatch);
+
+
+  return Stores;
+});

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/392dc523/app/addons/documents/routes-documents.js
----------------------------------------------------------------------
diff --git a/app/addons/documents/routes-documents.js b/app/addons/documents/routes-documents.js
index 1a72502..79a3c70 100644
--- a/app/addons/documents/routes-documents.js
+++ b/app/addons/documents/routes-documents.js
@@ -204,13 +204,7 @@ function(app, FauxtonAPI, BaseRoute, Documents, Changes, Index, DocEditor, Datab
         model: this.database
       }));
 
-      this.filterView = new Components.FilterView({
-        eventNamespace: "changes"
-      });
-
-      this.headerView = this.setView("#dashboard-upper-content", new Changes.ChangesHeader({
-        filterView: this.filterView
-      }));
+      this.headerView = this.setView('#dashboard-upper-content', new Changes.ChangesHeaderReactWrapper());
 
       this.footer && this.footer.remove();
       this.toolsView && this.toolsView.remove();

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/392dc523/app/addons/documents/templates/changes_header.html
----------------------------------------------------------------------
diff --git a/app/addons/documents/templates/changes_header.html b/app/addons/documents/templates/changes_header.html
deleted file mode 100644
index 51f55ed..0000000
--- a/app/addons/documents/templates/changes_header.html
+++ /dev/null
@@ -1,31 +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="dashboard-upper-menu">
-  <ul class="nav nav-tabs" id="db-views-tabs-nav">
-    <li>
-      <a class="js-toggle-filter" href="#filter" data-bypass="true" data-toggle="tab">
-        <i class="fonticon fonticon-plus"></i>Filter
-      </a>
-    </li>
-  </ul>
-</div>
-
-<div class="tab-content">
-  <div class="tab-pane" id="query">
-    <div class="changes-header">
-      <div class="pull-right js-filter"></div>
-    </div>
-  </div>
-</div>

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/392dc523/app/addons/documents/tests/changes.componentsSpec.react.jsx
----------------------------------------------------------------------
diff --git a/app/addons/documents/tests/changes.componentsSpec.react.jsx b/app/addons/documents/tests/changes.componentsSpec.react.jsx
new file mode 100644
index 0000000..4f3dce8
--- /dev/null
+++ b/app/addons/documents/tests/changes.componentsSpec.react.jsx
@@ -0,0 +1,185 @@
+// 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/changes/components.react',
+  'addons/documents/changes/stores',
+  'addons/documents/changes/actions',
+  'testUtils'
+], function (app, FauxtonAPI, React, Changes, Stores, Actions, utils) {
+  FauxtonAPI.router = new FauxtonAPI.Router([]);
+
+  var assert = utils.assert;
+  var TestUtils = React.addons.TestUtils;
+
+
+  describe('ChangesHeader', function () {
+    var container, tab, spy;
+
+    describe('Testing DOM', function () {
+      beforeEach(function () {
+        spy = sinon.spy(Actions, 'toggleTabVisibility');
+        container = document.createElement('div');
+        tab = TestUtils.renderIntoDocument(<Changes.ChangesHeader />, container);
+      });
+
+      afterEach(function () {
+        Stores.changesFilterStore.reset();
+        React.unmountComponentAtNode(container);
+      });
+
+      // similar as previous, except it confirms that the action gets fired, not the custom toggle func
+      it('calls toggleTabVisibility action on selecting a tab', function () {
+        TestUtils.Simulate.click($(tab.getDOMNode()).find('a')[0]);
+        assert.ok(spy.calledOnce);
+      });
+    });
+  });
+
+  describe('ChangesHeaderTab', function () {
+    var container, tab, toggleTabVisibility;
+
+    beforeEach(function () {
+      toggleTabVisibility = sinon.spy();
+      container = document.createElement('div');
+      tab = TestUtils.renderIntoDocument(<Changes.ChangesHeaderTab onToggle={toggleTabVisibility} />, container);
+    });
+
+    afterEach(function () {
+      Stores.changesFilterStore.reset();
+      React.unmountComponentAtNode(container);
+    });
+
+    it('should call toggle function on clicking tab', function () {
+      TestUtils.Simulate.click($(tab.getDOMNode()).find('a')[0]);
+      assert.ok(toggleTabVisibility.calledOnce);
+    });
+  });
+
+
+  describe('ChangesFilter', function () {
+    var container, changesFilterEl;
+
+    beforeEach(function () {
+      container = document.createElement('div');
+      changesFilterEl = TestUtils.renderIntoDocument(<Changes.ChangesFilter />, container);
+    });
+
+    afterEach(function () {
+      Stores.changesFilterStore.reset();
+      React.unmountComponentAtNode(container);
+    });
+
+    it('should add filter markup', function () {
+      var $el = $(changesFilterEl.getDOMNode()),
+          submitBtn = $el.find('[type="submit"]')[0],
+          addItemField = $el.find('.js-changes-filter-field')[0];
+
+      addItemField.value = 'I wandered lonely as a filter';
+      TestUtils.Simulate.change(addItemField);
+      TestUtils.Simulate.submit(submitBtn);
+
+      addItemField.value = 'A second filter';
+      TestUtils.Simulate.change(addItemField);
+      TestUtils.Simulate.submit(submitBtn);
+
+      assert.equal(2, $el.find('.js-remove-filter').length);
+    });
+
+    it('should call addFilter action on click', function () {
+      var $el = $(changesFilterEl.getDOMNode()),
+        submitBtn = $el.find('[type="submit"]')[0],
+        addItemField = $el.find('.js-changes-filter-field')[0];
+
+      var spy = sinon.spy(Actions, 'addFilter');
+
+      addItemField.value = 'I wandered lonely as a filter';
+      TestUtils.Simulate.change(addItemField);
+      TestUtils.Simulate.submit(submitBtn);
+
+      assert.ok(spy.calledOnce);
+    });
+
+    it('should remove filter markup', function () {
+      var $el = $(changesFilterEl.getDOMNode()),
+        submitBtn = $el.find('[type="submit"]')[0],
+        addItemField = $el.find('.js-changes-filter-field')[0];
+
+      addItemField.value = 'Bloop';
+      TestUtils.Simulate.change(addItemField);
+      TestUtils.Simulate.submit(submitBtn);
+
+      addItemField.value = 'Flibble';
+      TestUtils.Simulate.change(addItemField);
+      TestUtils.Simulate.submit(submitBtn);
+
+      // clicks ALL 'remove' elements
+      TestUtils.Simulate.click($el.find('.js-remove-filter')[0]);
+      TestUtils.Simulate.click($el.find('.js-remove-filter')[0]);
+
+      assert.equal(0, $el.find('.js-remove-filter').length);
+    });
+
+    it('should call removeFilter action on click', function () {
+      var $el = $(changesFilterEl.getDOMNode()),
+        submitBtn = $el.find('[type="submit"]')[0],
+        addItemField = $el.find('.js-changes-filter-field')[0];
+
+      var spy = sinon.spy(Actions, 'removeFilter');
+
+      addItemField.value = 'I wandered lonely as a filter';
+      TestUtils.Simulate.change(addItemField);
+      TestUtils.Simulate.submit(submitBtn);
+      TestUtils.Simulate.click($el.find('.js-remove-filter')[0]);
+
+      assert.ok(spy.calledOnce);
+    });
+
+    it('should not add empty filters', function () {
+      var $el = $(changesFilterEl.getDOMNode()),
+        submitBtn = $el.find('[type="submit"]')[0],
+        addItemField = $el.find('.js-changes-filter-field')[0];
+
+      addItemField.value = '';
+      TestUtils.Simulate.change(addItemField);
+      TestUtils.Simulate.submit(submitBtn);
+
+      assert.equal(0, $el.find('.js-remove-filter').length);
+    });
+
+    it('should not add tooltips by default', function () {
+      assert.equal(0, $(changesFilterEl.getDOMNode()).find('.js-remove-filter').length);
+    });
+
+    it('should not add the same filter twice', function () {
+      var $el = $(changesFilterEl.getDOMNode()),
+        submitBtn = $el.find('[type="submit"]')[0],
+        addItemField = $el.find('.js-changes-filter-field')[0];
+
+      var filter = 'I am unique in the whole wide world';
+      addItemField.value = filter;
+      TestUtils.Simulate.change(addItemField);
+      TestUtils.Simulate.submit(submitBtn);
+
+      addItemField.value = filter;
+      TestUtils.Simulate.change(addItemField);
+      TestUtils.Simulate.submit(submitBtn);
+
+      assert.equal(1, $el.find('.js-remove-filter').length);
+    });
+
+  });
+
+});

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/392dc523/app/addons/documents/tests/changes.storesSpec.js
----------------------------------------------------------------------
diff --git a/app/addons/documents/tests/changes.storesSpec.js b/app/addons/documents/tests/changes.storesSpec.js
new file mode 100644
index 0000000..f140c7c
--- /dev/null
+++ b/app/addons/documents/tests/changes.storesSpec.js
@@ -0,0 +1,73 @@
+// 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/changes/stores',
+  'testUtils'
+], function (app, FauxtonAPI, Stores, utils) {
+  FauxtonAPI.router = new FauxtonAPI.Router([]);
+
+  var assert = utils.assert;
+
+
+  describe('ChangesHeaderStore', function () {
+    it('toggleTabVisibility() changes state in store', function() {
+      assert.ok(Stores.changesHeaderStore.isTabVisible() === false);
+      Stores.changesHeaderStore.toggleTabVisibility();
+      assert.ok(Stores.changesHeaderStore.isTabVisible() === true);
+    });
+
+    it('reset() changes tab visibility to hidden', function() {
+      Stores.changesHeaderStore.toggleTabVisibility();
+      Stores.changesHeaderStore.reset();
+      assert.ok(Stores.changesHeaderStore.isTabVisible() === false);
+    });
+  });
+
+
+  describe('ChangesFilterStore', function () {
+
+    afterEach(function () {
+      Stores.changesFilterStore.reset();
+    });
+
+    it('addFilter() adds item in store', function () {
+      var filter = 'My filter';
+      Stores.changesFilterStore.addFilter(filter);
+      var filters = Stores.changesFilterStore.getFilters();
+      assert.ok(filters.length === 1);
+      assert.ok(filters[0] === filter);
+    });
+
+    it('removeFilter() removes item from store', function () {
+      var filter1 = 'My filter 1';
+      var filter2 = 'My filter 2';
+      Stores.changesFilterStore.addFilter(filter1);
+      Stores.changesFilterStore.addFilter(filter2);
+      Stores.changesFilterStore.removeFilter(filter1);
+
+      var filters = Stores.changesFilterStore.getFilters();
+      assert.ok(filters.length === 1);
+      assert.ok(filters[0] === filter2);
+    });
+
+    it('hasFilter() finds item in store', function () {
+      var filter = 'My filter';
+      Stores.changesFilterStore.addFilter(filter);
+      assert.ok(Stores.changesFilterStore.hasFilter(filter) === true);
+    });
+
+  });
+
+});

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/392dc523/app/addons/documents/views-changes.js
----------------------------------------------------------------------
diff --git a/app/addons/documents/views-changes.js b/app/addons/documents/views-changes.js
index 32c379d..11add17 100644
--- a/app/addons/documents/views-changes.js
+++ b/app/addons/documents/views-changes.js
@@ -14,34 +14,32 @@ define([
        "app",
 
        "api",
+
        // Libs
        "addons/fauxton/components",
+  'addons/documents/changes/components.react',
 
        // Plugins
        "plugins/prettify"
 ],
 
-function(app, FauxtonAPI, Components, prettify, ZeroClipboard) {
+function(app, FauxtonAPI, Components, Changes, prettify, ZeroClipboard) {
 
   var Views = {};
 
-  Views.ChangesHeader = FauxtonAPI.View.extend({
-    template: "addons/documents/templates/changes_header",
-
-    events: {
-      'click .js-toggle-filter': "toggleQuery"
-    },
 
-    toggleQuery: function (event) {
-      $('#dashboard-content').scrollTop(0);
-      this.$('#query').toggle('slow');
+  // wrapper for React component. The wrapper allows us to tie the React component into the Fauxton
+  // page load lifecycle
+  Views.ChangesHeaderReactWrapper = FauxtonAPI.View.extend({
+    afterRender: function () {
+      Changes.renderHeader(this.el);
     },
-
-    initialize: function () {
-      this.setView(".js-filter", this.filterView);
+    cleanup: function () {
+      Changes.removeHeader(this.el);
     }
   });
 
+
   Views.Changes = Components.FilteredView.extend({
     template: "addons/documents/templates/changes",
 

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/392dc523/app/addons/fauxton/components.js
----------------------------------------------------------------------
diff --git a/app/addons/fauxton/components.js b/app/addons/fauxton/components.js
index f78e9d8..462a424 100644
--- a/app/addons/fauxton/components.js
+++ b/app/addons/fauxton/components.js
@@ -692,78 +692,6 @@ function(app, FauxtonAPI, ace, spin, ZeroClipboard) {
     }
   });
 
-  Components.FilterView = FauxtonAPI.View.extend({
-    template: "addons/fauxton/templates/filter",
-
-    initialize: function (options) {
-      this.eventNamespace = options.eventNamespace;
-      this.tooltipText = options.tooltipText;
-    },
-
-    events: {
-      "submit .js-filter-form": "filterItems"
-    },
-
-    serialize: function () {
-      return {
-        tooltipText: this.tooltipText
-      };
-    },
-
-    filterItems: function (event) {
-      event.preventDefault();
-      var $filter = this.$('input[name="filter"]'),
-          filter = $.trim($filter.val());
-
-      if (!filter) {
-        return;
-      }
-
-      FauxtonAPI.triggerRouteEvent(this.eventNamespace + "FilterAdd", filter);
-
-      this.insertView(".filter-list", new Components.FilterItemView({
-        filter: filter,
-        eventNamespace: this.eventNamespace
-      })).render();
-
-      $filter.val('');
-    },
-
-    afterRender: function () {
-      if (this.tooltipText) {
-        this.$el.find(".js-filter-tooltip").tooltip();
-      }
-    }
-  });
-
-  Components.FilterItemView = FauxtonAPI.View.extend({
-    template: "addons/fauxton/templates/filter_item",
-    tagName: "li",
-
-    initialize: function (options) {
-      this.filter = options.filter;
-      this.eventNamespace = options.eventNamespace;
-    },
-
-    events: {
-      "click .js-remove-filter": "removeFilter"
-    },
-
-    serialize: function () {
-      return {
-        filter: this.filter
-      };
-    },
-
-    removeFilter: function (event) {
-      event.preventDefault();
-
-      FauxtonAPI.triggerRouteEvent(this.eventNamespace + "FilterRemove", this.filter);
-      this.remove();
-    }
-
-  });
-
   Components.Editor = FauxtonAPI.View.extend({
     initialize: function (options) {
       this.editorId = options.editorId;

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/392dc523/assets/less/animations.less
----------------------------------------------------------------------
diff --git a/assets/less/animations.less b/assets/less/animations.less
index cca377d..408c35a 100644
--- a/assets/less/animations.less
+++ b/assets/less/animations.less
@@ -24,3 +24,12 @@
 .fadeOut {
   opacity: 0;
 }
+
+@-webkit-keyframes errorBlinkBG {
+  from { background: @red; }
+  to { background: white; }
+}
+@keyframes errorBlinkBG {
+  from { background: @red; }
+  to { background: white; }
+}

http://git-wip-us.apache.org/repos/asf/couchdb-fauxton/blob/392dc523/assets/less/formstyles.less
----------------------------------------------------------------------
diff --git a/assets/less/formstyles.less b/assets/less/formstyles.less
index 746a2e7..4bd3b08 100644
--- a/assets/less/formstyles.less
+++ b/assets/less/formstyles.less
@@ -267,3 +267,8 @@ div.add-dropdown {
     font-size: 16px;
   }
 }
+
+input.errorHighlight {
+  -webkit-animation: errorBlinkBG 1s;
+  animation: errorBlinkBG 1s;
+}