diff --git a/client/app/scripts/components/details.js b/client/app/scripts/components/details.js
index 574e8c3a0..c9092f5a3 100644
--- a/client/app/scripts/components/details.js
+++ b/client/app/scripts/components/details.js
@@ -8,7 +8,6 @@ var IconButton = mui.IconButton;
var AppActions = require('../actions/app-actions');
var NodeDetails = require('./node-details');
-var WebapiUtils = require('../utils/web-api-utils');
var Details = React.createClass({
diff --git a/client/app/scripts/components/groupings.js b/client/app/scripts/components/groupings.js
index 97d976e44..d99457143 100644
--- a/client/app/scripts/components/groupings.js
+++ b/client/app/scripts/components/groupings.js
@@ -8,10 +8,12 @@ var AppStore = require('../stores/app-store');
var GROUPINGS = [{
id: 'none',
- iconClass: 'fa fa-th'
+ iconClass: 'fa fa-th',
+ needsTopology: false
}, {
id: 'grouped',
- iconClass: 'fa fa-th-large'
+ iconClass: 'fa fa-th-large',
+ needsTopology: 'grouped_url'
}];
var Groupings = React.createClass({
@@ -21,22 +23,40 @@ var Groupings = React.createClass({
AppActions.clickGrouping(ev.currentTarget.getAttribute('rel'));
},
- renderGrouping: function(grouping, active) {
- var className = grouping.id === active ? "groupings-item groupings-item-active" : "groupings-item";
+ isGroupingSupportedByTopology: function(topology, grouping) {
+ return !grouping.needsTopology || topology && topology[grouping.needsTopology];
+ },
+
+ getGroupingsSupportedByTopology: function(topology) {
+ return _.filter(GROUPINGS, _.partial(this.isGroupingSupportedByTopology, topology));
+ },
+
+ renderGrouping: function(grouping, activeGroupingId) {
+ var className = "groupings-item",
+ isSupportedByTopology = this.isGroupingSupportedByTopology(this.props.currentTopology, grouping);
+
+ if (grouping.id === activeGroupingId) {
+ className += " groupings-item-active";
+ } else if (!isSupportedByTopology) {
+ className += " groupings-item-disabled";
+ } else {
+ className += " groupings-item-default";
+ }
return (
-
+
);
},
render: function() {
- var activeGrouping = this.props.active;
+ var activeGrouping = this.props.active,
+ isGroupingSupported = _.size(this.getGroupingsSupportedByTopology(this.props.currentTopology)) > 1;
return (
- {GROUPINGS.map(function(grouping) {
+ {isGroupingSupported && GROUPINGS.map(function(grouping) {
return this.renderGrouping(grouping, activeGrouping);
}, this)}
diff --git a/client/app/scripts/components/topologies.js b/client/app/scripts/components/topologies.js
index 8c93e8c44..b5347e406 100644
--- a/client/app/scripts/components/topologies.js
+++ b/client/app/scripts/components/topologies.js
@@ -14,8 +14,9 @@ var Topologies = React.createClass({
},
renderTopology: function(topology, active) {
- var className = AppStore.isUrlForTopology(topology.url, active) ? "topologies-item topologies-item-active" : "topologies-item",
- topologyId = AppStore.getTopologyForUrl(topology.url),
+ var isActive = topology.name === this.props.currentTopology.name,
+ className = isActive ? "topologies-item topologies-item-active" : "topologies-item",
+ topologyId = AppStore.getTopologyIdForUrl(topology.url),
title = ['Topology: ' + topology.name,
'Nodes: ' + topology.stats.node_count,
'Connections: ' + topology.stats.node_count].join('\n');
@@ -32,15 +33,14 @@ var Topologies = React.createClass({
},
render: function() {
- var activeTopologyId = this.props.active,
- topologies = _.sortBy(this.props.topologies, function(topology) {
+ var topologies = _.sortBy(this.props.topologies, function(topology) {
return topology.name;
});
return (
- {topologies.map(function(topology) {
- return this.renderTopology(topology, activeTopologyId);
+ {this.props.currentTopology && topologies.map(function(topology) {
+ return this.renderTopology(topology);
}, this)}
);
diff --git a/client/app/scripts/stores/__tests__/app-store-test.js b/client/app/scripts/stores/__tests__/app-store-test.js
new file mode 100644
index 000000000..4c7159d0a
--- /dev/null
+++ b/client/app/scripts/stores/__tests__/app-store-test.js
@@ -0,0 +1,61 @@
+
+describe('AppStore', function() {
+
+ var ActionTypes = require('../../constants/action-types');
+ var AppStore, registeredCallback;
+
+ // actions
+
+ var ClickTopologyAction = {
+ type: ActionTypes.CLICK_TOPOLOGY,
+ topologyId: 'topo1'
+ };
+
+ var ClickGroupingAction = {
+ type: ActionTypes.CLICK_GROUPING,
+ grouping: 'grouped'
+ };
+
+ var ReceiveTopologiesAction = {
+ type: ActionTypes.RECEIVE_TOPOLOGIES,
+ topologies: [{
+ url: '/topo1',
+ grouped_url: '/topo1grouped',
+ name: 'Topo1'
+ }]
+ };
+
+ beforeEach(function() {
+ AppStore = require('../app-store');
+ registeredCallback = AppStore.registeredCallback;
+ });
+
+ // topology tests
+
+ it('init with no topologies', function() {
+ var topos = AppStore.getTopologies();
+ expect(topos.length).toBe(0);
+ expect(AppStore.getCurrentTopology()).toBeUndefined();
+ });
+
+ it('get current topology', function() {
+ registeredCallback(ClickTopologyAction);
+ registeredCallback(ReceiveTopologiesAction);
+
+ expect(AppStore.getTopologies().length).toBe(1);
+ expect(AppStore.getCurrentTopology().name).toBe('Topo1');
+ expect(AppStore.getCurrentTopologyUrl()).toBe('/topo1');
+ });
+
+ it('get grouped topology', function() {
+ registeredCallback(ClickTopologyAction);
+ registeredCallback(ReceiveTopologiesAction);
+ registeredCallback(ClickGroupingAction);
+
+ expect(AppStore.getTopologies().length).toBe(1);
+ expect(AppStore.getCurrentTopology().name).toBe('Topo1');
+ expect(AppStore.getCurrentTopologyUrl()).toBe('/topo1grouped');
+ });
+
+
+});
\ No newline at end of file
diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js
index f4cc8d18d..e670b6170 100644
--- a/client/app/scripts/stores/app-store.js
+++ b/client/app/scripts/stores/app-store.js
@@ -5,15 +5,15 @@ var assign = require('object-assign');
var AppDispatcher = require('../dispatcher/app-dispatcher');
var ActionTypes = require('../constants/action-types');
-var TopologyStore = require('./topology-store');
-// var topologies = require('../constants/topologies');
// Initial values
var connectionState = 'disconnected';
var currentGrouping = 'none';
-var currentTopology = 'applications';
+var currentTopologyId = 'applications';
+var mouseOverNode = null;
+var nodes = {};
var nodeDetails = null;
var selectedNodeId = null;
var topologies = [];
@@ -26,8 +26,8 @@ var AppStore = assign({}, EventEmitter.prototype, {
getAppState: function() {
return {
- currentTopology: this.getCurrentTopology(),
- currentGrouping: this.getCurrentGrouping(),
+ topologyId: currentTopologyId,
+ grouping: this.getCurrentGrouping(),
selectedNodeId: this.getSelectedNodeId()
};
},
@@ -37,7 +37,17 @@ var AppStore = assign({}, EventEmitter.prototype, {
},
getCurrentTopology: function() {
- return currentTopology;
+ return _.find(topologies, function(topology) {
+ return isUrlForTopologyId(topology.url, currentTopologyId);
+ });
+ },
+
+ getCurrentTopologyUrl: function() {
+ var topology = this.getCurrentTopology();
+
+ if (topology) {
+ return topology.grouped_url && currentGrouping == 'grouped' ? topology.grouped_url : topology.url;
+ }
},
getCurrentGrouping: function() {
@@ -48,6 +58,10 @@ var AppStore = assign({}, EventEmitter.prototype, {
return nodeDetails;
},
+ getNodes: function() {
+ return nodes;
+ },
+
getSelectedNodeId: function() {
return selectedNodeId;
},
@@ -56,40 +70,35 @@ var AppStore = assign({}, EventEmitter.prototype, {
return topologies;
},
- getTopologyForUrl: function(url) {
+ getTopologyIdForUrl: function(url) {
return url.split('/').pop();
- },
-
- getUrlForTopology: function(topologyId) {
- var topology = _.find(topologies, function(topology) {
- return this.isUrlForTopology(topology.url, topologyId);
- }, this);
-
- if (topology) {
- return topology.grouped_url && currentGrouping == 'grouped' ? topology.grouped_url : topology.url;
- }
- },
-
- isUrlForTopology: function(url, topologyId) {
- return _.endsWith(url, topologyId);
}
-
});
+// Helpers
+
+function isUrlForTopologyId(url, topologyId) {
+ return _.endsWith(url, topologyId);
+}
+
+
// Store Dispatch Hooks
-AppStore.dispatchToken = AppDispatcher.register(function(payload) {
+AppStore.registeredCallback = function(payload) {
switch (payload.type) {
+
case ActionTypes.CLICK_CLOSE_DETAILS:
selectedNodeId = null;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.CLICK_GROUPING:
- currentGrouping = payload.grouping;
- AppDispatcher.waitFor([TopologyStore.dispatchToken]);
- AppStore.emit(AppStore.CHANGE_EVENT);
+ if (payload.grouping !== currentGrouping) {
+ currentGrouping = payload.grouping;
+ nodes = {};
+ AppStore.emit(AppStore.CHANGE_EVENT);
+ }
break;
case ActionTypes.CLICK_NODE:
@@ -98,8 +107,15 @@ AppStore.dispatchToken = AppDispatcher.register(function(payload) {
break;
case ActionTypes.CLICK_TOPOLOGY:
- currentTopology = payload.topologyId;
- AppDispatcher.waitFor([TopologyStore.dispatchToken]);
+ if (payload.topologyId !== currentTopologyId) {
+ currentTopologyId = payload.topologyId;
+ nodes = {};
+ }
+ AppStore.emit(AppStore.CHANGE_EVENT);
+ break;
+
+ case ActionTypes.ENTER_NODE:
+ mouseOverNode = payload.nodeId;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
@@ -109,14 +125,43 @@ AppStore.dispatchToken = AppDispatcher.register(function(payload) {
AppStore.emit(AppStore.CHANGE_EVENT);
break;
+ case ActionTypes.LEAVE_NODE:
+ mouseOverNode = null;
+ AppStore.emit(AppStore.CHANGE_EVENT);
+ break;
+
case ActionTypes.RECEIVE_NODE_DETAILS:
nodeDetails = payload.details;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.RECEIVE_NODES_DELTA:
+ console.log('RECEIVE_NODES_DELTA',
+ 'remove', _.size(payload.delta.remove),
+ 'update', _.size(payload.delta.update),
+ 'add', _.size(payload.delta.add));
+
connectionState = "connected";
- AppDispatcher.waitFor([TopologyStore.dispatchToken]);
+
+ // nodes that no longer exist
+ _.each(payload.delta.remove, function(nodeId) {
+ // in case node disappears before mouseleave event
+ if (mouseOverNode === nodeId) {
+ mouseOverNode = null;
+ }
+ delete nodes[nodeId];
+ });
+
+ // update existing nodes
+ _.each(payload.delta.update, function(node) {
+ nodes[node.id] = node;
+ });
+
+ // add new nodes
+ _.each(payload.delta.add, function(node) {
+ nodes[node.id] = node;
+ });
+
AppStore.emit(AppStore.CHANGE_EVENT);
break;
@@ -126,10 +171,10 @@ AppStore.dispatchToken = AppDispatcher.register(function(payload) {
break;
case ActionTypes.ROUTE_TOPOLOGY:
- currentTopology = payload.state.currentTopology;
- currentGrouping = payload.state.currentGrouping;
+ nodes = {};
+ currentTopologyId = payload.state.topologyId;
+ currentGrouping = payload.state.grouping;
selectedNodeId = payload.state.selectedNodeId;
- AppDispatcher.waitFor([TopologyStore.dispatchToken]);
AppStore.emit(AppStore.CHANGE_EVENT);
break;
@@ -137,6 +182,8 @@ AppStore.dispatchToken = AppDispatcher.register(function(payload) {
break;
}
-});
+};
+
+AppStore.dispatchToken = AppDispatcher.register(AppStore.registeredCallback);
module.exports = AppStore;
diff --git a/client/app/scripts/stores/topology-store.js b/client/app/scripts/stores/topology-store.js
deleted file mode 100644
index e3422d986..000000000
--- a/client/app/scripts/stores/topology-store.js
+++ /dev/null
@@ -1,86 +0,0 @@
-var EventEmitter = require('events').EventEmitter;
-var _ = require('lodash');
-var assign = require('object-assign');
-
-var AppDispatcher = require('../dispatcher/app-dispatcher');
-var ActionTypes = require('../constants/action-types');
-
-
-
-// Initial values
-
-var nodes = {};
-var mouseOverNode = null;
-
-// Store API
-
-var TopologyStore = assign({}, EventEmitter.prototype, {
-
- CHANGE_EVENT: 'change',
-
- getNodes: function() {
- return nodes;
- }
-
-});
-
-
-// Store Dispatch Hooks
-
-TopologyStore.dispatchToken = AppDispatcher.register(function(payload) {
- switch (payload.type) {
- case ActionTypes.CLICK_GROUPING:
- nodes = {};
- TopologyStore.emit(TopologyStore.CHANGE_EVENT);
- break;
-
- case ActionTypes.CLICK_TOPOLOGY:
- nodes = {};
- TopologyStore.emit(TopologyStore.CHANGE_EVENT);
- break;
-
- case ActionTypes.ENTER_NODE:
- mouseOverNode = payload.nodeId;
- TopologyStore.emit(TopologyStore.CHANGE_EVENT);
- break;
-
- case ActionTypes.LEAVE_NODE:
- mouseOverNode = null;
- TopologyStore.emit(TopologyStore.CHANGE_EVENT);
- break;
-
- case ActionTypes.RECEIVE_NODES_DELTA:
- // nodes that no longer exist
- _.each(payload.delta.remove, function(nodeId) {
- // in case node disappears before mouseleave event
- if (mouseOverNode === nodeId) {
- mouseOverNode = null;
- }
- delete nodes[nodeId];
- });
-
- // update existing nodes
- _.each(payload.delta.update, function(node) {
- nodes[node.id] = node;
- });
-
- // add new nodes
- _.each(payload.delta.add, function(node) {
- nodes[node.id] = node;
- });
-
- TopologyStore.emit(TopologyStore.CHANGE_EVENT);
- break;
-
- case ActionTypes.ROUTE_TOPOLOGY:
- nodes = {};
- TopologyStore.emit(TopologyStore.CHANGE_EVENT);
- break;
-
- default:
- break;
-
- }
-});
-
-module.exports = TopologyStore;
\ No newline at end of file
diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js
index 64ef97cff..cd391ffdb 100644
--- a/client/app/scripts/utils/web-api-utils.js
+++ b/client/app/scripts/utils/web-api-utils.js
@@ -1,8 +1,6 @@
var reqwest = require('reqwest');
-var TopologyActions = require('../actions/topology-actions');
var AppActions = require('../actions/app-actions');
-var AppStore = require('../stores/app-store');
var WS_URL = window.WS_URL || 'ws://' + location.host;
@@ -33,7 +31,7 @@ function createWebsocket(topologyUrl) {
socket.onmessage = function(event) {
var msg = JSON.parse(event.data);
if (msg.add || msg.remove || msg.update) {
- TopologyActions.receiveNodesDelta(msg);
+ AppActions.receiveNodesDelta(msg);
}
};
@@ -48,9 +46,9 @@ function getTopologies() {
});
}
-function getNodeDetails(topology, nodeId) {
- if (nodeId) {
- var url = [AppStore.getUrlForTopology(topology), nodeId].join('/');
+function getNodeDetails(topologyUrl, nodeId) {
+ if (topologyUrl && nodeId) {
+ var url = [topologyUrl, nodeId].join('/');
reqwest(url, function(res) {
AppActions.receiveNodeDetails(res.node);
});
diff --git a/client/app/styles/main.less b/client/app/styles/main.less
index f508e89ec..9f96db31f 100644
--- a/client/app/styles/main.less
+++ b/client/app/styles/main.less
@@ -101,7 +101,13 @@ body {
display: inline-block;
color: @text-tertiary-color;
- &-active, &:hover {
+ &-disabled {
+ color: @text-tertiary-color;
+ cursor: default;
+ }
+
+ &-default:hover,
+ &-active {
color: @text-color;
}
}
diff --git a/client/package.json b/client/package.json
index cb3b71d28..7b14a29f5 100644
--- a/client/package.json
+++ b/client/package.json
@@ -38,15 +38,25 @@
"gulp-size": "^1.2.1",
"gulp-sourcemaps": "^1.5.2",
"gulp-uglify": "^1.2.0",
+ "gulp-useref": "^1.1.1",
"gulp-util": "^3.0.4",
+ "jasmine-core": "^2.3.4",
"jshint-stylish": "^1.0.2",
+ "karma": "^0.12.32",
+ "karma-browserify": "^4.2.1",
+ "karma-cli": "0.0.4",
+ "karma-jasmine": "^0.3.5",
+ "karma-phantomjs-launcher": "^0.1.4",
+ "opn": "^1.0.1",
"proxy-middleware": "^0.11.1",
+ "react-tools": "^0.13.3",
"reactify": "^1.1.1",
"vinyl-buffer": "^1.0.0",
"vinyl-source-stream": "^1.1.0"
},
"scripts": {
- "start": "gulp"
+ "start": "gulp",
+ "test": "karma start test/karma.conf.js --single-run"
},
"engines": {
"node": ">=0.10.0"
diff --git a/client/test/README.md b/client/test/README.md
new file mode 100644
index 000000000..f8419bcf9
--- /dev/null
+++ b/client/test/README.md
@@ -0,0 +1,9 @@
+# Testing
+
+Scope unit testing is done unsing Karma/Jasmine. (Jest was too big and slow.)
+
+To run tests, do `npm test` in the toplevel directory.
+
+The tests are placed in `__tests__` directories, relative to what they are testing.
+
+For more info see [Testing Flux Apps with Karma](http://kentor.me/posts/testing-react-and-flux-applications-with-karma-and-webpack/)
diff --git a/client/test/bower.json b/client/test/bower.json
deleted file mode 100644
index 26de14fc4..000000000
--- a/client/test/bower.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "name": "gulp-webapp",
- "private": true,
- "dependencies": {
- "chai": "~1.8.0",
- "mocha": "~1.14.0"
- },
- "devDependencies": {}
-}
diff --git a/client/test/index.html b/client/test/index.html
deleted file mode 100644
index d10cee026..000000000
--- a/client/test/index.html
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
-
Mocha Spec Runner
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/client/test/karma.conf.js b/client/test/karma.conf.js
new file mode 100644
index 000000000..1d50e1f7b
--- /dev/null
+++ b/client/test/karma.conf.js
@@ -0,0 +1,23 @@
+module.exports = function(config) {
+ config.set({
+ browsers: [
+ 'PhantomJS'
+ ],
+ files: [
+ '../app/**/__tests__/*.js'
+ ],
+ frameworks: [
+ 'jasmine', 'browserify'
+ ],
+ preprocessors: {
+ '../app/**/__tests__/*.js': ['browserify']
+ },
+ browserify: {
+ debug: true,
+ transform: ['reactify']
+ },
+ reporters: [
+ 'dots'
+ ]
+ });
+};
diff --git a/client/test/preprocessor.js b/client/test/preprocessor.js
new file mode 100644
index 000000000..4c91ab16b
--- /dev/null
+++ b/client/test/preprocessor.js
@@ -0,0 +1,7 @@
+var ReactTools = require('react-tools');
+
+module.exports = {
+ process: function(src) {
+ return ReactTools.transform(src);
+ }
+};
diff --git a/client/test/spec/test.js b/client/test/spec/test.js
deleted file mode 100644
index acbc11a4c..000000000
--- a/client/test/spec/test.js
+++ /dev/null
@@ -1,13 +0,0 @@
-/* global describe, it */
-
-(function () {
- 'use strict';
-
- describe('Give it some context', function () {
- describe('maybe a bit more context here', function () {
- it('should run here few assertions', function () {
-
- });
- });
- });
-})();