Antonio-Maranhao closed pull request #1134: Update documents/changes to use 
redux
URL: https://github.com/apache/couchdb-fauxton/pull/1134
 
 
   

This is a PR merged from a forked repository.
As GitHub hides the original diff on merge, it is displayed below for
the sake of provenance:

As this is a foreign pull request (from a fork), the diff is supplied
below (as it won't show otherwise due to GitHub magic):

diff --git a/app/addons/documents/__tests__/changes-reducers.test.js 
b/app/addons/documents/__tests__/changes-reducers.test.js
new file mode 100644
index 000000000..9b494fcef
--- /dev/null
+++ b/app/addons/documents/__tests__/changes-reducers.test.js
@@ -0,0 +1,145 @@
+
+// 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 FauxtonAPI from '../../../core/api';
+import utils from '../../../../test/mocha/testUtils';
+import ActionTypes from '../changes/actiontypes';
+import reducer from '../changes/reducers';
+
+FauxtonAPI.router = new FauxtonAPI.Router([]);
+
+const assert = utils.assert;
+
+describe('Changes Reducer', () => {
+
+  const changesList = [
+    { id: 'doc_1', seq: 4, deleted: false, changes: { code: 'here' }, isNew: 
false },
+    { id: 'doc_2', seq: 1, deleted: false, changes: { code: 'here' }, isNew: 
false },
+    { id: 'doc_3', seq: 6, deleted: true, changes: { code: 'here' }, isNew: 
false },
+    { id: 'doc_4', seq: 7, deleted: false, changes: { code: 'here' }, isNew: 
false },
+    { id: 'doc_5', seq: 1, deleted: true, changes: { code: 'here' }, isNew: 
false }
+  ];
+
+  it('adds new filter to state', () => {
+    const filter = 'My filter';
+    const action = {
+      type: ActionTypes.ADD_CHANGES_FILTER_ITEM,
+      filter
+    };
+    const newState = reducer(undefined, action);
+
+    assert.ok(newState.filters.length === 1);
+    assert.ok(newState.filters[0] === filter);
+  });
+
+  it('removes filter from state', () => {
+    const filter1 = 'My filter 1';
+    const filter2 = 'My filter 2';
+    let newState = reducer(undefined, {
+      type: ActionTypes.ADD_CHANGES_FILTER_ITEM,
+      filter: filter1
+    });
+    newState = reducer(newState, {
+      type: ActionTypes.ADD_CHANGES_FILTER_ITEM,
+      filter: filter2
+    });
+    newState = reducer(newState, {
+      type: ActionTypes.REMOVE_CHANGES_FILTER_ITEM,
+      filter: filter1
+    });
+
+    assert.ok(newState.filters.length === 1);
+    assert.ok(newState.filters[0] === filter2);
+  });
+
+  it('number of items is capped by maxChangesListed', () => {
+
+    // to keep the test speedy, we override the default max value
+    const maxChanges = 10;
+    const changes = [];
+    for (let i = 0; i < maxChanges + 10; i++) {
+      changes.push({ id: 'doc_' + i, seq: 1, changes: {}});
+    }
+    let state = reducer(undefined, {type: 'DO_NOTHING'});
+    state.maxChangesListed = maxChanges;
+
+    const seqNum = 123;
+    state = reducer(state, {
+      type: ActionTypes.UPDATE_CHANGES,
+      seqNum,
+      changes
+    });
+    assert.equal(state.changes.length, changes.length);
+    assert.equal(state.filteredChanges.length, maxChanges);
+  });
+
+  it('tracks last sequence number', () => {
+    let state = reducer(undefined, {type: 'DO_NOTHING'});
+    assert.equal(state.lastSequenceNum, null);
+
+    const seqNum = 123;
+    state = reducer(state, {
+      type: ActionTypes.UPDATE_CHANGES,
+      seqNum,
+      changes: []
+    });
+
+    // confirm it's been stored
+    assert.equal(state.lastSequenceNum, seqNum);
+  });
+
+  it('"true" filter should apply to change deleted status', () => {
+    let state = reducer(undefined, {
+      type: ActionTypes.UPDATE_CHANGES,
+      seqNum: 123,
+      changes: changesList
+    });
+
+    // add a filter
+    state = reducer(state, {
+      type: ActionTypes.ADD_CHANGES_FILTER_ITEM,
+      filter: 'true'
+    });
+
+    // confirm only the two deleted items are part of filtered results
+    assert.equal(state.filteredChanges.length, 2);
+    state.filteredChanges.forEach(el => {
+      assert.equal(el.deleted, true);
+    });
+  });
+
+  // confirms that if there are multiple filters, ALL are applied to return 
the subset of results that match
+  // all filters
+  it('multiple filters should all be applied to results', () => {
+    let state = reducer(undefined, {
+      type: ActionTypes.UPDATE_CHANGES,
+      seqNum: 123,
+      changes: changesList
+    });
+
+    // add the filters
+    state = reducer(state, {
+      type: ActionTypes.ADD_CHANGES_FILTER_ITEM,
+      filter: 'true'
+    });
+    state = reducer(state, {
+      type: ActionTypes.ADD_CHANGES_FILTER_ITEM,
+      filter: '1'
+    });
+
+    // confirm only doc_5 matches both filters
+    assert.equal(state.filteredChanges.length, 1);
+    assert.equal(state.filteredChanges[0].id, 'doc_5');
+  });
+
+});
diff --git a/app/addons/documents/__tests__/changes-stores.test.js 
b/app/addons/documents/__tests__/changes-stores.test.js
deleted file mode 100644
index 7336af038..000000000
--- a/app/addons/documents/__tests__/changes-stores.test.js
+++ /dev/null
@@ -1,90 +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.
-
-import FauxtonAPI from "../../../core/api";
-import Stores from "../changes/stores";
-import utils from "../../../../test/mocha/testUtils";
-FauxtonAPI.router = new FauxtonAPI.Router([]);
-
-const assert = utils.assert;
-
-describe('ChangesStore', () => {
-
-  afterEach(() => {
-    Stores.changesStore.reset();
-  });
-
-  it('addFilter() adds item in store', () => {
-    const filter = 'My filter';
-    Stores.changesStore.addFilter(filter);
-    const filters = Stores.changesStore.getFilters();
-    assert.ok(filters.length === 1);
-    assert.ok(filters[0] === filter);
-  });
-
-  it('removeFilter() removes item from store', () => {
-    const filter1 = 'My filter 1';
-    const filter2 = 'My filter 2';
-    Stores.changesStore.addFilter(filter1);
-    Stores.changesStore.addFilter(filter2);
-    Stores.changesStore.removeFilter(filter1);
-
-    const filters = Stores.changesStore.getFilters();
-    assert.ok(filters.length === 1);
-    assert.ok(filters[0] === filter2);
-  });
-
-  it('hasFilter() finds item in store', () => {
-    const filter = 'My filter';
-    Stores.changesStore.addFilter(filter);
-    assert.ok(Stores.changesStore.hasFilter(filter) === true);
-  });
-
-  it('getDatabaseName() returns database name', () => {
-    const dbName = 'hoopoes';
-    Stores.changesStore.initChanges({ databaseName: dbName });
-    assert.equal(Stores.changesStore.getDatabaseName(), dbName);
-
-    Stores.changesStore.reset();
-    assert.equal(Stores.changesStore.getDatabaseName(), '');
-  });
-
-  it("getChanges() should return a subset if there are a lot of changes", () 
=> {
-
-    // to keep the test speedy, we override the default max value
-    const maxChanges = 10;
-    const changes = [];
-    _.times(maxChanges + 10, (i) => {
-      changes.push({ id: 'doc_' + i, seq: 1, changes: {}});
-    });
-    Stores.changesStore.initChanges({ databaseName: "test" });
-    Stores.changesStore.setMaxChanges(maxChanges);
-
-    const seqNum = 123;
-    Stores.changesStore.updateChanges(seqNum, changes);
-
-    const results = Stores.changesStore.getChanges();
-    assert.equal(maxChanges, results.length);
-  });
-
-  it("tracks last sequence number", () => {
-    assert.equal(null, Stores.changesStore.getLastSeqNum());
-
-    const seqNum = 123;
-    Stores.changesStore.updateChanges(seqNum, []);
-
-    // confirm it's been stored
-    assert.equal(seqNum, Stores.changesStore.getLastSeqNum());
-  });
-
-});
diff --git a/app/addons/documents/__tests__/changes.test.js 
b/app/addons/documents/__tests__/changes.test.js
index 07454588e..833c8c167 100644
--- a/app/addons/documents/__tests__/changes.test.js
+++ b/app/addons/documents/__tests__/changes.test.js
@@ -13,9 +13,9 @@
 
 import React from "react";
 import ReactDOM from "react-dom";
-import Changes from "../changes/components";
-import Stores from "../changes/stores";
-import Actions from "../changes/actions";
+import ChangeRow from '../changes/components/ChangeRow';
+import ChangesScreen from '../changes/components/ChangesScreen';
+import ChangesTabContent from "../changes/components/ChangesTabContent";
 import {mount} from 'enzyme';
 import utils from "../../../../test/mocha/testUtils";
 import sinon from "sinon";
@@ -25,86 +25,78 @@ const assert = utils.assert;
 
 
 describe('ChangesTabContent', () => {
-  let el;
-
-  beforeEach(() => {
-    el = mount(<Changes.ChangesTabContent />);
-  });
-
-  afterEach(() => {
-    Stores.changesStore.reset();
-  });
-
-  it('should add filter markup', () => {
-    const submitBtn = el.find('[type="submit"]'),
-          addItemField = el.find('.js-changes-filter-field');
-
-    addItemField.simulate('change', {target: {value: 'I wandered lonely as a 
filter'}});
-    submitBtn.simulate('submit');
+  const defaultProps = {
+    filters: [],
+    addFilter: () => {},
+    removeFilter: () => {}
+  };
 
-    addItemField.simulate('change', {target: {value: 'A second filter'}});
-    submitBtn.simulate('submit');
+  it('should add filter badges', () => {
+    const el = mount(<ChangesTabContent
+      {...defaultProps}
+      filters={['I wandered lonely as a filter', 'A second filter']}
+    />);
 
     assert.equal(2, el.find('.remove-filter').length);
   });
 
   it('should call addFilter action on click', () => {
+    const addFilterStub = sinon.stub();
+    const el = mount(<ChangesTabContent
+      {...defaultProps}
+      addFilter={addFilterStub}
+    />);
     const submitBtn = el.find('[type="submit"]'),
           addItemField = el.find('.js-changes-filter-field');
 
-    const spy = sinon.spy(Actions, 'addFilter');
-
     addItemField.simulate('change', {target: {value: 'I wandered lonely as a 
filter'}});
     submitBtn.simulate('submit');
 
-    assert.ok(spy.calledOnce);
-  });
-
-  it('should remove filter markup', () => {
-    const submitBtn = el.find('[type="submit"]'),
-          addItemField = el.find('.js-changes-filter-field');
-
-    addItemField.simulate('change', {target: {value: 'I wandered lonely as a 
filter'}});
-    submitBtn.simulate('submit');
-
-    addItemField.simulate('change', {target: {value: 'Flibble'}});
-    submitBtn.simulate('submit');
-
-    // clicks ALL 'remove' elements
-    el.find('.remove-filter').first().simulate('click');
-    el.find('.remove-filter').simulate('click');
-
-    assert.equal(0, el.find('.remove-filter').length);
+    assert.ok(addFilterStub.calledOnce);
   });
 
   it('should call removeFilter action on click', () => {
-    const submitBtn = el.find('[type="submit"]'),
-          addItemField = el.find('.js-changes-filter-field');
-
-    const spy = sinon.spy(Actions, 'removeFilter');
-
-    addItemField.simulate('change', {target: {value: 'Flibble'}});
-    submitBtn.simulate('submit');
+    const removeFilterStub = sinon.stub();
+    const el = mount(<ChangesTabContent
+      {...defaultProps}
+      filters={['I wandered lonely as a filter']}
+      removeFilter={removeFilterStub}
+    />);
     el.find('.remove-filter').simulate('click');
 
-    assert.ok(spy.calledOnce);
+    assert.ok(removeFilterStub.calledOnce);
   });
 
   it('should not add empty filters', () => {
+    const addFilterStub = sinon.stub();
+    const el = mount(<ChangesTabContent
+      {...defaultProps}
+      addFilter={addFilterStub}
+    />);
     const submitBtn = el.find('[type="submit"]'),
           addItemField = el.find('.js-changes-filter-field');
 
     addItemField.simulate('change', {target: {value: ''}});
     submitBtn.simulate('submit');
 
-    assert.equal(0, el.find('.remove-filter').length);
+    assert.ok(addFilterStub.notCalled);
   });
 
-  it('should not add tooltips by default', () => {
+  it('should not add badges by default', () => {
+    const el = mount(<ChangesTabContent
+      {...defaultProps}
+    />);
     assert.equal(0, el.find('.remove-filter').length);
   });
 
   it('should not add the same filter twice', () => {
+    const filters = [];
+    let callCount = 0;
+    const el = mount(<ChangesTabContent
+      {...defaultProps}
+      addFilter={(f) => {filters.push(f); callCount++;}}
+      filters={filters}
+    />);
     const submitBtn = el.find('[type="submit"]'),
           addItemField = el.find('.js-changes-filter-field');
 
@@ -115,143 +107,52 @@ describe('ChangesTabContent', () => {
     addItemField.simulate('change', {target: {value: filter}});
     submitBtn.simulate('submit');
 
-    assert.equal(1, el.find('.remove-filter').length);
+    assert.equal(callCount, 1);
   });
 });
 
 
-describe('ChangesController', () => {
-  let  headerEl, changesEl;
-
-  const results = [
-    { id: 'doc_1', seq: 4, deleted: false, changes: { code: 'here' } },
-    { id: 'doc_2', seq: 1, deleted: false, changes: { code: 'here' } },
-    { id: 'doc_3', seq: 6, deleted: true, changes: { code: 'here' } },
-    { id: 'doc_4', seq: 7, deleted: false, changes: { code: 'here' } },
-    { id: 'doc_5', seq: 1, deleted: true, changes: { code: 'here' } }
+describe('ChangesScreen', () => {
+  const changesList = [
+    { id: 'doc_1', seq: 4, deleted: false, changes: { code: 'here' }, isNew: 
false },
+    { id: 'doc_2', seq: 1, deleted: false, changes: { code: 'here' }, isNew: 
false },
+    { id: 'doc_3', seq: 6, deleted: true, changes: { code: 'here' }, isNew: 
false },
+    { id: 'doc_4', seq: 7, deleted: false, changes: { code: 'here' }, isNew: 
false },
+    { id: 'doc_5', seq: 1, deleted: true, changes: { code: 'here' }, isNew: 
false }
   ];
 
-  const changesResponse = {
-    last_seq: 123,
-    'results': results
+  const defaultProps = {
+    changes: [],
+    loaded: true,
+    databaseName: 'my_db',
+    isShowingSubset: false,
+    loadChanges: () => {}
   };
 
-  beforeEach(() => {
-    Actions.initChanges({ databaseName: 'testDatabase' });
-    Actions.updateChanges(changesResponse);
-    headerEl  = mount(<Changes.ChangesTabContent />);
-    changesEl = mount(<Changes.ChangesController />);
-  });
-
-  afterEach(() => {
-    Stores.changesStore.reset();
-  });
-
-
   it('should list the right number of changes', () => {
-    changesEl.update();
-    assert.equal(results.length, changesEl.find('.change-box').length);
-  });
-
-
-  it('"false"/"true" filter strings should apply to change deleted status', () 
=> {
-    // add a filter
-    const addItemField = headerEl.find('.js-changes-filter-field');
-    const submitBtn = headerEl.find('[type="submit"]');
-    addItemField.value = 'true';
-    addItemField.simulate('change', {target: {value: 'true'}});
-    submitBtn.simulate('submit');
-
-    // confirm only the two deleted items shows up and the IDs maps to the 
deleted rows
-    changesEl.update();
-    assert.equal(2, changesEl.find('.change-box').length);
-    assert.equal('doc_3', changesEl.find('.js-doc-id').first().text());
-    assert.equal('doc_5', changesEl.find('.js-doc-id').at(1).text());
-  });
+    const changesEl = mount(<ChangesScreen
+      {...defaultProps}
+      changes={changesList}
+    />);
 
-
-  it('confirms that a filter affects the actual search results', () => {
-    // add a filter
-    const addItemField = headerEl.find('.js-changes-filter-field');
-    const submitBtn = headerEl.find('[type="submit"]');
-    addItemField.simulate('change', {target: {value: '6'}});
-    submitBtn.simulate('submit');
-
-    // confirm only one item shows up and the ID maps to what we'd expect
-    changesEl.update();
-    assert.equal(1, changesEl.find('.change-box').length);
-    assert.equal('doc_3', changesEl.find('.js-doc-id').first().text());
-  });
-
-
-  // confirms that if there are multiple filters, ALL are applied to return 
the subset of results that match
-  // all filters
-  it('multiple filters should all be applied to results', () => {
-    // add the filters
-    const addItemField = headerEl.find('.js-changes-filter-field');
-    const submitBtn = headerEl.find('[type="submit"]');
-
-    // *** should match doc_1, doc_2 and doc_5
-    addItemField.simulate('change', {target: {value: '1'}});
-    submitBtn.simulate('submit');
-
-    // *** should match doc_3 and doc_5
-    addItemField.simulate('change', {target: {value: 'true'}});
-    submitBtn.simulate('submit');
-
-    // confirm only one item shows up and that it's doc_5
-    changesEl.update();
-    assert.equal(1, changesEl.find('.change-box').length);
-    assert.equal('doc_5', changesEl.find('.js-doc-id').first().text());
+    assert.equal(changesList.length, changesEl.find('.change-box').length);
   });
 
   it('shows a No Docs Found message if no docs', () => {
-    Stores.changesStore.reset();
-    Actions.updateChanges({ last_seq: 124, results: [] });
-    changesEl.update();
+    const changesEl = mount(<ChangesScreen
+      {...defaultProps}
+    />);
     assert.ok(/There\sare\sno\sdocument\schanges/.test(changesEl.html()));
   });
-});
-
-
-describe('ChangesController max results', () => {
-  let changesEl;
-  const maxChanges = 10;
-
-
-  beforeEach(() => {
-    const changes = [];
-    _.times(maxChanges + 10, (i) => {
-      changes.push({ id: 'doc_' + i, seq: 1, changes: { code: 'here' } });
-    });
-
-    const response = {
-      last_seq: 1,
-      results: changes
-    };
-
-    Actions.initChanges({ databaseName: 'test' });
-
-    // to keep the test speedy, override the default value (1000)
-    Stores.changesStore.setMaxChanges(maxChanges);
-
-    Actions.updateChanges(response);
-    changesEl = mount(<Changes.ChangesController />);
-  });
-
-  afterEach(() => {
-    Stores.changesStore.reset();
-  });
-
-  it('should truncate the number of results with very large # of changes', () 
=> {
-    // check there's no more than maxChanges results
-    assert.equal(maxChanges, changesEl.find('.change-box').length);
-  });
 
   it('should show a message if the results are truncated', () => {
+    const changesEl = mount(<ChangesScreen
+      {...defaultProps}
+      changes={changesList}
+      isShowingSubset={true}
+    />);
     assert.equal(1, changesEl.find('.changes-result-limit').length);
   });
-
 });
 
 
@@ -264,7 +165,7 @@ describe('ChangeRow', () => {
   };
 
   it('clicking the toggle-json button shows the code section', function () {
-    const changeRow = mount(<Changes.ChangeRow change={change} 
databaseName="testDatabase" />);
+    const changeRow = mount(<ChangeRow change={change} 
databaseName="testDatabase" />);
 
     // confirm it's hidden by default
     assert.equal(0, changeRow.find('.prettyprint').length);
@@ -276,13 +177,13 @@ describe('ChangeRow', () => {
 
   it('deleted docs should not be clickable', () => {
     change.deleted = true;
-    const changeRow = mount(<Changes.ChangeRow change={change} 
databaseName="testDatabase" />);
+    const changeRow = mount(<ChangeRow change={change} 
databaseName="testDatabase" />);
     assert.equal(0, changeRow.find('a.js-doc-link').length);
   });
 
   it('non-deleted docs should be clickable', () => {
     change.deleted = false;
-    const changeRow = mount(<Changes.ChangeRow change={change} 
databaseName="testDatabase" />);
+    const changeRow = mount(<ChangeRow change={change} 
databaseName="testDatabase" />);
     assert.equal(1, changeRow.find('a.js-doc-link').length);
   });
 });
diff --git a/app/addons/documents/base.js b/app/addons/documents/base.js
index 090a45099..c5f78261e 100644
--- a/app/addons/documents/base.js
+++ b/app/addons/documents/base.js
@@ -20,6 +20,7 @@ import mangoReducers from "./mango/mango.reducers";
 import sidebarReducers from "./sidebar/reducers";
 import partitionKeyReducers from "./partition-key/reducers";
 import revisionBrowserReducers from './rev-browser/reducers';
+import changesReducers from './changes/reducers';
 import "./assets/less/documents.less";
 
 FauxtonAPI.addReducers({
@@ -28,6 +29,7 @@ FauxtonAPI.addReducers({
   sidebar: sidebarReducers,
   revisionBrowser: revisionBrowserReducers,
   partitionKey: partitionKeyReducers,
+  changes: changesReducers,
   designDocInfo: designDocInfoReducers
 });
 
diff --git a/app/addons/documents/changes/actions.js 
b/app/addons/documents/changes/actions.js
index 224bd12ff..f6f72d4de 100644
--- a/app/addons/documents/changes/actions.js
+++ b/app/addons/documents/changes/actions.js
@@ -11,80 +11,74 @@
 // License for the specific language governing permissions and limitations 
under
 // the License.
 
-import app from "../../../app";
-import FauxtonAPI from "../../../core/api";
-import { get } from "../../../core/ajax";
-import ActionTypes from "./actiontypes";
-import Stores from "./stores";
-import Helpers from "../helpers";
+import app from '../../../app';
+import FauxtonAPI from '../../../core/api';
+import { get } from '../../../core/ajax';
+import ActionTypes from './actiontypes';
+import Helpers from '../helpers';
 
-const changesStore = Stores.changesStore;
 const pollingTimeout = 60000;
-var currentRequest;
+let currentRequest;
 
+const addFilter = (filter) => (dispatch) => {
+  dispatch({
+    type: ActionTypes.ADD_CHANGES_FILTER_ITEM,
+    filter: filter
+  });
+};
 
-export default {
-  addFilter: function (filter) {
-    FauxtonAPI.dispatch({
-      type: ActionTypes.ADD_CHANGES_FILTER_ITEM,
-      filter: filter
-    });
-  },
+const removeFilter = (filter) => (dispatch) => {
+  dispatch({
+    type: ActionTypes.REMOVE_CHANGES_FILTER_ITEM,
+    filter: filter
+  });
+};
 
-  removeFilter: function (filter) {
-    FauxtonAPI.dispatch({
-      type: ActionTypes.REMOVE_CHANGES_FILTER_ITEM,
-      filter: filter
-    });
-  },
+const loadChanges = (databaseName) => (dispatch) => {
+  currentRequest = null;
+  getLatestChanges(dispatch, databaseName);
+};
 
-  initChanges: function (options) {
-    FauxtonAPI.dispatch({
-      type: ActionTypes.INIT_CHANGES,
-      options: options
-    });
-    currentRequest = null;
-    this.getLatestChanges();
-  },
+const getLatestChanges = (dispatch, databaseName, lastSeqNum) => {
+  const params = {
+    limit: 100
+  };
 
-  getLatestChanges: function () {
-    const params = {
-      limit: 100
-    };
+  // after the first request for the changes list has been made, switch to 
longpoll
+  if (currentRequest) {
+    params.since = lastSeqNum;
+    params.timeout = pollingTimeout;
+    params.feed = 'longpoll';
+  }
 
-    // after the first request for the changes list has been made, switch to 
longpoll
-    if (currentRequest) {
-      params.since = changesStore.getLastSeqNum();
-      params.timeout = pollingTimeout;
-      params.feed = 'longpoll';
+  const query = app.utils.queryParams(params);
+  const db = app.utils.safeURLName(databaseName);
+  const endpoint = FauxtonAPI.urls('changes', 'server', db, '?' + query);
+  get(endpoint).then((res) => {
+    if (res.error) {
+      throw new Error(res.reason || res.error);
     }
-
-    const query = app.utils.queryParams(params);
-    const db = app.utils.safeURLName(changesStore.getDatabaseName());
-    const endpoint = FauxtonAPI.urls('changes', 'server', db, '?' + query);
-    get(endpoint).then((res) => {
-      if (res.error) {
-        throw new Error(res.reason || res.error);
-      }
-      this.updateChanges(res);
-    }).catch((err) => {
-      FauxtonAPI.addNotification({
-        msg: 'Error loading list of changes. Reason: ' + err.message,
-        type: 'error',
-        clear: true
-      });
+    updateChanges(res, dispatch);
+  }).catch((err) => {
+    FauxtonAPI.addNotification({
+      msg: 'Error loading list of changes. Reason: ' + err.message,
+      type: 'error',
+      clear: true
     });
-  },
+  });
+};
 
-  updateChanges: function (json) {
-    // only bother updating the list of changes if the seq num has changed
-    const latestSeqNum = Helpers.getSeqNum(json.last_seq);
-    if (latestSeqNum !== changesStore.getLastSeqNum()) {
-      FauxtonAPI.dispatch({
-        type: ActionTypes.UPDATE_CHANGES,
-        changes: json.results,
-        seqNum: latestSeqNum
-      });
-    }
-  }
+const updateChanges = (json, dispatch) => {
+  const latestSeqNum = Helpers.getSeqNum(json.last_seq);
+  dispatch({
+    type: ActionTypes.UPDATE_CHANGES,
+    changes: json.results,
+    seqNum: latestSeqNum
+  });
+};
+
+export default {
+  addFilter,
+  removeFilter,
+  loadChanges
 };
diff --git a/app/addons/documents/changes/actiontypes.js 
b/app/addons/documents/changes/actiontypes.js
index bd2e4128c..6325683e6 100644
--- a/app/addons/documents/changes/actiontypes.js
+++ b/app/addons/documents/changes/actiontypes.js
@@ -11,7 +11,6 @@
 // the License.
 
 export default {
-  INIT_CHANGES: 'INIT_CHANGES',
   UPDATE_CHANGES: 'UPDATE_CHANGES',
   ADD_CHANGES_FILTER_ITEM: 'ADD_CHANGES_FILTER_ITEM',
   REMOVE_CHANGES_FILTER_ITEM: 'REMOVE_CHANGES_FILTER_ITEM',
diff --git a/app/addons/documents/changes/components.js 
b/app/addons/documents/changes/components.js
deleted file mode 100644
index 3c5d1c961..000000000
--- a/app/addons/documents/changes/components.js
+++ /dev/null
@@ -1,411 +0,0 @@
-import FauxtonAPI from "../../../core/api";
-
-// 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 PropTypes from 'prop-types';
-
-import React from "react";
-import ReactDOM from "react-dom";
-import Actions from "./actions";
-import Stores from "./stores";
-import Components from "../../fauxton/components";
-import ReactComponents from "../../components/react-components";
-import {TransitionMotion, spring, presets} from 'react-motion';
-import "../../../../assets/js/plugins/prettify";
-import uuid from 'uuid';
-
-const store = Stores.changesStore;
-const BadgeList = ReactComponents.BadgeList;
-const {Copy} = ReactComponents;
-
-class ChangesController extends React.Component {
-  constructor (props) {
-    super(props);
-    this.state = this.getStoreState();
-  }
-
-  getStoreState () {
-    return {
-      changes: store.getChanges(),
-      loaded: store.isLoaded(),
-      databaseName: store.getDatabaseName(),
-      isShowingSubset: store.isShowingSubset()
-    };
-  }
-
-  onChange () {
-    this.setState(this.getStoreState());
-  }
-
-  componentDidMount () {
-    store.on('change', this.onChange, this);
-  }
-
-  componentWillUnmount () {
-    store.off('change', this.onChange);
-  }
-
-  showingSubsetMsg () {
-    const { isShowingSubset, changes } = this.state;
-    let msg = '';
-    if (isShowingSubset) {
-      let numChanges = changes.length;
-      msg = <p className="changes-result-limit">Limiting results to latest 
<b>{numChanges}</b> changes.</p>;
-    }
-    return msg;
-  }
-
-  getRows () {
-    const { changes, loaded, databaseName } = this.state;
-    if (!changes.length && loaded) {
-      return (
-        <p className="no-doc-changes">
-          There are no document changes to display.
-        </p>
-      );
-    }
-
-    return changes.map((change, i) => {
-      return <ChangeRow change={change} key={i} databaseName={databaseName} />;
-    });
-  }
-
-  render () {
-    return (
-      <div>
-        <div className="js-changes-view">
-          {this.showingSubsetMsg()}
-          {this.getRows()}
-        </div>
-      </div>
-    );
-  }
-}
-
-
-class ChangesTabContent extends React.Component {
-  constructor (props) {
-    super(props);
-    this.state = this.getStoreState();
-  }
-
-  getStoreState () {
-    return {
-      filters: store.getFilters()
-    };
-  }
-
-  onChange () {
-    this.setState(this.getStoreState());
-  }
-
-  componentDidMount () {
-    store.on('change', this.onChange, this);
-  }
-
-  componentWillUnmount () {
-    store.off('change', this.onChange);
-  }
-
-  addFilter (newFilter) {
-    if (_.isEmpty(newFilter)) {
-      return;
-    }
-    Actions.addFilter(newFilter);
-  }
-
-  hasFilter (filter) {
-    return store.hasFilter(filter);
-  }
-
-  render () {
-    return (
-      <div className="changes-header">
-        <AddFilterForm filter={(label) => Actions.removeFilter(label)} 
addFilter={this.addFilter}
-          hasFilter={this.hasFilter} />
-        <BadgeList elements={this.state.filters} removeBadge={(label) => 
Actions.removeFilter(label)} />
-      </div>
-    );
-  }
-}
-
-
-class AddFilterForm extends React.Component {
-  constructor (props) {
-    super(props);
-    this.state = {
-      filter: '',
-      error: false
-    };
-    this.submitForm = this.submitForm.bind(this);
-  }
-
-  submitForm (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
-      let component = this;
-      setTimeout(function () {
-        component.setState({ error: false });
-      }, 1000);
-    } else {
-      this.props.addFilter(this.state.filter);
-      this.setState({ filter: '', error: false });
-    }
-  }
-
-  componentDidMount () {
-    this.focusFilterField();
-  }
-
-  componentDidUpdate () {
-    this.focusFilterField();
-  }
-
-  focusFilterField () {
-    this.addItem.focus();
-  }
-
-  inputClassNames () {
-    let className = 'js-changes-filter-field';
-    if (this.state.error) {
-      className += ' errorHighlight';
-    }
-    return className;
-  }
-
-  render () {
-    return (
-      <form className="form-inline js-filter-form" onSubmit={this.submitForm}>
-        <fieldset>
-          <i className="fonticon-filter" />
-          <input
-            type="text"
-            ref={node => this.addItem = node}
-            className={this.inputClassNames()}
-            placeholder="Sequence or ID"
-            onChange={(e) => this.setState({ filter: e.target.value })}
-            value={this.state.filter} />
-          <button type="submit" className="btn btn-secondary">Filter</button>
-          <div className="help-block"></div>
-        </fieldset>
-      </form>
-    );
-  }
-}
-AddFilterForm.propTypes = {
-  addFilter: PropTypes.func.isRequired,
-  hasFilter: PropTypes.func.isRequired,
-  tooltips: PropTypes.string
-};
-AddFilterForm.defaultProps = {
-  tooltip: ''
-};
-
-class ChangeRow extends React.Component {
-  constructor (props) {
-    super(props);
-    this.state = {
-      codeVisible: false
-    };
-  }
-
-  toggleJSON (e) {
-    e.preventDefault();
-    this.setState({ codeVisible: !this.state.codeVisible });
-  }
-
-  getChangesCode () {
-    return (this.state.codeVisible) ? <Components.CodeFormat 
key="changesCodeSection" code={this.getChangeCode()} /> : null;
-  }
-
-  getChangeCode () {
-    return {
-      changes: this.props.change.changes,
-      doc: this.props.change.doc
-    };
-  }
-
-  showCopiedMessage (target) {
-    let msg = 'The document ID has been copied to your clipboard.';
-    if (target === 'seq') {
-      msg = 'The document seq number has been copied to your clipboard.';
-    }
-    FauxtonAPI.addNotification({
-      msg: msg,
-      type: 'info',
-      clear: true
-    });
-  }
-
-  render () {
-    const { codeVisible } = this.state;
-    const { change, databaseName } = this.props;
-    const wrapperClass = 'change-wrapper' + (change.isNew ? ' new-change-row' 
: '');
-
-    return (
-      <div className={wrapperClass}>
-        <div className="change-box" data-id={change.id}>
-          <div className="row-fluid">
-            <div className="span2">seq</div>
-            <div className="span8 change-sequence">{change.seq}</div>
-            <div className="span2 text-right">
-              <Copy
-                uniqueKey={uuid.v4()}
-                text={change.seq.toString()}
-                onClipboardClick={() => this.showCopiedMessage('seq')} />
-            </div>
-          </div>
-
-          <div className="row-fluid">
-            <div className="span2">id</div>
-            <div className="span8">
-              <ChangeID id={change.id} deleted={change.deleted} 
databaseName={databaseName} />
-            </div>
-            <div className="span2 text-right">
-              <Copy
-                uniqueKey={uuid.v4()}
-                text={change.id}
-                onClipboardClick={() => this.showCopiedMessage('id')} />
-            </div>
-          </div>
-
-          <div className="row-fluid">
-            <div className="span2">deleted</div>
-            <div className="span10">{change.deleted ? 'True' : 'False'}</div>
-          </div>
-
-          <div className="row-fluid">
-            <div className="span2">changes</div>
-            <div className="span10">
-              <button type="button" className='btn btn-small btn-secondary' 
onClick={this.toggleJSON.bind(this)}>
-                {codeVisible ? 'Close JSON' : 'View JSON'}
-              </button>
-            </div>
-          </div>
-
-          <ChangesCodeTransition
-            codeVisible={this.state.codeVisible}
-            code={this.getChangeCode()}
-          />
-        </div>
-      </div>
-    );
-  }
-}
-
-ChangeRow.propTypes = {
-  change: PropTypes.object,
-  databaseName: PropTypes.string.isRequired
-};
-
-
-export class ChangesCodeTransition extends React.Component {
-  willEnter () {
-    return {
-      opacity: spring(1, presets.gentle),
-      height: spring(160, presets.gentle)
-    };
-  }
-
-  willLeave () {
-    return {
-      opacity: spring(0, presets.gentle),
-      height: spring(0, presets.gentle)
-    };
-  }
-
-  getStyles (prevStyle) {
-    if (!prevStyle && this.props.codeVisible) {
-      return [{
-        key: '1',
-        style: this.willEnter()
-      }];
-    }
-
-    if (!prevStyle && !this.props.codeVisible) {
-      return [{
-        key: '1',
-        style: this.willLeave()
-      }];
-    }
-    return prevStyle.map(item => {
-      return {
-        key: '1',
-        style: item.style
-      };
-    });
-  }
-
-  getChildren (items) {
-    const code =  items.map(({style}) => {
-      if (this.props.codeVisible === false && style.opacity === 0) {
-        return null;
-      }
-      return (
-        <div key='1' style={{opacity: style.opacity, height: style.height + 
'px'}}>
-          <Components.CodeFormat
-            code={this.props.code}
-          />
-        </div>
-      );
-    });
-
-    return (
-      <span>
-        {code}
-      </span>
-    );
-  }
-
-  render () {
-    return (
-      <TransitionMotion
-        styles={this.getStyles()}
-        willLeave={this.willLeave}
-        willEnter={this.willEnter}
-      >
-        {this.getChildren.bind(this)}
-      </TransitionMotion>
-    );
-  }
-}
-
-
-class ChangeID extends React.Component {
-  render () {
-    const { deleted, id, databaseName } = this.props;
-    if (deleted) {
-      return (
-        <span className="js-doc-id">{id}</span>
-      );
-    }
-    const link = '#' + FauxtonAPI.urls('document', 'app', databaseName, id);
-    return (
-      <a href={link} className="js-doc-link">{id}</a>
-    );
-  }
-}
-
-
-export default {
-  ChangesController,
-  ChangesTabContent,
-  ChangeRow,
-  ChangeID
-};
diff --git a/app/addons/documents/changes/components/AddFilterForm.js 
b/app/addons/documents/changes/components/AddFilterForm.js
new file mode 100644
index 000000000..8e0b795c4
--- /dev/null
+++ b/app/addons/documents/changes/components/AddFilterForm.js
@@ -0,0 +1,93 @@
+// 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 PropTypes from 'prop-types';
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+export default class AddFilterForm extends React.Component {
+  constructor (props) {
+    super(props);
+    this.state = {
+      filter: '',
+      error: false
+    };
+    this.submitForm = this.submitForm.bind(this);
+  }
+
+  submitForm (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
+      let component = this;
+      setTimeout(function () {
+        component.setState({ error: false });
+      }, 1000);
+    } else {
+      this.props.addFilter(this.state.filter);
+      this.setState({ filter: '', error: false });
+    }
+  }
+
+  componentDidMount () {
+    this.focusFilterField();
+  }
+
+  componentDidUpdate () {
+    this.focusFilterField();
+  }
+
+  focusFilterField () {
+    this.addItem.focus();
+  }
+
+  inputClassNames () {
+    let className = 'js-changes-filter-field';
+    if (this.state.error) {
+      className += ' errorHighlight';
+    }
+    return className;
+  }
+
+  render () {
+    return (
+      <form className="form-inline js-filter-form" onSubmit={this.submitForm}>
+        <fieldset>
+          <i className="fonticon-filter" />
+          <input
+            type="text"
+            ref={node => this.addItem = node}
+            className={this.inputClassNames()}
+            placeholder="Sequence or ID"
+            onChange={(e) => this.setState({ filter: e.target.value })}
+            value={this.state.filter} />
+          <button type="submit" className="btn btn-secondary">Filter</button>
+          <div className="help-block"></div>
+        </fieldset>
+      </form>
+    );
+  }
+}
+
+AddFilterForm.propTypes = {
+  addFilter: PropTypes.func.isRequired,
+  hasFilter: PropTypes.func.isRequired,
+  tooltips: PropTypes.string
+};
+AddFilterForm.defaultProps = {
+  tooltip: ''
+};
diff --git a/app/addons/documents/changes/components/ChangeID.js 
b/app/addons/documents/changes/components/ChangeID.js
new file mode 100644
index 000000000..79c8a33f1
--- /dev/null
+++ b/app/addons/documents/changes/components/ChangeID.js
@@ -0,0 +1,29 @@
+// 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 React from 'react';
+import FauxtonAPI from '../../../../core/api';
+
+export default class ChangeID extends React.Component {
+  render () {
+    const { deleted, id, databaseName } = this.props;
+    if (deleted) {
+      return (
+        <span className="js-doc-id">{id}</span>
+      );
+    }
+    const link = '#' + FauxtonAPI.urls('document', 'app', databaseName, id);
+    return (
+      <a href={link} className="js-doc-link">{id}</a>
+    );
+  }
+}
diff --git a/app/addons/documents/changes/components/ChangeRow.js 
b/app/addons/documents/changes/components/ChangeRow.js
new file mode 100644
index 000000000..e6d30fe2a
--- /dev/null
+++ b/app/addons/documents/changes/components/ChangeRow.js
@@ -0,0 +1,120 @@
+// 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 PropTypes from 'prop-types';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import uuid from 'uuid';
+import FauxtonAPI from '../../../../core/api';
+import Components from '../../../fauxton/components';
+import ReactComponents from '../../../components/react-components';
+import ChangesCodeTransition from './ChangesCodeTransition';
+import ChangeID from './ChangeID';
+
+const {Copy} = ReactComponents;
+
+export default class ChangeRow extends React.Component {
+  constructor (props) {
+    super(props);
+    this.state = {
+      codeVisible: false
+    };
+  }
+
+  toggleJSON (e) {
+    e.preventDefault();
+    this.setState({ codeVisible: !this.state.codeVisible });
+  }
+
+  getChangesCode () {
+    return (this.state.codeVisible) ? <Components.CodeFormat 
key="changesCodeSection" code={this.getChangeCode()} /> : null;
+  }
+
+  getChangeCode () {
+    return {
+      changes: this.props.change.changes,
+      doc: this.props.change.doc
+    };
+  }
+
+  showCopiedMessage (target) {
+    let msg = 'The document ID has been copied to your clipboard.';
+    if (target === 'seq') {
+      msg = 'The document seq number has been copied to your clipboard.';
+    }
+    FauxtonAPI.addNotification({
+      msg: msg,
+      type: 'info',
+      clear: true
+    });
+  }
+
+  render () {
+    const { codeVisible } = this.state;
+    const { change, databaseName } = this.props;
+    const wrapperClass = 'change-wrapper' + (change.isNew ? ' new-change-row' 
: '');
+
+    return (
+      <div className={wrapperClass}>
+        <div className="change-box" data-id={change.id}>
+          <div className="row-fluid">
+            <div className="span2">seq</div>
+            <div className="span8 change-sequence">{change.seq}</div>
+            <div className="span2 text-right">
+              <Copy
+                uniqueKey={uuid.v4()}
+                text={change.seq.toString()}
+                onClipboardClick={() => this.showCopiedMessage('seq')} />
+            </div>
+          </div>
+
+          <div className="row-fluid">
+            <div className="span2">id</div>
+            <div className="span8">
+              <ChangeID id={change.id} deleted={change.deleted} 
databaseName={databaseName} />
+            </div>
+            <div className="span2 text-right">
+              <Copy
+                uniqueKey={uuid.v4()}
+                text={change.id}
+                onClipboardClick={() => this.showCopiedMessage('id')} />
+            </div>
+          </div>
+
+          <div className="row-fluid">
+            <div className="span2">deleted</div>
+            <div className="span10">{change.deleted ? 'True' : 'False'}</div>
+          </div>
+
+          <div className="row-fluid">
+            <div className="span2">changes</div>
+            <div className="span10">
+              <button type="button" className='btn btn-small btn-secondary' 
onClick={this.toggleJSON.bind(this)}>
+                {codeVisible ? 'Close JSON' : 'View JSON'}
+              </button>
+            </div>
+          </div>
+
+          <ChangesCodeTransition
+            codeVisible={this.state.codeVisible}
+            code={this.getChangeCode()}
+          />
+        </div>
+      </div>
+    );
+  }
+}
+
+ChangeRow.propTypes = {
+  change: PropTypes.object,
+  databaseName: PropTypes.string.isRequired
+};
diff --git a/app/addons/documents/changes/components/ChangesCodeTransition.js 
b/app/addons/documents/changes/components/ChangesCodeTransition.js
new file mode 100644
index 000000000..09ba58d59
--- /dev/null
+++ b/app/addons/documents/changes/components/ChangesCodeTransition.js
@@ -0,0 +1,94 @@
+// 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 PropTypes from 'prop-types';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import Components from '../../../fauxton/components';
+import {TransitionMotion, spring, presets} from 'react-motion';
+import '../../../../../assets/js/plugins/prettify';
+
+export default class ChangesCodeTransition extends React.Component {
+  willEnter () {
+    return {
+      opacity: spring(1, presets.gentle),
+      height: spring(160, presets.gentle)
+    };
+  }
+
+  willLeave () {
+    return {
+      opacity: spring(0, presets.gentle),
+      height: spring(0, presets.gentle)
+    };
+  }
+
+  getStyles (prevStyle) {
+    if (!prevStyle && this.props.codeVisible) {
+      return [{
+        key: '1',
+        style: this.willEnter()
+      }];
+    }
+
+    if (!prevStyle && !this.props.codeVisible) {
+      return [{
+        key: '1',
+        style: this.willLeave()
+      }];
+    }
+    return prevStyle.map(item => {
+      return {
+        key: '1',
+        style: item.style
+      };
+    });
+  }
+
+  getChildren (items) {
+    const code =  items.map(({style}) => {
+      if (this.props.codeVisible === false && style.opacity === 0) {
+        return null;
+      }
+      return (
+        <div key='1' style={{opacity: style.opacity, height: style.height + 
'px'}}>
+          <Components.CodeFormat
+            code={this.props.code}
+          />
+        </div>
+      );
+    });
+
+    return (
+      <span>
+        {code}
+      </span>
+    );
+  }
+
+  render () {
+    return (
+      <TransitionMotion
+        styles={this.getStyles()}
+        willLeave={this.willLeave}
+        willEnter={this.willEnter}
+      >
+        {this.getChildren.bind(this)}
+      </TransitionMotion>
+    );
+  }
+}
+
+ChangesCodeTransition.propTypes = {
+  code: PropTypes.object.isRequired,
+  codeVisible: PropTypes.bool.isRequired
+};
diff --git a/app/addons/documents/changes/components/ChangesContainer.js 
b/app/addons/documents/changes/components/ChangesContainer.js
new file mode 100644
index 000000000..3fa7ce36c
--- /dev/null
+++ b/app/addons/documents/changes/components/ChangesContainer.js
@@ -0,0 +1,27 @@
+import { connect } from 'react-redux';
+import ChangesScreen from './ChangesScreen';
+import Actions from '../actions';
+
+const mapStateToProps = ({ changes }, ownProps) => {
+  return {
+    changes: changes.filteredChanges,
+    loaded: changes.isLoaded,
+    databaseName: ownProps.databaseName,
+    isShowingSubset: changes.showingSubset
+  };
+};
+
+const mapDispatchToProps = (dispatch) => {
+  return {
+    loadChanges: (databaseName) => {
+      dispatch(Actions.loadChanges(databaseName));
+    }
+  };
+};
+
+const ChangesContainer = connect(
+  mapStateToProps,
+  mapDispatchToProps
+)(ChangesScreen);
+
+export default ChangesContainer;
diff --git a/app/addons/documents/changes/components/ChangesScreen.js 
b/app/addons/documents/changes/components/ChangesScreen.js
new file mode 100644
index 000000000..8a78d3793
--- /dev/null
+++ b/app/addons/documents/changes/components/ChangesScreen.js
@@ -0,0 +1,67 @@
+// 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 PropTypes from 'prop-types';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import ChangeRow from './ChangeRow';
+
+export default class ChangesScreen extends React.Component {
+  constructor (props) {
+    super(props);
+    this.props.loadChanges(this.props.databaseName);
+  }
+
+  showingSubsetMsg () {
+    const { isShowingSubset, changes } = this.props;
+    let msg = '';
+    if (isShowingSubset) {
+      let numChanges = changes.length;
+      msg = <p className="changes-result-limit">Limiting results to latest 
<b>{numChanges}</b> changes.</p>;
+    }
+    return msg;
+  }
+
+  getRows () {
+    const { changes, loaded, databaseName } = this.props;
+    if (!changes.length && loaded) {
+      return (
+        <p className="no-doc-changes">
+          There are no document changes to display.
+        </p>
+      );
+    }
+
+    return changes.map((change, i) => {
+      return <ChangeRow change={change} key={i} databaseName={databaseName} />;
+    });
+  }
+
+  render () {
+    return (
+      <div>
+        <div className="js-changes-view">
+          {this.showingSubsetMsg()}
+          {this.getRows()}
+        </div>
+      </div>
+    );
+  }
+}
+
+ChangesScreen.propTypes = {
+  changes: PropTypes.array.isRequired,
+  loaded: PropTypes.bool.isRequired,
+  databaseName: PropTypes.string.isRequired,
+  isShowingSubset: PropTypes.bool.isRequired,
+  loadChanges: PropTypes.func.isRequired
+};
diff --git a/app/addons/documents/changes/components/ChangesTabContent.js 
b/app/addons/documents/changes/components/ChangesTabContent.js
new file mode 100644
index 000000000..205fe443e
--- /dev/null
+++ b/app/addons/documents/changes/components/ChangesTabContent.js
@@ -0,0 +1,52 @@
+// 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 PropTypes from 'prop-types';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import ReactComponents from '../../../components/react-components';
+import AddFilterForm from './AddFilterForm';
+
+export default class ChangesTabContent extends React.Component {
+  constructor (props) {
+    super(props);
+    this.addFilter = this.addFilter.bind(this);
+    this.hasFilter = this.hasFilter.bind(this);
+  }
+
+  addFilter (newFilter) {
+    if (_.isEmpty(newFilter)) {
+      return;
+    }
+    this.props.addFilter(newFilter);
+  }
+
+  hasFilter (filter) {
+    return this.props.filters.includes(filter);
+  }
+
+  render () {
+    return (
+      <div className="changes-header">
+        <AddFilterForm filter={(label) => this.props.removeFilter(label)} 
addFilter={this.addFilter}
+          hasFilter={this.hasFilter} />
+        <ReactComponents.BadgeList elements={this.props.filters} 
removeBadge={(label) => this.props.removeFilter(label)} />
+      </div>
+    );
+  }
+}
+
+ChangesTabContent.propTypes = {
+  filters: PropTypes.array.isRequired,
+  addFilter: PropTypes.func.isRequired,
+  removeFilter: PropTypes.func.isRequired
+};
diff --git 
a/app/addons/documents/changes/components/ChangesTabContentContainer.js 
b/app/addons/documents/changes/components/ChangesTabContentContainer.js
new file mode 100644
index 000000000..ed222b451
--- /dev/null
+++ b/app/addons/documents/changes/components/ChangesTabContentContainer.js
@@ -0,0 +1,28 @@
+import { connect } from 'react-redux';
+import ChangesTabContent from './ChangesTabContent';
+import Actions from '../actions';
+
+const mapStateToProps = ({ changes }) => {
+  return {
+    filters: changes.filters
+  };
+};
+
+const mapDispatchToProps = (dispatch) => {
+  return {
+    addFilter: (filter) => {
+      dispatch(Actions.addFilter(filter));
+    },
+
+    removeFilter: (filter) => {
+      dispatch(Actions.removeFilter(filter));
+    }
+  };
+};
+
+const ChangesTabContentContainer = connect(
+  mapStateToProps,
+  mapDispatchToProps
+)(ChangesTabContent);
+
+export default ChangesTabContentContainer;
diff --git a/app/addons/documents/changes/reducers.js 
b/app/addons/documents/changes/reducers.js
new file mode 100644
index 000000000..8bcf9f634
--- /dev/null
+++ b/app/addons/documents/changes/reducers.js
@@ -0,0 +1,126 @@
+
+// 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 ActionTypes from './actiontypes';
+import Helpers from '../helpers';
+
+const initialState = {
+  isLoaded: false,
+  filters: [],
+  changes: [],
+  filteredChanges: [],
+  maxChangesListed: 100,
+  showingSubset: false,
+  lastSequenceNum: null
+};
+
+function updateChanges(state, seqNum, changes) {
+  const newState = {
+    ...state,
+    // make a note of the most recent sequence number. This is used for a 
point of reference for polling for new changes
+    lastSequenceNum: seqNum,
+    isLoaded: true
+  };
+
+  // mark any additional changes that come after first page load as "new" so 
we can add a nice highlight effect
+  // when the new row is rendered
+  const firstBatch = newState.changes.length === 0;
+  newState.changes.forEach((change) => {
+    change.isNew = false;
+  });
+
+  const newChanges = changes.map((change) => {
+    const seq = Helpers.getSeqNum(change.seq);
+    return {
+      id: change.id,
+      seq: seq,
+      deleted: _.has(change, 'deleted') ? change.deleted : false,
+      changes: change.changes,
+      doc: change.doc, // only populated with ?include_docs=true
+      isNew: !firstBatch
+    };
+  });
+
+  // add the new changes to the start of the list
+  newState.changes = newChanges.concat(newState.changes);
+  updateFilteredChanges(newState);
+  return newState;
+}
+
+function addFilter(state, filter) {
+  const newFilters = state.filters.slice();
+  newFilters.push(filter);
+
+  const newState = {
+    ...state,
+    filters: newFilters
+  };
+  updateFilteredChanges(newState);
+  return newState;
+}
+
+function removeFilter(state, filter) {
+  const newFilters = state.filters.slice();
+  const idx = newFilters.indexOf(filter);
+  if (idx >= 0) {
+    newFilters.splice(idx, 1);
+  }
+
+  const newState = {
+    ...state,
+    filters: newFilters
+  };
+  updateFilteredChanges(newState);
+  return newState;
+}
+
+function updateFilteredChanges(state) {
+  state.showingSubset = false;
+  let numMatches = 0;
+  state.filteredChanges = state.changes.filter((change) => {
+    if (numMatches >= state.maxChangesListed) {
+      state.showingSubset = true;
+      return false;
+    }
+    let changeStr = JSON.stringify(change);
+    let match = state.filters.every((filter) => {
+      return new RegExp(filter, 'i').test(changeStr);
+    });
+
+    if (match) {
+      numMatches++;
+    }
+    return match;
+  });
+}
+
+export default function changes (state = initialState, action) {
+  switch (action.type) {
+
+    case ActionTypes.UPDATE_CHANGES:
+      // only bother updating the list of changes if the seq num has changed
+      if (state.lastSequenceNum !== action.seqNum) {
+        return updateChanges(state, action.seqNum, action.changes);
+      }
+      return state;
+
+    case ActionTypes.ADD_CHANGES_FILTER_ITEM:
+      return addFilter(state, action.filter);
+
+    case ActionTypes.REMOVE_CHANGES_FILTER_ITEM:
+      return removeFilter(state, action.filter);
+
+    default:
+      return state;
+  }
+}
diff --git a/app/addons/documents/changes/stores.js 
b/app/addons/documents/changes/stores.js
deleted file mode 100644
index 7c204ac9e..000000000
--- a/app/addons/documents/changes/stores.js
+++ /dev/null
@@ -1,157 +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.
-
-import FauxtonAPI from "../../../core/api";
-import ActionTypes from "./actiontypes";
-import Helpers from "../helpers";
-
-
-var ChangesStore = FauxtonAPI.Store.extend({
-  initialize: function () {
-    this.reset();
-  },
-
-  reset: function () {
-    this._isLoaded = false;
-    this._filters = [];
-    this._changes = [];
-    this._databaseName = '';
-    this._maxChangesListed = 100;
-    this._showingSubset = false;
-    this._lastSequenceNum = null;
-  },
-
-  initChanges: function (options) {
-    this.reset();
-    this._databaseName = options.databaseName;
-  },
-
-  isLoaded: function () {
-    return this._isLoaded;
-  },
-
-  updateChanges: function (seqNum, changes) {
-
-    // make a note of the most recent sequence number. This is used for a 
point of reference for polling for new changes
-    this._lastSequenceNum = seqNum;
-
-    // mark any additional changes that come after first page load as "new" so 
we can add a nice highlight effect
-    // when the new row is rendered
-    var firstBatch = this._changes.length === 0;
-    _.each(this._changes, (change) => {
-      change.isNew = false;
-    });
-
-    var newChanges = _.map(changes, (change) => {
-      var seq = Helpers.getSeqNum(change.seq);
-      return {
-        id: change.id,
-        seq: seq,
-        deleted: _.has(change, 'deleted') ? change.deleted : false,
-        changes: change.changes,
-        doc: change.doc, // only populated with ?include_docs=true
-        isNew: !firstBatch
-      };
-    });
-
-    // add the new changes to the start of the list
-    this._changes = newChanges.concat(this._changes);
-    this._isLoaded = true;
-  },
-
-  getChanges: function () {
-    this._showingSubset = false;
-    var numMatches = 0;
-
-    return _.filter(this._changes, (change) => {
-      if (numMatches >= this._maxChangesListed) {
-        this._showingSubset = true;
-        return false;
-      }
-      var changeStr = JSON.stringify(change);
-      var match = _.every(this._filters, (filter) => {
-        return new RegExp(filter, 'i').test(changeStr);
-      });
-
-      if (match) {
-        numMatches++;
-      }
-      return match;
-    });
-  },
-
-  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 _.includes(this._filters, filter);
-  },
-
-  getDatabaseName: function () {
-    return this._databaseName;
-  },
-
-  isShowingSubset: function () {
-    return this._showingSubset;
-  },
-
-  // added to speed up the tests
-  setMaxChanges: function (num) {
-    this._maxChangesListed = num;
-  },
-
-  getLastSeqNum: function () {
-    return this._lastSequenceNum;
-  },
-
-  dispatch: function (action) {
-    switch (action.type) {
-      case ActionTypes.INIT_CHANGES:
-        this.initChanges(action.options);
-        break;
-
-      case ActionTypes.UPDATE_CHANGES:
-        this.updateChanges(action.seqNum, action.changes);
-        break;
-
-      case ActionTypes.ADD_CHANGES_FILTER_ITEM:
-        this.addFilter(action.filter);
-        break;
-
-      case ActionTypes.REMOVE_CHANGES_FILTER_ITEM:
-        this.removeFilter(action.filter);
-        break;
-
-      default:
-        return;
-    }
-
-    this.triggerChange();
-  }
-});
-
-
-var Stores = {};
-Stores.changesStore = new ChangesStore();
-Stores.changesStore.dispatchToken = 
FauxtonAPI.dispatcher.register(Stores.changesStore.dispatch.bind(Stores.changesStore));
-
-export default Stores;
diff --git a/app/addons/documents/layouts.js b/app/addons/documents/layouts.js
index bb11d66b0..3b921aa98 100644
--- a/app/addons/documents/layouts.js
+++ b/app/addons/documents/layouts.js
@@ -16,7 +16,8 @@ import React from 'react';
 import { NotificationCenterButton } from 
'../fauxton/notifications/notifications';
 import SidebarControllerContainer from "./sidebar/SidebarControllerContainer";
 import HeaderDocsLeft from './components/header-docs-left';
-import Changes from './changes/components';
+import ChangesContainer from './changes/components/ChangesContainer';
+import ChangesTabContentContainer from 
'./changes/components/ChangesTabContentContainer';
 import IndexEditorComponents from "./index-editor/components";
 import DesignDocInfoContainer from 
'./designdocinfo/components/DesignDocInfoContainer';
 import RightAllDocsHeader from './components/header-docs-right';
@@ -105,7 +106,7 @@ TabsSidebarHeader.propTypes = {
   showPartitionKeySelector: PropTypes.bool.isRequired,
   partitionKey: PropTypes.string,
   onPartitionKeySelected: PropTypes.func,
-  onGlobalModeSelected: PropTypes.bool,
+  onGlobalModeSelected: PropTypes.func,
   globalMode: PropTypes.bool
 };
 
@@ -228,8 +229,8 @@ export const ChangesSidebarLayout = ({ docURL, database, 
endpoint, dbName, dropD
         hideQueryOptions={true}
       />
       <TabsSidebarContent
-        upperContent={<Changes.ChangesTabContent />}
-        lowerContent={<Changes.ChangesController />}
+        upperContent={<ChangesTabContentContainer />}
+        lowerContent={<ChangesContainer databaseName={dbName}/>}
         hideFooter={true}
         selectedNavItem={selectedNavItem}
       />
diff --git a/app/addons/documents/routes-documents.js 
b/app/addons/documents/routes-documents.js
index e61fab789..9def382f2 100644
--- a/app/addons/documents/routes-documents.js
+++ b/app/addons/documents/routes-documents.js
@@ -13,7 +13,6 @@
 import React from 'react';
 import FauxtonAPI from '../../core/api';
 import BaseRoute from './shared-routes';
-import ChangesActions from './changes/actions';
 import Databases from '../databases/base';
 import Resources from './resources';
 import {SidebarItemSelection} from './sidebar/helpers';
@@ -131,9 +130,6 @@ var DocumentsRouteObject = BaseRoute.extend({
   },
 
   changes: function () {
-    ChangesActions.initChanges({
-      databaseName: this.database.id
-    });
     const selectedNavItem = new SidebarItemSelection('changes');
 
     return <ChangesSidebarLayout


 

----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on GitHub and use the
URL above to go to the specific comment.
 
For queries about this service, please contact Infrastructure at:
[email protected]


With regards,
Apache Git Services

Reply via email to