feat(upgrade): support binding of Ng2 form Ng1

Closes #4458
This commit is contained in:
Misko Hevery 2015-10-01 13:14:59 -07:00
parent 0b3e4fa090
commit 09371a3f0b
4 changed files with 357 additions and 29 deletions

View File

@ -14,11 +14,57 @@ if (!(Reflect && (<any>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(<AttrProp>{
prop: prop,
attr: attr,
bracketAttr: `[${attr}]`,
parenAttr: `(${attr})`,
bracketParenAttr: `[(${attr})]`
onAttr: `on${capitalAttr}`,
bindAttr: `bind${capitalAttr}`,
bindonAttr: `bindon${capitalAttr}`
});
}
}
return attrProps;
}

View File

@ -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, <any[]>factory);
var info: ComponentInfo = getComponentInfo(type);
var factory: Function = ng1ComponentDirective(info, `${this.idPrefix}${info.selector}_c`);
this.ng1Module.directive(info.selector, <any[]>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, <Injector>parentInjector, parse, viewManager, protoView);
facade.setupInputs();
facade.bootstrapNg2();
facade.setupOutputs();
facade.registerCleanup();
}
};
}
return directiveFactory;
}
class Ng2ComponentFacade {
component: any = null;
inputChangeCount: number = 0;
inputChanges: StringMap<string, SimpleChange> = 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;

View File

@ -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(`<div>
<ng2 literal="Text" interpolate="Hello {{'world'}}"
bind-one-way-a="dataA" [one-way-b]="dataB"
bindon-two-way-a="modelA" [(two-way-b)]="modelB"
on-event-a='eventA=$event' (event-b)="eventB=$event"></ng2>
| modelA: {{modelA}}; modelB: {{modelB}}; eventA: {{eventA}}; eventB: {{eventB}};
</div>`);
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;

View File

@ -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'
}
]);
});
})
});
}