From 09371a3f0bdba523b0e6e2fd64fd9e69f84c7172 Mon Sep 17 00:00:00 2001 From: Misko Hevery Date: Thu, 1 Oct 2015 13:14:59 -0700 Subject: [PATCH] feat(upgrade): support binding of Ng2 form Ng1 Closes #4458 --- modules/upgrade/src/metadata.ts | 50 ++++++- modules/upgrade/src/upgrade_module.ts | 174 ++++++++++++++++++++--- modules/upgrade/test/integration_spec.ts | 120 +++++++++++++++- modules/upgrade/test/metadata_spec.ts | 42 +++++- 4 files changed, 357 insertions(+), 29 deletions(-) diff --git a/modules/upgrade/src/metadata.ts b/modules/upgrade/src/metadata.ts index 9d6948d17e..7edd6af451 100644 --- a/modules/upgrade/src/metadata.ts +++ b/modules/upgrade/src/metadata.ts @@ -14,11 +14,57 @@ if (!(Reflect && (Reflect)['getOwnMetadata'])) { throw 'reflect-metadata shim is required when using class decorators'; } -export function getComponentSelector(type: Type): string { +export interface AttrProp { + prop: string; + attr: string; + bracketAttr: string; + bracketParenAttr: string; + parenAttr: string; + onAttr: string; + bindAttr: string; + bindonAttr: string; +} + +export interface ComponentInfo { + selector: string; + inputs: AttrProp[]; + outputs: AttrProp[]; +} + +export function getComponentInfo(type: Type): string { var resolvedMetadata: DirectiveMetadata = directiveResolver.resolve(type); var selector = resolvedMetadata.selector; if (!selector.match(COMPONENT_SELECTOR)) { throw new Error('Only selectors matching element names are supported, got: ' + selector); } - return selector.replace(SKEWER_CASE, (all, letter: string) => letter.toUpperCase()); + var selector = selector.replace(SKEWER_CASE, (all, letter: string) => letter.toUpperCase()); + return { + type: type, + selector: selector, + inputs: parseFields(resolvedMetadata.inputs), + outputs: parseFields(resolvedMetadata.outputs) + }; +} + +export function parseFields(names: string[]): AttrProp[] { + var attrProps: AttrProp[] = []; + if (names) { + for (var i = 0; i < names.length; i++) { + var parts = names[i].split(':'); + var prop = parts[0].trim(); + var attr = (parts[1] || parts[0]).trim(); + var capitalAttr = attr.charAt(0).toUpperCase() + attr.substr(1); + attrProps.push({ + prop: prop, + attr: attr, + bracketAttr: `[${attr}]`, + parenAttr: `(${attr})`, + bracketParenAttr: `[(${attr})]` + onAttr: `on${capitalAttr}`, + bindAttr: `bind${capitalAttr}`, + bindonAttr: `bindon${capitalAttr}` + }); + } + } + return attrProps; } diff --git a/modules/upgrade/src/upgrade_module.ts b/modules/upgrade/src/upgrade_module.ts index 0958e52ed6..9bd62dcbf8 100644 --- a/modules/upgrade/src/upgrade_module.ts +++ b/modules/upgrade/src/upgrade_module.ts @@ -21,13 +21,14 @@ import { ProtoViewRef, ElementRef, HostViewRef, - ViewRef + ViewRef, + SimpleChange } from 'angular2/angular2'; import {applicationDomBindings} from 'angular2/src/core/application_common'; import {applicationCommonBindings} from '../../angular2/src/core/application_ref'; import {compilerBindings} from 'angular2/src/core/compiler/compiler'; -import {getComponentSelector} from './metadata'; +import {getComponentInfo, ComponentInfo} from './metadata'; import {onError} from './util'; export const INJECTOR = 'ng2.Injector'; export const APP_VIEW_MANAGER = 'ng2.AppViewManager'; @@ -39,10 +40,12 @@ const NG1_REQUIRE_INJECTOR_REF = '$' + INJECTOR + 'Controller'; const NG1_SCOPE = '$scope'; const NG1_COMPILE = '$compile'; const NG1_INJECTOR = '$injector'; +const NG1_PARSE = '$parse'; const REQUIRE_INJECTOR = '^' + INJECTOR; var moduleCount: number = 0; const CAMEL_CASE = /([A-Z])/g; +var INITIAL_VALUE = {}; export function createUpgradeModule(): UpgradeModule { var prefix = `NG2_UPGRADE_m${moduleCount++}_`; @@ -57,9 +60,9 @@ export class UpgradeModule { importNg2Component(type: Type): UpgradeModule { this.componentTypes.push(type); - var selector: string = getComponentSelector(type); - var factory: Function = ng1ComponentDirective(selector, type, `${this.idPrefix}${selector}_c`); - this.ng1Module.directive(selector, factory); + var info: ComponentInfo = getComponentInfo(type); + var factory: Function = ng1ComponentDirective(info, `${this.idPrefix}${info.selector}_c`); + this.ng1Module.directive(info.selector, factory); return this; } @@ -132,7 +135,7 @@ export class UpgradeModule { var protoViewRefMap: ProtoViewRefMap = {}; var types = this.componentTypes; for (var i = 0; i < protoViews.length; i++) { - protoViewRefMap[getComponentSelector(types[i])] = protoViews[i]; + protoViewRefMap[getComponentInfo(types[i]).selector] = protoViews[i]; } return protoViewRefMap; }, onError); @@ -143,32 +146,163 @@ interface ProtoViewRefMap { [selector: string]: ProtoViewRef } -function ng1ComponentDirective(selector: string, type: Type, idPrefix: string): Function { - directiveFactory.$inject = [PROTO_VIEW_REF_MAP, APP_VIEW_MANAGER]; - function directiveFactory(protoViewRefMap: ProtoViewRefMap, viewManager: AppViewManager): - angular.IDirective { - var protoView: ProtoViewRef = protoViewRefMap[selector]; - if (!protoView) throw new Error('Expecting ProtoViewRef for: ' + selector); +function ng1ComponentDirective(info: ComponentInfo, idPrefix: string): Function { + directiveFactory.$inject = [PROTO_VIEW_REF_MAP, APP_VIEW_MANAGER, NG1_PARSE]; + function directiveFactory(protoViewRefMap: ProtoViewRefMap, viewManager: AppViewManager, + parse: angular.IParseService): angular.IDirective { + var protoView: ProtoViewRef = protoViewRefMap[info.selector]; + if (!protoView) throw new Error('Expecting ProtoViewRef for: ' + info.selector); var idCount = 0; return { restrict: 'E', require: REQUIRE_INJECTOR, link: (scope: angular.IScope, element: angular.IAugmentedJQuery, attrs: angular.IAttributes, parentInjector: any, transclude: angular.ITranscludeFunction): void => { - var id = element[0].id = idPrefix + (idCount++); - var componentScope = scope.$new(); - componentScope.$watch(() => changeDetector.detectChanges()); - var childInjector = - parentInjector.resolveAndCreateChild([bind(NG1_SCOPE).toValue(componentScope)]); - var hostViewRef = viewManager.createRootHostView(protoView, '#' + id, childInjector); - var changeDetector: ChangeDetectorRef = hostViewRef.changeDetectorRef; - element.bind('$remove', () => viewManager.destroyRootHostView(hostViewRef)); + var facade = + new Ng2ComponentFacade(element[0].id = idPrefix + (idCount++), info, element, attrs, + scope, parentInjector, parse, viewManager, protoView); + + facade.setupInputs(); + facade.bootstrapNg2(); + facade.setupOutputs(); + facade.registerCleanup(); } }; } return directiveFactory; } +class Ng2ComponentFacade { + component: any = null; + inputChangeCount: number = 0; + inputChanges: StringMap = null; + hostViewRef: HostViewRef = null; + changeDetector: ChangeDetectorRef = null; + componentScope: angular.IScope; + + constructor(private id: string, private info: ComponentInfo, + private element: angular.IAugmentedJQuery, private attrs: angular.IAttributes, + private scope: angular.IScope, private parentInjector: Injector, + private parse: angular.IParseService, private viewManager: AppViewManager, + private protoView: ProtoViewRef) { + this.componentScope = scope.$new(); + } + + bootstrapNg2() { + var childInjector = + this.parentInjector.resolveAndCreateChild([bind(NG1_SCOPE).toValue(this.componentScope)]); + this.hostViewRef = + this.viewManager.createRootHostView(this.protoView, '#' + this.id, childInjector); + var hostElement = this.viewManager.getHostElement(this.hostViewRef); + this.changeDetector = this.hostViewRef.changeDetectorRef; + this.component = this.viewManager.getComponent(hostElement); + } + + setupInputs() { + var attrs = this.attrs; + var inputs = this.info.inputs; + for (var i = 0; i < inputs.length; i++) { + var input = inputs[i]; + var expr = null; + if (attrs.hasOwnProperty(input.attr)) { + var observeFn = ((prop) => { + var prevValue = INITIAL_VALUE; + return (value) => { + if (this.inputChanges !== null) { + this.inputChangeCount++; + this.inputChanges[prop] = + new Ng1Change(value, prevValue === INITIAL_VALUE ? value : prevValue); + prevValue = value; + } + this.component[prop] = value; + } + })(input.prop); + attrs.$observe(input.attr, observeFn); + } else if (attrs.hasOwnProperty(input.bindAttr)) { + expr = attrs[input.bindAttr]; + } else if (attrs.hasOwnProperty(input.bracketAttr)) { + expr = attrs[input.bracketAttr]; + } else if (attrs.hasOwnProperty(input.bindonAttr)) { + expr = attrs[input.bindonAttr]; + } else if (attrs.hasOwnProperty(input.bracketParenAttr)) { + expr = attrs[input.bracketParenAttr]; + } + if (expr != null) { + var watchFn = ((prop) => (value, prevValue) => { + if (this.inputChanges != null) { + this.inputChangeCount++; + this.inputChanges[prop] = new Ng1Change(prevValue, value); + } + this.component[prop] = value; + })(input.prop); + this.componentScope.$watch(expr, watchFn); + } + } + + var prototype = this.info.type.prototype; + if (prototype && prototype.onChanges) { + // Detect: OnChanges interface + this.inputChanges = {}; + this.componentScope.$watch(() => this.inputChangeCount, () => { + var inputChanges = this.inputChanges; + this.inputChanges = {}; + this.component.onChanges(inputChanges); + }); + } + this.componentScope.$watch(() => this.changeDetector.detectChanges()); + } + + setupOutputs() { + var attrs = this.attrs; + var outputs = this.info.outputs; + for (var j = 0; j < outputs.length; j++) { + var output = outputs[j]; + var expr = null; + var assignExpr = false; + if (attrs.hasOwnProperty(output.onAttr)) { + expr = attrs[output.onAttr]; + } else if (attrs.hasOwnProperty(output.parenAttr)) { + expr = attrs[output.parenAttr]; + } else if (attrs.hasOwnProperty(output.bindonAttr)) { + expr = attrs[output.bindonAttr]; + assignExpr = true; + } else if (attrs.hasOwnProperty(output.bracketParenAttr)) { + expr = attrs[output.bracketParenAttr]; + assignExpr = true; + } + + if (expr != null && assignExpr != null) { + var getter = this.parse(expr); + var setter = getter.assign; + if (assignExpr && !setter) { + throw new Error(`Expression '${expr}' is not assignable!`); + } + var emitter = this.component[output.prop]; + if (emitter) { + emitter.observer({ + next: assignExpr ? ((setter) => (value) => setter(this.scope, value))(setter) : + ((getter) => (value) => getter(this.scope, {$event: value}))(getter) + }); + } else { + throw new Error( + `Missing emitter '${output.prop}' on component '${this.input.selector}'!`); + } + } + } + } + + registerCleanup() { + this.element.bind('$remove', () => this.viewManager.destroyRootHostView(this.hostViewRef)); + } +} + +export class Ng1Change implements SimpleChange { + constructor(public previousValue: any, public currentValue: any) {} + + isFirstChange(): boolean { return this.previousValue === this.currentValue; } +} + + export class UpgradeRef { readyFn: Function; diff --git a/modules/upgrade/test/integration_spec.ts b/modules/upgrade/test/integration_spec.ts index e2e221d2b0..f4522e4b46 100644 --- a/modules/upgrade/test/integration_spec.ts +++ b/modules/upgrade/test/integration_spec.ts @@ -11,7 +11,7 @@ import { xit, } from 'angular2/test_lib'; -import {Component, View, Inject} from 'angular2/angular2'; +import {Component, View, Inject, EventEmitter} from 'angular2/angular2'; import {createUpgradeModule, UpgradeModule, bootstrapHybrid} from 'upgrade/upgrade'; export function main() { @@ -91,9 +91,127 @@ export function main() { }); })); }); + + describe('binding from ng1 to ng2', () => { + it('should bind properties, events', inject([AsyncTestCompleter], (async) { + var upgrMod: UpgradeModule = createUpgradeModule(); + upgrMod.ng1Module.run(($rootScope) => { + $rootScope.dataA = 'A'; + $rootScope.dataB = 'B'; + $rootScope.modelA = 'initModelA'; + $rootScope.modelB = 'initModelB'; + $rootScope.eventA = '?'; + $rootScope.eventB = '?'; + }); + upgrMod.importNg2Component( + Component({ + selector: 'ng2', + inputs: ['literal', 'interpolate', 'oneWayA', 'oneWayB', 'twoWayA', 'twoWayB'], + outputs: + ['eventA', 'eventB', 'twoWayAEmitter: twoWayA', 'twoWayBEmitter: twoWayB'] + }) + .View({ + template: + "ignore: {{ignore}}; " + + "literal: {{literal}}; interpolate: {{interpolate}}; " + + "oneWayA: {{oneWayA}}; oneWayB: {{oneWayB}}; " + + "twoWayA: {{twoWayA}}; twoWayB: {{twoWayB}}; ({{onChangesCount}})" + }) + .Class({ + constructor: function() { + this.onChangesCount = 0; + this.ignore = '-'; + this.literal = '?'; + this.interpolate = '?'; + this.oneWayA = '?'; + this.oneWayB = '?'; + this.twoWayA = '?'; + this.twoWayB = '?'; + this.eventA = new EventEmitter(); + this.eventB = new EventEmitter(); + this.twoWayAEmitter = new EventEmitter(); + this.twoWayBEmitter = new EventEmitter(); + }, + onChanges: function(changes) { + var assert = + (prop, value) => { + if (this[prop] != value) { + throw new Error( + `Expected: '${prop}' to be '${value}' but was '${this[prop]}'`); + } + } + + var assertChange = + (prop, value) => { + assert(prop, value); + if (!changes[prop]) { + throw new Error(`Changes record for '${prop}' not found.`); + } + var actValue = changes[prop].currentValue; + if (actValue != value) { + throw new Error( + `Expected changes record for'${prop}' to be '${value}' but was '${actValue}'`); + } + } + + switch (this.onChangesCount++) { + case 0: + assert('ignore', '-'); + assertChange('literal', 'Text'); + assertChange('interpolate', 'Hello world'); + assertChange('oneWayA', 'A'); + assertChange('oneWayB', 'B'); + assertChange('twoWayA', 'initModelA'); + assertChange('twoWayB', 'initModelB'); + + this.twoWayAEmitter.next('newA'); + this.twoWayBEmitter.next('newB'); + this.eventA.next('aFired'); + this.eventB.next('bFired'); + break; + case 1: + assertChange('twoWayA', 'newA'); + break; + case 2: + assertChange('twoWayB', 'newB'); + break; + default: + throw new Error('Called too many times! ' + JSON.stringify(changes)); + } + } + })); + var element = html(`
+ + | modelA: {{modelA}}; modelB: {{modelB}}; eventA: {{eventA}}; eventB: {{eventB}}; +
`); + upgrMod.bootstrap(element).ready(() => { + expect(multiTrim(document.body.textContent)) + .toEqual( + "ignore: -; " + "literal: Text; interpolate: Hello world; " + + "oneWayA: A; oneWayB: B; twoWayA: initModelA; twoWayB: initModelB; (1) | " + + "modelA: initModelA; modelB: initModelB; eventA: ?; eventB: ?;"); + setTimeout(() => { + // we need to do setTimeout, because the EventEmitter uses setTimeout to schedule + // events, and so without this we would not see the events processed. + expect(multiTrim(document.body.textContent)) + .toEqual("ignore: -; " + "literal: Text; interpolate: Hello world; " + + "oneWayA: A; oneWayB: B; twoWayA: newA; twoWayB: newB; (3) | " + + "modelA: newA; modelB: newB; eventA: aFired; eventB: bFired;"); + async.done(); + }); + }); + + })); + }); }); } +function multiTrim(text: string): string { + return text.replace(/\n/g, '').replace(/\s\s+/g, ' ').trim(); +} function html(html: string): Element { var body = document.body; diff --git a/modules/upgrade/test/metadata_spec.ts b/modules/upgrade/test/metadata_spec.ts index a7b235019d..5c59e8bc81 100644 --- a/modules/upgrade/test/metadata_spec.ts +++ b/modules/upgrade/test/metadata_spec.ts @@ -12,27 +12,57 @@ import { } from 'angular2/test_lib'; import {Component, View} from 'angular2/angular2'; -import {getComponentSelector} from 'upgrade/src/metadata'; +import {getComponentInfo, parseFields} from 'upgrade/src/metadata'; export function main() { describe('upgrade metadata', () => { - it('should extract component selector', - () => { expect(getComponentSelector(ElementNameComponent)).toEqual('elementNameDashed'); }); + it('should extract component selector', () => { + expect(getComponentInfo(ElementNameComponent).selector).toEqual('elementNameDashed'); + }); describe('errors', () => { it('should throw on missing selector', () => { - expect(() => getComponentSelector(AttributeNameComponent)) + expect(() => getComponentInfo(AttributeNameComponent)) .toThrowErrorWith( "Only selectors matching element names are supported, got: [attr-name]"); }); it('should throw on non element names', () => { - expect(() => getComponentSelector(NoAnnotationComponent)) + expect(() => getComponentInfo(NoAnnotationComponent)) .toThrowErrorWith("No Directive annotation found on NoAnnotationComponent"); }); - }); + + describe('parseFields', () => { + it('should process nulls', () => { expect(parseFields(null)).toEqual([]); }); + + it('should process values', () => { + expect(parseFields([' name ', ' prop : attr '])) + .toEqual([ + { + prop: 'name', + attr: 'name', + bracketAttr: '[name]', + parenAttr: '(name)', + bracketParenAttr: '[(name)]', + onAttr: 'onName', + bindAttr: 'bindName', + bindonAttr: 'bindonName' + }, + { + prop: 'prop', + attr: 'attr', + bracketAttr: '[attr]', + parenAttr: '(attr)', + bracketParenAttr: '[(attr)]', + onAttr: 'onAttr', + bindAttr: 'bindAttr', + bindonAttr: 'bindonAttr' + } + ]); + }); + }) }); }