Index: [revision details]
=== added file '[revision details]'
--- [revision details] 2012-01-01 00:00:00 +0000
+++ [revision details] 2012-01-01 00:00:00 +0000
@@ -0,0 +1,2 @@
+Old revision: francesco.banconi@canonical.com-20121113100030-02cq28gzcpchzx8a
+New revision: bcsaller@gmail.com-20121113135532-fxwuuzayp9kbhoi8
Index: app/modules-debug.js
=== modified file 'app/modules-debug.js'
--- app/modules-debug.js 2012-11-09 15:37:08 +0000
+++ app/modules-debug.js 2012-11-13 13:55:16 +0000
@@ -14,6 +14,9 @@
modules: {
'd3': {
'fullpath': '/juju-ui/assets/javascripts/d3.v2.min.js'
+ },
+ 'd3-components': {
+ fullpath: '/juju-ui/assets/javascripts/d3-components.js'
}
}
},
@@ -68,6 +71,7 @@
'juju-views': {
use: [
+ 'd3-components',
'juju-templates',
'juju-notifications',
'juju-view-utils',
Index: app/modules.js
=== modified file 'app/modules.js'
--- app/modules.js 2012-11-09 21:27:16 +0000
+++ app/modules.js 2012-11-13 13:55:16 +0000
@@ -4,10 +4,21 @@
ignoreRegistered: true,
groups: {
+ d3: {
+ modules: {
+ 'd3': {
+ 'fullpath': '/juju-ui/assets/javascripts/d3.v2.min.js'
+ },
+ 'd3-components': {
+ fullpath: '/juju-ui/assets/javascripts/d3-components.js'
+ }
+ }
+ },
juju: {
modules: {
'juju-views': {
use: [
+ 'd3-components',
'juju-templates',
'juju-notifications',
'juju-view-utils',
Index: bin/merge-files
=== modified file 'bin/merge-files'
--- bin/merge-files 2012-11-09 19:15:30 +0000
+++ bin/merge-files 2012-11-13 13:55:16 +0000
@@ -57,7 +57,7 @@
// Combine third party js libraries
merge.combine([ './app/assets/javascripts/d3.v2.min.js',
'./app/assets/javascripts/reconnecting-websocket.js',
- './app/assets/javascripts/svg-layouts.js' ],
+ './app/assets/javascripts/svg-layouts.js'],
'./app/assets/javascripts/generated/all-third.js', true);
// Now we only need to generate the file that is used to tell YUI where all
@@ -66,10 +66,10 @@
// The production version aggregates all of the files together.
// Creating the combined file for the modules-debug.js and config.js files
- merge.combine([ './app/modules-debug.js', './app/config.js' ],
- './app/assets/javascripts/generated/all-app-debug.js', false);
+ merge.combine([ './app/modules-debug.js', './app/config.js'],
+ './app/assets/javascripts/generated/all-app-debug.js', false);
// Creating the combined file for all our files. Note that this includes
- // app/modules.js in the rollup, which is why that file still needs exist.
+ // app/modules.js in the rollup, which is why that file still needs to exist.
merge.combine(paths, './app/assets/javascripts/generated/all-app.js', true);
-});
\ No newline at end of file
+});
Index: test/index.html
=== modified file 'test/index.html'
--- test/index.html 2012-11-06 15:47:45 +0000
+++ test/index.html 2012-11-13 13:55:16 +0000
@@ -15,6 +15,7 @@
mocha.setup({'ui': 'bdd', 'ignoreLeaks': false})
+
Index: test/test_d3_components.js
=== added file 'test/test_d3_components.js'
--- test/test_d3_components.js 1970-01-01 00:00:00 +0000
+++ test/test_d3_components.js 2012-11-09 14:17:58 +0000
@@ -0,0 +1,173 @@
+'use strict';
+
+describe('d3-components', function() {
+ var Y, NS, TestModule, modA, state,
+ container, comp;
+
+ before(function(done) {
+ Y = YUI(GlobalConfig).use(['d3-components',
+ 'node',
+ 'node-event-simulate'],
+ function(Y) {
+ NS = Y.namespace('d3');
+
+ TestModule = Y.Base.create('TestModule', NS.Module, [], {
+ events: {
+ scene: { '.thing': {click: 'decorateThing'}},
+ d3: {'.target': {click: 'targetTarget'}},
+ yui: {
+ cancel: 'cancelHandler'
+ }
+ },
+
+ decorateThing: function(evt) {
+ state.thing = 'decorated';
+ },
+
+ targetTarget: function(evt) {
+ state.targeted = true;
+ },
+
+ cancelHandler: function(evt) {
+ state.cancelled = true;
+ }
+ });
+
+ done();
+ });
+ });
+
+ beforeEach(function() {
+ container = Y.Node.create('
' +
+ '' +
+ '' +
+ '
');
+ state = {};
+ });
+
+ afterEach(function() {
+ container.remove();
+ container.destroy();
+ if (comp) {
+ comp.unbind();
+ }
+ });
+
+
+ it('should be able to create a component and add a module', function() {
+ comp = new NS.Component();
+ Y.Lang.isValue(comp).should.equal(true);
+ });
+
+ it('should be able to add and remove a module', function() {
+ comp = new NS.Component();
+ comp.setAttrs({container: container});
+ comp.addModule(TestModule);
+ Y.Lang.isValue(comp.events).should.equal(true);
+ Y.Lang.isValue(comp.modules).should.equal(true);
+ });
+
+ it('should be able to (un)bind module event subscriptions', function() {
+ comp = new NS.Component();
+ comp.setAttrs({container: container});
+ comp.addModule(TestModule);
+
+ // Test that default bindings work by simulating
+ Y.fire('cancel');
+ state.cancelled.should.equal(true);
+
+ // XXX: While on the plane I determined that things like
+ // 'events' are sharing state with other runs/modules.
+ // This must be fixed before this can work again.
+
+ // Manually set state, remove the module and test again
+ state.cancelled = false;
+ comp.removeModule('TestModule');
+
+ Y.fire('cancel');
+ state.cancelled.should.equal(false);
+
+ // Adding the module back again doesn't create any issues.
+ comp.addModule(TestModule);
+ Y.fire('cancel');
+ state.cancelled.should.equal(true);
+
+ // Simulated events on DOM handlers better work.
+ // These require a bound DOM element however
+ comp.render();
+ Y.one('.thing').simulate('click');
+ state.thing.should.equal('decorated');
+ });
+
+ it('should allow event bindings through the use of a declartive object',
+ function() {
+ comp = new NS.Component();
+ comp.setAttrs({container: container});
+
+ // Change test module to use rich captures on some events.
+ // This defines a phase for click (before, after, on (default))
+ // and also shows an inline callback (which is discouraged but allowed)
+ modA = new TestModule();
+ modA.events.scene['.thing'] = {
+ click: {phase: 'after',
+ callback: 'afterThing'},
+ dblclick: {phase: 'on',
+ callback: function(evt) {
+ state.dbldbl = true;
+ }}};
+ modA.afterThing = function(evt) {
+ state.clicked = true;
+ };
+ comp.addModule(modA);
+ comp.render();
+
+ Y.one('.thing').simulate('click');
+ state.clicked.should.equal(true);
+
+ Y.one('.thing').simulate('dblclick');
+ state.dbldbl.should.equal(true);
+
+ });
+
+ it('should support basic rendering from all modules',
+ function() {
+ var modA = new TestModule(),
+ modB = new TestModule();
+
+ comp = new NS.Component();
+ // Give each of these a render method that adds to container
+ modA.name = 'moda';
+ modA.render = function() {
+ this.get('container').append(Y.Node.create(''));
+ };
+
+ modB.name = 'modb';
+ modB.render = function() {
+ this.get('container').append(Y.Node.create(''));
+ };
+
+ comp.setAttrs({container: container});
+ comp.addModule(modA)
+ .addModule(modB);
+
+ comp.render();
+ Y.Lang.isValue(Y.one('#fromA')).should.equal(true);
+ Y.Lang.isValue(Y.one('#fromB')).should.equal(true);
+ });
+
+ it('should support d3 event bindings post render', function() {
+ comp = new NS.Component();
+ comp.setAttrs({container: container});
+
+ comp.addModule(TestModule);
+
+ comp.render();
+
+ // This is a d3 bound handler that occurs only after render.
+ container.one('.target').simulate('click');
+ state.targeted.should.equal(true);
+ });
+
+});
+
+
Index: app/assets/javascripts/d3-components.js
=== added file 'app/assets/javascripts/d3-components.js'
--- app/assets/javascripts/d3-components.js 1970-01-01 00:00:00 +0000
+++ app/assets/javascripts/d3-components.js 2012-11-09 13:39:45 +0000
@@ -0,0 +1,401 @@
+'use strict';
+
+/**
+ * Provides a declarative structure around interactive D3
+ * applications.
+ *
+ * @module d3-components
+ **/
+
+YUI.add('d3-components', function(Y) {
+ var ns = Y.namespace('d3'),
+ L = Y.Lang;
+
+ var Module = Y.Base.create('Module', Y.Base, [], {
+ /**
+ * @property events
+ * @type {object}
+ **/
+ events: {
+ scene: {},
+ d3: {},
+ yui: {}
+ },
+
+ initializer: function() {
+ this.events = Y.merge(this.events);
+ }
+ }, {
+ ATTRS: {
+ component: {},
+ options: {},
+ container: {getter: function() {
+ return this.get('component').get('container');}}
+ }
+ });
+ ns.Module = Module;
+
+
+ var Component = Y.Base.create('Component', Y.Base, [], {
+ /**
+ * @class Component
+ *
+ * Component collects modules implementing various portions of an
+ * applications functionality in a declarative way. It is designed to allow
+ * both a cleaner separation of concerns and the ability to reuse the
+ * component in different ways.
+ *
+ * Component accomplishes these goals by:
+ * - Control how events are bound and unbound.
+ * - Providing patterns for update data cleanly.
+ * - Providing suggestions around updating the interactive portions
+ * of the application.
+ *
+ * @constructor
+ **/
+ initializer: function() {
+ this.modules = {};
+ this.events = {};
+ },
+
+ /**
+ * @method addModule
+ * @chainable
+ * @param {Module} ModClassOrInstance bound will be to this.
+ * @param {Object} options dict of options set as options attribute on
+ * module.
+ *
+ * Add a Module to this Component. This will bind its events and set up all
+ * needed event subscriptions. Modules can return three sets of events
+ * that will be bound in different ways
+ *
+ * - scene: {selector: event-type: handlerName} -> YUI styled event
+ * delegation
+ * - d3 {selector: event-type: handlerName} -> Bound using
+ * specialized d3 event handling
+ * - yui {event-type: handlerName} -> collection of global and custom
+ * events the module reacts to.
+ **/
+
+ addModule: function(ModClassOrInstance, options) {
+ var config = options || {},
+ module = ModClassOrInstance,
+ modEvents;
+
+ if (!(ModClassOrInstance instanceof Module)) {
+ module = new ModClassOrInstance();
+ }
+ module.setAttrs({
+ component: this,
+ options: config});
+
+ this.modules[module.name] = module;
+
+ modEvents = module.events;
+ this.events[module.name] = modEvents;
+ this.bind(module.name);
+ return this;
+ },
+
+ /**
+ * @method removeModule
+ * @param {String} moduleName Module name to remove.
+ * @chainable
+ **/
+ removeModule: function(moduleName) {
+ this.unbind(moduleName);
+ delete this.events[moduleName];
+ delete this.modules[moduleName];
+ return this;
+ },
+
+ /**
+ * Internal implementation of
+ * binding both
+ * Module.events.scene and
+ * Module.events.yui.
+ **/
+ _bindEvents: function(modName) {
+ var self = this,
+ modEvents = this.events[modName],
+ module = this.modules[modName],
+ owns = Y.Object.owns,
+ container = this.get('container'),
+ subscriptions = [],
+ handlers,
+ handler;
+
+ function _bindEvent(name, handler, container, selector, context) {
+ // Adapt between d3 events and YUI delegates.
+ var d3Adaptor = function(evt) {
+ var selection = d3.select(evt.currentTarget.getDOMNode()),
+ d = selection.data()[0];
+ // This is a minor violation (extension)
+ // of the interface, but suits us well.
+ d3.event = evt;
+ return handler.call(
+ evt.currentTarget.getDOMNode(), d, context);
+ };
+
+ subscriptions.push(
+ Y.delegate(name, d3Adaptor, container, selector, context));
+ }
+
+ // Return a resolved handler object in the form
+ // {phase: str, callback: function}
+ function _normalizeHandler(handler, module, selector) {
+ var result = {};
+
+ if (L.isString(handler)) {
+ result.callback = module[handler];
+ result.phase = 'on';
+ }
+
+ if (L.isObject(handler)) {
+ result.phase = handler.phase || 'on';
+ result.callback = handler.callback;
+ }
+
+ if (L.isString(result.callback)) {
+ result.callback = module[result.callback];
+ }
+
+ if (!result.callback) {
+ console.error('No Event handler for', selector, modName);
+ return;
+ }
+ if (!L.isFunction(result.callback)) {
+ console.error('Unable to resolve a proper callback for',
+ selector, handler, modName, result);
+ return;
+ }
+ return result;
+ }
+
+ this.unbind(modName);
+
+ // Bind 'scene' events
+ Y.each(modEvents.scene, function(handlers, selector, sceneEvents) {
+ Y.each(handlers, function(handler, trigger) {
+ handler = _normalizeHandler(handler, module, selector);
+ if (L.isValue(handler)) {
+ _bindEvent(trigger, handler.callback, container, selector, self);
+ }
+ });
+ });
+
+ // Bind 'yui' custom/global subscriptions
+ // yui: {str: str_or_function}
+ // TODO {str: str/func/obj}
+ // where object includes phase (before, on, after)
+ if (modEvents.yui) {
+ // Resolve any 'string' handlers to methods on module.
+ Y.each(['after', 'before', 'on'], function(eventPhase) {
+ var resolvedHandler = {};
+ Y.each(modEvents.yui, function(handler, name) {
+ handler = _normalizeHandler(handler, module);
+ if (!handler || handler.phase !== eventPhase) {
+ return;
+ }
+ resolvedHandler[name] = handler.callback;
+ }, this);
+ // Bind resolved event handlers as a group.
+ if (Y.Object.keys(resolvedHandler).length) {
+ subscriptions.push(Y[eventPhase](resolvedHandler));
+ }
+ });
+ }
+ return subscriptions;
+ },
+
+ /**
+ * @method bind
+ *
+ * Internal. Called automatically by addModule.
+ **/
+ bind: function(moduleName) {
+ var eventSet = this.events,
+ filtered = {};
+
+ if (moduleName) {
+ filtered[moduleName] = eventSet[moduleName];
+ eventSet = filtered;
+ }
+
+ Y.each(Y.Object.keys(eventSet), function(name) {
+ this.events[name].subscriptions = this._bindEvents(name);
+ }, this);
+ return this;
+ },
+
+ /**
+ * Specialized handling of events only found in d3.
+ * This is again an internal implementation detail.
+ *
+ * Its worth noting that d3 events don't use a delegate pattern
+ * and thus must be bound to nodes present in a selection.
+ * For this reason binding d3 events happens after render cycles.
+ *
+ * @method _bindD3Events
+ * @param {String} modName Module name.
+ **/
+ _bindD3Events: function(modName) {
+ // Walk each selector for a given module 'name', doing a
+ // d3 selection and an 'on' binding.
+ var modEvents = this.events[modName],
+ owns = Y.Object.owns,
+ module;
+ if (!modEvents || !modEvents.d3) {
+ return;
+ }
+ modEvents = modEvents.d3;
+ module = this.modules[modName];
+
+ function _normalizeHandler(handler, module) {
+ if (handler && !L.isFunction(handler)) {
+ handler = module[handler];
+ }
+ return handler;
+ }
+
+ Y.each(modEvents, function(handlers, selector) {
+ Y.each(handlers, function(handler, trigger) {
+ handler = _normalizeHandler(handler, module);
+ d3.selectAll(selector).on(trigger, handler);
+ });
+ });
+ },
+
+ /**
+ * @method _unbindD3Events
+ *
+ * Internal Detail. Called by unbind automatically.
+ * D3 events follow a 'slot' like system. Setting the
+ * event to null unbinds existing handlers.
+ **/
+ _unbindD3Events: function(modName) {
+ var modEvents = this.events[modName],
+ owns = Y.Object.owns,
+ module;
+
+ if (!modEvents || !modEvents.d3) {
+ return;
+ }
+ modEvents = modEvents.d3;
+ module = this.modules[modName];
+
+ Y.each(modEvents, function(handlers, selector) {
+ Y.each(handlers, function(handler, trigger) {
+ d3.selectAll(selector).on(trigger, null);
+ });
+ });
+ },
+
+ /**
+ * @method unbind
+ * Internal. Called automatically by removeModule.
+ **/
+ unbind: function(moduleName) {
+ var eventSet = this.events,
+ filtered = {};
+
+ function _unbind(modEvents) {
+ Y.each(modEvents.subscriptions, function(handler) {
+ if (handler) {
+ handler.detach();
+ }
+ });
+ delete modEvents.subscriptions;
+ }
+
+ if (moduleName) {
+ filtered[moduleName] = eventSet[moduleName];
+ eventSet = filtered;
+ }
+ Y.each(Y.Object.values(eventSet), _unbind, this);
+ // Remove any d3 subscriptions as well.
+ this._unbindD3Events();
+
+ return this;
+ },
+
+ /**
+ * @method render
+ * @chainable
+ *
+ * Render each module bound to the canvas
+ */
+ render: function() {
+ var self = this;
+ function renderAndBind(module, name) {
+ if (module && module.render) {
+ module.render();
+ }
+ self._bindD3Events(name);
+ }
+
+ // If the container isn't bound to the DOM
+ // do so now.
+ this.attachContainer();
+ // Render modules.
+ Y.each(this.modules, renderAndBind, this);
+ return this;
+ },
+
+ /**
+ * @method attachContainer
+ * @chainable
+ *
+ * Called by render, conditionally attach container to the DOM if
+ * it isn't already. The framework calls this before module
+ * rendering so that d3 Events will have attached DOM elements. If
+ * your application doesn't need this behavior feel free to override.
+ **/
+ attachContainer: function() {
+ var container = this.get('container');
+ if (container && !container.inDoc()) {
+ Y.one('body').append(container);
+ }
+ return this;
+ },
+
+ /**
+ * @method detachContainer
+ *
+ * Remove container from DOM returning container. This
+ * is explicitly not chainable.
+ **/
+ detachContainer: function() {
+ var container = this.get('container');
+ if (container.inDoc()) {
+ container.remove();
+ }
+ return container;
+ },
+
+ /**
+ *
+ * @method update
+ * @chainable
+ *
+ * Update the data for each module
+ * see also the dataBinding event hookup
+ */
+ update: function() {
+ Y.each(Y.Object.values(this.modules), function(mod) {
+ mod.update();
+ });
+ return this;
+ }
+ }, {
+ ATTRS: {
+ container: {}
+ }
+
+ });
+ ns.Component = Component;
+}, '0.1', {
+ 'requires': ['d3',
+ 'base',
+ 'array-extras',
+ 'event']});