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
+
+
+
+ Name |
+ |
+
+
+
+ {this.state.archivedFeatures.map(this.renderArchivedItem)}
+
+
+
+ );
+ },
+
+ 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); });
}