From 505d6373cdd8da2084183ec70619d3ea0dc4fc14 Mon Sep 17 00:00:00 2001 From: ivaosthu Date: Wed, 17 Dec 2014 21:56:27 +0100 Subject: [PATCH] Added archived toggles feature #43 --- app.js | 2 + lib/eventType.js | 1 + lib/featureApi.js | 6 +- lib/featureArchiveApi.js | 37 +++++++++++ lib/featureDb.js | 24 +++++++ .../feature/ArchiveFeatureComponent-test.js | 51 +++++++++++++++ public/js/app.jsx | 8 ++- .../feature/ArchiveFeatureComponent.jsx | 64 +++++++++++++++++++ .../feature/FeatureTogglesComponent.jsx | 19 +++--- public/js/stores/FeatureStore.js | 23 +++++-- test/featureApiSpec.js | 12 ++++ test/featureArchiveApiSpec.js | 38 +++++++++++ test/specHelper.js | 27 ++++++++ 13 files changed, 296 insertions(+), 16 deletions(-) create mode 100644 lib/featureArchiveApi.js create mode 100644 public/js/__tests__/components/feature/ArchiveFeatureComponent-test.js create mode 100644 public/js/components/feature/ArchiveFeatureComponent.jsx create mode 100644 test/featureArchiveApiSpec.js diff --git a/app.js b/app.js index f26691d066..e520d5b383 100644 --- a/app.js +++ b/app.js @@ -5,6 +5,7 @@ var express = require('express'), routes = require('./lib/routes'), eventApi = require('./lib/eventApi'), featureApi = require('./lib/featureApi'), + featureArchiveApi = require('./lib/featureArchiveApi'), strategyApi = require('./lib/strategyApi'), validator = require('express-validator'), app = express(), @@ -39,6 +40,7 @@ app.use(bodyParser.json({strict: false})); eventApi(router); featureApi(router); +featureArchiveApi(router); strategyApi(router); routes(router); app.use(baseUriPath, router); diff --git a/lib/eventType.js b/lib/eventType.js index 98078a4188..60d06788f4 100644 --- a/lib/eventType.js +++ b/lib/eventType.js @@ -2,6 +2,7 @@ module.exports = { featureCreated : 'feature-created', featureUpdated : 'feature-updated', featureArchive : 'feature-archive', + featureRevive : 'feature-revive', strategyCreated: 'strategy-created', strategyDeleted: 'strategy-deleted' }; \ No newline at end of file diff --git a/lib/featureApi.js b/lib/featureApi.js index 65718febd4..61ff4cfc1d 100644 --- a/lib/featureApi.js +++ b/lib/featureApi.js @@ -6,7 +6,7 @@ var featureDb = require('./featureDb'); var NameExistsError = require('./error/NameExistsError'); var NotFoundError = require('./error/NotFoundError'); var ValidationError = require('./error/ValidationError'); -var validateRequest = require('./error/validateRequest'); +var validateRequest = require('./error/validateRequest'); module.exports = function (app) { @@ -41,7 +41,9 @@ module.exports = function (app) { res.status(201).end(); }) .catch(NameExistsError, function() { - res.status(403).json([{msg: "A feature named '" + req.body.name + "' already exists."}]).end(); + res.status(403).json([{ + msg: "A feature named '" + req.body.name + "' already exists. It could be archived." + }]).end(); }) .catch(ValidationError, function() { res.status(400).json(req.validationErrors()); diff --git a/lib/featureArchiveApi.js b/lib/featureArchiveApi.js new file mode 100644 index 0000000000..19c12bd09d --- /dev/null +++ b/lib/featureArchiveApi.js @@ -0,0 +1,37 @@ +var logger = require('./logger'); +var eventStore = require('./eventStore'); +var eventType = require('./eventType'); +var featureDb = require('./featureDb'); +var ValidationError = require('./error/ValidationError'); +var validateRequest = require('./error/validateRequest'); + +module.exports = function (app) { + + app.get('/archive/features', function (req, res) { + featureDb.getArchivedFeatures().then(function (archivedFeatures) { + res.json({'features': archivedFeatures}); + }); + }); + + app.post('/archive/revive', function (req, res) { + req.checkBody('name', 'Name is required').notEmpty(); + + validateRequest(req) + .then(function() { + return eventStore.create({ + type: eventType.featureRevive, + createdBy: req.connection.remoteAddress, + data: req.body + }); + }).then(function() { + res.status(200).end(); + }).catch(ValidationError, function() { + res.status(400).json(req.validationErrors()); + }) + .catch(function(err) { + logger.error("Could not revive feature toggle", err); + res.status(500).end(); + }); + }); +}; + diff --git a/lib/featureDb.js b/lib/featureDb.js index 5acb14cf8b..9baf329c86 100644 --- a/lib/featureDb.js +++ b/lib/featureDb.js @@ -17,6 +17,10 @@ eventStore.on(eventType.featureArchive, function (event) { return archiveFeature(event.data); }); +eventStore.on(eventType.featureRevive, function (event) { + return reviveFeature(event.data); +}); + function getFeatures() { return knex .select(FEATURE_COLUMNS) @@ -34,6 +38,15 @@ function getFeature(name) { .then(rowToFeature); } +function getArchivedFeatures() { + return knex + .select(['name', 'description']) + .from('features') + .where({archived: 1}) + .orderBy('name', 'asc'); +} + + function rowToFeature(row) { if (!row) { throw new NotFoundError('No feature toggle found'); @@ -53,6 +66,7 @@ function eventDataToRow(data) { name: data.name, description: data.description, enabled: data.enabled ? 1 : 0, + archived: data.archived ? 1 :0, strategy_name: data.strategy, // jshint ignore: line parameters: data.parameters }; @@ -84,10 +98,20 @@ function archiveFeature(data) { }); } +function reviveFeature(data) { + return knex('features') + .where({name: data.name}) + .update({archived: 0, enabled: 0}) + .catch(function (err) { + logger.error('Could not archive feature, error was: ', err); + }); +} + module.exports = { getFeatures: getFeatures, getFeature: getFeature, + getArchivedFeatures: getArchivedFeatures, _createFeature: createFeature, // visible for testing _updateFeature: updateFeature // visible for testing }; diff --git a/public/js/__tests__/components/feature/ArchiveFeatureComponent-test.js b/public/js/__tests__/components/feature/ArchiveFeatureComponent-test.js new file mode 100644 index 0000000000..9391e19fae --- /dev/null +++ b/public/js/__tests__/components/feature/ArchiveFeatureComponent-test.js @@ -0,0 +1,51 @@ +jest.dontMock("../../../components/feature/ArchiveFeatureComponent"); + +var React = require("react/addons"); +var TestUtils = React.addons.TestUtils; +var FeatureArchive = require("../../../components/feature/ArchiveFeatureComponent"); +var FeatureStore = require("../../../stores/FeatureStore"); + +describe("FeatureForm", function () { + var Component; + beforeEach(function() { + FeatureStore.getArchivedFeatures.mockImplementation(function() { + return { + then: function (callback) { + return callback({ + features: [ + { name: "featureX" }, + { name: "featureY" } + ] + }); + } + }; + }); + FeatureStore.reviveFeature.mockImplementation(function() { + return { + then: function (callback) {return callback();} + }; + }); + + Component = TestUtils .renderIntoDocument(); + }); + + afterEach(function() { + React.unmountComponentAtNode(document.body); + }); + + it("should render two archived features", function() { + var rows = Component.getDOMNode().querySelectorAll("tbody tr"); + expect(rows.length).toEqual(2); + }); + + it("should revive archived feature toggle", function() { + var button = Component.getDOMNode().querySelector("tbody button"); + TestUtils.Simulate.click(button); + var rows = Component.getDOMNode().querySelectorAll("tbody tr"); + + expect(rows.length).toEqual(1); + expect(FeatureStore.reviveFeature).toBeCalledWith({ + name: "featureX" + }); + }); +}); \ No newline at end of file diff --git a/public/js/app.jsx b/public/js/app.jsx index 72b21835a6..7905d84703 100644 --- a/public/js/app.jsx +++ b/public/js/app.jsx @@ -1,10 +1,10 @@ var React = require('react'); var TabView = require('./components/TabView'); var Menu = require('./components/Menu'); -var LogEntriesComponent = React.createFactory(require('./components/log/LogEntriesComponent')); +var LogEntriesComponent = React.createFactory(require('./components/log/LogEntriesComponent')); var FeatureTogglesComponent = React.createFactory(require('./components/feature/FeatureTogglesComponent')); var StrategiesComponent = React.createFactory(require('./components/strategy/StrategiesComponent')); - +var ArchiveFeatureComponent = React.createFactory(require('./components/feature/ArchiveFeatureComponent')); var tabPanes = [ { @@ -18,6 +18,10 @@ var tabPanes = [ { name: "Log", content: new LogEntriesComponent({}) + }, + { + name: "Archive", + content: new ArchiveFeatureComponent({}) } ]; diff --git a/public/js/components/feature/ArchiveFeatureComponent.jsx b/public/js/components/feature/ArchiveFeatureComponent.jsx new file mode 100644 index 0000000000..7bd403eb75 --- /dev/null +++ b/public/js/components/feature/ArchiveFeatureComponent.jsx @@ -0,0 +1,64 @@ +var React = require("react"); +var FeatureStore = require('../../stores/FeatureStore'); + +var ArchiveFeatureComponent = React.createClass({ + getInitialState: function() { + return { + archivedFeatures: [] + }; + }, + + removeToggleFromState: function(item) { + var updatedArchive = this.state.archivedFeatures.filter(function(f) { + return f.name !== item.name; + }); + this.setState({archivedFeatures: updatedArchive}); + }, + + onRevive: function( item) { + FeatureStore.reviveFeature(item).then(this.removeToggleFromState.bind(this, item)); + }, + + componentDidMount: function () { + FeatureStore.getArchivedFeatures().then(function(data) { + this.setState({archivedFeatures: data.features}); + }.bind(this)) + }, + + render: function () { + return ( +
+

