feat(aio): migrate embedded comp to elements (#22413)

PR Close #22413
This commit is contained in:
Andrew Seguin 2018-02-28 12:05:59 -08:00 committed by Miško Hevery
parent 22b96b9690
commit 7c9b411777
69 changed files with 1021 additions and 1753 deletions

View File

@ -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",

View File

@ -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');
}

View File

@ -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<string[]>((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');
});
});

View File

@ -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 `<mat-icon>` 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 { }

View File

@ -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:');
});
});

View File

@ -59,12 +59,12 @@ export class AnnouncementBarComponent implements OnInit {
ngOnInit() {
this.http.get<Announcement[]>(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);

View File

@ -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<any> = AnnouncementBarComponent;
}

View File

@ -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)}`);
});
}

View File

@ -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,

View File

@ -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<any> = ApiListComponent;
}

View File

@ -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<HostComponent>;
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-example [title]="title" [path]="path" [hidecopy]="hidecopy">
{{code}}
</code-example>
`
})
class HostComponent {
code = `const foo = "bar";`;
title = 'Great Example';
path = 'code-path';
hidecopy: boolean | string = false;
@ViewChild(CodeExampleComponent) codeExampleComponent: CodeExampleComponent;
}

View File

@ -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:
*
* ```
* <code-example language="ts" linenums="2" class="special" title="Do Stuff">
* // a code block
* console.log('do stuff');
* </code-example>
* ```
*/
@Component({
selector: 'code-example',
template: `
<!-- Content projection is used to get the content HTML provided to this component -->
<div #content style="display: none"><ng-content></ng-content></div>
<header *ngIf="title">{{title}}</header>
<aio-code [ngClass]="classes"
[language]="language"
[linenums]="linenums"
[path]="path"
[region]="region"
[hideCopy]="hidecopy"
[title]="title">
</aio-code>
`,
})
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;
}
}

View File

@ -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<any> = CodeExampleComponent;
}

View File

