feat(aio): lazy-load embedded components (#18428)
Fixes #16127 PR Close #18428
This commit is contained in:
parent
225baf4686
commit
7d81309e11
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
}
|
|
@ -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>
|
||||
<aio-lazy-bar></aio-lazy-bar>
|
||||
`);
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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$;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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); }
|
||||
}
|
|
@ -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 { }
|
Loading…
Reference in New Issue