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": { |   "aio": { | ||||||
|     "master": { |     "master": { | ||||||
|       "gzip7": { |       "gzip7": { | ||||||
|         "inline": 925, |         "inline": 941, | ||||||
|         "main": 119519, |         "main": 116124, | ||||||
|         "polyfills": 11863 |         "polyfills": 11860 | ||||||
|       }, |       }, | ||||||
|       "gzip9": { |       "gzip9": { | ||||||
|         "inline": 925, |         "inline": 941, | ||||||
|         "main": 119301, |         "main": 115954, | ||||||
|         "polyfills": 11861 |         "polyfills": 11858 | ||||||
|       }, |       }, | ||||||
|       "uncompressed": { |       "uncompressed": { | ||||||
|         "inline": 1533, |         "inline": 1558, | ||||||
|         "main": 486493, |         "main": 456432, | ||||||
|         "polyfills": 37068 |         "polyfills": 37070 | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ import { AppComponent } from './app.component'; | |||||||
| import { AppModule } from './app.module'; | import { AppModule } from './app.module'; | ||||||
| import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component'; | import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component'; | ||||||
| import { Deployment } from 'app/shared/deployment.service'; | import { Deployment } from 'app/shared/deployment.service'; | ||||||
|  | import { EmbedComponentsService } from 'app/embed-components/embed-components.service'; | ||||||
| import { GaService } from 'app/shared/ga.service'; | import { GaService } from 'app/shared/ga.service'; | ||||||
| import { LocationService } from 'app/shared/location.service'; | import { LocationService } from 'app/shared/location.service'; | ||||||
| import { Logger } from 'app/shared/logger.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 { SearchResultsComponent } from 'app/shared/search-results/search-results.component'; | ||||||
| import { SearchService } from 'app/search/search.service'; | import { SearchService } from 'app/search/search.service'; | ||||||
| import { SelectComponent } from 'app/shared/select/select.component'; | 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'; | import { TocItem, TocService } from 'app/shared/toc.service'; | ||||||
| 
 | 
 | ||||||
| const sideBySideBreakPoint = 992; | const sideBySideBreakPoint = 992; | ||||||
| @ -1033,6 +1034,7 @@ function createTestingModule(initialUrl: string, mode: string = 'stable') { | |||||||
|     imports: [ AppModule ], |     imports: [ AppModule ], | ||||||
|     providers: [ |     providers: [ | ||||||
|       { provide: APP_BASE_HREF, useValue: '/' }, |       { provide: APP_BASE_HREF, useValue: '/' }, | ||||||
|  |       { provide: EmbedComponentsService, useClass: TestEmbedComponentsService }, | ||||||
|       { provide: GaService, useClass: TestGaService }, |       { provide: GaService, useClass: TestGaService }, | ||||||
|       { provide: HttpClient, useClass: TestHttpClient }, |       { provide: HttpClient, useClass: TestHttpClient }, | ||||||
|       { provide: LocationService, useFactory: () => mockLocationService }, |       { 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 { | class TestGaService { | ||||||
|   locationChanged = jasmine.createSpy('locationChanged'); |   locationChanged = jasmine.createSpy('locationChanged'); | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| import { Component, ElementRef, HostBinding, HostListener, OnInit, | import { Component, ElementRef, HostBinding, HostListener, OnInit, | ||||||
|          QueryList, ViewChild, ViewChildren } from '@angular/core'; |          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 { CurrentNodes, NavigationService, NavigationNode, VersionInfo } from 'app/navigation/navigation.service'; | ||||||
| import { DocumentService, DocumentContents } from 'app/documents/document.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 { Location, LocationStrategy, PathLocationStrategy } from '@angular/common'; | ||||||
| 
 | 
 | ||||||
| import { | import { MatButtonModule } from '@angular/material/button'; | ||||||
|   MatButtonModule, | import { MatIconModule, MatIconRegistry } from '@angular/material/icon'; | ||||||
|   MatIconModule, | import { MatProgressBarModule } from '@angular/material/progress-bar'; | ||||||
|   MatIconRegistry, | import { MatSidenavModule } from '@angular/material/sidenav'; | ||||||
|   MatInputModule, | import { MatToolbarModule } from '@angular/material/toolbar'; | ||||||
|   MatProgressBarModule, |  | ||||||
|   MatSidenavModule, |  | ||||||
|   MatTabsModule, |  | ||||||
|   MatToolbarModule |  | ||||||
| } from '@angular/material'; |  | ||||||
| 
 |  | ||||||
| import { |  | ||||||
|   Platform |  | ||||||
| } from '@angular/cdk/platform'; |  | ||||||
| 
 | 
 | ||||||
|  | import { ROUTES } from '@angular/router'; | ||||||
| 
 | 
 | ||||||
| // Temporary fix for MatSidenavModule issue:
 | // Temporary fix for MatSidenavModule issue:
 | ||||||
| // crashes with "missing first" operator when SideNav.mode is "over"
 | // crashes with "missing first" operator when SideNav.mode is "over"
 | ||||||
| import 'rxjs/add/operator/first'; | import 'rxjs/add/operator/first'; | ||||||
| 
 | 
 | ||||||
| import { SwUpdatesModule } from 'app/sw-updates/sw-updates.module'; |  | ||||||
| 
 | 
 | ||||||
| import { AppComponent } from 'app/app.component'; | 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 { CustomIconRegistry, SVG_ICONS } from 'app/shared/custom-icon-registry'; | ||||||
| import { Deployment } from 'app/shared/deployment.service'; | import { Deployment } from 'app/shared/deployment.service'; | ||||||
| import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component'; | import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component'; | ||||||
| import { DtComponent } from 'app/layout/doc-viewer/dt.component'; | import { DtComponent } from 'app/layout/doc-viewer/dt.component'; | ||||||
| import { ModeBannerComponent } from 'app/layout/mode-banner/mode-banner.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 { GaService } from 'app/shared/ga.service'; | ||||||
| import { Logger } from 'app/shared/logger.service'; | import { Logger } from 'app/shared/logger.service'; | ||||||
| import { LocationService } from 'app/shared/location.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 { NavItemComponent } from 'app/layout/nav-item/nav-item.component'; | ||||||
| import { ScrollService } from 'app/shared/scroll.service'; | import { ScrollService } from 'app/shared/scroll.service'; | ||||||
| import { ScrollSpyService } from 'app/shared/scroll-spy.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 { TocService } from 'app/shared/toc.service'; | ||||||
| import { WindowToken, windowProvider } from 'app/shared/window'; | import { WindowToken, windowProvider } from 'app/shared/window'; | ||||||
| 
 | 
 | ||||||
|  | import { EmbedComponentsModule } from 'app/embed-components/embed-components.module'; | ||||||
| import { SharedModule } from 'app/shared/shared.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
 | // These are the hardcoded inline svg sources to be used by the `<mat-icon>` component
 | ||||||
| export const svgIconProviders = [ | export const svgIconProviders = [ | ||||||
| @ -78,15 +75,13 @@ export const svgIconProviders = [ | |||||||
| @NgModule({ | @NgModule({ | ||||||
|   imports: [ |   imports: [ | ||||||
|     BrowserModule, |     BrowserModule, | ||||||
|     EmbeddedModule, |  | ||||||
|     HttpClientModule, |  | ||||||
|     BrowserAnimationsModule, |     BrowserAnimationsModule, | ||||||
|  |     EmbedComponentsModule, | ||||||
|  |     HttpClientModule, | ||||||
|     MatButtonModule, |     MatButtonModule, | ||||||
|     MatIconModule, |     MatIconModule, | ||||||
|     MatInputModule, |  | ||||||
|     MatProgressBarModule, |     MatProgressBarModule, | ||||||
|     MatSidenavModule, |     MatSidenavModule, | ||||||
|     MatTabsModule, |  | ||||||
|     MatToolbarModule, |     MatToolbarModule, | ||||||
|     SwUpdatesModule, |     SwUpdatesModule, | ||||||
|     SharedModule |     SharedModule | ||||||
| @ -100,10 +95,10 @@ export const svgIconProviders = [ | |||||||
|     NavMenuComponent, |     NavMenuComponent, | ||||||
|     NavItemComponent, |     NavItemComponent, | ||||||
|     SearchBoxComponent, |     SearchBoxComponent, | ||||||
|  |     TocComponent, | ||||||
|     TopMenuComponent, |     TopMenuComponent, | ||||||
|   ], |   ], | ||||||
|   providers: [ |   providers: [ | ||||||
|     ApiService, |  | ||||||
|     Deployment, |     Deployment, | ||||||
|     DocumentService, |     DocumentService, | ||||||
|     GaService, |     GaService, | ||||||
| @ -113,14 +108,31 @@ export const svgIconProviders = [ | |||||||
|     LocationService, |     LocationService, | ||||||
|     { provide: MatIconRegistry, useClass: CustomIconRegistry }, |     { provide: MatIconRegistry, useClass: CustomIconRegistry }, | ||||||
|     NavigationService, |     NavigationService, | ||||||
|     Platform, |  | ||||||
|     ScrollService, |     ScrollService, | ||||||
|     ScrollSpyService, |     ScrollSpyService, | ||||||
|     SearchService, |     SearchService, | ||||||
|     svgIconProviders, |     svgIconProviders, | ||||||
|     TocService, |     TocService, | ||||||
|     { provide: WindowToken, useFactory: windowProvider }, |     { provide: WindowToken, useFactory: windowProvider }, | ||||||
|  | 
 | ||||||
|  |     { | ||||||
|  |       provide: EMBEDDED_COMPONENTS, | ||||||
|  |       useValue: { | ||||||
|  |         /* tslint:disable: max-line-length */ | ||||||
|  |         'aio-toc': [TocComponent], | ||||||
|  |         'aio-api-list, aio-contributor-list, aio-file-not-found-search, aio-resource-list, code-example, code-tabs, current-location, live-example': embeddedModulePath, | ||||||
|  |         /* tslint:enable: max-line-length */ | ||||||
|  |       } as EmbeddedComponentsMap, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       // This is currently the only way to get `@angular/cli`
 | ||||||
|  |       // to split `EmbeddedModule` into a separate chunk :(
 | ||||||
|  |       provide: ROUTES, | ||||||
|  |       useValue: [{ path: '/embedded', loadChildren: embeddedModulePath }], | ||||||
|  |       multi: true, | ||||||
|  |     }, | ||||||
|   ], |   ], | ||||||
|  |   entryComponents: [ TocComponent ], | ||||||
|   bootstrap: [ AppComponent ] |   bootstrap: [ AppComponent ] | ||||||
| }) | }) | ||||||
| export class AppModule { | 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 { Logger } from 'app/shared/logger.service'; | ||||||
| import { PrettyPrinter } from './pretty-printer.service'; | import { PrettyPrinter } from './pretty-printer.service'; | ||||||
| import { CopierService } from 'app/shared/copier.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
 | 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 { CommonModule } from '@angular/common'; | ||||||
| 
 | 
 | ||||||
| import { ContributorService } from './contributor/contributor.service'; | import { ContributorService } from './contributor/contributor.service'; | ||||||
| import { CopierService } from 'app/shared/copier.service'; | import { CopierService } from 'app/shared/copier.service'; | ||||||
| import { PrettyPrinter } from './code/pretty-printer.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
 | // 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
 | // It is not enough just to import them inside the AppModule
 | ||||||
| 
 | 
 | ||||||
| // Reusable components (used inside embedded components)
 | // 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 { CodeComponent } from './code/code.component'; | ||||||
| import { SharedModule } from 'app/shared/shared.module'; | import { SharedModule } from 'app/shared/shared.module'; | ||||||
| 
 | 
 | ||||||
| // Embedded Components
 | // Embedded Components
 | ||||||
| import { ApiListComponent } from './api/api-list.component'; | import { ApiListComponent } from './api/api-list.component'; | ||||||
|  | import { ApiService } from './api/api.service'; | ||||||
| import { CodeExampleComponent } from './code/code-example.component'; | import { CodeExampleComponent } from './code/code-example.component'; | ||||||
| import { CodeTabsComponent } from './code/code-tabs.component'; | import { CodeTabsComponent } from './code/code-tabs.component'; | ||||||
| import { ContributorListComponent } from './contributor/contributor-list.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 { LiveExampleComponent, EmbeddedPlunkerComponent } from './live-example/live-example.component'; | ||||||
| import { ResourceListComponent } from './resource/resource-list.component'; | import { ResourceListComponent } from './resource/resource-list.component'; | ||||||
| import { ResourceService } from './resource/resource.service'; | 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,... |  * such as CodeExampleComponent, LiveExampleComponent,... | ||||||
|  */ |  */ | ||||||
| export const embeddedComponents: any[] = [ | export const embeddedComponents: Type<any>[] = [ | ||||||
|   ApiListComponent, CodeExampleComponent, CodeTabsComponent, ContributorListComponent, |   ApiListComponent, CodeExampleComponent, CodeTabsComponent, ContributorListComponent, | ||||||
|   CurrentLocationComponent, FileNotFoundSearchComponent, LiveExampleComponent, ResourceListComponent, |   CurrentLocationComponent, FileNotFoundSearchComponent, LiveExampleComponent, ResourceListComponent | ||||||
|   TocComponent |  | ||||||
| ]; | ]; | ||||||
| 
 | 
 | ||||||
| /** Injectable class w/ property returning components that can be embedded in docs */ |  | ||||||
| export class EmbeddedComponents { |  | ||||||
|   components = embeddedComponents; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| @NgModule({ | @NgModule({ | ||||||
|   imports: [ |   imports: [ | ||||||
|     CommonModule, |     CommonModule, | ||||||
| @ -54,16 +52,15 @@ export class EmbeddedComponents { | |||||||
|     ContributorComponent, |     ContributorComponent, | ||||||
|     EmbeddedPlunkerComponent |     EmbeddedPlunkerComponent | ||||||
|   ], |   ], | ||||||
|   exports: [ |  | ||||||
|     TocComponent |  | ||||||
|   ], |  | ||||||
|   providers: [ |   providers: [ | ||||||
|  |     ApiService, | ||||||
|     ContributorService, |     ContributorService, | ||||||
|     CopierService, |     CopierService, | ||||||
|     EmbeddedComponents, |  | ||||||
|     PrettyPrinter, |     PrettyPrinter, | ||||||
|     ResourceService |     ResourceService | ||||||
|   ], |   ], | ||||||
|   entryComponents: [ embeddedComponents ] |   entryComponents: [ embeddedComponents ] | ||||||
| }) | }) | ||||||
| export class EmbeddedModule { } | export class EmbeddedModule implements WithEmbeddedComponents { | ||||||
|  |   embeddedComponents = embeddedComponents; | ||||||
|  | } | ||||||
|  | |||||||
| @ -1,348 +1,230 @@ | |||||||
|  | import { ComponentRef } from '@angular/core'; | ||||||
| import { ComponentFixture, TestBed } from '@angular/core/testing'; | import { 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 { 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 { 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', () => { | describe('DocViewerComponent', () => { | ||||||
|   let component: TestComponent; |   let parentFixture: ComponentFixture<TestParentComponent>; | ||||||
|   let docViewerDE: DebugElement; |   let parentComponent: TestParentComponent; | ||||||
|   let docViewerEl: HTMLElement; |   let docViewerEl: HTMLElement; | ||||||
|   let fixture: ComponentFixture<TestComponent>; |   let docViewer: TestDocViewerComponent; | ||||||
| 
 |  | ||||||
|   function setCurrentDoc(contents = '', id = 'fizz/buzz') { |  | ||||||
|     component.currentDoc = { contents, id }; |  | ||||||
|   } |  | ||||||
| 
 | 
 | ||||||
|   beforeEach(() => { |   beforeEach(() => { | ||||||
|     TestBed.configureTestingModule({ |     TestBed.configureTestingModule({ | ||||||
|       imports: [ TestModule ], |       imports: [TestModule] | ||||||
|       declarations: [ |  | ||||||
|         TestComponent, |  | ||||||
|         DocViewerComponent, |  | ||||||
|         embeddedTestComponents |  | ||||||
|       ], |  | ||||||
|       providers: [ |  | ||||||
|         { provide: EmbeddedComponents, useValue: {components: embeddedTestComponents} }, |  | ||||||
|         { provide: Title, useClass: TestTitleService }, |  | ||||||
|         { provide: TocService, useClass: TestTocService } |  | ||||||
|       ] |  | ||||||
|     }); |     }); | ||||||
|  | 
 | ||||||
|  |     parentFixture = TestBed.createComponent(TestParentComponent); | ||||||
|  |     parentComponent = parentFixture.componentInstance; | ||||||
|  | 
 | ||||||
|  |     parentFixture.detectChanges(); | ||||||
|  | 
 | ||||||
|  |     docViewerEl = parentFixture.debugElement.children[0].nativeElement; | ||||||
|  |     docViewer = parentComponent.docViewer as any; | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|  |   it('should create a `DocViewer`', () => { | ||||||
|  |     expect(docViewer).toEqual(jasmine.any(DocViewerComponent)); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('#doc / #docRendered', () => { | ||||||
|  |     let destroyEmbeddedComponentsSpy: jasmine.Spy; | ||||||
|  |     let renderSpy: jasmine.Spy; | ||||||
|  | 
 | ||||||
|  |     const setCurrentDoc = (contents, id = 'fizz/buzz') => { | ||||||
|  |       parentComponent.currentDoc = {contents, id}; | ||||||
|  |       parentFixture.detectChanges(); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|     beforeEach(() => { |     beforeEach(() => { | ||||||
|     fixture = TestBed.createComponent(TestComponent); |       destroyEmbeddedComponentsSpy = spyOn(docViewer, 'destroyEmbeddedComponents'); | ||||||
|     component = fixture.componentInstance; |       renderSpy = spyOn(docViewer, 'render').and.returnValue([null]); | ||||||
|     fixture.detectChanges(); |  | ||||||
|     docViewerDE = fixture.debugElement.children[0]; |  | ||||||
|     docViewerEl = docViewerDE.nativeElement; |  | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|   it('should create a DocViewer', () => { |     it('should render the new document', () => { | ||||||
|     expect(component.docViewer).toBeTruthy(); |       setCurrentDoc('foo', 'bar'); | ||||||
|  |       expect(renderSpy).toHaveBeenCalledTimes(1); | ||||||
|  |       expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'bar', contents: 'foo'}]); | ||||||
|  | 
 | ||||||
|  |       setCurrentDoc(null, 'baz'); | ||||||
|  |       expect(renderSpy).toHaveBeenCalledTimes(2); | ||||||
|  |       expect(renderSpy.calls.mostRecent().args).toEqual([{id: 'baz', contents: null}]); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|   it(('should display nothing when set currentDoc has no content'), () => { |     it('should destroy the currently active components (before rendering the new document)', () => { | ||||||
|     setCurrentDoc(); |       setCurrentDoc('foo'); | ||||||
|     fixture.detectChanges(); |       expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1); | ||||||
|     expect(docViewerEl.innerHTML).toBe(''); |       expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledBefore(renderSpy); | ||||||
|  | 
 | ||||||
|  |       destroyEmbeddedComponentsSpy.calls.reset(); | ||||||
|  |       renderSpy.calls.reset(); | ||||||
|  | 
 | ||||||
|  |       setCurrentDoc(null); | ||||||
|  |       expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1); | ||||||
|  |       expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledBefore(renderSpy); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|   it(('should display simple static content doc'), () => { |     it('should emit `docRendered` after the new document has been rendered', done => { | ||||||
|     const contents = '<p>Howdy, doc viewer</p>'; |       let completeRender: () => void; | ||||||
|     setCurrentDoc(contents); |       renderSpy.and.returnValue(new Promise(resolve => completeRender = resolve)); | ||||||
|     fixture.detectChanges(); |       docViewer.docRendered.subscribe(done); | ||||||
|     expect(docViewerEl.innerHTML).toEqual(contents); | 
 | ||||||
|  |       setCurrentDoc('foo'); | ||||||
|  |       expect(renderSpy).toHaveBeenCalledTimes(1); | ||||||
|  | 
 | ||||||
|  |       completeRender(); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|   it(('should display nothing after reset static content doc'), () => { |     it('should unsubscribe from the previous "render" observable upon new document', () => { | ||||||
|     const contents = '<p>Howdy, doc viewer</p>'; |       const obs = new ObservableWithSubscriptionSpies(); | ||||||
|     setCurrentDoc(contents); |       renderSpy.and.returnValue(obs); | ||||||
|     fixture.detectChanges(); | 
 | ||||||
|     component.currentDoc = { contents: '', id: 'a/c' }; |       setCurrentDoc('foo', 'bar'); | ||||||
|     fixture.detectChanges(); |       expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); | ||||||
|     expect(docViewerEl.innerHTML).toEqual(''); |       expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled(); | ||||||
|  | 
 | ||||||
|  |       setCurrentDoc('baz', 'qux'); | ||||||
|  |       expect(obs.subscribeSpy).toHaveBeenCalledTimes(2); | ||||||
|  |       expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|   it(('should apply FooComponent'), () => { |     it('should ignore falsy document values', () => { | ||||||
|     const contents = ` |       const onDocRenderedSpy = jasmine.createSpy('onDocRendered'); | ||||||
|       <p>Above Foo</p> |       docViewer.docRendered.subscribe(onDocRenderedSpy); | ||||||
|       <p><aio-foo></aio-foo></p> | 
 | ||||||
|       <p>Below Foo</p> |       parentComponent.currentDoc = null; | ||||||
|     `;
 |       parentFixture.detectChanges(); | ||||||
|     setCurrentDoc(contents); | 
 | ||||||
|     fixture.detectChanges(); |       expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled(); | ||||||
|     const fooHtml = docViewerEl.querySelector('aio-foo').innerHTML; |       expect(renderSpy).not.toHaveBeenCalled(); | ||||||
|     expect(fooHtml).toContain('Foo Component'); |       expect(onDocRenderedSpy).not.toHaveBeenCalled(); | ||||||
|  | 
 | ||||||
|  |       parentComponent.currentDoc = undefined; | ||||||
|  |       parentFixture.detectChanges(); | ||||||
|  | 
 | ||||||
|  |       expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled(); | ||||||
|  |       expect(renderSpy).not.toHaveBeenCalled(); | ||||||
|  |       expect(onDocRenderedSpy).not.toHaveBeenCalled(); | ||||||
|  |     }); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   it(('should apply multiple FooComponents'), () => { |   describe('#ngDoCheck()', () => { | ||||||
|     const contents = ` |     let componentInstances: ComponentRef<any>[]; | ||||||
|       <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; |  | ||||||
| 
 | 
 | ||||||
|     beforeEach(() => { |     beforeEach(() => { | ||||||
|       titleService = TestBed.get(Title); |       componentInstances = [ | ||||||
|  |         {changeDetectorRef: {detectChanges: jasmine.createSpy('detectChanges')}}, | ||||||
|  |         {changeDetectorRef: {detectChanges: jasmine.createSpy('detectChanges')}}, | ||||||
|  |         {changeDetectorRef: {detectChanges: jasmine.createSpy('detectChanges')}}, | ||||||
|  |       ] as any; | ||||||
|  |       docViewer.embeddedComponentRefs.push(...componentInstances); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should set the default empty title when no <h1>', () => { |     afterEach(() => { | ||||||
|       setCurrentDoc('Some content'); |       // Clean up the fake component instances, to avoid error in `ngOnDestroy()`.
 | ||||||
|       fixture.detectChanges(); |       docViewer.embeddedComponentRefs = []; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should detect changes on each active component instance', () => { | ||||||
|  |       parentFixture.detectChanges(); | ||||||
|  |       componentInstances.forEach(({changeDetectorRef: cd}) => { | ||||||
|  |         expect(cd.detectChanges).toHaveBeenCalledTimes(1); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       parentFixture.detectChanges(); | ||||||
|  |       componentInstances.forEach(({changeDetectorRef: cd}) => { | ||||||
|  |         expect(cd.detectChanges).toHaveBeenCalledTimes(2); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('#ngOnDestroy()', () => { | ||||||
|  |     it('should destroy the active embedded component instances', () => { | ||||||
|  |       const destroyEmbeddedComponentsSpy = spyOn(docViewer, 'destroyEmbeddedComponents'); | ||||||
|  |       docViewer.ngOnDestroy(); | ||||||
|  | 
 | ||||||
|  |       expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should stop responding to document changes', () => { | ||||||
|  |       const destroyEmbeddedComponentsSpy = spyOn(docViewer, 'destroyEmbeddedComponents'); | ||||||
|  |       const renderSpy = spyOn(docViewer, 'render').and.returnValue([undefined]); | ||||||
|  |       const onDocRenderedSpy = jasmine.createSpy('onDocRendered'); | ||||||
|  |       docViewer.docRendered.subscribe(onDocRenderedSpy); | ||||||
|  | 
 | ||||||
|  |       expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled(); | ||||||
|  |       expect(renderSpy).not.toHaveBeenCalled(); | ||||||
|  |       expect(onDocRenderedSpy).not.toHaveBeenCalled(); | ||||||
|  | 
 | ||||||
|  |       docViewer.doc = {contents: 'Some content', id: 'some-id'}; | ||||||
|  |       expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1); | ||||||
|  |       expect(renderSpy).toHaveBeenCalledTimes(1); | ||||||
|  |       expect(onDocRenderedSpy).toHaveBeenCalledTimes(1); | ||||||
|  | 
 | ||||||
|  |       docViewer.ngOnDestroy();  // Also calls `destroyEmbeddedComponents()`.
 | ||||||
|  | 
 | ||||||
|  |       docViewer.doc = {contents: 'Other content', id: 'other-id'}; | ||||||
|  |       expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(2); | ||||||
|  |       expect(renderSpy).toHaveBeenCalledTimes(1); | ||||||
|  |       expect(onDocRenderedSpy).toHaveBeenCalledTimes(1); | ||||||
|  | 
 | ||||||
|  |       docViewer.doc = {contents: 'More content', id: 'more-id'}; | ||||||
|  |       expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(2); | ||||||
|  |       expect(renderSpy).toHaveBeenCalledTimes(1); | ||||||
|  |       expect(onDocRenderedSpy).toHaveBeenCalledTimes(1); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   describe('#addTitleAndToc()', () => { | ||||||
|  |     const EMPTY_DOC = ''; | ||||||
|  |     const DOC_WITHOUT_H1 = 'Some content'; | ||||||
|  |     const DOC_WITH_H1 = '<h1>Features</h1>Some content'; | ||||||
|  |     const DOC_WITH_NO_TOC_H1 = '<h1 class="no-toc">Features</h1>Some content'; | ||||||
|  |     const DOC_WITH_HIDDEN_H1_CONTENT = '<h1><i style="visibility: hidden">link</i>Features</h1>Some content'; | ||||||
|  | 
 | ||||||
|  |     const tryDoc = (contents: string, docId = '') => { | ||||||
|  |       docViewerEl.innerHTML = contents; | ||||||
|  |       docViewer.addTitleAndToc(docId); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     describe('(title)', () => { | ||||||
|  |       let titleService: MockTitle; | ||||||
|  | 
 | ||||||
|  |       beforeEach(() => titleService = TestBed.get(Title)); | ||||||
|  | 
 | ||||||
|  |       it('should set the title if there is an `<h1>` heading', () => { | ||||||
|  |         tryDoc(DOC_WITH_H1); | ||||||
|  |         expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('should set the title if there is a `.no-toc` `<h1>` heading', () => { | ||||||
|  |         tryDoc(DOC_WITH_NO_TOC_H1); | ||||||
|  |         expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('should set the default title if there is no `<h1>` heading', () => { | ||||||
|  |         tryDoc(DOC_WITHOUT_H1); | ||||||
|  |         expect(titleService.setTitle).toHaveBeenCalledWith('Angular'); | ||||||
|  | 
 | ||||||
|  |         tryDoc(EMPTY_DOC); | ||||||
|         expect(titleService.setTitle).toHaveBeenCalledWith('Angular'); |         expect(titleService.setTitle).toHaveBeenCalledWith('Angular'); | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|     it('should set the expected title when has <h1>', () => { |       it('should not include hidden content of the `<h1>` heading in the title', () => { | ||||||
|       setCurrentDoc('<h1>Features</h1>Some content'); |         tryDoc(DOC_WITH_HIDDEN_H1_CONTENT); | ||||||
|       fixture.detectChanges(); |  | ||||||
|       expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     it('should set the expected title with a no-toc <h1>', () => { |  | ||||||
|       setCurrentDoc('<h1 class="no-toc">Features</h1>Some content'); |  | ||||||
|       fixture.detectChanges(); |  | ||||||
|       expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     it('should not include hidden content of the <h1> in the title', () => { |  | ||||||
|       setCurrentDoc('<h1><i style="visibility: hidden">link</i>Features</h1>Some content'); |  | ||||||
|       fixture.detectChanges(); |  | ||||||
|         expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); |         expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features'); | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
| @ -350,15 +232,14 @@ describe('DocViewerComponent', () => { | |||||||
|         const querySelector_ = docViewerEl.querySelector; |         const querySelector_ = docViewerEl.querySelector; | ||||||
|         spyOn(docViewerEl, 'querySelector').and.callFake((selector: string) => { |         spyOn(docViewerEl, 'querySelector').and.callFake((selector: string) => { | ||||||
|           const elem = querySelector_.call(docViewerEl, selector); |           const elem = querySelector_.call(docViewerEl, selector); | ||||||
|         Object.defineProperties(elem, { |           return Object.defineProperties(elem, { | ||||||
|             innerText: {value: undefined}, |             innerText: {value: undefined}, | ||||||
|           textContent: { value: 'Text Content' } |             textContent: {value: 'Text Content'}, | ||||||
|           }); |           }); | ||||||
|         return elem; |  | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|       setCurrentDoc('<h1><i style="visibility: hidden">link</i>Features</h1>Some content'); |         tryDoc(DOC_WITH_HIDDEN_H1_CONTENT); | ||||||
|       fixture.detectChanges(); | 
 | ||||||
|         expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Text Content'); |         expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Text Content'); | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
| @ -366,71 +247,238 @@ describe('DocViewerComponent', () => { | |||||||
|         const querySelector_ = docViewerEl.querySelector; |         const querySelector_ = docViewerEl.querySelector; | ||||||
|         spyOn(docViewerEl, 'querySelector').and.callFake((selector: string) => { |         spyOn(docViewerEl, 'querySelector').and.callFake((selector: string) => { | ||||||
|           const elem = querySelector_.call(docViewerEl, selector); |           const elem = querySelector_.call(docViewerEl, selector); | ||||||
|         Object.defineProperties(elem, { |           return Object.defineProperties(elem, { | ||||||
|             innerText: { value: '' }, |             innerText: { value: '' }, | ||||||
|             textContent: { value: 'Text Content' } |             textContent: { value: 'Text Content' } | ||||||
|           }); |           }); | ||||||
|         return elem; |  | ||||||
|         }); |         }); | ||||||
| 
 | 
 | ||||||
|       setCurrentDoc('<h1><i style="visibility: hidden">link</i></h1>Some content'); |         tryDoc(DOC_WITH_HIDDEN_H1_CONTENT); | ||||||
|       fixture.detectChanges(); | 
 | ||||||
|         expect(titleService.setTitle).toHaveBeenCalledWith('Angular'); |         expect(titleService.setTitle).toHaveBeenCalledWith('Angular'); | ||||||
|       }); |       }); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|   describe('TOC', () => { |     describe('(ToC)', () => { | ||||||
|     let tocService: TestTocService; |       let tocService: MockTocService; | ||||||
| 
 | 
 | ||||||
|     function getAioToc(): HTMLElement { |       const getTocEl = () => docViewerEl.querySelector('aio-toc'); | ||||||
|       return fixture.debugElement.nativeElement.querySelector('aio-toc'); |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     beforeEach(() => { |       beforeEach(() => tocService = TestBed.get(TocService)); | ||||||
|       tocService = TestBed.get(TocService); | 
 | ||||||
|  |       it('should have an (embedded) ToC if there is an `<h1>` heading', () => { | ||||||
|  |         tryDoc(DOC_WITH_H1, 'foo'); | ||||||
|  |         const tocEl = getTocEl()!; | ||||||
|  | 
 | ||||||
|  |         expect(tocEl).toBeTruthy(); | ||||||
|  |         expect(tocEl.classList.contains('embedded')).toBe(true); | ||||||
|  |         expect(tocService.genToc).toHaveBeenCalledTimes(1); | ||||||
|  |         expect(tocService.genToc).toHaveBeenCalledWith(docViewerEl, 'foo'); | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|     describe('if no <h1> title', () => { |       it('should have no ToC if there is a `.no-toc` `<h1>` heading', () => { | ||||||
|       beforeEach(() => { |         tryDoc(DOC_WITH_NO_TOC_H1); | ||||||
|         setCurrentDoc('Some content'); | 
 | ||||||
|         fixture.detectChanges(); |         expect(getTocEl()).toBeFalsy(); | ||||||
|  |         expect(tocService.genToc).not.toHaveBeenCalled(); | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|       it('should not have an <aio-toc>', () => { |       it('should have no ToC if there is no `<h1>` heading', () => { | ||||||
|         expect(getAioToc()).toBeFalsy(); |         tryDoc(DOC_WITHOUT_H1); | ||||||
|  |         expect(getTocEl()).toBeFalsy(); | ||||||
|  | 
 | ||||||
|  |         tryDoc(EMPTY_DOC); | ||||||
|  |         expect(getTocEl()).toBeFalsy(); | ||||||
|  | 
 | ||||||
|  |         expect(tocService.genToc).not.toHaveBeenCalled(); | ||||||
|       }); |       }); | ||||||
| 
 | 
 | ||||||
|       it('should reset Toc Service', () => { |       it('should always reset the ToC (before generating the new one)', () => { | ||||||
|         expect(tocService.reset).toHaveBeenCalled(); |         expect(tocService.reset).not.toHaveBeenCalled(); | ||||||
|       }); |         expect(tocService.genToc).not.toHaveBeenCalled(); | ||||||
| 
 | 
 | ||||||
|       it('should not call Toc Service genToc()', () => { |         tocService.genToc.calls.reset(); | ||||||
|  |         tryDoc(DOC_WITH_H1, 'foo'); | ||||||
|  |         expect(tocService.reset).toHaveBeenCalledTimes(1); | ||||||
|  |         expect(tocService.reset).toHaveBeenCalledBefore(tocService.genToc); | ||||||
|  |         expect(tocService.genToc).toHaveBeenCalledWith(docViewerEl, 'foo'); | ||||||
|  | 
 | ||||||
|  |         tocService.genToc.calls.reset(); | ||||||
|  |         tryDoc(DOC_WITH_NO_TOC_H1, 'bar'); | ||||||
|  |         expect(tocService.reset).toHaveBeenCalledTimes(2); | ||||||
|  |         expect(tocService.genToc).not.toHaveBeenCalled(); | ||||||
|  | 
 | ||||||
|  |         tocService.genToc.calls.reset(); | ||||||
|  |         tryDoc(DOC_WITHOUT_H1, 'baz'); | ||||||
|  |         expect(tocService.reset).toHaveBeenCalledTimes(3); | ||||||
|  |         expect(tocService.genToc).not.toHaveBeenCalled(); | ||||||
|  | 
 | ||||||
|  |         tocService.genToc.calls.reset(); | ||||||
|  |         tryDoc(EMPTY_DOC, 'qux'); | ||||||
|  |         expect(tocService.reset).toHaveBeenCalledTimes(4); | ||||||
|         expect(tocService.genToc).not.toHaveBeenCalled(); |         expect(tocService.genToc).not.toHaveBeenCalled(); | ||||||
|       }); |       }); | ||||||
|     }); |     }); | ||||||
| 
 |  | ||||||
|     it('should not have an <aio-toc> with a no-toc <h1>', () => { |  | ||||||
|       setCurrentDoc('<h1 class="no-toc">Features</h1>Some content'); |  | ||||||
|       fixture.detectChanges(); |  | ||||||
|       expect(getAioToc()).toBeFalsy(); |  | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|     describe('when has an <h1> (title)', () => { |   describe('#destroyEmbeddedComponents()', () => { | ||||||
|  |     let componentInstances: ComponentRef<any>[]; | ||||||
|  | 
 | ||||||
|     beforeEach(() => { |     beforeEach(() => { | ||||||
|         setCurrentDoc('<h1>Features</h1>Some content'); |       componentInstances = [ | ||||||
|         fixture.detectChanges(); |         {destroy: jasmine.createSpy('destroy#1')}, | ||||||
|  |         {destroy: jasmine.createSpy('destroy#2')}, | ||||||
|  |         {destroy: jasmine.createSpy('destroy#3')}, | ||||||
|  |       ] as any; | ||||||
|  |       docViewer.embeddedComponentRefs.push(...componentInstances); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|       it('should add <aio-toc>', () => { |     it('should destroy each active component instance', () => { | ||||||
|         expect(getAioToc()).toBeTruthy(); |       docViewer.destroyEmbeddedComponents(); | ||||||
|  | 
 | ||||||
|  |       expect(componentInstances.length).toBe(3); | ||||||
|  |       componentInstances.forEach(comp => expect(comp.destroy).toHaveBeenCalledTimes(1)); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|       it('should have <aio-toc> with "embedded" class', () => { |     it('should clear the list of active component instances', () => { | ||||||
|         expect(getAioToc().classList.contains('embedded')).toEqual(true); |       expect(docViewer.embeddedComponentRefs.length).toBeGreaterThan(0); | ||||||
|  | 
 | ||||||
|  |       docViewer.destroyEmbeddedComponents(); | ||||||
|  |       expect(docViewer.embeddedComponentRefs.length).toBe(0); | ||||||
|  |     }); | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|       it('should call Toc Service genToc()', () => { |   describe('#render()', () => { | ||||||
|         expect(tocService.genToc).toHaveBeenCalled(); |     let addTitleAndTocSpy: jasmine.Spy; | ||||||
|  |     let embedIntoSpy: jasmine.Spy; | ||||||
|  | 
 | ||||||
|  |     const doRender = (contents: string | null, id = 'foo') => | ||||||
|  |       new Promise<void>((resolve, reject) => | ||||||
|  |         docViewer.render({contents, id}).subscribe(resolve, reject)); | ||||||
|  | 
 | ||||||
|  |     beforeEach(() => { | ||||||
|  |       const embedComponentsService = TestBed.get(EmbedComponentsService) as MockEmbedComponentsService; | ||||||
|  | 
 | ||||||
|  |       addTitleAndTocSpy = spyOn(docViewer, 'addTitleAndToc'); | ||||||
|  |       embedIntoSpy = embedComponentsService.embedInto.and.returnValue(of([])); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it('should return an `Observable`', () => { | ||||||
|  |       expect(docViewer.render({contents: '', id: ''})).toEqual(jasmine.any(Observable)); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('(contents, title, ToC)', () => { | ||||||
|  |       it('should display the document contents', async () => { | ||||||
|  |         const contents = '<h1>Hello,</h1> <div>world!</div>'; | ||||||
|  |         await doRender(contents); | ||||||
|  | 
 | ||||||
|  |         expect(docViewerEl.innerHTML).toBe(contents); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('should display nothing if the document has no contents', async () => { | ||||||
|  |         docViewerEl.innerHTML = 'Test'; | ||||||
|  |         await doRender(''); | ||||||
|  |         expect(docViewerEl.innerHTML).toBe(''); | ||||||
|  | 
 | ||||||
|  |         docViewerEl.innerHTML = 'Test'; | ||||||
|  |         await doRender(null); | ||||||
|  |         expect(docViewerEl.innerHTML).toBe(''); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('should set the title and ToC (after the content has been set)', async () => { | ||||||
|  |         addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.innerHTML).toBe('Foo content')); | ||||||
|  |         await doRender('Foo content', 'foo'); | ||||||
|  |         expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1); | ||||||
|  |         expect(addTitleAndTocSpy).toHaveBeenCalledWith('foo'); | ||||||
|  | 
 | ||||||
|  |         addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.innerHTML).toBe('Bar content')); | ||||||
|  |         await doRender('Bar content', 'bar'); | ||||||
|  |         expect(addTitleAndTocSpy).toHaveBeenCalledTimes(2); | ||||||
|  |         expect(addTitleAndTocSpy).toHaveBeenCalledWith('bar'); | ||||||
|  | 
 | ||||||
|  |         addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.innerHTML).toBe('')); | ||||||
|  |         await doRender('', 'baz'); | ||||||
|  |         expect(addTitleAndTocSpy).toHaveBeenCalledTimes(3); | ||||||
|  |         expect(addTitleAndTocSpy).toHaveBeenCalledWith('baz'); | ||||||
|  | 
 | ||||||
|  |         addTitleAndTocSpy.and.callFake(() => expect(docViewerEl.innerHTML).toBe('Qux content')); | ||||||
|  |         await doRender('Qux content', 'qux'); | ||||||
|  |         expect(addTitleAndTocSpy).toHaveBeenCalledTimes(4); | ||||||
|  |         expect(addTitleAndTocSpy).toHaveBeenCalledWith('qux'); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('(embedding components)', () => { | ||||||
|  |       it('should embed components', async () => { | ||||||
|  |         await doRender('Some content'); | ||||||
|  |         expect(embedIntoSpy).toHaveBeenCalledTimes(1); | ||||||
|  |         expect(embedIntoSpy).toHaveBeenCalledWith(docViewerEl); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('should attempt to embed components even if the document is empty', async () => { | ||||||
|  |         await doRender(''); | ||||||
|  |         await doRender(null); | ||||||
|  | 
 | ||||||
|  |         expect(embedIntoSpy).toHaveBeenCalledTimes(2); | ||||||
|  |         expect(embedIntoSpy.calls.argsFor(0)).toEqual([docViewerEl]); | ||||||
|  |         expect(embedIntoSpy.calls.argsFor(1)).toEqual([docViewerEl]); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('should store the embedded components', async () => { | ||||||
|  |         const embeddedComponents = []; | ||||||
|  |         embedIntoSpy.and.returnValue(of(embeddedComponents)); | ||||||
|  | 
 | ||||||
|  |         await doRender('Some content'); | ||||||
|  | 
 | ||||||
|  |         expect(docViewer.embeddedComponentRefs).toBe(embeddedComponents); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('should unsubscribe from the previous "embed" observable when unsubscribed from', () => { | ||||||
|  |         const obs = new ObservableWithSubscriptionSpies(); | ||||||
|  |         embedIntoSpy.and.returnValue(obs); | ||||||
|  | 
 | ||||||
|  |         const renderObservable = docViewer.render({contents: 'Some content', id: 'foo'}); | ||||||
|  |         const subscription = renderObservable.subscribe(); | ||||||
|  | 
 | ||||||
|  |         expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); | ||||||
|  |         expect(obs.unsubscribeSpies[0]).not.toHaveBeenCalled(); | ||||||
|  | 
 | ||||||
|  |         subscription.unsubscribe(); | ||||||
|  | 
 | ||||||
|  |         expect(obs.subscribeSpy).toHaveBeenCalledTimes(1); | ||||||
|  |         expect(obs.unsubscribeSpies[0]).toHaveBeenCalledTimes(1); | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     describe('(on error) should log the error and recover', () => { | ||||||
|  |       let logger: MockLogger; | ||||||
|  | 
 | ||||||
|  |       beforeEach(() => logger = TestBed.get(Logger)); | ||||||
|  | 
 | ||||||
|  |       it('when `addTitleAndToc()` fails', async () => { | ||||||
|  |         const error = Error('Typical `addTitleAndToc()` error'); | ||||||
|  |         addTitleAndTocSpy.and.callFake(() => { throw error; }); | ||||||
|  | 
 | ||||||
|  |         await doRender('Some content', 'foo'); | ||||||
|  | 
 | ||||||
|  |         expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1); | ||||||
|  |         expect(embedIntoSpy).not.toHaveBeenCalled(); | ||||||
|  |         expect(logger.output.error).toEqual([ | ||||||
|  |           ['[DocViewer]: Error preparing document \'foo\'.', error], | ||||||
|  |         ]); | ||||||
|  |       }); | ||||||
|  | 
 | ||||||
|  |       it('when `EmbedComponentsService#embedInto()` fails', async () => { | ||||||
|  |         const error = Error('Typical `embedInto()` error'); | ||||||
|  |         embedIntoSpy.and.callFake(() => { throw error; }); | ||||||
|  | 
 | ||||||
|  |         await doRender('Some content', 'bar'); | ||||||
|  | 
 | ||||||
|  |         expect(addTitleAndTocSpy).toHaveBeenCalledTimes(1); | ||||||
|  |         expect(embedIntoSpy).toHaveBeenCalledTimes(1); | ||||||
|  |         expect(logger.output.error).toEqual([ | ||||||
|  |           ['[DocViewer]: Error preparing document \'bar\'.', error], | ||||||
|  |         ]); | ||||||
|       }); |       }); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
|  | |||||||
| @ -1,18 +1,18 @@ | |||||||
| import { | import { Component, ComponentRef, DoCheck, ElementRef, EventEmitter, Input, OnDestroy, Output } from '@angular/core'; | ||||||
|   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 { Title } from '@angular/platform-browser'; | 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'; | import { TocService } from 'app/shared/toc.service'; | ||||||
| 
 | 
 | ||||||
| interface EmbeddedComponentFactory { |  | ||||||
|   contentPropertyName: string; |  | ||||||
|   factory: ComponentFactory<any>; |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| // Initialization prevents flicker once pre-rendering is on
 | // Initialization prevents flicker once pre-rendering is on
 | ||||||
| const initialDocViewerElement = document.querySelector('aio-doc-viewer'); | const initialDocViewerElement = document.querySelector('aio-doc-viewer'); | ||||||
| @ -26,18 +26,30 @@ const initialDocViewerContent = initialDocViewerElement ? initialDocViewerElemen | |||||||
| }) | }) | ||||||
| export class DocViewerComponent implements DoCheck, OnDestroy { | export class DocViewerComponent implements DoCheck, OnDestroy { | ||||||
| 
 | 
 | ||||||
|   private embeddedComponents: ComponentRef<any>[] = []; |  | ||||||
|   private embeddedComponentFactories: Map<string, EmbeddedComponentFactory> = new Map(); |  | ||||||
|   private hostElement: HTMLElement; |   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() |   @Output() | ||||||
|   docRendered = new EventEmitter(); |   docRendered = new EventEmitter<void>(); | ||||||
| 
 | 
 | ||||||
|   constructor( |   constructor( | ||||||
|     componentFactoryResolver: ComponentFactoryResolver, |  | ||||||
|     elementRef: ElementRef, |     elementRef: ElementRef, | ||||||
|     embeddedComponents: EmbeddedComponents, |     private embedComponentsService: EmbedComponentsService, | ||||||
|     private injector: Injector, |     private logger: Logger, | ||||||
|     private titleService: Title, |     private titleService: Title, | ||||||
|     private tocService: TocService |     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
 |     // Security: the initialDocViewerContent comes from the prerendered DOM and is considered to be secure
 | ||||||
|     this.hostElement.innerHTML = initialDocViewerContent; |     this.hostElement.innerHTML = initialDocViewerContent; | ||||||
| 
 | 
 | ||||||
|     for (const component of embeddedComponents.components) { |     this.onDestroy$.subscribe(() => this.destroyEmbeddedComponents()); | ||||||
|       const factory = componentFactoryResolver.resolveComponentFactory(component); |     this.docContents$ | ||||||
|       const selector = factory.selector; |         .do(() => this.destroyEmbeddedComponents()) | ||||||
|       const contentPropertyName = this.selectorToContentPropertyName(selector); |         .switchMap(newDoc => this.render(newDoc)) | ||||||
|       this.embeddedComponentFactories.set(selector, { contentPropertyName, factory }); |         .do(() => this.docRendered.emit()) | ||||||
|     } |         .takeUntil(this.onDestroy$) | ||||||
|  |         .subscribe(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @Input() |   ngDoCheck() { | ||||||
|   set doc(newDoc: DocumentContents) { |     this.embeddedComponentRefs.forEach(comp => comp.changeDetectorRef.detectChanges()); | ||||||
|     this.ngOnDestroy(); |  | ||||||
|     if (newDoc) { |  | ||||||
|       this.build(newDoc); |  | ||||||
|       this.docRendered.emit(); |  | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   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) { |   protected addTitleAndToc(docId: string): void { | ||||||
| 
 |  | ||||||
|     // 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) { |  | ||||||
|     this.tocService.reset(); |     this.tocService.reset(); | ||||||
|     const titleEl = this.hostElement.querySelector('h1'); |     const titleEl = this.hostElement.querySelector('h1'); | ||||||
|     let title = ''; |     let title = ''; | ||||||
| @ -108,21 +95,31 @@ export class DocViewerComponent implements DoCheck, OnDestroy { | |||||||
|     this.titleService.setTitle(title ? `Angular - ${title}` : 'Angular'); |     this.titleService.setTitle(title ? `Angular - ${title}` : 'Angular'); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   ngDoCheck() { |   /** | ||||||
|     this.embeddedComponents.forEach(comp => comp.changeDetectorRef.detectChanges()); |    * Destroy the embedded components to avoid memory leaks. | ||||||
|   } |    */ | ||||||
| 
 |   protected destroyEmbeddedComponents(): void { | ||||||
|   ngOnDestroy() { |     this.embeddedComponentRefs.forEach(comp => comp.destroy()); | ||||||
|     // destroy these components else there will be memory leaks
 |     this.embeddedComponentRefs = []; | ||||||
|     this.embeddedComponents.forEach(comp => comp.destroy()); |  | ||||||
|     this.embeddedComponents.length = 0; |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|    * Compute the component content property name by converting the selector to camelCase and appending |    * Add doc content to host element and build it out with embedded components. | ||||||
|    * 'Content', e.g. live-example => liveExampleContent |  | ||||||
|    */ |    */ | ||||||
|   private selectorToContentPropertyName(selector: string) { |   protected render(doc: DocumentContents): Observable<void> { | ||||||
|     return selector.replace(/-(.)/g, (match, $1) => $1.toUpperCase()) + 'Content'; |     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 { InjectionToken, Inject, Injectable } from '@angular/core'; | ||||||
| import { of } from 'rxjs/observable/of'; | import { of } from 'rxjs/observable/of'; | ||||||
| import { MatIconRegistry } from '@angular/material'; | import { MatIconRegistry } from '@angular/material/icon'; | ||||||
| import { HttpClient } from '@angular/common/http'; | import { HttpClient } from '@angular/common/http'; | ||||||
| import { DomSanitizer } from '@angular/platform-browser'; | 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