From 7d81309e11e2b0167d129371093b4da49311082c Mon Sep 17 00:00:00 2001 From: Georgios Kalpakas Date: Mon, 31 Jul 2017 15:45:18 +0300 Subject: [PATCH] feat(aio): lazy-load embedded components (#18428) Fixes #16127 PR Close #18428 --- aio/scripts/_payload-limits.json | 18 +- aio/src/app/app.component.spec.ts | 8 +- aio/src/app/app.component.ts | 2 +- aio/src/app/app.module.spec.ts | 48 ++ aio/src/app/app.module.ts | 62 +- .../embed-components.module.ts | 13 + .../embed-components.service.spec.ts | 378 +++++++++ .../embed-components.service.ts | 154 ++++ aio/src/app/embedded/code/code.component.ts | 2 +- aio/src/app/embedded/embedded.module.ts | 31 +- .../doc-viewer/doc-viewer.component.spec.ts | 802 ++++++++++-------- .../layout/doc-viewer/doc-viewer.component.ts | 137 ++- .../toc/toc.component.html | 0 .../toc/toc.component.spec.ts | 0 .../{embedded => layout}/toc/toc.component.ts | 0 aio/src/app/shared/custom-icon-registry.ts | 2 +- aio/src/testing/doc-viewer-utils.ts | 85 ++ aio/src/testing/embed-components-utils.ts | 138 +++ 18 files changed, 1378 insertions(+), 502 deletions(-) create mode 100644 aio/src/app/app.module.spec.ts create mode 100644 aio/src/app/embed-components/embed-components.module.ts create mode 100644 aio/src/app/embed-components/embed-components.service.spec.ts create mode 100644 aio/src/app/embed-components/embed-components.service.ts rename aio/src/app/{embedded => layout}/toc/toc.component.html (100%) rename aio/src/app/{embedded => layout}/toc/toc.component.spec.ts (100%) rename aio/src/app/{embedded => layout}/toc/toc.component.ts (100%) create mode 100644 aio/src/testing/doc-viewer-utils.ts create mode 100644 aio/src/testing/embed-components-utils.ts diff --git a/aio/scripts/_payload-limits.json b/aio/scripts/_payload-limits.json index d90c86053e..a12936ce2a 100755 --- a/aio/scripts/_payload-limits.json +++ b/aio/scripts/_payload-limits.json @@ -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 } } } diff --git a/aio/src/app/app.component.spec.ts b/aio/src/app/app.component.spec.ts index 97aa709d36..4245cd1aed 100644 --- a/aio/src/app/app.component.spec.ts +++ b/aio/src/app/app.component.spec.ts @@ -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'); } diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts index 6b447cb684..d954fd5183 100644 --- a/aio/src/app/app.component.ts +++ b/aio/src/app/app.component.ts @@ -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'; diff --git a/aio/src/app/app.module.spec.ts b/aio/src/app/app.module.spec.ts new file mode 100644 index 0000000000..7568db0323 --- /dev/null +++ b/aio/src/app/app.module.spec.ts @@ -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'); + }); +}); diff --git a/aio/src/app/app.module.ts b/aio/src/app/app.module.ts index 3dec7d0bd1..75c47ce125 100644 --- a/aio/src/app/app.module.ts +++ b/aio/src/app/app.module.ts @@ -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 `` 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,15 +108,32 @@ 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, + }, ], - bootstrap: [AppComponent] + entryComponents: [ TocComponent ], + bootstrap: [ AppComponent ] }) export class AppModule { } diff --git a/aio/src/app/embed-components/embed-components.module.ts b/aio/src/app/embed-components/embed-components.module.ts new file mode 100644 index 0000000000..b0b857fa67 --- /dev/null +++ b/aio/src/app/embed-components/embed-components.module.ts @@ -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 { +} diff --git a/aio/src/app/embed-components/embed-components.service.spec.ts b/aio/src/app/embed-components/embed-components.service.spec.ts new file mode 100644 index 0000000000..c4e1d38f08 --- /dev/null +++ b/aio/src/app/embed-components/embed-components.service.spec.ts @@ -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 = ` +

Header

+

+

+

Footer

+ `; + + 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 = ` +

Header

+

+

+

+

+

Footer

+ `; + + 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 = ` +

Header

+

${projectedContent}

+

Footer

+ `; + + 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 = ` + + + + `; + + 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 = ` + + + + `; + + 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[]; + let createComponentsSpy: jasmine.Spy; + let prepareComponentFactoriesSpy: jasmine.Spy; + + const doEmbed = (contents: string) => + new Promise[]>((resolve, reject) => { + host.innerHTML = contents; + service.embedInto(host).subscribe(resolve, reject); + }); + + beforeEach(() => { + mockComponentRefs = [{foo: true}, {bar: true}] as any as ComponentRef[]; + + 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('')).toEqual(mockComponentRefs); + expect(await doEmbed('')).toEqual(mockComponentRefs); + + // When there are no embedded components. + expect(await doEmbed('
Test
')).toEqual([]); + expect(await doEmbed('')).toEqual([]); + }); + + it('should prepare all component factories if there are embedded components', async () => { + await doEmbed(` +
foo
+ bar + `); + + expect(prepareComponentFactoriesSpy).toHaveBeenCalledTimes(2); + expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(testEagerEmbeddedComponents); + expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(mockEmbeddedModulePath); + }); + + it('should only prepare the necessary factories', async () => { + await doEmbed('Eager only'); + expect(prepareComponentFactoriesSpy).toHaveBeenCalledTimes(1); + expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(testEagerEmbeddedComponents); + + await doEmbed('Lazy only'); + 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(''); + await doEmbed(''); + + expect(prepareComponentFactoriesSpy).not.toHaveBeenCalled(); + }); + }); + + describe('(creating embedded components)', () => { + it('should create embedded components if the element contains any', async () => { + await doEmbed('
blah
'); + + expect(createComponentsSpy).toHaveBeenCalledTimes(1); + expect(prepareComponentFactoriesSpy).toHaveBeenCalledBefore(createComponentsSpy); + + prepareComponentFactoriesSpy.calls.reset(); + createComponentsSpy.calls.reset(); + + await doEmbed('blah'); + expect(createComponentsSpy).toHaveBeenCalledTimes(1); + expect(prepareComponentFactoriesSpy).toHaveBeenCalledBefore(createComponentsSpy); + }); + + it('should emit the created embedded components', async () => { + const componentRefs = await doEmbed(''); + expect(componentRefs).toBe(mockComponentRefs); + }); + + it('should not create embedded components if the element does not contain any', async () => { + await doEmbed(` + + <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 = ''; + service.embedInto(host).subscribe(); + await new Promise(resolve => setTimeout(resolve)); + expect(createComponentsSpy).toHaveBeenCalledTimes(1); + + createComponentsSpy.calls.reset(); + + // When unsubscribed from... + host.innerHTML = ''; + 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'); + }); + }); +}); diff --git a/aio/src/app/embed-components/embed-components.service.ts b/aio/src/app/embed-components/embed-components.service.ts new file mode 100644 index 0000000000..67b9dc78f6 --- /dev/null +++ b/aio/src/app/embed-components/embed-components.service.ts @@ -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; +} + +/** + * 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[]; +} + +/** + * Either an array of components or the path to a module that implements `WithEmbeddedComponents`. + */ +export type ComponentsOrModulePath = Type[] | string; + +/** + * The injection token for the `EmbeddedComponentsMap`. + */ +export const EMBEDDED_COMPONENTS = new InjectionToken('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>(); + protected componentFactories = new Map(); + + 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[]> { + 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[], 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[] { + const componentRefs: ComponentRef[] = []; + + 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 { + if (!this.componentFactoriesReady.has(compsOrPath)) { + const componentsAndResolverPromise = (typeof compsOrPath !== 'string') + ? Promise.resolve({components: compsOrPath, resolver: this.resolver}) + : this.loader.load(compsOrPath).then((ngModuleFactory: NgModuleFactory) => { + 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'; + } +} diff --git a/aio/src/app/embedded/code/code.component.ts b/aio/src/app/embedded/code/code.component.ts index d3e71c8cbf..e72b93bbfb 100644 --- a/aio/src/app/embedded/code/code.component.ts +++ b/aio/src/app/embedded/code/code.component.ts @@ -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 diff --git a/aio/src/app/embedded/embedded.module.ts b/aio/src/app/embedded/embedded.module.ts index ec30c3379a..ea13933b72 100644 --- a/aio/src/app/embedded/embedded.module.ts +++ b/aio/src/app/embedded/embedded.module.ts @@ -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[] = [ 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; +} diff --git a/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts b/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts index 1ab9eb630a..b04ed6bb24 100644 --- a/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts +++ b/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts @@ -1,436 +1,484 @@ +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: ` -
-

Bar Component

-

-
- ` -}) -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: ` -
++++++++++++++
-

Baz Component

-

-
++++++++++++++
- ` -}) -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: ` - Test Component - ` -}) -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; + let parentComponent: TestParentComponent; let docViewerEl: HTMLElement; - let fixture: ComponentFixture; - - 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; }); - beforeEach(() => { - fixture = TestBed.createComponent(TestComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - docViewerDE = fixture.debugElement.children[0]; - docViewerEl = docViewerDE.nativeElement; + it('should create a `DocViewer`', () => { + expect(docViewer).toEqual(jasmine.any(DocViewerComponent)); }); - it('should create a DocViewer', () => { - expect(component.docViewer).toBeTruthy(); - }); + describe('#doc / #docRendered', () => { + let destroyEmbeddedComponentsSpy: jasmine.Spy; + let renderSpy: jasmine.Spy; - it(('should display nothing when set currentDoc has no content'), () => { - setCurrentDoc(); - fixture.detectChanges(); - expect(docViewerEl.innerHTML).toBe(''); - }); - - it(('should display simple static content doc'), () => { - const contents = '

Howdy, doc viewer

'; - setCurrentDoc(contents); - fixture.detectChanges(); - expect(docViewerEl.innerHTML).toEqual(contents); - }); - - it(('should display nothing after reset static content doc'), () => { - const contents = '

Howdy, doc viewer

'; - setCurrentDoc(contents); - fixture.detectChanges(); - component.currentDoc = { contents: '', id: 'a/c' }; - fixture.detectChanges(); - expect(docViewerEl.innerHTML).toEqual(''); - }); - - it(('should apply FooComponent'), () => { - const contents = ` -

Above Foo

-

-

Below Foo

- `; - setCurrentDoc(contents); - fixture.detectChanges(); - const fooHtml = docViewerEl.querySelector('aio-foo').innerHTML; - expect(fooHtml).toContain('Foo Component'); - }); - - it(('should apply multiple FooComponents'), () => { - const contents = ` -

Above Foo

-

-
- Holds a - Ignored text -
-

Below Foo

- `; - setCurrentDoc(contents); - fixture.detectChanges(); - const foos = docViewerEl.querySelectorAll('aio-foo'); - expect(foos.length).toBe(2); - }); - - it(('should apply BarComponent'), () => { - const contents = ` -

Above Bar

- -

Below Bar

- `; - 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 = ` -

Above Bar

- ###bar content### -

Below Bar

- `; - 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 = ` -

Top

-

ignored

- ###bar content### -

-

Bottom

- `; - 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 = ` -

Top

-
- - ###bar content### - -
-

-

Bottom

- `; - 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 = ` -

Top

- -
- Inner -
-
-

-

Bottom

- `; - 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 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 = ` -

Top

- -
- Inner ---baz stuff--- -
-
-

---More baz--

-

Bottom

- `; - 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; + const setCurrentDoc = (contents, id = 'fizz/buzz') => { + parentComponent.currentDoc = {contents, id}; + parentFixture.detectChanges(); + }; beforeEach(() => { - titleService = TestBed.get(Title); + destroyEmbeddedComponentsSpy = spyOn(docViewer, 'destroyEmbeddedComponents'); + renderSpy = spyOn(docViewer, 'render').and.returnValue([null]); }); - it('should set the default empty title when no

', () => { - setCurrentDoc('Some content'); - fixture.detectChanges(); - expect(titleService.setTitle).toHaveBeenCalledWith('Angular'); + 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 set the expected title when has

', () => { - setCurrentDoc('

Features

Some content'); - fixture.detectChanges(); - expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); + 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 set the expected title with a no-toc

', () => { - setCurrentDoc('

Features

Some content'); - fixture.detectChanges(); - expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); + 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 not include hidden content of the

in the title', () => { - setCurrentDoc('

linkFeatures

Some content'); - fixture.detectChanges(); - expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); + 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 fall back to `textContent` if `innerText` is not available', () => { - const querySelector_ = docViewerEl.querySelector; - spyOn(docViewerEl, 'querySelector').and.callFake((selector: string) => { - const elem = querySelector_.call(docViewerEl, selector); - Object.defineProperties(elem, { - innerText: { value: undefined }, - textContent: { value: 'Text Content' } - }); - return elem; - }); + it('should ignore falsy document values', () => { + const onDocRenderedSpy = jasmine.createSpy('onDocRendered'); + docViewer.docRendered.subscribe(onDocRenderedSpy); - setCurrentDoc('

linkFeatures

Some content'); - fixture.detectChanges(); - expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Text Content'); - }); + parentComponent.currentDoc = null; + parentFixture.detectChanges(); - it('should still use `innerText` if available but empty', () => { - const querySelector_ = docViewerEl.querySelector; - spyOn(docViewerEl, 'querySelector').and.callFake((selector: string) => { - const elem = querySelector_.call(docViewerEl, selector); - Object.defineProperties(elem, { - innerText: { value: '' }, - textContent: { value: 'Text Content' } - }); - return elem; - }); + expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled(); + expect(renderSpy).not.toHaveBeenCalled(); + expect(onDocRenderedSpy).not.toHaveBeenCalled(); - setCurrentDoc('

link

Some content'); - fixture.detectChanges(); - expect(titleService.setTitle).toHaveBeenCalledWith('Angular'); + parentComponent.currentDoc = undefined; + parentFixture.detectChanges(); + + expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled(); + expect(renderSpy).not.toHaveBeenCalled(); + expect(onDocRenderedSpy).not.toHaveBeenCalled(); }); }); - describe('TOC', () => { - let tocService: TestTocService; - - function getAioToc(): HTMLElement { - return fixture.debugElement.nativeElement.querySelector('aio-toc'); - } + describe('#ngDoCheck()', () => { + let componentInstances: ComponentRef[]; beforeEach(() => { - tocService = TestBed.get(TocService); + componentInstances = [ + {changeDetectorRef: {detectChanges: jasmine.createSpy('detectChanges')}}, + {changeDetectorRef: {detectChanges: jasmine.createSpy('detectChanges')}}, + {changeDetectorRef: {detectChanges: jasmine.createSpy('detectChanges')}}, + ] as any; + docViewer.embeddedComponentRefs.push(...componentInstances); }); - describe('if no

title', () => { - beforeEach(() => { - 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); }); - it('should not have an ', () => { - expect(getAioToc()).toBeFalsy(); + 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 = '

Features

Some content'; + const DOC_WITH_NO_TOC_H1 = '

Features

Some content'; + const DOC_WITH_HIDDEN_H1_CONTENT = '

linkFeatures

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 `

` heading', () => { + tryDoc(DOC_WITH_H1); + expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); }); - it('should reset Toc Service', () => { - expect(tocService.reset).toHaveBeenCalled(); + it('should set the title if there is a `.no-toc` `

` heading', () => { + tryDoc(DOC_WITH_NO_TOC_H1); + expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); }); - it('should not call Toc Service genToc()', () => { + it('should set the default title if there is no `

` heading', () => { + tryDoc(DOC_WITHOUT_H1); + expect(titleService.setTitle).toHaveBeenCalledWith('Angular'); + + tryDoc(EMPTY_DOC); + expect(titleService.setTitle).toHaveBeenCalledWith('Angular'); + }); + + it('should not include hidden content of the `

` heading in the title', () => { + tryDoc(DOC_WITH_HIDDEN_H1_CONTENT); + expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); + }); + + it('should fall back to `textContent` if `innerText` is not available', () => { + const querySelector_ = docViewerEl.querySelector; + spyOn(docViewerEl, 'querySelector').and.callFake((selector: string) => { + const elem = querySelector_.call(docViewerEl, selector); + return Object.defineProperties(elem, { + innerText: {value: undefined}, + textContent: {value: 'Text Content'}, + }); + }); + + tryDoc(DOC_WITH_HIDDEN_H1_CONTENT); + + expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Text Content'); + }); + + it('should still use `innerText` if available but empty', () => { + const querySelector_ = docViewerEl.querySelector; + spyOn(docViewerEl, 'querySelector').and.callFake((selector: string) => { + const elem = querySelector_.call(docViewerEl, selector); + return Object.defineProperties(elem, { + innerText: { value: '' }, + textContent: { value: 'Text Content' } + }); + }); + + tryDoc(DOC_WITH_HIDDEN_H1_CONTENT); + + expect(titleService.setTitle).toHaveBeenCalledWith('Angular'); + }); + }); + + describe('(ToC)', () => { + let tocService: MockTocService; + + const getTocEl = () => docViewerEl.querySelector('aio-toc'); + + beforeEach(() => tocService = TestBed.get(TocService)); + + it('should have an (embedded) ToC if there is an `

` 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'); + }); + + it('should have no ToC if there is a `.no-toc` `

` heading', () => { + tryDoc(DOC_WITH_NO_TOC_H1); + + expect(getTocEl()).toBeFalsy(); + expect(tocService.genToc).not.toHaveBeenCalled(); + }); + + it('should have no ToC if there is no `

` heading', () => { + tryDoc(DOC_WITHOUT_H1); + expect(getTocEl()).toBeFalsy(); + + tryDoc(EMPTY_DOC); + expect(getTocEl()).toBeFalsy(); + + expect(tocService.genToc).not.toHaveBeenCalled(); + }); + + it('should always reset the ToC (before generating the new one)', () => { + expect(tocService.reset).not.toHaveBeenCalled(); + expect(tocService.genToc).not.toHaveBeenCalled(); + + 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 with a no-toc

', () => { - setCurrentDoc('

Features

Some content'); - fixture.detectChanges(); - expect(getAioToc()).toBeFalsy(); + describe('#destroyEmbeddedComponents()', () => { + let componentInstances: ComponentRef[]; + + beforeEach(() => { + componentInstances = [ + {destroy: jasmine.createSpy('destroy#1')}, + {destroy: jasmine.createSpy('destroy#2')}, + {destroy: jasmine.createSpy('destroy#3')}, + ] as any; + docViewer.embeddedComponentRefs.push(...componentInstances); }); - describe('when has an

(title)', () => { - beforeEach(() => { - setCurrentDoc('

Features

Some content'); - fixture.detectChanges(); + it('should destroy each active component instance', () => { + docViewer.destroyEmbeddedComponents(); + + expect(componentInstances.length).toBe(3); + componentInstances.forEach(comp => expect(comp.destroy).toHaveBeenCalledTimes(1)); + }); + + it('should clear the list of active component instances', () => { + expect(docViewer.embeddedComponentRefs.length).toBeGreaterThan(0); + + docViewer.destroyEmbeddedComponents(); + expect(docViewer.embeddedComponentRefs.length).toBe(0); + }); + }); + + describe('#render()', () => { + let addTitleAndTocSpy: jasmine.Spy; + let embedIntoSpy: jasmine.Spy; + + const doRender = (contents: string | null, id = 'foo') => + new Promise((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 = '

Hello,

world!
'; + await doRender(contents); + + expect(docViewerEl.innerHTML).toBe(contents); }); - it('should add ', () => { - expect(getAioToc()).toBeTruthy(); + 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 have with "embedded" class', () => { - expect(getAioToc().classList.contains('embedded')).toEqual(true); + 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 call Toc Service genToc()', () => { - expect(tocService.genToc).toHaveBeenCalled(); + 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], + ]); }); }); }); diff --git a/aio/src/app/layout/doc-viewer/doc-viewer.component.ts b/aio/src/app/layout/doc-viewer/doc-viewer.component.ts index 5808d2a45d..6de60a853e 100644 --- a/aio/src/app/layout/doc-viewer/doc-viewer.component.ts +++ b/aio/src/app/layout/doc-viewer/doc-viewer.component.ts @@ -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; -} // 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[] = []; - private embeddedComponentFactories: Map = new Map(); private hostElement: HTMLElement; + private void$ = of(undefined); + private onDestroy$ = new EventEmitter(); + private docContents$ = new EventEmitter(); + + protected embeddedComponentRefs: ComponentRef[] = []; + + @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(); 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 { + 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$; + }); } } diff --git a/aio/src/app/embedded/toc/toc.component.html b/aio/src/app/layout/toc/toc.component.html similarity index 100% rename from aio/src/app/embedded/toc/toc.component.html rename to aio/src/app/layout/toc/toc.component.html diff --git a/aio/src/app/embedded/toc/toc.component.spec.ts b/aio/src/app/layout/toc/toc.component.spec.ts similarity index 100% rename from aio/src/app/embedded/toc/toc.component.spec.ts rename to aio/src/app/layout/toc/toc.component.spec.ts diff --git a/aio/src/app/embedded/toc/toc.component.ts b/aio/src/app/layout/toc/toc.component.ts similarity index 100% rename from aio/src/app/embedded/toc/toc.component.ts rename to aio/src/app/layout/toc/toc.component.ts diff --git a/aio/src/app/shared/custom-icon-registry.ts b/aio/src/app/shared/custom-icon-registry.ts index d0fe754f21..fdec8bd247 100644 --- a/aio/src/app/shared/custom-icon-registry.ts +++ b/aio/src/app/shared/custom-icon-registry.ts @@ -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'; diff --git a/aio/src/testing/doc-viewer-utils.ts b/aio/src/testing/doc-viewer-utils.ts new file mode 100644 index 0000000000..3c4c289bdb --- /dev/null +++ b/aio/src/testing/doc-viewer-utils.ts @@ -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[]; + + addTitleAndToc(docId: string): void { return null as any; } + destroyEmbeddedComponents(): void { return null as any; } + render(doc: DocumentContents): Observable { return null as any; } +} + + +//////////////////////////////////////////////////////////////////////////////////////////////////// +/// `TestModule` and `TestParentComponent`. /// +//////////////////////////////////////////////////////////////////////////////////////////////////// + +// Test parent component. +@Component({ + selector: 'aio-test', + template: 'Test Component', +}) +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 extends Observable { + 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); } +} diff --git a/aio/src/testing/embed-components-utils.ts b/aio/src/testing/embed-components-utils.ts new file mode 100644 index 0000000000..fc8f7af566 --- /dev/null +++ b/aio/src/testing/embed-components-utils.ts @@ -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; + + createComponentFactories(components: Type[], resolver: ComponentFactoryResolver): void { return null as any; } + createComponents(elem: HTMLElement): ComponentRef[] { return null as any; } + prepareComponentFactories(compsOrPath: ComponentsOrModulePath): Promise { 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: ` +
+

Eager Bar Component

+

+
+ `, +}) +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: ` +
+

Lazy Bar Component

+

+
+ `, +}) +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 { }