From bfa18ffd9b60c183723615127cf30addc708acb9 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Fri, 6 Mar 2015 15:44:59 +0100 Subject: [PATCH] feat(Directives): add the ability to declaratively bind events relates to #621 --- .../src/core/annotations/annotations.js | 16 +++- .../src/core/compiler/element_binder.js | 4 +- .../src/core/compiler/element_injector.js | 26 +++--- .../core/compiler/pipeline/compile_element.js | 2 +- .../pipeline/element_binder_builder.js | 17 +++- modules/angular2/src/core/compiler/view.js | 87 +++++++++++++----- .../test/core/compiler/integration_spec.js | 90 ++++++++++++++++++- .../pipeline/element_binder_builder_spec.js | 40 ++++++++- .../angular2/test/core/compiler/view_spec.js | 26 +++++- 9 files changed, 259 insertions(+), 49 deletions(-) diff --git a/modules/angular2/src/core/annotations/annotations.js b/modules/angular2/src/core/annotations/annotations.js index b0a4e0882a..eb31368ada 100644 --- a/modules/angular2/src/core/annotations/annotations.js +++ b/modules/angular2/src/core/annotations/annotations.js @@ -8,16 +8,19 @@ export class Directive { lightDomServices:any; //List; implementsTypes:any; //List; lifecycle:any; //List + events:any; //List @CONST() constructor({ selector, bind, + events, lightDomServices, implementsTypes, lifecycle }:{ selector:string, bind:any, + events: any, lightDomServices:List, implementsTypes:List, lifecycle:List @@ -27,6 +30,7 @@ export class Directive { this.lightDomServices = lightDomServices; this.implementsTypes = implementsTypes; this.bind = bind; + this.events = events; this.lifecycle = lifecycle; } @@ -37,15 +41,14 @@ export class Directive { export class Component extends Directive { //TODO: vsavkin: uncomment it once the issue with defining fields in a sublass works - lightDomServices:any; //List; shadowDomServices:any; //List; componentServices:any; //List; - lifecycle:any; //List @CONST() constructor({ selector, bind, + events, lightDomServices, shadowDomServices, componentServices, @@ -54,6 +57,7 @@ export class Component extends Directive { }:{ selector:String, bind:Object, + events:Object, lightDomServices:List, shadowDomServices:List, componentServices:List, @@ -64,15 +68,14 @@ export class Component extends Directive { super({ selector: selector, bind: bind, + events: events, lightDomServices: lightDomServices, implementsTypes: implementsTypes, lifecycle: lifecycle }); - this.lightDomServices = lightDomServices; this.shadowDomServices = shadowDomServices; this.componentServices = componentServices; - this.lifecycle = lifecycle; } } @@ -82,6 +85,7 @@ export class Decorator extends Directive { constructor({ selector, bind, + events, lightDomServices, implementsTypes, lifecycle, @@ -89,6 +93,7 @@ export class Decorator extends Directive { }:{ selector:string, bind:any, + events:any, lightDomServices:List, implementsTypes:List, lifecycle:List, @@ -99,6 +104,7 @@ export class Decorator extends Directive { super({ selector: selector, bind: bind, + events: events, lightDomServices: lightDomServices, implementsTypes: implementsTypes, lifecycle: lifecycle @@ -111,6 +117,7 @@ export class Viewport extends Directive { constructor({ selector, bind, + events, lightDomServices, implementsTypes, lifecycle @@ -125,6 +132,7 @@ export class Viewport extends Directive { super({ selector: selector, bind: bind, + events: events, lightDomServices: lightDomServices, implementsTypes: implementsTypes, lifecycle: lifecycle diff --git a/modules/angular2/src/core/compiler/element_binder.js b/modules/angular2/src/core/compiler/element_binder.js index c9515b8afb..eb4bce90a5 100644 --- a/modules/angular2/src/core/compiler/element_binder.js +++ b/modules/angular2/src/core/compiler/element_binder.js @@ -1,6 +1,6 @@ import {ProtoElementInjector} from './element_injector'; import {DirectiveMetadata} from './directive_metadata'; -import {List, Map} from 'angular2/src/facade/collection'; +import {List, StringMap} from 'angular2/src/facade/collection'; import {ProtoView} from './view'; export class ElementBinder { @@ -10,7 +10,7 @@ export class ElementBinder { textNodeIndices:List; hasElementPropertyBindings:boolean; nestedProtoView: ProtoView; - events:Map; + events:StringMap; constructor( protoElementInjector: ProtoElementInjector, componentDirective:DirectiveMetadata, viewportDirective:DirectiveMetadata) { diff --git a/modules/angular2/src/core/compiler/element_injector.js b/modules/angular2/src/core/compiler/element_injector.js index 8e90a76a0a..36b6d0b984 100644 --- a/modules/angular2/src/core/compiler/element_injector.js +++ b/modules/angular2/src/core/compiler/element_injector.js @@ -1,6 +1,6 @@ import {FIELD, isPresent, isBlank, Type, int, BaseException} from 'angular2/src/facade/lang'; import {Math} from 'angular2/src/facade/math'; -import {List, ListWrapper, MapWrapper} from 'angular2/src/facade/collection'; +import {List, ListWrapper, MapWrapper, StringMap, StringMapWrapper} from 'angular2/src/facade/collection'; import {Injector, Key, Dependency, bind, Binding, NoProviderError, ProviderError, CyclicDependencyError} from 'angular2/di'; import {Parent, Ancestor} from 'angular2/src/core/annotations/visibility'; import {EventEmitter, PropertySetter} from 'angular2/src/core/annotations/di'; @@ -18,6 +18,8 @@ var MAX_DEPTH = Math.pow(2, 30) - 1; var _undefined = new Object(); +var _noop = function(_) {}; + var _staticKeys; class StaticKeys { @@ -270,9 +272,9 @@ export class ProtoElementInjector { } } - instantiate(parent:ElementInjector, host:ElementInjector, eventCallbacks, - reflector: Reflector):ElementInjector { - return new ElementInjector(this, parent, host, eventCallbacks, reflector); + instantiate(parent:ElementInjector, host:ElementInjector, events, + reflector: Reflector):ElementInjector { + return new ElementInjector(this, parent, host, events, reflector); } directParent(): ProtoElementInjector { @@ -325,11 +327,11 @@ export class ElementInjector extends TreeNode { _obj9:any; _preBuiltObjects; _constructionCounter; - _eventCallbacks; + _events:StringMap; _refelector: Reflector; constructor(proto:ProtoElementInjector, parent:ElementInjector, host:ElementInjector, - eventCallbacks: Map, reflector: Reflector) { + events: StringMap, reflector: Reflector) { super(parent); if (isPresent(parent) && isPresent(host)) { throw new BaseException('Only either parent or host is allowed'); @@ -348,7 +350,7 @@ export class ElementInjector extends TreeNode { this._preBuiltObjects = null; this._lightDomAppInjector = null; this._shadowDomAppInjector = null; - this._eventCallbacks = eventCallbacks; + this._events = events; this._obj0 = null; this._obj1 = null; this._obj2 = null; @@ -513,13 +515,13 @@ export class ElementInjector extends TreeNode { _buildEventEmitter(dep) { var view = this._getPreBuiltObjectByKeyId(StaticKeys.instance().viewId); - if (isPresent(this._eventCallbacks)) { - var callback = MapWrapper.get(this._eventCallbacks, dep.eventEmitterName); - if (isPresent(callback)) { - return ProtoView.buildInnerCallback(callback, view); + if (isPresent(this._events)) { + var eventMap = StringMapWrapper.get(this._events, dep.eventEmitterName); + if (isPresent(eventMap)) { + return ProtoView.buildEventCallback(eventMap, view, this._proto.index); } } - return (_) => {}; + return _noop; } _buildPropSetter(dep) { diff --git a/modules/angular2/src/core/compiler/pipeline/compile_element.js b/modules/angular2/src/core/compiler/pipeline/compile_element.js index fd3adfb814..4f7ab4744d 100644 --- a/modules/angular2/src/core/compiler/pipeline/compile_element.js +++ b/modules/angular2/src/core/compiler/pipeline/compile_element.js @@ -183,7 +183,7 @@ function getElementDescription(domElement):string { buf.add("<"); buf.add(DOM.tagName(domElement).toLowerCase()); - + // show id and class first to ease element identification addDescriptionAttribute(buf, "id", MapWrapper.get(atts, "id")); addDescriptionAttribute(buf, "class", MapWrapper.get(atts, "class")); diff --git a/modules/angular2/src/core/compiler/pipeline/element_binder_builder.js b/modules/angular2/src/core/compiler/pipeline/element_binder_builder.js index 7de2067281..58748d15c7 100644 --- a/modules/angular2/src/core/compiler/pipeline/element_binder_builder.js +++ b/modules/angular2/src/core/compiler/pipeline/element_binder_builder.js @@ -152,7 +152,9 @@ export class ElementBinderBuilder extends CompileStep { if (isPresent(current.eventBindings)) { this._bindEvents(protoView, current); } - this._bindDirectiveProperties(current.getAllDirectives(), current); + var directives = current.getAllDirectives(); + this._bindDirectiveProperties(directives, current); + this._bindDirectiveEvents(directives, current); } else if (isPresent(parent)) { elementBinder = parent.inheritedElementBinder; } @@ -199,6 +201,19 @@ export class ElementBinderBuilder extends CompileStep { }); } + _bindDirectiveEvents(directives: List, compileElement: CompileElement) { + for (var directiveIndex = 0; directiveIndex < directives.length; directiveIndex++) { + var directive = directives[directiveIndex]; + var annotation = directive.annotation; + if (isBlank(annotation.events)) continue; + var protoView = compileElement.inheritedProtoView; + StringMapWrapper.forEach(annotation.events, (action, eventName) => { + var expression = this._parser.parseAction(action, compileElement.elementDescription); + protoView.bindEvent(eventName, expression, directiveIndex); + }); + } + } + _bindDirectiveProperties(directives: List, compileElement: CompileElement) { var protoView = compileElement.inheritedProtoView; diff --git a/modules/angular2/src/core/compiler/view.js b/modules/angular2/src/core/compiler/view.js index 89392cb396..8213a73abc 100644 --- a/modules/angular2/src/core/compiler/view.js +++ b/modules/angular2/src/core/compiler/view.js @@ -1,6 +1,6 @@ import {DOM} from 'angular2/src/dom/dom_adapter'; import {Promise} from 'angular2/src/facade/async'; -import {ListWrapper, MapWrapper, StringMapWrapper, List} from 'angular2/src/facade/collection'; +import {ListWrapper, MapWrapper, Map, StringMapWrapper, List} from 'angular2/src/facade/collection'; import {AST, ContextWithVariableBindings, ChangeDispatcher, ProtoChangeDetector, ChangeDetector, ChangeRecord} from 'angular2/change_detection'; @@ -326,8 +326,8 @@ export class ProtoView { } var elementsWithBindings = ListWrapper.createFixedSize(elementsWithBindingsDynamic.length); - for (var i = 0; i < elementsWithBindingsDynamic.length; ++i) { - elementsWithBindings[i] = elementsWithBindingsDynamic[i]; + for (var binderIdx = 0; binderIdx < elementsWithBindingsDynamic.length; ++binderIdx) { + elementsWithBindings[binderIdx] = elementsWithBindingsDynamic[binderIdx]; } var viewNodes; @@ -353,13 +353,13 @@ export class ProtoView { var viewContainers = []; var componentChildViews = []; - for (var i = 0; i < binders.length; i++) { - var binder = binders[i]; + for (var binderIdx = 0; binderIdx < binders.length; binderIdx++) { + var binder = binders[binderIdx]; var element; - if (i === 0 && this.rootBindingOffset === 1) { + if (binderIdx === 0 && this.rootBindingOffset === 1) { element = rootElementClone; } else { - element = elementsWithBindings[i - this.rootBindingOffset]; + element = elementsWithBindings[binderIdx - this.rootBindingOffset]; } var elementInjector = null; @@ -376,7 +376,7 @@ export class ProtoView { ListWrapper.push(rootElementInjectors, elementInjector); } } - elementInjectors[i] = elementInjector; + elementInjectors[binderIdx] = elementInjector; if (binder.hasElementPropertyBindings) { ListWrapper.push(elementsWithPropertyBindings, element); @@ -421,15 +421,15 @@ export class ProtoView { // preBuiltObjects if (isPresent(elementInjector)) { - preBuiltObjects[i] = new PreBuiltObjects(view, new NgElement(element), viewContainer, + preBuiltObjects[binderIdx] = new PreBuiltObjects(view, new NgElement(element), viewContainer, lightDom, bindingPropagationConfig); } // events if (isPresent(binder.events)) { - MapWrapper.forEach(binder.events, (expr, eventName) => { + StringMapWrapper.forEach(binder.events, (eventMap, eventName) => { if (isBlank(elementInjector) || !elementInjector.hasEventEmitter(eventName)) { - var handler = ProtoView.buildInnerCallback(expr, view); + var handler = ProtoView.buildEventCallback(eventMap, view, binderIdx); eventManager.addEventListener(element, eventName, handler); } }); @@ -446,17 +446,39 @@ export class ProtoView { this._viewPool.push(view); } - static buildInnerCallback(expr:AST, view:View) { + /** + * Create an event callback invoked in the context of the enclosing View + * + * @param {AST} expr + * @param {View} view + * @returns {Function} + */ + + /** + * Creates the event callback. + * + * @param {Map} eventMap Map directiveIndexes to expressions + * @param {View} view + * @param {int} injectorIdx + * @returns {Function} + */ + static buildEventCallback(eventMap: Map, view:View, injectorIdx: int) { var locals = MapWrapper.create(); return (event) => { - // Most of the time the event will be fired only when the view is - // in the live document. However, in a rare circumstance the - // view might get dehydrated, in between the event queuing up and - // firing. + // Most of the time the event will be fired only when the view is in the live document. + // However, in a rare circumstance the view might get dehydrated, in between the event + // queuing up and firing. if (view.hydrated()) { MapWrapper.set(locals, '$event', event); - var context = new ContextWithVariableBindings(view.context, locals); - expr.eval(context); + MapWrapper.forEach(eventMap, (expr, directiveIndex) => { + var context; + if (directiveIndex === -1) { + context = view.context; + } else { + context = view.elementInjectors[injectorIdx].getDirectiveAtIndex(directiveIndex); + } + expr.eval(new ContextWithVariableBindings(context, locals)); + }); } } } @@ -505,14 +527,31 @@ export class ProtoView { } /** - * Adds an event binding for the last created ElementBinder via bindElement + * Adds an event binding for the last created ElementBinder via bindElement. + * + * If the directive index is a positive integer, the event is evaluated in the context of + * the given directive. + * + * If the directive index is -1, the event is evaluated in the context of the enclosing view. + * + * @param {string} eventName + * @param {AST} expression + * @param {int} directiveIndex The directive index in the binder or -1 when the event is not bound + * to a directive */ - bindEvent(eventName:string, expression:AST) { - var elBinder = this.elementBinders[this.elementBinders.length-1]; - if (isBlank(elBinder.events)) { - elBinder.events = MapWrapper.create(); + bindEvent(eventName:string, expression:AST, directiveIndex: int = -1) { + var elBinder = this.elementBinders[this.elementBinders.length - 1]; + var events = elBinder.events; + if (isBlank(events)) { + events = StringMapWrapper.create(); + elBinder.events = events; } - MapWrapper.set(elBinder.events, eventName, expression); + var event = StringMapWrapper.get(events, eventName); + if (isBlank(event)) { + event = MapWrapper.create(); + StringMapWrapper.set(events, eventName, event); + } + MapWrapper.set(event, directiveIndex, expression); } /** diff --git a/modules/angular2/test/core/compiler/integration_spec.js b/modules/angular2/test/core/compiler/integration_spec.js index 9f067f7601..58cc788269 100644 --- a/modules/angular2/test/core/compiler/integration_spec.js +++ b/modules/angular2/test/core/compiler/integration_spec.js @@ -19,9 +19,14 @@ import {UrlResolver} from 'angular2/src/core/compiler/url_resolver'; import {StyleUrlResolver} from 'angular2/src/core/compiler/style_url_resolver'; import {CssProcessor} from 'angular2/src/core/compiler/css_processor'; +import {EventManager, DomEventsPlugin} from 'angular2/src/core/events/event_manager'; + +import {VmTurnZone} from 'angular2/src/core/zone/vm_turn_zone'; + import {Decorator, Component, Viewport} from 'angular2/src/core/annotations/annotations'; import {Template} from 'angular2/src/core/annotations/template'; import {Parent, Ancestor} from 'angular2/src/core/annotations/visibility'; +import {EventEmitter} from 'angular2/src/core/annotations/di'; import {If} from 'angular2/src/directives/if'; @@ -57,7 +62,11 @@ export function main() { var view, ctx, cd; function createView(pv) { ctx = new MyComp(); - view = pv.instantiate(null, null, reflector); + view = pv.instantiate( + null, + new EventManager([new DomEventsPlugin()], new FakeVmTurnZone()), + reflector + ); view.hydrate(new Injector([]), null, ctx); cd = view.changeDetector; } @@ -435,6 +444,32 @@ export function main() { done(); }) }); + + it('should support events', (done) => { + tplResolver.setTemplate(MyComp, new Template({ + inline: '
', + directives: [DecoratorEmitingEvent, DecoratorListeningEvent] + })); + + compiler.compile(MyComp).then((pv) => { + createView(pv); + + var injector = view.elementInjectors[0]; + + var emitter = injector.get(DecoratorEmitingEvent); + var listener = injector.get(DecoratorListeningEvent); + + expect(emitter.msg).toEqual(''); + expect(listener.msg).toEqual(''); + + emitter.fireEvent('fired !'); + expect(emitter.msg).toEqual('fired !'); + expect(listener.msg).toEqual('fired !'); + + done(); + }); + }); + }); if (assertionsEnabled()) { @@ -651,3 +686,56 @@ class DoublePipeFactory { return new DoublePipe(); } } + +@Decorator({ + selector: '[emitter]', + events: {'event': 'onEvent($event)'} +}) +class DecoratorEmitingEvent { + msg: string; + emitter; + + constructor(@EventEmitter('event') emitter:Function) { + this.msg = ''; + this.emitter = emitter; + } + + fireEvent(msg: string) { + this.emitter(msg); + } + + onEvent(msg: string) { + this.msg = msg; + } +} + +@Decorator({ + selector: '[listener]', + events: {'event': 'onEvent($event)'} +}) +class DecoratorListeningEvent { + msg: string; + + constructor() { + this.msg = ''; + } + + onEvent(msg: string) { + this.msg = msg; + } +} + +class FakeVmTurnZone extends VmTurnZone { + constructor() { + super({enableLongStackTrace: false}); + } + + run(fn) { + fn(); + } + + runOutsideAngular(fn) { + fn(); + } +} + diff --git a/modules/angular2/test/core/compiler/pipeline/element_binder_builder_spec.js b/modules/angular2/test/core/compiler/pipeline/element_binder_builder_spec.js index e704f5d806..7dbf916740 100644 --- a/modules/angular2/test/core/compiler/pipeline/element_binder_builder_spec.js +++ b/modules/angular2/test/core/compiler/pipeline/element_binder_builder_spec.js @@ -1,7 +1,7 @@ import {describe, beforeEach, it, expect, iit, ddescribe, el} from 'angular2/test_lib'; import {isPresent, normalizeBlank} from 'angular2/src/facade/lang'; import {DOM} from 'angular2/src/dom/dom_adapter'; -import {ListWrapper, MapWrapper} from 'angular2/src/facade/collection'; +import {ListWrapper, MapWrapper, Map, StringMap, StringMapWrapper} from 'angular2/src/facade/collection'; import {ElementBinderBuilder} from 'angular2/src/core/compiler/pipeline/element_binder_builder'; import {CompilePipeline} from 'angular2/src/core/compiler/pipeline/compile_pipeline'; @@ -138,7 +138,6 @@ export function main() { expect(pv.elementBinders[1].protoElementInjector).toBeNull(); }); - it('should store the component directive', () => { var directives = [SomeComponentDirective]; var pipeline = createPipeline({protoElementInjector: null, directives: directives}); @@ -379,10 +378,30 @@ export function main() { var results = pipeline.process(el('
')); var pv = results[0].inheritedProtoView; - var ast = MapWrapper.get(pv.elementBinders[0].events, 'event1'); + var eventMap = StringMapWrapper.get(pv.elementBinders[0].events, 'event1'); + var ast = MapWrapper.get(eventMap, -1); expect(ast.eval(null)).toBe(2); }); + it('should bind directive events', () => { + var directives = [SomeDecoratorWithEvent]; + var protoElementInjector = new ProtoElementInjector(null, 0, directives, true); + var pipeline = createPipeline({ + directives: directives, + protoElementInjector: protoElementInjector + }); + var results = pipeline.process(el('
')); + var pv = results[0].inheritedProtoView; + + var directiveEvents = pv.elementBinders[0].events; + var eventMap = StringMapWrapper.get(directiveEvents, 'event'); + // Get the cb AST for the directive at index 0 (SomeDecoratorWithEvent) + var ast = MapWrapper.get(eventMap, 0); + + var context = new SomeDecoratorWithEvent(); + expect(ast.eval(context)).toEqual('onEvent() callback'); + }); + it('should bind directive properties', () => { var propertyBindings = MapWrapper.createFromStringMap({ 'boundprop1': 'prop1', @@ -516,6 +535,21 @@ class SomeDecoratorDirectiveWithBinding { } } +@Decorator({ + events: {'event': 'onEvent($event)'} +}) +class SomeDecoratorWithEvent { + // Added here so that we don't have to wrap the content in a ContextWithVariableBindings + $event: string; + + constructor() { + this.$event = 'onEvent' + } + onEvent(event) { + return `${event}() callback`; + } +} + @Decorator({ bind: { 'decorProp': 'boundprop1', diff --git a/modules/angular2/test/core/compiler/view_spec.js b/modules/angular2/test/core/compiler/view_spec.js index 3821fe4c5c..86dbbc88ee 100644 --- a/modules/angular2/test/core/compiler/view_spec.js +++ b/modules/angular2/test/core/compiler/view_spec.js @@ -519,6 +519,20 @@ export function main() { expect(called).toEqual(1); }); + + it('should bind to directive events', () => { + var pv = new ProtoView(el('
'), + new DynamicProtoChangeDetector(null), null); + pv.bindElement(new ProtoElementInjector(null, 0, [SomeDirectiveWithEventHandler])); + pv.bindEvent('click', parser.parseAction('onEvent($event)', null), 0); + view = createView(pv, new EventManager([new DomEventsPlugin()], new FakeVmTurnZone())); + + var directive = view.elementInjectors[0].get(SomeDirectiveWithEventHandler); + expect(directive.event).toEqual(null); + + dispatchClick(view.nodes[0]); + expect(directive.event).toBe(dispatchedEvent); + }); }); describe('react to record changes', () => { @@ -690,7 +704,6 @@ class SomeViewport { } } - class AnotherDirective { prop:string; constructor() { @@ -708,6 +721,17 @@ class EventEmitterDirective { } } +class SomeDirectiveWithEventHandler { + event; + + constructor() { + this.event = null; + } + + onEvent(event) { + this.event = event; + } +} class MyEvaluationContext { foo:string;