1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

Added archived toggles feature #43

This commit is contained in:
ivaosthu 2014-12-17 21:56:27 +01:00
parent 48d2e0c304
commit 505d6373cd
13 changed files with 296 additions and 16 deletions

2
app.js
View File

@ -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);

View File

@ -2,6 +2,7 @@ module.exports = {
featureCreated : 'feature-created',
featureUpdated : 'feature-updated',
featureArchive : 'feature-archive',
featureRevive : 'feature-revive',
strategyCreated: 'strategy-created',
strategyDeleted: 'strategy-deleted'
};

View File

@ -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());

37
lib/featureArchiveApi.js Normal file
View File

@ -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();
});
});
};

View File

@ -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
};

View File

@ -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(<FeatureArchive />);
});
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"
});
});
});

View File

@ -4,7 +4,7 @@ var Menu = require('./components/Menu');
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({})
}
];

View File

@ -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 (
<div>
<h1>Archived feature toggles</h1>
<table className="outerborder man">
<thead>
<tr>
<th>Name</th>
<th></th>
</tr>
</thead>
<tbody>
{this.state.archivedFeatures.map(this.renderArchivedItem)}
</tbody>
</table>
</div>
);
},
renderArchivedItem: function(f) {
return (
<tr key={f.name}>
<td>
{f.name}<br />
<span className="opaque smalltext word-break">{f.description}</span>
</td>
<td className="rightify" width="150">
<button onClick={this.onRevive.bind(this, f)} title="Revive feature toggle">
<span className="icon-svar"></span>
</button>
</td>
</tr>);
}
});
module.exports = ArchiveFeatureComponent;

View File

@ -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)

View File

@ -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)
});
}
};

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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); });
}