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'),
|
routes = require('./lib/routes'),
|
||||||
eventApi = require('./lib/eventApi'),
|
eventApi = require('./lib/eventApi'),
|
||||||
featureApi = require('./lib/featureApi'),
|
featureApi = require('./lib/featureApi'),
|
||||||
|
featureArchiveApi = require('./lib/featureArchiveApi'),
|
||||||
strategyApi = require('./lib/strategyApi'),
|
strategyApi = require('./lib/strategyApi'),
|
||||||
validator = require('express-validator'),
|
validator = require('express-validator'),
|
||||||
app = express(),
|
app = express(),
|
||||||
@ -39,6 +40,7 @@ app.use(bodyParser.json({strict: false}));
|
|||||||
|
|
||||||
eventApi(router);
|
eventApi(router);
|
||||||
featureApi(router);
|
featureApi(router);
|
||||||
|
featureArchiveApi(router);
|
||||||
strategyApi(router);
|
strategyApi(router);
|
||||||
routes(router);
|
routes(router);
|
||||||
app.use(baseUriPath, router);
|
app.use(baseUriPath, router);
|
||||||
|
@ -2,6 +2,7 @@ module.exports = {
|
|||||||
featureCreated : 'feature-created',
|
featureCreated : 'feature-created',
|
||||||
featureUpdated : 'feature-updated',
|
featureUpdated : 'feature-updated',
|
||||||
featureArchive : 'feature-archive',
|
featureArchive : 'feature-archive',
|
||||||
|
featureRevive : 'feature-revive',
|
||||||
strategyCreated: 'strategy-created',
|
strategyCreated: 'strategy-created',
|
||||||
strategyDeleted: 'strategy-deleted'
|
strategyDeleted: 'strategy-deleted'
|
||||||
};
|
};
|
@ -6,7 +6,7 @@ var featureDb = require('./featureDb');
|
|||||||
var NameExistsError = require('./error/NameExistsError');
|
var NameExistsError = require('./error/NameExistsError');
|
||||||
var NotFoundError = require('./error/NotFoundError');
|
var NotFoundError = require('./error/NotFoundError');
|
||||||
var ValidationError = require('./error/ValidationError');
|
var ValidationError = require('./error/ValidationError');
|
||||||
var validateRequest = require('./error/validateRequest');
|
var validateRequest = require('./error/validateRequest');
|
||||||
|
|
||||||
module.exports = function (app) {
|
module.exports = function (app) {
|
||||||
|
|
||||||
@ -41,7 +41,9 @@ module.exports = function (app) {
|
|||||||
res.status(201).end();
|
res.status(201).end();
|
||||||
})
|
})
|
||||||
.catch(NameExistsError, function() {
|
.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() {
|
.catch(ValidationError, function() {
|
||||||
res.status(400).json(req.validationErrors());
|
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);
|
return archiveFeature(event.data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
eventStore.on(eventType.featureRevive, function (event) {
|
||||||
|
return reviveFeature(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
function getFeatures() {
|
function getFeatures() {
|
||||||
return knex
|
return knex
|
||||||
.select(FEATURE_COLUMNS)
|
.select(FEATURE_COLUMNS)
|
||||||
@ -34,6 +38,15 @@ function getFeature(name) {
|
|||||||
.then(rowToFeature);
|
.then(rowToFeature);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getArchivedFeatures() {
|
||||||
|
return knex
|
||||||
|
.select(['name', 'description'])
|
||||||
|
.from('features')
|
||||||
|
.where({archived: 1})
|
||||||
|
.orderBy('name', 'asc');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function rowToFeature(row) {
|
function rowToFeature(row) {
|
||||||
if (!row) {
|
if (!row) {
|
||||||
throw new NotFoundError('No feature toggle found');
|
throw new NotFoundError('No feature toggle found');
|
||||||
@ -53,6 +66,7 @@ function eventDataToRow(data) {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
enabled: data.enabled ? 1 : 0,
|
enabled: data.enabled ? 1 : 0,
|
||||||
|
archived: data.archived ? 1 :0,
|
||||||
strategy_name: data.strategy, // jshint ignore: line
|
strategy_name: data.strategy, // jshint ignore: line
|
||||||
parameters: data.parameters
|
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 = {
|
module.exports = {
|
||||||
getFeatures: getFeatures,
|
getFeatures: getFeatures,
|
||||||
getFeature: getFeature,
|
getFeature: getFeature,
|
||||||
|
getArchivedFeatures: getArchivedFeatures,
|
||||||
_createFeature: createFeature, // visible for testing
|
_createFeature: createFeature, // visible for testing
|
||||||
_updateFeature: updateFeature // 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 React = require('react');
|
||||||
var TabView = require('./components/TabView');
|
var TabView = require('./components/TabView');
|
||||||
var Menu = require('./components/Menu');
|
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 FeatureTogglesComponent = React.createFactory(require('./components/feature/FeatureTogglesComponent'));
|
||||||
var StrategiesComponent = React.createFactory(require('./components/strategy/StrategiesComponent'));
|
var StrategiesComponent = React.createFactory(require('./components/strategy/StrategiesComponent'));
|
||||||
|
var ArchiveFeatureComponent = React.createFactory(require('./components/feature/ArchiveFeatureComponent'));
|
||||||
|
|
||||||
var tabPanes = [
|
var tabPanes = [
|
||||||
{
|
{
|
||||||
@ -18,6 +18,10 @@ var tabPanes = [
|
|||||||
{
|
{
|
||||||
name: "Log",
|
name: "Log",
|
||||||
content: new LogEntriesComponent({})
|
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: [],
|
features: [],
|
||||||
errors: [],
|
errors: [],
|
||||||
createView: false,
|
createView: false,
|
||||||
featurePoller: new Timer(this.loadFeaturesFromServer, this.props.pollInterval),
|
featurePoller: new Timer(this.loadFeaturesFromServer, this.props.pollInterval)
|
||||||
featureStore: new FeatureStore()
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -26,7 +25,7 @@ var FeatureTogglesComponent = React.createClass({
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadFeaturesFromServer: function () {
|
loadFeaturesFromServer: function () {
|
||||||
this.state.featureStore.getFeatures().then(this.setFeatures).catch(this.handleError);
|
FeatureStore.getFeatures().then(this.setFeatures).catch(this.handleError);
|
||||||
},
|
},
|
||||||
|
|
||||||
setFeatures: function (data) {
|
setFeatures: function (data) {
|
||||||
@ -49,18 +48,22 @@ var FeatureTogglesComponent = React.createClass({
|
|||||||
updateFeature: function (feature) {
|
updateFeature: function (feature) {
|
||||||
this.stopFeaturePoller();
|
this.stopFeaturePoller();
|
||||||
|
|
||||||
this.state.featureStore
|
FeatureStore
|
||||||
.updateFeature(feature)
|
.updateFeature(feature)
|
||||||
.then(this.startFeaturePoller)
|
.then(this.startFeaturePoller)
|
||||||
.catch(this.handleError);
|
.catch(this.handleError);
|
||||||
},
|
},
|
||||||
|
|
||||||
archiveFeature: function (feature) {
|
archiveFeature: function (feature) {
|
||||||
this.stopFeaturePoller();
|
var updatedFeatures = this.state.features.filter(function(item) {
|
||||||
|
return item.name !== feature.name;
|
||||||
|
});
|
||||||
|
|
||||||
this.state.featureStore
|
FeatureStore
|
||||||
.archiveFeature(feature)
|
.archiveFeature(feature)
|
||||||
.then(this.startFeaturePoller)
|
.then(function() {
|
||||||
|
this.setState({features: updatedFeatures})
|
||||||
|
}.bind(this))
|
||||||
.catch(this.handleError);
|
.catch(this.handleError);
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -77,7 +80,7 @@ var FeatureTogglesComponent = React.createClass({
|
|||||||
createFeature: function (feature) {
|
createFeature: function (feature) {
|
||||||
this.stopFeaturePoller();
|
this.stopFeaturePoller();
|
||||||
|
|
||||||
this.state.featureStore
|
FeatureStore
|
||||||
.createFeature(feature)
|
.createFeature(feature)
|
||||||
.then(this.cancelNewFeature)
|
.then(this.cancelNewFeature)
|
||||||
.then(this.startFeaturePoller)
|
.then(this.startFeaturePoller)
|
||||||
|
@ -1,12 +1,9 @@
|
|||||||
var reqwest = require('reqwest');
|
var reqwest = require('reqwest');
|
||||||
|
|
||||||
var FeatureStore = function () {
|
|
||||||
};
|
|
||||||
|
|
||||||
var TYPE = 'json';
|
var TYPE = 'json';
|
||||||
var CONTENT_TYPE = 'application/json';
|
var CONTENT_TYPE = 'application/json';
|
||||||
|
|
||||||
FeatureStore.prototype = {
|
FeatureStore = {
|
||||||
updateFeature: function (feature) {
|
updateFeature: function (feature) {
|
||||||
return reqwest({
|
return reqwest({
|
||||||
url: 'features/' + feature.name,
|
url: 'features/' + feature.name,
|
||||||
@ -41,6 +38,24 @@ FeatureStore.prototype = {
|
|||||||
method: 'get',
|
method: 'get',
|
||||||
type: TYPE
|
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);
|
.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": {
|
"parameters": {
|
||||||
"foo": "rab"
|
"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); });
|
], function (feature) { return featureDb._createFeature(feature); });
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user