feat(aio): add Table of Contents (toc) component. (#16078)

This commit is contained in:
Ward Bell
2017-04-27 15:32:46 -07:00
committed by Miško Hevery
parent 2a7f63650c
commit 3f46645f5f
29 changed files with 938 additions and 171 deletions

View File

@ -10,19 +10,19 @@ import { of } from 'rxjs/observable/of';
import { AppComponent } from './app.component';
import { AppModule } from './app.module';
import { AutoScrollService } from 'app/shared/auto-scroll.service';
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { GaService } from 'app/shared/ga.service';
import { LocationService } from 'app/shared/location.service';
import { Logger } from 'app/shared/logger.service';
import { MockLogger } from 'testing/logger.service';
import { MockLocationService } from 'testing/location.service';
import { MockSearchService } from 'testing/search.service';
import { MockSwUpdateNotificationsService } from 'testing/sw-update-notifications.service';
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
import { SearchBoxComponent } from 'app/search/search-box/search-box.component';
import { SearchService } from 'app/search/search.service';
import { MockSearchService } from 'testing/search.service';
import { AutoScrollService } from 'app/shared/auto-scroll.service';
import { LocationService } from 'app/shared/location.service';
import { MockLocationService } from 'testing/location.service';
import { Logger } from 'app/shared/logger.service';
import { MockLogger } from 'testing/logger.service';
import { SwUpdateNotificationsService } from 'app/sw-updates/sw-update-notifications.service';
import { MockSwUpdateNotificationsService } from 'testing/sw-update-notifications.service';
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
describe('AppComponent', () => {
let component: AppComponent;
@ -245,7 +245,7 @@ describe('AppComponent', () => {
it('should display a marketing page', () => {
locationService.go('features');
fixture.detectChanges();
expect(docViewer.innerText).toMatch(/Features Doc/i);
expect(docViewer.innerText).toMatch(/Features/i);
});
it('should update the document title', () => {
@ -443,7 +443,11 @@ class TestHttp {
{
"url": "features",
"title": "Features"
}
},
{
"url": "no-title",
"title": "No Title"
},
],
"SideNav": [
{
@ -459,7 +463,7 @@ class TestHttp {
"url": "guide/bags",
"title": "Bags",
"tooltip": "Pack your bags for a code adventure."
},
}
]
},
{
@ -493,12 +497,11 @@ class TestHttp {
} else {
const match = /content\/docs\/(.+)\.json/.exec(url);
const id = match[1];
// Make up a title for test purposes
const title = id.split('/').pop().replace(/^([a-z])/, (_, letter) => letter.toUpperCase());
const contents = `<h1>${title} Doc</h1><h2 id="#somewhere">Some heading</h2>`;
data = { id, title, contents };
if (id === 'no-title') {
data.title = '';
}
const h1 = (id === 'no-title') ? '' : `<h1>${title}</h1>`;
const contents = `${h1}<h2 id="#somewhere">Some heading</h2>`;
data = { id, contents };
}
return of({ json: () => data });
}

View File

@ -1,7 +1,6 @@
import { Component, ElementRef, HostListener, OnInit,
QueryList, ViewChild, ViewChildren } from '@angular/core';
import { MdSidenav } from '@angular/material';
import { Title } from '@angular/platform-browser';
import { AutoScrollService } from 'app/shared/auto-scroll.service';
import { CurrentNode, NavigationService, NavigationViews, NavigationNode, VersionInfo } from 'app/navigation/navigation.service';
@ -62,8 +61,7 @@ export class AppComponent implements OnInit {
private documentService: DocumentService,
private locationService: LocationService,
private navigationService: NavigationService,
private swUpdateNotifications: SwUpdateNotificationsService,
private titleService: Title
private swUpdateNotifications: SwUpdateNotificationsService
) { }
ngOnInit() {
@ -73,7 +71,6 @@ export class AppComponent implements OnInit {
this.documentService.currentDocument.subscribe(doc => {
this.currentDocument = doc;
this.setDocumentTitle(doc.title);
this.setPageId(doc.id);
});
@ -155,14 +152,6 @@ export class AppComponent implements OnInit {
this.sidenav.toggle(value);
}
setDocumentTitle(title: string) {
if (title.trim()) {
this.titleService.setTitle(`Angular - ${title}`);
} else {
this.titleService.setTitle('Angular');
}
}
setPageId(id: string) {
// Special case the home page
this.pageId = (id === 'index') ? 'home' : id.replace('/', '-');

View File

@ -16,6 +16,8 @@ import { SwUpdatesModule } from 'app/sw-updates/sw-updates.module';
import { AppComponent } from 'app/app.component';
import { ApiService } from 'app/embedded/api/api.service';
import { AutoScrollService } from 'app/shared/auto-scroll.service';
import { CustomMdIconRegistry, SVG_ICONS } from 'app/shared/custom-md-icon-registry';
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { DtComponent } from 'app/layout/doc-viewer/dt.component';
import { EmbeddedModule } from 'app/embedded/embedded.module';
@ -31,8 +33,7 @@ import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
import { NavItemComponent } from 'app/layout/nav-item/nav-item.component';
import { SearchResultsComponent } from './search/search-results/search-results.component';
import { SearchBoxComponent } from './search/search-box/search-box.component';
import { AutoScrollService } from 'app/shared/auto-scroll.service';
import { CustomMdIconRegistry, SVG_ICONS } from 'app/shared/custom-md-icon-registry';
import { TocService } from 'app/shared/toc.service';
// These are the hardcoded inline svg sources to be used by the `<md-icon>` component
export const svgIconProviders = [
@ -83,18 +84,19 @@ export const svgIconProviders = [
],
providers: [
ApiService,
AutoScrollService,
DocumentService,
GaService,
Logger,
Location,
{ provide: LocationStrategy, useClass: PathLocationStrategy },
LocationService,
{ provide: MdIconRegistry, useClass: CustomMdIconRegistry },
NavigationService,
DocumentService,
SearchService,
Platform,
AutoScrollService,
{ provide: MdIconRegistry, useClass: CustomMdIconRegistry },
svgIconProviders
svgIconProviders,
TocService
],
bootstrap: [AppComponent]
})

View File

@ -1,8 +1,6 @@
export interface DocumentContents {
/** The unique identifier for this document */
id: string;
/** The string to display in the browser tab when this document is being viewed */
title: string;
/** The HTML to display in the doc viewer */
contents: string;
}

View File

@ -10,7 +10,8 @@ import { LocationService } from 'app/shared/location.service';
import { MockLocationService } from 'testing/location.service';
import { Logger } from 'app/shared/logger.service';
import { MockLogger } from 'testing/logger.service';
import { DocumentService, DocumentContents } from './document.service';
import { DocumentService, DocumentContents,
FETCHING_ERROR_ID, FILE_NOT_FOUND_ID } from './document.service';
const CONTENT_URL_PREFIX = 'content/docs/';
@ -61,8 +62,8 @@ describe('DocumentService', () => {
it('should emit a document each time the location changes', () => {
let latestDocument: DocumentContents;
const doc0 = { title: 'doc 0', id: 'initial/doc' };
const doc1 = { title: 'doc 1', id: 'new/doc' };
const doc0 = { contents: 'doc 0', id: 'initial/doc' };
const doc1 = { contents: 'doc 1', id: 'new/doc' };
const { docService, backend, locationService } = getServices('initial/doc');
const connections = backend.connectionsArray;
@ -86,16 +87,16 @@ describe('DocumentService', () => {
connections[0].mockError(new Response(new ResponseOptions({ status: 404, statusText: 'NOT FOUND'})) as any);
expect(connections.length).toEqual(2);
expect(connections[1].request.url).toEqual(CONTENT_URL_PREFIX + 'file-not-found.json');
const fileNotFoundDoc = { id: 'file-not-found', title: 'Page Not Found', contents: '<h1>Page Not Found</h1>' };
const fileNotFoundDoc = { id: FILE_NOT_FOUND_ID, contents: '<h1>Page Not Found</h1>' };
connections[1].mockRespond(createResponse(fileNotFoundDoc));
expect(currentDocument).toEqual(fileNotFoundDoc);
});
it('should emit a hard-coded not-found document if the not-found document is not found on the server', () => {
let currentDocument: DocumentContents;
const notFoundDoc: DocumentContents = { title: 'Not Found', contents: 'Document not found', id: 'file-not-found' };
const nextDoc = { title: 'Next Doc', id: 'new/doc' };
const { docService, backend, locationService } = getServices('file-not-found');
const notFoundDoc: DocumentContents = { contents: 'Document not found', id: FILE_NOT_FOUND_ID };
const nextDoc = { contents: 'Next Doc', id: 'new/doc' };
const { docService, backend, locationService } = getServices(FILE_NOT_FOUND_ID);
const connections = backend.connectionsArray;
docService.currentDocument.subscribe(doc => currentDocument = doc);
@ -117,9 +118,9 @@ describe('DocumentService', () => {
docService.currentDocument.subscribe(doc => latestDocument = doc);
connections[0].mockRespond(new Response(new ResponseOptions({ body: 'this is invalid JSON' })));
expect(latestDocument.title).toMatch('Document retrieval error');
expect(latestDocument.id).toEqual(FETCHING_ERROR_ID);
const doc1 = { title: 'doc 1' };
const doc1 = { contents: 'doc 1' };
locationService.go('new/doc');
connections[1].mockRespond(createResponse(doc1));
expect(latestDocument).toEqual(jasmine.objectContaining(doc1));
@ -129,8 +130,8 @@ describe('DocumentService', () => {
let latestDocument: DocumentContents;
let subscription: Subscription;
const doc0 = { title: 'doc 0' };
const doc1 = { title: 'doc 1' };
const doc0 = { contents: 'doc 0' };
const doc1 = { contents: 'doc 1' };
const { docService, backend, locationService} = getServices('url/0');
const connections = backend.connectionsArray;
@ -141,7 +142,7 @@ describe('DocumentService', () => {
subscription.unsubscribe();
// modify the response so we can check that future subscriptions do not trigger another request
connections[0].response.next(createResponse({ title: 'error 0' }));
connections[0].response.next(createResponse({ contents: 'error 0' }));
subscription = docService.currentDocument.subscribe(doc => latestDocument = doc);
locationService.go('url/1');
@ -151,7 +152,7 @@ describe('DocumentService', () => {
subscription.unsubscribe();
// modify the response so we can check that future subscriptions do not trigger another request
connections[1].response.next(createResponse({ title: 'error 1' }));
connections[1].response.next(createResponse({ contents: 'error 1' }));
subscription = docService.currentDocument.subscribe(doc => latestDocument = doc);
locationService.go('url/0');

View File

@ -14,9 +14,10 @@ export { DocumentContents } from './document-contents';
import { LocationService } from 'app/shared/location.service';
import { Logger } from 'app/shared/logger.service';
export const FILE_NOT_FOUND_ID = 'file-not-found';
export const FETCHING_ERROR_ID = 'fetching-error';
const CONTENT_URL_PREFIX = 'content/docs/';
const FILE_NOT_FOUND_ID = 'file-not-found';
const FETCHING_ERROR_ID = 'fetching-error';
const FETCHING_ERROR_CONTENTS = `
<div class="nf-container l-flex-wrap flex-center">
<div class="nf-icon material-icons">error_outline</div>
@ -72,9 +73,8 @@ export class DocumentService {
return this.getDocument(FILE_NOT_FOUND_ID);
} else {
return of({
title: 'Not Found',
contents: 'Document not found',
id: FILE_NOT_FOUND_ID
id: FILE_NOT_FOUND_ID,
contents: 'Document not found'
});
}
}
@ -83,9 +83,8 @@ export class DocumentService {
this.logger.error('Error fetching document', error);
this.cache.delete(id);
return Observable.of({
title: 'Document retrieval error',
contents: FETCHING_ERROR_CONTENTS,
id: FETCHING_ERROR_ID
id: FETCHING_ERROR_ID,
contents: FETCHING_ERROR_CONTENTS
});
}
}

View File

@ -19,7 +19,7 @@ import { Component, ElementRef, OnInit } from '@angular/core';
<aio-code [ngClass]="{'headed-code':title, 'simple-code':!title}" [code]="code" [language]="language" [linenums]="linenums"></aio-code>
`
})
export class CodeExampleComponent implements OnInit { // implements AfterViewInit {
export class CodeExampleComponent implements OnInit {
code: string;
language: string;

View File

@ -1,15 +0,0 @@
/* tslint:disable component-selector */
import { Component } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { DocumentService } from 'app/documents/document.service';
@Component({
selector: 'doc-title',
template: '<h1 class="docs-primary-header">{{title | async}}</h1>'
})
export class DocTitleComponent {
title: Observable<string>;
constructor(docs: DocumentService) {
this.title = docs.currentDocument.map(doc => doc.title);
}
}

View File

@ -18,18 +18,18 @@ 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 { DocTitleComponent } from './doc-title.component';
import { CurrentLocationComponent } from './current-location.component';
import { LiveExampleComponent, EmbeddedPlunkerComponent } from './live-example/live-example.component';
import { ResourceListComponent } from './resource/resource-list.component';
import { ResourceService } from './resource/resource.service';
import { TocComponent } from './toc/toc.component';
/** Components that can be embedded in docs
* such as CodeExampleComponent, LiveExampleComponent,...
*/
export const embeddedComponents: any[] = [
ApiListComponent, CodeExampleComponent, CodeTabsComponent, ContributorListComponent,
CurrentLocationComponent, DocTitleComponent, LiveExampleComponent, ResourceListComponent
CurrentLocationComponent, LiveExampleComponent, ResourceListComponent, TocComponent
];
/** Injectable class w/ property returning components that can be embedded in docs */

View File

@ -26,17 +26,4 @@
</div>
</div>
</div>
<!--</div>-->
<!--<div class="c3">-->
<div class="c-resource-nav shadow-1 l-flex--column h-affix" [ngClass]="{ 'affix-top': scrollPos > 200 }">
<div class="category" *ngFor="let category of categories">
<a class="category-link h-capitalize" [href]="href(category)">{{category.title}}</a>
<div class="subcategory" *ngFor="let subCategory of category.subCategories">
<a class="subcategory-link" [href]="href(subCategory)">{{subCategory.title}}</a>
</div>
</div>
</div>
<!--</div>-->
</div>

View File

@ -0,0 +1,24 @@
<div *ngIf="hasToc" [class.closed]="isClosed">
<div *ngIf="!hasSecondary"class="toc-heading">Contents</div>
<div *ngIf="hasSecondary" class="toc-heading secondary"
(click)="toggle()"
title="Expand/collapse contents"
aria-label="Expand/collapse contents">
Contents
<button type="button"
class="toc-show-all material-icons" [class.closed]="isClosed">
</button>
</div>
<ul class="toc-list">
<li *ngFor="let toc of tocList" title="{{toc.title}}" class="{{toc.level}}" [class.secondary]="toc.isSecondary">
<a [href]="toc.href" [innerHTML]="toc.content"></a>
</li>
</ul>
<button type="button" (click)="toggle()" *ngIf="hasSecondary"
class="toc-more-items material-icons" [class.closed]="isClosed"
title="Expand/collapse contents"
aria-label="Expand/collapse contents">
</button>
</div>

View File

@ -0,0 +1,232 @@
import { Component, DebugElement } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { By, DOCUMENT } from '@angular/platform-browser';
import { TocComponent } from './toc.component';
import { TocItem, TocService } from 'app/shared/toc.service';
describe('TocComponent', () => {
let tocComponentDe: DebugElement;
let tocComponent: TocComponent;
let tocService: TestTocService;
let page: {
listItems: DebugElement[];
tocHeading: DebugElement;
tocHeadingButton: DebugElement;
tocMoreButton: DebugElement;
};
function setPage(): typeof page {
return {
listItems: tocComponentDe.queryAll(By.css('ul.toc-list>li')),
tocHeading: tocComponentDe.query(By.css('.toc-heading')),
tocHeadingButton: tocComponentDe.query(By.css('.toc-heading button')),
tocMoreButton: tocComponentDe.query(By.css('button.toc-more-items')),
};
}
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ HostEmbeddedTocComponent, HostNotEmbeddedTocComponent, TocComponent ],
providers: [
{ provide: TocService, useClass: TestTocService }
]
})
.compileComponents();
}));
describe('when embedded in doc body', () => {
let fixture: ComponentFixture<HostEmbeddedTocComponent>;
beforeEach(() => {
fixture = TestBed.createComponent(HostEmbeddedTocComponent);
tocComponentDe = fixture.debugElement.children[0];
tocComponent = tocComponentDe.componentInstance;
tocService = TestBed.get(TocService);
});
it('should create tocComponent', () => {
expect(tocComponent).toBeTruthy();
});
it('should be in embedded state', () => {
expect(tocComponent.isEmbedded).toEqual(true);
});
it('should not display anything when no TocItems', () => {
tocService.tocList = [];
fixture.detectChanges();
expect(tocComponentDe.children.length).toEqual(0);
});
describe('when four TocItems', () => {
beforeEach(() => {
tocService.tocList.length = 4;
fixture.detectChanges();
page = setPage();
});
it('should have four displayed items', () => {
expect(page.listItems.length).toEqual(4);
});
it('should not have secondary items', () => {
expect(tocComponent.hasSecondary).toEqual(false, 'hasSecondary flag');
const aSecond = page.listItems.find(item => item.classes.secondary);
expect(aSecond).toBeFalsy('should not find a secondary');
});
it('should not display expando buttons', () => {
expect(page.tocHeadingButton).toBeFalsy('top expand/collapse button');
expect(page.tocMoreButton).toBeFalsy('bottom more button');
});
});
describe('when many TocItems', () => {
beforeEach(() => {
fixture.detectChanges();
page = setPage();
});
it('should have more than 4 displayed items', () => {
expect(page.listItems.length).toBeGreaterThan(4);
expect(page.listItems.length).toEqual(tocService.tocList.length);
});
it('should be in "closed" (not expanded) state at the start', () => {
expect(tocComponent.isClosed).toBeTruthy();
});
it('should have "closed" class at the start', () => {
expect(tocComponentDe.children[0].classes.closed).toEqual(true);
});
it('should display expando buttons', () => {
expect(page.tocHeadingButton).toBeTruthy('top expand/collapse button');
expect(page.tocMoreButton).toBeTruthy('bottom more button');
});
it('should have secondary items', () => {
expect(tocComponent.hasSecondary).toEqual(true, 'hasSecondary flag');
});
// CSS should hide items with the secondary class when closed
it('should have secondary item with a secondary class', () => {
const aSecondary = page.listItems.find(item => item.classes.secondary);
expect(aSecondary).toBeTruthy('should find a secondary');
expect(aSecondary.classes.secondary).toEqual(true, 'has secondary class');
});
describe('after click expando button', () => {
beforeEach(() => {
page.tocHeadingButton.nativeElement.click();
fixture.detectChanges();
});
it('should not be "closed"', () => {
expect(tocComponent.isClosed).toEqual(false);
});
it('should not have "closed" class', () => {
expect(tocComponentDe.children[0].classes.closed).toBeFalsy();
});
});
});
});
describe('when in side panel (not embedded))', () => {
let fixture: ComponentFixture<HostNotEmbeddedTocComponent>;
beforeEach(() => {
fixture = TestBed.createComponent(HostNotEmbeddedTocComponent);
tocComponentDe = fixture.debugElement.children[0];
tocComponent = tocComponentDe.componentInstance;
fixture.detectChanges();
page = setPage();
});
it('should not be in embedded state', () => {
expect(tocComponent.isEmbedded).toEqual(false);
});
it('should display all items', () => {
expect(page.listItems.length).toEqual(tocService.tocList.length);
});
it('should not have secondary items', () => {
expect(tocComponent.hasSecondary).toEqual(false, 'hasSecondary flag');
const aSecond = page.listItems.find(item => item.classes.secondary);
expect(aSecond).toBeFalsy('should not find a secondary');
});
it('should not display expando buttons', () => {
expect(page.tocHeadingButton).toBeFalsy('top expand/collapse button');
expect(page.tocMoreButton).toBeFalsy('bottom more button');
});
});
});
//// helpers ////
@Component({
selector: 'aio-embedded-host',
template: '<aio-toc class="embedded"></aio-toc>'
})
class HostEmbeddedTocComponent {}
@Component({
selector: 'aio-not-embedded-host',
template: '<aio-toc></aio-toc>'
})
class HostNotEmbeddedTocComponent {}
class TestTocService {
tocList: TocItem[] = getTestTocList();
}
// tslint:disable:quotemark
function getTestTocList() {
return [
{
"content": "Heading one",
"href": "fizz/buzz#heading-one-special-id",
"level": "h2",
"title": "Heading one"
},
{
"content": "H2 Two",
"href": "fizz/buzz#h2-two",
"level": "h2",
"title": "H2 Two"
},
{
"content": "H2 <b>Three</b>",
"href": "fizz/buzz#h2-three",
"level": "h2",
"title": "H2 Three"
},
{
"content": "H3 3a",
"href": "fizz/buzz#h3-3a",
"level": "h3",
"title": "H3 3a"
},
{
"content": "H3 3b",
"href": "fizz/buzz#h3-3b",
"level": "h3",
"title": "H3 3b"
},
{
"content": "<i>H2 <b>four</b></i>",
"href": "fizz/buzz#h2-four",
"level": "h2",
"title": "H2 4"
}
];
}

View File

@ -0,0 +1,42 @@
import { Component, ElementRef, OnInit } from '@angular/core';
import { TocItem, TocService } from 'app/shared/toc.service';
@Component({
selector: 'aio-toc',
templateUrl: 'toc.component.html',
styles: []
})
export class TocComponent implements OnInit {
hasSecondary = false;
hasToc = true;
isClosed = true;
isEmbedded = false;
private primaryMax = 4;
tocList: TocItem[];
constructor(
private elementRef: ElementRef,
private tocService: TocService) {
const hostElement = this.elementRef.nativeElement;
this.isEmbedded = hostElement.className.indexOf('embedded') !== -1;
}
ngOnInit() {
const tocList = this.tocList = this.tocService.tocList;
const count = tocList.length;
this.hasToc = count > 0;
if (this.isEmbedded && this.hasToc) {
// If TOC is embedded in doc, mark secondary (sometimes hidden) items
this.hasSecondary = tocList.length > this.primaryMax;
for (let i = this.primaryMax; i < count; i++) {
tocList[i].isSecondary = true;
}
}
}
toggle() {
this.isClosed = !this.isClosed;
}
}

View File

@ -1,10 +1,13 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFactoryResolver, ElementRef, Injector, NgModule, OnInit, ViewChild, Component, DebugElement } from '@angular/core';
import {
Component, ComponentFactoryResolver, DebugElement,
ElementRef, Injector, NgModule, OnInit, ViewChild } from '@angular/core';
import { By } from '@angular/platform-browser';
import { DocViewerComponent } from './doc-viewer.component';
import { DocumentContents } from 'app/documents/document.service';
import { EmbeddedModule, embeddedComponents, EmbeddedComponents } from 'app/embedded/embedded.module';
import { Title } from '@angular/platform-browser';
import { TocService } from 'app/shared/toc.service';
/// Embedded Test Components ///
@ -86,6 +89,17 @@ class TestComponent {
@ViewChild(DocViewerComponent) docViewer: DocViewerComponent;
}
//// Test Services ////
class TestTitleService {
setTitle = jasmine.createSpy('reset');
}
class TestTocService {
reset = jasmine.createSpy('reset');
genToc = jasmine.createSpy('genToc');
}
//////// Tests //////////////
describe('DocViewerComponent', () => {
@ -94,6 +108,10 @@ describe('DocViewerComponent', () => {
let docViewerEl: HTMLElement;
let fixture: ComponentFixture<TestComponent>;
function setCurrentDoc(contents = '', id = 'fizz/buzz') {
component.currentDoc = { contents, id };
}
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [ TestModule ],
@ -103,7 +121,9 @@ describe('DocViewerComponent', () => {
embeddedTestComponents
],
providers: [
{provide: EmbeddedComponents, useValue: {components: embeddedTestComponents}}
{ provide: EmbeddedComponents, useValue: {components: embeddedTestComponents} },
{ provide: Title, useClass: TestTitleService },
{ provide: TocService, useClass: TestTocService }
]
})
.compileComponents();
@ -122,23 +142,23 @@ describe('DocViewerComponent', () => {
});
it(('should display nothing when set currentDoc has no content'), () => {
component.currentDoc = { title: 'fake title', contents: '', id: 'a/b' };
setCurrentDoc();
fixture.detectChanges();
expect(docViewerEl.innerHTML).toBe('');
});
it(('should display simple static content doc'), () => {
const contents = '<p>Howdy, doc viewer</p>';
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
fixture.detectChanges();
expect(docViewerEl.innerHTML).toEqual(contents);
});
it(('should display nothing after reset static content doc'), () => {
const contents = '<p>Howdy, doc viewer</p>';
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
fixture.detectChanges();
component.currentDoc = { title: 'fake title', contents: '', id: 'a/c' };
component.currentDoc = { contents: '', id: 'a/c' };
fixture.detectChanges();
expect(docViewerEl.innerHTML).toEqual('');
});
@ -149,7 +169,7 @@ describe('DocViewerComponent', () => {
<p><aio-foo></aio-foo></p>
<p>Below Foo</p>
`;
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
fixture.detectChanges();
const fooHtml = docViewerEl.querySelector('aio-foo').innerHTML;
expect(fooHtml).toContain('Foo Component');
@ -165,7 +185,7 @@ describe('DocViewerComponent', () => {
</div>
<p>Below Foo</p>
`;
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
fixture.detectChanges();
const foos = docViewerEl.querySelectorAll('aio-foo');
expect(foos.length).toBe(2);
@ -177,7 +197,7 @@ describe('DocViewerComponent', () => {
<aio-bar></aio-bar>
<p>Below Bar</p>
`;
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
fixture.detectChanges();
const barHtml = docViewerEl.querySelector('aio-bar').innerHTML;
expect(barHtml).toContain('Bar Component');
@ -189,7 +209,7 @@ describe('DocViewerComponent', () => {
<aio-bar>###bar content###</aio-bar>
<p>Below Bar</p>
`;
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
// necessary to trigger projection within ngOnInit
fixture.detectChanges();
@ -207,7 +227,7 @@ describe('DocViewerComponent', () => {
<p><aio-foo></aio-foo></p>
<p>Bottom</p>
`;
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
// necessary to trigger Bar's projection within ngOnInit
fixture.detectChanges();
@ -230,7 +250,7 @@ describe('DocViewerComponent', () => {
<p><aio-foo></aio-foo><p>
<p>Bottom</p>
`;
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
// necessary to trigger Bar's projection within ngOnInit
fixture.detectChanges();
@ -254,7 +274,7 @@ describe('DocViewerComponent', () => {
<p><aio-foo></aio-foo></p>
<p>Bottom</p>
`;
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
// necessary to trigger Bar's projection within ngOnInit
fixture.detectChanges();
@ -282,7 +302,7 @@ describe('DocViewerComponent', () => {
<p><aio-baz>---More baz--</aio-baz></p>
<p>Bottom</p>
`;
component.currentDoc = { title: 'fake title', contents, id: 'a/b' };
setCurrentDoc(contents);
// necessary to trigger Bar's projection within ngOnInit
fixture.detectChanges();
@ -298,4 +318,86 @@ describe('DocViewerComponent', () => {
'expected 2nd Baz template content');
});
describe('Title', () => {
let titleService: TestTitleService;
beforeEach(() => {
titleService = TestBed.get(Title);
});
it('should set the default empty title when no <h1>', () => {
setCurrentDoc('Some content');
fixture.detectChanges();
expect(titleService.setTitle).toHaveBeenCalledWith('Angular');
});
it('should set the expected title when has <h1>', () => {
setCurrentDoc('<h1>Features</h1>Some content');
fixture.detectChanges();
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features');
});
it('should set the expected title with a no-toc <h1>', () => {
setCurrentDoc('<h1 class="no-toc">Features</h1>Some content');
fixture.detectChanges();
expect(titleService.setTitle).toHaveBeenCalledWith('Angular - Features');
});
});
describe('TOC', () => {
let tocService: TestTocService;
function getAioToc(): HTMLElement {
return fixture.debugElement.nativeElement.querySelector('aio-toc');
}
beforeEach(() => {
tocService = TestBed.get(TocService);
});
describe('if no <h1> title', () => {
beforeEach(() => {
setCurrentDoc('Some content');
fixture.detectChanges();
});
it('should not have an <aio-toc>', () => {
expect(getAioToc()).toBeFalsy();
});
it('should reset Toc Service', () => {
expect(tocService.reset).toHaveBeenCalled();
});
it('should not call Toc Service genToc()', () => {
expect(tocService.genToc).not.toHaveBeenCalled();
});
});
it('should not have an <aio-toc> with a no-toc <h1>', () => {
setCurrentDoc('<h1 class="no-toc">Features</h1>Some content');
fixture.detectChanges();
expect(getAioToc()).toBeFalsy();
});
describe('when has an <h1> (title)', () => {
beforeEach(() => {
setCurrentDoc('<h1>Features</h1>Some content');
fixture.detectChanges();
});
it('should add <aio-toc>', () => {
expect(getAioToc()).toBeTruthy();
});
it('should have <aio-toc> with "embedded" class', () => {
expect(getAioToc().classList.contains('embedded')).toEqual(true);
});
it('should call Toc Service genToc()', () => {
expect(tocService.genToc).toHaveBeenCalled();
});
});
});
});

View File

@ -6,6 +6,8 @@ import {
import { EmbeddedComponents } from 'app/embedded/embedded.module';
import { DocumentContents } from 'app/documents/document.service';
import { Title } from '@angular/platform-browser';
import { TocService } from 'app/shared/toc.service';
interface EmbeddedComponentFactory {
contentPropertyName: string;
@ -18,13 +20,7 @@ const initialDocViewerContent = initialDocViewerElement ? initialDocViewerElemen
@Component({
selector: 'aio-doc-viewer',
template: '',
styles: [ `
:host >>> doc-title.not-found h1 {
color: white;
background-color: red;
}
`]
template: ''
// TODO(robwormald): shadow DOM and emulated don't work here (?!)
// encapsulation: ViewEncapsulation.Native
})
@ -41,7 +37,9 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
componentFactoryResolver: ComponentFactoryResolver,
elementRef: ElementRef,
embeddedComponents: EmbeddedComponents,
private injector: Injector
private injector: Injector,
private titleService: Title,
private tocService: TocService
) {
this.hostElement = elementRef.nativeElement;
// Security: the initialDocViewerContent comes from the prerendered DOM and is considered to be secure
@ -77,6 +75,8 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
if (!doc.contents) { return; }
this.addTitleAndToc(doc.id);
// TODO(i): why can't I use for-of? why doesn't typescript like Map#value() iterators?
this.embeddedComponentFactories.forEach(({ contentPropertyName, factory }, selector) => {
const embeddedComponentElements = this.hostElement.querySelectorAll(selector);
@ -92,8 +92,27 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
});
}
private addTitleAndToc(docId: string) {
this.tocService.reset();
let title = '';
const titleEl = this.hostElement.querySelector('h1');
// Only create TOC for docs with an <h1> title
// If you don't want a TOC, don't have an <h1>
if (titleEl) {
title = titleEl.innerText.trim();
if (!/(no-toc|notoc)/i.test(titleEl.className)) {
this.tocService.genToc(this.hostElement, docId);
titleEl.insertAdjacentHTML('afterend', '<aio-toc class="embedded"></aio-toc>');
}
}
this.titleService.setTitle(title ? `Angular - ${title}` : 'Angular');
}
ngDoCheck() {
if (this.displayedDoc) { this.displayedDoc.detectChanges(); }
// TODO: make sure this isn't called too often on the same doc
if (this.displayedDoc) {
this.displayedDoc.detectChanges();
}
}
ngOnDestroy() {

View File

@ -0,0 +1,227 @@
import { ReflectiveInjector, SecurityContext } from '@angular/core';
import { DOCUMENT, DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { TocItem, TocService } from './toc.service';
describe('TocService', () => {
let injector: ReflectiveInjector;
let tocService: TocService;
// call TocService.genToc
function callGenToc(html = '', docId = 'fizz/buzz'): HTMLDivElement {
const el = document.createElement('div');
el.innerHTML = html;
tocService.genToc(el, docId);
return el;
}
beforeEach(() => {
injector = ReflectiveInjector.resolveAndCreate([
{ provide: DomSanitizer, useClass: TestDomSanitizer },
{ provide: DOCUMENT, useValue: document },
TocService,
]);
tocService = injector.get(TocService);
});
it('should be creatable', () => {
expect(tocService).toBeTruthy();
});
describe('should clear tocList', () => {
// Start w/ dummy data from previous usage
beforeEach(() => tocService.tocList = [{}, {}] as TocItem[]);
it('when reset()', () => {
tocService.reset();
expect(tocService.tocList.length).toEqual(0);
});
it('when given undefined doc element', () => {
tocService.genToc(undefined);
expect(tocService.tocList.length).toEqual(0);
});
it('when given doc element w/ no headings', () => {
callGenToc('<p>This</p><p>and</p><p>that</p>');
expect(tocService.tocList.length).toEqual(0);
});
it('when given doc element w/ headings other than h2 & h3', () => {
callGenToc('<h1>This</h1><h4>and</h4><h5>that</h5>');
expect(tocService.tocList.length).toEqual(0);
});
it('when given doc element w/ no-toc headings', () => {
// tolerates different spellings/casing of the no-toc class
callGenToc(`
<h2 class="no-toc">one</h2><p>some one</p>
<h2 class="notoc">two</h2><p>some two</p>
<h2 class="no-Toc">three</h2><p>some three</p>
<h2 class="noToc">four</h2><p>some four</p>
`);
expect(tocService.tocList.length).toEqual(0);
});
});
describe('when given many headings', () => {
let docId: string;
let docEl: HTMLDivElement;
let tocList: TocItem[];
let headings: NodeListOf<HTMLHeadingElement>;
beforeEach(() => {
docId = 'fizz/buzz';
docEl = callGenToc(`
<h1>Fun with TOC</h1>
<h2 id="heading-one-special-id">Heading one</h2>
<p>h2 toc 0</p>
<h2>H2 Two</h2>
<p>h2 toc 1</p>
<h2>H2 <b>Three</b></h2>
<p>h2 toc 2</p>
<h3 id="h3-3a">H3 3a</h3> <p>h3 toc 3</p>
<h3 id="h3-3b">H3 3b</h3> <p>h3 toc 4</p>
<!-- h4 shouldn't be in TOC -->
<h4 id="h4-3b">H4 of h3-3b</h4> <p>an h4</p>
<h2><i>H2 4 <b>repeat</b></i></h2>
<p>h2 toc 5</p>
<h2><b>H2 4 <i>repeat</i></b></h2>
<p>h2 toc 6</p>
<h2 class="no-toc" id="skippy">Skippy</h2>
<p>Skip this header</p>
<h2 id="h2-6">H2 6</h2>
<p>h2 toc 7</p>
<h3 id="h3-6a">H3 6a</h3> <p>h3 toc 8</p>
`, docId);
tocList = tocService.tocList;
headings = docEl.querySelectorAll('h1,h2,h3,h4') as NodeListOf<HTMLHeadingElement>;
});
it('should have tocList with expect number of TocItems', () => {
// should ignore h1, h4, and the no-toc h2
expect(tocList.length).toEqual(headings.length - 3);
});
it('should have href with docId and heading\'s id', () => {
const tocItem = tocList[0];
expect(tocItem.href).toEqual(`${docId}#heading-one-special-id`);
});
it('should have level "h2" for an <h2>', () => {
const tocItem = tocList[0];
expect(tocItem.level).toEqual('h2');
});
it('should have level "h3" for an <h3>', () => {
const tocItem = tocList[3];
expect(tocItem.level).toEqual('h3');
});
it('should have title which is heading\'s innerText ', () => {
const heading = headings[3];
const tocItem = tocList[2];
expect(heading.innerText).toEqual(tocItem.title);
});
it('should have "SafeHtml" content which is heading\'s innerHTML ', () => {
const heading = headings[3];
const content = tocList[2].content;
expect((<TestSafeHtml>content).changingThisBreaksApplicationSecurity)
.toEqual(heading.innerHTML);
});
it('should calculate and set id of heading without an id', () => {
const id = headings[2].getAttribute('id');
expect(id).toEqual('h2-two');
});
it('should have href with docId and calculated heading id', () => {
const tocItem = tocList[1];
expect(tocItem.href).toEqual(`${docId}#h2-two`);
});
it('should ignore HTML in heading when calculating id', () => {
const id = headings[3].getAttribute('id');
const tocItem = tocList[2];
expect(id).toEqual('h2-three', 'heading id');
expect(tocItem.href).toEqual(`${docId}#h2-three`, 'tocItem href');
});
it('should avoid repeating an id when calculating', () => {
const tocItem4a = tocList[5];
const tocItem4b = tocList[6];
expect(tocItem4a.href).toEqual(`${docId}#h2-4-repeat`, 'first');
expect(tocItem4b.href).toEqual(`${docId}#h2-4-repeat-2`, 'second');
});
});
describe('TocItem for an h2 with anchor link and extra whitespace', () => {
let docId: string;
let docEl: HTMLDivElement;
let tocItem: TocItem;
let expectedTocContent: string;
beforeEach(() => {
docId = 'fizz/buzz/';
expectedTocContent = 'Setup to develop <i>locally</i>.';
// An almost-actual <h2> ... with extra whitespace
docEl = callGenToc(`
<h2 id="setup-to-develop-locally">
<a href="tutorial/toh-pt1#setup-to-develop-locally" aria-hidden="true">
<span class="icon icon-link"></span>
</a>
${expectedTocContent}
</h2>
`, docId);
tocItem = tocService.tocList[0];
});
it('should have expected href', () => {
expect(tocItem.href).toEqual(`${docId}#setup-to-develop-locally`);
});
it('should have expected title', () => {
expect(tocItem.title).toEqual('Setup to develop locally.');
});
it('should have removed anchor link from tocItem html content', () => {
expect((<TestSafeHtml>tocItem.content)
.changingThisBreaksApplicationSecurity)
.toEqual('Setup to develop <i>locally</i>.');
});
it('should have bypassed HTML sanitizing of heading\'s innerHTML ', () => {
const domSanitizer: TestDomSanitizer = injector.get(DomSanitizer);
expect(domSanitizer.bypassSecurityTrustHtml)
.toHaveBeenCalledWith(expectedTocContent);
});
});
});
interface TestSafeHtml extends SafeHtml {
changingThisBreaksApplicationSecurity: string;
getTypeName: () => string;
}
class TestDomSanitizer {
bypassSecurityTrustHtml = jasmine.createSpy('bypassSecurityTrustHtml')
.and.callFake(html => {
return {
changingThisBreaksApplicationSecurity: html,
getTypeName: () => 'HTML',
} as TestSafeHtml;
});
}

View File

@ -0,0 +1,81 @@
import { Inject, Injectable } from '@angular/core';
import { DOCUMENT, DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ReplaySubject } from 'rxjs/ReplaySubject';
import { DocumentContents } from 'app/documents/document.service';
export interface TocItem {
content: SafeHtml;
href: string;
isSecondary?: boolean;
level: string;
title: string;
}
@Injectable()
export class TocService {
tocList: TocItem[];
constructor(@Inject(DOCUMENT) private document: any, private domSanitizer: DomSanitizer) { }
genToc(docElement: Element, docId = '') {
const tocList = this.tocList = [];
if (!docElement) { return; }
const headings = docElement.querySelectorAll('h2,h3');
const idMap = new Map<string, number>();
for (let i = 0; i < headings.length; i++) {
const heading = headings[i] as HTMLHeadingElement;
// skip if heading class is 'no-toc'
if (/(no-toc|notoc)/i.test(heading.className)) { continue; }
const id = this.getId(heading, idMap);
const toc: TocItem = {
content: this.extractHeadingSafeHtml(heading),
href: `${docId}#${id}`,
level: heading.tagName.toLowerCase(),
title: heading.innerText.trim(),
};
tocList.push(toc);
}
}
reset() {
this.tocList = [];
}
// This bad boy exists only to strip off the anchor link attached to a heading
private extractHeadingSafeHtml(heading: HTMLHeadingElement) {
const a = this.document.createElement('a') as HTMLAnchorElement;
a.innerHTML = heading.innerHTML;
const anchorLink = a.querySelector('a');
if (anchorLink) {
a.removeChild(anchorLink);
}
// security: the document element which provides this heading content
// is always authored by the documentation team and is considered to be safe
return this.domSanitizer.bypassSecurityTrustHtml(a.innerHTML.trim());
}
// Extract the id from the heading; create one if necessary
// Is it possible for a heading to lack an id?
private getId(h: HTMLHeadingElement, idMap: Map<string, number>) {
let id = h.id;
if (id) {
addToMap(id);
} else {
id = h.innerText.toLowerCase().replace(/\W+/g, '-');
id = addToMap(id);
h.id = id;
}
return id;
// Map guards against duplicate id creation.
function addToMap(key: string) {
const count = idMap[key] = idMap[key] ? idMap[key] + 1 : 1;
return count === 1 ? key : `${key}-${count}`;
}
}
}