diff --git a/aio/package.json b/aio/package.json index 9be842003c..82b62c1ce7 100644 --- a/aio/package.json +++ b/aio/package.json @@ -75,6 +75,7 @@ "@angular/common": "^5.2.0", "@angular/compiler": "^5.2.0", "@angular/core": "^5.2.0", + "@angular/elements": "file:../dist/packages-dist/elements", "@angular/forms": "^5.2.0", "@angular/http": "^5.2.0", "@angular/material": "^5.0.0-rc.1", @@ -83,6 +84,7 @@ "@angular/platform-server": "^5.2.0", "@angular/router": "^5.2.0", "@angular/service-worker": "^1.0.0-beta.16", + "@webcomponents/custom-elements": "^1.0.8", "classlist.js": "^1.1.20150312", "core-js": "^2.4.1", "jasmine": "^2.6.0", diff --git a/aio/src/app/app.component.spec.ts b/aio/src/app/app.component.spec.ts index bc77fed2bd..7b7e32aca5 100644 --- a/aio/src/app/app.component.spec.ts +++ b/aio/src/app/app.component.spec.ts @@ -7,7 +7,6 @@ import { MatProgressBar, MatSidenav } from '@angular/material'; import { By } from '@angular/platform-browser'; import { Observable } from 'rxjs/Observable'; -import { of } from 'rxjs/observable/of'; import { timer } from 'rxjs/observable/timer'; import 'rxjs/add/operator/mapTo'; @@ -16,7 +15,6 @@ import { AppModule } from './app.module'; import { DocumentService } from 'app/documents/document.service'; import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component'; import { Deployment } from 'app/shared/deployment.service'; -import { EmbedComponentsService } from 'app/embed-components/embed-components.service'; import { GaService } from 'app/shared/ga.service'; import { LocationService } from 'app/shared/location.service'; import { Logger } from 'app/shared/logger.service'; @@ -1280,7 +1278,6 @@ function createTestingModule(initialUrl: string, mode: string = 'stable') { imports: [ AppModule ], providers: [ { provide: APP_BASE_HREF, useValue: '/' }, - { provide: EmbedComponentsService, useClass: TestEmbedComponentsService }, { provide: GaService, useClass: TestGaService }, { provide: HttpClient, useClass: TestHttpClient }, { provide: LocationService, useFactory: () => mockLocationService }, @@ -1295,10 +1292,6 @@ function createTestingModule(initialUrl: string, mode: string = 'stable') { }); } -class TestEmbedComponentsService { - embedInto = jasmine.createSpy('embedInto').and.returnValue(of([])); -} - class TestGaService { locationChanged = jasmine.createSpy('locationChanged'); } diff --git a/aio/src/app/app.module.spec.ts b/aio/src/app/app.module.spec.ts index 749eb4ef51..e69de29bb2 100644 --- a/aio/src/app/app.module.spec.ts +++ b/aio/src/app/app.module.spec.ts @@ -1,50 +0,0 @@ -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 eagerConfig = Object.keys(componentsMap).filter(selector => Array.isArray(componentsMap[selector])); - expect(eagerConfig.length).toBeGreaterThan(0); - - const eagerSelectors = eagerConfig.reduce((selectors, config) => selectors.concat(config.split(',')), []); - expect(eagerSelectors.length).toBeGreaterThan(0); - - // For example... - expect(eagerSelectors).toContain('aio-toc'); - expect(eagerSelectors).toContain('aio-announcement-bar'); - }); - - it('should provide a list of lazy-loaded embedded components', () => { - const lazySelector = Object.keys(componentsMap).find(selector => selector.includes('code-example'))!; - const selectorCount = lazySelector.split(',').length; - - expect(lazySelector).not.toBeNull(); - expect(selectorCount).toBe(embeddedComponents.length); - - // For example... - expect(lazySelector).toContain('code-example'); - expect(lazySelector).toContain('code-tabs'); - expect(lazySelector).toContain('live-example'); - }); -}); diff --git a/aio/src/app/app.module.ts b/aio/src/app/app.module.ts index 3026ff534a..78fe5b905e 100644 --- a/aio/src/app/app.module.ts +++ b/aio/src/app/app.module.ts @@ -11,12 +11,7 @@ import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSidenavModule } from '@angular/material/sidenav'; import { MatToolbarModule } from '@angular/material/toolbar'; -import { ROUTES } from '@angular/router'; - - -import { AnnouncementBarComponent } from 'app/embedded/announcement-bar/announcement-bar.component'; import { AppComponent } from 'app/app.component'; -import { EMBEDDED_COMPONENTS, EmbeddedComponentsMap } from 'app/embed-components/embed-components.service'; import { CustomIconRegistry, SVG_ICONS } from 'app/shared/custom-icon-registry'; import { Deployment } from 'app/shared/deployment.service'; import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component'; @@ -42,14 +37,10 @@ import { TocService } from 'app/shared/toc.service'; import { CurrentDateToken, currentDateProvider } from 'app/shared/current-date'; import { WindowToken, windowProvider } from 'app/shared/window'; -import { EmbedComponentsModule } from 'app/embed-components/embed-components.module'; +import { CustomElementsModule } from 'app/custom-elements/custom-elements.module'; import { SharedModule } from 'app/shared/shared.module'; import { SwUpdatesModule } from 'app/sw-updates/sw-updates.module'; - -// The path to the `EmbeddedModule`. -const embeddedModulePath = 'app/embedded/embedded.module#EmbeddedModule'; - // These are the hardcoded inline svg sources to be used by the `` component export const svgIconProviders = [ { @@ -100,7 +91,7 @@ export const svgIconProviders = [ imports: [ BrowserModule, BrowserAnimationsModule, - EmbedComponentsModule, + CustomElementsModule, HttpClientModule, MatButtonModule, MatIconModule, @@ -108,10 +99,9 @@ export const svgIconProviders = [ MatSidenavModule, MatToolbarModule, SwUpdatesModule, - SharedModule + SharedModule, ], declarations: [ - AnnouncementBarComponent, AppComponent, DocViewerComponent, DtComponent, @@ -142,27 +132,8 @@ export const svgIconProviders = [ TocService, { provide: CurrentDateToken, useFactory: currentDateProvider }, { provide: WindowToken, useFactory: windowProvider }, - - { - provide: EMBEDDED_COMPONENTS, - useValue: { - /* tslint:disable: max-line-length */ - 'aio-announcement-bar': [AnnouncementBarComponent], - '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: [ AnnouncementBarComponent, TocComponent ], + entryComponents: [ TocComponent ], bootstrap: [ AppComponent ] }) -export class AppModule { -} +export class AppModule { } diff --git a/aio/src/app/embedded/announcement-bar/announcement-bar.component.spec.ts b/aio/src/app/custom-elements/announcement-bar/announcement-bar.component.spec.ts similarity index 90% rename from aio/src/app/embedded/announcement-bar/announcement-bar.component.spec.ts rename to aio/src/app/custom-elements/announcement-bar/announcement-bar.component.spec.ts index 068f792ad3..009c720c15 100644 --- a/aio/src/app/embedded/announcement-bar/announcement-bar.component.spec.ts +++ b/aio/src/app/custom-elements/announcement-bar/announcement-bar.component.spec.ts @@ -66,10 +66,7 @@ describe('AnnouncementBarComponent', () => { const request = httpMock.expectOne('generated/announcements.json'); request.flush('some random response'); expect(component.announcement).toBeUndefined(); - expect(mockLogger.output.error).toEqual([ - [jasmine.any(Error)] - ]); - expect(mockLogger.output.error[0][0].message).toMatch(/^generated\/announcements\.json contains invalid data:/); + expect(mockLogger.output.error[0][0]).toContain('generated/announcements.json contains invalid data:'); }); it('should handle a failed request for `announcements.json`', () => { @@ -77,10 +74,7 @@ describe('AnnouncementBarComponent', () => { const request = httpMock.expectOne('generated/announcements.json'); request.error(new ErrorEvent('404')); expect(component.announcement).toBeUndefined(); - expect(mockLogger.output.error).toEqual([ - [jasmine.any(Error)] - ]); - expect(mockLogger.output.error[0][0].message).toMatch(/^generated\/announcements\.json request failed:/); + expect(mockLogger.output.error[0][0]).toContain('generated/announcements.json request failed:'); }); }); diff --git a/aio/src/app/embedded/announcement-bar/announcement-bar.component.ts b/aio/src/app/custom-elements/announcement-bar/announcement-bar.component.ts similarity index 92% rename from aio/src/app/embedded/announcement-bar/announcement-bar.component.ts rename to aio/src/app/custom-elements/announcement-bar/announcement-bar.component.ts index abd88d0b2d..ebe511873f 100644 --- a/aio/src/app/embedded/announcement-bar/announcement-bar.component.ts +++ b/aio/src/app/custom-elements/announcement-bar/announcement-bar.component.ts @@ -59,12 +59,12 @@ export class AnnouncementBarComponent implements OnInit { ngOnInit() { this.http.get(announcementsPath) .catch(error => { - this.logger.error(new Error(`${announcementsPath} request failed: ${error.message}`)); + this.logger.error(`${announcementsPath} request failed: ${error.message}`); return []; }) .map(announcements => this.findCurrentAnnouncement(announcements)) .catch(error => { - this.logger.error(new Error(`${announcementsPath} contains invalid data: ${error.message}`)); + this.logger.error(`${announcementsPath} contains invalid data: ${error.message}`); return []; }) .subscribe(announcement => this.announcement = announcement); diff --git a/aio/src/app/custom-elements/announcement-bar/announcement-bar.module.ts b/aio/src/app/custom-elements/announcement-bar/announcement-bar.module.ts new file mode 100644 index 0000000000..90c905514d --- /dev/null +++ b/aio/src/app/custom-elements/announcement-bar/announcement-bar.module.ts @@ -0,0 +1,15 @@ +import { NgModule, Type } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { HttpClientModule } from '@angular/common/http'; +import { SharedModule } from '../../shared/shared.module'; +import { AnnouncementBarComponent } from './announcement-bar.component'; +import { WithCustomElementComponent } from '../element-registry'; + +@NgModule({ + imports: [ CommonModule, SharedModule, HttpClientModule ], + declarations: [ AnnouncementBarComponent ], + entryComponents: [ AnnouncementBarComponent ], +}) +export class AnnouncementBarModule implements WithCustomElementComponent { + customElementComponent: Type = AnnouncementBarComponent; +} diff --git a/aio/src/app/embedded/api/api-list.component.html b/aio/src/app/custom-elements/api/api-list.component.html similarity index 100% rename from aio/src/app/embedded/api/api-list.component.html rename to aio/src/app/custom-elements/api/api-list.component.html diff --git a/aio/src/app/embedded/api/api-list.component.spec.ts b/aio/src/app/custom-elements/api/api-list.component.spec.ts similarity index 95% rename from aio/src/app/embedded/api/api-list.component.spec.ts rename to aio/src/app/custom-elements/api/api-list.component.spec.ts index 157867007a..68bc6bee74 100644 --- a/aio/src/app/embedded/api/api-list.component.spec.ts +++ b/aio/src/app/custom-elements/api/api-list.component.spec.ts @@ -4,7 +4,9 @@ import { BehaviorSubject } from 'rxjs/BehaviorSubject'; import { ApiListComponent } from './api-list.component'; import { ApiItem, ApiSection, ApiService } from './api.service'; import { LocationService } from 'app/shared/location.service'; -import { SharedModule } from 'app/shared/shared.module'; +import { Logger } from 'app/shared/logger.service'; +import { MockLogger } from 'testing/logger.service'; +import { ApiListModule } from './api-list.module'; describe('ApiListComponent', () => { let component: ApiListComponent; @@ -13,10 +15,10 @@ describe('ApiListComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ SharedModule ], - declarations: [ ApiListComponent ], + imports: [ ApiListModule ], providers: [ { provide: ApiService, useClass: TestApiService }, + { provide: Logger, useClass: MockLogger }, { provide: LocationService, useClass: TestLocationService } ] }); @@ -37,11 +39,11 @@ describe('ApiListComponent', () => { let badItem: ApiItem|undefined; expect(filtered.length).toBeGreaterThan(0, 'expected something'); expect(filtered.every(section => section.items.every( - item => { - const ok = item.show === itemTest(item); - if (!ok) { badItem = item; } - return ok; - } + item => { + const ok = item.show === itemTest(item); + if (!ok) { badItem = item; } + return ok; + } ))).toBe(true, `${label} fail: ${JSON.stringify(badItem, null, 2)}`); }); } diff --git a/aio/src/app/embedded/api/api-list.component.ts b/aio/src/app/custom-elements/api/api-list.component.ts similarity index 99% rename from aio/src/app/embedded/api/api-list.component.ts rename to aio/src/app/custom-elements/api/api-list.component.ts index 2dbeda2016..cf39591260 100644 --- a/aio/src/app/embedded/api/api-list.component.ts +++ b/aio/src/app/custom-elements/api/api-list.component.ts @@ -25,7 +25,7 @@ class SearchCriteria { @Component({ selector: 'aio-api-list', - templateUrl: './api-list.component.html' + templateUrl: './api-list.component.html', }) export class ApiListComponent implements OnInit { @@ -69,7 +69,6 @@ export class ApiListComponent implements OnInit { private locationService: LocationService) { } ngOnInit() { - this.filteredSections = combineLatest( this.apiService.sections, this.criteriaSubject, diff --git a/aio/src/app/custom-elements/api/api-list.module.ts b/aio/src/app/custom-elements/api/api-list.module.ts new file mode 100644 index 0000000000..b81de1411e --- /dev/null +++ b/aio/src/app/custom-elements/api/api-list.module.ts @@ -0,0 +1,17 @@ +import { NgModule, Type } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { HttpClientModule } from '@angular/common/http'; +import { SharedModule } from '../../shared/shared.module'; +import { ApiListComponent } from './api-list.component'; +import { ApiService } from './api.service'; +import { WithCustomElementComponent } from '../element-registry'; + +@NgModule({ + imports: [ CommonModule, SharedModule, HttpClientModule ], + declarations: [ ApiListComponent ], + entryComponents: [ ApiListComponent ], + providers: [ ApiService ] +}) +export class ApiListModule implements WithCustomElementComponent { + customElementComponent: Type = ApiListComponent; +} diff --git a/aio/src/app/embedded/api/api.service.spec.ts b/aio/src/app/custom-elements/api/api.service.spec.ts similarity index 100% rename from aio/src/app/embedded/api/api.service.spec.ts rename to aio/src/app/custom-elements/api/api.service.spec.ts diff --git a/aio/src/app/embedded/api/api.service.ts b/aio/src/app/custom-elements/api/api.service.ts similarity index 100% rename from aio/src/app/embedded/api/api.service.ts rename to aio/src/app/custom-elements/api/api.service.ts diff --git a/aio/src/app/custom-elements/code/code-example.component.spec.ts b/aio/src/app/custom-elements/code/code-example.component.spec.ts new file mode 100644 index 0000000000..ec97440747 --- /dev/null +++ b/aio/src/app/custom-elements/code/code-example.component.spec.ts @@ -0,0 +1,100 @@ +import { Component, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CodeExampleComponent } from './code-example.component'; +import { CodeExampleModule } from './code-example.module'; +import { Logger } from 'app/shared/logger.service'; +import { MockLogger } from 'testing/logger.service'; + +describe('CodeExampleComponent', () => { + let hostComponent: HostComponent; + let codeExampleComponent: CodeExampleComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ CodeExampleModule ], + declarations: [ + HostComponent, + ], + providers: [ + { provide: Logger, useClass: MockLogger }, + ] + }); + + fixture = TestBed.createComponent(HostComponent); + hostComponent = fixture.componentInstance; + codeExampleComponent = hostComponent.codeExampleComponent; + + fixture.detectChanges(); + }); + + it('should be able to capture the code snippet provided in content', () => { + expect(codeExampleComponent.code.trim()).toBe(`const foo = "bar";`); + }); + + it('should change aio-code classes based on title presence', () => { + expect(codeExampleComponent.title).toBe('Great Example'); + expect(fixture.nativeElement.querySelector('header')).toBeTruthy(); + expect(codeExampleComponent.classes).toEqual({ + 'headed-code': true, + 'simple-code': false + }); + + codeExampleComponent.title = ''; + fixture.detectChanges(); + + expect(codeExampleComponent.title).toBe(''); + expect(fixture.nativeElement.querySelector('header')).toBeFalsy(); + expect(codeExampleComponent.classes).toEqual({ + 'headed-code': false, + 'simple-code': true + }); + }); + + it('should set avoidFile class if path has .avoid.', () => { + const codeExampleComponentElement: HTMLElement = + fixture.nativeElement.querySelector('code-example'); + + expect(codeExampleComponent.path).toBe('code-path'); + expect(codeExampleComponentElement.className.indexOf('avoidFile') === -1).toBe(true); + + codeExampleComponent.path = 'code-path.avoid.'; + fixture.detectChanges(); + + expect(codeExampleComponentElement.className.indexOf('avoidFile') === -1).toBe(false); + }); + + it('should coerce hidecopy', () => { + expect(codeExampleComponent.hidecopy).toBe(false); + + hostComponent.hidecopy = true; + fixture.detectChanges(); + expect(codeExampleComponent.hidecopy).toBe(true); + + hostComponent.hidecopy = 'false'; + fixture.detectChanges(); + expect(codeExampleComponent.hidecopy).toBe(false); + + hostComponent.hidecopy = 'true'; + fixture.detectChanges(); + expect(codeExampleComponent.hidecopy).toBe(true); + }); +}); + +@Component({ + selector: 'aio-host-comp', + template: ` + + {{code}} + + ` +}) +class HostComponent { + code = `const foo = "bar";`; + title = 'Great Example'; + path = 'code-path'; + hidecopy: boolean | string = false; + + @ViewChild(CodeExampleComponent) codeExampleComponent: CodeExampleComponent; +} diff --git a/aio/src/app/custom-elements/code/code-example.component.ts b/aio/src/app/custom-elements/code/code-example.component.ts new file mode 100644 index 0000000000..2e402dd07a --- /dev/null +++ b/aio/src/app/custom-elements/code/code-example.component.ts @@ -0,0 +1,91 @@ +/* tslint:disable component-selector */ +import { Component, HostBinding, ElementRef, ViewChild, Input, AfterViewInit } from '@angular/core'; +import { CodeComponent } from './code.component'; + +/** + * An embeddable code block that displays nicely formatted code. + * Example usage: + * + * ``` + * + * // a code block + * console.log('do stuff'); + * + * ``` + */ +@Component({ + selector: 'code-example', + template: ` + +
+ +
{{title}}
+ + + + `, +}) +export class CodeExampleComponent implements AfterViewInit { + classes: {}; + + code: string; + + @Input() language: string; + + @Input() linenums: string; + + @Input() region: string; + + @Input() + set title(title: string) { + this._title = title; + this.classes = { + 'headed-code': !!this.title, + 'simple-code': !this.title, + }; + } + get title(): string { return this._title; } + private _title: string; + + @Input() + set path(path: string) { + this._path = path; + this.isAvoid = this.path.indexOf('.avoid.') !== -1; + } + get path(): string { return this._path; } + private _path = ''; + + @Input() + set hidecopy(hidecopy: boolean) { + // Coerce the boolean value. + this._hidecopy = hidecopy != null && `${hidecopy}` !== 'false'; + } + get hidecopy(): boolean { return this._hidecopy; } + private _hidecopy: boolean; + + @Input('hide-copy') + set hyphenatedHideCopy(hidecopy: boolean) { + this.hidecopy = hidecopy; + } + + @Input('hideCopy') + set capitalizedHideCopy(hidecopy: boolean) { + this.hidecopy = hidecopy; + } + + @HostBinding('class.avoidFile') isAvoid = false; + + @ViewChild('content') content: ElementRef; + + @ViewChild(CodeComponent) aioCode: CodeComponent; + + ngAfterViewInit() { + this.aioCode.code = this.content.nativeElement.innerHTML; + } +} diff --git a/aio/src/app/custom-elements/code/code-example.module.ts b/aio/src/app/custom-elements/code/code-example.module.ts new file mode 100644 index 0000000000..54e04cf670 --- /dev/null +++ b/aio/src/app/custom-elements/code/code-example.module.ts @@ -0,0 +1,15 @@ +import { NgModule, Type } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { CodeExampleComponent } from './code-example.component'; +import { CodeModule } from './code.module'; +import { WithCustomElementComponent } from '../element-registry'; + +@NgModule({ + imports: [ CommonModule, CodeModule ], + declarations: [ CodeExampleComponent ], + exports: [ CodeExampleComponent ], + entryComponents: [ CodeExampleComponent ] +}) +export class CodeExampleModule implements WithCustomElementComponent { + customElementComponent: Type = CodeExampleComponent; +} diff --git a/aio/src/app/custom-elements/code/code-tabs.component.spec.ts b/aio/src/app/custom-elements/code/code-tabs.component.spec.ts new file mode 100644 index 0000000000..932e6fdb9b --- /dev/null +++ b/aio/src/app/custom-elements/code/code-tabs.component.spec.ts @@ -0,0 +1,96 @@ +import { Component, ViewChild, NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Logger } from 'app/shared/logger.service'; +import { MockLogger } from 'testing/logger.service'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { CodeTabsComponent } from './code-tabs.component'; +import { CodeTabsModule } from './code-tabs.module'; + +describe('CodeTabsComponent', () => { + let fixture: ComponentFixture; + let hostComponent: HostComponent; + let codeTabsComponent: CodeTabsComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ HostComponent ], + imports: [ CodeTabsModule, NoopAnimationsModule ], + schemas: [ NO_ERRORS_SCHEMA ], + providers: [ + { provide: Logger, useClass: MockLogger }, + ] + }); + + fixture = TestBed.createComponent(HostComponent); + hostComponent = fixture.componentInstance; + codeTabsComponent = hostComponent.codeTabsComponent; + + fixture.detectChanges(); + }); + + it('should get correct tab info', () => { + const tabs = codeTabsComponent.tabs; + expect(tabs.length).toBe(2); + + // First code pane expectations + expect(tabs[0].class).toBe('class-A'); + expect(tabs[0].language).toBe('language-A'); + expect(tabs[0].linenums).toBe('linenums-A'); + expect(tabs[0].path).toBe('path-A'); + expect(tabs[0].region).toBe('region-A'); + expect(tabs[0].title).toBe('title-A'); + expect(tabs[0].code.trim()).toBe('Code example 1'); + + // Second code pane expectations + expect(tabs[1].class).toBe('class-B'); + expect(tabs[1].language).toBe('language-B'); + expect(tabs[1].linenums).toBe('default-linenums', 'Default linenums should have been used'); + expect(tabs[1].path).toBe('path-B'); + expect(tabs[1].region).toBe('region-B'); + expect(tabs[1].title).toBe('title-B'); + expect(tabs[1].code.trim()).toBe('Code example 2'); + }); + + it('should create the right number of tabs with the right labels and classes', () => { + const matTabs = fixture.nativeElement.querySelectorAll('.mat-tab-label'); + expect(matTabs.length).toBe(2); + + expect(matTabs[0].textContent.trim()).toBe('title-A'); + expect(matTabs[0].querySelector('.class-A')).toBeTruthy(); + + expect(matTabs[1].textContent.trim()).toBe('title-B'); + expect(matTabs[1].querySelector('.class-B')).toBeTruthy(); + }); + + it('should show the first tab with the right code', () => { + const codeContent = fixture.nativeElement.querySelector('aio-code').textContent; + expect(codeContent.indexOf('Code example 1') !== -1).toBeTruthy(); + }); +}); + +@Component({ + selector: 'aio-host-comp', + template: ` + + + Code example 1 + + + Code example 2 + + + ` +}) +class HostComponent { + @ViewChild(CodeTabsComponent) codeTabsComponent: CodeTabsComponent; +} diff --git a/aio/src/app/custom-elements/code/code-tabs.component.ts b/aio/src/app/custom-elements/code/code-tabs.component.ts new file mode 100644 index 0000000000..100d000bd0 --- /dev/null +++ b/aio/src/app/custom-elements/code/code-tabs.component.ts @@ -0,0 +1,81 @@ +/* tslint:disable component-selector */ +import { Component, AfterViewInit, ViewChild, Input, ViewChildren, QueryList, OnInit } from '@angular/core'; +import { CodeComponent } from './code.component'; + +export interface TabInfo { + class: string|null; + code: string; + language: string|null; + linenums: any; + path: string; + region: string; + title: string|null; +} + +/** + * Renders a set of tab group of code snippets. + * + * The innerHTML of the `` component should contain `` elements. + * Each `` has the same interface as the embedded `` component. + * The optional `linenums` attribute is the default `linenums` for each code pane. + */ +@Component({ + selector: 'code-tabs', + template: ` + +
+ + + + + {{ tab.title }} + + + + + + `, +}) +export class CodeTabsComponent implements OnInit, AfterViewInit { + tabs: TabInfo[]; + + @Input('linenums') linenums: string; + + @ViewChild('content') content; + + @ViewChildren(CodeComponent) codeComponents: QueryList; + + ngOnInit() { + this.tabs = []; + const codeExamples = this.content.nativeElement.querySelectorAll('code-pane'); + + for (let i = 0; i < codeExamples.length; i++) { + const tabContent = codeExamples[i]; + this.tabs.push(this.getTabInfo(tabContent)); + } + } + + ngAfterViewInit() { + this.codeComponents.toArray().forEach((codeComponent, i) => { + codeComponent.code = this.tabs[i].code; + }); + } + + /** Gets the extracted TabInfo data from the provided code-pane element. */ + private getTabInfo(tabContent: HTMLElement): TabInfo { + return { + class: tabContent.getAttribute('class'), + code: tabContent.innerHTML, + language: tabContent.getAttribute('language'), + linenums: tabContent.getAttribute('linenums') || this.linenums, + path: tabContent.getAttribute('path') || '', + region: tabContent.getAttribute('region') || '', + title: tabContent.getAttribute('title') + }; + } +} diff --git a/aio/src/app/custom-elements/code/code-tabs.module.ts b/aio/src/app/custom-elements/code/code-tabs.module.ts new file mode 100644 index 0000000000..7dffaa19b6 --- /dev/null +++ b/aio/src/app/custom-elements/code/code-tabs.module.ts @@ -0,0 +1,16 @@ +import { NgModule, Type } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { CodeTabsComponent } from './code-tabs.component'; +import { MatTabsModule } from '@angular/material'; +import { CodeModule } from './code.module'; +import { WithCustomElementComponent } from '../element-registry'; + +@NgModule({ + imports: [ CommonModule, MatTabsModule, CodeModule ], + declarations: [ CodeTabsComponent ], + exports: [ CodeTabsComponent ], + entryComponents: [ CodeTabsComponent ] +}) +export class CodeTabsModule implements WithCustomElementComponent { + customElementComponent: Type = CodeTabsComponent; +} diff --git a/aio/src/app/embedded/code/code.component.spec.ts b/aio/src/app/custom-elements/code/code.component.spec.ts similarity index 81% rename from aio/src/app/embedded/code/code.component.spec.ts rename to aio/src/app/custom-elements/code/code.component.spec.ts index bb47c765b2..26b409c145 100644 --- a/aio/src/app/embedded/code/code.component.spec.ts +++ b/aio/src/app/custom-elements/code/code.component.spec.ts @@ -1,10 +1,11 @@ -import { Component, DebugElement } from '@angular/core'; +import { Component, ViewChild, AfterViewInit } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatSnackBarModule, MatSnackBar } from '@angular/material'; +import { MatSnackBar } from '@angular/material'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { CodeComponent } from './code.component'; +import { CodeModule } from './code.module'; import { CopierService } from 'app/shared//copier.service'; import { Logger } from 'app/shared/logger.service'; import { PrettyPrinter } from './pretty-printer.service'; @@ -22,12 +23,9 @@ const smallMultiLineCode = ` const bigMultiLineCode = smallMultiLineCode + smallMultiLineCode + smallMultiLineCode; describe('CodeComponent', () => { - let codeComponentDe: DebugElement; - let codeComponent: CodeComponent; let hostComponent: HostComponent; let fixture: ComponentFixture; - // WARNING: Chance of cross-test pollution // CodeComponent injects PrettyPrintService // Once PrettyPrintService runs once _anywhere_, its ctor loads `prettify.js` @@ -42,14 +40,14 @@ describe('CodeComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ MatSnackBarModule, NoopAnimationsModule ], - declarations: [ CodeComponent, HostComponent ], + imports: [ NoopAnimationsModule, CodeModule ], + declarations: [ HostComponent ], providers: [ PrettyPrinter, CopierService, {provide: Logger, useClass: TestLogger } ] - }); + }).compileComponents(); }); // Must be async because @@ -58,26 +56,20 @@ describe('CodeComponent', () => { beforeEach(async(() => { fixture = TestBed.createComponent(HostComponent); hostComponent = fixture.componentInstance; - codeComponentDe = fixture.debugElement.children[0]; - codeComponent = codeComponentDe.componentInstance; + fixture.detectChanges(); })); - it('should create CodeComponent', () => { - expect(codeComponentDe.name).toBe('aio-code', 'selector'); - expect(codeComponent).toBeTruthy('CodeComponent'); - }); - describe('pretty printing', () => { it('should format a one-line code sample', () => { // 'pln' spans are a tell-tale for syntax highlighing - const spans = codeComponentDe.nativeElement.querySelectorAll('span.pln'); + const spans = fixture.nativeElement.querySelectorAll('span.pln'); expect(spans.length).toBeGreaterThan(0, 'formatted spans'); }); function hasLineNumbers() { // presence of `
  • `s are a tell-tale for line numbers - return 0 < codeComponentDe.nativeElement.querySelectorAll('li').length; + return 0 < fixture.nativeElement.querySelectorAll('li').length; } it('should format a one-line code sample without linenums by default', () => { @@ -87,25 +79,25 @@ describe('CodeComponent', () => { it('should add line numbers to one-line code sample when linenums set true', () => { hostComponent.linenums = 'true'; fixture.detectChanges(); + expect(hasLineNumbers()).toBe(true); }); it('should format a small multi-line code without linenums by default', () => { - hostComponent.code = smallMultiLineCode; - fixture.detectChanges(); + hostComponent.setCode(smallMultiLineCode); expect(hasLineNumbers()).toBe(false); }); it('should add line numbers to a big multi-line code by default', () => { - hostComponent.code = bigMultiLineCode; - fixture.detectChanges(); + hostComponent.setCode(bigMultiLineCode); expect(hasLineNumbers()).toBe(true); }); it('should format big multi-line code without linenums when linenums set false', () => { hostComponent.linenums = false; - hostComponent.code = bigMultiLineCode; fixture.detectChanges(); + + hostComponent.setCode(bigMultiLineCode); expect(hasLineNumbers()).toBe(false); }); }); @@ -113,25 +105,27 @@ describe('CodeComponent', () => { describe('whitespace handling', () => { it('should remove common indentation from the code before rendering', () => { hostComponent.linenums = false; - hostComponent.code = ' abc\n let x = text.split(\'\\n\');\n ghi\n\n jkl\n'; fixture.detectChanges(); - const codeContent = codeComponentDe.nativeElement.querySelector('code').textContent; + + hostComponent.setCode(' abc\n let x = text.split(\'\\n\');\n ghi\n\n jkl\n'); + const codeContent = fixture.nativeElement.querySelector('code').textContent; expect(codeContent).toEqual('abc\n let x = text.split(\'\\n\');\nghi\n\njkl'); }); it('should trim whitespace from the code before rendering', () => { hostComponent.linenums = false; - hostComponent.code = '\n\n\n' + smallMultiLineCode + '\n\n\n'; fixture.detectChanges(); - const codeContent = codeComponentDe.nativeElement.querySelector('code').textContent; + + hostComponent.setCode('\n\n\n' + smallMultiLineCode + '\n\n\n'); + const codeContent = fixture.nativeElement.querySelector('code').textContent; expect(codeContent).toEqual(codeContent.trim()); }); it('should trim whitespace from code before computing whether to format linenums', () => { - hostComponent.code = '\n\n\n' + hostComponent.code + '\n\n\n'; - fixture.detectChanges(); + hostComponent.setCode('\n\n\n' + oneLineCode + '\n\n\n'); + // `
  • `s are a tell-tale for line numbers - const lis = codeComponentDe.nativeElement.querySelectorAll('li'); + const lis = fixture.nativeElement.querySelectorAll('li'); expect(lis.length).toBe(0, 'should be no linenums'); }); }); @@ -139,39 +133,38 @@ describe('CodeComponent', () => { describe('error message', () => { function getErrorMessage() { - const missing: HTMLElement = codeComponentDe.nativeElement.querySelector('.code-missing'); + const missing: HTMLElement = fixture.nativeElement.querySelector('.code-missing'); return missing ? missing.textContent : null; } it('should not display "code-missing" class when there is some code', () => { - fixture.detectChanges(); expect(getErrorMessage()).toBeNull('should not have element with "code-missing" class'); }); it('should display error message when there is no code (after trimming)', () => { - hostComponent.code = ' \n '; - fixture.detectChanges(); + hostComponent.setCode(' \n '); expect(getErrorMessage()).toContain('missing'); }); it('should show path and region in missing-code error message', () => { - hostComponent.code = ' \n '; hostComponent.path = 'fizz/buzz/foo.html'; hostComponent.region = 'something'; fixture.detectChanges(); + + hostComponent.setCode(' \n '); expect(getErrorMessage()).toMatch(/for[\s\S]fizz\/buzz\/foo\.html#something$/); }); it('should show path only in missing-code error message when no region', () => { - hostComponent.code = ' \n '; hostComponent.path = 'fizz/buzz/foo.html'; fixture.detectChanges(); + + hostComponent.setCode(' \n '); expect(getErrorMessage()).toMatch(/for[\s\S]fizz\/buzz\/foo\.html$/); }); it('should show simple missing-code error message when no path/region', () => { - hostComponent.code = ' \n '; - fixture.detectChanges(); + hostComponent.setCode(' \n '); expect(getErrorMessage()).toMatch(/missing.$/); }); }); @@ -190,12 +183,10 @@ describe('CodeComponent', () => { }); it('should have title', () => { - fixture.detectChanges(); expect(getButton().title).toBe('Copy code snippet'); }); it('should have no aria-label by default', () => { - fixture.detectChanges(); expect(getButton().getAttribute('aria-label')).toBe(''); }); @@ -226,12 +217,11 @@ describe('CodeComponent', () => { const expectedCode = smallMultiLineCode.trim().replace(/</g, '<').replace(/>/g, '>'); let actualCode; - hostComponent.code = smallMultiLineCode; + hostComponent.setCode(smallMultiLineCode); [false, true, 42].forEach(linenums => { hostComponent.linenums = linenums; fixture.detectChanges(); - codeComponent.ngOnChanges(); getButton().click(); actualCode = spy.calls.mostRecent().args[0]; @@ -271,19 +261,29 @@ describe('CodeComponent', () => { @Component({ selector: 'aio-host-comp', template: ` - ` }) -class HostComponent { - code = oneLineCode; +class HostComponent implements AfterViewInit { hideCopy: boolean; language: string; linenums: boolean | number | string; path: string; region: string; title: string; + + @ViewChild(CodeComponent) codeComponent: CodeComponent; + + ngAfterViewInit() { + this.setCode(oneLineCode); + } + + /** Changes the displayed code on the code component. */ + setCode(code: string) { + this.codeComponent.code = code; + } } class TestLogger { diff --git a/aio/src/app/embedded/code/code.component.ts b/aio/src/app/custom-elements/code/code.component.ts similarity index 50% rename from aio/src/app/embedded/code/code.component.ts rename to aio/src/app/custom-elements/code/code.component.ts index 856e9bb040..a1b4a21df8 100644 --- a/aio/src/app/embedded/code/code.component.ts +++ b/aio/src/app/custom-elements/code/code.component.ts @@ -1,10 +1,14 @@ -import { Component, ElementRef, ViewChild, OnChanges, Input } from '@angular/core'; +import { Component, ElementRef, ViewChild, Input, OnChanges } from '@angular/core'; import { Logger } from 'app/shared/logger.service'; import { PrettyPrinter } from './pretty-printer.service'; import { CopierService } from 'app/shared/copier.service'; import { MatSnackBar } from '@angular/material/snack-bar'; -const defaultLineNumsCount = 10; // by default, show linenums over this number +/** + * If linenums is not set, this is the default maximum number of lines that + * an example can display without line numbers. + */ +const DEFAULT_LINE_NUMS_COUNT = 10; /** * Formatted Code Block @@ -17,13 +21,15 @@ const defaultLineNumsCount = 10; // by default, show linenums over this number * * ``` * * * ``` + * + * + * Renders code provided through the `updateCode` method. */ @Component({ selector: 'aio-code', @@ -40,63 +46,54 @@ const defaultLineNumsCount = 10; // by default, show linenums over this number ` }) export class CodeComponent implements OnChanges { - ariaLabel = ''; - /** - * The code to be formatted, this should already be HTML encoded - */ - @Input() - code: string; - - /** - * The code to be copied when clicking the copy button, this should not be HTML encoded - */ + /** The code to be copied when clicking the copy button, this should not be HTML encoded */ private codeText: string; - /** - * set to true if the copy button is not to be shown - */ - @Input() - hideCopy: boolean; + /** Code that should be formatted with current inputs and displayed in the view. */ + set code(code: string) { + this._code = code; - /** - * The language of the code to render - * (could be javascript, dart, typescript, etc) - */ - @Input() - language: string; + if (!this._code || !this._code.trim()) { + this.showMissingCodeMessage(); + } else { + this.formatDisplayedCode(); + } + } + get code(): string { return this._code; } + _code: string; + + /** Whether the copy button should be shown. */ + @Input() hideCopy: boolean; + + /** Language to render the code (e.g. javascript, dart, typescript). */ + @Input() language: string; /** * Whether to display line numbers: - * - false: don't display - * - true: do display - * - number: do display but start at the given number + * - If false: hide + * - If true: show + * - If number: show but start at that number */ - @Input() - linenums: boolean | number | string; + @Input() linenums: boolean | number | string; - /** - * path to the source of the code being displayed - */ - @Input() - path: string; + /** Path to the source of the code. */ + @Input() path: string; - /** - * region of the source of the code being displayed - */ - @Input() - region: string; + /** Region of the source of the code being displayed. */ + @Input() region: string; - /** - * title for this snippet (optional) - */ + /** Optional title to be displayed above the code. */ @Input() - title: string; + set title(title: string) { + this._title = title; + this.ariaLabel = this.title ? `Copy code snippet from ${this.title}` : ''; + } + get title(): string { return this._title; } + private _title: string; - /** - * The element in the template that will display the formatted code - */ + /** The element in the template that will display the formatted code. */ @ViewChild('codeContainer') codeContainer: ElementRef; constructor( @@ -106,32 +103,38 @@ export class CodeComponent implements OnChanges { private logger: Logger) {} ngOnChanges() { - this.code = this.code && leftAlign(this.code); - this.ariaLabel = this.title ? `Copy code snippet from ${this.title}` : ''; - - if (!this.code) { - const src = this.path ? this.path + (this.region ? '#' + this.region : '') : ''; - const srcMsg = src ? ` for\n${src}` : '.'; - this.setCodeHtml(`

    The code sample is missing${srcMsg}

    `); - return; + // If some inputs have changed and there is code displayed, update the view with the latest + // formatted code. + if (this.code) { + this.formatDisplayedCode(); } + } - const linenums = this.getLinenums(); - - this.setCodeHtml(this.code); // start with unformatted code + private formatDisplayedCode() { + const leftAlignedCode = leftAlign(this.code); + this.setCodeHtml(leftAlignedCode); // start with unformatted code this.codeText = this.getCodeText(); // store the unformatted code as text (for copying) - this.pretty.formatCode(this.code, this.language, linenums).subscribe( - formattedCode => this.setCodeHtml(formattedCode), - err => { /* ignore failure to format */ } + + this.pretty.formatCode(leftAlignedCode, this.language, this.getLinenums(leftAlignedCode)) + .subscribe(c => this.setCodeHtml(c), err => { /* ignore failure to format */ } ); } + /** Sets the message showing that the code could not be found. */ + private showMissingCodeMessage() { + const src = this.path ? this.path + (this.region ? '#' + this.region : '') : ''; + const srcMsg = src ? ` for\n${src}` : '.'; + this.setCodeHtml(`

    The code sample is missing${srcMsg}

    `); + } + + /** Sets the innerHTML of the code container to the provided code string. */ private setCodeHtml(formattedCode: string) { - // **Security:** `codeExampleContent` is provided by docs authors and as such its considered to + // **Security:** Code example content is provided by docs authors and as such its considered to // be safe for innerHTML purposes. this.codeContainer.nativeElement.innerHTML = formattedCode; } + /** Gets the textContent of the displayed code element. */ private getCodeText() { // `prettify` may remove newlines, e.g. when `linenums` are on. Retrieve the content of the // container as text, before prettifying it. @@ -139,24 +142,22 @@ export class CodeComponent implements OnChanges { return this.codeContainer.nativeElement.textContent; } + /** Copies the code snippet to the user's clipboard. */ doCopy() { const code = this.codeText; - if (this.copier.copyText(code)) { + const successfullyCopied = this.copier.copyText(code); + + if (successfullyCopied) { this.logger.log('Copied code to clipboard:', code); - // success snackbar alert - this.snackbar.open('Code Copied', '', { - duration: 800, - }); + this.snackbar.open('Code Copied', '', { duration: 800 }); } else { this.logger.error(new Error(`ERROR copying code to clipboard: "${code}"`)); - // failure snackbar alert - this.snackbar.open('Copy failed. Please try again!', '', { - duration: 800, - }); + this.snackbar.open('Copy failed. Please try again!', '', { duration: 800 }); } } - getLinenums() { + /** Gets the calculated value of linenums (boolean/number). */ + getLinenums(code: string) { const linenums = typeof this.linenums === 'boolean' ? this.linenums : this.linenums === 'true' ? true : @@ -165,13 +166,14 @@ export class CodeComponent implements OnChanges { this.linenums; // if no linenums, enable line numbers if more than one line - return linenums == null || linenums === NaN ? - (this.code.match(/\n/g) || []).length > defaultLineNumsCount : linenums; + return linenums == null || isNaN(linenums as number) ? + (code.match(/\n/g) || []).length > DEFAULT_LINE_NUMS_COUNT : linenums; } } -function leftAlign(text: string) { +function leftAlign(text: string): string { let indent = Number.MAX_VALUE; + const lines = text.split('\n'); lines.forEach(line => { const lineIndent = line.search(/\S/); @@ -179,5 +181,6 @@ function leftAlign(text: string) { indent = Math.min(lineIndent, indent); } }); + return lines.map(line => line.substr(indent)).join('\n').trim(); } diff --git a/aio/src/app/custom-elements/code/code.module.ts b/aio/src/app/custom-elements/code/code.module.ts new file mode 100644 index 0000000000..ced134dcb4 --- /dev/null +++ b/aio/src/app/custom-elements/code/code.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { CodeComponent } from './code.component'; +import { MatSnackBarModule } from '@angular/material'; +import { PrettyPrinter } from './pretty-printer.service'; +import { CopierService } from 'app/shared/copier.service'; + +@NgModule({ + imports: [ CommonModule, MatSnackBarModule ], + declarations: [ CodeComponent ], + entryComponents: [ CodeComponent ], + exports: [ CodeComponent ], + providers: [ PrettyPrinter, CopierService ] +}) +export class CodeModule { } diff --git a/aio/src/app/embedded/code/pretty-printer.service.ts b/aio/src/app/custom-elements/code/pretty-printer.service.ts similarity index 94% rename from aio/src/app/embedded/code/pretty-printer.service.ts rename to aio/src/app/custom-elements/code/pretty-printer.service.ts index cdfed57360..28131046c9 100644 --- a/aio/src/app/embedded/code/pretty-printer.service.ts +++ b/aio/src/app/custom-elements/code/pretty-printer.service.ts @@ -33,8 +33,8 @@ export class PrettyPrinter { .then( () => (window as any)['prettyPrintOne'], err => { - const msg = `Cannot get prettify.js from server: ${err.message}`; - this.logger.error(new Error(msg)); + const msg = 'Cannot get prettify.js from server'; + this.logger.error(msg, err); // return a pretty print fn that always fails. return () => { throw new Error(msg); }; }); diff --git a/aio/src/app/embedded/contributor/contributor-list.component.spec.ts b/aio/src/app/custom-elements/contributor/contributor-list.component.spec.ts similarity index 100% rename from aio/src/app/embedded/contributor/contributor-list.component.spec.ts rename to aio/src/app/custom-elements/contributor/contributor-list.component.spec.ts diff --git a/aio/src/app/embedded/contributor/contributor-list.component.ts b/aio/src/app/custom-elements/contributor/contributor-list.component.ts similarity index 100% rename from aio/src/app/embedded/contributor/contributor-list.component.ts rename to aio/src/app/custom-elements/contributor/contributor-list.component.ts diff --git a/aio/src/app/custom-elements/contributor/contributor-list.module.ts b/aio/src/app/custom-elements/contributor/contributor-list.module.ts new file mode 100644 index 0000000000..d404fae9f4 --- /dev/null +++ b/aio/src/app/custom-elements/contributor/contributor-list.module.ts @@ -0,0 +1,16 @@ +import { NgModule, Type } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ContributorListComponent } from './contributor-list.component'; +import { ContributorService } from './contributor.service'; +import { ContributorComponent } from './contributor.component'; +import { WithCustomElementComponent } from '../element-registry'; + +@NgModule({ + imports: [ CommonModule ], + declarations: [ ContributorListComponent, ContributorComponent ], + entryComponents: [ ContributorListComponent ], + providers: [ ContributorService ] +}) +export class ContributorListModule implements WithCustomElementComponent { + customElementComponent: Type = ContributorListComponent; +} diff --git a/aio/src/app/embedded/contributor/contributor.component.ts b/aio/src/app/custom-elements/contributor/contributor.component.ts similarity index 100% rename from aio/src/app/embedded/contributor/contributor.component.ts rename to aio/src/app/custom-elements/contributor/contributor.component.ts diff --git a/aio/src/app/embedded/contributor/contributor.service.spec.ts b/aio/src/app/custom-elements/contributor/contributor.service.spec.ts similarity index 100% rename from aio/src/app/embedded/contributor/contributor.service.spec.ts rename to aio/src/app/custom-elements/contributor/contributor.service.spec.ts diff --git a/aio/src/app/embedded/contributor/contributor.service.ts b/aio/src/app/custom-elements/contributor/contributor.service.ts similarity index 95% rename from aio/src/app/embedded/contributor/contributor.service.ts rename to aio/src/app/custom-elements/contributor/contributor.service.ts index 21b4a8c8da..bbfa4599ef 100644 --- a/aio/src/app/embedded/contributor/contributor.service.ts +++ b/aio/src/app/custom-elements/contributor/contributor.service.ts @@ -6,6 +6,8 @@ import 'rxjs/add/operator/map'; import 'rxjs/add/operator/publishLast'; import { Contributor, ContributorGroup } from './contributors.model'; + +// TODO(andrewjs): Look into changing this so that we don't import the service just to get the const import { CONTENT_URL_PREFIX } from 'app/documents/document.service'; const contributorsPath = CONTENT_URL_PREFIX + 'contributors.json'; diff --git a/aio/src/app/embedded/contributor/contributors.model.ts b/aio/src/app/custom-elements/contributor/contributors.model.ts similarity index 100% rename from aio/src/app/embedded/contributor/contributors.model.ts rename to aio/src/app/custom-elements/contributor/contributors.model.ts diff --git a/aio/src/app/embedded/current-location.component.spec.ts b/aio/src/app/custom-elements/current-location/current-location.component.spec.ts similarity index 100% rename from aio/src/app/embedded/current-location.component.spec.ts rename to aio/src/app/custom-elements/current-location/current-location.component.spec.ts diff --git a/aio/src/app/embedded/current-location.component.ts b/aio/src/app/custom-elements/current-location/current-location.component.ts similarity index 68% rename from aio/src/app/embedded/current-location.component.ts rename to aio/src/app/custom-elements/current-location/current-location.component.ts index 57f4280faa..38af7b148d 100644 --- a/aio/src/app/embedded/current-location.component.ts +++ b/aio/src/app/custom-elements/current-location/current-location.component.ts @@ -2,14 +2,11 @@ import { Component } from '@angular/core'; import { LocationService } from 'app/shared/location.service'; -/** - * A simple embedded component that displays the current location path - */ +/** Renders the current location path. */ @Component({ selector: 'current-location', template: '{{ location.currentPath | async }}' }) export class CurrentLocationComponent { - constructor(public location: LocationService) { - } + constructor(public location: LocationService) { } } diff --git a/aio/src/app/custom-elements/current-location/current-location.module.ts b/aio/src/app/custom-elements/current-location/current-location.module.ts new file mode 100644 index 0000000000..dce7885408 --- /dev/null +++ b/aio/src/app/custom-elements/current-location/current-location.module.ts @@ -0,0 +1,13 @@ +import { NgModule, Type } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { CurrentLocationComponent } from './current-location.component'; +import { WithCustomElementComponent } from '../element-registry'; + +@NgModule({ + imports: [ CommonModule ], + declarations: [ CurrentLocationComponent ], + entryComponents: [ CurrentLocationComponent ] +}) +export class CurrentLocationModule implements WithCustomElementComponent { + customElementComponent: Type = CurrentLocationComponent; +} diff --git a/aio/src/app/custom-elements/custom-elements.module.ts b/aio/src/app/custom-elements/custom-elements.module.ts new file mode 100644 index 0000000000..ebc5b3704a --- /dev/null +++ b/aio/src/app/custom-elements/custom-elements.module.ts @@ -0,0 +1,22 @@ +import { NgModule, NgModuleFactoryLoader, SystemJsNgModuleLoader } from '@angular/core'; +import { ROUTES} from '@angular/router'; +import { ElementsLoader } from './elements-loader'; +import { + ELEMENT_MODULE_PATHS, + ELEMENT_MODULE_PATHS_AS_ROUTES, + ELEMENT_MODULE_PATHS_TOKEN +} from './element-registry'; + +@NgModule({ + providers: [ + ElementsLoader, + { provide: NgModuleFactoryLoader, useClass: SystemJsNgModuleLoader }, + { provide: ELEMENT_MODULE_PATHS_TOKEN, useValue: ELEMENT_MODULE_PATHS }, + + // Providing these routes as a signal to the build system that these modules should be + // registered as lazy-loadable. + // TODO(andrewjs): Provide first-class support for providing this. + { provide: ROUTES, useValue: ELEMENT_MODULE_PATHS_AS_ROUTES, multi: true }, + ], +}) +export class CustomElementsModule { } diff --git a/aio/src/app/custom-elements/element-registry.ts b/aio/src/app/custom-elements/element-registry.ts new file mode 100644 index 0000000000..869b83f09a --- /dev/null +++ b/aio/src/app/custom-elements/element-registry.ts @@ -0,0 +1,64 @@ +import { InjectionToken, Type } from '@angular/core'; + +// Modules containing custom elements must be set up as lazy-loaded routes (loadChildren) +// TODO(andrewjs): This is a hack, Angular should have first-class support for preparing a module +// that contains custom elements. +export const ELEMENT_MODULE_PATHS_AS_ROUTES = [ + { + selector: 'aio-announcement-bar', + loadChildren: './announcement-bar/announcement-bar.module#AnnouncementBarModule' + }, + { + selector: 'aio-api-list', + loadChildren: './api/api-list.module#ApiListModule' + }, + { + selector: 'live-example', + loadChildren: './live-example/live-example.module#LiveExampleModule' + }, + { + selector: 'aio-file-not-found-search', + loadChildren: './search/file-not-found-search.module#FileNotFoundSearchModule' + }, + { + selector: 'aio-resource-list', + loadChildren: './resource/resource-list.module#ResourceListModule' + }, + { + selector: 'current-location', + loadChildren: './current-location/current-location.module#CurrentLocationModule' + }, + { + selector: 'aio-contributor-list', + loadChildren: './contributor/contributor-list.module#ContributorListModule' + }, + { + selector: 'code-tabs', + loadChildren: './code/code-tabs.module#CodeTabsModule' + }, + { + selector: 'code-example', + loadChildren: './code/code-example.module#CodeExampleModule' + }, + { + selector: 'expandable-section', + loadChildren: './expandable-section/expandable-section.module#ExpandableSectionModule' + } +]; + +/** + * Interface expected to be implemented by all modules that declare a component that can be used as + * a custom element. + */ +export interface WithCustomElementComponent { + customElementComponent: Type; +} + +/** Injection token to provide the element path modules. */ +export const ELEMENT_MODULE_PATHS_TOKEN = new InjectionToken('aio/elements-map'); + +/** Map of possible custom element selectors to their lazy-loadable module paths. */ +export const ELEMENT_MODULE_PATHS = new Map(); +ELEMENT_MODULE_PATHS_AS_ROUTES.forEach(route => { + ELEMENT_MODULE_PATHS.set(route.selector, route.loadChildren); +}); diff --git a/aio/src/app/custom-elements/elements-loader.spec.ts b/aio/src/app/custom-elements/elements-loader.spec.ts new file mode 100644 index 0000000000..3166db4118 --- /dev/null +++ b/aio/src/app/custom-elements/elements-loader.spec.ts @@ -0,0 +1,140 @@ +import { + ComponentFactory, + ComponentFactoryResolver, ComponentRef, Injector, NgModuleFactory, NgModuleFactoryLoader, + NgModuleRef, + Type +} from '@angular/core'; +import {TestBed, fakeAsync, tick} from '@angular/core/testing'; + +import { ElementsLoader } from './elements-loader'; +import { ELEMENT_MODULE_PATHS_TOKEN, WithCustomElementComponent } from './element-registry'; + +const actualCustomElements = window.customElements; + +class FakeComponentFactory extends ComponentFactory { + selector: string; + componentType: Type; + ngContentSelectors: string[]; + inputs = [{propName: this.identifyingInput, templateName: this.identifyingInput}]; + outputs = []; + + constructor(private identifyingInput: string) { super(); } + + create(injector: Injector, + projectableNodes?: any[][], + rootSelectorOrNode?: string | any, + ngModule?: NgModuleRef): ComponentRef { + return jasmine.createSpyObj('ComponentRef', ['methods']); + }; +} + +const FAKE_COMPONENT_FACTORIES = new Map([ + ['element-a-module-path', new FakeComponentFactory('element-a-input')] +]); + +describe('ElementsLoader', () => { + let elementsLoader: ElementsLoader; + let injectedModuleRef: NgModuleRef; + let fakeCustomElements; + + // ElementsLoader uses the window's customElements API. Provide a fake for this test. + beforeEach(() => { + fakeCustomElements = jasmine.createSpyObj('customElements', ['define']); + window.customElements = fakeCustomElements; + }); + afterEach(() => { + window.customElements = actualCustomElements; + }); + + beforeEach(() => { + const injector = TestBed.configureTestingModule({ + providers: [ + ElementsLoader, + { provide: NgModuleFactoryLoader, useClass: FakeModuleFactoryLoader }, + { provide: ELEMENT_MODULE_PATHS_TOKEN, useValue: new Map([ + ['element-a-selector', 'element-a-module-path'] + ])}, + ] + }); + + injectedModuleRef = injector.get(NgModuleRef); + elementsLoader = injector.get(ElementsLoader); + }); + + it('should be able to register an element', fakeAsync(() => { + // Verify that the elements loader considered `element-a-selector` to be unregistered. + expect(elementsLoader.elementsToLoad.has('element-a-selector')).toBeTruthy(); + + const hostEl = document.createElement('div'); + hostEl.innerHTML = ``; + + elementsLoader.loadContainingCustomElements(hostEl); + tick(); + + const defineArgs = fakeCustomElements.define.calls.argsFor(0); + expect(defineArgs[0]).toBe('element-a-selector'); + + // Verify the right component was loaded/created + expect(defineArgs[1].observedAttributes[0]).toBe('element-a-input'); + + expect(elementsLoader.elementsToLoad.has('element-a-selector')).toBeFalsy(); + })); + + it('should only register an element one time', fakeAsync(() => { + const hostEl = document.createElement('div'); + hostEl.innerHTML = ``; + + elementsLoader.loadContainingCustomElements(hostEl); + tick(); // Tick for the module factory loader's async `load` function + + // Call again to to check how many times registerAsCustomElements was called. + elementsLoader.loadContainingCustomElements(hostEl); + tick(); // Tick for the module factory loader's async `load` function + + // Should have only been called once, since the second load would not query for element-a + expect(window.customElements.define).toHaveBeenCalledTimes(1); + })); +}); + +// TEST CLASSES/HELPERS + +class FakeCustomElementModule implements WithCustomElementComponent { + customElementComponent: Type; +} + +class FakeComponentFactoryResolver extends ComponentFactoryResolver { + constructor(private modulePath) { super(); } + + resolveComponentFactory(component: Type): ComponentFactory { + return FAKE_COMPONENT_FACTORIES.get(this.modulePath)!; + } +} + +class FakeModuleRef extends NgModuleRef { + injector: Injector; + componentFactoryResolver = new FakeComponentFactoryResolver(this.modulePath); + instance: WithCustomElementComponent = new FakeCustomElementModule(); + + constructor(private modulePath) { super(); } + + destroy() {} + onDestroy(callback: () => void) {} +} + +class FakeModuleFactory extends NgModuleFactory { + moduleType: Type; + moduleRefToCreate = new FakeModuleRef(this.modulePath); + + constructor(private modulePath) { super(); } + + create(parentInjector: Injector | null): NgModuleRef { + return this.moduleRefToCreate; + } +} + +class FakeModuleFactoryLoader extends NgModuleFactoryLoader { + load(modulePath: string): Promise> { + const fakeModuleFactory = new FakeModuleFactory(modulePath); + return Promise.resolve(fakeModuleFactory); + } +} diff --git a/aio/src/app/custom-elements/elements-loader.ts b/aio/src/app/custom-elements/elements-loader.ts new file mode 100644 index 0000000000..2216c74079 --- /dev/null +++ b/aio/src/app/custom-elements/elements-loader.ts @@ -0,0 +1,70 @@ +import { + ComponentFactory, + Inject, + Injectable, + NgModuleFactoryLoader, + NgModuleRef, +} from '@angular/core'; +import { ELEMENT_MODULE_PATHS_TOKEN, WithCustomElementComponent } from './element-registry'; +import { of } from 'rxjs/observable/of'; +import { Observable } from 'rxjs/Observable'; +import { fromPromise } from 'rxjs/observable/fromPromise'; +import { createNgElementConstructor, getConfigFromComponentFactory } from '@angular/elements'; + +@Injectable() +export class ElementsLoader { + /** Map of unregistered custom elements and their respective module paths to load. */ + elementsToLoad: Map; + + constructor(private moduleFactoryLoader: NgModuleFactoryLoader, + private moduleRef: NgModuleRef, + @Inject(ELEMENT_MODULE_PATHS_TOKEN) elementModulePaths) { + this.elementsToLoad = new Map(elementModulePaths); + } + + /** + * Queries the provided element for any custom elements that have not yet been registered with + * the browser. Custom elements that are registered will be removed from the list of unregistered + * elements so that they will not be queried in subsequent calls. + */ + loadContainingCustomElements(element: HTMLElement): Observable { + const selectors: any[] = Array.from(this.elementsToLoad.keys()) + .filter(s => element.querySelector(s)); + + if (!selectors.length) { return of(null); } + + selectors.forEach(s => this.register(s)); + + // Returns observable that completes when all discovered elements have been registered. + return fromPromise(Promise.all(selectors.map(s => this.register(s))).then(result => null)); + } + + /** Registers the custom element defined on the WithCustomElement module factory. */ + private register(selector: string) { + const modulePath = this.elementsToLoad.get(selector)!; + return this.moduleFactoryLoader.load(modulePath).then(elementModuleFactory => { + if (!this.elementsToLoad.has(selector)) { return; } + + const injector = this.moduleRef.injector; + const elementModuleRef = elementModuleFactory.create(injector); + const componentFactory = this.getCustomElementComponentFactory(elementModuleRef); + + const ngElementConfig = getConfigFromComponentFactory(componentFactory, injector); + const NgElement = createNgElementConstructor(ngElementConfig); + + customElements!.define(selector, NgElement); + this.elementsToLoad.delete(selector); + + return customElements.whenDefined(selector); + }); + } + + /** Gets the component factory of the custom element defined on the NgModuleRef. */ + private getCustomElementComponentFactory( + customElementModuleRef: NgModuleRef): ComponentFactory { + const resolver = customElementModuleRef.componentFactoryResolver; + const customElementComponent = customElementModuleRef.instance.customElementComponent; + + return resolver.resolveComponentFactory(customElementComponent); + } +} diff --git a/aio/src/app/custom-elements/expandable-section/expandable-section.component.html b/aio/src/app/custom-elements/expandable-section/expandable-section.component.html new file mode 100644 index 0000000000..e0c5017f03 --- /dev/null +++ b/aio/src/app/custom-elements/expandable-section/expandable-section.component.html @@ -0,0 +1,7 @@ + + + {{title}} + + + + diff --git a/aio/src/app/custom-elements/expandable-section/expandable-section.component.ts b/aio/src/app/custom-elements/expandable-section/expandable-section.component.ts new file mode 100644 index 0000000000..403c463513 --- /dev/null +++ b/aio/src/app/custom-elements/expandable-section/expandable-section.component.ts @@ -0,0 +1,11 @@ +/* tslint:disable component-selector */ +import {Component, Input} from '@angular/core'; + +/** Custom element wrapper for the material expansion panel with a title input. */ +@Component({ + selector: 'expandable-section', + templateUrl: 'expandable-section.component.html', +}) +export class ExpandableSectionComponent { + @Input() title; +} diff --git a/aio/src/app/custom-elements/expandable-section/expandable-section.module.ts b/aio/src/app/custom-elements/expandable-section/expandable-section.module.ts new file mode 100644 index 0000000000..54875b5a31 --- /dev/null +++ b/aio/src/app/custom-elements/expandable-section/expandable-section.module.ts @@ -0,0 +1,13 @@ +import { NgModule, Type } from '@angular/core'; +import { ExpandableSectionComponent } from './expandable-section.component'; +import { WithCustomElementComponent } from '../element-registry'; +import { MatExpansionModule } from '@angular/material'; + +@NgModule({ + imports: [ MatExpansionModule ], + declarations: [ ExpandableSectionComponent, ], + entryComponents: [ ExpandableSectionComponent ] +}) +export class ExpandableSectionModule implements WithCustomElementComponent { + customElementComponent: Type = ExpandableSectionComponent; +} diff --git a/aio/src/app/embedded/live-example/live-example.component.html b/aio/src/app/custom-elements/live-example/live-example.component.html similarity index 100% rename from aio/src/app/embedded/live-example/live-example.component.html rename to aio/src/app/custom-elements/live-example/live-example.component.html diff --git a/aio/src/app/embedded/live-example/live-example.component.spec.ts b/aio/src/app/custom-elements/live-example/live-example.component.spec.ts similarity index 100% rename from aio/src/app/embedded/live-example/live-example.component.spec.ts rename to aio/src/app/custom-elements/live-example/live-example.component.spec.ts diff --git a/aio/src/app/embedded/live-example/live-example.component.ts b/aio/src/app/custom-elements/live-example/live-example.component.ts similarity index 100% rename from aio/src/app/embedded/live-example/live-example.component.ts rename to aio/src/app/custom-elements/live-example/live-example.component.ts diff --git a/aio/src/app/custom-elements/live-example/live-example.module.ts b/aio/src/app/custom-elements/live-example/live-example.module.ts new file mode 100644 index 0000000000..f8e536427b --- /dev/null +++ b/aio/src/app/custom-elements/live-example/live-example.module.ts @@ -0,0 +1,13 @@ +import { NgModule, Type } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { EmbeddedStackblitzComponent, LiveExampleComponent } from './live-example.component'; +import { WithCustomElementComponent } from '../element-registry'; + +@NgModule({ + imports: [ CommonModule ], + declarations: [ LiveExampleComponent, EmbeddedStackblitzComponent ], + entryComponents: [ LiveExampleComponent ] +}) +export class LiveExampleModule implements WithCustomElementComponent { + customElementComponent: Type = LiveExampleComponent; +} diff --git a/aio/src/app/embedded/resource/resource-list.component.html b/aio/src/app/custom-elements/resource/resource-list.component.html similarity index 100% rename from aio/src/app/embedded/resource/resource-list.component.html rename to aio/src/app/custom-elements/resource/resource-list.component.html diff --git a/aio/src/app/embedded/resource/resource-list.component.spec.ts b/aio/src/app/custom-elements/resource/resource-list.component.spec.ts similarity index 100% rename from aio/src/app/embedded/resource/resource-list.component.spec.ts rename to aio/src/app/custom-elements/resource/resource-list.component.spec.ts diff --git a/aio/src/app/embedded/resource/resource-list.component.ts b/aio/src/app/custom-elements/resource/resource-list.component.ts similarity index 100% rename from aio/src/app/embedded/resource/resource-list.component.ts rename to aio/src/app/custom-elements/resource/resource-list.component.ts diff --git a/aio/src/app/custom-elements/resource/resource-list.module.ts b/aio/src/app/custom-elements/resource/resource-list.module.ts new file mode 100644 index 0000000000..1f08688cf2 --- /dev/null +++ b/aio/src/app/custom-elements/resource/resource-list.module.ts @@ -0,0 +1,15 @@ +import { NgModule, Type } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ResourceListComponent } from './resource-list.component'; +import { ResourceService } from './resource.service'; +import { WithCustomElementComponent } from '../element-registry'; + +@NgModule({ + imports: [ CommonModule ], + declarations: [ ResourceListComponent ], + entryComponents: [ ResourceListComponent ], + providers: [ ResourceService ] +}) +export class ResourceListModule implements WithCustomElementComponent { + customElementComponent: Type = ResourceListComponent; +} diff --git a/aio/src/app/embedded/resource/resource.model.ts b/aio/src/app/custom-elements/resource/resource.model.ts similarity index 100% rename from aio/src/app/embedded/resource/resource.model.ts rename to aio/src/app/custom-elements/resource/resource.model.ts diff --git a/aio/src/app/embedded/resource/resource.service.spec.ts b/aio/src/app/custom-elements/resource/resource.service.spec.ts similarity index 100% rename from aio/src/app/embedded/resource/resource.service.spec.ts rename to aio/src/app/custom-elements/resource/resource.service.spec.ts diff --git a/aio/src/app/embedded/resource/resource.service.ts b/aio/src/app/custom-elements/resource/resource.service.ts similarity index 100% rename from aio/src/app/embedded/resource/resource.service.ts rename to aio/src/app/custom-elements/resource/resource.service.ts diff --git a/aio/src/app/embedded/search/file-not-found-search.component.spec.ts b/aio/src/app/custom-elements/search/file-not-found-search.component.spec.ts similarity index 100% rename from aio/src/app/embedded/search/file-not-found-search.component.spec.ts rename to aio/src/app/custom-elements/search/file-not-found-search.component.spec.ts diff --git a/aio/src/app/embedded/search/file-not-found-search.component.ts b/aio/src/app/custom-elements/search/file-not-found-search.component.ts similarity index 100% rename from aio/src/app/embedded/search/file-not-found-search.component.ts rename to aio/src/app/custom-elements/search/file-not-found-search.component.ts diff --git a/aio/src/app/custom-elements/search/file-not-found-search.module.ts b/aio/src/app/custom-elements/search/file-not-found-search.module.ts new file mode 100644 index 0000000000..31756e2a17 --- /dev/null +++ b/aio/src/app/custom-elements/search/file-not-found-search.module.ts @@ -0,0 +1,14 @@ +import { NgModule, Type } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../../shared/shared.module'; +import { FileNotFoundSearchComponent } from './file-not-found-search.component'; +import { WithCustomElementComponent } from '../element-registry'; + +@NgModule({ + imports: [ CommonModule, SharedModule ], + declarations: [ FileNotFoundSearchComponent ], + entryComponents: [ FileNotFoundSearchComponent ] +}) +export class FileNotFoundSearchModule implements WithCustomElementComponent { + customElementComponent: Type = FileNotFoundSearchComponent; +} diff --git a/aio/src/app/embed-components/embed-components.module.ts b/aio/src/app/embed-components/embed-components.module.ts deleted file mode 100644 index b0b857fa67..0000000000 --- a/aio/src/app/embed-components/embed-components.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { NgModule, NgModuleFactoryLoader, SystemJsNgModuleLoader } from '@angular/core'; - -import { EmbedComponentsService } from './embed-components.service'; - - -@NgModule({ - providers: [ - EmbedComponentsService, - { provide: NgModuleFactoryLoader, useClass: SystemJsNgModuleLoader }, - ], -}) -export class EmbedComponentsModule { -} diff --git a/aio/src/app/embed-components/embed-components.service.spec.ts b/aio/src/app/embed-components/embed-components.service.spec.ts deleted file mode 100644 index 8a97d56f1b..0000000000 --- a/aio/src/app/embed-components/embed-components.service.spec.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { ComponentFactory, ComponentFactoryResolver, ComponentRef, NgModuleFactoryLoader } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; - -import { - MockNgModuleFactoryLoader, TestEmbedComponentsService, TestModule, mockEmbeddedModulePath, - testEagerEmbeddedComponents, testEagerEmbeddedSelectors, testLazyEmbeddedComponents -} from 'testing/embed-components-utils'; -import { EmbedComponentsService, ComponentsOrModulePath } from './embed-components.service'; - - -describe('EmbedComponentsService', () => { - let service: TestEmbedComponentsService; - let host: HTMLElement; - - beforeEach(() => { - TestBed.configureTestingModule({imports: [TestModule]}); - - service = TestBed.get(EmbedComponentsService); - host = document.createElement('div'); - }); - - it('should be instantiated', () => { - expect(service).toEqual(jasmine.any(EmbedComponentsService)); - }); - - describe('#createComponentFactories()', () => { - let factories: typeof service.componentFactories; - let resolver: ComponentFactoryResolver; - - const doCreateComponentFactories = () => - service.createComponentFactories(testEagerEmbeddedComponents, resolver); - - beforeEach(() => { - factories = service.componentFactories; - resolver = TestBed.get(ComponentFactoryResolver) as ComponentFactoryResolver; - }); - - it('should create a factory entry for each component', () => { - expect(factories.size).toBe(0); - - doCreateComponentFactories(); - expect(factories.size).toBe(testEagerEmbeddedComponents.length); - }); - - it('should key the factory entries by selector', () => { - doCreateComponentFactories(); - - const actualSelectors = Array.from(factories.keys()); - const expectedSelectors = testEagerEmbeddedSelectors; - - expect(actualSelectors).toEqual(expectedSelectors); - }); - - it('should store the projected content property name', () => { - doCreateComponentFactories(); - - const actualContentPropNames = Array.from(factories.values()).map(x => x.contentPropertyName); - const expectedContentPropNames = testEagerEmbeddedSelectors.map(x => service.selectorToContentPropertyName(x)); - - expect(actualContentPropNames).toEqual(expectedContentPropNames); - }); - - it('should store the factory for each component', () => { - doCreateComponentFactories(); - - const actualFactories = Array.from(factories.values()).map(x => x.factory); - const expectedComponentTypes = testEagerEmbeddedComponents; - - actualFactories.forEach((factory, i) => { - expect(factory).toEqual(jasmine.any(ComponentFactory)); - expect(factory.componentType).toBe(expectedComponentTypes[i]); - }); - }); - }); - - describe('#createComponents()', () => { - const FooComponent = testEagerEmbeddedComponents[0]; - const BarComponent = testEagerEmbeddedComponents[1]; - - beforeEach(() => service.prepareComponentFactories(testEagerEmbeddedComponents)); - - it('should apply all embedded components (and return the `ComponentRef`s)', () => { - host.innerHTML = ` -

    Header

    -

    -

    -

    Footer

    - `; - - const componentRefs = service.createComponents(host); - - expect(host.innerHTML).toContain('Foo Component'); - expect(host.innerHTML).toContain('Bar Component'); - - expect(componentRefs.length).toBe(2); - expect(componentRefs[0].instance).toEqual(jasmine.any(FooComponent)); - expect(componentRefs[1].instance).toEqual(jasmine.any(BarComponent)); - }); - - it('should apply embedded components to all matching elements', () => { - host.innerHTML = ` -

    Header

    -

    -

    -

    -

    -

    Footer

    - `; - - const componentRefs = service.createComponents(host); - - expect(componentRefs.length).toBe(4); - expect(componentRefs[0].instance).toEqual(jasmine.any(FooComponent)); - expect(componentRefs[1].instance).toEqual(jasmine.any(FooComponent)); - expect(componentRefs[2].instance).toEqual(jasmine.any(BarComponent)); - expect(componentRefs[3].instance).toEqual(jasmine.any(BarComponent)); - }); - - it('should allow projecting content by assigning it on the element', () => { - const projectedContent = 'Projected content'; - host.innerHTML = ` -

    Header

    -

    ${projectedContent}

    -

    Footer

    - `; - - const componentRefs = service.createComponents(host); - componentRefs[0].changeDetectorRef.detectChanges(); - - const barEl = host.querySelector('aio-eager-bar')!; - - expect((barEl as any)['aioEagerBarContent']).toBe(projectedContent); - expect(barEl.innerHTML).toContain(projectedContent); - }); - - // Because `FooComponent` is processed before `BarComponent`... - it('should apply `FooComponent` within `BarComponent`', () => { - host.innerHTML = ` - - - - `; - - const componentRefs = service.createComponents(host); - componentRefs.forEach(ref => ref.changeDetectorRef.detectChanges()); - - expect(host.innerHTML).toContain('Foo Component'); - expect(host.innerHTML).toContain('Bar Component'); - - expect(componentRefs.length).toBe(2); - expect(componentRefs[0].instance).toEqual(jasmine.any(FooComponent)); - expect(componentRefs[1].instance).toEqual(jasmine.any(BarComponent)); - }); - - // Because `BarComponent` is processed after `FooComponent`... - it('should not apply `BarComponent` within `FooComponent`', () => { - host.innerHTML = ` - - - - `; - - const componentRefs = service.createComponents(host); - componentRefs.forEach(ref => ref.changeDetectorRef.detectChanges()); - - expect(host.innerHTML).toContain('Foo Component'); - expect(host.innerHTML).not.toContain('Bar Component'); - - expect(componentRefs.length).toBe(1); - expect(componentRefs[0].instance).toEqual(jasmine.any(FooComponent)); - }); - }); - - describe('#embedInto()', () => { - let mockComponentRefs: ComponentRef[]; - let createComponentsSpy: jasmine.Spy; - let prepareComponentFactoriesSpy: jasmine.Spy; - - const doEmbed = (contents: string) => - new Promise[]>((resolve, reject) => { - host.innerHTML = contents; - service.embedInto(host).subscribe(resolve, reject); - }); - - beforeEach(() => { - mockComponentRefs = [{foo: true}, {bar: true}] as any as ComponentRef[]; - - createComponentsSpy = spyOn(service, 'createComponents').and.returnValue(mockComponentRefs); - prepareComponentFactoriesSpy = spyOn(service, 'prepareComponentFactories') - .and.returnValue(Promise.resolve()); - }); - - it('should return an observable', done => { - service.embedInto(host).subscribe(done, done.fail); - }); - - describe('(preparing component factories)', () => { - it('should return an array of `ComponentRef`s', async () => { - // When there are embedded components. - expect(await doEmbed('')).toEqual(mockComponentRefs); - expect(await doEmbed('')).toEqual(mockComponentRefs); - - // When there are no embedded components. - expect(await doEmbed('
    Test
    ')).toEqual([]); - expect(await doEmbed('')).toEqual([]); - }); - - it('should prepare all component factories if there are embedded components', async () => { - await doEmbed(` -
    foo
    - bar - `); - - expect(prepareComponentFactoriesSpy).toHaveBeenCalledTimes(2); - expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(testEagerEmbeddedComponents); - expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(mockEmbeddedModulePath); - }); - - it('should only prepare the necessary factories', async () => { - await doEmbed('Eager only'); - expect(prepareComponentFactoriesSpy).toHaveBeenCalledTimes(1); - expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(testEagerEmbeddedComponents); - - await doEmbed('Lazy only'); - expect(prepareComponentFactoriesSpy).toHaveBeenCalledTimes(2); - expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(mockEmbeddedModulePath); - }); - - it('should not load embedded components if the document does not contain any', async () => { - await doEmbed(''); - await doEmbed(''); - await doEmbed(''); - - expect(prepareComponentFactoriesSpy).not.toHaveBeenCalled(); - }); - }); - - describe('(creating embedded components)', () => { - it('should create embedded components if the element contains any', async () => { - await doEmbed('
    blah
    '); - - expect(createComponentsSpy).toHaveBeenCalledTimes(1); - expect(prepareComponentFactoriesSpy).toHaveBeenCalledBefore(createComponentsSpy); - - prepareComponentFactoriesSpy.calls.reset(); - createComponentsSpy.calls.reset(); - - await doEmbed('blah'); - expect(createComponentsSpy).toHaveBeenCalledTimes(1); - expect(prepareComponentFactoriesSpy).toHaveBeenCalledBefore(createComponentsSpy); - }); - - it('should emit the created embedded components', async () => { - const componentRefs = await doEmbed(''); - expect(componentRefs).toBe(mockComponentRefs); - }); - - it('should not create embedded components if the element does not contain any', async () => { - await doEmbed(` - - <aio-lazy-bar></aio-lazy-bar> - `); - expect(createComponentsSpy).not.toHaveBeenCalled(); - }); - - it('should not create embedded components if the document is empty', async () => { - await doEmbed(''); - expect(createComponentsSpy).not.toHaveBeenCalled(); - }); - - it('should not create embedded components if unsubscribed from', async () => { - const preparePromise = Promise.resolve(); - prepareComponentFactoriesSpy.and.returnValue(preparePromise); - - // When not unsubscribed from... - host.innerHTML = ''; - service.embedInto(host).subscribe(); - await new Promise(resolve => setTimeout(resolve)); - expect(createComponentsSpy).toHaveBeenCalledTimes(1); - - createComponentsSpy.calls.reset(); - - // When unsubscribed from... - host.innerHTML = ''; - service.embedInto(host).subscribe().unsubscribe(); - await new Promise(resolve => setTimeout(resolve)); - expect(createComponentsSpy).not.toHaveBeenCalled(); - }); - }); - }); - - describe('#prepareComponentFactories()', () => { - let loader: MockNgModuleFactoryLoader; - let resolver: ComponentFactoryResolver; - let createComponentFactoriesSpy: jasmine.Spy; - - beforeEach(() => { - loader = TestBed.get(NgModuleFactoryLoader); - resolver = TestBed.get(ComponentFactoryResolver); - - createComponentFactoriesSpy = spyOn(service, 'createComponentFactories'); - }); - - [testLazyEmbeddedComponents, mockEmbeddedModulePath].forEach((compsOrPath: ComponentsOrModulePath) => { - const useComponents = Array.isArray(compsOrPath); - - describe(`(using ${useComponents ? 'component types' : 'module path'})`, () => { - const doPrepareComponentFactories = () => - service.prepareComponentFactories(compsOrPath); - - it('should return a promise', done => { - doPrepareComponentFactories().then(done, done.fail); - }); - - it('should create the component factories', async () => { - expect(createComponentFactoriesSpy).not.toHaveBeenCalled(); - - await doPrepareComponentFactories(); - expect(createComponentFactoriesSpy).toHaveBeenCalledTimes(1); - - const args = createComponentFactoriesSpy.calls.mostRecent().args; - expect(args[0]).toBe(testLazyEmbeddedComponents); - - if (useComponents) { - expect(args[1]).toBe(resolver); - } else { - expect(args[1]).not.toBe(resolver); - } - }); - - it('should not create create the component factories more than once', async () => { - const results = await Promise.all([ - doPrepareComponentFactories(), - doPrepareComponentFactories(), - ]); - - expect(createComponentFactoriesSpy).toHaveBeenCalledTimes(1); - expect(results[1]).toBe(results[0]); - - const anotherResult = await doPrepareComponentFactories(); - - expect(createComponentFactoriesSpy).toHaveBeenCalledTimes(1); - expect(anotherResult).toBe(results[0]); - }); - - it(`should ${useComponents ? 'not load' : 'load'} the embedded module`, async () => { - expect(loader.loadedPaths).toEqual([]); - - await doPrepareComponentFactories(); - const expectedLoadedPaths = useComponents ? [] : [mockEmbeddedModulePath]; - - expect(loader.loadedPaths).toEqual(expectedLoadedPaths); - }); - - it(`should not load the embedded module more than once`, async () => { - await Promise.all([ - doPrepareComponentFactories(), - doPrepareComponentFactories(), - ]); - const loadedPathCount = loader.loadedPaths.length; - - expect(loadedPathCount).toBeLessThan(2); - - await doPrepareComponentFactories(); - - expect(loader.loadedPaths.length).toBe(loadedPathCount); - }); - }); - }); - }); - - describe('#selectorToContentPropertyName()', () => { - it('should convert an element selector to a property name', () => { - expect(service.selectorToContentPropertyName('foobar')).toBe('foobarContent'); - expect(service.selectorToContentPropertyName('baz-qux')).toBe('bazQuxContent'); - }); - }); -}); diff --git a/aio/src/app/embed-components/embed-components.service.ts b/aio/src/app/embed-components/embed-components.service.ts deleted file mode 100644 index 9a9d6403ef..0000000000 --- a/aio/src/app/embed-components/embed-components.service.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { - ComponentFactory, ComponentFactoryResolver, ComponentRef, Inject, Injectable, InjectionToken, - Injector, NgModuleFactory, NgModuleFactoryLoader, Type -} from '@angular/core'; - -import { Observable } from 'rxjs/Observable'; -import { of } from 'rxjs/observable/of'; -import 'rxjs/add/operator/switchMap'; - - -export interface EmbeddedComponentFactory { - contentPropertyName: string; - factory: ComponentFactory; -} - -/** - * A mapping from combined component selectors (keys) to the corresponding components (values). The - * components can be specified either as a list of embedded components or a path to a module that - * provides embedded components (i.e. implements `WithEmbeddedComponents`). - */ -export interface EmbeddedComponentsMap { - [multiSelector: string]: ComponentsOrModulePath; -} - -/** - * Interface expected to be implemented by all modules that contribute components to the - * `EmbeddedComponentsMap`. - */ -export interface WithEmbeddedComponents { - embeddedComponents: Type[]; -} - -/** - * Either an array of components or the path to a module that implements `WithEmbeddedComponents`. - */ -export type ComponentsOrModulePath = Type[] | string; - -/** - * The injection token for the `EmbeddedComponentsMap`. - */ -export const EMBEDDED_COMPONENTS = new InjectionToken('EMBEDDED_COMPONENTS'); - -/** - * Embed components into an element. It takes care of indentifying the embedded components, loading - * the necessary modules and instantiating the components. - * - * Embeddable components are identified and loaded based on the info in `EmbeddedComponentsMap` - * (provided through dependency injection). - * - * The caller is responsible for trigering change detection and destroying the components as - * necessary. - */ -@Injectable() -export class EmbedComponentsService { - private componentFactoriesReady = new Map>(); - protected componentFactories = new Map(); - - constructor( - private injector: Injector, - private loader: NgModuleFactoryLoader, - private resolver: ComponentFactoryResolver, - @Inject(EMBEDDED_COMPONENTS) private embeddedComponentsMap: EmbeddedComponentsMap) { } - - /** - * Embed components into the specified element: - * - Load the necessary modules (if any). - * - Prepare the component factories. - * - Instantiate the components. - * - * Return the list of `ComponentRef`s. - */ - embedInto(elem: HTMLElement): Observable[]> { - const requiredComponents = Object.keys(this.embeddedComponentsMap) - .filter(selector => elem.querySelector(selector)) - .map(selector => this.embeddedComponentsMap[selector]); - - const factoriesReady = requiredComponents.map(compsOrPath => this.prepareComponentFactories(compsOrPath)); - - return !requiredComponents.length - ? of([]) - : of(undefined) - .switchMap(() => Promise.all(factoriesReady)) - .switchMap(() => [this.createComponents(elem)]); - } - - /** - * Resolve the embedded component factories (which will later be used to instantiate components). - */ - protected createComponentFactories(components: Type[], resolver: ComponentFactoryResolver): void { - for (const comp of components) { - const factory = resolver.resolveComponentFactory(comp); - const selector = factory.selector; - const contentPropertyName = this.selectorToContentPropertyName(selector); - this.componentFactories.set(selector, {contentPropertyName, factory}); - } - } - - /** - * Instantiate embedded components for the current contents of `elem`. - * (Store the original HTML contents of each element on the corresponding property for later - * retrieval by the component instance.) - */ - protected createComponents(elem: HTMLElement): ComponentRef[] { - const componentRefs: ComponentRef[] = []; - - this.componentFactories.forEach(({contentPropertyName, factory}, selector) => { - const componentHosts = elem.querySelectorAll(selector); - - // Cast due to https://github.com/Microsoft/TypeScript/issues/4947. - for (const host of componentHosts as any as HTMLElement[]) { - // Hack: Preserve the current element content, because the factory will empty it out. - // Security: The source of this `innerHTML` should always be authored by the documentation - // team and is considered to be safe. - (host as any)[contentPropertyName] = host.innerHTML; - componentRefs.push(factory.create(this.injector, [], host)); - } - }); - - return componentRefs; - } - - /** - * Prepare the component factories for the given components. - * If necessary, load and instantiate the module first. - */ - protected prepareComponentFactories(compsOrPath: ComponentsOrModulePath): Promise { - if (!this.componentFactoriesReady.has(compsOrPath)) { - const componentsAndResolverPromise = (typeof compsOrPath !== 'string') - ? Promise.resolve({components: compsOrPath, resolver: this.resolver}) - : this.loader.load(compsOrPath).then((ngModuleFactory: NgModuleFactory) => { - const moduleRef = ngModuleFactory.create(this.injector); - return { - components: moduleRef.instance.embeddedComponents, - resolver: moduleRef.componentFactoryResolver, - }; - }); - - const readyPromise = componentsAndResolverPromise - .then(({components, resolver}) => this.createComponentFactories(components, resolver)); - - this.componentFactoriesReady.set(compsOrPath, readyPromise); - } - - return this.componentFactoriesReady.get(compsOrPath)!; - } - - /** - * Compute the component content property name by converting the selector to camelCase and - * appending `Content`, e.g. `live-example` => `liveExampleContent`. - */ - protected selectorToContentPropertyName(selector: string): string { - return selector.replace(/-(.)/g, (match, $1) => $1.toUpperCase()) + 'Content'; - } -} diff --git a/aio/src/app/embedded/code/code-example.component.spec.ts b/aio/src/app/embedded/code/code-example.component.spec.ts index 6dc25a915a..e69de29bb2 100644 --- a/aio/src/app/embedded/code/code-example.component.spec.ts +++ b/aio/src/app/embedded/code/code-example.component.spec.ts @@ -1,137 +0,0 @@ -import { Component, DebugElement, Input } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; - -import { CodeExampleComponent } from './code-example.component'; - -describe('CodeExampleComponent', () => { - let hostComponent: HostComponent; - let codeComponent: TestCodeComponent; - let codeExampleDe: DebugElement; - let codeExampleComponent: CodeExampleComponent; - let fixture: ComponentFixture; - - const oneLineCode = `const foo = "bar";`; - - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [ CodeExampleComponent, HostComponent, TestCodeComponent ], - }); - }); - - function createComponent(codeExampleContent = '') { - fixture = TestBed.createComponent(HostComponent); - hostComponent = fixture.componentInstance; - codeExampleDe = fixture.debugElement.children[0]; - codeExampleComponent = codeExampleDe.componentInstance; - codeComponent = codeExampleDe.query(By.directive(TestCodeComponent)).componentInstance; - - // Copy the CodeExample's innerHTML (content) - // into the `codeExampleContent` property as the DocViewer does - codeExampleDe.nativeElement.codeExampleContent = codeExampleContent; - - fixture.detectChanges(); - } - - it('should create CodeExampleComponent', () => { - createComponent(); - expect(codeExampleComponent).toBeTruthy('CodeExampleComponent'); - }); - - it('should pass content to CodeComponent ()', () => { - createComponent(oneLineCode); - expect(codeComponent.code).toBe(oneLineCode); - }); - - it('should pass language to CodeComponent', () => { - TestBed.overrideComponent(HostComponent, { - set: {template: ''}}); - createComponent(oneLineCode); - expect(codeComponent.language).toBe('html'); - }); - - it('should pass linenums to CodeComponent', () => { - TestBed.overrideComponent(HostComponent, { - set: {template: ''}}); - createComponent(oneLineCode); - expect(codeComponent.linenums).toBe('true'); - }); - - it('should add title (header) when set `title` attribute', () => { - TestBed.overrideComponent(HostComponent, { - set: {template: ''}}); - createComponent(oneLineCode); - const actual = codeExampleDe.query(By.css('header')).nativeElement.textContent; - expect(actual).toBe('Great Example'); - }); - - it('should remove the `title` attribute after initialisation', () => { - TestBed.overrideComponent(HostComponent, { - set: {template: ''}}); - createComponent(oneLineCode); - expect(codeExampleDe.nativeElement.getAttribute('title')).toEqual(null); - }); - - it('should pass hideCopy to CodeComponent', () => { - TestBed.overrideComponent(HostComponent, { - set: {template: ''}}); - createComponent(oneLineCode); - expect(codeComponent.hideCopy).toBe(true); - }); - - it('should have `avoidFile` class when `avoid` atty present', () => { - TestBed.overrideComponent(HostComponent, { - set: {template: ''}}); - createComponent(oneLineCode); - const classes: DOMTokenList = codeExampleDe.nativeElement.classList; - expect(classes.contains('avoidFile')).toBe(true, 'has avoidFile class'); - expect(codeExampleComponent.isAvoid).toBe(true, 'isAvoid flag'); - expect(codeComponent.hideCopy).toBe(true, 'hiding copy button'); - }); - - it('should have `avoidFile` class when `.avoid` in path', () => { - TestBed.overrideComponent(HostComponent, { - set: {template: ''}}); - createComponent(oneLineCode); - const classes: DOMTokenList = codeExampleDe.nativeElement.classList; - expect(classes.contains('avoidFile')).toBe(true, 'has avoidFile class'); - expect(codeExampleComponent.isAvoid).toBe(true, 'isAvoid flag'); - expect(codeComponent.hideCopy).toBe(true, 'hide copy button flag'); - }); - - it('should not have `avoidFile` class in normal case', () => { - createComponent(oneLineCode); - const classes: DOMTokenList = codeExampleDe.nativeElement.classList; - expect(classes.contains('avoidFile')).toBe(false, 'avoidFile class'); - expect(codeExampleComponent.isAvoid).toBe(false, 'isAvoid flag'); - expect(codeComponent.hideCopy).toBe(false, 'hide copy button flag'); - }); -}); - -//// Test helpers //// -@Component({ - selector: 'aio-code', - template: ` -
    lang: {{language}}
    -
    linenums: {{linenums}}
    - code:
    {{someCode}}
    - ` -}) -class TestCodeComponent { - @Input() code = ''; - @Input() language: string; - @Input() linenums: string; - @Input() path: string; - @Input() region: string; - @Input() hideCopy: boolean; - - get someCode() { - return this.code && this.code.length > 30 ? this.code.substr(0, 30) + '...' : this.code; - } -} - -@Component({ - selector: 'aio-host-comp', - template: `` -}) -class HostComponent { } diff --git a/aio/src/app/embedded/code/code-example.component.ts b/aio/src/app/embedded/code/code-example.component.ts index dd2faaa4b7..e69de29bb2 100644 --- a/aio/src/app/embedded/code/code-example.component.ts +++ b/aio/src/app/embedded/code/code-example.component.ts @@ -1,66 +0,0 @@ -/* tslint:disable component-selector */ -import { Component, ElementRef, HostBinding, OnInit } from '@angular/core'; -import { getBoolFromAttribute } from 'app/shared/attribute-utils'; - -/** - * An embeddable code block that displays nicely formatted code. - * Example usage: - * - * ``` - * - * // a code block - * console.log('do stuff'); - * - * ``` - */ -@Component({ - selector: 'code-example', - template: ` -
    {{title}}
    - - ` -}) -export class CodeExampleComponent implements OnInit { - - classes: {}; - code: string; - language: string; - linenums: string; - path: string; - region: string; - title: string; - hideCopy: boolean; - - @HostBinding('class.avoidFile') - isAvoid = false; - - constructor(private elementRef: ElementRef) { - const element: HTMLElement = this.elementRef.nativeElement; - - this.language = element.getAttribute('language') || ''; - this.linenums = element.getAttribute('linenums') || ''; - this.path = element.getAttribute('path') || ''; - this.region = element.getAttribute('region') || ''; - this.title = element.getAttribute('title') || ''; - // Now remove the title attribute to prevent unwanted tooltip popups when hovering over the code. - element.removeAttribute('title'); - - const avoid = getBoolFromAttribute(element, 'avoid'); - this.isAvoid = avoid || this.path.indexOf('.avoid.') !== -1; - this.hideCopy = this.isAvoid || getBoolFromAttribute(element, ['hidecopy', 'hide-copy']); - - this.classes = { - 'headed-code': !!this.title, - 'simple-code': !this.title, - }; - } - - ngOnInit() { - // The `codeExampleContent` property is set by the DocViewer when it builds this component. - // It is the original innerHTML of the host element. - this.code = this.elementRef.nativeElement.codeExampleContent; - } -} diff --git a/aio/src/app/embedded/code/code-tabs.component.spec.ts b/aio/src/app/embedded/code/code-tabs.component.spec.ts deleted file mode 100644 index dff7b5ff3a..0000000000 --- a/aio/src/app/embedded/code/code-tabs.component.spec.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { Component, DebugElement, Input, NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { MatTabGroup, MatTabsModule } from '@angular/material'; -import { By } from '@angular/platform-browser'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; - -import { CodeTabsComponent } from './code-tabs.component'; - - -describe('CodeTabsComponent', () => { - let fixture: ComponentFixture; - let hostComponent: HostComponent; - let codeTabsDe: DebugElement; - let codeTabsComponent: CodeTabsComponent; - - const createComponentBasic = (codeTabsContent = '') => { - fixture = TestBed.createComponent(HostComponent); - hostComponent = fixture.componentInstance; - codeTabsDe = fixture.debugElement.children[0]; - codeTabsComponent = codeTabsDe.componentInstance; - - // Copy the CodeTab's innerHTML (content) - // into the `codeTabsContent` property as the DocViewer does. - codeTabsDe.nativeElement.codeTabsContent = codeTabsContent; - fixture.detectChanges(); - }; - - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [ CodeTabsComponent, HostComponent, TestCodeComponent ], - imports: [ CommonModule ], - schemas: [ NO_ERRORS_SCHEMA ], - }); - }); - - it('should create CodeTabsComponent', () => { - createComponentBasic(); - expect(codeTabsComponent).toBeTruthy('CodeTabsComponent'); - }); - - describe('(tab labels)', () => { - let labelElems: HTMLSpanElement[]; - - const createComponent = (codeTabsContent?: string) => { - createComponentBasic(codeTabsContent); - const labelDes = codeTabsDe.queryAll(By.css('.mat-tab-label')); - labelElems = labelDes.map(de => de.nativeElement.querySelector('span')); - }; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ MatTabsModule, NoopAnimationsModule ] - }); - }); - - it('should create a label for each tab', () => { - createComponent(` - foo - bar - baz - `); - - expect(labelElems.length).toBe(3); - }); - - it('should use the `title` as label', () => { - createComponent(` - foo - bar - `); - const texts = labelElems.map(s => s.textContent); - - expect(texts).toEqual(['foo-title', 'bar-title']); - }); - - it('should add the `class` to the label element', () => { - createComponent(` - foo - bar - `); - const classes = labelElems.map(s => s.className); - - expect(classes[0].split(' ')).toContain('foo-class'); - expect(classes[1].split(' ')).toContain('bar-class'); - }); - - it('should disable ripple effect on tab labels', () => { - createComponent(); - const tabsGroupComponent = codeTabsDe.query(By.directive(MatTabGroup)).componentInstance; - - expect(tabsGroupComponent.disableRipple).toBe(true); - }); - }); - - describe('(tab content)', () => { - let codeDes: DebugElement[]; - let codeComponents: TestCodeComponent[]; - - const createComponent = (codeTabsContent?: string) => { - createComponentBasic(codeTabsContent); - codeDes = codeTabsDe.queryAll(By.directive(TestCodeComponent)); - codeComponents = codeDes.map(de => de.componentInstance); - }; - - it('should pass `class` to CodeComponent ()', () => { - createComponent(` - foo - bar - `); - const classes = codeDes.map(de => de.nativeElement.className); - - expect(classes).toEqual(['foo-class', 'bar-class']); - }); - - it('should pass content to CodeComponent ()', () => { - createComponent(` - foo - bar - `); - const codes = codeComponents.map(c => c.code); - - expect(codes).toEqual(['foo', 'bar']); - }); - - it('should pass `language` to CodeComponent ()', () => { - createComponent(` - foo - bar - `); - const langs = codeComponents.map(c => c.language); - - expect(langs).toEqual(['foo-lang', 'bar-lang']); - }); - - it('should pass `linenums` to CodeComponent ()', () => { - createComponent(` - foo - bar - baz - qux - `); - const lnums = codeComponents.map(c => c.linenums); - - expect(lnums).toEqual(['foo-lnums', 'bar-lnums', '', '']); - }); - - it('should use the default value (if present on ) if `linenums` is not specified', () => { - TestBed.overrideComponent(HostComponent, { - set: { template: '' } - }); - - createComponent(` - foo - bar - baz - `); - const lnums = codeComponents.map(c => c.linenums); - - expect(lnums).toEqual(['foo-lnums', '', 'default-lnums']); - }); - - it('should pass `path` to CodeComponent ()', () => { - createComponent(` - foo - bar - `); - const paths = codeComponents.map(c => c.path); - - expect(paths).toEqual(['foo-path', 'bar-path']); - }); - - it('should default to an empty string if `path` is not spcified', () => { - createComponent(` - foo - bar - `); - const paths = codeComponents.map(c => c.path); - - expect(paths).toEqual(['', '']); - }); - - it('should pass `region` to CodeComponent ()', () => { - createComponent(` - foo - bar - `); - const regions = codeComponents.map(c => c.region); - - expect(regions).toEqual(['foo-region', 'bar-region']); - }); - - it('should default to an empty string if `region` is not spcified', () => { - createComponent(` - foo - bar - `); - const regions = codeComponents.map(c => c.region); - - expect(regions).toEqual(['', '']); - }); - - it('should pass `title` to CodeComponent ()', () => { - createComponent(` - foo - bar - `); - const titles = codeComponents.map(c => c.title); - - expect(titles).toEqual(['foo-title', 'bar-title']); - }); - }); -}); - -//// Test helpers //// -@Component({ - selector: 'aio-code', - template: ` -
    lang: {{ language }}
    -
    linenums: {{ linenums }}
    - code:
    {{ someCode }}
    - ` -}) -class TestCodeComponent { - @Input() code = ''; - @Input() hideCopy: boolean; - @Input() language: string; - @Input() linenums: string; - @Input() path: string; - @Input() region: string; - @Input() title: string; - - get someCode() { - if (this.code && this.code.length > 30) { - return `${this.code.substring(0, 30)}...`; - } - - return this.code; - } -} - -@Component({ - selector: 'aio-host-comp', - template: `` -}) -class HostComponent {} diff --git a/aio/src/app/embedded/code/code-tabs.component.ts b/aio/src/app/embedded/code/code-tabs.component.ts deleted file mode 100644 index 03283c5779..0000000000 --- a/aio/src/app/embedded/code/code-tabs.component.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* tslint:disable component-selector */ -import { Component, ElementRef, OnInit } from '@angular/core'; - -export interface TabInfo { - class: string|null; - code: string; - language: string|null; - linenums: any; - path: string; - region: string; - title: string|null; -} - -/** - * An embedded component used to generate tabbed code panes inside docs - * - * The innerHTML of the `` component should contain `` elements. - * Each `` has the same interface as the embedded `` component. - * The optional `linenums` attribute is the default `linenums` for each code pane. - */ -@Component({ - selector: 'code-tabs', - template: ` - - - - {{ tab.title }} - - - - - - ` -}) -export class CodeTabsComponent implements OnInit { - tabs: TabInfo[]; - linenumsDefault: string; - - constructor(private elementRef: ElementRef) { } - - ngOnInit() { - const element = this.elementRef.nativeElement; - this.linenumsDefault = this.getLinenums(element); - - // The `codeTabsContent` property is set by the DocViewer when it builds this component. - // It is the original innerHTML of the host element. - const content = element.codeTabsContent; - this.processContent(content); - } - - processContent(content: string) { - // We add it to an element so that we can easily parse the HTML - const element = document.createElement('div'); - // **Security:** `codeTabsContent` is provided by docs authors and as such is considered to - // be safe for innerHTML purposes. - element.innerHTML = content; - - this.tabs = []; - const codeExamples = element.querySelectorAll('code-pane'); - for (let i = 0; i < codeExamples.length; i++) { - const codeExample = codeExamples.item(i); - const tab = { - class: codeExample.getAttribute('class'), - code: codeExample.innerHTML, - language: codeExample.getAttribute('language'), - linenums: this.getLinenums(codeExample), - path: codeExample.getAttribute('path') || '', - region: codeExample.getAttribute('region') || '', - title: codeExample.getAttribute('title') - }; - this.tabs.push(tab); - } - } - - getLinenums(element: Element) { - const linenums = element.getAttribute('linenums'); - return linenums == null ? this.linenumsDefault : linenums; - } -} diff --git a/aio/src/app/embedded/embedded.module.ts b/aio/src/app/embedded/embedded.module.ts deleted file mode 100644 index bc0a886dec..0000000000 --- a/aio/src/app/embedded/embedded.module.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { NgModule, Type } from '@angular/core'; -import { CommonModule } from '@angular/common'; - -import { ContributorService } from './contributor/contributor.service'; -import { CopierService } from 'app/shared/copier.service'; -import { PrettyPrinter } from './code/pretty-printer.service'; -import { WithEmbeddedComponents } from 'app/embed-components/embed-components.service'; - -// Any components that we want to use inside embedded components must be declared or imported here -// It is not enough just to import them inside the AppModule - -// Reusable components (used inside embedded components) -import { MatIconModule } from '@angular/material/icon'; -import { MatSnackBarModule } from '@angular/material/snack-bar'; -import { MatTabsModule } from '@angular/material/tabs'; -import { CodeComponent } from './code/code.component'; -import { SharedModule } from 'app/shared/shared.module'; - -// Embedded Components -import { ApiListComponent } from './api/api-list.component'; -import { ApiService } from './api/api.service'; -import { CodeExampleComponent } from './code/code-example.component'; -import { CodeTabsComponent } from './code/code-tabs.component'; -import { ContributorListComponent } from './contributor/contributor-list.component'; -import { ContributorComponent } from './contributor/contributor.component'; -import { CurrentLocationComponent } from './current-location.component'; -import { FileNotFoundSearchComponent } from './search/file-not-found-search.component'; -import { LiveExampleComponent, EmbeddedStackblitzComponent } from './live-example/live-example.component'; -import { ResourceListComponent } from './resource/resource-list.component'; -import { ResourceService } from './resource/resource.service'; - -/** - * Components that can be embedded in docs, - * such as CodeExampleComponent, LiveExampleComponent,... - */ -export const embeddedComponents: Type[] = [ - ApiListComponent, CodeExampleComponent, CodeTabsComponent, ContributorListComponent, - CurrentLocationComponent, FileNotFoundSearchComponent, LiveExampleComponent, ResourceListComponent -]; - -@NgModule({ - imports: [ - CommonModule, - MatIconModule, - MatSnackBarModule, - MatTabsModule, - SharedModule - ], - declarations: [ - embeddedComponents, - CodeComponent, - ContributorComponent, - EmbeddedStackblitzComponent - ], - providers: [ - ApiService, - ContributorService, - CopierService, - PrettyPrinter, - ResourceService - ], - entryComponents: [ embeddedComponents ] -}) -export class EmbeddedModule implements WithEmbeddedComponents { - embeddedComponents = embeddedComponents; -} diff --git a/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts b/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts index b838926f26..37daf7eda7 100644 --- a/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts +++ b/aio/src/app/layout/doc-viewer/doc-viewer.component.spec.ts @@ -1,4 +1,3 @@ -import { ComponentRef } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Title, Meta } from '@angular/platform-browser'; @@ -6,11 +5,11 @@ import { Observable } from 'rxjs/Observable'; import { of } from 'rxjs/observable/of'; import { FILE_NOT_FOUND_ID, FETCHING_ERROR_ID } from 'app/documents/document.service'; -import { EmbedComponentsService } from 'app/embed-components/embed-components.service'; import { Logger } from 'app/shared/logger.service'; +import { CustomElementsModule } from 'app/custom-elements/custom-elements.module'; import { TocService } from 'app/shared/toc.service'; import { - MockEmbedComponentsService, MockTitle, MockTocService, ObservableWithSubscriptionSpies, + MockTitle, MockTocService, ObservableWithSubscriptionSpies, TestDocViewerComponent, TestModule, TestParentComponent } from 'testing/doc-viewer-utils'; import { MockLogger } from 'testing/logger.service'; @@ -25,7 +24,7 @@ describe('DocViewerComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [TestModule] + imports: [TestModule, CustomElementsModule], }); parentFixture = TestBed.createComponent(TestParentComponent); @@ -87,44 +86,7 @@ describe('DocViewerComponent', () => { }); }); - describe('#ngDoCheck()', () => { - let componentInstances: ComponentRef[]; - - beforeEach(() => { - componentInstances = [ - {changeDetectorRef: {detectChanges: jasmine.createSpy('detectChanges')}}, - {changeDetectorRef: {detectChanges: jasmine.createSpy('detectChanges')}}, - {changeDetectorRef: {detectChanges: jasmine.createSpy('detectChanges')}}, - ] as any; - docViewer.embeddedComponentRefs.push(...componentInstances); - }); - - afterEach(() => { - // Clean up the fake component instances, to avoid error in `ngOnDestroy()`. - docViewer.embeddedComponentRefs = []; - }); - - it('should detect changes on each active component instance', () => { - parentFixture.detectChanges(); - componentInstances.forEach(({changeDetectorRef: cd}) => { - expect(cd.detectChanges).toHaveBeenCalledTimes(1); - }); - - parentFixture.detectChanges(); - componentInstances.forEach(({changeDetectorRef: cd}) => { - expect(cd.detectChanges).toHaveBeenCalledTimes(2); - }); - }); - }); - describe('#ngOnDestroy()', () => { - it('should destroy the active embedded component instances', () => { - const destroyEmbeddedComponentsSpy = spyOn(docViewer, 'destroyEmbeddedComponents'); - docViewer.ngOnDestroy(); - - expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1); - }); - it('should stop responding to document changes', () => { const renderSpy = spyOn(docViewer, 'render').and.returnValue([undefined]); @@ -143,33 +105,6 @@ describe('DocViewerComponent', () => { }); }); - describe('#destroyEmbeddedComponents()', () => { - let componentInstances: ComponentRef[]; - - beforeEach(() => { - componentInstances = [ - {destroy: jasmine.createSpy('destroy#1')}, - {destroy: jasmine.createSpy('destroy#2')}, - {destroy: jasmine.createSpy('destroy#3')}, - ] as any; - docViewer.embeddedComponentRefs.push(...componentInstances); - }); - - it('should destroy each active component instance', () => { - docViewer.destroyEmbeddedComponents(); - - expect(componentInstances.length).toBe(3); - componentInstances.forEach(comp => expect(comp.destroy).toHaveBeenCalledTimes(1)); - }); - - it('should clear the list of active component instances', () => { - expect(docViewer.embeddedComponentRefs.length).toBeGreaterThan(0); - - docViewer.destroyEmbeddedComponents(); - expect(docViewer.embeddedComponentRefs.length).toBe(0); - }); - }); - describe('#prepareTitleAndToc()', () => { const EMPTY_DOC = ''; const DOC_WITHOUT_H1 = 'Some content'; @@ -357,8 +292,6 @@ describe('DocViewerComponent', () => { }); describe('#render()', () => { - let destroyEmbeddedComponentsSpy: jasmine.Spy; - let embedIntoSpy: jasmine.Spy; let prepareTitleAndTocSpy: jasmine.Spy; let swapViewsSpy: jasmine.Spy; @@ -367,10 +300,6 @@ describe('DocViewerComponent', () => { docViewer.render({contents, id}).subscribe(resolve, reject)); beforeEach(() => { - const embedComponentsService = TestBed.get(EmbedComponentsService) as MockEmbedComponentsService; - - destroyEmbeddedComponentsSpy = spyOn(docViewer, 'destroyEmbeddedComponents'); - embedIntoSpy = embedComponentsService.embedInto.and.returnValue(of([])); prepareTitleAndTocSpy = spyOn(docViewer, 'prepareTitleAndToc'); swapViewsSpy = spyOn(docViewer, 'swapViews').and.returnValue(of(undefined)); }); @@ -404,7 +333,7 @@ describe('DocViewerComponent', () => { expect(docViewerEl.textContent).toBe(''); }); - it('should prepare the title and ToC (before embedding components)', async () => { + it('should prepare the title and ToC', async () => { prepareTitleAndTocSpy.and.callFake((targetEl: HTMLElement, docId: string) => { expect(targetEl.innerHTML).toBe('Some content'); expect(docId).toBe('foo'); @@ -413,7 +342,6 @@ describe('DocViewerComponent', () => { await doRender('Some content', 'foo'); expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); - expect(prepareTitleAndTocSpy).toHaveBeenCalledBefore(embedIntoSpy); }); it('should set the title and ToC (after the content has been set)', async () => { @@ -456,73 +384,7 @@ describe('DocViewerComponent', () => { }); }); - describe('(embedding components)', () => { - it('should embed components', async () => { - await doRender('Some content'); - expect(embedIntoSpy).toHaveBeenCalledTimes(1); - expect(embedIntoSpy).toHaveBeenCalledWith(docViewer.nextViewContainer); - }); - - 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([docViewer.nextViewContainer]); - expect(embedIntoSpy.calls.argsFor(1)).toEqual([docViewer.nextViewContainer]); - }); - - it('should store the embedded components', async () => { - const embeddedComponents: ComponentRef[] = []; - 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('(destroying old embedded components)', () => { - it('should destroy old embedded components after creating new embedded components', async () => { - await doRender('
    '); - - expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1); - expect(embedIntoSpy).toHaveBeenCalledBefore(destroyEmbeddedComponentsSpy); - }); - - it('should still destroy old embedded components if the new document is empty', async () => { - await doRender(''); - expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1); - - await doRender(null); - expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(2); - }); - }); - describe('(swapping views)', () => { - it('should swap the views after destroying old embedded components', async () => { - await doRender('
    '); - - expect(swapViewsSpy).toHaveBeenCalledTimes(1); - expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledBefore(swapViewsSpy); - }); - it('should still swap the views if the document is empty', async () => { await doRender(''); expect(swapViewsSpy).toHaveBeenCalledTimes(1); @@ -572,8 +434,6 @@ describe('DocViewerComponent', () => { await doRender('Some content', 'foo'); expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); - expect(embedIntoSpy).not.toHaveBeenCalled(); - expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled(); expect(swapViewsSpy).not.toHaveBeenCalled(); expect(docViewer.nextViewContainer.innerHTML).toBe(''); expect(logger.output.error).toEqual([ @@ -584,49 +444,6 @@ describe('DocViewerComponent', () => { expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); }); - it('when `EmbedComponentsService.embedInto()` fails', async () => { - const error = Error('Typical `embedInto()` error'); - embedIntoSpy.and.callFake(() => { - expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); - throw error; - }); - - await doRender('Some content', 'bar'); - - expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); - expect(embedIntoSpy).toHaveBeenCalledTimes(1); - expect(destroyEmbeddedComponentsSpy).not.toHaveBeenCalled(); - expect(swapViewsSpy).not.toHaveBeenCalled(); - expect(docViewer.nextViewContainer.innerHTML).toBe(''); - expect(logger.output.error).toEqual([ - [jasmine.any(Error)] - ]); - expect(logger.output.error[0][0].message).toEqual(`[DocViewer] Error preparing document 'bar': ${error.stack}`); - expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'googlebot', content: 'noindex' }); - expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); - }); - - it('when `destroyEmbeddedComponents()` fails', async () => { - const error = Error('Typical `destroyEmbeddedComponents()` error'); - destroyEmbeddedComponentsSpy.and.callFake(() => { - expect(docViewer.nextViewContainer.innerHTML).not.toBe(''); - throw error; - }); - - await doRender('Some content', 'baz'); - - expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); - expect(embedIntoSpy).toHaveBeenCalledTimes(1); - expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1); - expect(swapViewsSpy).not.toHaveBeenCalled(); - expect(docViewer.nextViewContainer.innerHTML).toBe(''); - expect(logger.output.error).toEqual([ - [jasmine.any(Error)] - ]); - expect(logger.output.error[0][0].message).toEqual(`[DocViewer] Error preparing document 'baz': ${error.stack}`); - expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'googlebot', content: 'noindex' }); - expect(TestBed.get(Meta).addTag).toHaveBeenCalledWith({ name: 'robots', content: 'noindex' }); - }); it('when `swapViews()` fails', async () => { const error = Error('Typical `swapViews()` error'); @@ -638,8 +455,6 @@ describe('DocViewerComponent', () => { await doRender('Some content', 'qux'); expect(prepareTitleAndTocSpy).toHaveBeenCalledTimes(1); - expect(embedIntoSpy).toHaveBeenCalledTimes(1); - expect(destroyEmbeddedComponentsSpy).toHaveBeenCalledTimes(1); expect(swapViewsSpy).toHaveBeenCalledTimes(1); expect(docViewer.nextViewContainer.innerHTML).toBe(''); expect(logger.output.error).toEqual([ @@ -671,25 +486,13 @@ describe('DocViewerComponent', () => { }); describe('(events)', () => { - it('should emit `docReady` after embedding components', async () => { + it('should emit `docReady`', async () => { const onDocReadySpy = jasmine.createSpy('onDocReady'); docViewer.docReady.subscribe(onDocReadySpy); await doRender('Some content'); expect(onDocReadySpy).toHaveBeenCalledTimes(1); - expect(embedIntoSpy).toHaveBeenCalledBefore(onDocReadySpy); - }); - - it('should emit `docReady` before destroying old embedded components and swapping views', async () => { - const onDocReadySpy = jasmine.createSpy('onDocReady'); - docViewer.docReady.subscribe(onDocReadySpy); - - await doRender('Some content'); - - expect(onDocReadySpy).toHaveBeenCalledTimes(1); - expect(onDocReadySpy).toHaveBeenCalledBefore(destroyEmbeddedComponentsSpy); - expect(onDocReadySpy).toHaveBeenCalledBefore(swapViewsSpy); }); it('should emit `docRendered` after swapping views', async () => { diff --git a/aio/src/app/layout/doc-viewer/doc-viewer.component.ts b/aio/src/app/layout/doc-viewer/doc-viewer.component.ts index db2e8c1962..5e103aded0 100644 --- a/aio/src/app/layout/doc-viewer/doc-viewer.component.ts +++ b/aio/src/app/layout/doc-viewer/doc-viewer.component.ts @@ -1,4 +1,4 @@ -import { Component, ComponentRef, DoCheck, ElementRef, EventEmitter, Input, OnDestroy, Output } from '@angular/core'; +import { Component, ElementRef, EventEmitter, Input, OnDestroy, Output } from '@angular/core'; import { Title, Meta } from '@angular/platform-browser'; import { Observable } from 'rxjs/Observable'; @@ -10,9 +10,9 @@ import 'rxjs/add/operator/switchMap'; import 'rxjs/add/operator/takeUntil'; import { DocumentContents, FILE_NOT_FOUND_ID, FETCHING_ERROR_ID } 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 { ElementsLoader } from 'app/custom-elements/elements-loader'; // Constants @@ -28,7 +28,7 @@ const initialDocViewerContent = initialDocViewerElement ? initialDocViewerElemen // TODO(robwormald): shadow DOM and emulated don't work here (?!) // encapsulation: ViewEncapsulation.Native }) -export class DocViewerComponent implements DoCheck, OnDestroy { +export class DocViewerComponent implements OnDestroy { // Enable/Disable view transition animations. static animationsEnabled = true; @@ -38,7 +38,6 @@ export class DocViewerComponent implements DoCheck, OnDestroy { private onDestroy$ = new EventEmitter(); private docContents$ = new EventEmitter(); - protected embeddedComponentRefs: ComponentRef[] = []; protected currViewContainer: HTMLElement = document.createElement('div'); protected nextViewContainer: HTMLElement = document.createElement('div'); @@ -69,12 +68,11 @@ export class DocViewerComponent implements DoCheck, OnDestroy { constructor( elementRef: ElementRef, - private embedComponentsService: EmbedComponentsService, private logger: Logger, private titleService: Title, private metaService: Meta, - private tocService: TocService - ) { + private tocService: TocService, + private elementsLoader: ElementsLoader) { this.hostElement = elementRef.nativeElement; // Security: the initialDocViewerContent comes from the prerendered DOM and is considered to be secure this.hostElement.innerHTML = initialDocViewerContent; @@ -83,29 +81,16 @@ export class DocViewerComponent implements DoCheck, OnDestroy { this.currViewContainer = this.hostElement.firstElementChild as HTMLElement; } - this.onDestroy$.subscribe(() => this.destroyEmbeddedComponents()); this.docContents$ .switchMap(newDoc => this.render(newDoc)) .takeUntil(this.onDestroy$) .subscribe(); } - ngDoCheck() { - this.embeddedComponentRefs.forEach(comp => comp.changeDetectorRef.detectChanges()); - } - ngOnDestroy() { this.onDestroy$.emit(); } - /** - * Destroy the embedded components to avoid memory leaks. - */ - protected destroyEmbeddedComponents(): void { - this.embeddedComponentRefs.forEach(comp => comp.destroy()); - this.embeddedComponentRefs = []; - } - /** * Prepare for setting the window title and ToC. * Return a function to actually set them. @@ -154,10 +139,8 @@ export class DocViewerComponent implements DoCheck, OnDestroy { // and is considered to be safe. .do(() => this.nextViewContainer.innerHTML = doc.contents || '') .do(() => addTitleAndToc = this.prepareTitleAndToc(this.nextViewContainer, doc.id)) - .switchMap(() => this.embedComponentsService.embedInto(this.nextViewContainer)) + .switchMap(() => this.elementsLoader.loadContainingCustomElements(this.nextViewContainer)) .do(() => this.docReady.emit()) - .do(() => this.destroyEmbeddedComponents()) - .do(componentRefs => this.embeddedComponentRefs = componentRefs) .switchMap(() => this.swapViews(addTitleAndToc)) .do(() => this.docRendered.emit()) .catch(err => { diff --git a/aio/src/index.html b/aio/src/index.html index 4350c2aa19..66959ba1a1 100644 --- a/aio/src/index.html +++ b/aio/src/index.html @@ -109,6 +109,7 @@ xhr.send(); } + diff --git a/aio/src/polyfills.ts b/aio/src/polyfills.ts index d5d0302a14..1487c63c87 100644 --- a/aio/src/polyfills.ts +++ b/aio/src/polyfills.ts @@ -30,6 +30,8 @@ /** HACK: force import of environment.ts/environment.prod.ts to load env specific polyfills */ import './environments/environment'; +/** window.customElements */ +import '@webcomponents/custom-elements/custom-elements.min'; /** ALL Firefox browsers require the following to support `@angular/animation`. **/ // import 'web-animations-js'; // Run `npm install --save web-animations-js`. diff --git a/aio/src/testing/doc-viewer-utils.ts b/aio/src/testing/doc-viewer-utils.ts index 3bdf14fa5a..c153002ba8 100644 --- a/aio/src/testing/doc-viewer-utils.ts +++ b/aio/src/testing/doc-viewer-utils.ts @@ -1,10 +1,9 @@ -import { Component, ComponentRef, NgModule, ViewChild } from '@angular/core'; +import { Component, NgModule, ViewChild } from '@angular/core'; import { Title, Meta } 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'; @@ -17,11 +16,9 @@ import { MockLogger } from 'testing/logger.service'; //////////////////////////////////////////////////////////////////////////////////////////////////// export class TestDocViewerComponent extends DocViewerComponent { - embeddedComponentRefs: ComponentRef[]; currViewContainer: HTMLElement; nextViewContainer: HTMLElement; - destroyEmbeddedComponents(): void { return null as any; } prepareTitleAndToc(targetElem: HTMLElement, docId: string): () => void { return null as any; } render(doc: DocumentContents): Observable { return null as any; } swapViews(onInsertedCb?: () => void): Observable { return null as any; } @@ -43,10 +40,6 @@ export class TestParentComponent { } // Mock services. -export class MockEmbedComponentsService { - embedInto = jasmine.createSpy('EmbedComponentsService#embedInto'); -} - export class MockTitle { setTitle = jasmine.createSpy('Title#reset'); } @@ -68,7 +61,6 @@ export class MockTocService { ], providers: [ { provide: Logger, useClass: MockLogger }, - { provide: EmbedComponentsService, useClass: MockEmbedComponentsService }, { provide: Title, useClass: MockTitle }, { provide: Meta, useClass: MockMeta }, { provide: TocService, useClass: MockTocService }, diff --git a/aio/src/testing/embed-components-utils.ts b/aio/src/testing/embed-components-utils.ts deleted file mode 100644 index 898e8dc92b..0000000000 --- a/aio/src/testing/embed-components-utils.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { - Component, ComponentFactoryResolver, ComponentRef, CompilerFactory, ElementRef, NgModule, - NgModuleFactoryLoader, OnInit, Type, ViewChild, getPlatform -} from '@angular/core'; - -import { - ComponentsOrModulePath, EMBEDDED_COMPONENTS, EmbedComponentsService, EmbeddedComponentFactory, - WithEmbeddedComponents -} from 'app/embed-components/embed-components.service'; - - -//////////////////////////////////////////////////////////////////////////////////////////////////// -/// `TestEmbedComponentsService` (for exposing internal methods as public). /// -/// Only used for type-casting; the actual implementation is irrelevant. /// -//////////////////////////////////////////////////////////////////////////////////////////////////// - -export class TestEmbedComponentsService extends EmbedComponentsService { - componentFactories: Map; - - createComponentFactories(components: Type[], resolver: ComponentFactoryResolver): void { return null as any; } - createComponents(elem: HTMLElement): ComponentRef[] { return null as any; } - prepareComponentFactories(compsOrPath: ComponentsOrModulePath): Promise { return null as any; } - selectorToContentPropertyName(selector: string): string { return null as any; } -} - - -//////////////////////////////////////////////////////////////////////////////////////////////////// -/// Mock `EmbeddedModule` and test components. /// -//////////////////////////////////////////////////////////////////////////////////////////////////// - -// Test embedded components. -@Component({ - selector: 'aio-eager-foo', - template: `Eager Foo Component`, -}) -class EagerFooComponent { } - -@Component({ - selector: 'aio-eager-bar', - template: ` -
    -

    Eager Bar Component

    -

    -
    - `, -}) -class EagerBarComponent implements OnInit { - @ViewChild('content') contentRef: ElementRef; - - constructor(public elementRef: ElementRef) { } - - // Project content in `ngOnInit()` just like in `CodeExampleComponent`. - ngOnInit() { - // Security: This is a test component; never deployed. - this.contentRef.nativeElement.innerHTML = this.elementRef.nativeElement.aioEagerBarContent; - } -} - -@Component({ - selector: 'aio-lazy-foo', - template: `Lazy Foo Component`, -}) -class LazyFooComponent { } - -@Component({ - selector: 'aio-lazy-bar', - template: ` -
    -

    Lazy Bar Component

    -

    -
    - `, -}) -class LazyBarComponent implements OnInit { - @ViewChild('content') contentRef: ElementRef; - - constructor(public elementRef: ElementRef) { } - - // Project content in `ngOnInit()` just like in `CodeExampleComponent`. - ngOnInit() { - // Security: This is a test component; never deployed. - this.contentRef.nativeElement.innerHTML = this.elementRef.nativeElement.aioLazyBarContent; - } -} - -// Export test embedded selectors and components. -export const testEagerEmbeddedSelectors = ['aio-eager-foo', 'aio-eager-bar']; -export const testEagerEmbeddedComponents = [EagerFooComponent, EagerBarComponent]; -export const testLazyEmbeddedSelectors = ['aio-lazy-foo', 'aio-lazy-bar']; -export const testLazyEmbeddedComponents = [LazyFooComponent, LazyBarComponent]; - -// Export mock `EmbeddedModule` and path. -export const mockEmbeddedModulePath = 'mock/mock-embedded#MockEmbeddedModule'; - -@NgModule({ - declarations: [testLazyEmbeddedComponents], - entryComponents: [testLazyEmbeddedComponents], -}) -class MockEmbeddedModule implements WithEmbeddedComponents { - embeddedComponents = testLazyEmbeddedComponents; -} - - -//////////////////////////////////////////////////////////////////////////////////////////////////// -/// `TestModule`. /// -//////////////////////////////////////////////////////////////////////////////////////////////////// - -// Mock services. -export class MockNgModuleFactoryLoader implements NgModuleFactoryLoader { - loadedPaths: string[] = []; - - load(path: string) { - this.loadedPaths.push(path); - - const platformRef = getPlatform(); - const compilerFactory = platformRef!.injector.get(CompilerFactory) as CompilerFactory; - const compiler = compilerFactory.createCompiler([]); - - return compiler.compileModuleAsync(MockEmbeddedModule); - } -} - -@NgModule({ - providers: [ - EmbedComponentsService, - { provide: NgModuleFactoryLoader, useClass: MockNgModuleFactoryLoader }, - { - provide: EMBEDDED_COMPONENTS, - useValue: { - [testEagerEmbeddedSelectors.join(',')]: testEagerEmbeddedComponents, - [testLazyEmbeddedSelectors.join(',')]: mockEmbeddedModulePath, - }, - }, - ], - declarations: [testEagerEmbeddedComponents], - entryComponents: [testEagerEmbeddedComponents], -}) -export class TestModule { } diff --git a/aio/yarn.lock b/aio/yarn.lock index 6431fd29d7..05c477a53e 100644 --- a/aio/yarn.lock +++ b/aio/yarn.lock @@ -130,6 +130,11 @@ dependencies: tslib "^1.7.1" +"@angular/elements@file:../dist/packages-dist/elements": + version "6.0.0-beta.5-8531ff3335" + dependencies: + tslib "^1.7.1" + "@angular/forms@^5.2.0": version "5.2.0" resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-5.2.0.tgz#b5fb6b9ba97334bca0e3202d7fee6b9162cbc824" @@ -331,6 +336,14 @@ dependencies: "@types/node" "*" +"@webcomponents/custom-elements@^1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@webcomponents/custom-elements/-/custom-elements-1.0.8.tgz#b7b8ef7248f7681d1ad4286a0ada5fe3c2bc7228" + +"@webcomponents/webcomponentsjs@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-1.1.0.tgz#1392799c266fca142622a720176f688beb74d181" + JSONStream@^1.2.1: version "1.3.1" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.1.tgz#707f761e01dae9e16f1bcf93703b78c70966579a"