From 90a6ac95e51fbfe7bbf4967bc1dd458bef09ed63 Mon Sep 17 00:00:00 2001 From: Shlomi Assaf Date: Tue, 22 Aug 2017 01:15:47 +0300 Subject: [PATCH] feat(portal): support context in TemplatePortal (#6408) BREAKING CHANGE: - `TemplatePortal` now requires a generic type (C) - method `attach` return type C and property `locals` was removed - method `attachTemplatePortal` in `BasePortalHost` now requires a generic type (C) and returns EmbeddedViewRef (also applies to extending types `DomPortalHost`, `PortalHostDirective` and `MdDialogContainer`) --- src/cdk/overlay/overlay-directives.ts | 2 +- src/cdk/overlay/overlay.spec.ts | 2 +- src/cdk/portal/dom-portal-host.ts | 6 +- src/cdk/portal/portal-directives.ts | 11 ++-- src/cdk/portal/portal.spec.ts | 65 ++++++++++++++++++-- src/cdk/portal/portal.ts | 32 +++++----- src/demo-app/dialog/dialog-demo.html | 12 +++- src/lib/autocomplete/autocomplete-trigger.ts | 2 +- src/lib/dialog/dialog-container.ts | 3 +- src/lib/dialog/dialog.spec.ts | 42 +++++++++++++ src/lib/dialog/dialog.ts | 4 +- src/lib/menu/menu-trigger.ts | 2 +- src/lib/snack-bar/snack-bar-container.ts | 5 +- src/lib/tabs/tab-body.spec.ts | 2 +- src/lib/tabs/tab-body.ts | 2 +- src/lib/tabs/tab.ts | 4 +- 16 files changed, 155 insertions(+), 41 deletions(-) diff --git a/src/cdk/overlay/overlay-directives.ts b/src/cdk/overlay/overlay-directives.ts index 757ae1331fb..5ab882e57d7 100644 --- a/src/cdk/overlay/overlay-directives.ts +++ b/src/cdk/overlay/overlay-directives.ts @@ -93,7 +93,7 @@ export class OverlayOrigin { }) export class ConnectedOverlayDirective implements OnDestroy, OnChanges { private _overlayRef: OverlayRef; - private _templatePortal: TemplatePortal; + private _templatePortal: TemplatePortal; private _hasBackdrop = false; private _backdropSubscription: Subscription | null; private _positionSubscription: Subscription; diff --git a/src/cdk/overlay/overlay.spec.ts b/src/cdk/overlay/overlay.spec.ts index a5b0e930978..8599803ab10 100644 --- a/src/cdk/overlay/overlay.spec.ts +++ b/src/cdk/overlay/overlay.spec.ts @@ -20,7 +20,7 @@ import { describe('Overlay', () => { let overlay: Overlay; let componentPortal: ComponentPortal; - let templatePortal: TemplatePortal; + let templatePortal: TemplatePortal; let overlayContainerElement: HTMLElement; let viewContainerFixture: ComponentFixture; diff --git a/src/cdk/portal/dom-portal-host.ts b/src/cdk/portal/dom-portal-host.ts index dc64fdc88bc..1dcefe35648 100644 --- a/src/cdk/portal/dom-portal-host.ts +++ b/src/cdk/portal/dom-portal-host.ts @@ -69,9 +69,9 @@ export class DomPortalHost extends BasePortalHost { * Attaches a template portal to the DOM as an embedded view. * @param portal Portal to be attached. */ - attachTemplatePortal(portal: TemplatePortal): Map { + attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef { let viewContainer = portal.viewContainerRef; - let viewRef = viewContainer.createEmbeddedView(portal.templateRef); + let viewRef = viewContainer.createEmbeddedView(portal.templateRef, portal.context); viewRef.detectChanges(); // The method `createEmbeddedView` will add the view as a child of the viewContainer. @@ -87,7 +87,7 @@ export class DomPortalHost extends BasePortalHost { })); // TODO(jelbourn): Return locals from view. - return new Map(); + return viewRef; } /** diff --git a/src/cdk/portal/portal-directives.ts b/src/cdk/portal/portal-directives.ts index 15ef6a67533..a0a30560dfb 100644 --- a/src/cdk/portal/portal-directives.ts +++ b/src/cdk/portal/portal-directives.ts @@ -10,6 +10,7 @@ import { NgModule, ComponentRef, Directive, + EmbeddedViewRef, TemplateRef, ComponentFactoryResolver, ViewContainerRef, @@ -32,7 +33,7 @@ import {Portal, TemplatePortal, ComponentPortal, BasePortalHost} from './portal' selector: '[cdk-portal], [cdkPortal], [portal]', exportAs: 'cdkPortal', }) -export class TemplatePortalDirective extends TemplatePortal { +export class TemplatePortalDirective extends TemplatePortal { constructor(templateRef: TemplateRef, viewContainerRef: ViewContainerRef) { super(templateRef, viewContainerRef); } @@ -117,16 +118,14 @@ export class PortalHostDirective extends BasePortalHost implements OnDestroy { * Attach the given TemplatePortal to this PortlHost as an embedded View. * @param portal Portal to be attached. */ - attachTemplatePortal(portal: TemplatePortal): Map { + attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef { portal.setAttachedHost(this); - - this._viewContainerRef.createEmbeddedView(portal.templateRef); + const viewRef = this._viewContainerRef.createEmbeddedView(portal.templateRef, portal.context); super.setDisposeFn(() => this._viewContainerRef.clear()); this._portal = portal; - // TODO(jelbourn): return locals from view - return new Map(); + return viewRef; } } diff --git a/src/cdk/portal/portal.spec.ts b/src/cdk/portal/portal.spec.ts index b0d46fe9ba6..61496f5ba45 100644 --- a/src/cdk/portal/portal.spec.ts +++ b/src/cdk/portal/portal.spec.ts @@ -10,10 +10,11 @@ import { Optional, Injector, ApplicationRef, + TemplateRef } from '@angular/core'; import {CommonModule} from '@angular/common'; import {TemplatePortalDirective, PortalHostDirective, PortalModule} from './portal-directives'; -import {Portal, ComponentPortal} from './portal'; +import {Portal, ComponentPortal, TemplatePortal} from './portal'; import {DomPortalHost} from './dom-portal-host'; @@ -45,6 +46,57 @@ describe('Portals', () => { expect(hostContainer.textContent).toContain('Pizza'); }); + it('should load a template into the portal', () => { + let testAppComponent = fixture.debugElement.componentInstance; + let hostContainer = fixture.nativeElement.querySelector('.portal-container'); + + let templatePortal = new TemplatePortal(testAppComponent.templateRef, null!); + testAppComponent.selectedPortal = templatePortal; + fixture.detectChanges(); + // Expect that the content of the attached portal is present and no context is projected + expect(hostContainer.textContent).toContain('Banana'); + }); + + it('should project template context bindings in the portal', () => { + let testAppComponent = fixture.debugElement.componentInstance; + let hostContainer = fixture.nativeElement.querySelector('.portal-container'); + + // TemplatePortal without context: + let templatePortal = new TemplatePortal(testAppComponent.templateRef, null!); + testAppComponent.selectedPortal = templatePortal; + fixture.detectChanges(); + // Expect that the content of the attached portal is present and NO context is projected + expect(hostContainer.textContent).toContain('Banana - !'); + + // using TemplatePortal.attach method to set context + testAppComponent.selectedPortal = undefined; + fixture.detectChanges(); + templatePortal.attach(testAppComponent.portalHost, {$implicit: {status: 'rotten'}}); + fixture.detectChanges(); + // Expect that the content of the attached portal is present and context given via the + // attach method is projected + expect(hostContainer.textContent).toContain('Banana - rotten!'); + + // using TemplatePortal constructor to set the context + templatePortal = + new TemplatePortal(testAppComponent.templateRef, null!, {$implicit: {status: 'fresh'}}); + testAppComponent.selectedPortal = templatePortal; + fixture.detectChanges(); + // Expect that the content of the attached portal is present and context given via the + // constructor is projected + expect(hostContainer.textContent).toContain('Banana - fresh!'); + + // using TemplatePortal constructor to set the context but also calling attach method with + // context, the latter should take precedence: + testAppComponent.selectedPortal = undefined; + fixture.detectChanges(); + templatePortal.attach(testAppComponent.portalHost, {$implicit: {status: 'rotten'}}); + fixture.detectChanges(); + // Expect that the content of the attached portal is present and and context given via the + // attach method is projected and get precedence over constructor context + expect(hostContainer.textContent).toContain('Banana - rotten!'); + }); + it('should dispose the host when destroyed', () => { // Set the selectedHost to be a ComponentPortal. let testAppComponent = fixture.debugElement.componentInstance; @@ -299,7 +351,7 @@ describe('Portals', () => { fixture.detectChanges(); // Attach the TemplatePortal. - testAppComponent.portalWithBinding.attach(host); + testAppComponent.portalWithBinding.attach(host, {$implicit: {status: 'fresh'}}); fixture.detectChanges(); // Now that the portal is attached, change detection has to happen again in order @@ -307,7 +359,7 @@ describe('Portals', () => { fixture.detectChanges(); // Expect that the content of the attached portal is present. - expect(someDomElement.textContent).toContain('Banana'); + expect(someDomElement.textContent).toContain('Banana - fresh'); // When updating the binding value. testAppComponent.fruit = 'Mango'; @@ -416,18 +468,22 @@ class ArbitraryViewContainerRefComponent { Cake
Pie
- {{fruit}} + {{fruit}} - {{ data?.status }}
  • {{fruitName}}
+ + {{fruit}} - {{ data?.status }}! `, }) class PortalTestApp { @ViewChildren(TemplatePortalDirective) portals: QueryList; @ViewChild(PortalHostDirective) portalHost: PortalHostDirective; + @ViewChild('templateRef', { read: TemplateRef }) templateRef: TemplateRef; + selectedPortal: Portal; fruit: string = 'Banana'; fruits = ['Apple', 'Pineapple', 'Durian']; @@ -449,6 +505,7 @@ class PortalTestApp { get portalWithTemplate() { return this.portals.toArray()[3]; } + } // Create a real (non-test) NgModule as a workaround for diff --git a/src/cdk/portal/portal.ts b/src/cdk/portal/portal.ts index d0cf865be5e..32eb30ec902 100644 --- a/src/cdk/portal/portal.ts +++ b/src/cdk/portal/portal.ts @@ -11,6 +11,7 @@ import { ViewContainerRef, ElementRef, ComponentRef, + EmbeddedViewRef, Injector } from '@angular/core'; import { @@ -103,42 +104,43 @@ export class ComponentPortal extends Portal> { } } - /** * A `TemplatePortal` is a portal that represents some embedded template (TemplateRef). */ -export class TemplatePortal extends Portal> { +export class TemplatePortal extends Portal { /** The embedded template that will be used to instantiate an embedded View in the host. */ - templateRef: TemplateRef; + templateRef: TemplateRef; /** Reference to the ViewContainer into which the template will be stamped out. */ viewContainerRef: ViewContainerRef; - /** - * Additional locals for the instantiated embedded view. - * These locals can be seen as "exports" for the template, such as how ngFor has - * index / event / odd. - * See https://angular.io/docs/ts/latest/api/core/EmbeddedViewRef-class.html - */ - locals: Map = new Map(); + context: C | undefined; - constructor(template: TemplateRef, viewContainerRef: ViewContainerRef) { + constructor(template: TemplateRef, viewContainerRef: ViewContainerRef, context?: C) { super(); this.templateRef = template; this.viewContainerRef = viewContainerRef; + if (context) { + this.context = context; + } } get origin(): ElementRef { return this.templateRef.elementRef; } - attach(host: PortalHost, locals?: Map): Map { - this.locals = locals == null ? new Map() : locals; + /** + * Attach the the portal to the provided `PortalHost`. + * When a context is provided it will override the `context` property of the `TemplatePortal` + * instance. + */ + attach(host: PortalHost, context: C | undefined = this.context): C { + this.context = context; return super.attach(host); } detach(): void { - this.locals = new Map(); + this.context = undefined; return super.detach(); } } @@ -203,7 +205,7 @@ export abstract class BasePortalHost implements PortalHost { abstract attachComponentPortal(portal: ComponentPortal): ComponentRef; - abstract attachTemplatePortal(portal: TemplatePortal): Map; + abstract attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef; detach(): void { if (this._attachedPortal) { diff --git a/src/demo-app/dialog/dialog-demo.html b/src/demo-app/dialog/dialog-demo.html index 2a335be3841..37efd6e028f 100644 --- a/src/demo-app/dialog/dialog-demo.html +++ b/src/demo-app/dialog/dialog-demo.html @@ -78,6 +78,16 @@

Last afterClosed result: {{lastAfterClosedResult}}

Last beforeClose result: {{lastBeforeCloseResult}}

- + I'm a template dialog. I've been opened {{numTemplateOpens}} times! + +

It's Jazz!

+ + + + + +

{{ data.message }}

+ + `
diff --git a/src/lib/autocomplete/autocomplete-trigger.ts b/src/lib/autocomplete/autocomplete-trigger.ts index 41b393d2355..5d90c9bb440 100644 --- a/src/lib/autocomplete/autocomplete-trigger.ts +++ b/src/lib/autocomplete/autocomplete-trigger.ts @@ -118,7 +118,7 @@ export function getMdAutocompleteMissingPanelError(): Error { }) export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy { private _overlayRef: OverlayRef | null; - private _portal: TemplatePortal; + private _portal: TemplatePortal; private _panelOpen: boolean = false; /** Strategy that is used to position the panel. */ diff --git a/src/lib/dialog/dialog-container.ts b/src/lib/dialog/dialog-container.ts index 235f46cd717..6ef4ddaf6c8 100644 --- a/src/lib/dialog/dialog-container.ts +++ b/src/lib/dialog/dialog-container.ts @@ -10,6 +10,7 @@ import { Component, ComponentRef, ElementRef, + EmbeddedViewRef, EventEmitter, Inject, NgZone, @@ -124,7 +125,7 @@ export class MdDialogContainer extends BasePortalHost { * Attach a TemplatePortal as content to this dialog container. * @param portal Portal to be attached as the dialog content. */ - attachTemplatePortal(portal: TemplatePortal): Map { + attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef { if (this._portalHost.hasAttached()) { throwMdDialogContentAlreadyAttachedError(); } diff --git a/src/lib/dialog/dialog.spec.ts b/src/lib/dialog/dialog.spec.ts index 11a8bdb48fd..ca09dc13883 100644 --- a/src/lib/dialog/dialog.spec.ts +++ b/src/lib/dialog/dialog.spec.ts @@ -16,6 +16,7 @@ import { Injector, Inject, ChangeDetectionStrategy, + TemplateRef } from '@angular/core'; import {By} from '@angular/platform-browser'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; @@ -79,6 +80,28 @@ describe('MdDialog', () => { expect(dialogContainerElement.getAttribute('role')).toBe('dialog'); }); + it('should open a dialog with a template', () => { + const templateRefFixture = TestBed.createComponent(ComponentWithTemplateRef); + templateRefFixture.componentInstance.localValue = 'Bees'; + templateRefFixture.detectChanges(); + + const data = {value: 'Knees'}; + + let dialogRef = dialog.open(templateRefFixture.componentInstance.templateRef, { data }); + + viewContainerFixture.detectChanges(); + + expect(overlayContainerElement.textContent).toContain('Cheese Bees Knees'); + expect(templateRefFixture.componentInstance.dialogRef).toBe(dialogRef); + + viewContainerFixture.detectChanges(); + + let dialogContainerElement = overlayContainerElement.querySelector('md-dialog-container')!; + expect(dialogContainerElement.getAttribute('role')).toBe('dialog'); + + dialogRef.close(); + }); + it('should use injector from viewContainerRef for DialogInjector', () => { let dialogRef = dialog.open(PizzaMsg, { viewContainerRef: testViewContainerRef @@ -876,6 +899,23 @@ class ComponentWithChildViewContainer { } } +@Component({ + selector: 'arbitrary-component-with-template-ref', + template: ` + Cheese {{localValue}} {{data?.value}}{{setDialogRef(dialogRef)}}`, +}) +class ComponentWithTemplateRef { + localValue: string; + dialogRef: MdDialogRef; + + @ViewChild(TemplateRef) templateRef: TemplateRef; + + setDialogRef(dialogRef: MdDialogRef): string { + this.dialogRef = dialogRef; + return ''; + } +} + /** Simple component for testing ComponentPortal. */ @Component({template: '

Pizza

'}) class PizzaMsg { @@ -916,6 +956,7 @@ class DialogWithInjectedData { // https://github.com/angular/angular/issues/10760 const TEST_DIRECTIVES = [ ComponentWithChildViewContainer, + ComponentWithTemplateRef, PizzaMsg, DirectiveWithViewContainer, ComponentWithOnPushViewContainer, @@ -929,6 +970,7 @@ const TEST_DIRECTIVES = [ declarations: TEST_DIRECTIVES, entryComponents: [ ComponentWithChildViewContainer, + ComponentWithTemplateRef, PizzaMsg, ContentElementDialog, DialogWithInjectedData diff --git a/src/lib/dialog/dialog.ts b/src/lib/dialog/dialog.ts index efb0d3bd0af..1d920f0072f 100644 --- a/src/lib/dialog/dialog.ts +++ b/src/lib/dialog/dialog.ts @@ -242,7 +242,9 @@ export class MdDialog { } if (componentOrTemplateRef instanceof TemplateRef) { - dialogContainer.attachTemplatePortal(new TemplatePortal(componentOrTemplateRef, null!)); + dialogContainer.attachTemplatePortal( + new TemplatePortal(componentOrTemplateRef, null!, + { $implicit: config.data, dialogRef })); } else { const injector = this._createInjector(config, dialogRef, dialogContainer); const contentRef = dialogContainer.attachComponentPortal( diff --git a/src/lib/menu/menu-trigger.ts b/src/lib/menu/menu-trigger.ts index af325ae3a66..b2bda1683e1 100644 --- a/src/lib/menu/menu-trigger.ts +++ b/src/lib/menu/menu-trigger.ts @@ -86,7 +86,7 @@ export const MENU_PANEL_TOP_PADDING = 8; exportAs: 'mdMenuTrigger' }) export class MdMenuTrigger implements AfterViewInit, OnDestroy { - private _portal: TemplatePortal; + private _portal: TemplatePortal; private _overlayRef: OverlayRef | null = null; private _menuOpen: boolean = false; private _closeSubscription: Subscription; diff --git a/src/lib/snack-bar/snack-bar-container.ts b/src/lib/snack-bar/snack-bar-container.ts index 6a0f8f6d065..da5c2d1df69 100644 --- a/src/lib/snack-bar/snack-bar-container.ts +++ b/src/lib/snack-bar/snack-bar-container.ts @@ -9,12 +9,13 @@ import { Component, ComponentRef, + EmbeddedViewRef, ViewChild, NgZone, OnDestroy, Renderer2, ElementRef, - ChangeDetectionStrategy, + ChangeDetectionStrategy } from '@angular/core'; import { trigger, @@ -109,7 +110,7 @@ export class MdSnackBarContainer extends BasePortalHost implements OnDestroy { } /** Attach a template portal as content to this snack bar container. */ - attachTemplatePortal(): Map { + attachTemplatePortal(): EmbeddedViewRef { throw Error('Not yet implemented'); } diff --git a/src/lib/tabs/tab-body.spec.ts b/src/lib/tabs/tab-body.spec.ts index 1f83fc3639d..e5f459ec12f 100644 --- a/src/lib/tabs/tab-body.spec.ts +++ b/src/lib/tabs/tab-body.spec.ts @@ -180,7 +180,7 @@ describe('MdTabBody', () => { ` }) class SimpleTabBodyApp { - content: TemplatePortal; + content: TemplatePortal; position: number; origin: number; diff --git a/src/lib/tabs/tab-body.ts b/src/lib/tabs/tab-body.ts index 7d6a2f25ebf..ce54ea95778 100644 --- a/src/lib/tabs/tab-body.ts +++ b/src/lib/tabs/tab-body.ts @@ -98,7 +98,7 @@ export class MdTabBody implements OnInit, AfterViewChecked { @Output() onCentered: EventEmitter = new EventEmitter(true); /** The tab body content to display. */ - @Input('content') _content: TemplatePortal; + @Input('content') _content: TemplatePortal; /** The shifted index position of the tab body, where zero represents the active center tab. */ _position: MdTabBodyPositionState; diff --git a/src/lib/tabs/tab.ts b/src/lib/tabs/tab.ts index 709681f7a48..6af56be136c 100644 --- a/src/lib/tabs/tab.ts +++ b/src/lib/tabs/tab.ts @@ -39,8 +39,8 @@ export class MdTab extends _MdTabMixinBase implements OnInit, CanDisable, OnChan @Input('label') textLabel: string = ''; /** The portal that will be the hosted content of the tab */ - private _contentPortal: TemplatePortal | null = null; - get content(): TemplatePortal | null { return this._contentPortal; } + private _contentPortal: TemplatePortal | null = null; + get content(): TemplatePortal | null { return this._contentPortal; } /** Emits whenever the label changes. */ _labelChange = new Subject();