From c6f14dd833da574b396f5740c5f7127864bd4e6d Mon Sep 17 00:00:00 2001 From: Rado Kirov Date: Fri, 21 Nov 2014 15:13:01 -0800 Subject: [PATCH] feat(viewPort): adds initial implementation of ViewPort. ViewPort is the mechanism backing @Template directives. Those directives can use the viewport to dynamically create, attach and detach views. --- modules/core/src/compiler/element_injector.js | 18 ++- modules/core/src/compiler/view.js | 68 +++++--- modules/core/src/compiler/viewport.js | 119 ++++++++++++++ .../test/compiler/element_injector_spec.js | 16 +- modules/core/test/compiler/view_spec.js | 41 ++++- modules/core/test/compiler/viewport_spec.js | 153 ++++++++++++++++++ modules/facade/src/dom.dart | 6 + modules/facade/src/dom.es6 | 6 + modules/test_lib/src/test_lib.dart | 6 +- 9 files changed, 403 insertions(+), 30 deletions(-) create mode 100644 modules/core/src/compiler/viewport.js create mode 100644 modules/core/test/compiler/viewport_spec.js diff --git a/modules/core/src/compiler/element_injector.js b/modules/core/src/compiler/element_injector.js index ec47d1d8d6..c1043106dd 100644 --- a/modules/core/src/compiler/element_injector.js +++ b/modules/core/src/compiler/element_injector.js @@ -4,6 +4,7 @@ import {List, ListWrapper} from 'facade/collection'; import {Injector, Key, Dependency, bind, Binding, NoProviderError, ProviderError, CyclicDependencyError} from 'di/di'; import {Parent, Ancestor} from 'core/annotations/visibility'; import {View} from 'core/compiler/view'; +import {ViewPort} from 'core/compiler/viewport'; import {NgElement} from 'core/dom/element'; var _MAX_DIRECTIVE_CONSTRUCTION_COUNTER = 10; @@ -17,10 +18,12 @@ var _staticKeys; class StaticKeys { viewId:int; ngElementId:int; + viewPortId:int; constructor() { //TODO: vsavkin Key.annotate(Key.get(View), 'static') this.viewId = Key.get(View).id; this.ngElementId = Key.get(NgElement).id; + this.viewPortId = Key.get(ViewPort).id; } static instance() { @@ -61,6 +64,10 @@ class TreeNode { return this._parent; } + set parent(node:TreeNode) { + this._parent = node; + } + get children() { var res = []; var child = this._head; @@ -92,12 +99,16 @@ class DirectiveDependency extends Dependency { } } + +// TODO(rado): benchmark and consider rolling in as ElementInjector fields. export class PreBuiltObjects { view:View; element:NgElement; - constructor(view:View, element:NgElement) { + viewPort:ViewPort; + constructor(view, element:NgElement, viewPort: ViewPort) { this.view = view; this.element = element; + this.viewPort = viewPort; } } @@ -410,6 +421,11 @@ export class ElementInjector extends TreeNode { var staticKeys = StaticKeys.instance(); if (keyId === staticKeys.viewId) return this._preBuiltObjects.view; if (keyId === staticKeys.ngElementId) return this._preBuiltObjects.element; + if (keyId === staticKeys.viewPortId) { + if (isBlank(staticKeys.viewPortId)) throw new BaseException( + 'ViewPort is constructed only for @Template directives'); + return this._preBuiltObjects.viewPort; + } //TODO add other objects as needed return _undefined; } diff --git a/modules/core/src/compiler/view.js b/modules/core/src/compiler/view.js index 0d3ac6c200..435c850370 100644 --- a/modules/core/src/compiler/view.js +++ b/modules/core/src/compiler/view.js @@ -11,10 +11,11 @@ import {SetterFn} from 'reflection/types'; import {FIELD, IMPLEMENTS, int, isPresent, isBlank} from 'facade/lang'; import {Injector} from 'di/di'; import {NgElement} from 'core/dom/element'; +import {ViewPort} from './viewport'; const NG_BINDING_CLASS = 'ng-binding'; -/*** +/** * Const of making objects: http://jsperf.com/instantiate-size-of-object */ @IMPLEMENTS(WatchGroupDispatcher) @@ -29,7 +30,8 @@ export class View { /// to keep track of the nodes. nodes:List; onChangeDispatcher:OnChangeDispatcher; - childViews: List; + componentChildViews: List; + viewPorts: List; constructor(nodes:List, elementInjectors:List, rootElementInjectors:List, textNodes:List, bindElements:List, protoRecordRange:ProtoRecordRange, context) { @@ -41,9 +43,8 @@ export class View { this.bindElements = bindElements; this.recordRange = protoRecordRange.instantiate(this, MapWrapper.create()); this.recordRange.setContext(context); - // TODO(rado): Since this is only used in tests for now, investigate whether - // we can remove it. - this.childViews = []; + this.componentChildViews = null; + this.viewPorts = null; } onRecordChange(record:Record, target) { @@ -62,10 +63,24 @@ export class View { } } - addChild(childView: View) { - ListWrapper.push(this.childViews, childView); + addViewPort(viewPort: ViewPort) { + if (isBlank(this.viewPorts)) this.viewPorts = []; + ListWrapper.push(this.viewPorts, viewPort); + } + + addComponentChildView(childView: View) { + if (isBlank(this.componentChildViews)) this.componentChildViews = []; + ListWrapper.push(this.componentChildViews, childView); this.recordRange.addRange(childView.recordRange); } + + addViewPortChildView(childView: View) { + this.recordRange.addRange(childView.recordRange); + } + + removeViewPortChildView(childView: View) { + childView.recordRange.remove(); + } } export class ProtoView { @@ -120,9 +135,10 @@ export class ProtoView { bindElements, this.protoRecordRange, context); ProtoView._instantiateDirectives( - view, elements, elementInjectors, lightDomAppInjector, shadowAppInjectors); - ProtoView._instantiateChildComponentViews( - elements, binders, elementInjectors, shadowAppInjectors, view); + view, elements, binders, elementInjectors, lightDomAppInjector, + shadowAppInjectors, hostElementInjector); + ProtoView._instantiateChildComponentViews(view, elements, binders, + elementInjectors, shadowAppInjectors); return view; } @@ -212,12 +228,25 @@ export class ProtoView { } static _instantiateDirectives( - view: View, elements:List, injectors:List, lightDomAppInjector: Injector, - shadowDomAppInjectors:List) { + view, elements:List, binders: List, injectors:List, + lightDomAppInjector: Injector, shadowDomAppInjectors:List, + hostElementInjector: ElementInjector) { for (var i = 0; i < injectors.length; ++i) { - var preBuiltObjs = new PreBuiltObjects(view, new NgElement(elements[i])); - if (injectors[i] != null) injectors[i].instantiateDirectives( + var injector = injectors[i]; + if (injector != null) { + var binder = binders[i]; + var element = elements[i]; + var ngElement = new NgElement(element); + var viewPort = null; + if (isPresent(binder.templateDirective)) { + viewPort = new ViewPort(view, element, binder.nestedProtoView, injector); + viewPort.attach(lightDomAppInjector, hostElementInjector); + view.addViewPort(viewPort); + } + var preBuiltObjs = new PreBuiltObjects(view, ngElement, viewPort); + injector.instantiateDirectives( lightDomAppInjector, shadowDomAppInjectors[i], preBuiltObjs); + } } } @@ -252,20 +281,17 @@ export class ProtoView { } } - static _instantiateChildComponentViews(elements, binders, injectors, - shadowDomAppInjectors: List, view: View) { + static _instantiateChildComponentViews(view: View, elements, binders, + injectors, shadowDomAppInjectors: List) { for (var i = 0; i < binders.length; ++i) { var binder = binders[i]; if (isPresent(binder.componentDirective)) { var injector = injectors[i]; var childView = binder.nestedProtoView.instantiate( injector.getComponent(), shadowDomAppInjectors[i], injector); - view.addChild(childView); + view.addComponentChildView(childView); var shadowRoot = elements[i].createShadowRoot(); - // TODO(rado): reuse utility from ViewPort/View. - for (var j = 0; j < childView.nodes.length; ++j) { - DOM.appendChild(shadowRoot, childView.nodes[j]); - } + ViewPort.moveViewNodesIntoParent(shadowRoot, childView); } } } diff --git a/modules/core/src/compiler/viewport.js b/modules/core/src/compiler/viewport.js new file mode 100644 index 0000000000..cb1bf550d5 --- /dev/null +++ b/modules/core/src/compiler/viewport.js @@ -0,0 +1,119 @@ +import {View, ProtoView} from './view'; +import {DOM, Node, Element} from 'facade/dom'; +import {ListWrapper, MapWrapper, List} from 'facade/collection'; +import {BaseException} from 'facade/lang'; +import {Injector} from 'di/di'; +import {ElementInjector} from 'core/compiler/element_injector'; +import {isPresent, isBlank} from 'facade/lang'; + +export class ViewPort { + parentView: View; + templateElement: Element; + defaultProtoView: ProtoView; + _views: List; + _viewLastNode: List; + elementInjector: ElementInjector; + appInjector: Injector; + hostElementInjector: ElementInjector; + + constructor(parentView: View, templateElement: Element, defaultProtoView: ProtoView, + elementInjector: ElementInjector) { + this.parentView = parentView; + this.templateElement = templateElement; + this.defaultProtoView = defaultProtoView; + this.elementInjector = elementInjector; + + // The order in this list matches the DOM order. + this._views = []; + this.appInjector = null; + this.hostElementInjector = null; + } + + attach(appInjector: Injector, hostElementInjector: ElementInjector) { + this.appInjector = appInjector; + this.hostElementInjector = hostElementInjector; + } + + detach() { + this.appInjector = null; + this.hostElementInjector = null; + } + + get(index: number): View { + return this._views[index]; + } + + get length() { + return this._views.length; + } + + _siblingToInsertAfter(index: number) { + if (index == 0) return this.templateElement; + return ListWrapper.last(this._views[index - 1].nodes); + } + + get detached() { + return isBlank(this.appInjector); + } + + // TODO(rado): profile and decide whether bounds checks should be added + // to the methods below. + create(atIndex=-1): View { + if (this.detached) throw new BaseException( + 'Cannot create views on a detached view port'); + // TODO(rado): replace curried defaultProtoView.instantiate(appInjector, + // hostElementInjector) with ViewFactory. + var newView = this.defaultProtoView.instantiate( + null, this.appInjector, this.hostElementInjector); + return this.insert(newView, atIndex); + } + + insert(view, atIndex=-1): View { + if (atIndex == -1) atIndex = this._views.length; + ListWrapper.insert(this._views, atIndex, view); + ViewPort.moveViewNodesAfterSibling(this._siblingToInsertAfter(atIndex), view); + this.parentView.addViewPortChildView(view); + this._linkElementInjectors(view); + return view; + } + + remove(atIndex=-1): View { + if (atIndex == -1) atIndex = this._views.length - 1; + var removedView = this.get(atIndex); + ListWrapper.removeAt(this._views, atIndex); + ViewPort.removeViewNodesFromParent(this.templateElement.parentNode, removedView); + this.parentView.removeViewPortChildView(removedView); + this._unlinkElementInjectors(removedView); + return removedView; + } + + _linkElementInjectors(view) { + for (var i = 0; i < view.rootElementInjectors.length; ++i) { + view.rootElementInjectors[i].parent = this.elementInjector; + } + } + + _unlinkElementInjectors(view) { + for (var i = 0; i < view.rootElementInjectors.length; ++i) { + view.rootElementInjectors[i].parent = null; + } + } + + static moveViewNodesIntoParent(parent, view) { + for (var i = 0; i < view.nodes.length; ++i) { + DOM.appendChild(parent, view.nodes[i]); + } + } + + static moveViewNodesAfterSibling(sibling, view) { + for (var i = view.nodes.length - 1; i >= 0; --i) { + DOM.insertAfter(sibling, view.nodes[i]); + } + } + + static removeViewNodesFromParent(parent, view) { + for (var i = view.nodes.length - 1; i >= 0; --i) { + DOM.removeChild(parent, view.nodes[i]); + } + } +} diff --git a/modules/core/test/compiler/element_injector_spec.js b/modules/core/test/compiler/element_injector_spec.js index 4a80c99f0d..a898acb036 100644 --- a/modules/core/test/compiler/element_injector_spec.js +++ b/modules/core/test/compiler/element_injector_spec.js @@ -6,6 +6,7 @@ import {Parent, Ancestor} from 'core/annotations/visibility'; import {Injector, Inject, bind} from 'di/di'; import {View} from 'core/compiler/view'; import {ProtoRecordRange} from 'change_detection/record_range'; +import {ViewPort} from 'core/compiler/viewport'; import {NgElement} from 'core/dom/element'; //TODO: vsavkin: use a spy object @@ -66,7 +67,7 @@ class NeedsView { } export function main() { - var defaultPreBuiltObjects = new PreBuiltObjects(null, null); + var defaultPreBuiltObjects = new PreBuiltObjects(null, null, null); function humanize(tree, names:List) { var lookupName = (item) => @@ -177,7 +178,7 @@ export function main() { it("should instantiate directives that depend on pre built objects", function () { var view = new DummyView(); - var inj = injector([NeedsView], null, null, new PreBuiltObjects(view, null)); + var inj = injector([NeedsView], null, null, new PreBuiltObjects(view, null, null)); expect(inj.get(NeedsView).view).toBe(view); }); @@ -282,17 +283,24 @@ export function main() { describe("pre built objects", function () { it("should return view", function () { var view = new DummyView(); - var inj = injector([], null, null, new PreBuiltObjects(view, null)); + var inj = injector([], null, null, new PreBuiltObjects(view, null, null)); expect(inj.get(View)).toEqual(view); }); it("should return element", function () { var element = new NgElement(null); - var inj = injector([], null, null, new PreBuiltObjects(null, element)); + var inj = injector([], null, null, new PreBuiltObjects(null, element, null)); expect(inj.get(NgElement)).toEqual(element); }); + + it('should return viewPort', function () { + var viewPort = new ViewPort(null, null, null, null); + var inj = injector([], null, null, new PreBuiltObjects(null, null, viewPort)); + + expect(inj.get(ViewPort)).toEqual(viewPort); + }); }); }); } diff --git a/modules/core/test/compiler/view_spec.js b/modules/core/test/compiler/view_spec.js index b49c1afaf5..b497e3f945 100644 --- a/modules/core/test/compiler/view_spec.js +++ b/modules/core/test/compiler/view_spec.js @@ -2,7 +2,7 @@ import {describe, xit, it, expect, beforeEach, ddescribe, iit} from 'test_lib/te import {ProtoView, ElementPropertyMemento, DirectivePropertyMemento} from 'core/compiler/view'; import {ProtoElementInjector, ElementInjector} from 'core/compiler/element_injector'; import {DirectiveMetadataReader} from 'core/compiler/directive_metadata_reader'; -import {Component, Decorator} from 'core/annotations/annotations'; +import {Component, Decorator, Template} from 'core/annotations/annotations'; import {ProtoRecordRange} from 'change_detection/record_range'; import {ChangeDetector} from 'change_detection/change_detector'; import {TemplateConfig} from 'core/annotations/template_config'; @@ -12,15 +12,17 @@ import {DOM, Element} from 'facade/dom'; import {FIELD} from 'facade/lang'; import {Injector} from 'di/di'; import {View} from 'core/compiler/view'; +import {ViewPort} from 'core/compiler/viewport'; import {reflector} from 'reflection/reflection'; export function main() { describe('view', function() { - var parser, someComponentDirective; + var parser, someComponentDirective, someTemplateDirective; beforeEach(() => { parser = new Parser(new Lexer()); someComponentDirective = new DirectiveMetadataReader().annotatedType(SomeComponent); + someTemplateDirective = new DirectiveMetadataReader().annotatedType(SomeTemplate); }); @@ -213,7 +215,7 @@ export function main() { var view = createNestedView(pv); - var subView = view.childViews[0]; + var subView = view.componentChildViews[0]; var subInj = subView.rootElementInjectors[0]; var subDecorator = subInj.get(ServiceDependentDecorator); var comp = view.rootElementInjectors[0].get(SomeComponent); @@ -224,6 +226,28 @@ export function main() { }); }); + describe('recurse over child templateViews', () => { + var ctx, view, cd; + function createView(protoView) { + ctx = new MyEvaluationContext(); + view = protoView.instantiate(ctx, null, null); + } + + it('should create a viewPort for the template directive', () => { + var templateProtoView = new ProtoView( + createElement('
'), new ProtoRecordRange()); + var pv = new ProtoView(createElement(''), new ProtoRecordRange()); + var binder = pv.bindElement(new ProtoElementInjector(null, 0, [SomeTemplate])); + binder.templateDirective = someTemplateDirective; + binder.nestedProtoView = templateProtoView; + + createView(pv); + + var tmplComp = view.rootElementInjectors[0].get(SomeTemplate); + expect(tmplComp.viewPort).not.toBe(null); + }); + }); + describe('react to record changes', () => { var view, cd, ctx; @@ -324,6 +348,17 @@ class ServiceDependentDecorator { } } +@Template({ + selector: 'someTmpl' +}) +class SomeTemplate { + viewPort: ViewPort; + constructor(viewPort: ViewPort) { + this.viewPort = viewPort; + } +} + + class AnotherDirective { prop:string; constructor() { diff --git a/modules/core/test/compiler/viewport_spec.js b/modules/core/test/compiler/viewport_spec.js new file mode 100644 index 0000000000..8340c0f1bb --- /dev/null +++ b/modules/core/test/compiler/viewport_spec.js @@ -0,0 +1,153 @@ +import {describe, xit, it, expect, beforeEach, ddescribe, iit} from 'test_lib/test_lib'; +import {View, ProtoView} from 'core/compiler/view'; +import {ViewPort} from 'core/compiler/viewport'; +import {DOM} from 'facade/dom'; +import {ListWrapper, MapWrapper} from 'facade/collection'; +import {Injector} from 'di/di'; +import {ProtoElementInjector, ElementInjector} from 'core/compiler/element_injector'; +import {ProtoRecordRange} from 'change_detection/record_range'; +import {Parser} from 'change_detection/parser/parser'; +import {Lexer} from 'change_detection/parser/lexer'; + +function createElement(html) { + return DOM.createTemplate(html).content.firstChild; +} + +function createView(nodes) { + return new View(nodes, [], [], [], [], new ProtoRecordRange(), null); +} + +export function main() { + describe('viewport', () => { + var viewPort, parentView, protoView, dom, customViewWithOneNode, + customViewWithTwoNodes, elementInjector; + + beforeEach(() => { + dom = createElement(`
`); + var insertionElement = dom.childNodes[1]; + parentView = createView([dom.childNodes[0]]); + protoView = new ProtoView(createElement('
hi
'), new ProtoRecordRange()); + elementInjector = new ElementInjector(null, null, null); + viewPort = new ViewPort(parentView, insertionElement, protoView, elementInjector); + customViewWithOneNode = createView([createElement('
single
')]); + customViewWithTwoNodes = createView([createElement('
one
'), createElement('
two
')]); + }); + + describe('when detached', () => { + it('should throw if create is called', () => { + expect(() => viewPort.create()).toThrowError(); + }); + }); + + describe('when attached', () => { + function textInViewPort() { + var out = ''; + // skipping starting filler, insert-me and final filler. + for (var i = 2; i < dom.childNodes.length - 1; i++) { + if (i != 2) out += ' '; + out += DOM.getInnerHTML(dom.childNodes[i]); + } + return out; + } + + beforeEach(() => { + viewPort.attach(new Injector([]), null); + var fillerView = createView([createElement('filler')]); + viewPort.insert(fillerView); + }); + + it('should create new views from protoView', () => { + viewPort.create(); + expect(textInViewPort()).toEqual('filler hi'); + expect(viewPort.length).toBe(2); + }); + + it('should create new views from protoView at index', () => { + viewPort.create(0); + expect(textInViewPort()).toEqual('hi filler'); + expect(viewPort.length).toBe(2); + }); + + it('should insert new views at the end by default', () => { + viewPort.insert(customViewWithOneNode); + expect(textInViewPort()).toEqual('filler single'); + expect(viewPort.get(1)).toBe(customViewWithOneNode); + expect(viewPort.length).toBe(2); + }); + + it('should insert new views at the given index', () => { + viewPort.insert(customViewWithOneNode, 0); + expect(textInViewPort()).toEqual('single filler'); + expect(viewPort.get(0)).toBe(customViewWithOneNode); + expect(viewPort.length).toBe(2); + }); + + it('should remove the last view by default', () => { + viewPort.insert(customViewWithOneNode); + + var removedView = viewPort.remove(); + + expect(textInViewPort()).toEqual('filler'); + expect(removedView).toBe(customViewWithOneNode); + expect(viewPort.length).toBe(1); + }); + + it('should remove the view at a given index', () => { + viewPort.insert(customViewWithOneNode); + viewPort.insert(customViewWithTwoNodes); + + var removedView = viewPort.remove(1); + expect(removedView).toBe(customViewWithOneNode); + expect(textInViewPort()).toEqual('filler one two'); + expect(viewPort.get(1)).toBe(customViewWithTwoNodes); + expect(viewPort.length).toBe(2); + }); + + it('should support adding/removing views with more than one node', () => { + viewPort.insert(customViewWithTwoNodes); + viewPort.insert(customViewWithOneNode); + + expect(textInViewPort()).toEqual('filler one two single'); + + viewPort.remove(1); + expect(textInViewPort()).toEqual('filler single'); + }); + }); + + describe('should update injectors and parent views.', () => { + var fancyView; + beforeEach(() => { + var parser = new Parser(new Lexer()); + viewPort.attach(new Injector([]), null); + + var pv = new ProtoView(createElement('
{{}}
'), + new ProtoRecordRange()); + pv.bindElement(new ProtoElementInjector(null, 1, [SomeDirective])); + pv.bindTextNode(0, parser.parseBinding('foo').ast); + fancyView = pv.instantiate(new Object(), null, null); + }); + + it('attaching should update rootElementInjectors and parent RR', () => { + viewPort.insert(fancyView); + ListWrapper.forEach(fancyView.rootElementInjectors, (inj) => + expect(inj.parent).toBe(elementInjector)); + expect(parentView.recordRange.findFirstEnabledRecord()).not.toBe(null); + }); + + it('detaching should update rootElementInjectors and parent RR', () => { + viewPort.insert(fancyView); + viewPort.remove(); + ListWrapper.forEach(fancyView.rootElementInjectors, (inj) => + expect(inj.parent).toBe(null)); + expect(parentView.recordRange.findFirstEnabledRecord()).toBe(null); + }); + }); + }); +} + +class SomeDirective { + prop; + constructor() { + this.prop = 'foo'; + } +} diff --git a/modules/facade/src/dom.dart b/modules/facade/src/dom.dart index 6c316db578..e1d044b8fd 100644 --- a/modules/facade/src/dom.dart +++ b/modules/facade/src/dom.dart @@ -47,6 +47,12 @@ class DOM { static appendChild(el, node) { el.append(node); } + static removeChild(el, node) { + node.remove(); + } + static insertAfter(el, node) { + el.parentNode.insertBefore(node, el.nextNode); + } static setText(Text text, String value) { text.text = value; } diff --git a/modules/facade/src/dom.es6 b/modules/facade/src/dom.es6 index 89731ebefc..8bb76f0765 100644 --- a/modules/facade/src/dom.es6 +++ b/modules/facade/src/dom.es6 @@ -40,6 +40,12 @@ export class DOM { static appendChild(el, node) { el.appendChild(node); } + static removeChild(el, node) { + el.removeChild(node); + } + static insertAfter(el, node) { + el.parentNode.insertBefore(node, el.nextSibling); + } static setInnerHTML(el, value) { el.innerHTML = value; } diff --git a/modules/test_lib/src/test_lib.dart b/modules/test_lib/src/test_lib.dart index ec4543cb9c..c116f17123 100644 --- a/modules/test_lib/src/test_lib.dart +++ b/modules/test_lib/src/test_lib.dart @@ -33,6 +33,10 @@ class NotExpect extends gns.NotExpect { void toEqual(expected) => toHaveSameProps(expected); } +beforeEach(fn) { + gns.beforeEach(_enableReflection(fn)); +} + it(name, fn) { gns.it(name, _enableReflection(_handleAsync(fn))); } @@ -62,4 +66,4 @@ _handleAsync(fn) { } return fn; -} \ No newline at end of file +}