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'; | ||||
|  | ||||
							
								
								
									
										48
									
								
								aio/src/app/app.module.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								aio/src/app/app.module.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,48 @@ | ||||
| import { TestBed } from '@angular/core/testing'; | ||||
| import { AppModule } from 'app/app.module'; | ||||
| import { ComponentsOrModulePath, EMBEDDED_COMPONENTS } from 'app/embed-components/embed-components.service'; | ||||
| import { embeddedComponents } from 'app/embedded/embedded.module'; | ||||
| 
 | ||||
| describe('AppModule', () => { | ||||
|   let componentsMap: {[multiSelectorstring: string]: ComponentsOrModulePath}; | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     TestBed.configureTestingModule({imports: [AppModule]}); | ||||
|     componentsMap = TestBed.get(EMBEDDED_COMPONENTS); | ||||
|   }); | ||||
| 
 | ||||
|   it('should provide a map of selectors to embedded components (or module)', () => { | ||||
|     const allSelectors = Object.keys(componentsMap); | ||||
| 
 | ||||
|     expect(allSelectors.length).toBeGreaterThan(1); | ||||
|     allSelectors.forEach(selector => { | ||||
|       const value = componentsMap[selector]; | ||||
|       const isArrayOrString = Array.isArray(value) || (typeof value === 'string'); | ||||
|       expect(isArrayOrString).toBe(true); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   it('should provide a list of eagerly-loaded embedded components', () => { | ||||
|     const eagerSelector = Object.keys(componentsMap).find(selector => Array.isArray(componentsMap[selector])); | ||||
|     const selectorCount = eagerSelector.split(',').length; | ||||
| 
 | ||||
|     expect(eagerSelector).not.toBeNull(); | ||||
|     expect(selectorCount).toBe(componentsMap[eagerSelector].length); | ||||
| 
 | ||||
|     // For example...
 | ||||
|     expect(eagerSelector).toContain('aio-toc'); | ||||
|   }); | ||||
| 
 | ||||
|   it('should provide a list of lazy-loaded embedded components', () => { | ||||
|     const lazySelector = Object.keys(componentsMap).find(selector => selector.includes('code-example')); | ||||
|     const selectorCount = lazySelector.split(',').length; | ||||
| 
 | ||||
|     expect(lazySelector).not.toBeNull(); | ||||
|     expect(selectorCount).toBe(embeddedComponents.length); | ||||
| 
 | ||||
|     // For example...
 | ||||
|     expect(lazySelector).toContain('code-example'); | ||||
|     expect(lazySelector).toContain('code-tabs'); | ||||
|     expect(lazySelector).toContain('live-example'); | ||||
|   }); | ||||
| }); | ||||
| @ -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,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 { | ||||
| } | ||||
|  | ||||
							
								
								
									
										13
									
								
								aio/src/app/embed-components/embed-components.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								aio/src/app/embed-components/embed-components.module.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | ||||
| import { NgModule, NgModuleFactoryLoader, SystemJsNgModuleLoader } from '@angular/core'; | ||||
| 
 | ||||
| import { EmbedComponentsService } from './embed-components.service'; | ||||
| 
 | ||||
| 
 | ||||
| @NgModule({ | ||||
|   providers: [ | ||||
|     EmbedComponentsService, | ||||
|     { provide: NgModuleFactoryLoader, useClass: SystemJsNgModuleLoader }, | ||||
|   ], | ||||
| }) | ||||
| export class EmbedComponentsModule { | ||||
| } | ||||
							
								
								
									
										378
									
								
								aio/src/app/embed-components/embed-components.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										378
									
								
								aio/src/app/embed-components/embed-components.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,378 @@ | ||||
| import { ComponentFactory, ComponentFactoryResolver, ComponentRef, NgModuleFactoryLoader } from '@angular/core'; | ||||
| import { TestBed } from '@angular/core/testing'; | ||||
| 
 | ||||
| import { | ||||
|   MockNgModuleFactoryLoader, TestEmbedComponentsService, TestModule, mockEmbeddedModulePath, | ||||
|   testEagerEmbeddedComponents, testEagerEmbeddedSelectors, testLazyEmbeddedComponents | ||||
| } from 'testing/embed-components-utils'; | ||||
| import { EmbedComponentsService, ComponentsOrModulePath } from './embed-components.service'; | ||||
| 
 | ||||
| 
 | ||||
| describe('EmbedComponentsService', () => { | ||||
|   let service: TestEmbedComponentsService; | ||||
|   let host: HTMLElement; | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     TestBed.configureTestingModule({imports: [TestModule]}); | ||||
| 
 | ||||
|     service = TestBed.get(EmbedComponentsService); | ||||
|     host = document.createElement('div'); | ||||
|   }); | ||||
| 
 | ||||
|   it('should be instantiated', () => { | ||||
|     expect(service).toEqual(jasmine.any(EmbedComponentsService)); | ||||
|   }); | ||||
| 
 | ||||
|   describe('#createComponentFactories()', () => { | ||||
|     let factories: typeof service.componentFactories; | ||||
|     let resolver: ComponentFactoryResolver; | ||||
| 
 | ||||
|     const doCreateComponentFactories = () => | ||||
|       service.createComponentFactories(testEagerEmbeddedComponents, resolver); | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|       factories = service.componentFactories; | ||||
|       resolver = TestBed.get(ComponentFactoryResolver) as ComponentFactoryResolver; | ||||
|     }); | ||||
| 
 | ||||
|     it('should create a factory entry for each component', () => { | ||||
|       expect(factories.size).toBe(0); | ||||
| 
 | ||||
|       doCreateComponentFactories(); | ||||
|       expect(factories.size).toBe(testEagerEmbeddedComponents.length); | ||||
|     }); | ||||
| 
 | ||||
|     it('should key the factory entries by selector', () => { | ||||
|       doCreateComponentFactories(); | ||||
| 
 | ||||
|       const actualSelectors = Array.from(factories.keys()); | ||||
|       const expectedSelectors = testEagerEmbeddedSelectors; | ||||
| 
 | ||||
|       expect(actualSelectors).toEqual(expectedSelectors); | ||||
|     }); | ||||
| 
 | ||||
|     it('should store the projected content property name', () => { | ||||
|       doCreateComponentFactories(); | ||||
| 
 | ||||
|       const actualContentPropNames = Array.from(factories.values()).map(x => x.contentPropertyName); | ||||
|       const expectedContentPropNames = testEagerEmbeddedSelectors.map(x => service.selectorToContentPropertyName(x)); | ||||
| 
 | ||||
|       expect(actualContentPropNames).toEqual(expectedContentPropNames); | ||||
|     }); | ||||
| 
 | ||||
|     it('should store the factory for each component', () => { | ||||
|       doCreateComponentFactories(); | ||||
| 
 | ||||
|       const actualFactories = Array.from(factories.values()).map(x => x.factory); | ||||
|       const expectedComponentTypes = testEagerEmbeddedComponents; | ||||
| 
 | ||||
|       actualFactories.forEach((factory, i) => { | ||||
|         expect(factory).toEqual(jasmine.any(ComponentFactory)); | ||||
|         expect(factory.componentType).toBe(expectedComponentTypes[i]); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('#createComponents()', () => { | ||||
|     const FooComponent = testEagerEmbeddedComponents[0]; | ||||
|     const BarComponent = testEagerEmbeddedComponents[1]; | ||||
| 
 | ||||
|     beforeEach(() => service.prepareComponentFactories(testEagerEmbeddedComponents)); | ||||
| 
 | ||||
|     it('should apply all embedded components (and return the `ComponentRef`s)', () => { | ||||
|       host.innerHTML = ` | ||||
|         <p>Header</p> | ||||
|         <p><aio-eager-foo></aio-eager-foo></p> | ||||
|         <p><aio-eager-bar></aio-eager-bar></p> | ||||
|         <p>Footer</p> | ||||
|       `;
 | ||||
| 
 | ||||
|       const componentRefs = service.createComponents(host); | ||||
| 
 | ||||
|       expect(host.innerHTML).toContain('Foo Component'); | ||||
|       expect(host.innerHTML).toContain('Bar Component'); | ||||
| 
 | ||||
|       expect(componentRefs.length).toBe(2); | ||||
|       expect(componentRefs[0].instance).toEqual(jasmine.any(FooComponent)); | ||||
|       expect(componentRefs[1].instance).toEqual(jasmine.any(BarComponent)); | ||||
|     }); | ||||
| 
 | ||||
|     it('should apply embedded components to all matching elements', () => { | ||||
|       host.innerHTML = ` | ||||
|         <p>Header</p> | ||||
|         <p><aio-eager-foo></aio-eager-foo></p> | ||||
|         <p><aio-eager-bar></aio-eager-bar></p> | ||||
|         <p><aio-eager-foo></aio-eager-foo></p> | ||||
|         <p><aio-eager-bar></aio-eager-bar></p> | ||||
|         <p>Footer</p> | ||||
|       `;
 | ||||
| 
 | ||||
|       const componentRefs = service.createComponents(host); | ||||
| 
 | ||||
|       expect(componentRefs.length).toBe(4); | ||||
|       expect(componentRefs[0].instance).toEqual(jasmine.any(FooComponent)); | ||||
|       expect(componentRefs[1].instance).toEqual(jasmine.any(FooComponent)); | ||||
|       expect(componentRefs[2].instance).toEqual(jasmine.any(BarComponent)); | ||||
|       expect(componentRefs[3].instance).toEqual(jasmine.any(BarComponent)); | ||||
|     }); | ||||
| 
 | ||||
|     it('should allow projecting content by assigning it on the element', () => { | ||||
|       const projectedContent = 'Projected content'; | ||||
|       host.innerHTML = ` | ||||
|         <p>Header</p> | ||||
|         <p><aio-eager-bar>${projectedContent}</aio-eager-bar></p> | ||||
|         <p>Footer</p> | ||||
|       `;
 | ||||
| 
 | ||||
|       const componentRefs = service.createComponents(host); | ||||
|       componentRefs[0].changeDetectorRef.detectChanges(); | ||||
| 
 | ||||
|       const barEl = host.querySelector('aio-eager-bar'); | ||||
| 
 | ||||
|       expect(barEl['aioEagerBarContent']).toBe(projectedContent); | ||||
|       expect(barEl.innerHTML).toContain(projectedContent); | ||||
|     }); | ||||
| 
 | ||||
|     // Because `FooComponent` is processed before `BarComponent`...
 | ||||
|     it('should apply `FooComponent` within `BarComponent`', () => { | ||||
|       host.innerHTML = ` | ||||
|         <aio-eager-bar> | ||||
|           <aio-eager-foo></aio-eager-foo> | ||||
|         </aio-eager-bar> | ||||
|       `;
 | ||||
| 
 | ||||
|       const componentRefs = service.createComponents(host); | ||||
|       componentRefs.forEach(ref => ref.changeDetectorRef.detectChanges()); | ||||
| 
 | ||||
|       expect(host.innerHTML).toContain('Foo Component'); | ||||
|       expect(host.innerHTML).toContain('Bar Component'); | ||||
| 
 | ||||
|       expect(componentRefs.length).toBe(2); | ||||
|       expect(componentRefs[0].instance).toEqual(jasmine.any(FooComponent)); | ||||
|       expect(componentRefs[1].instance).toEqual(jasmine.any(BarComponent)); | ||||
|     }); | ||||
| 
 | ||||
|     // Because `BarComponent` is processed after `FooComponent`...
 | ||||
|     it('should not apply `BarComponent` within `FooComponent`', () => { | ||||
|       host.innerHTML = ` | ||||
|         <aio-eager-foo> | ||||
|           <aio-eager-bar></aio-eager-bar> | ||||
|         </aio-eager-foo> | ||||
|       `;
 | ||||
| 
 | ||||
|       const componentRefs = service.createComponents(host); | ||||
|       componentRefs.forEach(ref => ref.changeDetectorRef.detectChanges()); | ||||
| 
 | ||||
|       expect(host.innerHTML).toContain('Foo Component'); | ||||
|       expect(host.innerHTML).not.toContain('Bar Component'); | ||||
| 
 | ||||
|       expect(componentRefs.length).toBe(1); | ||||
|       expect(componentRefs[0].instance).toEqual(jasmine.any(FooComponent)); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|   describe('#embedInto()', () => { | ||||
|     let mockComponentRefs: ComponentRef<any>[]; | ||||
|     let createComponentsSpy: jasmine.Spy; | ||||
|     let prepareComponentFactoriesSpy: jasmine.Spy; | ||||
| 
 | ||||
|     const doEmbed = (contents: string) => | ||||
|       new Promise<ComponentRef<any>[]>((resolve, reject) => { | ||||
|         host.innerHTML = contents; | ||||
|         service.embedInto(host).subscribe(resolve, reject); | ||||
|       }); | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|       mockComponentRefs = [{foo: true}, {bar: true}] as any as ComponentRef<any>[]; | ||||
| 
 | ||||
|       createComponentsSpy = spyOn(service, 'createComponents').and.returnValue(mockComponentRefs); | ||||
|       prepareComponentFactoriesSpy = spyOn(service, 'prepareComponentFactories') | ||||
|                                          .and.returnValue(Promise.resolve()); | ||||
|     }); | ||||
| 
 | ||||
|     it('should return an observable', done => { | ||||
|       service.embedInto(host).subscribe(done, done.fail); | ||||
|     }); | ||||
| 
 | ||||
|     describe('(preparing component factories)', () => { | ||||
|       it('should return an array of `ComponentRef`s', async () => { | ||||
|         // When there are embedded components.
 | ||||
|         expect(await doEmbed('<aio-eager-foo></aio-eager-foo>')).toEqual(mockComponentRefs); | ||||
|         expect(await doEmbed('<aio-lazy-bar></aio-lazy-bar>')).toEqual(mockComponentRefs); | ||||
| 
 | ||||
|         // When there are no embedded components.
 | ||||
|         expect(await doEmbed('<div>Test</div>')).toEqual([]); | ||||
|         expect(await doEmbed('')).toEqual([]); | ||||
|       }); | ||||
| 
 | ||||
|       it('should prepare all component factories if there are embedded components', async () => { | ||||
|         await doEmbed(` | ||||
|           <div><aio-eager-foo><b>foo</b></aio-eager-foo></div> | ||||
|           <span><aio-lazy-foo><i>bar</i></aio-lazy-foo></span> | ||||
|         `);
 | ||||
| 
 | ||||
|         expect(prepareComponentFactoriesSpy).toHaveBeenCalledTimes(2); | ||||
|         expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(testEagerEmbeddedComponents); | ||||
|         expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(mockEmbeddedModulePath); | ||||
|       }); | ||||
| 
 | ||||
|       it('should only prepare the necessary factories', async () => { | ||||
|         await doEmbed('<aio-eager-foo>Eager only</aio-eager-foo>'); | ||||
|         expect(prepareComponentFactoriesSpy).toHaveBeenCalledTimes(1); | ||||
|         expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(testEagerEmbeddedComponents); | ||||
| 
 | ||||
|         await doEmbed('<aio-lazy-foo>Lazy only</aio-lazy-foo>'); | ||||
|         expect(prepareComponentFactoriesSpy).toHaveBeenCalledTimes(2); | ||||
|         expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(mockEmbeddedModulePath); | ||||
|       }); | ||||
| 
 | ||||
|       it('should not load embedded components if the document does not contain any', async () => { | ||||
|         await doEmbed(''); | ||||
|         await doEmbed('<no-aio-eager-foo></no-aio-eager-foo>'); | ||||
|         await doEmbed('<no-aio-lazy-foo></no-aio-lazy-foo>'); | ||||
| 
 | ||||
|         expect(prepareComponentFactoriesSpy).not.toHaveBeenCalled(); | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('(creating embedded components)', () => { | ||||
|       it('should create embedded components if the element contains any', async () => { | ||||
|         await doEmbed('<div><aio-eager-foo><i>blah</i></aio-eager-foo></div>'); | ||||
| 
 | ||||
|         expect(createComponentsSpy).toHaveBeenCalledTimes(1); | ||||
|         expect(prepareComponentFactoriesSpy).toHaveBeenCalledBefore(createComponentsSpy); | ||||
| 
 | ||||
|         prepareComponentFactoriesSpy.calls.reset(); | ||||
|         createComponentsSpy.calls.reset(); | ||||
| 
 | ||||
|         await doEmbed('<aio-lazy-bar><i>blah</i></aio-lazy-bar>'); | ||||
|         expect(createComponentsSpy).toHaveBeenCalledTimes(1); | ||||
|         expect(prepareComponentFactoriesSpy).toHaveBeenCalledBefore(createComponentsSpy); | ||||
|       }); | ||||
| 
 | ||||
|       it('should emit the created embedded components', async () => { | ||||
|         const componentRefs = await doEmbed('<aio-eager-foo></aio-eager-foo>'); | ||||
|         expect(componentRefs).toBe(mockComponentRefs); | ||||
|       }); | ||||
| 
 | ||||
|       it('should not create embedded components if the element does not contain any', async () => { | ||||
|         await doEmbed(` | ||||
|           <aio-eager-foo-not></aio-eager-foo-not> | ||||
|           <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'); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										154
									
								
								aio/src/app/embed-components/embed-components.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								aio/src/app/embed-components/embed-components.service.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,154 @@ | ||||
| import { | ||||
|   ComponentFactory, ComponentFactoryResolver, ComponentRef, Inject, Injectable, InjectionToken, | ||||
|   Injector, NgModuleFactory, NgModuleFactoryLoader, Type | ||||
| } from '@angular/core'; | ||||
| 
 | ||||
| import { Observable } from 'rxjs/Observable'; | ||||
| import { of } from 'rxjs/observable/of'; | ||||
| import 'rxjs/add/operator/switchMap'; | ||||
| 
 | ||||
| 
 | ||||
| export interface EmbeddedComponentFactory { | ||||
|   contentPropertyName: string; | ||||
|   factory: ComponentFactory<any>; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * A mapping from combined component selectors (keys) to the corresponding components (values). The | ||||
|  * components can be specified either as a list of embedded components or a path to a module that | ||||
|  * provides embedded components (i.e. implements `WithEmbeddedComponents`). | ||||
|  */ | ||||
| export interface EmbeddedComponentsMap { | ||||
|   [multiSelector: string]: ComponentsOrModulePath; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Interface expected to be implemented by all modules that contribute components to the | ||||
|  * `EmbeddedComponentsMap`. | ||||
|  */ | ||||
| export interface WithEmbeddedComponents { | ||||
|   embeddedComponents: Type<any>[]; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Either an array of components or the path to a module that implements `WithEmbeddedComponents`. | ||||
|  */ | ||||
| export type ComponentsOrModulePath = Type<any>[] | string; | ||||
| 
 | ||||
| /** | ||||
|  * The injection token for the `EmbeddedComponentsMap`. | ||||
|  */ | ||||
| export const EMBEDDED_COMPONENTS = new InjectionToken<EmbeddedComponentsMap>('EMBEDDED_COMPONENTS'); | ||||
| 
 | ||||
| /** | ||||
|  * Embed components into an element. It takes care of indentifying the embedded components, loading | ||||
|  * the necessary modules and instantiating the components. | ||||
|  * | ||||
|  * Embeddable components are identified and loaded based on the info in `EmbeddedComponentsMap` | ||||
|  * (provided through dependency injection). | ||||
|  * | ||||
|  * The caller is responsible for trigering change detection and destroying the components as | ||||
|  * necessary. | ||||
|  */ | ||||
| @Injectable() | ||||
| export class EmbedComponentsService { | ||||
|   private componentFactoriesReady = new Map<ComponentsOrModulePath, Promise<void>>(); | ||||
|   protected componentFactories = new Map<string, EmbeddedComponentFactory>(); | ||||
| 
 | ||||
|   constructor( | ||||
|       private injector: Injector, | ||||
|       private loader: NgModuleFactoryLoader, | ||||
|       private resolver: ComponentFactoryResolver, | ||||
|       @Inject(EMBEDDED_COMPONENTS) private embeddedComponentsMap: EmbeddedComponentsMap) { } | ||||
| 
 | ||||
|   /** | ||||
|    * Embed components into the specified element: | ||||
|    * - Load the necessary modules (if any). | ||||
|    * - Prepare the component factories. | ||||
|    * - Instantiate the components. | ||||
|    * | ||||
|    * Return the list of `ComponentRef`s. | ||||
|    */ | ||||
|   embedInto(elem: HTMLElement): Observable<ComponentRef<any>[]> { | ||||
|     const requiredComponents = Object.keys(this.embeddedComponentsMap) | ||||
|         .filter(selector => elem.querySelector(selector)) | ||||
|         .map(selector => this.embeddedComponentsMap[selector]); | ||||
| 
 | ||||
|     const factoriesReady = requiredComponents.map(compsOrPath => this.prepareComponentFactories(compsOrPath)); | ||||
| 
 | ||||
|     return !requiredComponents.length | ||||
|         ? of([]) | ||||
|         : of(undefined) | ||||
|               .switchMap(() => Promise.all(factoriesReady)) | ||||
|               .switchMap(() => [this.createComponents(elem)]); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Resolve the embedded component factories (which will later be used to instantiate components). | ||||
|    */ | ||||
|   protected createComponentFactories(components: Type<any>[], resolver: ComponentFactoryResolver): void { | ||||
|     for (const comp of components) { | ||||
|       const factory = resolver.resolveComponentFactory(comp); | ||||
|       const selector = factory.selector; | ||||
|       const contentPropertyName = this.selectorToContentPropertyName(selector); | ||||
|       this.componentFactories.set(selector, {contentPropertyName, factory}); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Instantiate embedded components for the current contents of `elem`. | ||||
|    * (Store the original HTML contents of each element on the corresponding property for later | ||||
|    *  retrieval by the component instance.) | ||||
|    */ | ||||
|   protected createComponents(elem: HTMLElement): ComponentRef<any>[] { | ||||
|     const componentRefs: ComponentRef<any>[] = []; | ||||
| 
 | ||||
|     this.componentFactories.forEach(({contentPropertyName, factory}, selector) => { | ||||
|       const componentHosts = elem.querySelectorAll(selector); | ||||
| 
 | ||||
|       // Cast due to https://github.com/Microsoft/TypeScript/issues/4947.
 | ||||
|       for (const host of componentHosts as any as HTMLElement[]) { | ||||
|         // Hack: Preserve the current element content, because the factory will empty it out.
 | ||||
|         // Security: The source of this `innerHTML` should always be authored by the documentation
 | ||||
|         //           team and is considered to be safe.
 | ||||
|         host[contentPropertyName] = host.innerHTML; | ||||
|         componentRefs.push(factory.create(this.injector, [], host)); | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     return componentRefs; | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Prepare the component factories for the given components. | ||||
|    * If necessary, load and instantiate the module first. | ||||
|    */ | ||||
|   protected prepareComponentFactories(compsOrPath: ComponentsOrModulePath): Promise<void> { | ||||
|     if (!this.componentFactoriesReady.has(compsOrPath)) { | ||||
|       const componentsAndResolverPromise = (typeof compsOrPath !== 'string') | ||||
|           ? Promise.resolve({components: compsOrPath, resolver: this.resolver}) | ||||
|           : this.loader.load(compsOrPath).then((ngModuleFactory: NgModuleFactory<WithEmbeddedComponents>) => { | ||||
|               const moduleRef = ngModuleFactory.create(this.injector); | ||||
|               return { | ||||
|                 components: moduleRef.instance.embeddedComponents, | ||||
|                 resolver: moduleRef.componentFactoryResolver, | ||||
|               }; | ||||
|             }); | ||||
| 
 | ||||
|       const readyPromise = componentsAndResolverPromise | ||||
|           .then(({components, resolver}) => this.createComponentFactories(components, resolver)); | ||||
| 
 | ||||
|       this.componentFactoriesReady.set(compsOrPath, readyPromise); | ||||
|     } | ||||
| 
 | ||||
|     return this.componentFactoriesReady.get(compsOrPath); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Compute the component content property name by converting the selector to camelCase and | ||||
|    * appending `Content`, e.g. `live-example` => `liveExampleContent`. | ||||
|    */ | ||||
|   protected selectorToContentPropertyName(selector: string): string { | ||||
|     return selector.replace(/-(.)/g, (match, $1) => $1.toUpperCase()) + 'Content'; | ||||
|   } | ||||
| } | ||||
| @ -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,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: ` | ||||
|     <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; | ||||
|   }); | ||||
| 
 | ||||
|   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 = '<p>Howdy, doc viewer</p>'; | ||||
|     setCurrentDoc(contents); | ||||
|     fixture.detectChanges(); | ||||
|     expect(docViewerEl.innerHTML).toEqual(contents); | ||||
|   }); | ||||
| 
 | ||||
|   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 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 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; | ||||
|     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 <h1>', () => { | ||||
|       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 <h1>', () => { | ||||
|       setCurrentDoc('<h1>Features</h1>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 <h1>', () => { | ||||
|       setCurrentDoc('<h1 class="no-toc">Features</h1>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 <h1> in the title', () => { | ||||
|       setCurrentDoc('<h1><i style="visibility: hidden">link</i>Features</h1>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('<h1><i style="visibility: hidden">link</i>Features</h1>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('<h1><i style="visibility: hidden">link</i></h1>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<any>[]; | ||||
| 
 | ||||
|     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 <h1> 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 <aio-toc>', () => { | ||||
|         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 = '<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 reset Toc Service', () => { | ||||
|         expect(tocService.reset).toHaveBeenCalled(); | ||||
|       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 not call Toc Service genToc()', () => { | ||||
|       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 not include hidden content of the `<h1>` 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 `<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'); | ||||
|       }); | ||||
| 
 | ||||
|       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 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 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 <aio-toc> with a no-toc <h1>', () => { | ||||
|       setCurrentDoc('<h1 class="no-toc">Features</h1>Some content'); | ||||
|       fixture.detectChanges(); | ||||
|       expect(getAioToc()).toBeFalsy(); | ||||
|   describe('#destroyEmbeddedComponents()', () => { | ||||
|     let componentInstances: ComponentRef<any>[]; | ||||
| 
 | ||||
|     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 <h1> (title)', () => { | ||||
|       beforeEach(() => { | ||||
|         setCurrentDoc('<h1>Features</h1>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<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 add <aio-toc>', () => { | ||||
|         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 <aio-toc> 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], | ||||
|         ]); | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
| @ -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'; | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										85
									
								
								aio/src/testing/doc-viewer-utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								aio/src/testing/doc-viewer-utils.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,85 @@ | ||||
| import { Component, ComponentRef, NgModule, ViewChild } from '@angular/core'; | ||||
| import { Title } from '@angular/platform-browser'; | ||||
| 
 | ||||
| import { Observable } from 'rxjs/Observable'; | ||||
| 
 | ||||
| import { DocumentContents } from 'app/documents/document.service'; | ||||
| import { EmbedComponentsService } from 'app/embed-components/embed-components.service'; | ||||
| import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component'; | ||||
| import { Logger } from 'app/shared/logger.service'; | ||||
| import { TocService } from 'app/shared/toc.service'; | ||||
| import { MockLogger } from 'testing/logger.service'; | ||||
| 
 | ||||
| 
 | ||||
| ////////////////////////////////////////////////////////////////////////////////////////////////////
 | ||||
| /// `TestDocViewerComponent` (for exposing internal `DocViewerComponent` methods as public).     ///
 | ||||
| /// Only used for type-casting; the actual implementation is irrelevant.                         ///
 | ||||
| ////////////////////////////////////////////////////////////////////////////////////////////////////
 | ||||
| 
 | ||||
| export class TestDocViewerComponent extends DocViewerComponent { | ||||
|   embeddedComponentRefs: ComponentRef<any>[]; | ||||
| 
 | ||||
|   addTitleAndToc(docId: string): void { return null as any; } | ||||
|   destroyEmbeddedComponents(): void { return null as any; } | ||||
|   render(doc: DocumentContents): Observable<void> { return null as any; } | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| ////////////////////////////////////////////////////////////////////////////////////////////////////
 | ||||
| /// `TestModule` and `TestParentComponent`.                                                      ///
 | ||||
| ////////////////////////////////////////////////////////////////////////////////////////////////////
 | ||||
| 
 | ||||
| // Test parent component.
 | ||||
| @Component({ | ||||
|   selector: 'aio-test', | ||||
|   template: '<aio-doc-viewer [doc]="currentDoc">Test Component</aio-doc-viewer>', | ||||
| }) | ||||
| export class TestParentComponent { | ||||
|   currentDoc: DocumentContents; | ||||
|   @ViewChild(DocViewerComponent) docViewer: DocViewerComponent; | ||||
| } | ||||
| 
 | ||||
| // Mock services.
 | ||||
| export class MockEmbedComponentsService { | ||||
|   embedInto = jasmine.createSpy('EmbedComponentsService#embedInto'); | ||||
| } | ||||
| 
 | ||||
| export class MockTitle { | ||||
|   setTitle = jasmine.createSpy('Title#reset'); | ||||
| } | ||||
| 
 | ||||
| export class MockTocService { | ||||
|   genToc = jasmine.createSpy('TocService#genToc'); | ||||
|   reset = jasmine.createSpy('TocService#reset'); | ||||
| } | ||||
| 
 | ||||
| @NgModule({ | ||||
|   declarations: [ | ||||
|     DocViewerComponent, | ||||
|     TestParentComponent, | ||||
|   ], | ||||
|   providers: [ | ||||
|     { provide: Logger, useClass: MockLogger }, | ||||
|     { provide: EmbedComponentsService, useClass: MockEmbedComponentsService }, | ||||
|     { provide: Title, useClass: MockTitle }, | ||||
|     { provide: TocService, useClass: MockTocService }, | ||||
|   ], | ||||
| }) | ||||
| export class TestModule { } | ||||
| 
 | ||||
| 
 | ||||
| ////////////////////////////////////////////////////////////////////////////////////////////////////
 | ||||
| /// An observable with spies to test subscribing/unsubscribing.                                  ///
 | ||||
| ////////////////////////////////////////////////////////////////////////////////////////////////////
 | ||||
| 
 | ||||
| export class ObservableWithSubscriptionSpies<T = void> extends Observable<T> { | ||||
|   unsubscribeSpies: jasmine.Spy[] = []; | ||||
|   subscribeSpy = spyOn(this, 'subscribe').and.callFake((...args) => { | ||||
|     const subscription = super.subscribe(...args); | ||||
|     const unsubscribeSpy = spyOn(subscription, 'unsubscribe').and.callThrough(); | ||||
|     this.unsubscribeSpies.push(unsubscribeSpy); | ||||
|     return subscription; | ||||
|   }); | ||||
| 
 | ||||
|   constructor(subscriber = () => undefined) { super(subscriber); } | ||||
| } | ||||
							
								
								
									
										138
									
								
								aio/src/testing/embed-components-utils.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								aio/src/testing/embed-components-utils.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,138 @@ | ||||
| import { | ||||
|   Component, ComponentFactoryResolver, ComponentRef, CompilerFactory, ElementRef, NgModule, | ||||
|   NgModuleFactoryLoader, OnInit, Type, ViewChild, getPlatform | ||||
| } from '@angular/core'; | ||||
| 
 | ||||
| import { | ||||
|   ComponentsOrModulePath, EMBEDDED_COMPONENTS, EmbedComponentsService, EmbeddedComponentFactory, | ||||
|   WithEmbeddedComponents | ||||
| } from 'app/embed-components/embed-components.service'; | ||||
| 
 | ||||
| 
 | ||||
| ////////////////////////////////////////////////////////////////////////////////////////////////////
 | ||||
| /// `TestEmbedComponentsService` (for exposing internal methods as public).                      ///
 | ||||
| /// Only used for type-casting; the actual implementation is irrelevant.                         ///
 | ||||
| ////////////////////////////////////////////////////////////////////////////////////////////////////
 | ||||
| 
 | ||||
| export class TestEmbedComponentsService extends EmbedComponentsService { | ||||
|   componentFactories: Map<string, EmbeddedComponentFactory>; | ||||
| 
 | ||||
|   createComponentFactories(components: Type<any>[], resolver: ComponentFactoryResolver): void { return null as any; } | ||||
|   createComponents(elem: HTMLElement): ComponentRef<any>[] { return null as any; } | ||||
|   prepareComponentFactories(compsOrPath: ComponentsOrModulePath): Promise<void> { return null as any; } | ||||
|   selectorToContentPropertyName(selector: string): string { return null as any; } | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| ////////////////////////////////////////////////////////////////////////////////////////////////////
 | ||||
| /// Mock `EmbeddedModule` and test components.                                                   ///
 | ||||
| ////////////////////////////////////////////////////////////////////////////////////////////////////
 | ||||
| 
 | ||||
| // Test embedded components.
 | ||||
| @Component({ | ||||
|   selector: 'aio-eager-foo', | ||||
|   template: `Eager Foo Component`, | ||||
| }) | ||||
| class EagerFooComponent { } | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'aio-eager-bar', | ||||
|   template: ` | ||||
|     <hr> | ||||
|     <h2>Eager Bar Component</h2> | ||||
|     <p #content></p> | ||||
|     <hr> | ||||
|   `,
 | ||||
| }) | ||||
| class EagerBarComponent implements OnInit { | ||||
|   @ViewChild('content') contentRef: ElementRef; | ||||
| 
 | ||||
|   constructor(public elementRef: ElementRef) { } | ||||
| 
 | ||||
|   // Project content in `ngOnInit()` just like in `CodeExampleComponent`.
 | ||||
|   ngOnInit() { | ||||
|     // Security: This is a test component; never deployed.
 | ||||
|     this.contentRef.nativeElement.innerHTML = this.elementRef.nativeElement.aioEagerBarContent; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'aio-lazy-foo', | ||||
|   template: `Lazy Foo Component`, | ||||
| }) | ||||
| class LazyFooComponent { } | ||||
| 
 | ||||
| @Component({ | ||||
|   selector: 'aio-lazy-bar', | ||||
|   template: ` | ||||
|     <hr> | ||||
|     <h2>Lazy Bar Component</h2> | ||||
|     <p #content></p> | ||||
|     <hr> | ||||
|   `,
 | ||||
| }) | ||||
| class LazyBarComponent implements OnInit { | ||||
|   @ViewChild('content') contentRef: ElementRef; | ||||
| 
 | ||||
|   constructor(public elementRef: ElementRef) { } | ||||
| 
 | ||||
|   // Project content in `ngOnInit()` just like in `CodeExampleComponent`.
 | ||||
|   ngOnInit() { | ||||
|     // Security: This is a test component; never deployed.
 | ||||
|     this.contentRef.nativeElement.innerHTML = this.elementRef.nativeElement.aioLazyBarContent; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // Export test embedded selectors and components.
 | ||||
| export const testEagerEmbeddedSelectors = ['aio-eager-foo', 'aio-eager-bar']; | ||||
| export const testEagerEmbeddedComponents = [EagerFooComponent, EagerBarComponent]; | ||||
| export const testLazyEmbeddedSelectors = ['aio-lazy-foo', 'aio-lazy-bar']; | ||||
| export const testLazyEmbeddedComponents = [LazyFooComponent, LazyBarComponent]; | ||||
| 
 | ||||
| // Export mock `EmbeddedModule` and path.
 | ||||
| export const mockEmbeddedModulePath = 'mock/mock-embedded#MockEmbeddedModule'; | ||||
| 
 | ||||
| @NgModule({ | ||||
|   declarations: [testLazyEmbeddedComponents], | ||||
|   entryComponents: [testLazyEmbeddedComponents], | ||||
| }) | ||||
| class MockEmbeddedModule implements WithEmbeddedComponents { | ||||
|   embeddedComponents = testLazyEmbeddedComponents; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| ////////////////////////////////////////////////////////////////////////////////////////////////////
 | ||||
| /// `TestModule`.                                                                                ///
 | ||||
| ////////////////////////////////////////////////////////////////////////////////////////////////////
 | ||||
| 
 | ||||
| // Mock services.
 | ||||
| export class MockNgModuleFactoryLoader implements NgModuleFactoryLoader { | ||||
|   loadedPaths: string[] = []; | ||||
| 
 | ||||
|   load(path: string) { | ||||
|     this.loadedPaths.push(path); | ||||
| 
 | ||||
|     const platformRef = getPlatform(); | ||||
|     const compilerFactory = platformRef.injector.get(CompilerFactory) as CompilerFactory; | ||||
|     const compiler = compilerFactory.createCompiler([]); | ||||
| 
 | ||||
|     return compiler.compileModuleAsync(MockEmbeddedModule); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @NgModule({ | ||||
|   providers: [ | ||||
|     EmbedComponentsService, | ||||
|     { provide: NgModuleFactoryLoader, useClass: MockNgModuleFactoryLoader }, | ||||
|     { | ||||
|       provide: EMBEDDED_COMPONENTS, | ||||
|       useValue: { | ||||
|         [testEagerEmbeddedSelectors.join(',')]: testEagerEmbeddedComponents, | ||||
|         [testLazyEmbeddedSelectors.join(',')]: mockEmbeddedModulePath, | ||||
|       }, | ||||
|     }, | ||||
|   ], | ||||
|   declarations: [testEagerEmbeddedComponents], | ||||
|   entryComponents: [testEagerEmbeddedComponents], | ||||
| }) | ||||
| export class TestModule { } | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user