feat(upgrade): Support ng-model in downgraded components (#10578)
This commit is contained in:
parent
d3a3a8e1fc
commit
e21e9c5fb7
|
@ -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!');
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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<any> =
|
||||
|
@ -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();
|
||||
|
|
|
@ -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<any>) {
|
||||
private ngModel: angular.INgModelController, private parentInjector: Injector,
|
||||
private parse: angular.IParseService, private componentFactory: ComponentFactory<any>) {
|
||||
(<any>this.element[0]).id = id;
|
||||
this.componentScope = scope.$new();
|
||||
this.childNodes = <Node[]><any>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 {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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<any>) {
|
||||
private ngModel: angular.INgModelController, private parentInjector: Injector,
|
||||
private parse: angular.IParseService, private componentFactory: ComponentFactory<any>) {
|
||||
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 {
|
||||
|
|
|
@ -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: `<comp greeting="Hello {{name}}!">`
|
||||
* - Expression: `<comp [name]="username">`
|
||||
* - Event: `<comp (close)="doSomething()">`
|
||||
* - ng-model: `<comp ng-model="name">`
|
||||
* - 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++);
|
||||
(<any>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();
|
||||
|
|
|
@ -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<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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(() => {
|
||||
|
||||
let destroyed = false;
|
||||
|
|
|
@ -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(() => {
|
||||
const adapter: UpgradeAdapter = new UpgradeAdapter(forwardRef(() => Ng2Module));
|
||||
const ng1Module = angular.module('ng1', []);
|
||||
|
|
Loading…
Reference in New Issue