@ -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<HostComponent>;
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-tabs linenums="default-linenums">
<code-pane class="class-A"
language="language-A"
linenums="linenums-A"
path="path-A"
region="region-A"
title="title-A">
Code example 1
</code-pane>
<code-pane class="class-B"
language="language-B"
path="path-B"
region="region-B"
title="title-B">
Code example 2
</code-pane>
</code-tabs>
`
})
class HostComponent {
@ViewChild(CodeTabsComponent) codeTabsComponent: CodeTabsComponent;
}

View File

@ -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 `<code-tabs>` component should contain `<code-pane>` elements.
* Each `<code-pane>` has the same interface as the embedded `<code-example>` component.
* The optional `linenums` attribute is the default `linenums` for each code pane.
*/
@Component({
selector: 'code-tabs',
template: `
<!-- Use content projection so that the provided HTML's code-panes can be split into tabs -->
<div #content style="display: none"><ng-content></ng-content></div>
<mat-tab-group class="code-tab-group" disableRipple>
<mat-tab style="overflow-y: hidden;" *ngFor="let tab of tabs">
<ng-template mat-tab-label>
<span class="{{ tab.class }}">{{ tab.title }}</span>
</ng-template>
<aio-code class="{{ tab.class }}"
[language]="tab.language"
[linenums]="tab.linenums"
[path]="tab.path"
[region]="tab.region"
[title]="tab.title">
</aio-code>
</mat-tab>
</mat-tab-group>
`,
})
export class CodeTabsComponent implements OnInit, AfterViewInit {
tabs: TabInfo[];
@Input('linenums') linenums: string;
@ViewChild('content') content;
@ViewChildren(CodeComponent) codeComponents: QueryList<CodeComponent>;
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')
};
}
}

View File

@ -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<any> = CodeTabsComponent;
}

View File

@ -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<HostComponent>;
// 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 `<li>`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');
// `<li>`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(/&lt;/g, '<').replace(/&gt;/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: `
<aio-code [code]="code" [language]="language"
<aio-code [language]="language"
[linenums]="linenums" [path]="path" [region]="region"
[hideCopy]="hideCopy" [title]="title"></aio-code>
`
})
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 {

View File

@ -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
*
* ```
* <aio-code
* [code]="variableContainingCode"
* [language]="ts"
* [linenums]="true"
* [path]="router/src/app/app.module.ts"
* [region]="animations-module">
* </aio-code>
* ```
*
*
* 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(`<p class="code-missing">The code sample is missing${srcMsg}</p>`);
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(`<p class="code-missing">The code sample is missing${srcMsg}</p>`);
}
/** 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();
}

View File

@ -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 { }

View File

@ -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); };
});

View File

@ -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<any> = ContributorListComponent;
}

View File

@ -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';

View File

@ -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) { }
}

View File

@ -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<any> = CurrentLocationComponent;
}

View File

@ -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 { }

View File

@ -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<string>;
}
/** 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<string, string>();
ELEMENT_MODULE_PATHS_AS_ROUTES.forEach(route => {
ELEMENT_MODULE_PATHS.set(route.selector, route.loadChildren);
});

View File

@ -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<any> {
selector: string;
componentType: Type<any>;
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<any>): ComponentRef<string> {
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<any>;
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 = `<element-a-selector></element-a-selector>`;
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 = `<element-a-selector></element-a-selector>`;
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<any>;
}
class FakeComponentFactoryResolver extends ComponentFactoryResolver {
constructor(private modulePath) { super(); }
resolveComponentFactory(component: Type<any>): ComponentFactory<any> {
return FAKE_COMPONENT_FACTORIES.get(this.modulePath)!;
}
}
class FakeModuleRef extends NgModuleRef<WithCustomElementComponent> {
injector: Injector;
componentFactoryResolver = new FakeComponentFactoryResolver(this.modulePath);
instance: WithCustomElementComponent = new FakeCustomElementModule();
constructor(private modulePath) { super(); }
destroy() {}
onDestroy(callback: () => void) {}
}
class FakeModuleFactory extends NgModuleFactory<any> {
moduleType: Type<any>;
moduleRefToCreate = new FakeModuleRef(this.modulePath);
constructor(private modulePath) { super(); }
create(parentInjector: Injector | null): NgModuleRef<any> {
return this.moduleRefToCreate;
}
}
class FakeModuleFactoryLoader extends NgModuleFactoryLoader {
load(modulePath: string): Promise<NgModuleFactory<any>> {
const fakeModuleFactory = new FakeModuleFactory(modulePath);
return Promise.resolve(fakeModuleFactory);
}
}

View File

@ -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<string, string>;
constructor(private moduleFactoryLoader: NgModuleFactoryLoader,
private moduleRef: NgModuleRef<any>,
@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<null> {
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<WithCustomElementComponent>): ComponentFactory<string> {
const resolver = customElementModuleRef.componentFactoryResolver;
const customElementComponent = customElementModuleRef.instance.customElementComponent;
return resolver.resolveComponentFactory(customElementComponent);
}
}

View File

@ -0,0 +1,7 @@
<mat-expansion-panel style="background: inherit">
<mat-expansion-panel-header>
{{title}}
</mat-expansion-panel-header>
<ng-content></ng-content>
</mat-expansion-panel>

View File

@ -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;
}

View File

@ -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<any> = ExpandableSectionComponent;
}

View File

@ -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<any> = LiveExampleComponent;
}

View File

@ -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<any> = ResourceListComponent;
}

View File

@ -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<any> = FileNotFoundSearchComponent;
}

View File

@ -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 {
}

View File

@ -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 = `
<p>Header</p>
<p><aio-eager-foo></aio-eager-foo></p>
<p><aio-eager-bar></aio-eager-bar></p>
<p>Footer</p>
`;
const componentRefs = service.createComponents(host);
expect(host.innerHTML).toContain('Foo Component');
expect(host.innerHTML).toContain('Bar Component');
expect(componentRefs.length).toBe(2);
expect(componentRefs[0].instance).toEqual(jasmine.any(FooComponent));
expect(componentRefs[1].instance).toEqual(jasmine.any(BarComponent));
});
it('should apply embedded components to all matching elements', () => {
host.innerHTML = `
<p>Header</p>
<p><aio-eager-foo></aio-eager-foo></p>
<p><aio-eager-bar></aio-eager-bar></p>
<p><aio-eager-foo></aio-eager-foo></p>
<p><aio-eager-bar></aio-eager-bar></p>
<p>Footer</p>
`;
const componentRefs = service.createComponents(host);
expect(componentRefs.length).toBe(4);
expect(componentRefs[0].instance).toEqual(jasmine.any(FooComponent));
expect(componentRefs[1].instance).toEqual(jasmine.any(FooComponent));
expect(componentRefs[2].instance).toEqual(jasmine.any(BarComponent));
expect(componentRefs[3].instance).toEqual(jasmine.any(BarComponent));
});
it('should allow projecting content by assigning it on the element', () => {
const projectedContent = 'Projected content';
host.innerHTML = `
<p>Header</p>
<p><aio-eager-bar>${projectedContent}</aio-eager-bar></p>
<p>Footer</p>
`;
const componentRefs = service.createComponents(host);
componentRefs[0].changeDetectorRef.detectChanges();
const barEl = host.querySelector('aio-eager-bar')!;
expect((barEl as any)['aioEagerBarContent']).toBe(projectedContent);
expect(barEl.innerHTML).toContain(projectedContent);
});
// Because `FooComponent` is processed before `BarComponent`...
it('should apply `FooComponent` within `BarComponent`', () => {
host.innerHTML = `
<aio-eager-bar>
<aio-eager-foo></aio-eager-foo>
</aio-eager-bar>
`;
const componentRefs = service.createComponents(host);
componentRefs.forEach(ref => ref.changeDetectorRef.detectChanges());
expect(host.innerHTML).toContain('Foo Component');
expect(host.innerHTML).toContain('Bar Component');
expect(componentRefs.length).toBe(2);
expect(componentRefs[0].instance).toEqual(jasmine.any(FooComponent));
expect(componentRefs[1].instance).toEqual(jasmine.any(BarComponent));
});
// Because `BarComponent` is processed after `FooComponent`...
it('should not apply `BarComponent` within `FooComponent`', () => {
host.innerHTML = `
<aio-eager-foo>
<aio-eager-bar></aio-eager-bar>
</aio-eager-foo>
`;
const componentRefs = service.createComponents(host);
componentRefs.forEach(ref => ref.changeDetectorRef.detectChanges());
expect(host.innerHTML).toContain('Foo Component');
expect(host.innerHTML).not.toContain('Bar Component');
expect(componentRefs.length).toBe(1);
expect(componentRefs[0].instance).toEqual(jasmine.any(FooComponent));
});
});
describe('#embedInto()', () => {
let mockComponentRefs: ComponentRef<any>[];
let createComponentsSpy: jasmine.Spy;
let prepareComponentFactoriesSpy: jasmine.Spy;
const doEmbed = (contents: string) =>
new Promise<ComponentRef<any>[]>((resolve, reject) => {
host.innerHTML = contents;
service.embedInto(host).subscribe(resolve, reject);
});
beforeEach(() => {
mockComponentRefs = [{foo: true}, {bar: true}] as any as ComponentRef<any>[];
createComponentsSpy = spyOn(service, 'createComponents').and.returnValue(mockComponentRefs);
prepareComponentFactoriesSpy = spyOn(service, 'prepareComponentFactories')
.and.returnValue(Promise.resolve());
});
it('should return an observable', done => {
service.embedInto(host).subscribe(done, done.fail);
});
describe('(preparing component factories)', () => {
it('should return an array of `ComponentRef`s', async () => {
// When there are embedded components.
expect(await doEmbed('<aio-eager-foo></aio-eager-foo>')).toEqual(mockComponentRefs);
expect(await doEmbed('<aio-lazy-bar></aio-lazy-bar>')).toEqual(mockComponentRefs);
// When there are no embedded components.
expect(await doEmbed('<div>Test</div>')).toEqual([]);
expect(await doEmbed('')).toEqual([]);
});
it('should prepare all component factories if there are embedded components', async () => {
await doEmbed(`
<div><aio-eager-foo><b>foo</b></aio-eager-foo></div>
<span><aio-lazy-foo><i>bar</i></aio-lazy-foo></span>
`);
expect(prepareComponentFactoriesSpy).toHaveBeenCalledTimes(2);
expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(testEagerEmbeddedComponents);
expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(mockEmbeddedModulePath);
});
it('should only prepare the necessary factories', async () => {
await doEmbed('<aio-eager-foo>Eager only</aio-eager-foo>');
expect(prepareComponentFactoriesSpy).toHaveBeenCalledTimes(1);
expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(testEagerEmbeddedComponents);
await doEmbed('<aio-lazy-foo>Lazy only</aio-lazy-foo>');
expect(prepareComponentFactoriesSpy).toHaveBeenCalledTimes(2);
expect(prepareComponentFactoriesSpy).toHaveBeenCalledWith(mockEmbeddedModulePath);
});
it('should not load embedded components if the document does not contain any', async () => {
await doEmbed('');
await doEmbed('<no-aio-eager-foo></no-aio-eager-foo>');
await doEmbed('<no-aio-lazy-foo></no-aio-lazy-foo>');
expect(prepareComponentFactoriesSpy).not.toHaveBeenCalled();
});
});
describe('(creating embedded components)', () => {
it('should create embedded components if the element contains any', async () => {
await doEmbed('<div><aio-eager-foo><i>blah</i></aio-eager-foo></div>');
expect(createComponentsSpy).toHaveBeenCalledTimes(1);
expect(prepareComponentFactoriesSpy).toHaveBeenCalledBefore(createComponentsSpy);
prepareComponentFactoriesSpy.calls.reset();
createComponentsSpy.calls.reset();
await doEmbed('<aio-lazy-bar><i>blah</i></aio-lazy-bar>');
expect(createComponentsSpy).toHaveBeenCalledTimes(1);
expect(prepareComponentFactoriesSpy).toHaveBeenCalledBefore(createComponentsSpy);
});
it('should emit the created embedded components', async () => {
const componentRefs = await doEmbed('<aio-eager-foo></aio-eager-foo>');
expect(componentRefs).toBe(mockComponentRefs);
});
it('should not create embedded components if the element does not contain any', async () => {
await doEmbed(`
<aio-eager-foo-not></aio-eager-foo-not>
&lt;aio-lazy-bar&gt;&lt;/aio-lazy-bar&gt;
`);
expect(createComponentsSpy).not.toHaveBeenCalled();
});
it('should not create embedded components if the document is empty', async () => {
await doEmbed('');
expect(createComponentsSpy).not.toHaveBeenCalled();
});
it('should not create embedded components if unsubscribed from', async () => {
const preparePromise = Promise.resolve();
prepareComponentFactoriesSpy.and.returnValue(preparePromise);
// When not unsubscribed from...
host.innerHTML = '<aio-eager-foo></aio-eager-foo>';
service.embedInto(host).subscribe();
await new Promise(resolve => setTimeout(resolve));
expect(createComponentsSpy).toHaveBeenCalledTimes(1);
createComponentsSpy.calls.reset();
// When unsubscribed from...
host.innerHTML = '<aio-eager-foo></aio-eager-foo>';
service.embedInto(host).subscribe().unsubscribe();
await new Promise(resolve => setTimeout(resolve));
expect(createComponentsSpy).not.toHaveBeenCalled();
});
});
});
describe('#prepareComponentFactories()', () => {
let loader: MockNgModuleFactoryLoader;
let resolver: ComponentFactoryResolver;
let createComponentFactoriesSpy: jasmine.Spy;
beforeEach(() => {
loader = TestBed.get(NgModuleFactoryLoader);
resolver = TestBed.get(ComponentFactoryResolver);
createComponentFactoriesSpy = spyOn(service, 'createComponentFactories');
});
[testLazyEmbeddedComponents, mockEmbeddedModulePath].forEach((compsOrPath: ComponentsOrModulePath) => {
const useComponents = Array.isArray(compsOrPath);
describe(`(using ${useComponents ? 'component types' : 'module path'})`, () => {
const doPrepareComponentFactories = () =>
service.prepareComponentFactories(compsOrPath);
it('should return a promise', done => {
doPrepareComponentFactories().then(done, done.fail);
});
it('should create the component factories', async () => {
expect(createComponentFactoriesSpy).not.toHaveBeenCalled();
await doPrepareComponentFactories();
expect(createComponentFactoriesSpy).toHaveBeenCalledTimes(1);
const args = createComponentFactoriesSpy.calls.mostRecent().args;
expect(args[0]).toBe(testLazyEmbeddedComponents);
if (useComponents) {
expect(args[1]).toBe(resolver);
} else {
expect(args[1]).not.toBe(resolver);
}
});
it('should not create create the component factories more than once', async () => {
const results = await Promise.all([
doPrepareComponentFactories(),
doPrepareComponentFactories(),
]);
expect(createComponentFactoriesSpy).toHaveBeenCalledTimes(1);
expect(results[1]).toBe(results[0]);
const anotherResult = await doPrepareComponentFactories();
expect(createComponentFactoriesSpy).toHaveBeenCalledTimes(1);
expect(anotherResult).toBe(results[0]);
});
it(`should ${useComponents ? 'not load' : 'load'} the embedded module`, async () => {
expect(loader.loadedPaths).toEqual([]);
await doPrepareComponentFactories();
const expectedLoadedPaths = useComponents ? [] : [mockEmbeddedModulePath];
expect(loader.loadedPaths).toEqual(expectedLoadedPaths);
});
it(`should not load the embedded module more than once`, async () => {
await Promise.all([
doPrepareComponentFactories(),
doPrepareComponentFactories(),
]);
const loadedPathCount = loader.loadedPaths.length;
expect(loadedPathCount).toBeLessThan(2);
await doPrepareComponentFactories();
expect(loader.loadedPaths.length).toBe(loadedPathCount);
});
});
});
});
describe('#selectorToContentPropertyName()', () => {
it('should convert an element selector to a property name', () => {
expect(service.selectorToContentPropertyName('foobar')).toBe('foobarContent');
expect(service.selectorToContentPropertyName('baz-qux')).toBe('bazQuxContent');
});
});
});

View File

@ -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<any>;
}
/**
* A mapping from combined component selectors (keys) to the corresponding components (values). The
* components can be specified either as a list of embedded components or a path to a module that
* provides embedded components (i.e. implements `WithEmbeddedComponents`).
*/
export interface EmbeddedComponentsMap {
[multiSelector: string]: ComponentsOrModulePath;
}
/**
* Interface expected to be implemented by all modules that contribute components to the
* `EmbeddedComponentsMap`.
*/
export interface WithEmbeddedComponents {
embeddedComponents: Type<any>[];
}
/**
* Either an array of components or the path to a module that implements `WithEmbeddedComponents`.
*/
export type ComponentsOrModulePath = Type<any>[] | string;
/**
* The injection token for the `EmbeddedComponentsMap`.
*/
export const EMBEDDED_COMPONENTS = new InjectionToken<EmbeddedComponentsMap>('EMBEDDED_COMPONENTS');
/**
* Embed components into an element. It takes care of indentifying the embedded components, loading
* the necessary modules and instantiating the components.
*
* Embeddable components are identified and loaded based on the info in `EmbeddedComponentsMap`
* (provided through dependency injection).
*
* The caller is responsible for trigering change detection and destroying the components as
* necessary.
*/
@Injectable()
export class EmbedComponentsService {
private componentFactoriesReady = new Map<ComponentsOrModulePath, Promise<void>>();
protected componentFactories = new Map<string, EmbeddedComponentFactory>();
constructor(
private injector: Injector,
private loader: NgModuleFactoryLoader,
private resolver: ComponentFactoryResolver,
@Inject(EMBEDDED_COMPONENTS) private embeddedComponentsMap: EmbeddedComponentsMap) { }
/**
* Embed components into the specified element:
* - Load the necessary modules (if any).
* - Prepare the component factories.
* - Instantiate the components.
*
* Return the list of `ComponentRef`s.
*/
embedInto(elem: HTMLElement): Observable<ComponentRef<any>[]> {
const requiredComponents = Object.keys(this.embeddedComponentsMap)
.filter(selector => elem.querySelector(selector))
.map(selector => this.embeddedComponentsMap[selector]);
const factoriesReady = requiredComponents.map(compsOrPath => this.prepareComponentFactories(compsOrPath));
return !requiredComponents.length
? of([])
: of(undefined)
.switchMap(() => Promise.all(factoriesReady))
.switchMap(() => [this.createComponents(elem)]);
}
/**
* Resolve the embedded component factories (which will later be used to instantiate components).
*/
protected createComponentFactories(components: Type<any>[], resolver: ComponentFactoryResolver): void {
for (const comp of components) {
const factory = resolver.resolveComponentFactory(comp);
const selector = factory.selector;
const contentPropertyName = this.selectorToContentPropertyName(selector);
this.componentFactories.set(selector, {contentPropertyName, factory});
}
}
/**
* Instantiate embedded components for the current contents of `elem`.
* (Store the original HTML contents of each element on the corresponding property for later
* retrieval by the component instance.)
*/
protected createComponents(elem: HTMLElement): ComponentRef<any>[] {
const componentRefs: ComponentRef<any>[] = [];
this.componentFactories.forEach(({contentPropertyName, factory}, selector) => {
const componentHosts = elem.querySelectorAll(selector);
// Cast due to https://github.com/Microsoft/TypeScript/issues/4947.
for (const host of componentHosts as any as HTMLElement[]) {
// Hack: Preserve the current element content, because the factory will empty it out.
// Security: The source of this `innerHTML` should always be authored by the documentation
// team and is considered to be safe.
(host 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<void> {
if (!this.componentFactoriesReady.has(compsOrPath)) {
const componentsAndResolverPromise = (typeof compsOrPath !== 'string')
? Promise.resolve({components: compsOrPath, resolver: this.resolver})
: this.loader.load(compsOrPath).then((ngModuleFactory: NgModuleFactory<WithEmbeddedComponents>) => {
const moduleRef = ngModuleFactory.create(this.injector);
return {
components: moduleRef.instance.embeddedComponents,
resolver: moduleRef.componentFactoryResolver,
};
});
const readyPromise = componentsAndResolverPromise
.then(({components, resolver}) => this.createComponentFactories(components, resolver));
this.componentFactoriesReady.set(compsOrPath, readyPromise);
}
return this.componentFactoriesReady.get(compsOrPath)!;
}
/**
* Compute the component content property name by converting the selector to camelCase and
* appending `Content`, e.g. `live-example` => `liveExampleContent`.
*/
protected selectorToContentPropertyName(selector: string): string {
return selector.replace(/-(.)/g, (match, $1) => $1.toUpperCase()) + 'Content';
}
}

View File

@ -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<HostComponent>;
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 (<aio-code>)', () => {
createComponent(oneLineCode);
expect(codeComponent.code).toBe(oneLineCode);
});
it('should pass language to CodeComponent', () => {
TestBed.overrideComponent(HostComponent, {
set: {template: '<code-example language="html"></code-example>'}});
createComponent(oneLineCode);
expect(codeComponent.language).toBe('html');
});
it('should pass linenums to CodeComponent', () => {
TestBed.overrideComponent(HostComponent, {
set: {template: '<code-example linenums="true"></code-example>'}});
createComponent(oneLineCode);
expect(codeComponent.linenums).toBe('true');
});
it('should add title (header) when set `title` attribute', () => {
TestBed.overrideComponent(HostComponent, {
set: {template: '<code-example title="Great Example"></code-example>'}});
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: '<code-example title="Great Example"></code-example>'}});
createComponent(oneLineCode);
expect(codeExampleDe.nativeElement.getAttribute('title')).toEqual(null);
});
it('should pass hideCopy to CodeComponent', () => {
TestBed.overrideComponent(HostComponent, {
set: {template: '<code-example hideCopy="true"></code-example>'}});
createComponent(oneLineCode);
expect(codeComponent.hideCopy).toBe(true);
});
it('should have `avoidFile` class when `avoid` atty present', () => {
TestBed.overrideComponent(HostComponent, {
set: {template: '<code-example avoid></code-example>'}});
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: '<code-example path="test.avoid.ts"></code-example>'}});
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: `
<div>lang: {{language}}</div>
<div>linenums: {{linenums}}</div>
code: <pre>{{someCode}}</pre>
`
})
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: `<code-example></code-example>`
})
class HostComponent { }

View File

@ -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:
*
* ```
* <code-example language="ts" linenums="2" class="special" title="Do Stuff">
* // a code block
* console.log('do stuff');
* </code-example>
* ```
*/
@Component({
selector: 'code-example',
template: `
<header *ngIf="title">{{title}}</header>
<aio-code [ngClass]="classes" [code]="code"
[language]="language" [linenums]="linenums"
[path]="path" [region]="region"
[hideCopy]="hideCopy" [title]="title"></aio-code>
`
})
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;
}
}

View File

@ -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<HostComponent>;
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(`
<code-pane>foo</code-pane>
<code-pane>bar</code-pane>
<code-pane>baz</code-pane>
`);
expect(labelElems.length).toBe(3);
});
it('should use the `title` as label', () => {
createComponent(`
<code-pane title="foo-title">foo</code-pane>
<code-pane title="bar-title">bar</code-pane>
`);
const texts = labelElems.map(s => s.textContent);
expect(texts).toEqual(['foo-title', 'bar-title']);
});
it('should add the `class` to the label element', () => {
createComponent(`
<code-pane class="foo-class">foo</code-pane>
<code-pane class="bar-class">bar</code-pane>
`);
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 (<aio-code>)', () => {
createComponent(`
<code-pane class="foo-class">foo</code-pane>
<code-pane class="bar-class">bar</code-pane>
`);
const classes = codeDes.map(de => de.nativeElement.className);
expect(classes).toEqual(['foo-class', 'bar-class']);
});
it('should pass content to CodeComponent (<aio-code>)', () => {
createComponent(`
<code-pane>foo</code-pane>
<code-pane>bar</code-pane>
`);
const codes = codeComponents.map(c => c.code);
expect(codes).toEqual(['foo', 'bar']);
});
it('should pass `language` to CodeComponent (<aio-code>)', () => {
createComponent(`
<code-pane language="foo-lang">foo</code-pane>
<code-pane language="bar-lang">bar</code-pane>
`);
const langs = codeComponents.map(c => c.language);
expect(langs).toEqual(['foo-lang', 'bar-lang']);
});
it('should pass `linenums` to CodeComponent (<aio-code>)', () => {
createComponent(`
<code-pane linenums="foo-lnums">foo</code-pane>
<code-pane linenums="bar-lnums">bar</code-pane>
<code-pane linenums="">baz</code-pane>
<code-pane linenums>qux</code-pane>
`);
const lnums = codeComponents.map(c => c.linenums);
expect(lnums).toEqual(['foo-lnums', 'bar-lnums', '', '']);
});
it('should use the default value (if present on <code-tabs>) if `linenums` is not specified', () => {
TestBed.overrideComponent(HostComponent, {
set: { template: '<code-tabs linenums="default-lnums"></code-tabs>' }
});
createComponent(`
<code-pane linenums="foo-lnums">foo</code-pane>
<code-pane linenums>bar</code-pane>
<code-pane>baz</code-pane>
`);
const lnums = codeComponents.map(c => c.linenums);
expect(lnums).toEqual(['foo-lnums', '', 'default-lnums']);
});
it('should pass `path` to CodeComponent (<aio-code>)', () => {
createComponent(`
<code-pane path="foo-path">foo</code-pane>
<code-pane path="bar-path">bar</code-pane>
`);
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(`
<code-pane>foo</code-pane>
<code-pane>bar</code-pane>
`);
const paths = codeComponents.map(c => c.path);
expect(paths).toEqual(['', '']);
});
it('should pass `region` to CodeComponent (<aio-code>)', () => {
createComponent(`
<code-pane region="foo-region">foo</code-pane>
<code-pane region="bar-region">bar</code-pane>
`);
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(`
<code-pane>foo</code-pane>
<code-pane>bar</code-pane>
`);
const regions = codeComponents.map(c => c.region);
expect(regions).toEqual(['', '']);
});
it('should pass `title` to CodeComponent (<aio-code>)', () => {
createComponent(`
<code-pane title="foo-title">foo</code-pane>
<code-pane title="bar-title">bar</code-pane>
`);
const titles = codeComponents.map(c => c.title);
expect(titles).toEqual(['foo-title', 'bar-title']);
});
});
});
//// Test helpers ////
@Component({
selector: 'aio-code',
template: `
<div>lang: {{ language }}</div>
<div>linenums: {{ linenums }}</div>
code: <pre>{{ someCode }}</pre>
`
})
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: `<code-tabs></code-tabs>`
})
class HostComponent {}

View File

@ -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 `<code-tabs>` component should contain `<code-pane>` elements.
* Each `<code-pane>` has the same interface as the embedded `<code-example>` component.
* The optional `linenums` attribute is the default `linenums` for each code pane.
*/
@Component({
selector: 'code-tabs',
template: `
<mat-tab-group class="code-tab-group" disableRipple>
<mat-tab style="overflow-y: hidden;" *ngFor="let tab of tabs">
<ng-template mat-tab-label>
<span class="{{ tab.class }}">{{ tab.title }}</span>
</ng-template>
<aio-code class="{{ tab.class }}"
[code]="tab.code"
[language]="tab.language"
[linenums]="tab.linenums"
[path]="tab.path"
[region]="tab.region"
[title]="tab.title">
</aio-code>
</mat-tab>
</mat-tab-group>
`
})
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;
}
}

View File

@ -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<any>[] = [
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;
}

View File

@ -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<any>[];
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<any>[];
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<any>[] = [];
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('<div></div>');
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('<div></div>');
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 () => {

View File

@ -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<void>();
private docContents$ = new EventEmitter<DocumentContents>();
protected embeddedComponentRefs: ComponentRef<any>[] = [];
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 => {

View File

@ -109,6 +109,7 @@
xhr.send();
}
</script>
</head>
<body>

View File

@ -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`.

