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<C> (also applies to extending types `DomPortalHost`, `PortalHostDirective` and `MdDialogContainer`)
This commit is contained in:
Shlomi Assaf
2017-08-22 01:15:47 +03:00
committed by Kara
parent cdbf305771
commit 90a6ac95e5
16 changed files with 155 additions and 41 deletions
+1 -1
View File
@@ -93,7 +93,7 @@ export class OverlayOrigin {
})
export class ConnectedOverlayDirective implements OnDestroy, OnChanges {
private _overlayRef: OverlayRef;
private _templatePortal: TemplatePortal;
private _templatePortal: TemplatePortal<any>;
private _hasBackdrop = false;
private _backdropSubscription: Subscription | null;
private _positionSubscription: Subscription;
+1 -1
View File
@@ -20,7 +20,7 @@ import {
describe('Overlay', () => {
let overlay: Overlay;
let componentPortal: ComponentPortal<PizzaMsg>;
let templatePortal: TemplatePortal;
let templatePortal: TemplatePortal<any>;
let overlayContainerElement: HTMLElement;
let viewContainerFixture: ComponentFixture<TestComponentWithTemplatePortals>;
+3 -3
View File
@@ -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<string, any> {
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C> {
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<string, any>();
return viewRef;
}
/**
+5 -6
View File
@@ -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<any> {
constructor(templateRef: TemplateRef<any>, 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<string, any> {
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C> {
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<string, any>();
return viewRef;
}
}
+61 -4
View File
@@ -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 {
<ng-template cdk-portal>Cake</ng-template>
<div *cdk-portal>Pie</div>
<ng-template cdk-portal> {{fruit}} </ng-template>
<ng-template cdk-portal let-data> {{fruit}} - {{ data?.status }} </ng-template>
<ng-template cdk-portal>
<ul>
<li *ngFor="let fruitName of fruits"> {{fruitName}} </li>
</ul>
</ng-template>
<ng-template #templateRef let-data> {{fruit}} - {{ data?.status }}!</ng-template>
`,
})
class PortalTestApp {
@ViewChildren(TemplatePortalDirective) portals: QueryList<TemplatePortalDirective>;
@ViewChild(PortalHostDirective) portalHost: PortalHostDirective;
@ViewChild('templateRef', { read: TemplateRef }) templateRef: TemplateRef<any>;
selectedPortal: Portal<any>;
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
+17 -15
View File
@@ -11,6 +11,7 @@ import {
ViewContainerRef,
ElementRef,
ComponentRef,
EmbeddedViewRef,
Injector
} from '@angular/core';
import {
@@ -103,42 +104,43 @@ export class ComponentPortal<T> extends Portal<ComponentRef<T>> {
}
}
/**
* A `TemplatePortal` is a portal that represents some embedded template (TemplateRef).
*/
export class TemplatePortal extends Portal<Map<string, any>> {
export class TemplatePortal<C> extends Portal<C> {
/** The embedded template that will be used to instantiate an embedded View in the host. */
templateRef: TemplateRef<any>;
templateRef: TemplateRef<C>;
/** 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<string, any> = new Map<string, any>();
context: C | undefined;
constructor(template: TemplateRef<any>, viewContainerRef: ViewContainerRef) {
constructor(template: TemplateRef<any>, 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<string, any>): Map<string, any> {
this.locals = locals == null ? new Map<string, any>() : 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<string, any>();
this.context = undefined;
return super.detach();
}
}
@@ -203,7 +205,7 @@ export abstract class BasePortalHost implements PortalHost {
abstract attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T>;
abstract attachTemplatePortal(portal: TemplatePortal): Map<string, any>;
abstract attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C>;
detach(): void {
if (this._attachedPortal) {
+11 -1
View File
@@ -78,6 +78,16 @@
<p>Last afterClosed result: {{lastAfterClosedResult}}</p>
<p>Last beforeClose result: {{lastBeforeCloseResult}}</p>
<ng-template>
<ng-template let-data let-dialogRef="dialogRef">
I'm a template dialog. I've been opened {{numTemplateOpens}} times!
<p>It's Jazz!</p>
<md-input-container>
<input mdInput placeholder="How much?" #howMuch>
</md-input-container>
<p> {{ data.message }} </p>
<button type="button" (click)="dialogRef.close(lastCloseResult = howMuch.value)">Close dialog</button>
<button (click)="dialogRef.updateSize('500px', '500px').updatePosition({ top: '25px', left: '25px' });">Change dimensions</button>`
</ng-template>
+1 -1
View File
@@ -118,7 +118,7 @@ export function getMdAutocompleteMissingPanelError(): Error {
})
export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
private _overlayRef: OverlayRef | null;
private _portal: TemplatePortal;
private _portal: TemplatePortal<any>;
private _panelOpen: boolean = false;
/** Strategy that is used to position the panel. */
+2 -1
View File
@@ -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<string, any> {
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C> {
if (this._portalHost.hasAttached()) {
throwMdDialogContentAlreadyAttachedError();
}
+42
View File
@@ -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: `<ng-template let-data let-dialogRef="dialogRef">
Cheese {{localValue}} {{data?.value}}{{setDialogRef(dialogRef)}}</ng-template>`,
})
class ComponentWithTemplateRef {
localValue: string;
dialogRef: MdDialogRef<any>;
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
setDialogRef(dialogRef: MdDialogRef<any>): string {
this.dialogRef = dialogRef;
return '';
}
}
/** Simple component for testing ComponentPortal. */
@Component({template: '<p>Pizza</p> <input> <button>Close</button>'})
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
+3 -1
View File
@@ -242,7 +242,9 @@ export class MdDialog {
}
if (componentOrTemplateRef instanceof TemplateRef) {
dialogContainer.attachTemplatePortal(new TemplatePortal(componentOrTemplateRef, null!));
dialogContainer.attachTemplatePortal(
new TemplatePortal<T>(componentOrTemplateRef, null!,
<any>{ $implicit: config.data, dialogRef }));
} else {
const injector = this._createInjector<T>(config, dialogRef, dialogContainer);
const contentRef = dialogContainer.attachComponentPortal(
+1 -1
View File
@@ -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<any>;
private _overlayRef: OverlayRef | null = null;
private _menuOpen: boolean = false;
private _closeSubscription: Subscription;
+3 -2
View File
@@ -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<string, any> {
attachTemplatePortal(): EmbeddedViewRef<any> {
throw Error('Not yet implemented');
}
+1 -1
View File
@@ -180,7 +180,7 @@ describe('MdTabBody', () => {
`
})
class SimpleTabBodyApp {
content: TemplatePortal;
content: TemplatePortal<any>;
position: number;
origin: number;
+1 -1
View File
@@ -98,7 +98,7 @@ export class MdTabBody implements OnInit, AfterViewChecked {
@Output() onCentered: EventEmitter<void> = new EventEmitter<void>(true);
/** The tab body content to display. */
@Input('content') _content: TemplatePortal;
@Input('content') _content: TemplatePortal<any>;
/** The shifted index position of the tab body, where zero represents the active center tab. */
_position: MdTabBodyPositionState;
+2 -2
View File
@@ -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<any> | null = null;
get content(): TemplatePortal<any> | null { return this._contentPortal; }
/** Emits whenever the label changes. */
_labelChange = new Subject<void>();