Archived feature toggles

+ + + + + + + + + {this.state.archivedFeatures.map(this.renderArchivedItem)} + +
Name
+
+ ); + }, + + renderArchivedItem: function(f) { + return ( + + + {f.name}
+ {f.description} + + + + + + ); + } +}); + +module.exports = ArchiveFeatureComponent; \ No newline at end of file diff --git a/public/js/components/feature/FeatureTogglesComponent.jsx b/public/js/components/feature/FeatureTogglesComponent.jsx index d3f732d26c..be50960d3d 100644 --- a/public/js/components/feature/FeatureTogglesComponent.jsx +++ b/public/js/components/feature/FeatureTogglesComponent.jsx @@ -11,8 +11,7 @@ var FeatureTogglesComponent = React.createClass({ features: [], errors: [], createView: false, - featurePoller: new Timer(this.loadFeaturesFromServer, this.props.pollInterval), - featureStore: new FeatureStore() + featurePoller: new Timer(this.loadFeaturesFromServer, this.props.pollInterval) }; }, @@ -26,7 +25,7 @@ var FeatureTogglesComponent = React.createClass({ }, loadFeaturesFromServer: function () { - this.state.featureStore.getFeatures().then(this.setFeatures).catch(this.handleError); + FeatureStore.getFeatures().then(this.setFeatures).catch(this.handleError); }, setFeatures: function (data) { @@ -49,18 +48,22 @@ var FeatureTogglesComponent = React.createClass({ updateFeature: function (feature) { this.stopFeaturePoller(); - this.state.featureStore + FeatureStore .updateFeature(feature) .then(this.startFeaturePoller) .catch(this.handleError); }, archiveFeature: function (feature) { - this.stopFeaturePoller(); + var updatedFeatures = this.state.features.filter(function(item) { + return item.name !== feature.name; + }); - this.state.featureStore + FeatureStore .archiveFeature(feature) - .then(this.startFeaturePoller) + .then(function() { + this.setState({features: updatedFeatures}) + }.bind(this)) .catch(this.handleError); }, @@ -77,7 +80,7 @@ var FeatureTogglesComponent = React.createClass({ createFeature: function (feature) { this.stopFeaturePoller(); - this.state.featureStore + FeatureStore .createFeature(feature) .then(this.cancelNewFeature) .then(this.startFeaturePoller) diff --git a/public/js/stores/FeatureStore.js b/public/js/stores/FeatureStore.js index 1cb85f6504..333ab6bdb6 100644 --- a/public/js/stores/FeatureStore.js +++ b/public/js/stores/FeatureStore.js @@ -1,12 +1,9 @@ var reqwest = require('reqwest'); -var FeatureStore = function () { -}; - var TYPE = 'json'; var CONTENT_TYPE = 'application/json'; -FeatureStore.prototype = { +FeatureStore = { updateFeature: function (feature) { return reqwest({ url: 'features/' + feature.name, @@ -41,6 +38,24 @@ FeatureStore.prototype = { method: 'get', type: TYPE }); + }, + + getArchivedFeatures: function () { + return reqwest({ + url: 'archive/features', + method: 'get', + type: TYPE + }); + }, + + reviveFeature: function (feature) { + return reqwest({ + url: 'archive/revive', + method: 'post', + type: TYPE, + contentType: CONTENT_TYPE, + data: JSON.stringify(feature) + }); } }; diff --git a/test/featureApiSpec.js b/test/featureApiSpec.js index 0dcb887eba..d93acee57c 100644 --- a/test/featureApiSpec.js +++ b/test/featureApiSpec.js @@ -60,4 +60,16 @@ describe('The features api', function () { .expect(200, done); }); + it('archives a feature by name', function (done) { + request + .delete('/features/featureX') + .expect(200, done); + }); + + it('can not archive unknown feature', function (done) { + request + .delete('/features/featureUnknown') + .expect(404, done); + }); + }); \ No newline at end of file diff --git a/test/featureArchiveApiSpec.js b/test/featureArchiveApiSpec.js new file mode 100644 index 0000000000..cb4e637d86 --- /dev/null +++ b/test/featureArchiveApiSpec.js @@ -0,0 +1,38 @@ +var assert = require('assert'); +var specHelper = require('./specHelper'); +var request = specHelper.request; +var stringify = function (o) { return JSON.stringify(o, null, ' '); }; + +describe('The archive features api', function () { + beforeEach(function (done) { + specHelper.db.resetAndSetup() + .then(done.bind(null, null)) + .catch(done); + }); + + it('returns three archived toggles', function (done) { + request + .get('/archive/features') + .expect('Content-Type', /json/) + .expect(200) + .end(function (err, res) { + assert(res.body.features.length === 3, "expected 3 features, got " + stringify(res.body)); + done(); + }); + }); + + it('revives a feature by name', function (done) { + request + .post('/archive/revive') + .send({name: 'featureArchivedX'}) + .set('Content-Type', 'application/json') + .expect(200, done); + }); + + it('must set name when reviving toggle', function (done) { + request + .post('/archive/revive') + .expect(400, done); + }); + +}); \ No newline at end of file diff --git a/test/specHelper.js b/test/specHelper.js index 8e937bac0b..f91a36a1f1 100644 --- a/test/specHelper.js +++ b/test/specHelper.js @@ -51,6 +51,33 @@ function createFeatures() { "parameters": { "foo": "rab" } + }, + { + "name": "featureArchivedX", + "description": "the #1 feature", + "enabled": true, + "archived": true, + "strategy": "default" + }, + { + "name": "featureArchivedY", + "description": "soon to be the #1 feature", + "enabled": false, + "archived": true, + "strategy": "baz", + "parameters": { + "foo": "bar" + } + }, + { + "name": "featureArchivedZ", + "description": "terrible feature", + "enabled": true, + "archived": true, + "strategy": "baz", + "parameters": { + "foo": "rab" + } } ], function (feature) { return featureDb._createFeature(feature); }); }