diff --git a/modules/@angular/upgrade/src/angular_js.ts b/modules/@angular/upgrade/src/angular_js.ts index 729150a5b9..d693a7260a 100644 --- a/modules/@angular/upgrade/src/angular_js.ts +++ b/modules/@angular/upgrade/src/angular_js.ts @@ -161,6 +161,37 @@ export interface ITestabilityService { 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() { throw new Error('AngularJS v1.x is not loaded!'); } diff --git a/modules/@angular/upgrade/src/aot/constants.ts b/modules/@angular/upgrade/src/aot/constants.ts index 5b66390208..47db0dd09c 100644 --- a/modules/@angular/upgrade/src/aot/constants.ts +++ b/modules/@angular/upgrade/src/aot/constants.ts @@ -8,6 +8,7 @@ export const UPGRADE_MODULE_NAME = '$$UpgradeModule'; export const INJECTOR_KEY = '$$angularInjector'; +export const REQUIRE_NG1_MODEL = '?ngModel'; export const $INJECTOR = '$injector'; export const $PARSE = '$parse'; diff --git a/modules/@angular/upgrade/src/aot/downgrade_component.ts b/modules/@angular/upgrade/src/aot/downgrade_component.ts index ec846f5d3b..844df831df 100644 --- a/modules/@angular/upgrade/src/aot/downgrade_component.ts +++ b/modules/@angular/upgrade/src/aot/downgrade_component.ts @@ -10,7 +10,7 @@ import {ComponentFactory, ComponentFactoryResolver, Injector, Type} from '@angul 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'; let downgradeCount = 0; @@ -77,14 +77,16 @@ export function downgradeComponent(info: /* ComponentInfo */ { return { restrict: 'E', - require: '?^' + INJECTOR_KEY, + require: ['?^' + INJECTOR_KEY, REQUIRE_NG1_MODEL], 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) { parentInjector = $injector.get(INJECTOR_KEY); } + const ngModel: angular.INgModelController = required[1]; const componentFactoryResolver: ComponentFactoryResolver = parentInjector.get(ComponentFactoryResolver); const componentFactory: ComponentFactory = @@ -95,7 +97,7 @@ export function downgradeComponent(info: /* ComponentInfo */ { } const facade = new DowngradeComponentAdapter( - idPrefix + (idCount++), info, element, attrs, scope, parentInjector, $parse, + idPrefix + (idCount++), info, element, attrs, scope, ngModel, parentInjector, $parse, componentFactory); facade.setupInputs(); facade.createComponent(); diff --git a/modules/@angular/upgrade/src/aot/downgrade_component_adapter.ts b/modules/@angular/upgrade/src/aot/downgrade_component_adapter.ts index 275a30b01d..bb1074bf41 100644 --- a/modules/@angular/upgrade/src/aot/downgrade_component_adapter.ts +++ b/modules/@angular/upgrade/src/aot/downgrade_component_adapter.ts @@ -9,6 +9,7 @@ import {ChangeDetectorRef, ComponentFactory, ComponentRef, EventEmitter, Injector, OnChanges, ReflectiveInjector, SimpleChange, SimpleChanges, Type} from '@angular/core'; import * as angular from '../angular_js'; +import {hookupNgModel} from '../util'; import {ComponentInfo, PropertyBinding} from './component_info'; import {$SCOPE} from './constants'; @@ -31,8 +32,8 @@ export class DowngradeComponentAdapter { 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 componentFactory: ComponentFactory) { + private ngModel: angular.INgModelController, private parentInjector: Injector, + private parse: angular.IParseService, private componentFactory: ComponentFactory) { (this.element[0]).id = id; this.componentScope = scope.$new(); this.childNodes = element.contents(); @@ -47,6 +48,8 @@ export class DowngradeComponentAdapter { childInjector, [[this.contentInsertionPoint]], this.element[0]); this.changeDetector = this.componentRef.changeDetectorRef; this.component = this.componentRef.instance; + + hookupNgModel(this.ngModel, this.component); } setupInputs(): void { diff --git a/modules/@angular/upgrade/src/constants.ts b/modules/@angular/upgrade/src/constants.ts index 09c36a5009..f46a696a5e 100644 --- a/modules/@angular/upgrade/src/constants.ts +++ b/modules/@angular/upgrade/src/constants.ts @@ -22,3 +22,4 @@ export const NG1_PARSE = '$parse'; export const NG1_TEMPLATE_CACHE = '$templateCache'; export const NG1_TESTABILITY = '$$testability'; export const REQUIRE_INJECTOR = '?^^' + NG2_INJECTOR; +export const REQUIRE_NG1_MODEL = '?ngModel'; diff --git a/modules/@angular/upgrade/src/downgrade_ng2_adapter.ts b/modules/@angular/upgrade/src/downgrade_ng2_adapter.ts index bcf286f2e2..3163cc2c49 100644 --- a/modules/@angular/upgrade/src/downgrade_ng2_adapter.ts +++ b/modules/@angular/upgrade/src/downgrade_ng2_adapter.ts @@ -11,6 +11,7 @@ import {ChangeDetectorRef, ComponentFactory, ComponentRef, EventEmitter, Injecto import * as angular from './angular_js'; import {NG1_SCOPE} from './constants'; import {ComponentInfo} from './metadata'; +import {hookupNgModel} from './util'; const INITIAL_VALUE = { __UNINITIALIZED__: true @@ -27,8 +28,8 @@ export class DowngradeNg2ComponentAdapter { constructor( private info: ComponentInfo, private element: angular.IAugmentedJQuery, private attrs: angular.IAttributes, private scope: angular.IScope, - private parentInjector: Injector, private parse: angular.IParseService, - private componentFactory: ComponentFactory) { + private ngModel: angular.INgModelController, private parentInjector: Injector, + private parse: angular.IParseService, private componentFactory: ComponentFactory) { this.componentScope = scope.$new(); } @@ -40,6 +41,8 @@ export class DowngradeNg2ComponentAdapter { this.componentFactory.create(childInjector, projectableNodes, this.element[0]); this.changeDetector = this.componentRef.changeDetectorRef; this.component = this.componentRef.instance; + + hookupNgModel(this.ngModel, this.component); } setupInputs(): void { diff --git a/modules/@angular/upgrade/src/upgrade_adapter.ts b/modules/@angular/upgrade/src/upgrade_adapter.ts index d4f4b2de17..1b8be0f8b1 100644 --- a/modules/@angular/upgrade/src/upgrade_adapter.ts +++ b/modules/@angular/upgrade/src/upgrade_adapter.ts @@ -11,7 +11,7 @@ import {Compiler, CompilerOptions, ComponentFactory, Injector, NgModule, NgModul import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; 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 {ComponentInfo, getComponentInfo} from './metadata'; 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+ * syntax. This has to be done, this way because we must follow Angular 2+ components do not * 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 * @@ -146,6 +149,7 @@ export class UpgradeAdapter { * - Interpolation: `` * - Expression: `` * - Event: `` + * - ng-model: `` * - Content projection: yes * * ### Example @@ -655,18 +659,20 @@ function ng1ComponentDirective(info: ComponentInfo, idPrefix: string): Function return { restrict: 'E', terminal: true, - require: REQUIRE_INJECTOR, + require: [REQUIRE_INJECTOR, REQUIRE_NG1_MODEL], compile: (templateElement: angular.IAugmentedJQuery, templateAttributes: angular.IAttributes, transclude: angular.ITranscludeFunction) => { // 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 return { post: (scope: angular.IScope, element: angular.IAugmentedJQuery, - attrs: angular.IAttributes, parentInjector: Injector | ParentInjectorPromise, + attrs: angular.IAttributes, required: any[], transclude: angular.ITranscludeFunction): void => { let id = idPrefix + (idCount++); (element[0]).id = id; + let parentInjector: Injector|ParentInjectorPromise = required[0]; + const ngModel: angular.INgModelController = required[1]; let injectorPromise = new ParentInjectorPromise(element); const ng2Compiler = ng1Injector.get(NG2_COMPILER) as Compiler; @@ -697,7 +703,7 @@ function ng1ComponentDirective(info: ComponentInfo, idPrefix: string): Function function downgrade(injector: Injector) { const facade = new DowngradeNg2ComponentAdapter( - info, element, attrs, scope, injector, parse, componentFactory); + info, element, attrs, scope, ngModel, injector, parse, componentFactory); facade.setupInputs(); facade.bootstrapNg2(projectableNodes); facade.setupOutputs(); diff --git a/modules/@angular/upgrade/src/util.ts b/modules/@angular/upgrade/src/util.ts index e0568ced8e..8a038dd959 100644 --- a/modules/@angular/upgrade/src/util.ts +++ b/modules/@angular/upgrade/src/util.ts @@ -6,6 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ +import * as angular from './angular_js'; + export function onError(e: any) { // TODO: (misko): We seem to not have a stack trace here! if (console.error) { @@ -46,3 +48,23 @@ export class Deferred { }); } } + +/** + * @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)); + } +} diff --git a/modules/@angular/upgrade/test/aot/integration/downgrade_component_spec.ts b/modules/@angular/upgrade/test/aot/integration/downgrade_component_spec.ts index d06455c66e..02a6ee1c22 100644 --- a/modules/@angular/upgrade/test/aot/integration/downgrade_component_spec.ts +++ b/modules/@angular/upgrade/test/aot/integration/downgrade_component_spec.ts @@ -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: '{{_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', downgradeComponent({component: Ng2})); + + const element = html(`
| {{modelA}}
`); + + @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(() => { let destroyed = false; diff --git a/modules/@angular/upgrade/test/upgrade_spec.ts b/modules/@angular/upgrade/test/upgrade_spec.ts index 9f4dacee0e..dfd51b7575 100644 --- a/modules/@angular/upgrade/test/upgrade_spec.ts +++ b/modules/@angular/upgrade/test/upgrade_spec.ts @@ -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(`
| {{modelA}}
`); + + 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(() => { const adapter: UpgradeAdapter = new UpgradeAdapter(forwardRef(() => Ng2Module)); const ng1Module = angular.module('ng1', []);