From ef5d4ff4ce8fc3310e3468db24faef1d8c01f154 Mon Sep 17 00:00:00 2001 From: Maira Bello Date: Wed, 25 Feb 2015 16:32:25 -0300 Subject: [PATCH 01/10] Moves SoyComponent to soy folder --- src/{component => soy}/SoyComponent.js | 0 test/component/EventsCollector.js | 2 +- test/{component => soy}/SoyComponent.js | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/{component => soy}/SoyComponent.js (100%) rename test/{component => soy}/SoyComponent.js (97%) diff --git a/src/component/SoyComponent.js b/src/soy/SoyComponent.js similarity index 100% rename from src/component/SoyComponent.js rename to src/soy/SoyComponent.js diff --git a/test/component/EventsCollector.js b/test/component/EventsCollector.js index 2c2e1a73..40f3faf7 100644 --- a/test/component/EventsCollector.js +++ b/test/component/EventsCollector.js @@ -2,8 +2,8 @@ import {async} from '../../src/promise/Promise'; import dom from '../../src/dom/dom'; -import SoyComponent from '../../src/component/SoyComponent'; import EventsCollector from '../../src/component/EventsCollector'; +import SoyComponent from '../../src/soy/SoyComponent'; describe('EventsCollector', function() { afterEach(function() { diff --git a/test/component/SoyComponent.js b/test/soy/SoyComponent.js similarity index 97% rename from test/component/SoyComponent.js rename to test/soy/SoyComponent.js index ea20577d..31104f1f 100644 --- a/test/component/SoyComponent.js +++ b/test/soy/SoyComponent.js @@ -1,7 +1,7 @@ 'use strict'; import {async} from '../../src/promise/Promise'; -import SoyComponent from '../../src/component/SoyComponent'; +import SoyComponent from '../../src/soy/SoyComponent'; describe('SoyComponent', function() { afterEach(function() { From 2b1df5f0fc9ef3d2ea59deca66d120b1c178b2e2 Mon Sep 17 00:00:00 2001 From: Maira Bello Date: Wed, 25 Feb 2015 18:50:58 -0300 Subject: [PATCH 02/10] Adds ComponentRegistry --- src/component/ComponentRegistry.js | 47 +++++++++++++++++++++++++++++ test/component/ComponentRegistry.js | 24 +++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/component/ComponentRegistry.js create mode 100644 test/component/ComponentRegistry.js diff --git a/src/component/ComponentRegistry.js b/src/component/ComponentRegistry.js new file mode 100644 index 00000000..ed85e850 --- /dev/null +++ b/src/component/ComponentRegistry.js @@ -0,0 +1,47 @@ +'use strict'; + +/** + * The component registry is used to register components, so they can + * be accessible by name. + * @type {Object} + */ +class ComponentRegistry { + /** + * Gets the constructor function for the given component name, or + * undefined if it hasn't been registered yet. + * @param {string} name The component's name. + * @return {?function} + * @static + */ + static getConstructor(name) { + return ComponentRegistry.components_[name]; + } + + /** + * Registers a component. + * @param {string} name The component's name. + * @param {string} constructorFn The component's constructor function. + * @static + */ + static register(name, constructorFn) { + ComponentRegistry.components_[name] = constructorFn; + } +} + +/** + * Holds all registered components, indexed by their names. + * @type {!Object} + * @protected + * @static + */ +ComponentRegistry.components_ = {}; + +/** + * Holds all registered component templates, indexed by component names. + * Soy files automatically add their templates to this object when imported. + * @type {!Object>} + * @static + */ +ComponentRegistry.Templates = {}; + +export default ComponentRegistry; diff --git a/test/component/ComponentRegistry.js b/test/component/ComponentRegistry.js new file mode 100644 index 00000000..fb3188a2 --- /dev/null +++ b/test/component/ComponentRegistry.js @@ -0,0 +1,24 @@ +'use strict'; + +import ComponentRegistry from '../../src/component/ComponentRegistry'; + +describe('ComponentRegistry', function() { + it('should return undefined for getting constructor of unregistered component', function() { + assert.ok(!ComponentRegistry.getConstructor('UnregisteredComponent')); + }); + + it('should return constructor of registered components', function() { + class MyComponent1 {} + class MyComponent2 {} + + ComponentRegistry.register('MyComponent1', MyComponent1); + ComponentRegistry.register('MyComponent2', MyComponent2); + + assert.strictEqual(MyComponent1, ComponentRegistry.getConstructor('MyComponent1')); + assert.strictEqual(MyComponent2, ComponentRegistry.getConstructor('MyComponent2')); + }); + + it('should store templates', function() { + assert.strictEqual('object', typeof ComponentRegistry.Templates); + }); +}); From fdeb68ec6bd8959956dcabc31ecd9a72f8222f97 Mon Sep 17 00:00:00 2001 From: Maira Bello Date: Wed, 25 Feb 2015 15:54:19 -0300 Subject: [PATCH 03/10] Adds soy template for rendering other soy components --- .jshintrc | 3 +- gulpfile.js | 1 + karma.conf.js | 6 ++-- package.json | 3 +- src/soy/SoyComponent.js | 2 ++ src/soy/SoyComponent.soy | 23 ++++++++++++++ src/soy/SoyComponent.soy.js | 45 ++++++++++++++++++++++++++++ test/soy/SoyComponent.soy.js | 58 ++++++++++++++++++++++++++++++++++++ 8 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 src/soy/SoyComponent.soy create mode 100644 src/soy/SoyComponent.soy.js create mode 100644 test/soy/SoyComponent.soy.js diff --git a/.jshintrc b/.jshintrc index fe49183b..a8e42e5e 100644 --- a/.jshintrc +++ b/.jshintrc @@ -8,7 +8,8 @@ "evil": false, "forin": false, "globals": { - "lfr": true + "lfr": true, + "soy": true }, "immed": true, "indent": 2, diff --git a/gulpfile.js b/gulpfile.js index 1c0508a0..d0d1efa4 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -5,5 +5,6 @@ var registerTasks = require('alloyui-tasks'); registerTasks({ bundleFileName: 'aui.js', + corePathFromSoy: '../', pkg: pkg }); diff --git a/karma.conf.js b/karma.conf.js index 572506b8..89239b28 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -6,11 +6,13 @@ module.exports = function(config) { jspm: { // ES6 files need to go through jspm for module loading. - loadFiles: ['src/**/*.js', 'test/**/*.js'] + loadFiles: ['test/**/*.js'], + serveFiles: ['src/**/*.js'] }, files: [ - 'test/html/fixture/*.html', + 'node_modules/closure-templates/soyutils.js', + 'test/html/fixture/*.html' ], preprocessors: { diff --git a/package.json b/package.json index 2d86e69f..bb8d1d45 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ }, "keywords": [], "devDependencies": { - "alloyui-tasks": "git://github.com/alloyui/tasks", + "alloyui-tasks": "git://github.com/mairatma/alloyui-tasks#templates", + "closure-templates": "^20141017.0.0", "gulp": "^3.8.11", "isparta": "git://github.com/douglasduteil/isparta", "karma": "^0.12.31", diff --git a/src/soy/SoyComponent.js b/src/soy/SoyComponent.js index e8e12747..de55f7c8 100644 --- a/src/soy/SoyComponent.js +++ b/src/soy/SoyComponent.js @@ -6,6 +6,8 @@ import object from '../object/object'; import Component from '../component/Component'; import EventsCollector from '../component/EventsCollector'; +import './SoyComponent.soy'; + /** * Special Component class that handles a better integration between soy templates * and the components. It allows for automatic rendering of surfaces that have soy diff --git a/src/soy/SoyComponent.soy b/src/soy/SoyComponent.soy new file mode 100644 index 00000000..737c4d94 --- /dev/null +++ b/src/soy/SoyComponent.soy @@ -0,0 +1,23 @@ +{namespace Templates.SoyComponent} + +/** + * @param data + * @param name + * @param ref + */ +{template .component} +
+ {if $ij.renderChildComponents} + {delcall ComponentElement data="$data" variant="$name" /} + {/if} +
+{/template} + +/** + * @param data + * @param name + * @param ref + */ +{deltemplate Component} + {call Templates.SoyComponent.component data="all" /} +{/deltemplate} diff --git a/src/soy/SoyComponent.soy.js b/src/soy/SoyComponent.soy.js new file mode 100644 index 00000000..b6642933 --- /dev/null +++ b/src/soy/SoyComponent.soy.js @@ -0,0 +1,45 @@ +/* jshint ignore:start */ +import ComponentRegistry from '../component/ComponentRegistry'; +var Templates = ComponentRegistry.Templates; +// This file was automatically generated from SoyComponent.soy. +// Please don't edit this file by hand. + +/** + * @fileoverview Templates in namespace Templates.SoyComponent. + * @hassoydeltemplate {Component} + * @hassoydelcall {ComponentElement} + */ + +if (typeof Templates.SoyComponent == 'undefined') { Templates.SoyComponent = {}; } + + +/** + * @param {Object.=} opt_data + * @param {(null|undefined)=} opt_ignored + * @param {Object.=} opt_ijData + * @return {!soydata.SanitizedHtml} + * @suppress {checkTypes} + */ +Templates.SoyComponent.component = function(opt_data, opt_ignored, opt_ijData) { + return soydata.VERY_UNSAFE.ordainSanitizedHtml('
' + ((opt_ijData.renderChildComponents) ? soy.$$escapeHtml(soy.$$getDelegateFn(soy.$$getDelTemplateId('ComponentElement'), opt_data.name, true)(opt_data.data, null, opt_ijData)) : '') + '
'); +}; +if (goog.DEBUG) { + Templates.SoyComponent.component.soyTemplateName = 'Templates.SoyComponent.component'; +} + + +/** + * @param {Object.=} opt_data + * @param {(null|undefined)=} opt_ignored + * @param {Object.=} opt_ijData + * @return {!soydata.SanitizedHtml} + * @suppress {checkTypes} + */ +Templates.SoyComponent.__deltemplate_s12_0084916f = function(opt_data, opt_ignored, opt_ijData) { + return soydata.VERY_UNSAFE.ordainSanitizedHtml(Templates.SoyComponent.component(opt_data, null, opt_ijData)); +}; +if (goog.DEBUG) { + Templates.SoyComponent.__deltemplate_s12_0084916f.soyTemplateName = 'Templates.SoyComponent.__deltemplate_s12_0084916f'; +} +soy.$$registerDelegateFn(soy.$$getDelTemplateId('Component'), '', 0, Templates.SoyComponent.__deltemplate_s12_0084916f); +/* jshint ignore:end */ diff --git a/test/soy/SoyComponent.soy.js b/test/soy/SoyComponent.soy.js new file mode 100644 index 00000000..66f5ee76 --- /dev/null +++ b/test/soy/SoyComponent.soy.js @@ -0,0 +1,58 @@ +'use strict'; + +import dom from '../../src/dom/dom'; +import ComponentRegistry from '../../src/component/ComponentRegistry'; + +import '../../src/soy/SoyComponent.soy'; + +var Templates = ComponentRegistry.Templates; + +describe('SoyComponent.soy', function() { + before(function() { + sinon.stub(soy, '$$getDelegateFn').returns(function(data) { + return 'My Template ' + data.foo; + }); + }); + + after(function() { + soy.$$getDelegateFn.restore(); + }); + + it('should store component template', function() { + assert.ok(Templates.SoyComponent); + }); + + it('should render component wrapper', function() { + var rendered = Templates.SoyComponent.component({ + name: 'MyComponent', + data: { + foo: 'Foo' + }, + ref: 'ref' + }, null, {}); + + var element = dom.buildFragment(rendered.content); + var wrapper = element.querySelector('[data-component]'); + + assert.ok(wrapper); + assert.strictEqual('MyComponent', wrapper.getAttribute('data-component')); + assert.strictEqual('ref', wrapper.getAttribute('data-ref')); + assert.strictEqual('', wrapper.innerHTML); + }); + + it('should render component template inside wrapper if renderChildComponents is true', function() { + var rendered = Templates.SoyComponent.component({ + name: 'MyComponent', + data: { + foo: 'Foo' + }, + parentId: 'parentId', + ref: 'ref' + }, null, {renderChildComponents: true}); + + var element = dom.buildFragment(rendered.content); + var wrapper = element.querySelector('[data-component]'); + + assert.strictEqual('My Template Foo', wrapper.innerHTML); + }); +}); From 99f123d775d4833fc4dd07abac98f10dde333476 Mon Sep 17 00:00:00 2001 From: Maira Bello Date: Wed, 4 Mar 2015 15:17:12 -0300 Subject: [PATCH 04/10] Adds DomVisitor --- src/dom/DomVisitor.js | 110 +++++++++++++++++++++++++++++++++++++++++ test/dom/DomVisitor.js | 96 +++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 src/dom/DomVisitor.js create mode 100644 test/dom/DomVisitor.js diff --git a/src/dom/DomVisitor.js b/src/dom/DomVisitor.js new file mode 100644 index 00000000..127afda4 --- /dev/null +++ b/src/dom/DomVisitor.js @@ -0,0 +1,110 @@ +'use strict'; + +import Disposable from '../disposable/Disposable'; + +/** + * `DomVisitor` traverses an element's dom tree, running all + * registered handlers for each visited node. + */ +class DomVisitor extends Disposable { + /** + * Constructor for `DomVisitor`. + * @param {!Element} element Element that should be traversed. + * @constructor + */ + constructor(element) { + /** + * The element that should be traversed by this `DomVisitor`. + * @type {!Element} + * @protected + */ + this.element_ = element; + + /** + * Holds the handler functions that should be run for each traversed + * element. + * @type {!Array} + * @protected + */ + this.handlers_ = []; + + /** + * Holds the data that should be passed for the first call of each + * added handler. + * @type {!Array} + * @protected + */ + this.initialData_ = []; + } + + /** + * Creates a new `DomVisitor` instance for the given element + * and returns it. + * @param {!Element} element + * @return {DomVisitor} + */ + static visit(element) { + return new DomVisitor(element); + } + + /** + * Adds a function that should be run for each visited node. + * @param {!function(!Element)} handler + * @param {*} initialData The data to pass for the first time this + * handler is called. + * @chainable + */ + addHandler(handler, initialData) { + this.handlers_.push(handler); + this.initialData_.push(initialData); + return this; + } + + /** + * @inheritDoc + */ + disposeInternal() { + this.element_ = null; + this.handlers_ = null; + this.initialData_ = null; + } + + /** + * Runs all the handlers for the given element. + * @param {!Element} element + * @param {Array<*>} handlerData + * @protected + */ + runHandlers_(element, handlerData) { + handlerData = handlerData || []; + var newHandlerData = []; + this.handlers_.forEach(function(handler, index) { + newHandlerData.push(handler(element, handlerData[index])); + }); + return newHandlerData; + } + + /** + * Starts the visit. + * @chainable + */ + start() { + this.visit_(this.element_, this.initialData_); + return this; + } + + /** + * Visits the given element and its children. + * @param {!Element} element + * @param {Array<*>} handlerData An array of data objects + * @protected + */ + visit_(element, handlerData) { + var newHandlerData = this.runHandlers_(element, handlerData); + for (var i = 0; i < element.childNodes.length; i++) { + this.visit_(element.childNodes[i], newHandlerData); + } + } +} + +export default DomVisitor; diff --git a/test/dom/DomVisitor.js b/test/dom/DomVisitor.js new file mode 100644 index 00000000..e2ed421d --- /dev/null +++ b/test/dom/DomVisitor.js @@ -0,0 +1,96 @@ +'use strict'; + +import dom from '../../src/dom/dom'; +import DomVisitor from '../../src/dom/DomVisitor'; + +describe('DomVisitor', function() { + it('should return DomVisitor instance when DomVisitor.visit is called', function() { + var element = document.createElement('div'); + assert.ok(DomVisitor.visit(element) instanceof DomVisitor); + }); + + it('should run added handler for each element in dom tree', function() { + var element = document.createElement('div'); + var child1 = document.createElement('div'); + var child2 = document.createElement('div'); + var child3 = document.createElement('div'); + dom.append(element, child1); + dom.append(element, child2); + dom.append(child2, child3); + + var handler = sinon.stub(); + + DomVisitor.visit(element) + .addHandler(handler) + .start(); + + assert.strictEqual(4, handler.callCount); + assert.strictEqual(element, handler.args[0][0]); + assert.strictEqual(child1, handler.args[1][0]); + assert.strictEqual(child2, handler.args[2][0]); + assert.strictEqual(child3, handler.args[3][0]); + }); + + it('should run added handlers in the order they were added', function() { + var element = document.createElement('div'); + + var handler1 = sinon.stub(); + var handler2 = sinon.stub(); + + DomVisitor.visit(element) + .addHandler(handler1) + .addHandler(handler2) + .start(); + + handler1.calledBefore(handler2); + }); + + it('should pass requested initial data to handlers when called for the first time', function() { + var element = document.createElement('div'); + var child1 = document.createElement('div'); + dom.append(element, child1); + + var handler1 = sinon.stub(); + var handler2 = sinon.stub(); + + DomVisitor.visit(element) + .addHandler(handler1, 1) + .addHandler(handler2, 2) + .start(); + + assert.strictEqual(1, handler1.args[0][1]); + assert.strictEqual(undefined, handler1.args[1][1]); + assert.strictEqual(2, handler2.args[0][1]); + assert.strictEqual(undefined, handler2.args[1][1]); + }); + + it('should pass data returned from handlers down the tree', function() { + var element = document.createElement('div'); + var child1 = document.createElement('div'); + dom.append(element, child1); + + var handler1 = sinon.stub().returns(10); + var handler2 = sinon.stub().returns(20); + + DomVisitor.visit(element) + .addHandler(handler1, 1) + .addHandler(handler2, 2) + .start(); + + assert.strictEqual(1, handler1.args[0][1]); + assert.strictEqual(10, handler1.args[1][1]); + assert.strictEqual(2, handler2.args[0][1]); + assert.strictEqual(20, handler2.args[1][1]); + }); + + it('should throw error if trying to start visit after being disposed', function() { + var element = document.createElement('div'); + + var visitor = DomVisitor.visit(element).addHandler(sinon.stub()); + visitor.dispose(); + + assert.throws(function() { + visitor.start(); + }); + }); +}); From 5e94e55b4dfe1234db70ccc2a1108103a6ce85e3 Mon Sep 17 00:00:00 2001 From: Maira Bello Date: Wed, 4 Mar 2015 17:03:17 -0300 Subject: [PATCH 05/10] Makes EventsCollector work with DomVisitor --- src/component/EventsCollector.js | 78 ++++++++++++++----------------- src/soy/SoyComponent.js | 15 ++++-- test/component/EventsCollector.js | 2 +- 3 files changed, 47 insertions(+), 48 deletions(-) diff --git a/src/component/EventsCollector.js b/src/component/EventsCollector.js index 53388a63..c69132fe 100644 --- a/src/component/EventsCollector.js +++ b/src/component/EventsCollector.js @@ -37,88 +37,80 @@ class EventsCollector extends Disposable { } /** - * Attaches a list of collected events to an element. - * @param {!Array} events List of collected events which should be attached. + * Attaches all listeners declared in the collected events array. + * @param {!Array} collectedEvents + * @param {string} groupName + * @protected */ - attachListeners_(collectedEvents) { + attachCollectedListeners_(collectedEvents, groupName) { for (var i = 0; i < collectedEvents.length; i++) { var event = collectedEvents[i]; - if (!this.eventHandler_[event.group]) { - this.eventHandler_[event.group] = new EventHandler(); + if (!this.eventHandler_[groupName]) { + this.eventHandler_[groupName] = new EventHandler(); } - this.eventHandler_[event.group].add( + this.eventHandler_[groupName].add( this.component_.delegate(event.name, event.element, this.component_[event.value].bind(this.component_)) ); } } /** - * Visits all `rootElement` children and collects inline events into - * `groupName`. For each found surface element a new group element will be - * created. + * Attaches all listeners declared as attributes on the given element. + * @param {Element} element * @param {String} groupName - * @param {Element} rootElement - * @chainable - */ - collect(groupName, rootElement) { - var collectedEvents = []; - this.collectInlineEvents_(groupName, rootElement, collectedEvents); - this.attachListeners_(collectedEvents); - return this; - } - - /** - * Collects all events from a document element and its children. - * TODO(*): Analyzes performance. - * @param {Element} element The element from which the events should be - * extracted. - * @param {!Array} collectedEvents List of collected events. - * @return {Array} The collected list of events. */ - collectInlineEvents_(groupName, rootElement, collectedEvents) { - for (var i = 0; i < rootElement.childNodes.length; i++) { - this.collectInlineEvents_(groupName, rootElement.childNodes[i], collectedEvents); + attachListeners(element, groupName) { + groupName = groupName || element.id || this.component_.id; + var collectedEvents = this.collectInlineEventsFromAttributes_(element); + this.attachCollectedListeners_(collectedEvents, groupName); + if (element.id && this.component_.extractSurfaceId_(element.id)) { + groupName = element.id; } - this.collectInlineEventsFromAttributes_(groupName, rootElement, collectedEvents); + return groupName; } /** * Processes the attribute of an element and stores the found attribute * events to an array. - * TODO(*): Analyzes performance. + * TODO(*): Analyze performance. * @param {Element} element The element which should be processed. + * @param {!Object} attribute + * @return {Object} An objects that represents an event that should be + * attached to this element. * @protected */ - collectInlineEventsFromAttribute_(groupName, element, attribute, collectedEvents) { + collectInlineEventFromAttribute_(element, attribute) { var event = attribute.name.substring(2); if ((attribute.name.indexOf('on') === 0) && dom.supportsEvent(element, event)) { - var surfaceId = this.component_.extractSurfaceId_(element.id); - if (surfaceId) { - groupName = element.id; - } - collectedEvents.push({ - group: groupName, + element.removeAttribute(attribute.name); + return { element: element, name: event, value: attribute.value - }); - element.removeAttribute(attribute.name); + }; } } /** * Processes the attributes of an element and stores the found attribute * events to an array. - * TODO(*): Analyzes performance. + * TODO(*): Analyze performance. * @param {Element} element The element which should be processed. + * @return {!Array} An array with objects that represent each an + * event that should be attached to this element. * @protected */ - collectInlineEventsFromAttributes_(groupName, element, collectedEvents) { + collectInlineEventsFromAttributes_(element) { + var collectedEvents = []; if (element.attributes) { for (var i = element.attributes.length - 1; i >= 0; i--) { - this.collectInlineEventsFromAttribute_(groupName, element, element.attributes[i], collectedEvents); + var eventObj = this.collectInlineEventFromAttribute_(element, element.attributes[i]); + if (eventObj) { + collectedEvents.push(eventObj); + } } } + return collectedEvents; } /** diff --git a/src/soy/SoyComponent.js b/src/soy/SoyComponent.js index de55f7c8..857b3b59 100644 --- a/src/soy/SoyComponent.js +++ b/src/soy/SoyComponent.js @@ -4,6 +4,7 @@ import core from '../core'; import dom from '../dom/dom'; import object from '../object/object'; import Component from '../component/Component'; +import DomVisitor from '../dom/DomVisitor'; import EventsCollector from '../component/EventsCollector'; import './SoyComponent.soy'; @@ -36,8 +37,11 @@ class SoyComponent extends Component { * @override */ attach(opt_parentElement, opt_siblingElement) { - this.getEventsCollector_().detachAllListeners(); - this.getEventsCollector_().collect(this.element.id, this.element); + var eventsCollector = this.getEventsCollector_(); + eventsCollector.detachAllListeners(); + DomVisitor.visit(this.element) + .addHandler(eventsCollector.attachListeners.bind(eventsCollector)) + .start(); super.attach(opt_parentElement, opt_siblingElement); return this; } @@ -115,8 +119,11 @@ class SoyComponent extends Component { var frag = dom.buildFragment(content); if (this.inDocument) { var elementId = this.makeSurfaceId_(surfaceId); - this.getEventsCollector_().detachListeners(elementId); - this.getEventsCollector_().collect(elementId, frag); + var eventsCollector = this.getEventsCollector_(); + eventsCollector.detachListeners(elementId); + DomVisitor.visit(frag) + .addHandler(eventsCollector.attachListeners.bind(eventsCollector)) + .start(); } super.replaceSurfaceContent_(surfaceId, frag); } diff --git a/test/component/EventsCollector.js b/test/component/EventsCollector.js index 40f3faf7..602a17bb 100644 --- a/test/component/EventsCollector.js +++ b/test/component/EventsCollector.js @@ -55,7 +55,7 @@ describe('EventsCollector', function() { dom.triggerEvent(innerButton, 'click'); dom.triggerEvent(innerButton, 'mousedown'); - assert.ok(custom.handleClick.calledThrice, 'Click on parent element should trigger click event'); + assert.strictEqual(4, custom.handleClick.callCount, 'Click on parent element should trigger click event'); assert.ok(custom.handleButtonMousedown.calledOnce, 'Click on parent element should trigger click event'); custom.dispose(); From 02e3bb48bd9c8e65cacdf1325aac46c1d10d4d3d Mon Sep 17 00:00:00 2001 From: Maira Bello Date: Thu, 5 Mar 2015 12:23:56 -0300 Subject: [PATCH 06/10] Adds ComponentCollector --- src/component/ComponentCollector.js | 90 ++++++++++++++++++++++++++++ test/component/ComponentCollector.js | 89 +++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 src/component/ComponentCollector.js create mode 100644 test/component/ComponentCollector.js diff --git a/src/component/ComponentCollector.js b/src/component/ComponentCollector.js new file mode 100644 index 00000000..2ce925f3 --- /dev/null +++ b/src/component/ComponentCollector.js @@ -0,0 +1,90 @@ +'use strict'; + +import ComponentRegistry from '../component/ComponentRegistry'; +import Disposable from '../disposable/Disposable'; + +class ComponentCollector extends Disposable { + constructor() { + super(); + + /** + * Holds the extracted components, indexed by ref. + * @type {!Object} + * @protected + */ + this.components_ = {}; + } + + /** + * Creates a child component and renders it inside the specified parent. + * @param {string} ref The component ref. + * @param {string} name The component name. + * @param {!Object} data The component config data. + * @param {Element} parent The component's parent element. + * @protected + */ + createComponent_(ref, name, data, parent) { + var ConstructorFn = ComponentRegistry.getConstructor(name); + this.components_[ref] = new ConstructorFn(data).render(parent.parentNode, parent); + parent.parentNode.removeChild(parent); + } + + /** + * Gets all the extracted components. + * @return {!Array} + */ + getComponents() { + return this.components_; + } + + /** + * Extracts a component from the given element. + * @param {!Element} element Element that represents a component that should + * be extracted. + * @param {!Object} data Creation data for the component to be extracted. + * @protected + */ + extractComponent_(element, data) { + if (this.components_[data.ref]) { + this.updateComponent_(data.ref, data.data, element); + } else { + this.createComponent_(data.ref, data.name, data.data, element); + } + } + + /** + * Extracts components from the given element. + * @param {!Element} element + * @param {!Object} componentData An object with creation + * data for components that may be found inside the element, indexed by + * their ref strings. + * @return {!Object} The original `componentData` object. + */ + extractComponents(element, componentData) { + if (element.hasAttribute && element.hasAttribute('data-component')) { + var ref = element.getAttribute('data-ref'); + var data = componentData[ref]; + if (data) { + this.extractComponent_(element, data); + } + } + + return componentData; + } + + /** + * Updates a child component's data and parent. + * @param {string} ref The component's ref. + * @param {!Object} data The component's data. + * @param {Element} parent The component's parent element. + * @protected + */ + updateComponent_(ref, data, parent) { + var component = this.components_[ref]; + parent.parentNode.insertBefore(component.element, parent); + parent.parentNode.removeChild(parent); + component.setAttrs(data); + } +} + +export default ComponentCollector; diff --git a/test/component/ComponentCollector.js b/test/component/ComponentCollector.js new file mode 100644 index 00000000..a39ed9d8 --- /dev/null +++ b/test/component/ComponentCollector.js @@ -0,0 +1,89 @@ +'use strict'; + +import dom from '../../src/dom/dom'; +import Component from '../../src/component/Component'; +import ComponentRegistry from '../../src/component/ComponentRegistry'; +import ComponentCollector from '../../src/component/ComponentCollector'; + +class TestComponent extends Component { + constructor(opt_config) { + super(opt_config); + } +} +ComponentRegistry.register('TestComponent', TestComponent); +TestComponent.ATTRS = { + bar: {} +}; + +describe('ComponentCollector', function() { + it('should not create components on element without data-component attribute', function() { + var element = document.createElement('div'); + + var collector = new ComponentCollector(); + collector.extractComponents(element, {}); + + assert.deepEqual({}, collector.getComponents()); + }); + + it('should not create components on element without their creation data', function() { + var element = document.createElement('div'); + element.setAttribute('data-component', true); + element.setAttribute('data-ref', 'comp'); + + var collector = new ComponentCollector(); + collector.extractComponents(element, {}); + + assert.deepEqual({}, collector.getComponents()); + }); + + it('should instantiate extracted components', function() { + var parent = document.createElement('div'); + var element = document.createElement('div'); + element.setAttribute('data-component', true); + element.setAttribute('data-ref', 'comp'); + dom.append(parent, element); + + var collector = new ComponentCollector(); + var creationData = { + data: { + bar: 1 + }, + name: 'TestComponent', + ref: 'comp' + }; + collector.extractComponents(element, {comp: creationData}); + + var components = collector.getComponents(); + assert.strictEqual(1, Object.keys(components).length); + assert.ok(components.comp instanceof TestComponent); + assert.strictEqual(1, components.comp.bar); + }); + + it('should update extracted component instances', function() { + var parent = document.createElement('div'); + var element = document.createElement('div'); + element.setAttribute('data-component', true); + element.setAttribute('data-ref', 'comp'); + dom.append(parent, element); + + var collector = new ComponentCollector(); + var creationData = { + data: { + bar: 1 + }, + name: 'TestComponent', + ref: 'comp' + }; + collector.extractComponents(element, {comp: creationData}); + + parent.innerHTML = ''; + dom.append(parent, element); + creationData.data.bar = 2; + collector.extractComponents(element, {comp: creationData}); + + var components = collector.getComponents(); + assert.strictEqual(1, Object.keys(components).length); + assert.ok(components.comp instanceof TestComponent); + assert.strictEqual(2, components.comp.bar); + }); +}); From 31da845f60549fa416c95f29172ca1a1705401fc Mon Sep 17 00:00:00 2001 From: Maira Bello Date: Wed, 25 Feb 2015 18:59:16 -0300 Subject: [PATCH 07/10] Allows defining nested components through soy --- src/component/Component.js | 6 +- src/soy/SoyComponent.js | 115 +++++++++++++++++++++++++++++++------ test/soy/SoyComponent.js | 111 +++++++++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 19 deletions(-) diff --git a/src/component/Component.js b/src/component/Component.js index 8bc179de..e9f7df6b 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -649,10 +649,10 @@ class Component extends Attribute { var surface = this.getSurface(surfaceId); var cacheState = this.computeSurfaceCacheState_(content); - if (cacheState === Component.Cache.NOT_INITIALIZED || + surface.cacheMiss = cacheState === Component.Cache.NOT_INITIALIZED || cacheState === Component.Cache.NOT_CACHEABLE || - cacheState !== surface.cacheState) { - + cacheState !== surface.cacheState; + if (surface.cacheMiss) { this.replaceSurfaceContent_(surfaceId, content); } surface.cacheState = cacheState; diff --git a/src/soy/SoyComponent.js b/src/soy/SoyComponent.js index 857b3b59..b30a560a 100644 --- a/src/soy/SoyComponent.js +++ b/src/soy/SoyComponent.js @@ -4,11 +4,20 @@ import core from '../core'; import dom from '../dom/dom'; import object from '../object/object'; import Component from '../component/Component'; +import ComponentCollector from '../component/ComponentCollector'; +import ComponentRegistry from '../component/ComponentRegistry'; import DomVisitor from '../dom/DomVisitor'; import EventsCollector from '../component/EventsCollector'; import './SoyComponent.soy'; +/** + * We need to listen to calls to the SoyComponent template so we can use them to + * properly instantiate and update child components defined through soy. + * TODO: Switch to using proper AOP. + */ +var originalTemplate = ComponentRegistry.Templates.SoyComponent.component; + /** * Special Component class that handles a better integration between soy templates * and the components. It allows for automatic rendering of surfaces that have soy @@ -22,13 +31,35 @@ class SoyComponent extends Component { constructor(opt_config) { super(opt_config); + /** + * Holds a `ComponentCollector` that will extract inner components. + * @type {!ComponentCollector} + * @protected + */ + this.componentCollector_ = new ComponentCollector(); + + /** + * Holds the this component's child components. + * @type {!Object} + * @protected + */ + this.components_ = {}; + /** * Holds events that were listened through the element. - * @type {EventHandler} + * @type {!EventHandler} * @protected */ this.eventsCollector_ = null; + /** + * Stores the arguments that were passed to the last call to the SoyComponent + * template for each component instance (mapped by its ref). + * @type {!Object} + * @protected + */ + this.lastComponentTemplateCall_ = {}; + core.mergeSuperClassesProperty(this.constructor, 'TEMPLATES', this.mergeTemplates_); } @@ -39,9 +70,16 @@ class SoyComponent extends Component { attach(opt_parentElement, opt_siblingElement) { var eventsCollector = this.getEventsCollector_(); eventsCollector.detachAllListeners(); + + var extractComponents = this.componentCollector_.extractComponents.bind(this.componentCollector_); DomVisitor.visit(this.element) .addHandler(eventsCollector.attachListeners.bind(eventsCollector)) + .addHandler(extractComponents, this.lastComponentTemplateCall_) .start(); + + this.components_ = this.componentCollector_.getComponents(); + this.lastComponentTemplateCall_ = {}; + super.attach(opt_parentElement, opt_siblingElement); return this; } @@ -78,13 +116,22 @@ class SoyComponent extends Component { getSurfaceContent_(surfaceId) { var surfaceTemplate = this.constructor.TEMPLATES_MERGED[surfaceId]; if (core.isFunction(surfaceTemplate)) { - return surfaceTemplate(this).content; - } - else { + return this.renderTemplate_(surfaceTemplate); + } else { return super.getSurfaceContent_(surfaceId); } } + /** + * Handles a call to the SoyComponent template. + * @param {!Object} data The data the template was called with. + * @return {string} The original return value of the template. + */ + handleTemplateCall_(data) { + this.lastComponentTemplateCall_[data.ref] = data; + return originalTemplate.apply(originalTemplate, arguments); + } + /** * Merges an array of values for the `TEMPLATES` property into a single object. * @param {!Array} values The values to be merged. @@ -98,34 +145,70 @@ class SoyComponent extends Component { /** * Overrides the behavior of this method to automatically render the element * template if it's defined and to automatically attach listeners to all - * specified events by the user in the template. + * specified events by the user in the template. Also handles any calls to + * component templates. * @override */ renderInternal() { var elementTemplate = this.constructor.TEMPLATES_MERGED.element; if (core.isFunction(elementTemplate)) { - dom.append(this.element, elementTemplate(this).content); + dom.append(this.element, this.renderTemplate_(elementTemplate)); } } /** - * Replaces the content of a surface with a new one. + * Overrides the default behavior of `renderSurfaceContent` to also + * handle calls to component templates done by the surface's template. * @param {string} surfaceId The surface id. * @param {Object|string} content The content to be rendered. - * @protected * @override */ - replaceSurfaceContent_(surfaceId, content) { - var frag = dom.buildFragment(content); + renderSurfaceContent(surfaceId, content) { + super.renderSurfaceContent(surfaceId, content); + if (this.inDocument) { - var elementId = this.makeSurfaceId_(surfaceId); var eventsCollector = this.getEventsCollector_(); - eventsCollector.detachListeners(elementId); - DomVisitor.visit(frag) - .addHandler(eventsCollector.attachListeners.bind(eventsCollector)) - .start(); + eventsCollector.detachListeners(this.makeSurfaceId_(surfaceId)); + + var visitor = DomVisitor.visit(this.getSurfaceElement(surfaceId)) + .addHandler(eventsCollector.attachListeners.bind(eventsCollector)); + + if (this.getSurface(surfaceId).cacheMiss) { + visitor.addHandler( + this.componentCollector_.extractComponents.bind(this.componentCollector_), + this.lastComponentTemplateCall_ + ); + } else { + this.updateComponents_(); + } + this.lastComponentTemplateCall_ = {}; + + visitor.start(); + this.components_ = this.componentCollector_.getComponents(); + } + } + + /** + * Renders the specified template. + * @param {!function()} templateFn [description] + * @return {string} The template's result content. + */ + renderTemplate_(templateFn) { + ComponentRegistry.Templates.SoyComponent.component = this.handleTemplateCall_.bind(this); + var content = templateFn(this, null, {}).content; + ComponentRegistry.Templates.SoyComponent.component = originalTemplate; + return content; + } + + /** + * Updates all inner components with their last template call data. + * @protected + */ + updateComponents_() { + for (var ref in this.lastComponentTemplateCall_) { + var data = this.lastComponentTemplateCall_[ref]; + this.components_[data.ref].setAttrs(data.data); } - super.replaceSurfaceContent_(surfaceId, frag); } } diff --git a/test/soy/SoyComponent.js b/test/soy/SoyComponent.js index 31104f1f..dae7e469 100644 --- a/test/soy/SoyComponent.js +++ b/test/soy/SoyComponent.js @@ -1,8 +1,11 @@ 'use strict'; import {async} from '../../src/promise/Promise'; +import ComponentRegistry from '../../src/component/ComponentRegistry'; import SoyComponent from '../../src/soy/SoyComponent'; +var SoyTemplates = ComponentRegistry.Templates.SoyComponent; + describe('SoyComponent', function() { afterEach(function() { document.body.innerHTML = ''; @@ -91,6 +94,114 @@ describe('SoyComponent', function() { }); }); + describe('Child Components', function() { + beforeEach(function() { + var ChildComponent = createCustomComponentClass(); + ComponentRegistry.register('ChildComponent', ChildComponent); + ChildComponent.ATTRS = { + bar: { + value: '' + } + }; + + var CustomComponent = createCustomComponentClass(); + CustomComponent.ATTRS = { + count: { + value: 1 + }, + foo: { + value: 'bar' + } + }; + CustomComponent.SURFACES = { + component: { + renderAttrs: ['foo', 'count'] + } + }; + CustomComponent.TEMPLATES = { + element: function(data) { + return { + content: '
' + }; + }, + component: function(data) { + var result = {content: ''}; + for (var i = 0; i < data.count; i++) { + var childData = { + data: {bar: data.foo}, + name: 'ChildComponent', + parentId: data.id, + ref: 'myChild' + i + }; + result.content += SoyTemplates.component(childData, null, {}); + } + return result; + } + }; + + this.ChildComponent = ChildComponent; + this.CustomComponent = CustomComponent; + }); + + it('should instantiate rendered child component', function() { + var custom = new this.CustomComponent(); + custom.render(); + + var child = custom.components_.myChild0; + assert.ok(child); + assert.strictEqual(this.ChildComponent, child.constructor); + assert.strictEqual('bar', child.bar); + assert.ok(custom.element.querySelector('#' + child.id)); + }); + + it('should update rendered child component', function(done) { + var test = this; + var custom = new this.CustomComponent(); + custom.render(); + + custom.foo = 'bar2'; + custom.on('attrsChanged', function() { + var child = custom.components_.myChild0; + assert.ok(child); + assert.strictEqual(test.ChildComponent, child.constructor); + assert.strictEqual('bar2', child.bar); + assert.ok(custom.element.querySelector('#' + child.id)); + + done(); + }); + }); + + it('should reuse previously rendered component instances', function(done) { + var custom = new this.CustomComponent(); + custom.render(); + + var prevChild = custom.components_.myChild0; + custom.count = 2; + custom.on('attrsChanged', function() { + assert.strictEqual(prevChild, custom.components_.myChild0); + assert.ok(custom.components_.myChild1); + assert.notStrictEqual(prevChild, custom.components_.myChild1); + done(); + }); + }); + + it('should ignore component elements that were not rendered via a SoyTemplate call', function() { + var CustomComponent = createCustomComponentClass(); + CustomComponent.TEMPLATES = { + element: function() { + return { + content: '
' + }; + } + }; + + var custom = new CustomComponent(); + custom.render(); + + assert.ok(!custom.components_.myChild0); + }); + }); + function createCustomComponentClass() { class CustomComponent extends SoyComponent { constructor(opt_config) { From 3d51473e823e2df53dc709b984dea92bb780486c Mon Sep 17 00:00:00 2001 From: Maira Bello Date: Thu, 5 Mar 2015 12:20:58 -0300 Subject: [PATCH 08/10] Adds function for checking if 2 arrays have the same content --- src/array/array.js | 15 +++++++++++++++ test/array/array.js | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/array/array.js b/src/array/array.js index d8fa97cc..bf42cd1c 100644 --- a/src/array/array.js +++ b/src/array/array.js @@ -1,6 +1,21 @@ 'use strict'; class array { + /** + * Checks if the given arrays have the same content. + * @param {!Array<*>} arr1 + * @param {!Array<*>} arr2 + * @return {boolean} + */ + static equal(arr1, arr2) { + for (var i = 0; i < arr1.length; i++) { + if (arr1[i] !== arr2[i]) { + return false; + } + } + return arr1.length === arr2.length; + } + /** * Returns the first value in the given array that isn't undefined. * @param {!Array} arr diff --git a/test/array/array.js b/test/array/array.js index 702f21f8..7dd27032 100644 --- a/test/array/array.js +++ b/test/array/array.js @@ -50,4 +50,21 @@ describe('array', function() { assert.strictEqual(1, array.firstDefinedValue([undefined, undefined, 1, 2, 3])); assert.strictEqual(null, array.firstDefinedValue([undefined, undefined, null, 2, 3])); }); + + describe('equal', function() { + it('should return false for arrays with different length', function() { + assert.ok(!array.equal([1, 2], [1, 2, 3])); + }); + + it('should return false for arrays with different object instances', function() { + assert.ok(!array.equal([1, {}], [1, {}])); + }); + + it('should return true for arrays with the same content', function() { + assert.ok(array.equal([], [])); + assert.ok(array.equal([1, 2], [1, 2])); + var obj = {}; + assert.ok(array.equal([1, obj], [1, obj])); + }); + }); }); From 98fe99dba78d1e2f5cc08b5144fc94fcd07bf8cb Mon Sep 17 00:00:00 2001 From: Maira Bello Date: Thu, 5 Mar 2015 12:26:17 -0300 Subject: [PATCH 09/10] Allows passing children attribute with components --- src/component/Component.js | 25 ++++++++++++++++++++++++- test/component/Component.js | 16 ++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/component/Component.js b/src/component/Component.js index e9f7df6b..6abee2dc 100644 --- a/src/component/Component.js +++ b/src/component/Component.js @@ -708,10 +708,22 @@ class Component extends Attribute { dom.addClasses(this.element, classesToAdd); } + /** + * Validator logic for `children` element. + * @param {*} val + * @return {boolean} + * @protected + */ + validatorChildrenFn_(val) { + return (val instanceof Array) && val.every(function(component) { + return component instanceof Component; + }); + } + /** * Validator logic for element attribute. * @param {string|Element} val - * @return {Boolean} True if val is a valid element. + * @return {boolean} True if val is a valid element. * @protected */ validatorElementFn_(val) { @@ -763,6 +775,17 @@ class Component extends Attribute { * @static */ Component.ATTRS = { + /** + * Child components passed to this component. + * @type {Array} + */ + children: { + validator: 'validatorChildrenFn_', + valueFn: function() { + return []; + } + }, + /** * Component element bounding box. * @type {Element} diff --git a/test/component/Component.js b/test/component/Component.js index bd93b2e5..edccbb99 100644 --- a/test/component/Component.js +++ b/test/component/Component.js @@ -311,6 +311,22 @@ describe('Component', function() { sinon.assert.callCount(custom.syncBar, 1); }); + it('should only accept arrays of components as children attribute', function() { + var CustomComponent = createCustomComponentClass(); + var custom = new CustomComponent({ + children: 'children' + }); + assert.deepEqual([], custom.children); + + custom.children = 1; + custom.children = [1, 2]; + custom.children = new CustomComponent(); + assert.deepEqual([], custom.children); + + var componentsArray = [new CustomComponent(), new Component()]; + custom.children = componentsArray; + assert.strictEqual(componentsArray, custom.children); + }); }); describe('Render', function() { From e7cd82c0c19282a018140d523d064a30d9b71801 Mon Sep 17 00:00:00 2001 From: Maira Bello Date: Thu, 5 Mar 2015 12:19:27 -0300 Subject: [PATCH 10/10] Allows passing down children components through soy template --- src/component/ComponentCollector.js | 85 +++++++++- src/soy/SoyComponent.js | 39 ++++- src/soy/SoyComponent.soy | 18 +- src/soy/SoyComponent.soy.js | 25 ++- test/component/ComponentCollector.js | 123 ++++++++++++-- test/soy/SoyComponent.js | 238 +++++++++++++++++++++++---- 6 files changed, 467 insertions(+), 61 deletions(-) diff --git a/src/component/ComponentCollector.js b/src/component/ComponentCollector.js index 2ce925f3..cdb2dd2f 100644 --- a/src/component/ComponentCollector.js +++ b/src/component/ComponentCollector.js @@ -1,5 +1,7 @@ 'use strict'; +import dom from '../dom/dom'; +import object from '../object/object'; import ComponentRegistry from '../component/ComponentRegistry'; import Disposable from '../disposable/Disposable'; @@ -13,6 +15,14 @@ class ComponentCollector extends Disposable { * @protected */ this.components_ = {}; + + /** + * Holds the main extracted components (that is, components that are + * not children of other extracted components), indexed by ref. + * @type {!Object} + * @protected + */ + this.mainComponents_ = {}; } /** @@ -26,6 +36,7 @@ class ComponentCollector extends Disposable { createComponent_(ref, name, data, parent) { var ConstructorFn = ComponentRegistry.getConstructor(name); this.components_[ref] = new ConstructorFn(data).render(parent.parentNode, parent); + this.mainComponents_[ref] = this.components_[ref]; parent.parentNode.removeChild(parent); } @@ -37,14 +48,79 @@ class ComponentCollector extends Disposable { return this.components_; } + /** + * Gets all the main extracted components. + * @return {!Array} + */ + getMainComponents() { + return this.mainComponents_; + } + + /** + * Handles the child component with the given ref, creating it for the first + * time or updating it in case it doesn't exist yet. + * @param {!Object} data The child component's template call data. + * @return {!Component} The child component's instance. + * @protected + */ + extractChild_(data) { + var component = this.components_[data.ref]; + if (component) { + component.setAttrs(data.data); + } else { + var ConstructorFn = ComponentRegistry.getConstructor(data.name); + component = new ConstructorFn(data.data); + this.components_[data.ref] = component; + delete this.mainComponents_[data.ref]; + } + return component; + } + + /** + * Handles the given array of rendered child soy templates, converting them to + * component instances. + * @param {string} children Rendered children. + * @param {string} ref The parent component's ref. + * @param {!Object} componentData An object with creation + * data for components that may be found inside the element, indexed by + * their ref strings. + */ + extractChildren(children, parentRef, componentData) { + var parentData = componentData[parentRef]; + parentData.data = object.mixin({}, parentData.data); + parentData.data.children = []; + + var frag = dom.buildFragment(children); + for (var i = 0; i < frag.childNodes.length; i++) { + var ref = frag.childNodes[i].getAttribute('data-ref'); + var data = componentData[ref]; + if (data.children) { + this.extractChildren(data.children.content, ref, componentData); + } + parentData.data.children.push(this.extractChild_(data)); + } + } + /** * Extracts a component from the given element. * @param {!Element} element Element that represents a component that should * be extracted. - * @param {!Object} data Creation data for the component to be extracted. + * @param {string} ref The component's ref. + * @param {!Object} componentData An object with creation + * data for components that may be found inside the element, indexed by + * their ref strings. * @protected */ - extractComponent_(element, data) { + extractComponent_(element, ref, componentData) { + var data = componentData[ref]; + if (!data) { + return; + } + + if (data.children) { + this.extractChildren(data.children.content, ref, componentData); + } + if (this.components_[data.ref]) { this.updateComponent_(data.ref, data.data, element); } else { @@ -63,10 +139,7 @@ class ComponentCollector extends Disposable { extractComponents(element, componentData) { if (element.hasAttribute && element.hasAttribute('data-component')) { var ref = element.getAttribute('data-ref'); - var data = componentData[ref]; - if (data) { - this.extractComponent_(element, data); - } + this.extractComponent_(element, ref, componentData); } return componentData; diff --git a/src/soy/SoyComponent.js b/src/soy/SoyComponent.js index b30a560a..e7c4597d 100644 --- a/src/soy/SoyComponent.js +++ b/src/soy/SoyComponent.js @@ -1,5 +1,6 @@ 'use strict'; +import array from '../array/array'; import core from '../core'; import dom from '../dom/dom'; import object from '../object/object'; @@ -142,6 +143,26 @@ class SoyComponent extends Component { return object.mixin.apply(null, [{}].concat(values.reverse())); } + /** + * Renders this component's child components, if their placeholder is found. + * @protected + */ + renderChildren_() { + var placeholder = this.element.querySelector('#' + this.makeSurfaceId_('children-placeholder')); + if (placeholder) { + dom.removeChildren(placeholder); + + var children = this.children; + children.forEach(function(child) { + if (child.wasRendered) { + dom.append(placeholder, child.element); + } else { + child.render(placeholder); + } + }); + } + } + /** * Overrides the behavior of this method to automatically render the element * template if it's defined and to automatically attach listeners to all @@ -200,13 +221,27 @@ class SoyComponent extends Component { return content; } + /** + * Syncs the component according to the new value of the `children` attribute. + */ + syncChildren(newVal, prevVal) { + if (!array.equal(newVal, prevVal || [])) { + this.renderChildren_(); + } + } + /** * Updates all inner components with their last template call data. * @protected */ updateComponents_() { - for (var ref in this.lastComponentTemplateCall_) { - var data = this.lastComponentTemplateCall_[ref]; + var templateCalls = this.lastComponentTemplateCall_; + var mainComponents = this.componentCollector_.getMainComponents(); + for (var ref in mainComponents) { + var data = templateCalls[ref]; + if (data && data.children) { + this.componentCollector_.extractChildren(data.children.content, ref, templateCalls); + } this.components_[data.ref].setAttrs(data.data); } } diff --git a/src/soy/SoyComponent.soy b/src/soy/SoyComponent.soy index 737c4d94..44bff703 100644 --- a/src/soy/SoyComponent.soy +++ b/src/soy/SoyComponent.soy @@ -1,6 +1,7 @@ {namespace Templates.SoyComponent} /** + * @param? children * @param data * @param name * @param ref @@ -8,12 +9,15 @@ {template .component}
{if $ij.renderChildComponents} - {delcall ComponentElement data="$data" variant="$name" /} + {delcall ComponentElement data="$data" variant="$name"} + {param children: $children /} + {/delcall} {/if}
{/template} /** + * @param? children * @param data * @param name * @param ref @@ -21,3 +25,15 @@ {deltemplate Component} {call Templates.SoyComponent.component data="all" /} {/deltemplate} + +/** + * @param children + * @param id + */ +{deltemplate ComponentChildren} +
+ {if $ij.renderChildComponents} + {$children} + {/if} +
+{/deltemplate} diff --git a/src/soy/SoyComponent.soy.js b/src/soy/SoyComponent.soy.js index b6642933..a2842ea1 100644 --- a/src/soy/SoyComponent.soy.js +++ b/src/soy/SoyComponent.soy.js @@ -7,6 +7,7 @@ var Templates = ComponentRegistry.Templates; /** * @fileoverview Templates in namespace Templates.SoyComponent. * @hassoydeltemplate {Component} + * @hassoydeltemplate {ComponentChildren} * @hassoydelcall {ComponentElement} */ @@ -21,7 +22,7 @@ if (typeof Templates.SoyComponent == 'undefined') { Templates.SoyComponent = {}; * @suppress {checkTypes} */ Templates.SoyComponent.component = function(opt_data, opt_ignored, opt_ijData) { - return soydata.VERY_UNSAFE.ordainSanitizedHtml('
' + ((opt_ijData.renderChildComponents) ? soy.$$escapeHtml(soy.$$getDelegateFn(soy.$$getDelTemplateId('ComponentElement'), opt_data.name, true)(opt_data.data, null, opt_ijData)) : '') + '
'); + return soydata.VERY_UNSAFE.ordainSanitizedHtml('
' + ((opt_ijData.renderChildComponents) ? soy.$$escapeHtml(soy.$$getDelegateFn(soy.$$getDelTemplateId('ComponentElement'), opt_data.name, true)(soy.$$augmentMap(opt_data.data, {children: opt_data.children}), null, opt_ijData)) : '') + '
'); }; if (goog.DEBUG) { Templates.SoyComponent.component.soyTemplateName = 'Templates.SoyComponent.component'; @@ -35,11 +36,27 @@ if (goog.DEBUG) { * @return {!soydata.SanitizedHtml} * @suppress {checkTypes} */ -Templates.SoyComponent.__deltemplate_s12_0084916f = function(opt_data, opt_ignored, opt_ijData) { +Templates.SoyComponent.__deltemplate_s13_0084916f = function(opt_data, opt_ignored, opt_ijData) { return soydata.VERY_UNSAFE.ordainSanitizedHtml(Templates.SoyComponent.component(opt_data, null, opt_ijData)); }; if (goog.DEBUG) { - Templates.SoyComponent.__deltemplate_s12_0084916f.soyTemplateName = 'Templates.SoyComponent.__deltemplate_s12_0084916f'; + Templates.SoyComponent.__deltemplate_s13_0084916f.soyTemplateName = 'Templates.SoyComponent.__deltemplate_s13_0084916f'; } -soy.$$registerDelegateFn(soy.$$getDelTemplateId('Component'), '', 0, Templates.SoyComponent.__deltemplate_s12_0084916f); +soy.$$registerDelegateFn(soy.$$getDelTemplateId('Component'), '', 0, Templates.SoyComponent.__deltemplate_s13_0084916f); + + +/** + * @param {Object.=} opt_data + * @param {(null|undefined)=} opt_ignored + * @param {Object.=} opt_ijData + * @return {!soydata.SanitizedHtml} + * @suppress {checkTypes} + */ +Templates.SoyComponent.__deltemplate_s15_26860e4b = function(opt_data, opt_ignored, opt_ijData) { + return soydata.VERY_UNSAFE.ordainSanitizedHtml('
' + ((opt_ijData.renderChildComponents) ? soy.$$escapeHtml(opt_data.children) : '') + '
'); +}; +if (goog.DEBUG) { + Templates.SoyComponent.__deltemplate_s15_26860e4b.soyTemplateName = 'Templates.SoyComponent.__deltemplate_s15_26860e4b'; +} +soy.$$registerDelegateFn(soy.$$getDelTemplateId('ComponentChildren'), '', 0, Templates.SoyComponent.__deltemplate_s15_26860e4b); /* jshint ignore:end */ diff --git a/test/component/ComponentCollector.js b/test/component/ComponentCollector.js index a39ed9d8..56cfe361 100644 --- a/test/component/ComponentCollector.js +++ b/test/component/ComponentCollector.js @@ -17,7 +17,8 @@ TestComponent.ATTRS = { describe('ComponentCollector', function() { it('should not create components on element without data-component attribute', function() { - var element = document.createElement('div'); + var element = createComponentElement(); + element.removeAttribute('data-component'); var collector = new ComponentCollector(); collector.extractComponents(element, {}); @@ -26,9 +27,7 @@ describe('ComponentCollector', function() { }); it('should not create components on element without their creation data', function() { - var element = document.createElement('div'); - element.setAttribute('data-component', true); - element.setAttribute('data-ref', 'comp'); + var element = createComponentElement(); var collector = new ComponentCollector(); collector.extractComponents(element, {}); @@ -38,10 +37,7 @@ describe('ComponentCollector', function() { it('should instantiate extracted components', function() { var parent = document.createElement('div'); - var element = document.createElement('div'); - element.setAttribute('data-component', true); - element.setAttribute('data-ref', 'comp'); - dom.append(parent, element); + var element = createComponentElement(parent); var collector = new ComponentCollector(); var creationData = { @@ -57,14 +53,12 @@ describe('ComponentCollector', function() { assert.strictEqual(1, Object.keys(components).length); assert.ok(components.comp instanceof TestComponent); assert.strictEqual(1, components.comp.bar); + assert.strictEqual(parent, components.comp.element.parentNode); }); it('should update extracted component instances', function() { var parent = document.createElement('div'); - var element = document.createElement('div'); - element.setAttribute('data-component', true); - element.setAttribute('data-ref', 'comp'); - dom.append(parent, element); + var element = createComponentElement(parent); var collector = new ComponentCollector(); var creationData = { @@ -82,8 +76,111 @@ describe('ComponentCollector', function() { collector.extractComponents(element, {comp: creationData}); var components = collector.getComponents(); + assert.strictEqual(2, components.comp.bar); + assert.strictEqual(parent, components.comp.element.parentNode); + }); + + it('should instantiate extracted component children', function() { + var element = createComponentElement(); + + var collector = new ComponentCollector(); + var creationData = { + child1: { + data: {}, + name: 'TestComponent', + ref: 'child1' + }, + child2: { + children: {content: '
'}, + data: {}, + name: 'TestComponent', + ref: 'child2' + }, + comp: { + children: {content: '
'}, + data: {}, + name: 'TestComponent', + ref: 'comp' + } + }; + collector.extractComponents(element, creationData); + + var components = collector.getComponents(); + assert.strictEqual(3, Object.keys(components).length); + assert.ok(components.comp instanceof TestComponent); + assert.ok(components.child1 instanceof TestComponent); + assert.ok(components.child2 instanceof TestComponent); + assert.deepEqual([components.child2], components.comp.children); + assert.deepEqual([components.child1], components.child2.children); + }); + + it('should update extracted component children instances', function() { + var parent = document.createElement('div'); + var element = createComponentElement(parent); + + var collector = new ComponentCollector(); + var creationData = { + child1: { + data: {}, + name: 'TestComponent', + ref: 'child1' + }, + child2: { + data: {}, + name: 'TestComponent', + ref: 'child2' + }, + comp: { + children: { + content: '
' + + '
' + }, + data: {}, + name: 'TestComponent', + ref: 'comp' + } + }; + collector.extractComponents(element, creationData); + + parent.innerHTML = ''; + dom.append(parent, element); + creationData.child1.data.bar = 'child1'; + collector.extractComponents(element, creationData); + + var components = collector.getComponents(); + assert.strictEqual('child1', components.child1.bar); + }); + + it('should separately return components that are not children of others', function() { + var element = createComponentElement(); + + var collector = new ComponentCollector(); + var creationData = { + child1: { + data: {}, + name: 'TestComponent', + ref: 'child1' + }, + comp: { + children: {content: '
'}, + data: {}, + name: 'TestComponent', + ref: 'comp' + } + }; + collector.extractComponents(element, creationData); + + var components = collector.getMainComponents(); assert.strictEqual(1, Object.keys(components).length); assert.ok(components.comp instanceof TestComponent); - assert.strictEqual(2, components.comp.bar); }); + + function createComponentElement(parent) { + parent = parent || document.createElement('div'); + var element = document.createElement('div'); + element.setAttribute('data-component', true); + element.setAttribute('data-ref', 'comp'); + dom.append(parent, element); + return element; + } }); diff --git a/test/soy/SoyComponent.js b/test/soy/SoyComponent.js index dae7e469..f8e87a9e 100644 --- a/test/soy/SoyComponent.js +++ b/test/soy/SoyComponent.js @@ -5,6 +5,7 @@ import ComponentRegistry from '../../src/component/ComponentRegistry'; import SoyComponent from '../../src/soy/SoyComponent'; var SoyTemplates = ComponentRegistry.Templates.SoyComponent; +var placeholderTemplate = soy.$$getDelegateFn(soy.$$getDelTemplateId('ComponentChildren')); describe('SoyComponent', function() { afterEach(function() { @@ -96,55 +97,35 @@ describe('SoyComponent', function() { describe('Child Components', function() { beforeEach(function() { - var ChildComponent = createCustomComponentClass(); - ComponentRegistry.register('ChildComponent', ChildComponent); + var ChildComponent = createCustomComponentClass('ChildComponent'); ChildComponent.ATTRS = { bar: { value: '' } }; - - var CustomComponent = createCustomComponentClass(); - CustomComponent.ATTRS = { - count: { - value: 1 - }, - foo: { - value: 'bar' - } - }; - CustomComponent.SURFACES = { - component: { - renderAttrs: ['foo', 'count'] + ChildComponent.SURFACES = { + children: { + renderAttrs: ['bar'] } }; - CustomComponent.TEMPLATES = { + ChildComponent.TEMPLATES = { element: function(data) { return { - content: '
' + content: '
' }; }, - component: function(data) { - var result = {content: ''}; - for (var i = 0; i < data.count; i++) { - var childData = { - data: {bar: data.foo}, - name: 'ChildComponent', - parentId: data.id, - ref: 'myChild' + i - }; - result.content += SoyTemplates.component(childData, null, {}); - } - return result; + children: function(data) { + return { + content: data.bar + placeholderTemplate(data, null, {}).content + }; } }; - this.ChildComponent = ChildComponent; - this.CustomComponent = CustomComponent; }); it('should instantiate rendered child component', function() { - var custom = new this.CustomComponent(); + var NestedComponent = createNestedComponentClass(); + var custom = new NestedComponent(); custom.render(); var child = custom.components_.myChild0; @@ -156,7 +137,8 @@ describe('SoyComponent', function() { it('should update rendered child component', function(done) { var test = this; - var custom = new this.CustomComponent(); + var NestedComponent = createNestedComponentClass(); + var custom = new NestedComponent(); custom.render(); custom.foo = 'bar2'; @@ -172,7 +154,8 @@ describe('SoyComponent', function() { }); it('should reuse previously rendered component instances', function(done) { - var custom = new this.CustomComponent(); + var NestedComponent = createNestedComponentClass(); + var custom = new NestedComponent(); custom.render(); var prevChild = custom.components_.myChild0; @@ -200,14 +183,199 @@ describe('SoyComponent', function() { assert.ok(!custom.components_.myChild0); }); + + it('should pass children to nested components', function() { + var MultipleNestedComponent = createMultipleNestedComponentClass(); + var component = new MultipleNestedComponent(); + component.render(); + + var comps = component.components_; + assert.ok(comps.child1); + assert.ok(comps.child2); + assert.ok(comps.child3); + assert.ok(comps.nested); + + assert.strictEqual(0, component.children.length); + assert.strictEqual(2, comps.nested.children.length); + assert.deepEqual([comps.child2, comps.child3], comps.nested.children); + assert.strictEqual(0, comps.child1.children.length); + assert.strictEqual(1, comps.child2.children.length); + assert.deepEqual([comps.child1], comps.child2.children); + assert.strictEqual(0, comps.child3.children.length); + }); + + it('should update nested components children', function(done) { + var MultipleNestedComponent = createMultipleNestedComponentClass(); + var component = new MultipleNestedComponent(); + component.render(); + + component.bar = 'foo'; + component.on('attrsChanged', function() { + var comps = component.components_; + assert.strictEqual('foo', comps.child1.bar); + assert.strictEqual('foo', comps.child2.bar); + assert.strictEqual('foo', comps.child3.bar); + assert.strictEqual('foo', comps.nested.bar); + done(); + }); + }); + + it('should render children components inside placeholder', function() { + var MultipleNestedComponent = createMultipleNestedComponentClass(); + var component = new MultipleNestedComponent(); + component.render(); + + var comps = component.components_; + var placeholder = document.getElementById(comps.nested.id + '-children-placeholder'); + assert.strictEqual(2, placeholder.childNodes.length); + assert.strictEqual(comps.child2.element, placeholder.childNodes[0]); + assert.strictEqual(comps.child3.element, placeholder.childNodes[1]); + + placeholder = document.getElementById(comps.child2.id + '-children-placeholder'); + assert.strictEqual(1, placeholder.childNodes.length); + assert.strictEqual(comps.child1.element, placeholder.childNodes[0]); + }); + + it('should not render children components if no placeholder exists', function() { + createCustomComponentClass('NoPlaceholderComponent'); + var MainComponent = createCustomComponentClass('MainComponent'); + MainComponent.TEMPLATES = { + element: function() { + var child = SoyTemplates.component({ + data: {}, + name: 'ChildComponent', + ref: 'child' + }, null, {}); + return SoyTemplates.component({ + children: {content: child.content}, + data: {}, + name: 'NoPlaceholderComponent', + ref: 'noPlaceholder' + }, null, {}); + } + }; + + var component = new MainComponent(); + component.render(); + + var comps = component.components_; + assert.ok(!comps.child.wasRendered); + }); + + it('should update dom when children changes', function(done) { + var MultipleNestedComponent = createMultipleNestedComponentClass(); + var component = new MultipleNestedComponent(); + component.render(); + + var comps = component.components_; + + component.invert = true; + comps.nested.on('attrsChanged', function() { + var placeholder = document.getElementById(comps.nested.id + '-children-placeholder'); + assert.strictEqual(2, placeholder.childNodes.length); + assert.strictEqual(comps.child3.element, placeholder.childNodes[0]); + assert.strictEqual(comps.child2.element, placeholder.childNodes[1]); + done(); + }); + }); }); - function createCustomComponentClass() { + function createCustomComponentClass(name) { class CustomComponent extends SoyComponent { constructor(opt_config) { super(opt_config); } } + ComponentRegistry.register(name || 'CustomComponent', CustomComponent); return CustomComponent; } + + function createNestedComponentClass() { + var NestedComponent = createCustomComponentClass('NestedComponent'); + NestedComponent.ATTRS = { + count: { + value: 1 + }, + foo: { + value: 'bar' + } + }; + NestedComponent.SURFACES = { + component: { + renderAttrs: ['foo', 'count'] + } + }; + NestedComponent.TEMPLATES = { + element: function(data) { + return { + content: '
' + }; + }, + component: function(data) { + var result = {content: ''}; + for (var i = 0; i < data.count; i++) { + var childData = { + data: {bar: data.foo}, + name: 'ChildComponent', + ref: 'myChild' + i + }; + result.content += SoyTemplates.component(childData, null, {}); + } + return result; + } + }; + return NestedComponent; + } + + function createMultipleNestedComponentClass() { + var MultipleNestedComponent = createCustomComponentClass('MultipleNestedComponent'); + MultipleNestedComponent.ATTRS = { + bar: { + value: 'bar' + }, + invert: { + value: false + } + }; + MultipleNestedComponent.SURFACES = { + children: { + renderAttrs: ['bar', 'invert'] + } + }; + MultipleNestedComponent.TEMPLATES = { + element: function(data) { + return { + content: '
' + }; + }, + children: function(data) { + var childData = {bar: data.bar}; + var child1 = SoyTemplates.component({ + data: childData, + name: 'ChildComponent', + ref: 'child1' + }, null, {}); + var child2 = SoyTemplates.component({ + children: child1, + data: childData, + name: 'ChildComponent', + ref: 'child2' + }, null, {}); + var child3 = SoyTemplates.component({ + data: childData, + name: 'ChildComponent', + ref: 'child3' + }, null, {}); + var nested = SoyTemplates.component({ + children: {content: data.invert ? child3.content + child2.content : child2.content + child3.content}, + data: childData, + name: 'ChildComponent', + ref: 'nested' + }, null, {}); + + return nested; + } + }; + return MultipleNestedComponent; + } });