feat(upgrade): Support ng-model in downgraded components (#10578)

This commit is contained in:
Karl Seamon 2017-01-23 14:23:45 -05:00 committed by Alex Rickabaugh
parent d3a3a8e1fc
commit e21e9c5fb7
10 changed files with 172 additions and 12 deletions

View File

@ -161,6 +161,37 @@ export interface ITestabilityService {
whenStable(callback: Function): void; whenStable(callback: Function): void;
} }
export interface INgModelController {
$render(): void;
$isEmpty(value: any): boolean;
$setValidity(validationErrorKey: string, isValid: boolean): void;
$setPristine(): void;
$setDirty(): void;
$setUntouched(): void;
$setTouched(): void;
$rollbackViewValue(): void;
$validate(): void;
$commitViewValue(): void;
$setViewValue(value: any, trigger: string): void;
$viewValue: any;
$modelValue: any;
$parsers: Function[];
$formatters: Function[];
$validators: {[key: string]: Function};
$asyncValidators: {[key: string]: Function};
$viewChangeListeners: Function[];
$error: Object;
$pending: Object;
$untouched: boolean;
$touched: boolean;
$pristine: boolean;
$dirty: boolean;
$valid: boolean;
$invalid: boolean;
$name: string;
}
function noNg() { function noNg() {
throw new Error('AngularJS v1.x is not loaded!'); throw new Error('AngularJS v1.x is not loaded!');
} }

View File

@ -8,6 +8,7 @@
export const UPGRADE_MODULE_NAME = '$$UpgradeModule'; export const UPGRADE_MODULE_NAME = '$$UpgradeModule';
export const INJECTOR_KEY = '$$angularInjector'; export const INJECTOR_KEY = '$$angularInjector';
export const REQUIRE_NG1_MODEL = '?ngModel';
export const $INJECTOR = '$injector'; export const $INJECTOR = '$injector';
export const $PARSE = '$parse'; export const $PARSE = '$parse';

View File

