diff --git a/lib/eventApi.js b/lib/eventApi.js index 5458cbf811..2b41f37158 100644 --- a/lib/eventApi.js +++ b/lib/eventApi.js @@ -1,9 +1,11 @@ -var eventDb = require('./eventDb'); +var eventDb = require('./eventDb'); +var eventDiffer = require('./eventDiffer'); module.exports = function (app) { app.get('/events', function (req, res) { eventDb.getEvents().then(function (events) { + eventDiffer.addDiffs(events); res.json({events: events}); }); }); @@ -11,6 +13,7 @@ module.exports = function (app) { app.get('/events/:name', function (req, res) { eventDb.getEventsFilterByName(req.params.name).then(function (events) { if (events) { + eventDiffer.addDiffs(events); res.json(events); } else { res.status(404).json({error: 'Could not find events'}); @@ -18,5 +21,4 @@ module.exports = function (app) { }); }); - }; \ No newline at end of file diff --git a/lib/eventDiffer.js b/lib/eventDiffer.js new file mode 100644 index 0000000000..6b14292c49 --- /dev/null +++ b/lib/eventDiffer.js @@ -0,0 +1,74 @@ +var eventType = require('./eventType'); +var diff = require('deep-diff').diff; + +var strategyTypes = [ + eventType.strategyCreated, + eventType.strategyDeleted +]; + +var featureTypes = [ + eventType.featureCreated, + eventType.featureUpdated, + eventType.featureArchive, + eventType.featureRevive +]; + +function baseTypeFor(event) { + if (featureTypes.indexOf(event.type) !== -1) { + return 'features'; + } else if (strategyTypes.indexOf(event.type) !== -1) { + return 'strategies'; + } else { + throw new Error('unknown event type: ' + JSON.stringify(event)); + } +} + +function groupByBaseTypeAndName(events) { + var groups = {}; + + events.forEach(function (event) { + var baseType = baseTypeFor(event); + + groups[baseType] = groups[baseType] || {}; + groups[baseType][event.data.name] = groups[baseType][event.data.name] || []; + + groups[baseType][event.data.name].push(event); + }); + + return groups; +} + +function eachConsecutiveEvent(events, callback) { + var groups = groupByBaseTypeAndName(events); + + Object.keys(groups).forEach(function (baseType) { + var group = groups[baseType]; + + Object.keys(group).forEach(function (name) { + var events = group[name]; + var left, right, i, l; + for (i = 0, l = events.length; i < l; i++) { + left = events[i]; + right = events[i + 1]; + + callback(left, right); + } + }); + }); +} + +function addDiffs(events) { + eachConsecutiveEvent(events, function (left, right) { + if (right) { + left.diffs = diff(right.data, left.data); + left.diffs = left.diffs || []; + } else { + left.diffs = null; + } + }); +} + + +module.exports = { + addDiffs: addDiffs +}; \ No newline at end of file diff --git a/package.json b/package.json index b4561cc1bb..e7f250d9ae 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "bluebird": "2.6.2", "body-parser": "1.10.1", "db-migrate": "0.7.1", + "deep-diff": "^0.3.0", "errorhandler": "1.3.2", "express": "4.9.8", "express-validator": "2.8.0", diff --git a/public/css/unleash.css b/public/css/unleash.css index 2d77687794..4d903cbfe5 100644 --- a/public/css/unleash.css +++ b/public/css/unleash.css @@ -22,4 +22,16 @@ code { word-wrap: break-word; white-space: pre; +} + +code > .diff-N { + color: green; +} + +code > .diff-D { + color: red; +} + +code > .diff-A, .diff-E { + color: black; } \ No newline at end of file diff --git a/public/js/components/log/LogEntry.jsx b/public/js/components/log/LogEntry.jsx index 338c76a50a..12cf37f315 100644 --- a/public/js/components/log/LogEntry.jsx +++ b/public/js/components/log/LogEntry.jsx @@ -1,5 +1,19 @@ var React = require('react'); +var DIFF_PREFIXES = { + A: ' ', + E: ' ', + D: '-', + N: '+' +} + +var SPADEN_CLASS = { + A: 'blue', // array edited + E: 'blue', // edited + D: 'negative', // deleted + N: 'positive', // added +} + var LogEntry = React.createClass({ propTypes: { event: React.PropTypes.object.isRequired @@ -7,25 +21,64 @@ var LogEntry = React.createClass({ render: function() { var d = new Date(this.props.event.createdAt); + + return ( + + + {d.getDate() + "." + (d.getMonth() + 1) + "." + d.getFullYear()}
+ kl. {d.getHours() + "." + d.getMinutes()} + + + {this.props.event.data.name}[{this.props.event.type}] + + + {this.renderEventDiff()} + + {this.props.event.createdBy} + + ); + }, + + renderFullEventData: function() { var localEventData = JSON.parse(JSON.stringify(this.props.event.data)); delete localEventData.description; delete localEventData.name; - return ( - - - {d.getDate() + "." + d.getMonth() + "." + d.getFullYear()}
- kl. {d.getHours() + "." + d.getMinutes()} - - - {this.props.event.data.name}[{this.props.event.type}] - - - {JSON.stringify(localEventData, null, 2)} - - {this.props.event.createdBy} - - ); + + var prettyPrinted = JSON.stringify(localEventData, null, 2); + + return ({prettyPrinted}) + }, + + renderEventDiff: function() { + if (!this.props.showFullEvents && this.props.event.diffs) { + var changes = this.props.event.diffs.map(this.buildDiff); + return ({changes.length === 0 ? '(no changes)' : changes}) + } else { + return this.renderFullEventData(); + } + }, + + buildDiff: function(diff, idx) { + var change; + var key = diff.path.join('.'); + + if (diff.lhs !== undefined && diff.rhs !== undefined) { + change = ( +
+
- {key}: {JSON.stringify(diff.lhs)}
+
+ {key}: {JSON.stringify(diff.rhs)}
+
+ ); + } else { + var spadenClass = SPADEN_CLASS[diff.kind] + var prefix = DIFF_PREFIXES[diff.kind]; + + change = (
{prefix} {key}: {JSON.stringify(diff.rhs)}
) + } + + return (
{change}
) } + }); module.exports = LogEntry; \ No newline at end of file diff --git a/public/js/components/log/LogEntryList.jsx b/public/js/components/log/LogEntryList.jsx index 96f0492f5c..697f71f7d9 100644 --- a/public/js/components/log/LogEntryList.jsx +++ b/public/js/components/log/LogEntryList.jsx @@ -6,18 +6,32 @@ var LogEntryList = React.createClass({ events: React.PropTypes.array.isRequired }, + getInitialState: function() { + return { + showFullEvents: false + } + }, + render: function() { var logEntryNodes = this.props.events.map(function(event) { - return ; - }); + return ; + }.bind(this)); + return ( -
+
+ + - + @@ -27,7 +41,12 @@ var LogEntryList = React.createClass({
When ActionData + Data + Author
); + }, + + toggleFullEvents: function(e) { + this.setState({showFullEvents: !this.state.showFullEvents}); } + }); module.exports = LogEntryList; \ No newline at end of file diff --git a/test/eventDifferTest.js b/test/eventDifferTest.js new file mode 100644 index 0000000000..b00603474f --- /dev/null +++ b/test/eventDifferTest.js @@ -0,0 +1,97 @@ +var eventDiffer = require('../lib/eventDiffer'); +var eventType = require('../lib/eventType'); +var assert = require('assert'); + +describe('eventDiffer', function () { + it('fails if events include an unknown event type', function () { + var events = [ + {type: eventType.featureCreated, data: {}}, + {type: 'unknown-type', data: {}} + ]; + + assert.throws(function () { + eventDiffer.addDiffs(events); + }); + }); + + it('diffs a feature-update event', function () { + var name = 'foo'; + var desc = 'bar'; + + var events = [ + { + type: eventType.featureUpdated, + data: {name: name, description: desc, strategy: 'default', enabled: true, parameters: {value: 2 }} + }, + { + type: eventType.featureCreated, + data: {name: name, description: desc, strategy: 'default', enabled: false, parameters: {value: 1}} + } + ]; + + eventDiffer.addDiffs(events); + + assert.deepEqual(events[0].diffs, [ + {kind: 'E', path: ["enabled"], lhs: false, rhs: true}, + {kind: 'E', path: ["parameters", "value"], lhs: 1, rhs: 2} + ]); + + assert.strictEqual(events[1].diffs, null); + }); + + it('diffs only against features with the same name', function () { + var events = [ + { + type: eventType.featureUpdated, + data: {name: 'bar', description: 'desc', strategy: 'default', enabled: true, parameters: {}} + }, + { + type: eventType.featureUpdated, + data: {name: 'foo', description: 'desc', strategy: 'default', enabled: false, parameters: {}} + }, + { + type: eventType.featureCreated, + data: {name: 'bar', description: 'desc', strategy: 'default', enabled: false, parameters: {}} + }, + { + type: eventType.featureCreated, + data: {name: 'foo', description: 'desc', strategy: 'default', enabled: true, parameters: {}} + } + ]; + + eventDiffer.addDiffs(events); + + assert.strictEqual(events[0].diffs[0].rhs, true); + assert.strictEqual(events[1].diffs[0].rhs, false); + assert.strictEqual(events[2].diffs, null); + assert.strictEqual(events[3].diffs, null); + }); + + it('sets an empty array of diffs if nothing was changed', function () { + var events = [ + { + type: eventType.featureUpdated, + data: {name: 'foo', description: 'desc', strategy: 'default', enabled: true, parameters: {}} + }, + { + type: eventType.featureCreated, + data: {name: 'foo', description: 'desc', strategy: 'default', enabled: true, parameters: {}} + } + ]; + + eventDiffer.addDiffs(events); + assert.deepEqual(events[0].diffs, []); + }); + + it('sets diffs to null if there was nothing to diff against', function () { + var events = [ + { + type: eventType.featureUpdated, + data: {name: 'foo', description: 'desc', strategy: 'default', enabled: true, parameters: {}} + } + ]; + + eventDiffer.addDiffs(events); + assert.strictEqual(events[0].diffs, null); + }); +}); \ No newline at end of file