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:
parent
48d2e0c304
commit
505d6373cd
2
app.js
2
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);
|
||||
|
@ -2,6 +2,7 @@ module.exports = {
|
||||
featureCreated : 'feature-created',
|
||||
featureUpdated : 'feature-updated',
|
||||
featureArchive : 'feature-archive',
|
||||
featureRevive : 'feature-revive',
|
||||
strategyCreated: 'strategy-created',
|
||||
strategyDeleted: 'strategy-deleted'
|
||||
};
|
@ -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());
|
||||
|
37
lib/featureArchiveApi.js
Normal file
37
lib/featureArchiveApi.js
Normal 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();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -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"
|
||||
});
|
||||
});
|
||||
});
|
@ -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({})
|
||||
}
|
||||
];
|
||||
|
||||
|
64
public/js/components/feature/ArchiveFeatureComponent.jsx
Normal file
64
public/js/components/feature/ArchiveFeatureComponent.jsx
Normal 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;
|
@ -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)
|
||||
|
@ -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)
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
38
test/featureArchiveApiSpec.js
Normal file
38
test/featureArchiveApiSpec.js
Normal 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);
|
||||
});
|
||||
|
||||
});
|
@ -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); });
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user