@ -10,7 +10,7 @@ import {ComponentFactory, ComponentFactoryResolver, Injector, Type} from '@angul
import * as angular from '../angular_js'; import * as angular from '../angular_js';
import {$INJECTOR, $PARSE, INJECTOR_KEY} from './constants'; import {$INJECTOR, $PARSE, INJECTOR_KEY, REQUIRE_NG1_MODEL} from './constants';
import {DowngradeComponentAdapter} from './downgrade_component_adapter'; import {DowngradeComponentAdapter} from './downgrade_component_adapter';
let downgradeCount = 0; let downgradeCount = 0;
@ -77,14 +77,16 @@ export function downgradeComponent(info: /* ComponentInfo */ {
return { return {
restrict: 'E', restrict: 'E',
require: '?^' + INJECTOR_KEY, require: ['?^' + INJECTOR_KEY, REQUIRE_NG1_MODEL],
link: (scope: angular.IScope, element: angular.IAugmentedJQuery, attrs: angular.IAttributes, link: (scope: angular.IScope, element: angular.IAugmentedJQuery, attrs: angular.IAttributes,
parentInjector: Injector, transclude: angular.ITranscludeFunction) => { required: any[], transclude: angular.ITranscludeFunction) => {
let parentInjector: Injector = required[0];
if (parentInjector === null) { if (parentInjector === null) {
parentInjector = $injector.get(INJECTOR_KEY); parentInjector = $injector.get(INJECTOR_KEY);
} }
const ngModel: angular.INgModelController = required[1];
const componentFactoryResolver: ComponentFactoryResolver = const componentFactoryResolver: ComponentFactoryResolver =
parentInjector.get(ComponentFactoryResolver); parentInjector.get(ComponentFactoryResolver);
const componentFactory: ComponentFactory<any> = const componentFactory: ComponentFactory<any> =
@ -95,7 +97,7 @@ export function downgradeComponent(info: /* ComponentInfo */ {
} }
const facade = new DowngradeComponentAdapter( const facade = new DowngradeComponentAdapter(
idPrefix + (idCount++), info, element, attrs, scope, parentInjector, $parse, idPrefix + (idCount++), info, element, attrs, scope, ngModel, parentInjector, $parse,
componentFactory); componentFactory);
facade.setupInputs(); facade.setupInputs();
facade.createComponent(); facade.createComponent();

View File

@ -9,6 +9,7 @@
import {ChangeDetectorRef, ComponentFactory, ComponentRef, EventEmitter, Injector, OnChanges, ReflectiveInjector, SimpleChange, SimpleChanges, Type} from '@angular/core'; import {ChangeDetectorRef, ComponentFactory, ComponentRef, EventEmitter, Injector, OnChanges, ReflectiveInjector, SimpleChange, SimpleChanges, Type} from '@angular/core';
import * as angular from '../angular_js'; import * as angular from '../angular_js';
import {hookupNgModel} from '../util';
import {ComponentInfo, PropertyBinding} from './component_info'; import {ComponentInfo, PropertyBinding} from './component_info';
import {$SCOPE} from './constants'; import {$SCOPE} from './constants';
@ -31,8 +32,8 @@ export class DowngradeComponentAdapter {
constructor( constructor(
private id: string, private info: ComponentInfo, private element: angular.IAugmentedJQuery, private id: string, private info: ComponentInfo, private element: angular.IAugmentedJQuery,
private attrs: angular.IAttributes, private scope: angular.IScope, private attrs: angular.IAttributes, private scope: angular.IScope,
private parentInjector: Injector, private parse: angular.IParseService, private ngModel: angular.INgModelController, private parentInjector: Injector,
private componentFactory: ComponentFactory<any>) { private parse: angular.IParseService, private componentFactory: ComponentFactory<any>) {
(<any>this.element[0]).id = id; (<any>this.element[0]).id = id;
this.componentScope = scope.$new(); this.componentScope = scope.$new();
this.childNodes = <Node[]><any>element.contents(); this.childNodes = <Node[]><any>element.contents();
@ -47,6 +48,8 @@ export class DowngradeComponentAdapter {
childInjector, [[this.contentInsertionPoint]], this.element[0]); childInjector, [[this.contentInsertionPoint]], this.element[0]);
this.changeDetector = this.componentRef.changeDetectorRef; this.changeDetector = this.componentRef.changeDetectorRef;
this.component = this.componentRef.instance; this.component = this.componentRef.instance;
hookupNgModel(this.ngModel, this.component);
} }
setupInputs(): void { setupInputs(): void {

View File

@ -22,3 +22,4 @@ export const NG1_PARSE = '$parse';
export const NG1_TEMPLATE_CACHE = '$templateCache'; export const NG1_TEMPLATE_CACHE = '$templateCache';
export const NG1_TESTABILITY = '$$testability'; export const NG1_TESTABILITY = '$$testability';
export const REQUIRE_INJECTOR = '?^^' + NG2_INJECTOR; export const REQUIRE_INJECTOR = '?^^' + NG2_INJECTOR;
export const REQUIRE_NG1_MODEL = '?ngModel';

View File

@ -11,6 +11,7 @@ import {ChangeDetectorRef, ComponentFactory, ComponentRef, EventEmitter, Injecto
import * as angular from './angular_js'; import * as angular from './angular_js';
import {NG1_SCOPE} from './constants'; import {NG1_SCOPE} from './constants';
import {ComponentInfo} from './metadata'; import {ComponentInfo} from './metadata';
import {hookupNgModel} from './util';
const INITIAL_VALUE = { const INITIAL_VALUE = {
__UNINITIALIZED__: true __UNINITIALIZED__: true
@ -27,8 +28,8 @@ export class DowngradeNg2ComponentAdapter {
constructor( constructor(
private info: ComponentInfo, private element: angular.IAugmentedJQuery, private info: ComponentInfo, private element: angular.IAugmentedJQuery,
private attrs: angular.IAttributes, private scope: angular.IScope, private attrs: angular.IAttributes, private scope: angular.IScope,
private parentInjector: Injector, private parse: angular.IParseService, private ngModel: angular.INgModelController, private parentInjector: Injector,
private componentFactory: ComponentFactory<any>) { private parse: angular.IParseService, private componentFactory: ComponentFactory<any>) {
this.componentScope = scope.$new(); this.componentScope = scope.$new();
} }
@ -40,6 +41,8 @@ export class DowngradeNg2ComponentAdapter {
this.componentFactory.create(childInjector, projectableNodes, this.element[0]); this.componentFactory.create(childInjector, projectableNodes, this.element[0]);
this.changeDetector = this.componentRef.changeDetectorRef; this.changeDetector = this.componentRef.changeDetectorRef;
this.component = this.componentRef.instance; this.component = this.componentRef.instance;
hookupNgModel(this.ngModel, this.component);
} }
setupInputs(): void { setupInputs(): void {

View File

@ -11,7 +11,7 @@ import {Compiler, CompilerOptions, ComponentFactory, Injector, NgModule, NgModul
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import * as angular from './angular_js'; import * as angular from './angular_js';
import {NG1_COMPILE, NG1_INJECTOR, NG1_PARSE, NG1_ROOT_SCOPE, NG1_TESTABILITY, NG2_COMPILER, NG2_COMPONENT_FACTORY_REF_MAP, NG2_INJECTOR, NG2_ZONE, REQUIRE_INJECTOR} from './constants'; import {NG1_COMPILE, NG1_INJECTOR, NG1_PARSE, NG1_ROOT_SCOPE, NG1_TESTABILITY, NG2_COMPILER, NG2_COMPONENT_FACTORY_REF_MAP, NG2_INJECTOR, NG2_ZONE, REQUIRE_INJECTOR, REQUIRE_NG1_MODEL} from './constants';
import {DowngradeNg2ComponentAdapter} from './downgrade_ng2_adapter'; import {DowngradeNg2ComponentAdapter} from './downgrade_ng2_adapter';
import {ComponentInfo, getComponentInfo} from './metadata'; import {ComponentInfo, getComponentInfo} from './metadata';
import {UpgradeNg1ComponentAdapterBuilder} from './upgrade_ng1_adapter'; import {UpgradeNg1ComponentAdapterBuilder} from './upgrade_ng1_adapter';
@ -138,6 +138,9 @@ export class UpgradeAdapter {
* 2. Even thought the component is instantiated in Angular 1, it will be using Angular 2+ * 2. Even thought the component is instantiated in Angular 1, it will be using Angular 2+
* syntax. This has to be done, this way because we must follow Angular 2+ components do not * syntax. This has to be done, this way because we must follow Angular 2+ components do not
* declare how the attributes should be interpreted. * declare how the attributes should be interpreted.
* 3. ng-model is controlled by AngularJS v1 and communicates with the downgraded Ng2 component
* by way of the ControlValueAccessor interface from @angular/forms. Only components that
* implement this interface are eligible.
* *
* ## Supported Features * ## Supported Features
* *
@ -146,6 +149,7 @@ export class UpgradeAdapter {
* - Interpolation: `<comp greeting="Hello {{name}}!">` * - Interpolation: `<comp greeting="Hello {{name}}!">`
* - Expression: `<comp [name]="username">` * - Expression: `<comp [name]="username">`
* - Event: `<comp (close)="doSomething()">` * - Event: `<comp (close)="doSomething()">`
* - ng-model: `<comp ng-model="name">`
* - Content projection: yes * - Content projection: yes
* *
* ### Example * ### Example
@ -655,18 +659,20 @@ function ng1ComponentDirective(info: ComponentInfo, idPrefix: string): Function
return { return {
restrict: 'E', restrict: 'E',
terminal: true, terminal: true,
require: REQUIRE_INJECTOR, require: [REQUIRE_INJECTOR, REQUIRE_NG1_MODEL],
compile: (templateElement: angular.IAugmentedJQuery, templateAttributes: angular.IAttributes, compile: (templateElement: angular.IAugmentedJQuery, templateAttributes: angular.IAttributes,
transclude: angular.ITranscludeFunction) => { transclude: angular.ITranscludeFunction) => {
// We might have compile the contents lazily, because this might have been triggered by the // We might have compile the contents lazily, because this might have been triggered by the
// UpgradeNg1ComponentAdapterBuilder, when the ng2 templates have not been compiled yet // UpgradeNg1ComponentAdapterBuilder, when the ng2 templates have not been compiled yet
return { return {
post: (scope: angular.IScope, element: angular.IAugmentedJQuery, post: (scope: angular.IScope, element: angular.IAugmentedJQuery,
attrs: angular.IAttributes, parentInjector: Injector | ParentInjectorPromise, attrs: angular.IAttributes, required: any[],
transclude: angular.ITranscludeFunction): void => { transclude: angular.ITranscludeFunction): void => {
let id = idPrefix + (idCount++); let id = idPrefix + (idCount++);
(<any>element[0]).id = id; (<any>element[0]).id = id;
let parentInjector: Injector|ParentInjectorPromise = required[0];
const ngModel: angular.INgModelController = required[1];
let injectorPromise = new ParentInjectorPromise(element); let injectorPromise = new ParentInjectorPromise(element);
const ng2Compiler = ng1Injector.get(NG2_COMPILER) as Compiler; const ng2Compiler = ng1Injector.get(NG2_COMPILER) as Compiler;
@ -697,7 +703,7 @@ function ng1ComponentDirective(info: ComponentInfo, idPrefix: string): Function
function downgrade(injector: Injector) { function downgrade(injector: Injector) {
const facade = new DowngradeNg2ComponentAdapter( const facade = new DowngradeNg2ComponentAdapter(
info, element, attrs, scope, injector, parse, componentFactory); info, element, attrs, scope, ngModel, injector, parse, componentFactory);
facade.setupInputs(); facade.setupInputs();
facade.bootstrapNg2(projectableNodes); facade.bootstrapNg2(projectableNodes);
facade.setupOutputs(); facade.setupOutputs();

View File

@ -6,6 +6,8 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import * as angular from './angular_js';
export function onError(e: any) { export function onError(e: any) {
// TODO: (misko): We seem to not have a stack trace here! // TODO: (misko): We seem to not have a stack trace here!
if (console.error) { if (console.error) {
@ -46,3 +48,23 @@ export class Deferred<R> {
}); });
} }
} }
/**
* @return true if the passed-in component implements the subset of
* ControlValueAccessor needed for AngularJS ng-model compatibility.
*/
function supportsNgModel(component: any) {
return typeof component.writeValue === 'function' &&
typeof component.registerOnChange === 'function';
}
/**
* Glue the AngularJS ngModelController if it exists to the component if it
* implements the needed subset of ControlValueAccessor.
*/
export function hookupNgModel(ngModel: angular.INgModelController, component: any) {
if (ngModel && supportsNgModel(component)) {
ngModel.$render = () => { component.writeValue(ngModel.$viewValue); };
component.registerOnChange(ngModel.$setViewValue.bind(ngModel));
}
}

View File

@ -142,6 +142,51 @@ export function main() {
}); });
})); }));
it('should bind to ng-model', async(() => {
const ng1Module = angular.module('ng1', []).run(
($rootScope: angular.IScope) => { $rootScope['modelA'] = 'A'; });
let ng2Instance: Ng2;
@Component({selector: 'ng2', template: '<span>{{_value}}</span>'})
class Ng2 {
private _value: any = '';
private _onChangeCallback: (_: any) => void = () => {};
constructor() { ng2Instance = this; }
writeValue(value: any) { this._value = value; }
registerOnChange(fn: any) { this._onChangeCallback = fn; }
doChange(newValue: string) {
this._value = newValue;
this._onChangeCallback(newValue);
}
}
ng1Module.directive('ng2', downgradeComponent({component: Ng2}));
const element = html(`<div><ng2 ng-model="modelA"></ng2> | {{modelA}}</div>`);
@NgModule(
{declarations: [Ng2], entryComponents: [Ng2], imports: [BrowserModule, UpgradeModule]})
class Ng2Module {
ngDoBootstrap() {}
}
platformBrowserDynamic().bootstrapModule(Ng2Module).then((ref) => {
const adapter = ref.injector.get(UpgradeModule) as UpgradeModule;
adapter.bootstrap(element, [ng1Module.name]);
const $rootScope = adapter.$injector.get('$rootScope');
expect(multiTrim(document.body.textContent)).toEqual('A | A');
$rootScope.modelA = 'B';
$rootScope.$apply();
expect(multiTrim(document.body.textContent)).toEqual('B | B');
ng2Instance.doChange('C');
expect($rootScope.modelA).toBe('C');
expect(multiTrim(document.body.textContent)).toEqual('C | C');
});
}));
it('should properly run cleanup when ng1 directive is destroyed', async(() => { it('should properly run cleanup when ng1 directive is destroyed', async(() => {
let destroyed = false; let destroyed = false;

View File

@ -380,6 +380,52 @@ export function main() {
})); }));
it('should bind to ng-model', async(() => {
const adapter: UpgradeAdapter = new UpgradeAdapter(forwardRef(() => Ng2Module));
const ng1Module = angular.module('ng1', []);
ng1Module.run(($rootScope: any /** TODO #9100 */) => { $rootScope.modelA = 'A'; });
let ng2Instance: Ng2;
@Component({selector: 'ng2', template: '{{_value}}'})
class Ng2 {
private _value: any = '';
private _onChangeCallback: (_: any) => void = () => {};
constructor() { ng2Instance = this; }
writeValue(value: any) { this._value = value; }
registerOnChange(fn: any) { this._onChangeCallback = fn; }
doChange(newValue: string) {
this._value = newValue;
this._onChangeCallback(newValue);
}
}
ng1Module.directive('ng2', adapter.downgradeNg2Component(Ng2));
const element = html(`<div><ng2 ng-model="modelA"></ng2> | {{modelA}}</div>`);
const Ng2Module = NgModule({
declarations: [Ng2],
imports: [BrowserModule],
schemas: [NO_ERRORS_SCHEMA],
}).Class({constructor: function() {}});
adapter.bootstrap(element, ['ng1']).ready((ref) => {
let $rootScope: any = ref.ng1RootScope;
expect(multiTrim(document.body.textContent)).toEqual('A | A');
$rootScope.modelA = 'B';
$rootScope.$apply();
expect(multiTrim(document.body.textContent)).toEqual('B | B');
ng2Instance.doChange('C');
expect($rootScope.modelA).toBe('C');
expect(multiTrim(document.body.textContent)).toEqual('C | C');
ref.dispose();
});
}));
it('should properly run cleanup when ng1 directive is destroyed', async(() => { it('should properly run cleanup when ng1 directive is destroyed', async(() => {
const adapter: UpgradeAdapter = new UpgradeAdapter(forwardRef(() => Ng2Module)); const adapter: UpgradeAdapter = new UpgradeAdapter(forwardRef(() => Ng2Module));
const ng1Module = angular.module('ng1', []); const ng1Module = angular.module('ng1', []);