View File

@ -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<any>[];
currViewContainer: HTMLElement;
nextViewContainer: HTMLElement;
destroyEmbeddedComponents(): void { return null as any; }
prepareTitleAndToc(targetElem: HTMLElement, docId: string): () => void { return null as any; }
render(doc: DocumentContents): Observable<void> { return null as any; }
swapViews(onInsertedCb?: () => void): Observable<void> { 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 },

View File

@ -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<string, EmbeddedComponentFactory>;
createComponentFactories(components: Type<any>[], resolver: ComponentFactoryResolver): void { return null as any; }
createComponents(elem: HTMLElement): ComponentRef<any>[] { return null as any; }
prepareComponentFactories(compsOrPath: ComponentsOrModulePath): Promise<void> { return null as any; }
selectorToContentPropertyName(selector: string): string { return null as any; }
}
////////////////////////////////////////////////////////////////////////////////////////////////////
/// Mock `EmbeddedModule` and test components. ///
////////////////////////////////////////////////////////////////////////////////////////////////////
// Test embedded components.
@Component({
selector: 'aio-eager-foo',
template: `Eager Foo Component`,
})
class EagerFooComponent { }
@Component({
selector: 'aio-eager-bar',
template: `
<hr>
<h2>Eager Bar Component</h2>
<p #content></p>
<hr>
`,
})
class EagerBarComponent implements OnInit {
@ViewChild('content') contentRef: ElementRef;
constructor(public elementRef: ElementRef) { }
// Project content in `ngOnInit()` just like in `CodeExampleComponent`.
ngOnInit() {
// Security: This is a test component; never deployed.
this.contentRef.nativeElement.innerHTML = this.elementRef.nativeElement.aioEagerBarContent;
}
}
@Component({
selector: 'aio-lazy-foo',
template: `Lazy Foo Component`,
})
class LazyFooComponent { }
@Component({
selector: 'aio-lazy-bar',
template: `
<hr>
<h2>Lazy Bar Component</h2>
<p #content></p>
<hr>
`,
})
class LazyBarComponent implements OnInit {
@ViewChild('content') contentRef: ElementRef;
constructor(public elementRef: ElementRef) { }
// Project content in `ngOnInit()` just like in `CodeExampleComponent`.
ngOnInit() {
// Security: This is a test component; never deployed.
this.contentRef.nativeElement.innerHTML = this.elementRef.nativeElement.aioLazyBarContent;
}
}
// Export test embedded selectors and components.
export const testEagerEmbeddedSelectors = ['aio-eager-foo', 'aio-eager-bar'];
export const testEagerEmbeddedComponents = [EagerFooComponent, EagerBarComponent];
export const testLazyEmbeddedSelectors = ['aio-lazy-foo', 'aio-lazy-bar'];
export const testLazyEmbeddedComponents = [LazyFooComponent, LazyBarComponent];
// Export mock `EmbeddedModule` and path.
export const mockEmbeddedModulePath = 'mock/mock-embedded#MockEmbeddedModule';
@NgModule({
declarations: [testLazyEmbeddedComponents],
entryComponents: [testLazyEmbeddedComponents],
})
class MockEmbeddedModule implements WithEmbeddedComponents {
embeddedComponents = testLazyEmbeddedComponents;
}
////////////////////////////////////////////////////////////////////////////////////////////////////
/// `TestModule`. ///
////////////////////////////////////////////////////////////////////////////////////////////////////
// Mock services.
export class MockNgModuleFactoryLoader implements NgModuleFactoryLoader {
loadedPaths: string[] = [];
load(path: string) {
this.loadedPaths.push(path);
const platformRef = getPlatform();
const compilerFactory = platformRef!.injector.get(CompilerFactory) as CompilerFactory;
const compiler = compilerFactory.createCompiler([]);
return compiler.compileModuleAsync(MockEmbeddedModule);
}
}
@NgModule({
providers: [
EmbedComponentsService,
{ provide: NgModuleFactoryLoader, useClass: MockNgModuleFactoryLoader },
{
provide: EMBEDDED_COMPONENTS,
useValue: {
[testEagerEmbeddedSelectors.join(',')]: testEagerEmbeddedComponents,
[testLazyEmbeddedSelectors.join(',')]: mockEmbeddedModulePath,
},
},
],
declarations: [testEagerEmbeddedComponents],
entryComponents: [testEagerEmbeddedComponents],
})
export class TestModule { }

View File

@ -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"