← Back to team overview

yellow team mailing list archive

[Merge] lp:~bcsaller/juju-gui/component-framework into lp:juju-gui

 

Benjamin Saller has proposed merging lp:~bcsaller/juju-gui/component-framework into lp:juju-gui.

Requested reviews:
  Juju GUI Hackers (juju-gui)

For more details, see:
https://code.launchpad.net/~bcsaller/juju-gui/component-framework/+merge/133391

Micro Framework for Environment View

A subclass of Component will container 1 or more modules 
which implement their own areas of application concern.

The pattern is that we can declaratively define event listeners
and respond to those across modules. For example A canvas click
might fire an application event 'clickedCanvas' which each module
can have subscribers for that do things like close menus and remove
drag lines, etc.
-- 
https://code.launchpad.net/~bcsaller/juju-gui/component-framework/+merge/133391
Your team Juju GUI Hackers is requested to review the proposed merge of lp:~bcsaller/juju-gui/component-framework into lp:juju-gui.
=== 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-08 01:50:28 +0000
@@ -0,0 +1,407 @@
+'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,
+      Module = Y.Base.create('Module', Y.Base, [], {
+        /**
+         * @property events
+         * @type {object}
+         **/
+        events: {
+          scene: {},
+          d3: {},
+          yui: {}
+        },
+
+        initializer: function(options) {
+          options = options || {};
+          this.events = options.events ?
+              Y.merge(this.events, options.events) :
+              this.events;
+        }
+      }, {
+        ATTRS: {
+          component: {},
+          container: {getter: function() {
+            return this.get('component').get('container');}}
+        }
+      });
+  ns.Module = Module;
+
+
+  var Component = Y.Base.create('Component', Y.Base, [], {
+    /**
+     * @class Component
+     *
+     * Component collections 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 = {};
+
+      // If container is changed, rebind events.
+      // this.after('containerChange', this.bind());
+    },
+
+    /**
+     * @method addModule
+     * @chainable
+     * @param {Module} a Module class. Will be created and bound to Component
+     * internally.
+     *
+     * 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) {
+      options = options || {};
+      var module = ModClassOrInstance;
+      if (!(ModClassOrInstance instanceof Module)) {
+        module = new ModClassOrInstance();
+      }
+      module.setAttrs({component: this,
+        options: options});
+
+                      this.modules[module.name] = module;
+
+                      var modEvents = module.events;
+                      this.events[module.name] = Y.clone(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,
+          phase = 'on',
+          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));
+      }
+
+      function _normalizeHandler(handler, module, selector) {
+        if (typeof handler === 'object') {
+          phase = handler.phase || 'on';
+          handler = handler.callback;
+        }
+        if (typeof handler === 'string') {
+          handler = module[handler];
+        }
+        if (!handler) {
+          console.error('No Event handler for', selector, modName);
+          return;
+        }
+        if (!L.isFunction(handler)) {
+          console.error('Unable to resolve a proper callback for',
+                        selector, handler, modName);
+          return;
+        }
+        return handler;
+      }
+
+      this.unbind(modName);
+
+      // Bind 'scene' events
+      if (modEvents.scene) {
+        for (var selector in modEvents.scene) {
+          if (owns(modEvents.scene, selector)) {
+            handlers = modEvents.scene[selector];
+            for (var name in handlers) {
+              if (owns(handlers, name)) {
+                handler = _normalizeHandler(handlers[name], module, selector);
+                if (!handler) {
+                  continue;
+                }
+                _bindEvent(name, handler,
+                           this.get('container'), selector, this);
+              }
+            }
+          }
+        }
+      }
+
+      // 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) {
+        var resolvedHandler = {};
+
+        // Resolve any 'string' handlers to methods on module.
+        Y.each(modEvents.yui, function(handler, name) {
+          handler = _normalizeHandler(handler, module);
+          if (!handler) {
+            return;
+          }
+          resolvedHandler[name] = handler;
+        }, this);
+        // Bind resolved event handlers as a group.
+        subscriptions.push(Y.on(resolvedHandler));
+      }
+      return subscriptions;
+    },
+
+    /**
+     * @method bind
+     *
+     * Internal. Called automatically by addModule.
+     **/
+    bind: function(moduleName) {
+      var eventSet = this.events;
+      if (moduleName) {
+        var filtered = {};
+        filtered[moduleName] = eventSet[moduleName];
+        eventSet = filtered;
+      }
+
+      Y.each(Y.Object.keys(eventSet), function _bind(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];
+
+      if (!modEvents || modEvents.d3 === undefined) {
+        return;
+      }
+
+      modEvents = modEvents.d3;
+      var module = this.modules[modName],
+          owns = Y.Object.owns;
+
+      var selector, kind, handler,
+          handlers, name;
+
+      for (selector in modEvents) {
+        if (owns(modEvents, selector)) {
+          handlers = modEvents[selector];
+          for (name in handlers) {
+            if (owns(handlers, name)) {
+              handler = handlers[name];
+              if (typeof handler === 'string') {
+                handler = module[handler];
+              }
+              d3.selectAll(selector).on(name, 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];
+
+      if (!modEvents || !modEvents.d3) {
+        return;
+      }
+      modEvents = modEvents.d3;
+      var module = this.modules[modName],
+          owns = Y.Object.owns;
+
+      var selector, kind, handler,
+          handlers, name;
+
+      for (selector in modEvents) {
+        if (owns(modEvents, selector)) {
+          handlers = modEvents[selector];
+          for (name in handlers) {
+            if (owns(handlers, name)) {
+              d3.selectAll(selector).on(name, null);
+            }
+          }
+        }
+      }
+    },
+
+    /**
+     * @method unbind
+     * Internal. Called automatically by removeModule.
+     **/
+    unbind: function(moduleName) {
+      var eventSet = this.events;
+      function _unbind(modEvents) {
+        Y.each(modEvents.subscriptions, function(handler) {
+          if (handler) {
+            handler.detach();
+          }
+        });
+        delete modEvents.subscriptions;
+      }
+
+      if (moduleName) {
+        var filtered = {};
+        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']});

=== modified file 'app/modules.js'
--- app/modules.js	2012-11-01 13:30:58 +0000
+++ app/modules.js	2012-11-08 01:50:28 +0000
@@ -12,6 +12,9 @@
       modules: {
         'd3': {
           'fullpath': '/juju-ui/assets/javascripts/d3.v2.min.js'
+        },
+        'd3-components': {
+          fullpath: '/juju-ui/assets/javascripts/d3-components.js'
         }
       }
     },

=== modified file 'test/index.html'
--- test/index.html	2012-11-01 13:12:28 +0000
+++ test/index.html	2012-11-08 01:50:28 +0000
@@ -15,6 +15,7 @@
     mocha.setup({'ui': 'bdd', 'ignoreLeaks': false})
   </script>
 
+  <script src="test_d3_components.js"></script>
   <script src="test_env.js"></script>
   <script src="test_model.js"></script>
   <script src="test_notifications.js"></script>

=== 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-08 01:50:28 +0000
@@ -0,0 +1,171 @@
+'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('<div id="test" style="visibility: hidden">' +
+                              '<button class="thing"></button>' +
+                              '<button class="target"></button>' +
+                              '</div>');
+    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);
+  });
+
+  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('<div id="fromA"></div>'));
+       };
+
+       modB.name = 'modb';
+       modB.render = function() {
+         this.get('container').append(Y.Node.create('<div id="fromB"></div>'));
+       };
+
+       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);
+  });
+
+});
+
+


Follow ups