parent
22b96b9690
commit
7c9b411777
|
@ -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",
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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 { }
|
||||
|
|
|
@ -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:');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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)}`);
|
||||
});
|
||||
}
|
|
@ -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,
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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')
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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(/</g, '<').replace(/>/g, '>');
|
||||
let actualCode;
|
||||
|
||||
hostComponent.code = smallMultiLineCode;
|
||||
hostComponent.setCode(smallMultiLineCode);
|
||||
|
||||
[false, true, 42].forEach(linenums => {
|
||||
hostComponent.linenums = linenums;
|
||||
fixture.detectChanges();
|
||||
codeComponent.ngOnChanges();
|
||||
getButton().click();
|
||||
actualCode = spy.calls.mostRecent().args[0];
|
||||
|
||||
|
@ -271,19 +261,29 @@ describe('CodeComponent', () => {
|
|||
@Component({
|
||||
selector: 'aio-host-comp',
|
||||
template: `
|
||||
<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 {
|
|
@ -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();
|
||||
}
|
|
@ -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 { }
|
|
@ -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); };
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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';
|
|
@ -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) { }
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 { }
|
|
@ -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);
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 {
|
||||
}
|
|
@ -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>
|
||||
<aio-lazy-bar></aio-lazy-bar>
|
||||
`);
|
||||
expect(createComponentsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not create embedded components if the document is empty', async () => {
|
||||
await doEmbed('');
|
||||
expect(createComponentsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not create embedded components if unsubscribed from', async () => {
|
||||
const preparePromise = Promise.resolve();
|
||||
prepareComponentFactoriesSpy.and.returnValue(preparePromise);
|
||||
|
||||
// When not unsubscribed from...
|
||||
host.innerHTML = '<aio-eager-foo></aio-eager-foo>';
|
||||
service.embedInto(host).subscribe();
|
||||
await new Promise(resolve => setTimeout(resolve));
|
||||
expect(createComponentsSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
createComponentsSpy.calls.reset();
|
||||
|
||||
// When unsubscribed from...
|
||||
host.innerHTML = '<aio-eager-foo></aio-eager-foo>';
|
||||
service.embedInto(host).subscribe().unsubscribe();
|
||||
await new Promise(resolve => setTimeout(resolve));
|
||||
expect(createComponentsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#prepareComponentFactories()', () => {
|
||||
let loader: MockNgModuleFactoryLoader;
|
||||
let resolver: ComponentFactoryResolver;
|
||||
let createComponentFactoriesSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
loader = TestBed.get(NgModuleFactoryLoader);
|
||||
resolver = TestBed.get(ComponentFactoryResolver);
|
||||
|
||||
createComponentFactoriesSpy = spyOn(service, 'createComponentFactories');
|
||||
});
|
||||
|
||||
[testLazyEmbeddedComponents, mockEmbeddedModulePath].forEach((compsOrPath: ComponentsOrModulePath) => {
|
||||
const useComponents = Array.isArray(compsOrPath);
|
||||
|
||||
describe(`(using ${useComponents ? 'component types' : 'module path'})`, () => {
|
||||
const doPrepareComponentFactories = () =>
|
||||
service.prepareComponentFactories(compsOrPath);
|
||||
|
||||
it('should return a promise', done => {
|
||||
doPrepareComponentFactories().then(done, done.fail);
|
||||
});
|
||||
|
||||
it('should create the component factories', async () => {
|
||||
expect(createComponentFactoriesSpy).not.toHaveBeenCalled();
|
||||
|
||||
await doPrepareComponentFactories();
|
||||
expect(createComponentFactoriesSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const args = createComponentFactoriesSpy.calls.mostRecent().args;
|
||||
expect(args[0]).toBe(testLazyEmbeddedComponents);
|
||||
|
||||
if (useComponents) {
|
||||
expect(args[1]).toBe(resolver);
|
||||
} else {
|
||||
expect(args[1]).not.toBe(resolver);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not create create the component factories more than once', async () => {
|
||||
const results = await Promise.all([
|
||||
doPrepareComponentFactories(),
|
||||
doPrepareComponentFactories(),
|
||||
]);
|
||||
|
||||
expect(createComponentFactoriesSpy).toHaveBeenCalledTimes(1);
|
||||
expect(results[1]).toBe(results[0]);
|
||||
|
||||
const anotherResult = await doPrepareComponentFactories();
|
||||
|
||||
expect(createComponentFactoriesSpy).toHaveBeenCalledTimes(1);
|
||||
expect(anotherResult).toBe(results[0]);
|
||||
});
|
||||
|
||||
it(`should ${useComponents ? 'not load' : 'load'} the embedded module`, async () => {
|
||||
expect(loader.loadedPaths).toEqual([]);
|
||||
|
||||
await doPrepareComponentFactories();
|
||||
const expectedLoadedPaths = useComponents ? [] : [mockEmbeddedModulePath];
|
||||
|
||||
expect(loader.loadedPaths).toEqual(expectedLoadedPaths);
|
||||
});
|
||||
|
||||
it(`should not load the embedded module more than once`, async () => {
|
||||
await Promise.all([
|
||||
doPrepareComponentFactories(),
|
||||
doPrepareComponentFactories(),
|
||||
]);
|
||||
const loadedPathCount = loader.loadedPaths.length;
|
||||
|
||||
expect(loadedPathCount).toBeLessThan(2);
|
||||
|
||||
await doPrepareComponentFactories();
|
||||
|
||||
expect(loader.loadedPaths.length).toBe(loadedPathCount);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#selectorToContentPropertyName()', () => {
|
||||
it('should convert an element selector to a property name', () => {
|
||||
expect(service.selectorToContentPropertyName('foobar')).toBe('foobarContent');
|
||||
expect(service.selectorToContentPropertyName('baz-qux')).toBe('bazQuxContent');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
||||
}
|
||||
}
|
|
@ -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 { }
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 () => {
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -109,6 +109,7 @@
|
|||
xhr.send();
|
||||
}
|
||||
</script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
|
@ -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`.
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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 { }
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue