feat(aio): lazy-load embedded components (#18428)

Fixes #16127

PR Close #18428
This commit is contained in:
Georgios Kalpakas 2017-07-31 15:45:18 +03:00 committed by Jason Aden
parent 225baf4686
commit 7d81309e11
18 changed files with 1378 additions and 502 deletions

View File

@ -2,19 +2,19 @@
"aio": {
"master": {
"gzip7": {
"inline": 925,
"main": 119519,
"polyfills": 11863
"inline": 941,
"main": 116124,
"polyfills": 11860
},
"gzip9": {
"inline": 925,
"main": 119301,
"polyfills": 11861
"inline": 941,
"main": 115954,
"polyfills": 11858
},
"uncompressed": {
"inline": 1533,
"main": 486493,
"polyfills": 37068
"inline": 1558,
"main": 456432,
"polyfills": 37070
}
}
}

View File

@ -12,6 +12,7 @@ import { AppComponent } from './app.component';
import { AppModule } from './app.module';
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { Deployment } from 'app/shared/deployment.service';
import { EmbedComponentsService } from 'app/embed-components/embed-components.service';
import { GaService } from 'app/shared/ga.service';
import { LocationService } from 'app/shared/location.service';
import { Logger } from 'app/shared/logger.service';
@ -24,7 +25,7 @@ import { SearchBoxComponent } from 'app/search/search-box/search-box.component';
import { SearchResultsComponent } from 'app/shared/search-results/search-results.component';
import { SearchService } from 'app/search/search.service';
import { SelectComponent } from 'app/shared/select/select.component';
import { TocComponent } from 'app/embedded/toc/toc.component';
import { TocComponent } from 'app/layout/toc/toc.component';
import { TocItem, TocService } from 'app/shared/toc.service';
const sideBySideBreakPoint = 992;
@ -1033,6 +1034,7 @@ function createTestingModule(initialUrl: string, mode: string = 'stable') {
imports: [ AppModule ],
providers: [
{ provide: APP_BASE_HREF, useValue: '/' },
{ provide: EmbedComponentsService, useClass: TestEmbedComponentsService },
{ provide: GaService, useClass: TestGaService },
{ provide: HttpClient, useClass: TestHttpClient },
{ provide: LocationService, useFactory: () => mockLocationService },
@ -1047,6 +1049,10 @@ function createTestingModule(initialUrl: string, mode: string = 'stable') {
});
}
class TestEmbedComponentsService {
embedInto = jasmine.createSpy('embedInto').and.returnValue(of([]));
}
class TestGaService {
locationChanged = jasmine.createSpy('locationChanged');
}

View File

@ -1,6 +1,6 @@
import { Component, ElementRef, HostBinding, HostListener, OnInit,
QueryList, ViewChild, ViewChildren } from '@angular/core';
import { MatSidenav } from '@angular/material';
import { MatSidenav } from '@angular/material/sidenav';
import { CurrentNodes, NavigationService, NavigationNode, VersionInfo } from 'app/navigation/navigation.service';
import { DocumentService, DocumentContents } from 'app/documents/document.service';

View File

@ -0,0 +1,48 @@
import { TestBed } from '@angular/core/testing';
import { AppModule } from 'app/app.module';
import { ComponentsOrModulePath, EMBEDDED_COMPONENTS } from 'app/embed-components/embed-components.service';
import { embeddedComponents } from 'app/embedded/embedded.module';
describe('AppModule', () => {
let componentsMap: {[multiSelectorstring: string]: ComponentsOrModulePath};
beforeEach(() => {
TestBed.configureTestingModule({imports: [AppModule]});
componentsMap = TestBed.get(EMBEDDED_COMPONENTS);
});
it('should provide a map of selectors to embedded components (or module)', () => {
const allSelectors = Object.keys(componentsMap);
expect(allSelectors.length).toBeGreaterThan(1);
allSelectors.forEach(selector => {
const value = componentsMap[selector];
const isArrayOrString = Array.isArray(value) || (typeof value === 'string');
expect(isArrayOrString).toBe(true);
});
});
it('should provide a list of eagerly-loaded embedded components', () => {
const eagerSelector = Object.keys(componentsMap).find(selector => Array.isArray(componentsMap[selector]));
const selectorCount = eagerSelector.split(',').length;
expect(eagerSelector).not.toBeNull();
expect(selectorCount).toBe(componentsMap[eagerSelector].length);
// For example...
expect(eagerSelector).toContain('aio-toc');
});
it('should provide a list of lazy-loaded embedded components', () => {
const lazySelector = Object.keys(componentsMap).find(selector => selector.includes('code-example'));
const selectorCount = lazySelector.split(',').length;
expect(lazySelector).not.toBeNull();
expect(selectorCount).toBe(embeddedComponents.length);
// For example...
expect(lazySelector).toContain('code-example');
expect(lazySelector).toContain('code-tabs');
expect(lazySelector).toContain('live-example');
});
});

View File

@ -5,36 +5,26 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { Location, LocationStrategy, PathLocationStrategy } from '@angular/common';
import {
MatButtonModule,
MatIconModule,
MatIconRegistry,
MatInputModule,
MatProgressBarModule,
MatSidenavModule,
MatTabsModule,
MatToolbarModule
} from '@angular/material';
import {
Platform
} from '@angular/cdk/platform';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule, MatIconRegistry } from '@angular/material/icon';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatToolbarModule } from '@angular/material/toolbar';
import { ROUTES } from '@angular/router';
// Temporary fix for MatSidenavModule issue:
// crashes with "missing first" operator when SideNav.mode is "over"
import 'rxjs/add/operator/first';
import { SwUpdatesModule } from 'app/sw-updates/sw-updates.module';
import { AppComponent } from 'app/app.component';
import { ApiService } from 'app/embedded/api/api.service';
import { EMBEDDED_COMPONENTS, EmbeddedComponentsMap } from 'app/embed-components/embed-components.service';
import { CustomIconRegistry, SVG_ICONS } from 'app/shared/custom-icon-registry';
import { Deployment } from 'app/shared/deployment.service';
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { DtComponent } from 'app/layout/doc-viewer/dt.component';
import { ModeBannerComponent } from 'app/layout/mode-banner/mode-banner.component';
import { EmbeddedModule } from 'app/embedded/embedded.module';
import { GaService } from 'app/shared/ga.service';
import { Logger } from 'app/shared/logger.service';
import { LocationService } from 'app/shared/location.service';
@ -47,11 +37,18 @@ import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
import { NavItemComponent } from 'app/layout/nav-item/nav-item.component';
import { ScrollService } from 'app/shared/scroll.service';
import { ScrollSpyService } from 'app/shared/scroll-spy.service';
import { SearchBoxComponent } from './search/search-box/search-box.component';
import { SearchBoxComponent } from 'app/search/search-box/search-box.component';
import { TocComponent } from 'app/layout/toc/toc.component';
import { TocService } from 'app/shared/toc.service';
import { WindowToken, windowProvider } from 'app/shared/window';
import { EmbedComponentsModule } from 'app/embed-components/embed-components.module';
import { SharedModule } from 'app/shared/shared.module';
import { SwUpdatesModule } from 'app/sw-updates/sw-updates.module';
// The path to the `EmbeddedModule`.
const embeddedModulePath = 'app/embedded/embedded.module#EmbeddedModule';
// These are the hardcoded inline svg sources to be used by the `<mat-icon>` component
export const svgIconProviders = [
@ -78,15 +75,13 @@ export const svgIconProviders = [
@NgModule({
imports: [
BrowserModule,
EmbeddedModule,
HttpClientModule,
BrowserAnimationsModule,
EmbedComponentsModule,
HttpClientModule,
MatButtonModule,
MatIconModule,
MatInputModule,
MatProgressBarModule,
MatSidenavModule,
MatTabsModule,
MatToolbarModule,
SwUpdatesModule,
SharedModule
@ -100,10 +95,10 @@ export const svgIconProviders = [
NavMenuComponent,
NavItemComponent,
SearchBoxComponent,
TocComponent,
TopMenuComponent,
],
providers: [
ApiService,
Deployment,
DocumentService,
GaService,
@ -113,14 +108,31 @@ export const svgIconProviders = [
LocationService,
{ provide: MatIconRegistry, useClass: CustomIconRegistry },
NavigationService,
Platform,
ScrollService,
ScrollSpyService,
SearchService,
svgIconProviders,
TocService,
{ provide: WindowToken, useFactory: windowProvider },
{
provide: EMBEDDED_COMPONENTS,
useValue: {
/* tslint:disable: max-line-length */
'aio-toc': [TocComponent],
'aio-api-list, aio-contributor-list, aio-file-not-found-search, aio-resource-list, code-example, code-tabs, current-location, live-example': embeddedModulePath,
/* tslint:enable: max-line-length */
} as EmbeddedComponentsMap,
},
{
// This is currently the only way to get `@angular/cli`
// to split `EmbeddedModule` into a separate chunk :(
provide: ROUTES,
useValue: [{ path: '/embedded', loadChildren: embeddedModulePath }],
multi: true,
},
],
entryComponents: [ TocComponent ],
bootstrap: [ AppComponent ]
})
export class AppModule {

View File

@ -0,0 +1,13 @@
import { NgModule, NgModuleFactoryLoader, SystemJsNgModuleLoader } from '@angular/core';
import { EmbedComponentsService } from './embed-components.service';
@NgModule({
providers: [
EmbedComponentsService,
{ provide: NgModuleFactoryLoader, useClass: SystemJsNgModuleLoader },
],
})
export class EmbedComponentsModule {
}

View File

@ -0,0 +1,378 @@
import { ComponentFactory, ComponentFactoryResolver, ComponentRef, NgModuleFactoryLoader } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
MockNgModuleFactoryLoader, TestEmbedComponentsService, TestModule, mockEmbeddedModulePath,
testEagerEmbeddedComponents, testEagerEmbeddedSelectors, testLazyEmbeddedComponents
} from 'testing/embed-components-utils';
import { EmbedComponentsService, ComponentsOrModulePath } from './embed-components.service';
describe('EmbedComponentsService', () => {
let service: TestEmbedComponentsService;
let host: HTMLElement;
beforeEach(() => {
TestBed.configureTestingModule({imports: [TestModule]});
service = TestBed.get(EmbedComponentsService);
host = document.createElement('div');
});
it('should be instantiated', () => {
expect(service).toEqual(jasmine.any(EmbedComponentsService));
});
describe('#createComponentFactories()', () => {
let factories: typeof service.componentFactories;
let resolver: ComponentFactoryResolver;
const doCreateComponentFactories = () =>
service.createComponentFactories(testEagerEmbeddedComponents, resolver);
beforeEach(() => {
factories = service.componentFactories;
resolver = TestBed.get(ComponentFactoryResolver) as ComponentFactoryResolver;
});
it('should create a factory entry for each component', () => {
expect(factories.size).toBe(0);
doCreateComponentFactories();
expect(factories.size).toBe(testEagerEmbeddedComponents.length);
});
it('should key the factory entries by selector', () => {
doCreateComponentFactories();
const actualSelectors = Array.from(factories.keys());
const expectedSelectors = testEagerEmbeddedSelectors;
expect(actualSelectors).toEqual(expectedSelectors);
});
it('should store the projected content property name', () => {
doCreateComponentFactories();
const actualContentPropNames = Array.from(factories.values()).map(x => x.contentPropertyName);
const expectedContentPropNames = testEagerEmbeddedSelectors.map(x => service.selectorToContentPropertyName(x));
expect(actualContentPropNames).toEqual(expectedContentPropNames);
});
it('should store the factory for each component', () => {
doCreateComponentFactories();
const actualFactories = Array.from(factories.values()).map(x => x.factory);
const expectedComponentTypes = testEagerEmbeddedComponents;
actualFactories.forEach((factory, i) => {
expect(factory).toEqual(jasmine.any(ComponentFactory));
expect(factory.componentType).toBe(expectedComponentTypes[i]);
});
});
});
describe('#createComponents()', () => {
const FooComponent = testEagerEmbeddedComponents[0];
const BarComponent = testEagerEmbeddedComponents[1];
beforeEach(() => service.prepareComponentFactories(testEagerEmbeddedComponents));
it('should apply all embedded components (and return the `ComponentRef`s)', () => {
host.innerHTML = `
<p>Header</p>
<p><aio-eager-foo></aio-eager-foo></p>
<p><aio-eager-bar></aio-eager-bar></p>
<p>Footer</p>
`;
const componentRefs = service.createComponents(host);
expect(host.innerHTML).toContain('Foo Component');
expect(host.innerHTML).toContain('Bar Component');
expect(componentRefs.length).toBe(2);
expect(componentRefs[0].instance).toEqual(jasmine.any(FooComponent));
expect(componentRefs[1].instance).toEqual(jasmine.any(BarComponent));
});
it('should apply embedded components to all matching elements', () => {
host.innerHTML = `
<p>Header</p>
<p><aio-eager-foo></aio-eager-foo></p>
<p><aio-eager-bar></aio-eager-bar></p>
<p><aio-eager-foo></aio-eager-foo></p>
<p><aio-eager-bar></aio-eager-bar></p>
<p>Footer</p>
`;
const componentRefs = service.createComponents(host);
expect(componentRefs.length).toBe(4);
expect(componentRefs[0].instance).toEqual(jasmine.any(FooComponent));
expect(componentRefs[1].instance).toEqual(jasmine.any(FooComponent));
expect(componentRefs[2].instance).toEqual(jasmine.any(BarComponent));
expect(componentRefs[3].instance).toEqual(jasmine.any(BarComponent));
});
it('should allow projecting content by assigning it on the element', () => {
const projectedContent = 'Projected content';
host.innerHTML = `
<p>Header</p>
<p><aio-eager-bar>${projectedContent}</aio-eager-bar></p>
<p>Footer</p>
`;
const componentRefs = service.createComponents(host);
componentRefs[0].changeDetectorRef.detectChanges();
const barEl = host.querySelector('aio-eager-bar');
expect(barEl['aioEagerBarContent']).toBe(projectedContent);
expect(barEl.innerHTML).toContain(projectedContent);
});
// Because `FooComponent` is processed before `BarComponent`...
it('should apply `FooComponent` within `BarComponent`', () => {
host.innerHTML = `
<aio-eager-bar>
<aio-eager-foo></aio-eager-foo>
</aio-eager-bar>
`;
const componentRefs = service.createComponents(host);
componentRefs.forEach(ref => ref.changeDetectorRef.detectChanges());
expect(host.innerHTML).toContain('Foo Component');
expect(host.innerHTML).toContain('Bar Component');
expect(componentRefs.length).toBe(2);
expect(componentRefs[0].instance).toEqual(jasmine.any(FooComponent));
expect(componentRefs[1].instance).toEqual(jasmine.any(BarComponent));
});
// Because `BarComponent` is processed after `FooComponent`...
it('should not apply `BarComponent` within `FooComponent`', () => {
host.innerHTML = `
<aio-eager-foo>
<aio-eager-bar></aio-eager-bar>
</aio-eager-foo>
`;
const componentRefs = service.createComponents(host);
componentRefs.forEach(ref => ref.changeDetectorRef.detectChanges());
expect(host.innerHTML).toContain('Foo Component');
expect(host.innerHTML).not.toContain('Bar Component');
expect(componentRefs.length).toBe(1);
expect(componentRefs[0].instance).toEqual(jasmine.any(FooComponent));
});
});
describe('#embedInto()', () => {
let mockComponentRefs: ComponentRef<any>[];
let createComponentsSpy: jasmine.Spy;
let prepareComponentFactoriesSpy: jasmine.Spy;
const doEmbed = (contents: string) =>
new Promise<ComponentRef<any>[]>((resolve, reject) => {
host.innerHTML = contents;
service.embedInto(host).subscribe(resolve, reject);
});
beforeEach(() => {
mockComponentRefs = [{foo: true}, {bar: true}] as any as ComponentRef<any>[];
createComponentsSpy = spyOn(service, 'createComponents').and.returnValue(mockComponentRefs);
prepareComponentFactoriesSpy = spyOn(service, 'prepareComponentFactories')
.and.returnValue(Promise.resolve());
});
it('should return an observable', done => {
service.embedInto(host).subscribe(done, done.fail);
});
describe('(preparing component factories)', () => {
it('should return an array of `ComponentRef`s', async () => {
// When there are embedded components.
expect(await doEmbed('<aio-eager-foo></aio-eager-foo>')).toEqual(mockComponentRefs);
expect(await doEmbed('<aio-lazy-bar></aio-lazy-bar>')).toEqual(mockComponentRefs);
// When there are no embedded components.
expect(await doEmbed('<div>Test</div>')).toEqual([]);
expect(await doEmbed('')).toEqual([]);
});
it('should prepare all component factories if there are embedded components', async () => {
await doEmbed(`
<div><aio-eager-foo><b>foo</b></aio-eager-foo></div>
<span><aio-lazy-foo><i>bar</i></aio-lazy-foo></span>
`);
expect(prepareComponentFactoriesSpy).toHaveBeenCalledTimes(2);
expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(testEagerEmbeddedComponents);
expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(mockEmbeddedModulePath);
});
it('should only prepare the necessary factories', async () => {
await doEmbed('<aio-eager-foo>Eager only</aio-eager-foo>');
expect(prepareComponentFactoriesSpy).toHaveBeenCalledTimes(1);
expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(testEagerEmbeddedComponents);
await doEmbed('<aio-lazy-foo>Lazy only</aio-lazy-foo>');
expect(prepareComponentFactoriesSpy).toHaveBeenCalledTimes(2);
expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(mockEmbeddedModulePath);
});
it('should not load embedded components if the document does not contain any', async () => {
await doEmbed('');
await doEmbed('<no-aio-eager-foo></no-aio-eager-foo>');
await doEmbed('<no-aio-lazy-foo></no-aio-lazy-foo>');
expect(prepareComponentFactoriesSpy).not.toHaveBeenCalled();
});
});
describe('(creating embedded components)', () => {
it('should create embedded components if the element contains any', async () => {
await doEmbed('<div><aio-eager-foo><i>blah</i></aio-eager-foo></div>');
expect(createComponentsSpy).toHaveBeenCalledTimes(1);
expect(prepareComponentFactoriesSpy).toHaveBeenCalledBefore(createComponentsSpy);
prepareComponentFactoriesSpy.calls.reset();
createComponentsSpy.calls.reset();
await doEmbed('<aio-lazy-bar><i>blah</i></aio-lazy-bar>');
expect(createComponentsSpy).toHaveBeenCalledTimes(1);
expect(prepareComponentFactoriesSpy).toHaveBeenCalledBefore(createComponentsSpy);
});
it('should emit the created embedded components', async () => {
const componentRefs = await doEmbed('<aio-eager-foo></aio-eager-foo>');
expect(componentRefs).toBe(mockComponentRefs);
});
it('should not create embedded components if the element does not contain any', async () => {
await doEmbed(`
<aio-eager-foo-not></aio-eager-foo-not>
&lt;aio-lazy-bar&gt;&lt;/aio-lazy-bar&gt;
`);
expect(createComponentsSpy).not.toHaveBeenCalled();
});
it('should not create embedded components if the document is empty', async () => {
await doEmbed('');
expect(createComponentsSpy).not.toHaveBeenCalled();
});
it('should not create embedded components if unsubscribed from', async () => {
const preparePromise = Promise.resolve();
prepareComponentFactoriesSpy.and.returnValue(preparePromise);
// When not unsubscribed from...
host.innerHTML = '<aio-eager-foo></aio-eager-foo>';
service.embedInto(host).subscribe();
await new Promise(resolve => setTimeout(resolve));
expect(createComponentsSpy).toHaveBeenCalledTimes(1);
createComponentsSpy.calls.reset();
// When unsubscribed from...
host.innerHTML = '<aio-eager-foo></aio-eager-foo>';
service.embedInto(host).subscribe().unsubscribe();
await new Promise(resolve => setTimeout(resolve));
expect(createComponentsSpy).not.toHaveBeenCalled();
});
});
});
describe('#prepareComponentFactories()', () => {
let loader: MockNgModuleFactoryLoader;
let resolver: ComponentFactoryResolver;
let createComponentFactoriesSpy: jasmine.Spy;
beforeEach(() => {
loader = TestBed.get(NgModuleFactoryLoader);
resolver = TestBed.get(ComponentFactoryResolver);
createComponentFactoriesSpy = spyOn(service, 'createComponentFactories');
});
[testLazyEmbeddedComponents, mockEmbeddedModulePath].forEach((compsOrPath: ComponentsOrModulePath) => {
const useComponents = Array.isArray(compsOrPath);
describe(`(using ${useComponents ? 'component types' : 'module path'})`, () => {
const doPrepareComponentFactories = () =>
service.prepareComponentFactories(compsOrPath);
it('should return a promise', done => {
doPrepareComponentFactories().then(done, done.fail);
});
it('should create the component factories', async () => {
expect(createComponentFactoriesSpy).not.toHaveBeenCalled();
await doPrepareComponentFactories();
expect(createComponentFactoriesSpy).toHaveBeenCalledTimes(1);
const args = createComponentFactoriesSpy.calls.mostRecent().args;
expect(args[0]).toBe(testLazyEmbeddedComponents);
if (useComponents) {
expect(args[1]).toBe(resolver);
} else {
expect(args[1]).not.toBe(resolver);
}
});
it('should not create create the component factories more than once', async () => {
const results = await Promise.all([
doPrepareComponentFactories(),
doPrepareComponentFactories(),
]);
expect(createComponentFactoriesSpy).toHaveBeenCalledTimes(1);
expect(results[1]).toBe(results[0]);
const anotherResult = await doPrepareComponentFactories();
expect(createComponentFactoriesSpy).toHaveBeenCalledTimes(1);
expect(anotherResult).toBe(results[0]);
});
it(`should ${useComponents ? 'not load' : 'load'} the embedded module`, async () => {
expect(loader.loadedPaths).toEqual([]);
await doPrepareComponentFactories();
const expectedLoadedPaths = useComponents ? [] : [mockEmbeddedModulePath];
expect(loader.loadedPaths).toEqual(expectedLoadedPaths);
});
it(`should not load the embedded module more than once`, async () => {
await Promise.all([
doPrepareComponentFactories(),
doPrepareComponentFactories(),
]);
const loadedPathCount = loader.loadedPaths.length;
expect(loadedPathCount).toBeLessThan(2);
await doPrepareComponentFactories();
expect(loader.loadedPaths.length).toBe(loadedPathCount);
});
});
});
});
describe('#selectorToContentPropertyName()', () => {
it('should convert an element selector to a property name', () => {
expect(service.selectorToContentPropertyName('foobar')).toBe('foobarContent');
expect(service.selectorToContentPropertyName('baz-qux')).toBe('bazQuxContent');
});
});
});

View File

@ -0,0 +1,154 @@
import {
ComponentFactory, ComponentFactoryResolver, ComponentRef, Inject, Injectable, InjectionToken,
Injector, NgModuleFactory, NgModuleFactoryLoader, Type
} from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { of } from 'rxjs/observable/of';
import 'rxjs/add/operator/switchMap';
export interface EmbeddedComponentFactory {
contentPropertyName: string;
factory: ComponentFactory<any>;
}
/**
* A mapping from combined component selectors (keys) to the corresponding components (values). The
* components can be specified either as a list of embedded components or a path to a module that
* provides embedded components (i.e. implements `WithEmbeddedComponents`).
*/
export interface EmbeddedComponentsMap {
[multiSelector: string]: ComponentsOrModulePath;
}
/**
* Interface expected to be implemented by all modules that contribute components to the
* `EmbeddedComponentsMap`.
*/
export interface WithEmbeddedComponents {
embeddedComponents: Type<any>[];
}
/**
* Either an array of components or the path to a module that implements `WithEmbeddedComponents`.
*/
export type ComponentsOrModulePath = Type<any>[] | string;
/**
* The injection token for the `EmbeddedComponentsMap`.
*/
export const EMBEDDED_COMPONENTS = new InjectionToken<EmbeddedComponentsMap>('EMBEDDED_COMPONENTS');
/**
* Embed components into an element. It takes care of indentifying the embedded components, loading
* the necessary modules and instantiating the components.
*
* Embeddable components are identified and loaded based on the info in `EmbeddedComponentsMap`
* (provided through dependency injection).
*
* The caller is responsible for trigering change detection and destroying the components as
* necessary.
*/
@Injectable()
export class EmbedComponentsService {
private componentFactoriesReady = new Map<ComponentsOrModulePath, Promise<void>>();
protected componentFactories = new Map<string, EmbeddedComponentFactory>();
constructor(
private injector: Injector,
private loader: NgModuleFactoryLoader,
private resolver: ComponentFactoryResolver,
@Inject(EMBEDDED_COMPONENTS) private embeddedComponentsMap: EmbeddedComponentsMap) { }
/**
* Embed components into the specified element:
* - Load the necessary modules (if any).
* - Prepare the component factories.
* - Instantiate the components.
*
* Return the list of `ComponentRef`s.
*/
embedInto(elem: HTMLElement): Observable<ComponentRef<any>[]> {
const requiredComponents = Object.keys(this.embeddedComponentsMap)
.filter(selector => elem.querySelector(selector))
.map(selector => this.embeddedComponentsMap[selector]);
const factoriesReady = requiredComponents.map(compsOrPath => this.prepareComponentFactories(compsOrPath));
return !requiredComponents.length
? of([])
: of(undefined)
.switchMap(() => Promise.all(factoriesReady))
.switchMap(() => [this.createComponents(elem)]);
}
/**
* Resolve the embedded component factories (which will later be used to instantiate components).
*/
protected createComponentFactories(components: Type<any>[], resolver: ComponentFactoryResolver): void {
for (const comp of components) {
const factory = resolver.resolveComponentFactory(comp);
const selector = factory.selector;
const contentPropertyName = this.selectorToContentPropertyName(selector);
this.componentFactories.set(selector, {contentPropertyName, factory});
}
}
/**
* Instantiate embedded components for the current contents of `elem`.
* (Store the original HTML contents of each element on the corresponding property for later
* retrieval by the component instance.)
*/
protected createComponents(elem: HTMLElement): ComponentRef<any>[] {
const componentRefs: ComponentRef<any>[] = [];
this.componentFactories.forEach(({contentPropertyName, factory}, selector) => {
const componentHosts = elem.querySelectorAll(selector);
// Cast due to https://github.com/Microsoft/TypeScript/issues/4947.
for (const host of componentHosts as any as HTMLElement[]) {
// Hack: Preserve the current element content, because the factory will empty it out.
// Security: The source of this `innerHTML` should always be authored by the documentation
// team and is considered to be safe.
host[contentPropertyName] = host.innerHTML;
componentRefs.push(factory.create(this.injector, [], host));
}
});
return componentRefs;
}
/**
* Prepare the component factories for the given components.
* If necessary, load and instantiate the module first.
*/
protected prepareComponentFactories(compsOrPath: ComponentsOrModulePath): Promise<void> {
if (!this.componentFactoriesReady.has(compsOrPath)) {
const componentsAndResolverPromise = (typeof compsOrPath !== 'string')
? Promise.resolve({components: compsOrPath, resolver: this.resolver})
: this.loader.load(compsOrPath).then((ngModuleFactory: NgModuleFactory<WithEmbeddedComponents>) => {
const moduleRef = ngModuleFactory.create(this.injector);
return {
components: moduleRef.instance.embeddedComponents,
resolver: moduleRef.componentFactoryResolver,
};
});
const readyPromise = componentsAndResolverPromise
.then(({components, resolver}) => this.createComponentFactories(components, resolver));
this.componentFactoriesReady.set(compsOrPath, readyPromise);
}
return this.componentFactoriesReady.get(compsOrPath);
}
/**
* Compute the component content property name by converting the selector to camelCase and
* appending `Content`, e.g. `live-example` => `liveExampleContent`.
*/
protected selectorToContentPropertyName(selector: string): string {
return selector.replace(/-(.)/g, (match, $1) => $1.toUpperCase()) + 'Content';
}
}

View File

@ -2,7 +2,7 @@ import { Component, ElementRef, ViewChild, OnChanges, Input } from '@angular/cor
import { Logger } from 'app/shared/logger.service';
import { PrettyPrinter } from './pretty-printer.service';
import { CopierService } from 'app/shared/copier.service';
import { MatSnackBar } from '@angular/material';
import { MatSnackBar } from '@angular/material/snack-bar';
const defaultLineNumsCount = 10; // by default, show linenums over this number

View File

@ -1,20 +1,24 @@
import { NgModule } from '@angular/core';
import { NgModule, Type } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ContributorService } from './contributor/contributor.service';
import { CopierService } from 'app/shared/copier.service';
import { PrettyPrinter } from './code/pretty-printer.service';
import { WithEmbeddedComponents } from 'app/embed-components/embed-components.service';
// Any components that we want to use inside embedded components must be declared or imported here
// It is not enough just to import them inside the AppModule
// Reusable components (used inside embedded components)
import { MatIconModule, MatSnackBarModule, MatTabsModule } from '@angular/material';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatTabsModule } from '@angular/material/tabs';
import { CodeComponent } from './code/code.component';
import { SharedModule } from 'app/shared/shared.module';
// Embedded Components
import { ApiListComponent } from './api/api-list.component';
import { ApiService } from './api/api.service';
import { CodeExampleComponent } from './code/code-example.component';
import { CodeTabsComponent } from './code/code-tabs.component';
import { ContributorListComponent } from './contributor/contributor-list.component';
@ -24,22 +28,16 @@ import { FileNotFoundSearchComponent } from './search/file-not-found-search.comp
import { LiveExampleComponent, EmbeddedPlunkerComponent } from './live-example/live-example.component';
import { ResourceListComponent } from './resource/resource-list.component';
import { ResourceService } from './resource/resource.service';
import { TocComponent } from './toc/toc.component';
/** Components that can be embedded in docs
/**
* Components that can be embedded in docs,
* such as CodeExampleComponent, LiveExampleComponent,...
*/
export const embeddedComponents: any[] = [
export const embeddedComponents: Type<any>[] = [
ApiListComponent, CodeExampleComponent, CodeTabsComponent, ContributorListComponent,
CurrentLocationComponent, FileNotFoundSearchComponent, LiveExampleComponent, ResourceListComponent,
TocComponent
CurrentLocationComponent, FileNotFoundSearchComponent, LiveExampleComponent, ResourceListComponent
];
/** Injectable class w/ property returning components that can be embedded in docs */
export class EmbeddedComponents {
components = embeddedComponents;
}
@NgModule({
imports: [
CommonModule,
@ -54,16 +52,15 @@ export class EmbeddedComponents {
ContributorComponent,
EmbeddedPlunkerComponent
],
exports: [
TocComponent
],
providers: [
ApiService,
ContributorService,
CopierService,
EmbeddedComponents,
PrettyPrinter,
ResourceService
],
entryComponents: [ embeddedComponents ]
})
export class EmbeddedModule { }
export class EmbeddedModule implements WithEmbeddedComponents {
embeddedComponents = embeddedComponents;
}

View File

@ -1,348 +1,230 @@
import { ComponentRef } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, DebugElement, ElementRef, NgModule, OnInit, ViewChild } from '@angular/core';
import { DocViewerComponent } from './doc-viewer.component';
import { DocumentContents } from 'app/documents/document.service';
import { EmbeddedModule, EmbeddedComponents } from 'app/embedded/embedded.module';
import { Title } from '@angular/platform-browser';
import { Observable } from 'rxjs/Observable';
import { of } from 'rxjs/observable/of';
import { EmbedComponentsService } from 'app/embed-components/embed-components.service';
import { Logger } from 'app/shared/logger.service';
import { TocService } from 'app/shared/toc.service';
import {
MockEmbedComponentsService, MockTitle, MockTocService, ObservableWithSubscriptionSpies,
TestDocViewerComponent, TestModule, TestParentComponent
} from 'testing/doc-viewer-utils';
import { MockLogger } from 'testing/logger.service';
import { DocViewerComponent } from './doc-viewer.component';
/// Embedded Test Components ///
///// FooComponent /////
@Component({
selector: 'aio-foo',
template: `Foo Component`
})
class FooComponent { }
///// BarComponent /////
@Component({
selector: 'aio-bar',
template: `
<hr>
<h2>Bar Component</h2>
<p #barContent></p>
<hr>
`
})
class BarComponent implements OnInit {
@ViewChild('barContent') barContentRef: ElementRef;
constructor(public elementRef: ElementRef) { }
// Project content in ngOnInit just like CodeExampleComponent
ngOnInit() {
// Security: this is a test component; never deployed
this.barContentRef.nativeElement.innerHTML = this.elementRef.nativeElement.aioBarContent;
}
}
///// BazComponent /////
@Component({
selector: 'aio-baz',
template: `
<div>++++++++++++++</div>
<h2>Baz Component</h2>
<p #bazContent></p>
<div>++++++++++++++</div>
`
})
class BazComponent implements OnInit {
@ViewChild('bazContent') bazContentRef: ElementRef;
constructor(public elementRef: ElementRef) { }
// Project content in ngOnInit just like CodeExampleComponent
ngOnInit() {
// Security: this is a test component; never deployed
this.bazContentRef.nativeElement.innerHTML = this.elementRef.nativeElement.aioBazContent;
}
}
///// Test Module //////
const embeddedTestComponents = [FooComponent, BarComponent, BazComponent];
@NgModule({
imports: [ EmbeddedModule ],
entryComponents: embeddedTestComponents
})
class TestModule { }
//// Test Component //////
@Component({
selector: 'aio-test',
template: `
<aio-doc-viewer [doc]="currentDoc">Test Component</aio-doc-viewer>
`
})
class TestComponent {
currentDoc: DocumentContents;
@ViewChild(DocViewerComponent) docViewer: DocViewerComponent;
}
//// Test Services ////
class TestTitleService {
setTitle = jasmine.createSpy('reset');
}
class TestTocService {
reset = jasmine.createSpy('reset');
genToc = jasmine.createSpy('genToc');
}
//////// Tests //////////////
describe('DocViewerComponent', () => {
let component: TestComponent;
let docViewerDE: DebugElement;
let parentFixture: ComponentFixture<TestParentComponent>;
let parentComponent: TestParentComponent;
let docViewerEl: HTMLElement;
let fixture: ComponentFixture<TestComponent>;
function setCurrentDoc(contents = '', id = 'fizz/buzz') {
component.currentDoc = { contents, id };
}
let docViewer: TestDocViewerComponent;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ TestModule ],
declarations: [
TestComponent,
DocViewerComponent,
embeddedTestComponents
],
providers: [
{ provide: EmbeddedComponents, useValue: {components: embeddedTestComponents} },
{ provide: Title, useClass: TestTitleService },
{ provide: TocService, useClass: TestTocService }
]
imports: [TestModule]
});
parentFixture = TestBed.createComponent(TestParentComponent);
parentComponent = parentFixture.componentInstance;
parentFixture.detectChanges();
docViewerEl = parentFixture.debugElement.children[0].nativeElement;
docViewer = parentComponent.docViewer as any;
});
it('should create a `DocViewer`', () => {
expect(docViewer).toEqual(jasmine.any(DocViewerComponent));
});
describe('#doc / #docRendered', () => {
let destroyEmbeddedComponentsSpy: jasmine.Spy;
let renderSpy: jasmine.Spy;
const setCurrentDoc = (contents, id = 'fizz/buzz') => {
parentComponent.currentDoc = {contents, id};
parentFixture.detectChanges();
};
beforeEach(() => {
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
fixture.detectChanges();
docViewerDE = fixture.debugElement.children[0];
docViewerEl = docViewerDE.nativeElement;
destroyEmbeddedComponentsSpy = spyOn(docViewer, 'destroyEmbeddedComponents');
renderSpy = spyOn(docViewer, 'render').and.returnValue([null]);
});
it('should create a DocViewer', () => {
expect(component.docViewer).toBeTruthy();
it('should render the new document', () => {
setCurrentDoc('foo', 'bar');
expect(renderSpy).toHaveBeenCalledTimes(1);
expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'bar', contents: 'foo'}]);
setCurrentDoc(null, 'baz');
expect(renderSpy).toHaveBeenCalledTimes(2);
expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'baz', contents: null}]);
});
it(('should display nothing when set currentDoc has no content'), () => {
setCurrentDoc();
fixture.detectChanges();
expect(docViewerEl.innerHTML).toBe('');
it('should destroy the currently active components (before rendering the new document)', () => {
setCurrentDoc('foo');
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1);
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledBefore(renderSpy);
destroyEmbeddedComponentsSpy.calls.reset();
renderSpy.calls.reset();
setCurrentDoc(null);
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1);
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledBefore(renderSpy);
});
it(('should display simple static content doc'), () => {
const contents = '<p>Howdy, doc viewer</p>';
setCurrentDoc(contents);
fixture.detectChanges();
expect(docViewerEl.innerHTML).toEqual(contents);
it('should emit `docRendered` after the new document has been rendered', done => {
let completeRender: () => void;
renderSpy.and.returnValue(new Promise(resolve => completeRender = resolve));
docViewer.docRendered.subscribe(done);
setCurrentDoc('foo');
expect(renderSpy).toHaveBeenCalledTimes(1);
completeRender();
});
it(('should display nothing after reset static content doc'), () => {
const contents = '<p>Howdy, doc viewer</p>';
setCurrentDoc(contents);
fixture.detectChanges();
component.currentDoc = { contents: '', id: 'a/c' };
fixture.detectChanges();
expect(docViewerEl.innerHTML).toEqual('');
it('should unsubscribe from the previous "render" observable upon new document', () => {
const obs = new ObservableWithSubscriptionSpies();
renderSpy.and.returnValue(obs);
setCurrentDoc('foo', 'bar');
expect(obs.subscribeSpy).toHaveBeenCalledTimes(1);
expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled();
setCurrentDoc('baz', 'qux');
expect(obs.subscribeSpy).toHaveBeenCalledTimes(2);
expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1);
});
it(('should apply FooComponent'), () => {
const contents = `
<p>Above Foo</p>
<p><aio-foo></aio-foo></p>
<p>Below Foo</p>
`;
setCurrentDoc(contents);
fixture.detectChanges();
const fooHtml = docViewerEl.querySelector('aio-foo').innerHTML;
expect(fooHtml).toContain('Foo Component');
it('should ignore falsy document values', () => {
const onDocRenderedSpy = jasmine.createSpy('onDocRendered');
docViewer.docRendered.subscribe(onDocRenderedSpy);
parentComponent.currentDoc = null;
parentFixture.detectChanges();
expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled();
expect(renderSpy).not.toHaveBeenCalled();
expect(onDocRenderedSpy).not.toHaveBeenCalled();
parentComponent.currentDoc = undefined;
parentFixture.detectChanges();
expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled();
expect(renderSpy).not.toHaveBeenCalled();
expect(onDocRenderedSpy).not.toHaveBeenCalled();
});
});
it(('should apply multiple FooComponents'), () => {
const contents = `
<p>Above Foo</p>
<p><aio-foo></aio-foo></p>
<div style="margin-left: 2em;">
Holds a
<aio-foo>Ignored text</aio-foo>
</div>
<p>Below Foo</p>
`;
setCurrentDoc(contents);
fixture.detectChanges();
const foos = docViewerEl.querySelectorAll('aio-foo');
expect(foos.length).toBe(2);
});
it(('should apply BarComponent'), () => {
const contents = `
<p>Above Bar</p>
<aio-bar></aio-bar>
<p>Below Bar</p>
`;
setCurrentDoc(contents);
fixture.detectChanges();
const barHtml = docViewerEl.querySelector('aio-bar').innerHTML;
expect(barHtml).toContain('Bar Component');
});
it(('should project bar content into BarComponent'), () => {
const contents = `
<p>Above Bar</p>
<aio-bar>###bar content###</aio-bar>
<p>Below Bar</p>
`;
setCurrentDoc(contents);
// necessary to trigger projection within ngOnInit
fixture.detectChanges();
const barHtml = docViewerEl.querySelector('aio-bar').innerHTML;
expect(barHtml).toContain('###bar content###');
});
it(('should include Foo and Bar'), () => {
const contents = `
<p>Top</p>
<p><aio-foo>ignored</aio-foo></p>
<aio-bar>###bar content###</aio-bar>
<p><aio-foo></aio-foo></p>
<p>Bottom</p>
`;
setCurrentDoc(contents);
// necessary to trigger Bar's projection within ngOnInit
fixture.detectChanges();
const foos = docViewerEl.querySelectorAll('aio-foo');
expect(foos.length).toBe(2, 'should have 2 foos');
const barHtml = docViewerEl.querySelector('aio-bar').innerHTML;
expect(barHtml).toContain('###bar content###', 'should have bar with projected content');
});
it(('should not include Bar within Foo'), () => {
const contents = `
<p>Top</p>
<div>
<aio-foo>
<aio-bar>###bar content###</aio-bar>
</aio-foo>
</div>
<p><aio-foo></aio-foo><p>
<p>Bottom</p>
`;
setCurrentDoc(contents);
// necessary to trigger Bar's projection within ngOnInit
fixture.detectChanges();
const foos = docViewerEl.querySelectorAll('aio-foo');
expect(foos.length).toBe(2, 'should have 2 foos');
const bars = docViewerEl.querySelectorAll('aio-bar');
expect(bars.length).toBe(0, 'did not expect Bar inside Foo');
});
// because FooComponents are processed before BazComponents
it(('should include Foo within Bar'), () => {
const contents = `
<p>Top</p>
<aio-bar>
<div style="margin-left: 2em">
Inner <aio-foo></aio-foo>
</div>
</aio-bar>
<p><aio-foo></aio-foo></p>
<p>Bottom</p>
`;
setCurrentDoc(contents);
// necessary to trigger Bar's projection within ngOnInit
fixture.detectChanges();
const foos = docViewerEl.querySelectorAll('aio-foo');
expect(foos.length).toBe(2, 'should have 2 foos');
const bars = docViewerEl.querySelectorAll('aio-bar');
expect(bars.length).toBe(1, 'should have a bar');
expect(bars[0].innerHTML).toContain('Bar Component', 'should have bar template content');
});
// The <aio-baz> tag and its inner content is copied
// But the BazComponent is not created and therefore its template content is not displayed
// because BarComponents are processed before BazComponents
// and no chance for first Baz inside Bar to be processed by builder.
it(('should NOT include Bar within Baz'), () => {
const contents = `
<p>Top</p>
<aio-bar>
<div style="margin-left: 2em">
Inner <aio-baz>---baz stuff---</aio-baz>
</div>
</aio-bar>
<p><aio-baz>---More baz--</aio-baz></p>
<p>Bottom</p>
`;
setCurrentDoc(contents);
// necessary to trigger Bar's projection within ngOnInit
fixture.detectChanges();
const bazs = docViewerEl.querySelectorAll('aio-baz');
// Both baz tags are there ...
expect(bazs.length).toBe(2, 'should have 2 bazs');
expect(bazs[0].innerHTML).not.toContain('Baz Component',
'did not expect 1st Baz template content');
expect(bazs[1].innerHTML).toContain('Baz Component',
'expected 2nd Baz template content');
});
describe('Title', () => {
let titleService: TestTitleService;
describe('#ngDoCheck()', () => {
let componentInstances: ComponentRef<any>[];
beforeEach(() => {
titleService = TestBed.get(Title);
componentInstances = [
{changeDetectorRef: {detectChanges: jasmine.createSpy('detectChanges')}},
{changeDetectorRef: {detectChanges: jasmine.createSpy('detectChanges')}},
{changeDetectorRef: {detectChanges: jasmine.createSpy('detectChanges')}},
] as any;
docViewer.embeddedComponentRefs.push(...componentInstances);
});
it('should set the default empty title when no <h1>', () => {
setCurrentDoc('Some content');
fixture.detectChanges();
afterEach(() => {
// Clean up the fake component instances, to avoid error in `ngOnDestroy()`.
docViewer.embeddedComponentRefs = [];
});
it('should detect changes on each active component instance', () => {
parentFixture.detectChanges();
componentInstances.forEach(({changeDetectorRef: cd}) => {
expect(cd.detectChanges).toHaveBeenCalledTimes(1);
});
parentFixture.detectChanges();
componentInstances.forEach(({changeDetectorRef: cd}) => {
expect(cd.detectChanges).toHaveBeenCalledTimes(2);
});
});
});
describe('#ngOnDestroy()', () => {
it('should destroy the active embedded component instances', () => {
const destroyEmbeddedComponentsSpy = spyOn(docViewer, 'destroyEmbeddedComponents');
docViewer.ngOnDestroy();
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1);
});
it('should stop responding to document changes', () => {
const destroyEmbeddedComponentsSpy = spyOn(docViewer, 'destroyEmbeddedComponents');
const renderSpy = spyOn(docViewer, 'render').and.returnValue([undefined]);
const onDocRenderedSpy = jasmine.createSpy('onDocRendered');
docViewer.docRendered.subscribe(onDocRenderedSpy);
expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled();
expect(renderSpy).not.toHaveBeenCalled();
expect(onDocRenderedSpy).not.toHaveBeenCalled();
docViewer.doc = {contents: 'Some content', id: 'some-id'};
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1);
expect(renderSpy).toHaveBeenCalledTimes(1);
expect(onDocRenderedSpy).toHaveBeenCalledTimes(1);
docViewer.ngOnDestroy(); // Also calls `destroyEmbeddedComponents()`.
docViewer.doc = {contents: 'Other content', id: 'other-id'};
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(2);
expect(renderSpy).toHaveBeenCalledTimes(1);
expect(onDocRenderedSpy).toHaveBeenCalledTimes(1);
docViewer.doc = {contents: 'More content', id: 'more-id'};
expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(2);
expect(renderSpy).toHaveBeenCalledTimes(1);
expect(onDocRenderedSpy).toHaveBeenCalledTimes(1);
});
});
describe('#addTitleAndToc()', () => {
const EMPTY_DOC = '';
const DOC_WITHOUT_H1 = 'Some content';
const DOC_WITH_H1 = '<h1>Features</h1>Some content';
const DOC_WITH_NO_TOC_H1 = '<h1 class="no-toc">Features</h1>Some content';
const DOC_WITH_HIDDEN_H1_CONTENT = '<h1><i style="visibility: hidden">link</i>Features</h1>Some content';
const tryDoc = (contents: string, docId = '') => {
docViewerEl.innerHTML = contents;
docViewer.addTitleAndToc(docId);
};
describe('(title)', () => {
let titleService: MockTitle;
beforeEach(() => titleService = TestBed.get(Title));
it('should set the title if there is an `<h1>` heading', () => {
tryDoc(DOC_WITH_H1);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features');
});
it('should set the title if there is a `.no-toc` `<h1>` heading', () => {
tryDoc(DOC_WITH_NO_TOC_H1);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features');
});
it('should set the default title if there is no `<h1>` heading', () => {
tryDoc(DOC_WITHOUT_H1);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular');
tryDoc(EMPTY_DOC);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular');
});
it('should set the expected title when has <h1>', () => {
setCurrentDoc('<h1>Features</h1>Some content');
fixture.detectChanges();
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features');
});
it('should set the expected title with a no-toc <h1>', () => {
setCurrentDoc('<h1 class="no-toc">Features</h1>Some content');
fixture.detectChanges();
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features');
});
it('should not include hidden content of the <h1> in the title', () => {
setCurrentDoc('<h1><i style="visibility: hidden">link</i>Features</h1>Some content');
fixture.detectChanges();
it('should not include hidden content of the `<h1>` heading in the title', () => {
tryDoc(DOC_WITH_HIDDEN_H1_CONTENT);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features');
});
@ -350,15 +232,14 @@ describe('DocViewerComponent', () => {
const querySelector_ = docViewerEl.querySelector;
spyOn(docViewerEl, 'querySelector').and.callFake((selector: string) => {
const elem = querySelector_.call(docViewerEl, selector);
Object.defineProperties(elem, {
return Object.defineProperties(elem, {
innerText: {value: undefined},
textContent: { value: 'Text Content' }
textContent: {value: 'Text Content'},
});
return elem;
});
setCurrentDoc('<h1><i style="visibility: hidden">link</i>Features</h1>Some content');
fixture.detectChanges();
tryDoc(DOC_WITH_HIDDEN_H1_CONTENT);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Text Content');
});
@ -366,71 +247,238 @@ describe('DocViewerComponent', () => {
const querySelector_ = docViewerEl.querySelector;
spyOn(docViewerEl, 'querySelector').and.callFake((selector: string) => {
const elem = querySelector_.call(docViewerEl, selector);
Object.defineProperties(elem, {
return Object.defineProperties(elem, {
innerText: { value: '' },
textContent: { value: 'Text Content' }
});
return elem;
});
setCurrentDoc('<h1><i style="visibility: hidden">link</i></h1>Some content');
fixture.detectChanges();
tryDoc(DOC_WITH_HIDDEN_H1_CONTENT);
expect(titleService.setTitle).toHaveBeenCalledWith('Angular');
});
});
describe('TOC', () => {
let tocService: TestTocService;
describe('(ToC)', () => {
let tocService: MockTocService;
function getAioToc(): HTMLElement {
return fixture.debugElement.nativeElement.querySelector('aio-toc');
}
const getTocEl = () => docViewerEl.querySelector('aio-toc');
beforeEach(() => {
tocService = TestBed.get(TocService);
beforeEach(() => tocService = TestBed.get(TocService));
it('should have an (embedded) ToC if there is an `<h1>` heading', () => {
tryDoc(DOC_WITH_H1, 'foo');
const tocEl = getTocEl()!;
expect(tocEl).toBeTruthy();
expect(tocEl.classList.contains('embedded')).toBe(true);
expect(tocService.genToc).toHaveBeenCalledTimes(1);
expect(tocService.genToc).toHaveBeenCalledWith(docViewerEl, 'foo');
});
describe('if no <h1> title', () => {
beforeEach(() => {
setCurrentDoc('Some content');
fixture.detectChanges();
it('should have no ToC if there is a `.no-toc` `<h1>` heading', () => {
tryDoc(DOC_WITH_NO_TOC_H1);
expect(getTocEl()).toBeFalsy();
expect(tocService.genToc).not.toHaveBeenCalled();
});
it('should not have an <aio-toc>', () => {
expect(getAioToc()).toBeFalsy();
it('should have no ToC if there is no `<h1>` heading', () => {
tryDoc(DOC_WITHOUT_H1);
expect(getTocEl()).toBeFalsy();
tryDoc(EMPTY_DOC);
expect(getTocEl()).toBeFalsy();
expect(tocService.genToc).not.toHaveBeenCalled();
});
it('should reset Toc Service', () => {
expect(tocService.reset).toHaveBeenCalled();
});
it('should always reset the ToC (before generating the new one)', () => {
expect(tocService.reset).not.toHaveBeenCalled();
expect(tocService.genToc).not.toHaveBeenCalled();
it('should not call Toc Service genToc()', () => {
tocService.genToc.calls.reset();
tryDoc(DOC_WITH_H1, 'foo');
expect(tocService.reset).toHaveBeenCalledTimes(1);
expect(tocService.reset).toHaveBeenCalledBefore(tocService.genToc);
expect(tocService.genToc).toHaveBeenCalledWith(docViewerEl, 'foo');
tocService.genToc.calls.reset();
tryDoc(DOC_WITH_NO_TOC_H1, 'bar');
expect(tocService.reset).toHaveBeenCalledTimes(2);
expect(tocService.genToc).not.toHaveBeenCalled();
tocService.genToc.calls.reset();
tryDoc(DOC_WITHOUT_H1, 'baz');
expect(tocService.reset).toHaveBeenCalledTimes(3);
expect(tocService.genToc).not.toHaveBeenCalled();
tocService.genToc.calls.reset();
tryDoc(EMPTY_DOC, 'qux');
expect(tocService.reset).toHaveBeenCalledTimes(4);
expect(tocService.genToc).not.toHaveBeenCalled();
});
});
it('should not have an <aio-toc> with a no-toc <h1>', () => {
setCurrentDoc('<h1 class="no-toc">Features</h1>Some content');
fixture.detectChanges();
expect(getAioToc()).toBeFalsy();
});
describe('when has an <h1> (title)', () => {
describe('#destroyEmbeddedComponents()', () => {
let componentInstances: ComponentRef<any>[];
beforeEach(() => {
setCurrentDoc('<h1>Features</h1>Some content');
fixture.detectChanges();
componentInstances = [
{destroy: jasmine.createSpy('destroy#1')},
{destroy: jasmine.createSpy('destroy#2')},
{destroy: jasmine.createSpy('destroy#3')},
] as any;
docViewer.embeddedComponentRefs.push(...componentInstances);
});
it('should add <aio-toc>', () => {
expect(getAioToc()).toBeTruthy();
it('should destroy each active component instance', () => {
docViewer.destroyEmbeddedComponents();
expect(componentInstances.length).toBe(3);
componentInstances.forEach(comp => expect(comp.destroy).toHaveBeenCalledTimes(1));
});
it('should have <aio-toc> with "embedded" class', () => {
expect(getAioToc().classList.contains('embedded')).toEqual(true);
it('should clear the list of active component instances', () => {
expect(docViewer.embeddedComponentRefs.length).toBeGreaterThan(0);
docViewer.destroyEmbeddedComponents();
expect(docViewer.embeddedComponentRefs.length).toBe(0);
});
});
it('should call Toc Service genToc()', () => {
expect(tocService.genToc).toHaveBeenCalled();
describe('#render()', () => {
let addTitleAndTocSpy: jasmine.Spy;
let embedIntoSpy: jasmine.Spy;
const doRender = (contents: string | null, id = 'foo') =>
new Promise<void>((resolve, reject) =>
docViewer.render({contents, id}).subscribe(resolve, reject));
beforeEach(() => {
const embedComponentsService = TestBed.get(EmbedComponentsService) as MockEmbedComponentsService;
addTitleAndTocSpy = spyOn(docViewer, 'addTitleAndToc');
embedIntoSpy = embedComponentsService.embedInto.and.returnValue(of([]));
});
it('should return an `Observable`', () => {
expect(docViewer.render({contents: '', id: ''})).toEqual(jasmine.any(Observable));
});
describe('(contents, title, ToC)', () => {
it('should display the document contents', async () => {
const contents = '<h1>Hello,</h1> <div>world!</div>';
await doRender(contents);
expect(docViewerEl.innerHTML).toBe(contents);
});
it('should display nothing if the document has no contents', async () => {
docViewerEl.innerHTML = 'Test';
await doRender('');
expect(docViewerEl.innerHTML).toBe('');
docViewerEl.innerHTML = 'Test';
await doRender(null);
expect(docViewerEl.innerHTML).toBe('');
});
it('should set the title and ToC (after the content has been set)', async () => {
addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.innerHTML).toBe('Foo content'));
await doRender('Foo content', 'foo');
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1);
expect(addTitleAndTocSpy).toHaveBeenCalledWith('foo');
addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.innerHTML).toBe('Bar content'));
await doRender('Bar content', 'bar');
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(2);
expect(addTitleAndTocSpy).toHaveBeenCalledWith('bar');
addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.innerHTML).toBe(''));
await doRender('', 'baz');
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(3);
expect(addTitleAndTocSpy).toHaveBeenCalledWith('baz');
addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.innerHTML).toBe('Qux content'));
await doRender('Qux content', 'qux');
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(4);
expect(addTitleAndTocSpy).toHaveBeenCalledWith('qux');
});
});
describe('(embedding components)', () => {
it('should embed components', async () => {
await doRender('Some content');
expect(embedIntoSpy).toHaveBeenCalledTimes(1);
expect(embedIntoSpy).toHaveBeenCalledWith(docViewerEl);
});
it('should attempt to embed components even if the document is empty', async () => {
await doRender('');
await doRender(null);
expect(embedIntoSpy).toHaveBeenCalledTimes(2);
expect(embedIntoSpy.calls.argsFor(0)).toEqual([docViewerEl]);
expect(embedIntoSpy.calls.argsFor(1)).toEqual([docViewerEl]);
});
it('should store the embedded components', async () => {
const embeddedComponents = [];
embedIntoSpy.and.returnValue(of(embeddedComponents));
await doRender('Some content');
expect(docViewer.embeddedComponentRefs).toBe(embeddedComponents);
});
it('should unsubscribe from the previous "embed" observable when unsubscribed from', () => {
const obs = new ObservableWithSubscriptionSpies();
embedIntoSpy.and.returnValue(obs);
const renderObservable = docViewer.render({contents: 'Some content', id: 'foo'});
const subscription = renderObservable.subscribe();
expect(obs.subscribeSpy).toHaveBeenCalledTimes(1);
expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled();
subscription.unsubscribe();
expect(obs.subscribeSpy).toHaveBeenCalledTimes(1);
expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1);
});
});
describe('(on error) should log the error and recover', () => {
let logger: MockLogger;
beforeEach(() => logger = TestBed.get(Logger));
it('when `addTitleAndToc()` fails', async () => {
const error = Error('Typical `addTitleAndToc()` error');
addTitleAndTocSpy.and.callFake(() => { throw error; });
await doRender('Some content', 'foo');
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1);
expect(embedIntoSpy).not.toHaveBeenCalled();
expect(logger.output.error).toEqual([
['[DocViewer]: Error preparing document \'foo\'.', error],
]);
});
it('when `EmbedComponentsService#embedInto()` fails', async () => {
const error = Error('Typical `embedInto()` error');
embedIntoSpy.and.callFake(() => { throw error; });
await doRender('Some content', 'bar');
expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1);
expect(embedIntoSpy).toHaveBeenCalledTimes(1);
expect(logger.output.error).toEqual([
['[DocViewer]: Error preparing document \'bar\'.', error],
]);
});
});
});

View File

@ -1,18 +1,18 @@
import {
Component, ComponentFactory, ComponentFactoryResolver, ComponentRef,
DoCheck, ElementRef, EventEmitter, Injector, Input, OnDestroy,
Output
} from '@angular/core';
import { EmbeddedComponents } from 'app/embedded/embedded.module';
import { DocumentContents } from 'app/documents/document.service';
import { Component, ComponentRef, DoCheck, ElementRef, EventEmitter, Input, OnDestroy, Output } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { Observable } from 'rxjs/Observable';
import { of } from 'rxjs/observable/of';
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/do';
import 'rxjs/add/operator/switchMap';
import 'rxjs/add/operator/takeUntil';
import { DocumentContents } from 'app/documents/document.service';
import { EmbedComponentsService } from 'app/embed-components/embed-components.service';
import { Logger } from 'app/shared/logger.service';
import { TocService } from 'app/shared/toc.service';
interface EmbeddedComponentFactory {
contentPropertyName: string;
factory: ComponentFactory<any>;
}
// Initialization prevents flicker once pre-rendering is on
const initialDocViewerElement = document.querySelector('aio-doc-viewer');
@ -26,18 +26,30 @@ const initialDocViewerContent = initialDocViewerElement ? initialDocViewerElemen
})
export class DocViewerComponent implements DoCheck, OnDestroy {
private embeddedComponents: ComponentRef<any>[] = [];
private embeddedComponentFactories: Map<string, EmbeddedComponentFactory> = new Map();
private hostElement: HTMLElement;
private void$ = of<void>(undefined);
private onDestroy$ = new EventEmitter<void>();
private docContents$ = new EventEmitter<DocumentContents>();
protected embeddedComponentRefs: ComponentRef<any>[] = [];
@Input()
set doc(newDoc: DocumentContents) {
// Ignore `undefined` values that could happen if the host component
// does not initially specify a value for the `doc` input.
if (newDoc) {
this.docContents$.emit(newDoc);
}
}
@Output()
docRendered = new EventEmitter();
docRendered = new EventEmitter<void>();
constructor(
componentFactoryResolver: ComponentFactoryResolver,
elementRef: ElementRef,
embeddedComponents: EmbeddedComponents,
private injector: Injector,
private embedComponentsService: EmbedComponentsService,
private logger: Logger,
private titleService: Title,
private tocService: TocService
) {
@ -45,52 +57,27 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
// Security: the initialDocViewerContent comes from the prerendered DOM and is considered to be secure
this.hostElement.innerHTML = initialDocViewerContent;
for (const component of embeddedComponents.components) {
const factory = componentFactoryResolver.resolveComponentFactory(component);
const selector = factory.selector;
const contentPropertyName = this.selectorToContentPropertyName(selector);
this.embeddedComponentFactories.set(selector, { contentPropertyName, factory });
}
this.onDestroy$.subscribe(() => this.destroyEmbeddedComponents());
this.docContents$
.do(() => this.destroyEmbeddedComponents())
.switchMap(newDoc => this.render(newDoc))
.do(() => this.docRendered.emit())
.takeUntil(this.onDestroy$)
.subscribe();
}
@Input()
set doc(newDoc: DocumentContents) {
this.ngOnDestroy();
if (newDoc) {
this.build(newDoc);
this.docRendered.emit();
ngDoCheck() {
this.embeddedComponentRefs.forEach(comp => comp.changeDetectorRef.detectChanges());
}
ngOnDestroy() {
this.onDestroy$.emit();
}
/**
* Add doc content to host element and build it out with embedded components
* Set up the window title and ToC.
*/
private build(doc: DocumentContents) {
// security: the doc.content is always authored by the documentation team
// and is considered to be safe
this.hostElement.innerHTML = doc.contents || '';
if (!doc.contents) { return; }
this.addTitleAndToc(doc.id);
// TODO(i): why can't I use for-of? why doesn't typescript like Map#value() iterators?
this.embeddedComponentFactories.forEach(({ contentPropertyName, factory }, selector) => {
const embeddedComponentElements = this.hostElement.querySelectorAll(selector);
// cast due to https://github.com/Microsoft/TypeScript/issues/4947
for (const element of embeddedComponentElements as any as HTMLElement[]){
// hack: preserve the current element content because the factory will empty it out
// security: the source of this innerHTML is always authored by the documentation team
// and is considered to be safe
element[contentPropertyName] = element.innerHTML;
this.embeddedComponents.push(factory.create(this.injector, [], element));
}
});
}
private addTitleAndToc(docId: string) {
protected addTitleAndToc(docId: string): void {
this.tocService.reset();
const titleEl = this.hostElement.querySelector('h1');
let title = '';
@ -108,21 +95,31 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
this.titleService.setTitle(title ? `Angular - ${title}` : 'Angular');
}
ngDoCheck() {
this.embeddedComponents.forEach(comp => comp.changeDetectorRef.detectChanges());
}
ngOnDestroy() {
// destroy these components else there will be memory leaks
this.embeddedComponents.forEach(comp => comp.destroy());
this.embeddedComponents.length = 0;
/**
* Destroy the embedded components to avoid memory leaks.
*/
protected destroyEmbeddedComponents(): void {
this.embeddedComponentRefs.forEach(comp => comp.destroy());
this.embeddedComponentRefs = [];
}
/**
* Compute the component content property name by converting the selector to camelCase and appending
* 'Content', e.g. live-example => liveExampleContent
* Add doc content to host element and build it out with embedded components.
*/
private selectorToContentPropertyName(selector: string) {
return selector.replace(/-(.)/g, (match, $1) => $1.toUpperCase()) + 'Content';
protected render(doc: DocumentContents): Observable<void> {
return this.void$
.do(() => {
// Security: `doc.contents` is always authored by the documentation team
// and is considered to be safe.
this.hostElement.innerHTML = doc.contents || '';
this.addTitleAndToc(doc.id);
})
.switchMap(() => this.embedComponentsService.embedInto(this.hostElement))
.do(componentRefs => this.embeddedComponentRefs = componentRefs)
.switchMap(() => this.void$)
.catch(err => {
this.logger.error(`[DocViewer]: Error preparing document '${doc.id}'.`, err);
return this.void$;
});
}
}

View File

@ -1,6 +1,6 @@
import { InjectionToken, Inject, Injectable } from '@angular/core';
import { of } from 'rxjs/observable/of';
import { MatIconRegistry } from '@angular/material';
import { MatIconRegistry } from '@angular/material/icon';
import { HttpClient } from '@angular/common/http';
import { DomSanitizer } from '@angular/platform-browser';

View File

@ -0,0 +1,85 @@
import { Component, ComponentRef, NgModule, ViewChild } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { Observable } from 'rxjs/Observable';
import { DocumentContents } from 'app/documents/document.service';
import { EmbedComponentsService } from 'app/embed-components/embed-components.service';
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { Logger } from 'app/shared/logger.service';
import { TocService } from 'app/shared/toc.service';
import { MockLogger } from 'testing/logger.service';
////////////////////////////////////////////////////////////////////////////////////////////////////
/// `TestDocViewerComponent` (for exposing internal `DocViewerComponent` methods as public). ///
/// Only used for type-casting; the actual implementation is irrelevant. ///
////////////////////////////////////////////////////////////////////////////////////////////////////
export class TestDocViewerComponent extends DocViewerComponent {
embeddedComponentRefs: ComponentRef<any>[];
addTitleAndToc(docId: string): void { return null as any; }
destroyEmbeddedComponents(): void { return null as any; }
render(doc: DocumentContents): Observable<void> { return null as any; }
}
////////////////////////////////////////////////////////////////////////////////////////////////////
/// `TestModule` and `TestParentComponent`. ///
////////////////////////////////////////////////////////////////////////////////////////////////////
// Test parent component.
@Component({
selector: 'aio-test',
template: '<aio-doc-viewer [doc]="currentDoc">Test Component</aio-doc-viewer>',
})
export class TestParentComponent {
currentDoc: DocumentContents;
@ViewChild(DocViewerComponent) docViewer: DocViewerComponent;
}
// Mock services.
export class MockEmbedComponentsService {
embedInto = jasmine.createSpy('EmbedComponentsService#embedInto');
}
export class MockTitle {
setTitle = jasmine.createSpy('Title#reset');
}
export class MockTocService {
genToc = jasmine.createSpy('TocService#genToc');
reset = jasmine.createSpy('TocService#reset');
}
@NgModule({
declarations: [
DocViewerComponent,
TestParentComponent,
],
providers: [
{ provide: Logger, useClass: MockLogger },
{ provide: EmbedComponentsService, useClass: MockEmbedComponentsService },
{ provide: Title, useClass: MockTitle },
{ provide: TocService, useClass: MockTocService },
],
})
export class TestModule { }
////////////////////////////////////////////////////////////////////////////////////////////////////
/// An observable with spies to test subscribing/unsubscribing. ///
////////////////////////////////////////////////////////////////////////////////////////////////////
export class ObservableWithSubscriptionSpies<T = void> extends Observable<T> {
unsubscribeSpies: jasmine.Spy[] = [];
subscribeSpy = spyOn(this, 'subscribe').and.callFake((...args) => {
const subscription = super.subscribe(...args);
const unsubscribeSpy = spyOn(subscription, 'unsubscribe').and.callThrough();
this.unsubscribeSpies.push(unsubscribeSpy);
return subscription;
});
constructor(subscriber = () => undefined) { super(subscriber); }
}

View File

@ -0,0 +1,138 @@
import {
Component, ComponentFactoryResolver, ComponentRef, CompilerFactory, ElementRef, NgModule,
NgModuleFactoryLoader, OnInit, Type, ViewChild, getPlatform
} from '@angular/core';
import {
ComponentsOrModulePath, EMBEDDED_COMPONENTS, EmbedComponentsService, EmbeddedComponentFactory,
WithEmbeddedComponents
} from 'app/embed-components/embed-components.service';
////////////////////////////////////////////////////////////////////////////////////////////////////
/// `TestEmbedComponentsService` (for exposing internal methods as public). ///
/// Only used for type-casting; the actual implementation is irrelevant. ///
////////////////////////////////////////////////////////////////////////////////////////////////////
export class TestEmbedComponentsService extends EmbedComponentsService {
componentFactories: Map<string, EmbeddedComponentFactory>;
createComponentFactories(components: Type<any>[], resolver: ComponentFactoryResolver): void { return null as any; }
createComponents(elem: HTMLElement): ComponentRef<any>[] { return null as any; }
prepareComponentFactories(compsOrPath: ComponentsOrModulePath): Promise<void> { return null as any; }
selectorToContentPropertyName(selector: string): string { return null as any; }
}
////////////////////////////////////////////////////////////////////////////////////////////////////
/// Mock `EmbeddedModule` and test components. ///
////////////////////////////////////////////////////////////////////////////////////////////////////
// Test embedded components.
@Component({
selector: 'aio-eager-foo',
template: `Eager Foo Component`,
})
class EagerFooComponent { }
@Component({
selector: 'aio-eager-bar',
template: `
<hr>
<h2>Eager Bar Component</h2>
<p #content></p>
<hr>
`,
})
class EagerBarComponent implements OnInit {
@ViewChild('content') contentRef: ElementRef;
constructor(public elementRef: ElementRef) { }
// Project content in `ngOnInit()` just like in `CodeExampleComponent`.
ngOnInit() {
// Security: This is a test component; never deployed.
this.contentRef.nativeElement.innerHTML = this.elementRef.nativeElement.aioEagerBarContent;
}
}
@Component({
selector: 'aio-lazy-foo',
template: `Lazy Foo Component`,
})
class LazyFooComponent { }
@Component({
selector: 'aio-lazy-bar',
template: `
<hr>
<h2>Lazy Bar Component</h2>
<p #content></p>
<hr>
`,
})
class LazyBarComponent implements OnInit {
@ViewChild('content') contentRef: ElementRef;
constructor(public elementRef: ElementRef) { }
// Project content in `ngOnInit()` just like in `CodeExampleComponent`.
ngOnInit() {
// Security: This is a test component; never deployed.
this.contentRef.nativeElement.innerHTML = this.elementRef.nativeElement.aioLazyBarContent;
}
}
// Export test embedded selectors and components.
export const testEagerEmbeddedSelectors = ['aio-eager-foo', 'aio-eager-bar'];
export const testEagerEmbeddedComponents = [EagerFooComponent, EagerBarComponent];
export const testLazyEmbeddedSelectors = ['aio-lazy-foo', 'aio-lazy-bar'];
export const testLazyEmbeddedComponents = [LazyFooComponent, LazyBarComponent];
// Export mock `EmbeddedModule` and path.
export const mockEmbeddedModulePath = 'mock/mock-embedded#MockEmbeddedModule';
@NgModule({
declarations: [testLazyEmbeddedComponents],
entryComponents: [testLazyEmbeddedComponents],
})
class MockEmbeddedModule implements WithEmbeddedComponents {
embeddedComponents = testLazyEmbeddedComponents;
}
////////////////////////////////////////////////////////////////////////////////////////////////////
/// `TestModule`. ///
////////////////////////////////////////////////////////////////////////////////////////////////////
// Mock services.
export class MockNgModuleFactoryLoader implements NgModuleFactoryLoader {
loadedPaths: string[] = [];
load(path: string) {
this.loadedPaths.push(path);
const platformRef = getPlatform();
const compilerFactory = platformRef.injector.get(CompilerFactory) as CompilerFactory;
const compiler = compilerFactory.createCompiler([]);
return compiler.compileModuleAsync(MockEmbeddedModule);
}
}
@NgModule({
providers: [
EmbedComponentsService,
{ provide: NgModuleFactoryLoader, useClass: MockNgModuleFactoryLoader },
{
provide: EMBEDDED_COMPONENTS,
useValue: {
[testEagerEmbeddedSelectors.join(',')]: testEagerEmbeddedComponents,
[testLazyEmbeddedSelectors.join(',')]: mockEmbeddedModulePath,
},
},
],
declarations: [testEagerEmbeddedComponents],
entryComponents: [testEagerEmbeddedComponents],
})
export class TestModule { }