From 1cf45757cdd67f45a517f3538dafb1980d5531da Mon Sep 17 00:00:00 2001 From: Tobias Bosch Date: Fri, 25 Sep 2015 09:43:21 -0700 Subject: [PATCH] feat(render): add generic view factory based on the template commands Part of #3605 Closes #4367 --- .../angular2/src/core/dom/browser_adapter.ts | 4 +- .../angular2/src/core/dom/html_adapter.dart | 4 +- .../angular2/src/core/dom/parse5_adapter.ts | 9 +- modules/angular2/src/core/render/view.ts | 62 +++ .../angular2/src/core/render/view_factory.ts | 230 ++++++++ modules/angular2/src/test_lib/utils.ts | 3 +- .../test/core/dom/dom_adapter_spec.ts | 19 + .../test/core/render/view_factory_spec.ts | 526 ++++++++++++++++++ .../angular2/test/core/render/view_spec.ts | 38 ++ modules/angular2/test/core/spies.dart | 5 + modules/angular2/test/core/spies.ts | 14 +- 11 files changed, 906 insertions(+), 8 deletions(-) create mode 100644 modules/angular2/src/core/render/view.ts create mode 100644 modules/angular2/src/core/render/view_factory.ts create mode 100644 modules/angular2/test/core/render/view_factory_spec.ts create mode 100644 modules/angular2/test/core/render/view_spec.ts diff --git a/modules/angular2/src/core/dom/browser_adapter.ts b/modules/angular2/src/core/dom/browser_adapter.ts index e94b5fa6fc..c36e341b68 100644 --- a/modules/angular2/src/core/dom/browser_adapter.ts +++ b/modules/angular2/src/core/dom/browser_adapter.ts @@ -158,7 +158,9 @@ export class BrowserDomAdapter extends GenericBrowserDomAdapter { removeChild(el, node) { el.removeChild(node); } replaceChild(el: Node, newChild, oldChild) { el.replaceChild(newChild, oldChild); } remove(node): Node { - node.parentNode.removeChild(node); + if (node.parentNode) { + node.parentNode.removeChild(node); + } return node; } insertBefore(el, node) { el.parentNode.insertBefore(node, el); } diff --git a/modules/angular2/src/core/dom/html_adapter.dart b/modules/angular2/src/core/dom/html_adapter.dart index 5a13e1d13c..5ec4ae5cc0 100644 --- a/modules/angular2/src/core/dom/html_adapter.dart +++ b/modules/angular2/src/core/dom/html_adapter.dart @@ -221,9 +221,7 @@ class Html5LibDomAdapter implements DomAdapter { return new Element.tag(tagName); } - createTextNode(String text, [doc]) { - throw 'not implemented'; - } + createTextNode(String text, [doc]) => new Text(text); createScriptTag(String attrName, String attrValue, [doc]) { throw 'not implemented'; diff --git a/modules/angular2/src/core/dom/parse5_adapter.ts b/modules/angular2/src/core/dom/parse5_adapter.ts index a47eefdbc8..c655dda5c2 100644 --- a/modules/angular2/src/core/dom/parse5_adapter.ts +++ b/modules/angular2/src/core/dom/parse5_adapter.ts @@ -276,7 +276,11 @@ export class Parse5DomAdapter extends DomAdapter { createElement(tagName): HTMLElement { return treeAdapter.createElement(tagName, 'http://www.w3.org/1999/xhtml', []); } - createTextNode(text: string): Text { throw _notImplemented('createTextNode'); } + createTextNode(text: string): Text { + var t = this.createComment(text); + t.type = 'text'; + return t; + } createScriptTag(attrName: string, attrValue: string): HTMLElement { return treeAdapter.createElement("script", 'http://www.w3.org/1999/xhtml', [{name: attrName, value: attrValue}]); @@ -424,6 +428,9 @@ export class Parse5DomAdapter extends DomAdapter { setAttribute(element, attribute: string, value: string) { if (attribute) { element.attribs[attribute] = value; + if (attribute === 'class') { + element.className = value; + } } } removeAttribute(element, attribute: string) { diff --git a/modules/angular2/src/core/render/view.ts b/modules/angular2/src/core/render/view.ts new file mode 100644 index 0000000000..95c922942c --- /dev/null +++ b/modules/angular2/src/core/render/view.ts @@ -0,0 +1,62 @@ +import {BaseException} from 'angular2/src/core/facade/exceptions'; +import {ListWrapper, MapWrapper, Map, StringMapWrapper} from 'angular2/src/core/facade/collection'; +import {isPresent, isBlank, stringify} from 'angular2/src/core/facade/lang'; + +import { + RenderViewRef, + RenderEventDispatcher, + RenderTemplateCmd, + RenderProtoViewRef, + RenderFragmentRef +} from './api'; + +export class DefaultProtoViewRef extends RenderProtoViewRef { + constructor(public cmds: RenderTemplateCmd[]) { super(); } +} + +export class DefaultRenderFragmentRef extends RenderFragmentRef { + constructor(public nodes: N[]) { super(); } +} + +export class DefaultRenderView extends RenderViewRef { + hydrated: boolean = false; + eventDispatcher: RenderEventDispatcher = null; + globalEventRemovers: Function[] = null; + + constructor(public fragments: DefaultRenderFragmentRef[], public boundTextNodes: N[], + public boundElements: N[], public nativeShadowRoots: N[], + public globalEventAdders: Function[]) { + super(); + } + + hydrate() { + if (this.hydrated) throw new BaseException('The view is already hydrated.'); + this.hydrated = true; + this.globalEventRemovers = ListWrapper.createFixedSize(this.globalEventAdders.length); + for (var i = 0; i < this.globalEventAdders.length; i++) { + this.globalEventRemovers[i] = this.globalEventAdders[i](); + } + } + + dehydrate() { + if (!this.hydrated) throw new BaseException('The view is already dehydrated.'); + for (var i = 0; i < this.globalEventRemovers.length; i++) { + this.globalEventRemovers[i](); + } + this.globalEventRemovers = null; + this.hydrated = false; + } + + setEventDispatcher(dispatcher: RenderEventDispatcher) { this.eventDispatcher = dispatcher; } + + dispatchRenderEvent(boundElementIndex: number, eventName: string, event: any): boolean { + var allowDefaultBehavior = true; + if (isPresent(this.eventDispatcher)) { + var locals = new Map(); + locals.set('$event', event); + allowDefaultBehavior = + this.eventDispatcher.dispatchRenderEvent(boundElementIndex, eventName, locals); + } + return allowDefaultBehavior; + } +} diff --git a/modules/angular2/src/core/render/view_factory.ts b/modules/angular2/src/core/render/view_factory.ts new file mode 100644 index 0000000000..da3017e9fe --- /dev/null +++ b/modules/angular2/src/core/render/view_factory.ts @@ -0,0 +1,230 @@ +import {isBlank, isPresent} from 'angular2/src/core/facade/lang'; +import { + RenderEventDispatcher, + RenderTemplateCmd, + RenderCommandVisitor, + RenderBeginElementCmd, + RenderBeginComponentCmd, + RenderNgContentCmd, + RenderTextCmd, + RenderEmbeddedTemplateCmd +} from './api'; +import {DefaultRenderView, DefaultRenderFragmentRef} from './view'; + +export function createRenderView(fragmentCmds: RenderTemplateCmd[], inplaceElement: any, + nodeFactory: NodeFactory): DefaultRenderView { + var builders: RenderViewBuilder[] = []; + visitAll(new RenderViewBuilder(null, null, inplaceElement, builders, nodeFactory), + fragmentCmds); + var boundElements: any[] = []; + var boundTextNodes: any[] = []; + var nativeShadowRoots: any[] = []; + var fragments: DefaultRenderFragmentRef[] = []; + var viewElementOffset = 0; + var view: DefaultRenderView; + var eventDispatcher = (boundElementIndex: number, eventName: string, event: any) => + view.dispatchRenderEvent(boundElementIndex, eventName, event); + var globalEventAdders: Function[] = []; + + for (var i = 0; i < builders.length; i++) { + var builder = builders[i]; + addAll(builder.boundElements, boundElements); + addAll(builder.boundTextNodes, boundTextNodes); + addAll(builder.nativeShadowRoots, nativeShadowRoots); + if (isBlank(builder.rootNodesParent)) { + fragments.push(new DefaultRenderFragmentRef(builder.fragmentRootNodes)); + } + for (var j = 0; j < builder.eventData.length; j++) { + var eventData = builder.eventData[j]; + var boundElementIndex = eventData[0] + viewElementOffset; + var target = eventData[1]; + var eventName = eventData[2]; + if (isPresent(target)) { + var handler = + createEventHandler(boundElementIndex, `${target}:${eventName}`, eventDispatcher); + globalEventAdders.push(createGlobalEventAdder(target, eventName, handler, nodeFactory)); + } else { + var handler = createEventHandler(boundElementIndex, eventName, eventDispatcher); + nodeFactory.on(boundElements[boundElementIndex], eventName, handler); + } + } + viewElementOffset += builder.boundElements.length; + } + view = new DefaultRenderView(fragments, boundTextNodes, boundElements, nativeShadowRoots, + globalEventAdders); + return view; +} + +function createEventHandler(boundElementIndex: number, eventName: string, + eventDispatcher: Function): Function { + return ($event) => eventDispatcher(boundElementIndex, eventName, $event); +} + +function createGlobalEventAdder(target: string, eventName: string, eventHandler: Function, + nodeFactory: NodeFactory): Function { + return () => nodeFactory.globalOn(target, eventName, eventHandler); +} + +export interface NodeFactory { + resolveComponentTemplate(templateId: number): RenderTemplateCmd[]; + createTemplateAnchor(attrNameAndValues: string[]): N; + createElement(name: string, attrNameAndValues: string[]): N; + mergeElement(existing: N, attrNameAndValues: string[]); + createShadowRoot(host: N): N; + createText(value: string): N; + appendChild(parent: N, child: N); + on(element: N, eventName: string, callback: Function); + globalOn(target: string, eventName: string, callback: Function): Function; +} + +class RenderViewBuilder implements RenderCommandVisitor { + parentStack: Array>; + boundTextNodes: N[] = []; + boundElements: N[] = []; + eventData: any[][] = []; + + fragmentRootNodes: N[] = []; + nativeShadowRoots: N[] = []; + + constructor(public parentComponent: Component, public rootNodesParent: N, + public inplaceElement: N, public allBuilders: RenderViewBuilder[], + public factory: NodeFactory) { + this.parentStack = [rootNodesParent]; + allBuilders.push(this); + } + + get parent(): N | Component { return this.parentStack[this.parentStack.length - 1]; } + + visitText(cmd: RenderTextCmd, context: any): any { + var text = this.factory.createText(cmd.value); + this._addChild(text, cmd.ngContentIndex); + if (cmd.isBound) { + this.boundTextNodes.push(text); + } + return null; + } + visitNgContent(cmd: RenderNgContentCmd, context: any): any { + if (isPresent(this.parentComponent)) { + var projectedNodes = this.parentComponent.project(); + for (var i = 0; i < projectedNodes.length; i++) { + var node = projectedNodes[i]; + this._addChild(node, cmd.ngContentIndex); + } + } + return null; + } + visitBeginElement(cmd: RenderBeginElementCmd, context: any): any { + this.parentStack.push(this._beginElement(cmd)); + return null; + } + visitEndElement(context: any): any { + this._endElement(); + return null; + } + visitBeginComponent(cmd: RenderBeginComponentCmd, context: any): any { + var el = this._beginElement(cmd); + var root = el; + if (cmd.nativeShadow) { + root = this.factory.createShadowRoot(el); + this.nativeShadowRoots.push(root); + } + this.parentStack.push(new Component(el, root, cmd, this.factory)); + return null; + } + visitEndComponent(context: any): any { + var c = >this.parent; + var template = this.factory.resolveComponentTemplate(c.cmd.templateId); + this._visitChildTemplate(template, c, c.shadowRoot); + this._endElement(); + return null; + } + visitEmbeddedTemplate(cmd: RenderEmbeddedTemplateCmd, context: any): any { + var el = this.factory.createTemplateAnchor(cmd.attrNameAndValues); + this._addChild(el, cmd.ngContentIndex); + this.boundElements.push(el); + if (cmd.isMerged) { + this._visitChildTemplate(cmd.children, this.parentComponent, null); + } + return null; + } + + private _beginElement(cmd: RenderBeginElementCmd): N { + var el: N; + if (isPresent(this.inplaceElement)) { + el = this.inplaceElement; + this.inplaceElement = null; + this.factory.mergeElement(el, cmd.attrNameAndValues); + this.fragmentRootNodes.push(el); + } else { + el = this.factory.createElement(cmd.name, cmd.attrNameAndValues); + this._addChild(el, cmd.ngContentIndex); + } + if (cmd.isBound) { + this.boundElements.push(el); + for (var i = 0; i < cmd.eventTargetAndNames.length; i += 2) { + var target = cmd.eventTargetAndNames[i]; + var eventName = cmd.eventTargetAndNames[i + 1]; + this.eventData.push([this.boundElements.length - 1, target, eventName]); + } + } + return el; + } + + private _endElement() { this.parentStack.pop(); } + + private _visitChildTemplate(cmds: RenderTemplateCmd[], parent: Component, rootNodesParent: N) { + visitAll(new RenderViewBuilder(parent, rootNodesParent, null, this.allBuilders, this.factory), + cmds); + } + + private _addChild(node: N, ngContentIndex: number) { + var parent = this.parent; + if (isPresent(parent)) { + if (parent instanceof Component) { + parent.addContentNode(ngContentIndex, node); + } else { + this.factory.appendChild(parent, node); + } + } else { + this.fragmentRootNodes.push(node); + } + } +} + +class Component { + private contentNodesByNgContentIndex: N[][] = []; + private projectingNgContentIndex: number = 0; + + constructor(public hostElement: N, public shadowRoot: N, public cmd: RenderBeginComponentCmd, + public factory: NodeFactory) {} + addContentNode(ngContentIndex: number, node: N) { + if (isBlank(ngContentIndex)) { + if (this.cmd.nativeShadow) { + this.factory.appendChild(this.hostElement, node); + } + } else { + while (this.contentNodesByNgContentIndex.length <= ngContentIndex) { + this.contentNodesByNgContentIndex.push([]); + } + this.contentNodesByNgContentIndex[ngContentIndex].push(node); + } + } + project(): N[] { + var ngContentIndex = this.projectingNgContentIndex++; + return ngContentIndex < this.contentNodesByNgContentIndex.length ? + this.contentNodesByNgContentIndex[ngContentIndex] : + []; + } +} + +function addAll(source: any[], target: any[]) { + for (var i = 0; i < source.length; i++) { + target.push(source[i]); + } +} + +function visitAll(visitor: RenderCommandVisitor, fragmentCmds: RenderTemplateCmd[]) { + for (var i = 0; i < fragmentCmds.length; i++) { + fragmentCmds[i].visit(visitor, null); + } +} \ No newline at end of file diff --git a/modules/angular2/src/test_lib/utils.ts b/modules/angular2/src/test_lib/utils.ts index 40c7ebd7de..90085ad8d9 100644 --- a/modules/angular2/src/test_lib/utils.ts +++ b/modules/angular2/src/test_lib/utils.ts @@ -119,7 +119,8 @@ export function stringifyElement(el): string { result += '>'; // Children - var children = DOM.childNodes(DOM.templateAwareRoot(el)); + var childrenRoot = DOM.templateAwareRoot(el); + var children = isPresent(childrenRoot) ? DOM.childNodes(childrenRoot) : []; for (let j = 0; j < children.length; j++) { result += stringifyElement(children[j]); } diff --git a/modules/angular2/test/core/dom/dom_adapter_spec.ts b/modules/angular2/test/core/dom/dom_adapter_spec.ts index 8b9bf91be6..af01e8c042 100644 --- a/modules/angular2/test/core/dom/dom_adapter_spec.ts +++ b/modules/angular2/test/core/dom/dom_adapter_spec.ts @@ -50,6 +50,25 @@ export function main() { }); + it('should be able to create text nodes and use them with the other APIs', () => { + var t = DOM.createTextNode('hello'); + expect(DOM.isTextNode(t)).toBe(true); + var d = DOM.createElement('div'); + DOM.appendChild(d, t); + expect(DOM.getInnerHTML(d)).toEqual('hello'); + }); + + it('should set className via the class attribute', () => { + var d = DOM.createElement('div'); + DOM.setAttribute(d, 'class', 'class1'); + expect(d.className).toEqual('class1'); + }); + + it('should allow to remove nodes without parents', () => { + var d = DOM.createElement('div'); + expect(() => DOM.remove(d)).not.toThrow(); + }); + if (DOM.supportsDOMEvents()) { describe('getBaseHref', () => { beforeEach(() => DOM.resetBaseElement()); diff --git a/modules/angular2/test/core/render/view_factory_spec.ts b/modules/angular2/test/core/render/view_factory_spec.ts new file mode 100644 index 0000000000..7f91b74338 --- /dev/null +++ b/modules/angular2/test/core/render/view_factory_spec.ts @@ -0,0 +1,526 @@ +import { + AsyncTestCompleter, + beforeEach, + ddescribe, + describe, + expect, + iit, + inject, + it, + xit, + stringifyElement +} from 'angular2/test_lib'; + +import {isPresent} from 'angular2/src/core/facade/lang'; +import {MapWrapper, ListWrapper} from 'angular2/src/core/facade/collection'; +import * as appCmds from 'angular2/src/core/compiler/template_commands'; +import {createRenderView, NodeFactory} from 'angular2/src/core/render/view_factory'; +import {RenderTemplateCmd, RenderBeginElementCmd} from 'angular2/src/core/render/api'; +import {SpyRenderEventDispatcher} from '../spies'; +import {DOM} from 'angular2/src/core/dom/dom_adapter'; + +function beginElement(name: string, attrNameAndValues: string[], eventTargetAndNames: string[], + isBound: boolean, ngContentIndex: number): RenderBeginElementCmd { + return appCmds.beginElement(name, attrNameAndValues, eventTargetAndNames, [], [], isBound, + ngContentIndex) +} + +function endElement() { + return appCmds.endElement(); +} + +function text(value: string, isBound: boolean, ngContentIndex: number) { + return appCmds.text(value, isBound, ngContentIndex); +} + +function embeddedTemplate(attrNameAndValues: string[], isMerged: boolean, ngContentIndex: number, + children: any[]) { + return appCmds.embeddedTemplate(attrNameAndValues, [], [], isMerged, ngContentIndex, null, + children); +} + +function beginComponent(name: string, attrNameAndValues: string[], eventTargetAndNames: string[], + nativeShadow: boolean, ngContentIndex: number, templateId: number) { + return appCmds.beginComponent(name, attrNameAndValues, eventTargetAndNames, [], [], nativeShadow, + ngContentIndex, new appCmds.CompiledTemplate(templateId, null)); +} + +function endComponent() { + return appCmds.endComponent(); +} + +function ngContent(ngContentIndex: number) { + return appCmds.ngContent(ngContentIndex); +} + +export function main() { + describe('createRenderView', () => { + var nodeFactory: DomNodeFactory; + var eventDispatcher: SpyRenderEventDispatcher; + var componentTemplates: Map = new Map(); + beforeEach(() => { + nodeFactory = new DomNodeFactory(componentTemplates); + eventDispatcher = new SpyRenderEventDispatcher(); + }); + + describe('primitives', () => { + + it('should create elements with attributes', () => { + var view = createRenderView( + [beginElement('div', ['attr1', 'value1'], [], false, null), endElement()], null, + nodeFactory); + expect(stringifyFragment(view.fragments[0].nodes)).toEqual('
'); + }); + + it('should create host elements with attributes', () => { + componentTemplates.set(0, []); + var view = createRenderView( + [beginComponent('a-comp', ['attr1', 'value1'], [], false, null, 0), endElement()], null, + nodeFactory); + expect(stringifyFragment(view.fragments[0].nodes)) + .toEqual(''); + }); + + it('should create embedded templates with attributes', () => { + componentTemplates.set(0, []); + var view = createRenderView([embeddedTemplate(['attr1', 'value1'], false, null, [])], null, + nodeFactory); + expect(stringifyFragment(view.fragments[0].nodes)) + .toEqual(''); + }); + + it('should store bound elements', () => { + componentTemplates.set(0, []); + var view = createRenderView( + [ + beginElement('div', ['id', '1'], [], false, null), + endElement(), + beginElement('span', ['id', '2'], [], true, null), + endElement(), + beginComponent('a-comp', ['id', '3'], [], false, null, 0), + endElement(), + embeddedTemplate(['id', '4'], false, null, []) + ], + null, nodeFactory); + expect(mapAttrs(view.boundElements, 'id')).toEqual(['2', '3', '4']); + }); + + it('should use the inplace element for the first create element', () => { + var el = DOM.createElement('span'); + var view = createRenderView( + [ + beginElement('div', ['attr1', 'value1'], [], false, null), + endElement(), + beginElement('div', [], [], false, null), + endElement() + ], + el, nodeFactory); + expect(stringifyFragment(view.fragments[0].nodes)) + .toEqual('
'); + }); + + it('should create text nodes', () => { + var view = createRenderView([text('someText', false, null)], null, nodeFactory); + expect(stringifyFragment(view.fragments[0].nodes)).toEqual('someText'); + }); + + it('should store bound text nodes', () => { + var view = + createRenderView([text('1', false, null), text('2', true, null)], null, nodeFactory); + expect(stringifyElement(view.boundTextNodes[0])).toEqual('2'); + }); + + it('should register element event listeners', () => { + componentTemplates.set(0, []); + var view = createRenderView( + [ + beginElement('div', [], [null, 'click'], true, null), + endElement(), + beginComponent('a-comp', [], [null, 'click'], false, null, 0), + endElement(), + ], + null, nodeFactory); + view.setEventDispatcher(eventDispatcher); + var event = {}; + nodeFactory.triggerLocalEvent(view.boundElements[0], 'click', event); + nodeFactory.triggerLocalEvent(view.boundElements[1], 'click', event); + expect(eventDispatcher.spy('dispatchRenderEvent')) + .toHaveBeenCalledWith(0, 'click', MapWrapper.createFromStringMap({'$event': event})); + expect(eventDispatcher.spy('dispatchRenderEvent')) + .toHaveBeenCalledWith(1, 'click', MapWrapper.createFromStringMap({'$event': event})); + }); + + it('should register element global event listeners', () => { + var view = createRenderView( + [ + beginElement('div', [], ['window', 'scroll'], true, null), + endElement(), + beginComponent('a-comp', [], ['window', 'scroll'], false, null, 0), + endElement(), + ], + null, nodeFactory); + view.hydrate(); + view.setEventDispatcher(eventDispatcher); + var event = {}; + nodeFactory.triggerGlobalEvent('window', 'scroll', event); + expect(eventDispatcher.spy('dispatchRenderEvent')) + .toHaveBeenCalledWith(0, 'window:scroll', + MapWrapper.createFromStringMap({'$event': event})); + expect(eventDispatcher.spy('dispatchRenderEvent')) + .toHaveBeenCalledWith(1, 'window:scroll', + MapWrapper.createFromStringMap({'$event': event})); + }); + + }); + + describe('nested nodes', () => { + it('should create nested node', () => { + var view = createRenderView( + [ + beginElement('a', [], [], false, null), + beginElement('b', [], [], false, null), + text('someText', false, null), + endElement(), + endElement(), + ], + null, nodeFactory); + expect(stringifyFragment(view.fragments[0].nodes)).toEqual('someText'); + }); + + it('should store bound elements in depth first order', () => { + var view = createRenderView( + [ + beginElement('a', ['id', '1'], [], false, null), + endElement(), + beginElement('a', ['id', '2'], [], true, null), + beginElement('a', ['id', '3'], [], false, null), + endElement(), + beginElement('a', ['id', '4'], [], true, null), + endElement(), + endElement(), + beginElement('a', ['id', '5'], [], false, null), + endElement(), + beginElement('a', ['id', '6'], [], true, null), + endElement(), + ], + null, nodeFactory); + expect(mapAttrs(view.boundElements, 'id')).toEqual(['2', '4', '6']); + }); + + it('should store bound text nodes in depth first order', () => { + var view = createRenderView( + [ + text('1', false, null), + text('2', true, null), + beginElement('a', [], [], false, null), + text('3', false, null), + text('4', true, null), + endElement(), + text('5', false, null), + text('6', true, null), + ], + null, nodeFactory); + expect(mapText(view.boundTextNodes)).toEqual(['2', '4', '6']); + }); + }); + + describe('merged embedded templates', () => { + it('should create separate fragments', () => { + var view = createRenderView( + [embeddedTemplate(['attr1', 'value1'], true, null, [text('someText', false, null)])], + null, nodeFactory); + expect(view.fragments.length).toBe(2); + expect(stringifyFragment(view.fragments[1].nodes)).toEqual('someText'); + }); + + it('should store bound elements after the bound elements of earlier fragments', () => { + var view = + createRenderView( + [ + beginElement('a', ['id', '1.1'], [], true, null), + endElement(), + embeddedTemplate(['id', '1.2'], true, null, + [ + embeddedTemplate(['id', '2.1'], true, null, + [ + beginElement('a', ['id', '3.1'], + [], true, null), + endElement() + ]), + beginElement('a', ['id', '2.2'], [], true, null), + endElement(), + ]), + beginElement('a', ['id', '1.3'], [], true, null), + endElement(), + ], + null, nodeFactory); + expect(mapAttrs(view.boundElements, 'id')) + .toEqual(['1.1', '1.2', '1.3', '2.1', '2.2', '3.1']); + }); + + it('should store bound text nodes after the bound text nodes of earlier fragments', () => { + var view = + createRenderView( + [ + text('1.1', true, null), + embeddedTemplate(['id', '1.2'], true, null, + [ + text('2.1', true, null), + embeddedTemplate(['id', '2.1'], true, null, + [ + text('3.1', true, null), + ]), + text('2.2', true, null), + ]), + text('1.2', true, null), + ], + null, nodeFactory); + expect(mapText(view.boundTextNodes)).toEqual(['1.1', '1.2', '2.1', '2.2', '3.1']); + }); + + }); + + describe('non merged embedded templates', () => { + it('should only create the anchor element', () => { + var view = createRenderView( + [ + embeddedTemplate(['id', '1.1'], false, null, + [ + text('someText', true, null), + beginElement('a', ['id', '2.1'], [], true, null), + endElement() + ]) + ], + null, nodeFactory); + expect(view.fragments.length).toBe(1); + expect(stringifyFragment(view.fragments[0].nodes)) + .toEqual(''); + expect(view.boundTextNodes.length).toBe(0); + expect(mapAttrs(view.boundElements, 'id')).toEqual(['1.1']); + }); + }); + + describe('components', () => { + it('should store the component template in the same fragment', () => { + componentTemplates.set(0, [ + text('hello', false, null), + ]); + var view = createRenderView( + [beginComponent('my-comp', [], [], false, null, 0), endComponent()], null, nodeFactory); + expect(view.fragments.length).toBe(1); + expect(stringifyFragment(view.fragments[0].nodes)).toEqual('hello'); + }); + + it('should use native shadow DOM', () => { + componentTemplates.set(0, [ + text('hello', false, null), + ]); + var view = createRenderView( + [beginComponent('my-comp', [], [], true, null, 0), endComponent()], null, nodeFactory); + expect(view.fragments.length).toBe(1); + expect(stringifyFragment(view.fragments[0].nodes)) + .toEqual('hello'); + }); + + it('should store bound elements after the bound elements of the main template', () => { + componentTemplates.set(0, [ + beginComponent('b-comp', ['id', '2.1'], [], false, null, 1), + endComponent(), + beginComponent('b-comp', ['id', '2.2'], [], false, null, 1), + endComponent(), + ]); + componentTemplates.set(1, [beginElement('a', ['id', '3.1'], [], true, null), endElement()]); + var view = createRenderView( + [ + beginElement('a', ['id', '1.1'], [], true, null), + endElement(), + beginComponent('a-comp', ['id', '1.2'], [], false, null, 0), + beginElement('a', ['id', '1.3'], [], true, null), + endElement(), + endComponent(), + beginElement('a', ['id', '1.4'], [], true, null), + endElement(), + ], + null, nodeFactory); + + expect(mapAttrs(view.boundElements, 'id')) + .toEqual(['1.1', '1.2', '1.3', '1.4', '2.1', '2.2', '3.1', '3.1']); + }); + + it('should store bound text nodes after the bound text nodes of the main template', () => { + componentTemplates.set(0, [ + text('2.1', true, null), + beginComponent('b-comp', [], [], false, null, 1), + endComponent(), + beginComponent('b-comp', [], [], false, null, 1), + endComponent(), + text('2.2', true, null), + ]); + componentTemplates.set(1, [ + text('3.1', true, null), + ]); + var view = createRenderView( + [ + text('1.1', true, null), + beginComponent('a-comp', [], [], false, null, 0), + text('1.2', true, null), + endComponent(), + text('1.3', true, null), + ], + null, nodeFactory); + + expect(mapText(view.boundTextNodes)) + .toEqual(['1.1', '1.2', '1.3', '2.1', '2.2', '3.1', '3.1']); + }); + }); + + describe('content projection', () => { + it('should remove non projected nodes', () => { + componentTemplates.set(0, []); + var view = createRenderView( + [ + beginComponent('my-comp', [], [], false, null, 0), + text('hello', false, null), + endComponent() + ], + null, nodeFactory); + expect(stringifyFragment(view.fragments[0].nodes)).toEqual(''); + }); + + it('should keep non projected nodes in the light dom when using native shadow dom', () => { + componentTemplates.set(0, []); + var view = createRenderView( + [ + beginComponent('my-comp', [], [], true, null, 0), + text('hello', false, null), + endComponent() + ], + null, nodeFactory); + var rootEl = view.fragments[0].nodes[0]; + expect(stringifyElement(rootEl)) + .toEqual('hello'); + }); + + it('should project commands based on their ngContentIndex', () => { + componentTemplates.set(0, [ + text('(', false, null), + ngContent(null), + text(',', false, null), + ngContent(null), + text(')', false, null) + ]); + var view = createRenderView( + [ + beginComponent('my-comp', [], [], false, null, 0), + text('2', false, 1), + text('1', false, 0), + endComponent() + ], + null, nodeFactory); + expect(stringifyFragment(view.fragments[0].nodes)).toEqual('(1,2)'); + }); + + it('should reproject nodes over multiple ng-content commands', () => { + componentTemplates.set( + 0, [beginComponent('b-comp', [], [], false, null, 1), ngContent(0), endComponent()]); + componentTemplates.set(1, + [text('(', false, null), ngContent(null), text(')', false, null)]); + var view = createRenderView( + [ + beginComponent('a-comp', [], [], false, null, 0), + text('hello', false, 0), + endComponent() + ], + null, nodeFactory); + expect(stringifyFragment(view.fragments[0].nodes)) + .toEqual('(hello)'); + }); + }); + }); +} + +class DomNodeFactory implements NodeFactory { + private _globalEventListeners: GlobalEventListener[] = []; + private _localEventListeners: LocalEventListener[] = []; + + constructor(private _components: Map) {} + + triggerLocalEvent(el: Element, eventName: string, event: any) { + this._localEventListeners.forEach(listener => { + if (listener.eventName == eventName) { + listener.callback(event); + } + }); + } + + triggerGlobalEvent(target: string, eventName: string, event: any) { + this._globalEventListeners.forEach(listener => { + if (listener.eventName == eventName && listener.target == target) { + listener.callback(event); + } + }); + } + + resolveComponentTemplate(templateId: number): RenderTemplateCmd[] { + return this._components.get(templateId); + } + createTemplateAnchor(attrNameAndValues: string[]): Node { + var el = DOM.createElement('template'); + this._setAttributes(el, attrNameAndValues); + return el; + } + createElement(name: string, attrNameAndValues: string[]): Node { + var el = DOM.createElement(name); + this._setAttributes(el, attrNameAndValues); + return el; + } + mergeElement(existing: Node, attrNameAndValues: string[]) { + DOM.clearNodes(existing); + this._setAttributes(existing, attrNameAndValues); + } + private _setAttributes(el: Node, attrNameAndValues: string[]) { + for (var attrIdx = 0; attrIdx < attrNameAndValues.length; attrIdx += 2) { + DOM.setAttribute(el, attrNameAndValues[attrIdx], attrNameAndValues[attrIdx + 1]); + } + } + createShadowRoot(host: Node): Node { + var root = DOM.createElement('shadow-root'); + DOM.appendChild(host, root); + return root; + } + createText(value: string): Node { return DOM.createTextNode(isPresent(value) ? value : ''); } + appendChild(parent: Node, child: Node) { DOM.appendChild(parent, child); } + on(element: Node, eventName: string, callback: Function) { + this._localEventListeners.push(new LocalEventListener(element, eventName, callback)); + } + globalOn(target: string, eventName: string, callback: Function): Function { + var listener = new GlobalEventListener(target, eventName, callback); + this._globalEventListeners.push(listener); + return () => { + var index = this._globalEventListeners.indexOf(listener); + if (index !== -1) { + this._globalEventListeners.splice(index, 1); + } + } + } +} + +class LocalEventListener { + constructor(public element: Node, public eventName: string, public callback: Function) {} +} + +class GlobalEventListener { + constructor(public target: string, public eventName: string, public callback: Function) {} +} + +function stringifyFragment(nodes: Node[]) { + return nodes.map(stringifyElement).join(''); +} + +function mapAttrs(nodes: Node[], attrName): string[] { + return nodes.map(node => DOM.getAttribute(node, attrName)); +} + +function mapText(nodes: Node[]): string[] { + return nodes.map(node => DOM.getText(node)); +} \ No newline at end of file diff --git a/modules/angular2/test/core/render/view_spec.ts b/modules/angular2/test/core/render/view_spec.ts new file mode 100644 index 0000000000..ac8909a33b --- /dev/null +++ b/modules/angular2/test/core/render/view_spec.ts @@ -0,0 +1,38 @@ +import { + AsyncTestCompleter, + beforeEach, + ddescribe, + describe, + expect, + iit, + inject, + it, + xit +} from 'angular2/test_lib'; + +import {DefaultRenderView} from 'angular2/src/core/render/view'; + +export function main() { + describe('DefaultRenderView', () => { + describe('hydrate', () => { + it('should register global event listeners', () => { + var addCount = 0; + var adder = () => { addCount++ }; + var view = new DefaultRenderView([], [], [], [], [adder]); + view.hydrate(); + expect(addCount).toBe(1); + }); + }); + + describe('dehydrate', () => { + it('should deregister global event listeners', () => { + var removeCount = 0; + var adder = () => () => { removeCount++ }; + var view = new DefaultRenderView([], [], [], [], [adder]); + view.hydrate(); + view.dehydrate(); + expect(removeCount).toBe(1); + }); + }); + }); +} diff --git a/modules/angular2/test/core/spies.dart b/modules/angular2/test/core/spies.dart index 09b65531eb..729b14dd46 100644 --- a/modules/angular2/test/core/spies.dart +++ b/modules/angular2/test/core/spies.dart @@ -110,3 +110,8 @@ class SpyDomAdapter extends SpyObject implements DomAdapter { class SpyXHR extends SpyObject implements XHR { noSuchMethod(m) => super.noSuchMethod(m); } + +@proxy +class SpyRenderEventDispatcher extends SpyObject implements RenderEventDispatcher { + noSuchMethod(m) => super.noSuchMethod(m); +} diff --git a/modules/angular2/test/core/spies.ts b/modules/angular2/test/core/spies.ts index e1e1867b7c..c650140d4e 100644 --- a/modules/angular2/test/core/spies.ts +++ b/modules/angular2/test/core/spies.ts @@ -6,7 +6,7 @@ import { DynamicChangeDetector } from 'angular2/src/core/change_detection/change_detection'; -import {RenderCompiler, Renderer} from 'angular2/src/core/render/api'; +import {RenderCompiler, Renderer, RenderEventDispatcher} from 'angular2/src/core/render/api'; import {DirectiveResolver} from 'angular2/src/core/compiler/directive_resolver'; import {AppView} from 'angular2/src/core/compiler/view'; @@ -92,4 +92,14 @@ export class SpyDomAdapter extends SpyObject { export class SpyXHR extends SpyObject { constructor() { super(XHR); } -} \ No newline at end of file +} + +export class SpyRenderEventDispatcher extends SpyObject { + constructor() { + // Note: RenderEventDispatcher is an interface, + // so we can't pass it to super() and have to register + // the spy methods on our own. + super(); + this.spy('dispatchRenderEvent'); + } +}