diff --git a/npm/ng-packs/packages/core/src/lib/strategies/index.ts b/npm/ng-packs/packages/core/src/lib/strategies/index.ts index 8c36b230ef..2e621e7907 100644 --- a/npm/ng-packs/packages/core/src/lib/strategies/index.ts +++ b/npm/ng-packs/packages/core/src/lib/strategies/index.ts @@ -5,3 +5,4 @@ export * from './context.strategy'; export * from './cross-origin.strategy'; export * from './dom.strategy'; export * from './loading.strategy'; +export * from './projection.strategy'; diff --git a/npm/ng-packs/packages/core/src/lib/strategies/projection.strategy.ts b/npm/ng-packs/packages/core/src/lib/strategies/projection.strategy.ts new file mode 100644 index 0000000000..4d8c410480 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/strategies/projection.strategy.ts @@ -0,0 +1,181 @@ +import { + ApplicationRef, + ComponentFactoryResolver, + ComponentRef, + EmbeddedViewRef, + Injector, + TemplateRef, + Type, + ViewContainerRef, +} from '@angular/core'; +import { InferedInstanceOf } from '../models/utility'; +import { ContainerStrategy, CONTAINER_STRATEGY } from './container.strategy'; +import { + ComponentContextStrategy, + ContextStrategy, + CONTEXT_STRATEGY, + TemplateContextStrategy, +} from './context.strategy'; +import { DomStrategy, DOM_STRATEGY } from './dom.strategy'; + +export abstract class ProjectionStrategy { + constructor(public content: T) {} + + abstract injectContent(injector: Injector): ComponentRefOrEmbeddedViewRef; +} + +export class ComponentProjectionStrategy> extends ProjectionStrategy { + constructor( + component: T, + private containerStrategy: ContainerStrategy, + private contextStrategy: ContextStrategy = CONTEXT_STRATEGY.None(), + ) { + super(component); + } + + injectContent(injector: Injector) { + this.containerStrategy.prepare(); + + const resolver = injector.get(ComponentFactoryResolver) as ComponentFactoryResolver; + const factory = resolver.resolveComponentFactory>(this.content); + + const componentRef = this.containerStrategy.containerRef.createComponent( + factory, + this.containerStrategy.getIndex(), + injector, + ); + this.contextStrategy.setContext(componentRef); + + return componentRef as ComponentRefOrEmbeddedViewRef; + } +} + +export class RootComponentProjectionStrategy> extends ProjectionStrategy { + constructor( + component: T, + private contextStrategy: ContextStrategy = CONTEXT_STRATEGY.None(), + private domStrategy: DomStrategy = DOM_STRATEGY.AppendToBody(), + ) { + super(component); + } + + injectContent(injector: Injector) { + const appRef = injector.get(ApplicationRef); + const resolver = injector.get(ComponentFactoryResolver) as ComponentFactoryResolver; + const componentRef = resolver + .resolveComponentFactory>(this.content) + .create(injector); + + this.contextStrategy.setContext(componentRef); + + appRef.attachView(componentRef.hostView); + const element: HTMLElement = (componentRef.hostView as EmbeddedViewRef).rootNodes[0]; + this.domStrategy.insertElement(element); + + return componentRef as ComponentRefOrEmbeddedViewRef; + } +} + +export class TemplateProjectionStrategy> extends ProjectionStrategy { + constructor( + template: T, + private containerStrategy: ContainerStrategy, + private contextStrategy = CONTEXT_STRATEGY.None(), + ) { + super(template); + } + + injectContent(injector: Injector) { + this.containerStrategy.prepare(); + + const embeddedViewRef = this.containerStrategy.containerRef.createEmbeddedView( + this.content, + this.contextStrategy.context, + this.containerStrategy.getIndex(), + ); + embeddedViewRef.detectChanges(); + + return embeddedViewRef as ComponentRefOrEmbeddedViewRef; + } +} + +export const PROJECTION_STRATEGY = { + AppendComponentToBody>( + component: T, + contextStrategy?: ComponentContextStrategy, + ) { + return new RootComponentProjectionStrategy(component, contextStrategy); + }, + AppendComponentToContainer>( + component: T, + containerRef: ViewContainerRef, + contextStrategy?: ComponentContextStrategy, + ) { + return new ComponentProjectionStrategy( + component, + CONTAINER_STRATEGY.Append(containerRef), + contextStrategy, + ); + }, + AppendTemplateToContainer>( + template: T, + containerRef: ViewContainerRef, + contextStrategy?: TemplateContextStrategy, + ) { + return new TemplateProjectionStrategy( + template, + CONTAINER_STRATEGY.Append(containerRef), + contextStrategy, + ); + }, + PrependComponentToContainer>( + component: T, + containerRef: ViewContainerRef, + contextStrategy?: ComponentContextStrategy, + ) { + return new ComponentProjectionStrategy( + component, + CONTAINER_STRATEGY.Prepend(containerRef), + contextStrategy, + ); + }, + PrependTemplateToContainer>( + template: T, + containerRef: ViewContainerRef, + contextStrategy?: TemplateContextStrategy, + ) { + return new TemplateProjectionStrategy( + template, + CONTAINER_STRATEGY.Prepend(containerRef), + contextStrategy, + ); + }, + ProjectComponentToContainer>( + component: T, + containerRef: ViewContainerRef, + contextStrategy?: ComponentContextStrategy, + ) { + return new ComponentProjectionStrategy( + component, + CONTAINER_STRATEGY.Clear(containerRef), + contextStrategy, + ); + }, + ProjectTemplateToContainer>( + template: T, + containerRef: ViewContainerRef, + contextStrategy?: TemplateContextStrategy, + ) { + return new TemplateProjectionStrategy( + template, + CONTAINER_STRATEGY.Clear(containerRef), + contextStrategy, + ); + }, +}; + +type ComponentRefOrEmbeddedViewRef = T extends Type + ? ComponentRef + : T extends TemplateRef + ? EmbeddedViewRef + : never; diff --git a/npm/ng-packs/packages/core/src/lib/tests/projection.strategy.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/projection.strategy.spec.ts new file mode 100644 index 0000000000..71a531c5eb --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/projection.strategy.spec.ts @@ -0,0 +1,245 @@ +import { + Component, + ComponentRef, + EmbeddedViewRef, + TemplateRef, + ViewChild, + ViewContainerRef, +} from '@angular/core'; +import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; +import { + ComponentProjectionStrategy, + ContainerStrategy, + CONTAINER_STRATEGY, + CONTEXT_STRATEGY, + DOM_STRATEGY, + PROJECTION_STRATEGY, + RootComponentProjectionStrategy, + TemplateProjectionStrategy, +} from '../strategies'; + +describe('ComponentProjectionStrategy', () => { + @Component({ + template: '
{{ bar || baz }}
', + }) + class TestComponent { + bar: string; + baz = 'baz'; + } + + @Component({ + template: '', + }) + class HostComponent { + @ViewChild('container', { static: true, read: ViewContainerRef }) + containerRef: ViewContainerRef; + } + + let containerStrategy: ContainerStrategy; + let spectator: Spectator; + let componentRef: ComponentRef; + + const createComponent = createComponentFactory({ + component: HostComponent, + entryComponents: [TestComponent], + }); + + beforeEach(() => { + spectator = createComponent({}); + containerStrategy = CONTAINER_STRATEGY.Clear(spectator.component.containerRef); + }); + + afterEach(() => { + componentRef.destroy(); + spectator.detectChanges(); + }); + + describe('#injectContent', () => { + it('should should insert content into container and return a ComponentRef', () => { + const strategy = new ComponentProjectionStrategy(TestComponent, containerStrategy); + componentRef = strategy.injectContent(spectator); + spectator.detectChanges(); + + const div = spectator.query('div.foo'); + expect(div.textContent).toBe('baz'); + expect(componentRef).toBeInstanceOf(ComponentRef); + }); + + it('should be able to map context to projected component', () => { + const contextStrategy = CONTEXT_STRATEGY.Component({ bar: 'bar' }); + const strategy = new ComponentProjectionStrategy( + TestComponent, + containerStrategy, + contextStrategy, + ); + componentRef = strategy.injectContent(spectator); + spectator.detectChanges(); + + const div = spectator.query('div.foo'); + expect(div.textContent).toBe('bar'); + expect(componentRef.instance.bar).toBe('bar'); + }); + }); +}); + +describe('RootComponentProjectionStrategy', () => { + @Component({ + template: '
{{ bar || baz }}
', + }) + class TestComponent { + bar: string; + baz = 'baz'; + } + + @Component({ template: '' }) + class HostComponent {} + + let spectator: Spectator; + let componentRef: ComponentRef; + + const createComponent = createComponentFactory({ + component: HostComponent, + entryComponents: [TestComponent], + }); + + beforeEach(() => { + spectator = createComponent({}); + }); + + afterEach(() => { + componentRef.destroy(); + spectator.detectChanges(); + }); + + describe('#injectContent', () => { + it('should should insert content into body and return a ComponentRef', () => { + const strategy = new RootComponentProjectionStrategy(TestComponent); + componentRef = strategy.injectContent(spectator); + spectator.detectChanges(); + + const div = document.querySelector('body > ng-component > div.foo'); + expect(div.textContent).toBe('baz'); + expect(componentRef).toBeInstanceOf(ComponentRef); + componentRef.destroy(); + spectator.detectChanges(); + }); + + it('should be able to map context to projected component', () => { + const contextStrategy = CONTEXT_STRATEGY.Component({ bar: 'bar' }); + const strategy = new RootComponentProjectionStrategy(TestComponent, contextStrategy); + componentRef = strategy.injectContent(spectator); + spectator.detectChanges(); + + const div = document.querySelector('body > ng-component > div.foo'); + expect(div.textContent).toBe('bar'); + expect(componentRef.instance.bar).toBe('bar'); + }); + }); +}); + +describe('TemplateProjectionStrategy', () => { + @Component({ + template: ` + +
{{ bar || baz }}
+
+ + `, + }) + class HostComponent { + @ViewChild('container', { static: true, read: ViewContainerRef }) + containerRef: ViewContainerRef; + + @ViewChild('template', { static: true }) + templateRef: TemplateRef<{ $implicit?: string }>; + + baz = 'baz'; + } + + let containerStrategy: ContainerStrategy; + let spectator: Spectator; + let embeddedViewRef: EmbeddedViewRef<{ $implicit?: string }>; + + const createComponent = createComponentFactory({ + component: HostComponent, + }); + + beforeEach(() => { + spectator = createComponent({}); + containerStrategy = CONTAINER_STRATEGY.Clear(spectator.component.containerRef); + }); + + afterEach(() => { + embeddedViewRef.destroy(); + spectator.detectChanges(); + }); + + describe('#injectContent', () => { + it('should should insert content into container and return an EmbeddedViewRef', () => { + const templateRef = spectator.component.templateRef; + const strategy = new TemplateProjectionStrategy(templateRef, containerStrategy); + embeddedViewRef = strategy.injectContent(spectator); + spectator.detectChanges(); + + const div = spectator.query('div.foo'); + expect(div.textContent).toBe('baz'); + expect(embeddedViewRef).toHaveProperty('detectChanges'); + expect(embeddedViewRef).toHaveProperty('markForCheck'); + expect(embeddedViewRef).toHaveProperty('detach'); + expect(embeddedViewRef).toHaveProperty('reattach'); + expect(embeddedViewRef).toHaveProperty('destroy'); + expect(embeddedViewRef).toHaveProperty('rootNodes'); + expect(embeddedViewRef).toHaveProperty('context'); + }); + + it('should be able to map context to projected template', () => { + const templateRef = spectator.component.templateRef; + const contextStrategy = CONTEXT_STRATEGY.Template({ $implicit: 'bar' }); + const strategy = new TemplateProjectionStrategy( + templateRef, + containerStrategy, + contextStrategy, + ); + embeddedViewRef = strategy.injectContent(spectator); + spectator.detectChanges(); + + const div = spectator.query('div.foo'); + expect(div.textContent).toBe('bar'); + expect(embeddedViewRef.context).toEqual(contextStrategy.context); + }); + }); +}); + +describe('PROJECTION_STRATEGY', () => { + const content = undefined; + const containerRef = ({ length: 0 } as any) as ViewContainerRef; + test.each` + name | Strategy | containerStrategy + ${'AppendComponentToContainer'} | ${ComponentProjectionStrategy} | ${CONTAINER_STRATEGY.Append} + ${'AppendTemplateToContainer'} | ${TemplateProjectionStrategy} | ${CONTAINER_STRATEGY.Append} + ${'PrependComponentToContainer'} | ${ComponentProjectionStrategy} | ${CONTAINER_STRATEGY.Prepend} + ${'PrependTemplateToContainer'} | ${TemplateProjectionStrategy} | ${CONTAINER_STRATEGY.Prepend} + ${'ProjectComponentToContainer'} | ${ComponentProjectionStrategy} | ${CONTAINER_STRATEGY.Clear} + ${'ProjectTemplateToContainer'} | ${TemplateProjectionStrategy} | ${CONTAINER_STRATEGY.Clear} + `( + 'should successfully map $name to $Strategy.name with $containerStrategy.name container strategy', + ({ name, Strategy, containerStrategy }) => { + expect(PROJECTION_STRATEGY[name](content, containerRef)).toEqual( + new Strategy(content, containerStrategy(containerRef)), + ); + }, + ); + + const contextStrategy = undefined; + test.each` + name | Strategy | domStrategy + ${'AppendComponentToBody'} | ${RootComponentProjectionStrategy} | ${DOM_STRATEGY.AppendToBody} + `( + 'should successfully map $name to $Strategy.name with $domStrategy.name dom strategy', + ({ name, Strategy, domStrategy }) => { + expect(PROJECTION_STRATEGY[name](content, contextStrategy)).toEqual( + new Strategy(content, contextStrategy, domStrategy()), + ); + }, + ); +});