From 8082ba656a7034bd3ffe8f3c79fe664cdb09f0ee Mon Sep 17 00:00:00 2001 From: Georgios Kalpakas Date: Wed, 5 Jul 2017 13:58:27 +0300 Subject: [PATCH] fix(upgrade): fix transclusion on upgraded components Previously, only simple, single-slot transclusion worked on upgraded components. This commit fixes/adds support for the following: - Multi-slot transclusion. - Using fallback content when no transclusion content is provided. - Destroy unused scope (when using fallback content). Fixes #13271 --- packages/upgrade/src/common/angular1.ts | 6 +- packages/upgrade/src/common/util.ts | 8 + .../upgrade/src/static/upgrade_component.ts | 68 ++- packages/upgrade/test/common/test_helpers.ts | 5 +- .../integration/upgrade_component_spec.ts | 470 +++++++++++++++++- 5 files changed, 547 insertions(+), 10 deletions(-) diff --git a/packages/upgrade/src/common/angular1.ts b/packages/upgrade/src/common/angular1.ts index 24f612253f5f8d..0cd4fe232fe974 100644 --- a/packages/upgrade/src/common/angular1.ts +++ b/packages/upgrade/src/common/angular1.ts @@ -33,6 +33,7 @@ export interface ICompileService { } export interface ILinkFn { (scope: IScope, cloneAttachFn?: ICloneAttachFunction, options?: ILinkFnOptions): IAugmentedJQuery; + $$slots?: {[slotName: string]: ILinkFn}; } export interface ILinkFnOptions { parentBoundTranscludeFn?: Function; @@ -75,9 +76,10 @@ export interface IDirective { templateUrl?: string|Function; templateNamespace?: string; terminal?: boolean; - transclude?: boolean|'element'|{[key: string]: string}; + transclude?: DirectiveTranscludeProperty; } export type DirectiveRequireProperty = SingleOrListOrMap; +export type DirectiveTranscludeProperty = boolean | 'element' | {[key: string]: string}; export interface IDirectiveCompileFn { (templateElement: IAugmentedJQuery, templateAttributes: IAttributes, transclude: ITranscludeFunction): IDirectivePrePost; @@ -97,7 +99,7 @@ export interface IComponent { require?: DirectiveRequireProperty; template?: string|Function; templateUrl?: string|Function; - transclude?: boolean; + transclude?: DirectiveTranscludeProperty; } export interface IAttributes { $observe(attr: string, fn: (v: string) => void): void; } export interface ITranscludeFunction { diff --git a/packages/upgrade/src/common/util.ts b/packages/upgrade/src/common/util.ts index cc1d1a086c2c33..22180802f11056 100644 --- a/packages/upgrade/src/common/util.ts +++ b/packages/upgrade/src/common/util.ts @@ -9,6 +9,9 @@ import {Type} from '@angular/core'; import * as angular from './angular1'; +const DIRECTIVE_PREFIX_REGEXP = /^(?:x|data)[:\-_]/i; +const DIRECTIVE_SPECIAL_CHARS_REGEXP = /[:\-_]+(.)/g; + export function onError(e: any) { // TODO: (misko): We seem to not have a stack trace here! if (console.error) { @@ -24,6 +27,11 @@ export function controllerKey(name: string): string { return '$' + name + 'Controller'; } +export function directiveNormalize(name: string): string { + return name.replace(DIRECTIVE_PREFIX_REGEXP, '') + .replace(DIRECTIVE_SPECIAL_CHARS_REGEXP, (_, letter) => letter.toUpperCase()); +} + export function getAttributesAsArray(node: Node): [string, string][] { const attributes = node.attributes; let asArray: [string, string][] = undefined !; diff --git a/packages/upgrade/src/static/upgrade_component.ts b/packages/upgrade/src/static/upgrade_component.ts index 94065b0f8a07af..4b3b86b860e748 100644 --- a/packages/upgrade/src/static/upgrade_component.ts +++ b/packages/upgrade/src/static/upgrade_component.ts @@ -9,7 +9,7 @@ import {DoCheck, ElementRef, EventEmitter, Injector, OnChanges, OnDestroy, OnInit, SimpleChanges, ɵlooseIdentical as looseIdentical} from '@angular/core'; import * as angular from '../common/angular1'; import {$COMPILE, $CONTROLLER, $HTTP_BACKEND, $INJECTOR, $SCOPE, $TEMPLATE_CACHE} from '../common/constants'; -import {controllerKey} from '../common/util'; +import {controllerKey, directiveNormalize} from '../common/util'; const REQUIRE_PREFIX_RE = /^(\^\^?)?(\?)?(\^\^?)?/; const NOT_SUPPORTED: any = 'NOT_SUPPORTED'; @@ -144,7 +144,8 @@ export class UpgradeComponent implements OnInit, OnChanges, DoCheck, OnDestroy { ngOnInit() { // Collect contents, insert and compile template - const contentChildNodes = this.extractChildNodes(this.element); + const attachChildNodes: angular.ILinkFn|undefined = + this.prepareTransclusion(this.directive.transclude); const linkFn = this.compileTemplate(this.directive); // Instantiate controller @@ -203,8 +204,6 @@ export class UpgradeComponent implements OnInit, OnChanges, DoCheck, OnDestroy { preLink(this.$componentScope, this.$element, attrs, requiredControllers, transcludeFn); } - const attachChildNodes: angular.ILinkFn = (scope, cloneAttach) => - cloneAttach !(contentChildNodes); linkFn(this.$componentScope, null !, {parentBoundTranscludeFn: attachChildNodes}); if (postLink) { @@ -333,6 +332,66 @@ export class UpgradeComponent implements OnInit, OnChanges, DoCheck, OnDestroy { return bindings; } + private prepareTransclusion(transclude: angular.DirectiveTranscludeProperty = false): + angular.ILinkFn|undefined { + const contentChildNodes = this.extractChildNodes(this.element); + let $template = contentChildNodes; + let attachChildrenFn: angular.ILinkFn|undefined = (scope, cloneAttach) => + cloneAttach !($template, scope); + + if (transclude) { + const slots = Object.create(null); + + if (typeof transclude === 'object') { + $template = []; + + const slotMap = Object.create(null); + const filledSlots = Object.create(null); + + // Parse the element selectors. + Object.keys(transclude).forEach(slotName => { + let selector = transclude[slotName]; + const optional = selector.charAt(0) === '?'; + selector = optional ? selector.substring(1) : selector; + + slotMap[selector] = slotName; + slots[slotName] = null; // `null`: Defined but not yet filled. + filledSlots[slotName] = optional; // Consider optional slots as filled. + }); + + // Add the matching elements into their slot. + contentChildNodes.forEach(node => { + const slotName = slotMap[directiveNormalize(node.nodeName.toLowerCase())]; + if (slotName) { + filledSlots[slotName] = true; + slots[slotName] = slots[slotName] || []; + slots[slotName].push(node); + } else { + $template.push(node); + } + }); + + // Check for required slots that were not filled. + Object.keys(filledSlots).forEach(slotName => { + if (!filledSlots[slotName]) { + throw new Error(`Required transclusion slot '${slotName}' on directive: ${this.name}`); + } + }); + + Object.keys(slots).filter(slotName => slots[slotName]).forEach(slotName => { + const nodes = slots[slotName]; + slots[slotName] = (scope: angular.IScope, cloneAttach: angular.ICloneAttachFunction) => + cloneAttach !(nodes, scope); + }); + } + + // Attach `$$slots` to default slot transclude fn. + attachChildrenFn.$$slots = slots; + } + + return attachChildrenFn; + } + private extractChildNodes(element: Element): Node[] { const childNodes: Node[] = []; let childNode: Node|null; @@ -465,7 +524,6 @@ export class UpgradeComponent implements OnInit, OnChanges, DoCheck, OnDestroy { } } - function getOrCall(property: Function | T): T { return isFunction(property) ? property() : property; } diff --git a/packages/upgrade/test/common/test_helpers.ts b/packages/upgrade/test/common/test_helpers.ts index 037f59238c3299..827998ca76a470 100644 --- a/packages/upgrade/test/common/test_helpers.ts +++ b/packages/upgrade/test/common/test_helpers.ts @@ -20,9 +20,10 @@ export function html(html: string): Element { return div; } -export function multiTrim(text: string | null | undefined): string { +export function multiTrim(text: string | null | undefined, allSpace = false): string { if (typeof text == 'string') { - return text.replace(/\n/g, '').replace(/\s\s+/g, ' ').trim(); + const repl = allSpace ? '' : ' '; + return text.replace(/\n/g, '').replace(/\s+/g, repl).trim(); } throw new Error('Argument can not be undefined.'); } diff --git a/packages/upgrade/test/static/integration/upgrade_component_spec.ts b/packages/upgrade/test/static/integration/upgrade_component_spec.ts index 707a48197e03e1..988aa5ca8e1c3b 100644 --- a/packages/upgrade/test/static/integration/upgrade_component_spec.ts +++ b/packages/upgrade/test/static/integration/upgrade_component_spec.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Component, Directive, ElementRef, EventEmitter, Inject, Injector, Input, NO_ERRORS_SCHEMA, NgModule, Output, SimpleChanges, destroyPlatform} from '@angular/core'; +import {Component, Directive, ElementRef, ErrorHandler, EventEmitter, Inject, Injector, Input, NO_ERRORS_SCHEMA, NgModule, Output, SimpleChanges, destroyPlatform} from '@angular/core'; import {async, fakeAsync, tick} from '@angular/core/testing'; import {BrowserModule} from '@angular/platform-browser'; import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; @@ -1862,6 +1862,474 @@ export function main() { }); }); + describe('transclusion', () => { + it('should support single-slot transclusion', async(() => { + let ng2ComponentAInstance: Ng2ComponentA; + let ng2ComponentBInstance: Ng2ComponentB; + + // Define `ng1Component` + const ng1Component: + angular.IComponent = {template: 'ng1(
)', transclude: true}; + + // Define `Ng1ComponentFacade` + @Directive({selector: 'ng1'}) + class Ng1ComponentFacade extends UpgradeComponent { + constructor(elementRef: ElementRef, injector: Injector) { + super('ng1', elementRef, injector); + } + } + + // Define `Ng2Component` + @Component({ + selector: 'ng2A', + template: 'ng2A({{ value }} | )' + }) + class Ng2ComponentA { + value = 'foo'; + showB = false; + constructor() { ng2ComponentAInstance = this; } + } + + @Component({selector: 'ng2B', template: 'ng2B({{ value }})'}) + class Ng2ComponentB { + value = 'bar'; + constructor() { ng2ComponentBInstance = this; } + } + + // Define `ng1Module` + const ng1Module = angular.module('ng1Module', []) + .component('ng1', ng1Component) + .directive('ng2A', downgradeComponent({component: Ng2ComponentA})); + + // Define `Ng2Module` + @NgModule({ + imports: [BrowserModule, UpgradeModule], + declarations: [Ng1ComponentFacade, Ng2ComponentA, Ng2ComponentB], + entryComponents: [Ng2ComponentA] + }) + class Ng2Module { + ngDoBootstrap() {} + } + + // Bootstrap + const element = html(``); + + bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(adapter => { + expect(multiTrim(element.textContent)).toBe('ng2A(ng1(foo | ))'); + + ng2ComponentAInstance.value = 'baz'; + ng2ComponentAInstance.showB = true; + $digest(adapter); + + expect(multiTrim(element.textContent)).toBe('ng2A(ng1(baz | ng2B(bar)))'); + + ng2ComponentBInstance.value = 'qux'; + $digest(adapter); + + expect(multiTrim(element.textContent)).toBe('ng2A(ng1(baz | ng2B(qux)))'); + }); + })); + + it('should support single-slot transclusion with fallback content', async(() => { + let ng1ControllerInstances: any[] = []; + let ng2ComponentInstance: Ng2Component; + + // Define `ng1Component` + const ng1Component: angular.IComponent = { + template: 'ng1(
{{ $ctrl.value }}
)', + transclude: true, + controller: + class {value = 'from-ng1'; constructor() { ng1ControllerInstances.push(this); }} + }; + + // Define `Ng1ComponentFacade` + @Directive({selector: 'ng1'}) + class Ng1ComponentFacade extends UpgradeComponent { + constructor(elementRef: ElementRef, injector: Injector) { + super('ng1', elementRef, injector); + } + } + + // Define `Ng2Component` + @Component({selector: 'ng2', template: 'ng2({{ value }} | )'}) + class Ng2Component { + value = 'from-ng2'; + constructor() { ng2ComponentInstance = this; } + } + + // Define `ng1Module` + const ng1Module = angular.module('ng1Module', []) + .component('ng1', ng1Component) + .directive('ng2', downgradeComponent({component: Ng2Component})); + + // Define `Ng2Module` + @NgModule({ + imports: [BrowserModule, UpgradeModule], + declarations: [Ng1ComponentFacade, Ng2Component], + entryComponents: [Ng2Component] + }) + class Ng2Module { + ngDoBootstrap() {} + } + + // Bootstrap + const element = html(``); + + bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(adapter => { + expect(multiTrim(element.textContent)).toBe('ng2(ng1(from-ng2) | ng1(from-ng1))'); + + ng1ControllerInstances.forEach(ctrl => ctrl.value = 'ng1-foo'); + ng2ComponentInstance.value = 'ng2-bar'; + $digest(adapter); + + expect(multiTrim(element.textContent)).toBe('ng2(ng1(ng2-bar) | ng1(ng1-foo))'); + }); + })); + + it('should support multi-slot transclusion', async(() => { + let ng2ComponentInstance: Ng2Component; + + // Define `ng1Component` + const ng1Component: angular.IComponent = { + template: + 'ng1(x(
) | y(
))', + transclude: {slotX: 'contentX', slotY: 'contentY'} + }; + + // Define `Ng1ComponentFacade` + @Directive({selector: 'ng1'}) + class Ng1ComponentFacade extends UpgradeComponent { + constructor(elementRef: ElementRef, injector: Injector) { + super('ng1', elementRef, injector); + } + } + + // Define `Ng2Component` + @Component({ + selector: 'ng2', + template: ` + ng2( + + {{ x }}1 + {{ y }}1 + {{ x }}2 + {{ y }}2 + + )` + }) + class Ng2Component { + x = 'foo'; + y = 'bar'; + constructor() { ng2ComponentInstance = this; } + } + + // Define `ng1Module` + const ng1Module = angular.module('ng1Module', []) + .component('ng1', ng1Component) + .directive('ng2', downgradeComponent({component: Ng2Component})); + + // Define `Ng2Module` + @NgModule({ + imports: [BrowserModule, UpgradeModule], + declarations: [Ng1ComponentFacade, Ng2Component], + entryComponents: [Ng2Component], + schemas: [NO_ERRORS_SCHEMA] + }) + class Ng2Module { + ngDoBootstrap() {} + } + + // Bootstrap + const element = html(``); + + bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(adapter => { + expect(multiTrim(element.textContent, true)).toBe('ng2(ng1(x(foo1foo2)|y(bar1bar2)))'); + + ng2ComponentInstance.x = 'baz'; + ng2ComponentInstance.y = 'qux'; + $digest(adapter); + + expect(multiTrim(element.textContent, true)).toBe('ng2(ng1(x(baz1baz2)|y(qux1qux2)))'); + }); + })); + + it('should support default slot (with fallback content)', async(() => { + let ng1ControllerInstances: any[] = []; + let ng2ComponentInstance: Ng2Component; + + // Define `ng1Component` + const ng1Component: angular.IComponent = { + template: 'ng1(default(
fallback-{{ $ctrl.value }}
))', + transclude: {slotX: 'contentX', slotY: 'contentY'}, + controller: + class {value = 'ng1'; constructor() { ng1ControllerInstances.push(this); }} + }; + + // Define `Ng1ComponentFacade` + @Directive({selector: 'ng1'}) + class Ng1ComponentFacade extends UpgradeComponent { + constructor(elementRef: ElementRef, injector: Injector) { + super('ng1', elementRef, injector); + } + } + + // Define `Ng2Component` + @Component({ + selector: 'ng2', + template: ` + ng2( + + ({{ x }}) + ignored x + {{ x }}-{{ y }} + ignored y + ({{ y }}) + | + + ignored xignored y + )` + }) + class Ng2Component { + x = 'foo'; + y = 'bar'; + constructor() { ng2ComponentInstance = this; } + } + + // Define `ng1Module` + const ng1Module = angular.module('ng1Module', []) + .component('ng1', ng1Component) + .directive('ng2', downgradeComponent({component: Ng2Component})); + + // Define `Ng2Module` + @NgModule({ + imports: [BrowserModule, UpgradeModule], + declarations: [Ng1ComponentFacade, Ng2Component], + entryComponents: [Ng2Component], + schemas: [NO_ERRORS_SCHEMA] + }) + class Ng2Module { + ngDoBootstrap() {} + } + + // Bootstrap + const element = html(``); + + bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(adapter => { + expect(multiTrim(element.textContent, true)) + .toBe('ng2(ng1(default((foo)foo-bar(bar)))|ng1(default(fallback-ng1)))'); + + ng1ControllerInstances.forEach(ctrl => ctrl.value = 'ng1-plus'); + ng2ComponentInstance.x = 'baz'; + ng2ComponentInstance.y = 'qux'; + $digest(adapter); + + expect(multiTrim(element.textContent, true)) + .toBe('ng2(ng1(default((baz)baz-qux(qux)))|ng1(default(fallback-ng1-plus)))'); + }); + })); + + it('should support optional transclusion slots (with fallback content)', async(() => { + let ng1ControllerInstances: any[] = []; + let ng2ComponentInstance: Ng2Component; + + // Define `ng1Component` + const ng1Component: angular.IComponent = { + template: ` + ng1( + x(
{{ $ctrl.x }}
) | + y(
{{ $ctrl.y }}
) + )`, + transclude: {slotX: '?contentX', slotY: '?contentY'}, + controller: class { + x = 'ng1X'; y = 'ng1Y'; constructor() { ng1ControllerInstances.push(this); } + } + }; + + // Define `Ng1ComponentFacade` + @Directive({selector: 'ng1'}) + class Ng1ComponentFacade extends UpgradeComponent { + constructor(elementRef: ElementRef, injector: Injector) { + super('ng1', elementRef, injector); + } + } + + // Define `Ng2Component` + @Component({ + selector: 'ng2', + template: ` + ng2( + {{ x }} | + {{ y }} + )` + }) + class Ng2Component { + x = 'ng2X'; + y = 'ng2Y'; + constructor() { ng2ComponentInstance = this; } + } + + // Define `ng1Module` + const ng1Module = angular.module('ng1Module', []) + .component('ng1', ng1Component) + .directive('ng2', downgradeComponent({component: Ng2Component})); + + // Define `Ng2Module` + @NgModule({ + imports: [BrowserModule, UpgradeModule], + declarations: [Ng1ComponentFacade, Ng2Component], + entryComponents: [Ng2Component], + schemas: [NO_ERRORS_SCHEMA] + }) + class Ng2Module { + ngDoBootstrap() {} + } + + // Bootstrap + const element = html(``); + + bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(adapter => { + expect(multiTrim(element.textContent, true)) + .toBe('ng2(ng1(x(ng2X)|y(ng1Y))|ng1(x(ng1X)|y(ng2Y)))'); + + ng1ControllerInstances.forEach(ctrl => { + ctrl.x = 'ng1X-foo'; + ctrl.y = 'ng1Y-bar'; + }); + ng2ComponentInstance.x = 'ng2X-baz'; + ng2ComponentInstance.y = 'ng2Y-qux'; + $digest(adapter); + + expect(multiTrim(element.textContent, true)) + .toBe('ng2(ng1(x(ng2X-baz)|y(ng1Y-bar))|ng1(x(ng1X-foo)|y(ng2Y-qux)))'); + }); + })); + + it('should throw if a non-optional slot is not filled', async(() => { + let errorMessage: string; + + // Define `ng1Component` + const ng1Component: angular.IComponent = { + template: '', + transclude: {slotX: '?contentX', slotY: 'contentY'} + }; + + // Define `Ng1ComponentFacade` + @Directive({selector: 'ng1'}) + class Ng1ComponentFacade extends UpgradeComponent { + constructor(elementRef: ElementRef, injector: Injector) { + super('ng1', elementRef, injector); + } + } + + // Define `Ng2Component` + @Component({selector: 'ng2', template: ''}) + class Ng2Component { + } + + // Define `ng1Module` + const ng1Module = + angular.module('ng1Module', []) + .value('$exceptionHandler', (error: Error) => errorMessage = error.message) + .component('ng1', ng1Component) + .directive('ng2', downgradeComponent({component: Ng2Component})); + + // Define `Ng2Module` + @NgModule({ + imports: [BrowserModule, UpgradeModule], + declarations: [Ng1ComponentFacade, Ng2Component], + entryComponents: [Ng2Component] + }) + class Ng2Module { + ngDoBootstrap() {} + } + + // Bootstrap + const element = html(``); + + bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(adapter => { + expect(errorMessage) + .toContain('Required transclusion slot \'slotY\' on directive: ng1'); + }); + })); + + it('should support structural directives in transcluded content', async(() => { + let ng2ComponentInstance: Ng2Component; + + // Define `ng1Component` + const ng1Component: angular.IComponent = { + template: + 'ng1(x(
) | default(
))', + transclude: {slotX: 'contentX'} + }; + + // Define `Ng1ComponentFacade` + @Directive({selector: 'ng1'}) + class Ng1ComponentFacade extends UpgradeComponent { + constructor(elementRef: ElementRef, injector: Injector) { + super('ng1', elementRef, injector); + } + } + + // Define `Ng2Component` + @Component({ + selector: 'ng2', + template: ` + ng2( + +
{{ x }}1
+
{{ y }}1
+
{{ x }}2
+
{{ y }}2
+
+ )` + }) + class Ng2Component { + x = 'foo'; + y = 'bar'; + show = true; + constructor() { ng2ComponentInstance = this; } + } + + // Define `ng1Module` + const ng1Module = angular.module('ng1Module', []) + .component('ng1', ng1Component) + .directive('ng2', downgradeComponent({component: Ng2Component})); + + // Define `Ng2Module` + @NgModule({ + imports: [BrowserModule, UpgradeModule], + declarations: [Ng1ComponentFacade, Ng2Component], + entryComponents: [Ng2Component], + schemas: [NO_ERRORS_SCHEMA] + }) + class Ng2Module { + ngDoBootstrap() {} + } + + // Bootstrap + const element = html(``); + + bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then(adapter => { + expect(multiTrim(element.textContent, true)).toBe('ng2(ng1(x(foo1)|default(bar2)))'); + + ng2ComponentInstance.x = 'baz'; + ng2ComponentInstance.y = 'qux'; + ng2ComponentInstance.show = false; + $digest(adapter); + + expect(multiTrim(element.textContent, true)).toBe('ng2(ng1(x(baz2)|default(qux1)))'); + + ng2ComponentInstance.show = true; + $digest(adapter); + + expect(multiTrim(element.textContent, true)).toBe('ng2(ng1(x(baz1)|default(qux2)))'); + }); + })); + }); + describe('lifecycle hooks', () => { it('should call `$onChanges()` on binding destination (prototype)', fakeAsync(() => { const scopeOnChanges = jasmine.createSpy('scopeOnChanges');