298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google Inc. All Rights Reserved.
|
|
*
|
|
* Use of this source code is governed by an MIT-style license that can be
|
|
* found in the LICENSE file at https://angular.io/license
|
|
*/
|
|
|
|
import {DoCheck, ElementRef, EventEmitter, Injector, OnChanges, OnDestroy, OnInit, SimpleChanges, ɵlooseIdentical as looseIdentical} from '@angular/core';
|
|
|
|
import {IAttributes, IAugmentedJQuery, IDirective, IDirectivePrePost, IInjectorService, ILinkFn, IScope, ITranscludeFunction} from '../../src/common/src/angular1';
|
|
import {$SCOPE} from '../../src/common/src/constants';
|
|
import {IBindingDestination, IControllerInstance, UpgradeHelper} from '../../src/common/src/upgrade_helper';
|
|
import {isFunction} from '../../src/common/src/util';
|
|
|
|
const NOT_SUPPORTED: any = 'NOT_SUPPORTED';
|
|
const INITIAL_VALUE = {
|
|
__UNINITIALIZED__: true
|
|
};
|
|
|
|
class Bindings {
|
|
twoWayBoundProperties: string[] = [];
|
|
twoWayBoundLastValues: any[] = [];
|
|
|
|
expressionBoundProperties: string[] = [];
|
|
|
|
propertyToOutputMap: {[propName: string]: string} = {};
|
|
}
|
|
|
|
/**
|
|
* @description
|
|
*
|
|
* A helper class that allows an AngularJS component to be used from Angular.
|
|
*
|
|
* *Part of the [upgrade/static](api?query=upgrade%2Fstatic)
|
|
* library for hybrid upgrade apps that support AoT compilation.*
|
|
*
|
|
* This helper class should be used as a base class for creating Angular directives
|
|
* that wrap AngularJS components that need to be "upgraded".
|
|
*
|
|
* @usageNotes
|
|
* ### Examples
|
|
*
|
|
* Let's assume that you have an AngularJS component called `ng1Hero` that needs
|
|
* to be made available in Angular templates.
|
|
*
|
|
* {@example upgrade/static/ts/full/module.ts region="ng1-hero"}
|
|
*
|
|
* We must create a `Directive` that will make this AngularJS component
|
|
* available inside Angular templates.
|
|
*
|
|
* {@example upgrade/static/ts/full/module.ts region="ng1-hero-wrapper"}
|
|
*
|
|
* In this example you can see that we must derive from the `UpgradeComponent`
|
|
* base class but also provide an {@link Directive `@Directive`} decorator. This is
|
|
* because the AoT compiler requires that this information is statically available at
|
|
* compile time.
|
|
*
|
|
* Note that we must do the following:
|
|
* * specify the directive's selector (`ng1-hero`)
|
|
* * specify all inputs and outputs that the AngularJS component expects
|
|
* * derive from `UpgradeComponent`
|
|
* * call the base class from the constructor, passing
|
|
* * the AngularJS name of the component (`ng1Hero`)
|
|
* * the `ElementRef` and `Injector` for the component wrapper
|
|
*
|
|
* @publicApi
|
|
*/
|
|
export class UpgradeComponent implements OnInit, OnChanges, DoCheck, OnDestroy {
|
|
private helper: UpgradeHelper;
|
|
|
|
private $injector: IInjectorService;
|
|
|
|
private element: Element;
|
|
private $element: IAugmentedJQuery;
|
|
private $componentScope: IScope;
|
|
|
|
private directive: IDirective;
|
|
private bindings: Bindings;
|
|
|
|
// TODO(issue/24571): remove '!'.
|
|
private controllerInstance !: IControllerInstance;
|
|
// TODO(issue/24571): remove '!'.
|
|
private bindingDestination !: IBindingDestination;
|
|
|
|
// We will be instantiating the controller in the `ngOnInit` hook, when the
|
|
// first `ngOnChanges` will have been already triggered. We store the
|
|
// `SimpleChanges` and "play them back" later.
|
|
// TODO(issue/24571): remove '!'.
|
|
private pendingChanges !: SimpleChanges | null;
|
|
|
|
// TODO(issue/24571): remove '!'.
|
|
private unregisterDoCheckWatcher !: Function;
|
|
|
|
/**
|
|
* Create a new `UpgradeComponent` instance. You should not normally need to do this.
|
|
* Instead you should derive a new class from this one and call the super constructor
|
|
* from the base class.
|
|
*
|
|
* {@example upgrade/static/ts/full/module.ts region="ng1-hero-wrapper" }
|
|
*
|
|
* * The `name` parameter should be the name of the AngularJS directive.
|
|
* * The `elementRef` and `injector` parameters should be acquired from Angular by dependency
|
|
* injection into the base class constructor.
|
|
*/
|
|
constructor(private name: string, private elementRef: ElementRef, private injector: Injector) {
|
|
this.helper = new UpgradeHelper(injector, name, elementRef);
|
|
|
|
this.$injector = this.helper.$injector;
|
|
|
|
this.element = this.helper.element;
|
|
this.$element = this.helper.$element;
|
|
|
|
this.directive = this.helper.directive;
|
|
this.bindings = this.initializeBindings(this.directive);
|
|
|
|
// We ask for the AngularJS scope from the Angular injector, since
|
|
// we will put the new component scope onto the new injector for each component
|
|
const $parentScope = injector.get($SCOPE);
|
|
// QUESTION 1: Should we create an isolated scope if the scope is only true?
|
|
// QUESTION 2: Should we make the scope accessible through `$element.scope()/isolateScope()`?
|
|
this.$componentScope = $parentScope.$new(!!this.directive.scope);
|
|
|
|
this.initializeOutputs();
|
|
}
|
|
|
|
ngOnInit() {
|
|
// Collect contents, insert and compile template
|
|
const attachChildNodes: ILinkFn|undefined = this.helper.prepareTransclusion();
|
|
const linkFn = this.helper.compileTemplate();
|
|
|
|
// Instantiate controller
|
|
const controllerType = this.directive.controller;
|
|
const bindToController = this.directive.bindToController;
|
|
if (controllerType) {
|
|
this.controllerInstance = this.helper.buildController(controllerType, this.$componentScope);
|
|
} else if (bindToController) {
|
|
throw new Error(
|
|
`Upgraded directive '${this.directive.name}' specifies 'bindToController' but no controller.`);
|
|
}
|
|
|
|
// Set up outputs
|
|
this.bindingDestination = bindToController ? this.controllerInstance : this.$componentScope;
|
|
this.bindOutputs();
|
|
|
|
// Require other controllers
|
|
const requiredControllers =
|
|
this.helper.resolveAndBindRequiredControllers(this.controllerInstance);
|
|
|
|
// Hook: $onChanges
|
|
if (this.pendingChanges) {
|
|
this.forwardChanges(this.pendingChanges);
|
|
this.pendingChanges = null;
|
|
}
|
|
|
|
// Hook: $onInit
|
|
if (this.controllerInstance && isFunction(this.controllerInstance.$onInit)) {
|
|
this.controllerInstance.$onInit();
|
|
}
|
|
|
|
// Hook: $doCheck
|
|
if (this.controllerInstance && isFunction(this.controllerInstance.$doCheck)) {
|
|
const callDoCheck = () => this.controllerInstance.$doCheck !();
|
|
|
|
this.unregisterDoCheckWatcher = this.$componentScope.$parent.$watch(callDoCheck);
|
|
callDoCheck();
|
|
}
|
|
|
|
// Linking
|
|
const link = this.directive.link;
|
|
const preLink = (typeof link == 'object') && (link as IDirectivePrePost).pre;
|
|
const postLink = (typeof link == 'object') ? (link as IDirectivePrePost).post : link;
|
|
const attrs: IAttributes = NOT_SUPPORTED;
|
|
const transcludeFn: ITranscludeFunction = NOT_SUPPORTED;
|
|
if (preLink) {
|
|
preLink(this.$componentScope, this.$element, attrs, requiredControllers, transcludeFn);
|
|
}
|
|
|
|
linkFn(this.$componentScope, null !, {parentBoundTranscludeFn: attachChildNodes});
|
|
|
|
if (postLink) {
|
|
postLink(this.$componentScope, this.$element, attrs, requiredControllers, transcludeFn);
|
|
}
|
|
|
|
// Hook: $postLink
|
|
if (this.controllerInstance && isFunction(this.controllerInstance.$postLink)) {
|
|
this.controllerInstance.$postLink();
|
|
}
|
|
}
|
|
|
|
ngOnChanges(changes: SimpleChanges) {
|
|
if (!this.bindingDestination) {
|
|
this.pendingChanges = changes;
|
|
} else {
|
|
this.forwardChanges(changes);
|
|
}
|
|
}
|
|
|
|
ngDoCheck() {
|
|
const twoWayBoundProperties = this.bindings.twoWayBoundProperties;
|
|
const twoWayBoundLastValues = this.bindings.twoWayBoundLastValues;
|
|
const propertyToOutputMap = this.bindings.propertyToOutputMap;
|
|
|
|
twoWayBoundProperties.forEach((propName, idx) => {
|
|
const newValue = this.bindingDestination[propName];
|
|
const oldValue = twoWayBoundLastValues[idx];
|
|
|
|
if (!looseIdentical(newValue, oldValue)) {
|
|
const outputName = propertyToOutputMap[propName];
|
|
const eventEmitter: EventEmitter<any> = (this as any)[outputName];
|
|
|
|
eventEmitter.emit(newValue);
|
|
twoWayBoundLastValues[idx] = newValue;
|
|
}
|
|
});
|
|
}
|
|
|
|
ngOnDestroy() {
|
|
if (isFunction(this.unregisterDoCheckWatcher)) {
|
|
this.unregisterDoCheckWatcher();
|
|
}
|
|
this.helper.onDestroy(this.$componentScope, this.controllerInstance);
|
|
}
|
|
|
|
private initializeBindings(directive: IDirective) {
|
|
const btcIsObject = typeof directive.bindToController === 'object';
|
|
if (btcIsObject && Object.keys(directive.scope !).length) {
|
|
throw new Error(
|
|
`Binding definitions on scope and controller at the same time is not supported.`);
|
|
}
|
|
|
|
const context = (btcIsObject) ? directive.bindToController : directive.scope;
|
|
const bindings = new Bindings();
|
|
|
|
if (typeof context == 'object') {
|
|
Object.keys(context).forEach(propName => {
|
|
const definition = context[propName];
|
|
const bindingType = definition.charAt(0);
|
|
|
|
// QUESTION: What about `=*`? Ignore? Throw? Support?
|
|
|
|
switch (bindingType) {
|
|
case '@':
|
|
case '<':
|
|
// We don't need to do anything special. They will be defined as inputs on the
|
|
// upgraded component facade and the change propagation will be handled by
|
|
// `ngOnChanges()`.
|
|
break;
|
|
case '=':
|
|
bindings.twoWayBoundProperties.push(propName);
|
|
bindings.twoWayBoundLastValues.push(INITIAL_VALUE);
|
|
bindings.propertyToOutputMap[propName] = propName + 'Change';
|
|
break;
|
|
case '&':
|
|
bindings.expressionBoundProperties.push(propName);
|
|
bindings.propertyToOutputMap[propName] = propName;
|
|
break;
|
|
default:
|
|
let json = JSON.stringify(context);
|
|
throw new Error(
|
|
`Unexpected mapping '${bindingType}' in '${json}' in '${this.name}' directive.`);
|
|
}
|
|
});
|
|
}
|
|
|
|
return bindings;
|
|
}
|
|
|
|
private initializeOutputs() {
|
|
// Initialize the outputs for `=` and `&` bindings
|
|
this.bindings.twoWayBoundProperties.concat(this.bindings.expressionBoundProperties)
|
|
.forEach(propName => {
|
|
const outputName = this.bindings.propertyToOutputMap[propName];
|
|
(this as any)[outputName] = new EventEmitter();
|
|
});
|
|
}
|
|
|
|
private bindOutputs() {
|
|
// Bind `&` bindings to the corresponding outputs
|
|
this.bindings.expressionBoundProperties.forEach(propName => {
|
|
const outputName = this.bindings.propertyToOutputMap[propName];
|
|
const emitter = (this as any)[outputName];
|
|
|
|
this.bindingDestination[propName] = (value: any) => emitter.emit(value);
|
|
});
|
|
}
|
|
|
|
private forwardChanges(changes: SimpleChanges) {
|
|
// Forward input changes to `bindingDestination`
|
|
Object.keys(changes).forEach(
|
|
propName => this.bindingDestination[propName] = changes[propName].currentValue);
|
|
|
|
if (isFunction(this.bindingDestination.$onChanges)) {
|
|
this.bindingDestination.$onChanges(changes);
|
|
}
|
|
}
|
|
}
|