feat(aio): refactor DocViewer w/ services component builder (#14294)
Adds basic functionality to fetch documents and display them based on the v42 prototype https://github.com/igorMinar/angular-io-v42
This commit is contained in:
parent
a378aab9aa
commit
2e1413016e
|
@ -3,6 +3,13 @@ import { SitePage } from './app.po';
|
||||||
describe('site App', function() {
|
describe('site App', function() {
|
||||||
let page: SitePage;
|
let page: SitePage;
|
||||||
|
|
||||||
|
beforeAll(done => {
|
||||||
|
// Hack: CI has been failing on first test so
|
||||||
|
// buying time by giving the browser a wake-up call.
|
||||||
|
// Todo: Find and fix the root cause for flakes.
|
||||||
|
new SitePage().navigateTo().then(done);
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
page = new SitePage();
|
page = new SitePage();
|
||||||
});
|
});
|
||||||
|
@ -16,4 +23,14 @@ describe('site App', function() {
|
||||||
expect(page.getDocViewerText()).toContain('Progressive web apps');
|
expect(page.getDocViewerText()).toContain('Progressive web apps');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should convert code-example in pipe.html', () => {
|
||||||
|
page.navigateTo()
|
||||||
|
.then(() => {
|
||||||
|
return page.datePipeLink.click();
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
expect(page.codeExample.count()).toBeGreaterThan(0, 'should have code-example content');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,13 +2,18 @@ import { browser, element, by } from 'protractor';
|
||||||
|
|
||||||
export class SitePage {
|
export class SitePage {
|
||||||
|
|
||||||
featureLink = element(by.css('md-toolbar a[aioNavLink=features]'));
|
links = element.all(by.css('md-toolbar a'));
|
||||||
|
datePipeLink = element(by.css('md-toolbar a[aioNavLink="docs/api/common/date-pipe"]'));
|
||||||
|
docViewer = element(by.css('aio-doc-viewer'));
|
||||||
|
codeExample = element.all(by.css('aio-doc-viewer code-example > pre > code'));
|
||||||
|
featureLink = element(by.css('md-toolbar a[aioNavLink="features"]'));
|
||||||
|
|
||||||
navigateTo() {
|
navigateTo() {
|
||||||
return browser.get('/');
|
return browser.get('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
getDocViewerText() {
|
getDocViewerText() {
|
||||||
return element(by.css('aio-doc-viewer')).getText();
|
return this.docViewer.getText();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
<span><a class="nav-link" aioNavLink="home"> Home </a></span>
|
<span><a class="nav-link" aioNavLink="home"> Home </a></span>
|
||||||
<span><a class="nav-link" aioNavLink="news"> News</a></span>
|
<span><a class="nav-link" aioNavLink="news"> News</a></span>
|
||||||
<span><a class="nav-link" aioNavLink="features"> Features</a></span>
|
<span><a class="nav-link" aioNavLink="features"> Features</a></span>
|
||||||
|
<span><a class="nav-link" aioNavLink="docs/api/common/date-pipe"> DatePipe</a></span>
|
||||||
<span class="fill-remaining-space"></span>
|
<span class="fill-remaining-space"></span>
|
||||||
</md-toolbar>
|
</md-toolbar>
|
||||||
<section class="app-content">
|
<section class="app-content">
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
// /* tslint:disable:no-unused-variable */
|
|
||||||
|
|
||||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
|
@ -1,25 +1,35 @@
|
||||||
import { BrowserModule } from '@angular/platform-browser';
|
import { BrowserModule } from '@angular/platform-browser';
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
|
import { HttpModule } from '@angular/http';
|
||||||
import { MdToolbarModule } from '@angular/material/toolbar';
|
import { MdToolbarModule } from '@angular/material/toolbar';
|
||||||
import { MdButtonModule} from '@angular/material/button';
|
import { MdButtonModule} from '@angular/material/button';
|
||||||
|
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { DocViewerComponent } from './doc-viewer/doc-viewer.component';
|
import { DocViewerComponent } from './doc-viewer/doc-viewer.component';
|
||||||
import { NavEngine } from './nav-engine/nav-engine.service';
|
import { embeddedComponents, EmbeddedComponents } from './embedded';
|
||||||
import { NavLinkDirective } from './nav-engine/nav-link.directive';
|
import { Logger } from './logger.service';
|
||||||
|
import { navDirectives, navProviders } from './nav-engine';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
|
HttpModule,
|
||||||
MdToolbarModule.forRoot(),
|
MdToolbarModule.forRoot(),
|
||||||
MdButtonModule.forRoot()
|
MdButtonModule.forRoot()
|
||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
AppComponent,
|
AppComponent,
|
||||||
|
embeddedComponents,
|
||||||
DocViewerComponent,
|
DocViewerComponent,
|
||||||
NavLinkDirective
|
navDirectives,
|
||||||
],
|
],
|
||||||
providers: [NavEngine],
|
providers: [
|
||||||
|
EmbeddedComponents,
|
||||||
|
Logger,
|
||||||
|
navProviders
|
||||||
|
],
|
||||||
|
entryComponents: [ embeddedComponents ],
|
||||||
bootstrap: [AppComponent]
|
bootstrap: [AppComponent]
|
||||||
})
|
})
|
||||||
export class AppModule { }
|
export class AppModule {
|
||||||
|
}
|
||||||
|
|
|
@ -1,28 +1,308 @@
|
||||||
/* tslint:disable:no-unused-variable */
|
|
||||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { By } from '@angular/platform-browser';
|
import { By } from '@angular/platform-browser';
|
||||||
import { DebugElement } from '@angular/core';
|
import { Component, DebugElement } from '@angular/core';
|
||||||
|
|
||||||
import { DocViewerComponent } from './doc-viewer.component';
|
import { ComponentFactoryResolver, ElementRef, Injector, NgModule, OnInit, ViewChild } from '@angular/core';
|
||||||
|
|
||||||
|
import { Doc, DocMetadata } from '../nav-engine';
|
||||||
|
import { DocViewerComponent } from '../doc-viewer/doc-viewer.component';
|
||||||
|
|
||||||
|
import { embeddedComponents, EmbeddedComponents } from '../embedded';
|
||||||
|
|
||||||
|
|
||||||
|
/// Embedded Test Components ///
|
||||||
|
|
||||||
|
///// FooComponent /////
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'aio-foo',
|
||||||
|
template: `Foo Component`
|
||||||
|
})
|
||||||
|
class FooComponent {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
///// BarComponent /////
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'aio-bar',
|
||||||
|
template: `
|
||||||
|
<hr>
|
||||||
|
<h2>Bar Component</h2>
|
||||||
|
<p #barContent></p>
|
||||||
|
<hr>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
class BarComponent implements OnInit {
|
||||||
|
|
||||||
|
@ViewChild('barContent') barContentRef: ElementRef;
|
||||||
|
|
||||||
|
constructor(public elementRef: ElementRef) { }
|
||||||
|
|
||||||
|
// Project content in ngOnInit just like CodeExampleComponent
|
||||||
|
ngOnInit() {
|
||||||
|
// Security: this is a test component; never deployed
|
||||||
|
this.barContentRef.nativeElement.innerHTML = this.elementRef.nativeElement.aioBarContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///// BazComponent /////
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'aio-baz',
|
||||||
|
template: `
|
||||||
|
<div>++++++++++++++</div>
|
||||||
|
<h2>Baz Component</h2>
|
||||||
|
<p #bazContent></p>
|
||||||
|
<div>++++++++++++++</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
class BazComponent implements OnInit {
|
||||||
|
|
||||||
|
@ViewChild('bazContent') bazContentRef: ElementRef;
|
||||||
|
|
||||||
|
constructor(public elementRef: ElementRef) { }
|
||||||
|
|
||||||
|
// Project content in ngOnInit just like CodeExampleComponent
|
||||||
|
ngOnInit() {
|
||||||
|
// Security: this is a test component; never deployed
|
||||||
|
this.bazContentRef.nativeElement.innerHTML = this.elementRef.nativeElement.aioBazContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
///// Test Module //////
|
||||||
|
|
||||||
|
const embeddedTestComponents = [FooComponent, BarComponent, BazComponent, ...embeddedComponents];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
entryComponents: embeddedTestComponents
|
||||||
|
})
|
||||||
|
class TestModule { }
|
||||||
|
|
||||||
|
//// Test Component //////
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'aio-test',
|
||||||
|
template: `
|
||||||
|
<aio-doc-viewer>Test Component</aio-doc-viewer>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
class TestComponent {
|
||||||
|
private currentDoc: Doc;
|
||||||
|
|
||||||
|
@ViewChild(DocViewerComponent) docViewer: DocViewerComponent;
|
||||||
|
|
||||||
|
setDoc(doc: Doc) {
|
||||||
|
if (this.docViewer) {
|
||||||
|
this.docViewer.doc = doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//////// Tests //////////////
|
||||||
|
|
||||||
describe('DocViewerComponent', () => {
|
describe('DocViewerComponent', () => {
|
||||||
let component: DocViewerComponent;
|
const mockDocMetadata: DocMetadata = { id: 'mock', title: 'Mock Doc', url: '' };
|
||||||
let fixture: ComponentFixture<DocViewerComponent>;
|
let component: TestComponent;
|
||||||
|
let docViewerDE: DebugElement;
|
||||||
|
let docViewerEl: HTMLElement;
|
||||||
|
let fixture: ComponentFixture<TestComponent>;
|
||||||
|
|
||||||
beforeEach(async(() => {
|
beforeEach(async(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
declarations: [ DocViewerComponent ]
|
imports: [ TestModule ],
|
||||||
|
declarations: [
|
||||||
|
TestComponent,
|
||||||
|
DocViewerComponent,
|
||||||
|
embeddedTestComponents
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{provide: EmbeddedComponents, useValue: {components: embeddedTestComponents}}
|
||||||
|
]
|
||||||
})
|
})
|
||||||
.compileComponents();
|
.compileComponents();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fixture = TestBed.createComponent(DocViewerComponent);
|
fixture = TestBed.createComponent(TestComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
docViewerDE = fixture.debugElement.children[0];
|
||||||
|
docViewerEl = docViewerDE.nativeElement;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create', () => {
|
it('should create a DocViewer', () => {
|
||||||
expect(component).toBeTruthy();
|
expect(component.docViewer).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(('should display nothing when set DocViewer.doc to doc w/o content'), () => {
|
||||||
|
component.docViewer.doc = { metadata: mockDocMetadata, content: '' };
|
||||||
|
expect(docViewerEl.innerHTML).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it(('should display simple static content doc'), () => {
|
||||||
|
const content = '<p>Howdy, doc viewer</p>';
|
||||||
|
component.docViewer.doc = { metadata: mockDocMetadata, content };
|
||||||
|
expect(docViewerEl.innerHTML).toEqual(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(('should display nothing after reset static content doc'), () => {
|
||||||
|
const content = '<p>Howdy, doc viewer</p>';
|
||||||
|
component.docViewer.doc = { metadata: mockDocMetadata, content };
|
||||||
|
fixture.detectChanges();
|
||||||
|
component.docViewer.doc = { metadata: mockDocMetadata, content: '' };
|
||||||
|
expect(docViewerEl.innerHTML).toEqual('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it(('should apply FooComponent'), () => {
|
||||||
|
const content = `
|
||||||
|
<p>Above Foo</p>
|
||||||
|
<p><aio-foo></aio-foo></p>
|
||||||
|
<p>Below Foo</p>
|
||||||
|
`;
|
||||||
|
component.docViewer.doc = { metadata: mockDocMetadata, content };
|
||||||
|
const fooHtml = docViewerEl.querySelector('aio-foo').innerHTML;
|
||||||
|
expect(fooHtml).toContain('Foo Component');
|
||||||
|
});
|
||||||
|
|
||||||
|
it(('should apply multiple FooComponents'), () => {
|
||||||
|
const content = `
|
||||||
|
<p>Above Foo</p>
|
||||||
|
<p><aio-foo></aio-foo></p>
|
||||||
|
<div style="margin-left: 2em;">
|
||||||
|
Holds a
|
||||||
|
<aio-foo>Ignored text</aio-foo>
|
||||||
|
</div>
|
||||||
|
<p>Below Foo</p>
|
||||||
|
`;
|
||||||
|
component.docViewer.doc = { metadata: mockDocMetadata, content };
|
||||||
|
const foos = docViewerEl.querySelectorAll('aio-foo');
|
||||||
|
expect(foos.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(('should apply BarComponent'), () => {
|
||||||
|
const content = `
|
||||||
|
<p>Above Bar</p>
|
||||||
|
<aio-bar></aio-bar>
|
||||||
|
<p>Below Bar</p>
|
||||||
|
`;
|
||||||
|
component.docViewer.doc = { metadata: mockDocMetadata, content };
|
||||||
|
const barHtml = docViewerEl.querySelector('aio-bar').innerHTML;
|
||||||
|
expect(barHtml).toContain('Bar Component');
|
||||||
|
});
|
||||||
|
|
||||||
|
it(('should project bar content into BarComponent'), () => {
|
||||||
|
const content = `
|
||||||
|
<p>Above Bar</p>
|
||||||
|
<aio-bar>###bar content###</aio-bar>
|
||||||
|
<p>Below Bar</p>
|
||||||
|
`;
|
||||||
|
component.docViewer.doc = { metadata: mockDocMetadata, content };
|
||||||
|
|
||||||
|
// necessary to trigger projection within ngOnInit
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const barHtml = docViewerEl.querySelector('aio-bar').innerHTML;
|
||||||
|
expect(barHtml).toContain('###bar content###');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it(('should include Foo and Bar'), () => {
|
||||||
|
const content = `
|
||||||
|
<p>Top</p>
|
||||||
|
<p><aio-foo>ignored</aio-foo></p>
|
||||||
|
<aio-bar>###bar content###</aio-bar>
|
||||||
|
<p><aio-foo></aio-foo></p>
|
||||||
|
<p>Bottom</p>
|
||||||
|
`;
|
||||||
|
component.docViewer.doc = { metadata: mockDocMetadata, content };
|
||||||
|
|
||||||
|
// necessary to trigger Bar's projection within ngOnInit
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const foos = docViewerEl.querySelectorAll('aio-foo');
|
||||||
|
expect(foos.length).toBe(2, 'should have 2 foos');
|
||||||
|
|
||||||
|
const barHtml = docViewerEl.querySelector('aio-bar').innerHTML;
|
||||||
|
expect(barHtml).toContain('###bar content###', 'should have bar with projected content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it(('should not include Bar within Foo'), () => {
|
||||||
|
const content = `
|
||||||
|
<p>Top</p>
|
||||||
|
<div>
|
||||||
|
<aio-foo>
|
||||||
|
<aio-bar>###bar content###</aio-bar>
|
||||||
|
</aio-foo>
|
||||||
|
</div>
|
||||||
|
<p><aio-foo></aio-foo><p>
|
||||||
|
<p>Bottom</p>
|
||||||
|
`;
|
||||||
|
component.docViewer.doc = { metadata: mockDocMetadata, content };
|
||||||
|
|
||||||
|
// necessary to trigger Bar's projection within ngOnInit
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const foos = docViewerEl.querySelectorAll('aio-foo');
|
||||||
|
expect(foos.length).toBe(2, 'should have 2 foos');
|
||||||
|
|
||||||
|
const bars = docViewerEl.querySelectorAll('aio-bar');
|
||||||
|
expect(bars.length).toBe(0, 'did not expect Bar inside Foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
// because FooComponents are processed before BazComponents
|
||||||
|
it(('should include Foo within Bar'), () => {
|
||||||
|
const content = `
|
||||||
|
<p>Top</p>
|
||||||
|
<aio-bar>
|
||||||
|
<div style="margin-left: 2em">
|
||||||
|
Inner <aio-foo></aio-foo>
|
||||||
|
</div>
|
||||||
|
</aio-bar>
|
||||||
|
<p><aio-foo></aio-foo></p>
|
||||||
|
<p>Bottom</p>
|
||||||
|
`;
|
||||||
|
component.docViewer.doc = { metadata: mockDocMetadata, content };
|
||||||
|
|
||||||
|
// necessary to trigger Bar's projection within ngOnInit
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const foos = docViewerEl.querySelectorAll('aio-foo');
|
||||||
|
expect(foos.length).toBe(2, 'should have 2 foos');
|
||||||
|
|
||||||
|
const bars = docViewerEl.querySelectorAll('aio-bar');
|
||||||
|
expect(bars.length).toBe(1, 'should have a bar');
|
||||||
|
expect(bars[0].innerHTML).toContain('Bar Component', 'should have bar template content');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The <aio-baz> tag and its inner content is copied
|
||||||
|
// But the BazComponent is not created and therefore its template content is not displayed
|
||||||
|
// because BarComponents are processed before BazComponents
|
||||||
|
// and no chance for first Baz inside Bar to be processed by builder.
|
||||||
|
it(('should NOT include Bar within Baz'), () => {
|
||||||
|
const content = `
|
||||||
|
<p>Top</p>
|
||||||
|
<aio-bar>
|
||||||
|
<div style="margin-left: 2em">
|
||||||
|
Inner <aio-baz>---baz stuff---</aio-baz>
|
||||||
|
</div>
|
||||||
|
</aio-bar>
|
||||||
|
<p><aio-baz>---More baz--</aio-baz></p>
|
||||||
|
<p>Bottom</p>
|
||||||
|
`;
|
||||||
|
component.docViewer.doc = { metadata: mockDocMetadata, content };
|
||||||
|
|
||||||
|
// necessary to trigger Bar's projection within ngOnInit
|
||||||
|
fixture.detectChanges();
|
||||||
|
const bazs = docViewerEl.querySelectorAll('aio-baz');
|
||||||
|
|
||||||
|
// Both baz tags are there ...
|
||||||
|
expect(bazs.length).toBe(2, 'should have 2 bazs');
|
||||||
|
|
||||||
|
expect(bazs[0].innerHTML).not.toContain('Baz Component',
|
||||||
|
'did not expect 1st Baz template content');
|
||||||
|
|
||||||
|
expect(bazs[1].innerHTML).toContain('Baz Component',
|
||||||
|
'expected 2nd Baz template content');
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,19 @@
|
||||||
import { Component, Input, ElementRef, ViewEncapsulation } from '@angular/core';
|
import {
|
||||||
|
Component, ComponentFactory, ComponentFactoryResolver, ComponentRef,
|
||||||
|
DoCheck, ElementRef, Injector, Input, OnDestroy, ViewEncapsulation
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
import { Doc, DocMetadata } from '../nav-engine';
|
||||||
|
import { EmbeddedComponents } from '../embedded';
|
||||||
|
|
||||||
|
interface EmbeddedComponentFactory {
|
||||||
|
contentPropertyName: string;
|
||||||
|
factory: ComponentFactory<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialization prevents flicker once pre-rendering is on
|
||||||
|
const initialDocViewerElement = document.querySelector('aio-doc-viewer');
|
||||||
|
const initialDocViewerContent = initialDocViewerElement ? initialDocViewerElement.innerHTML : '';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'aio-doc-viewer',
|
selector: 'aio-doc-viewer',
|
||||||
|
@ -6,15 +21,110 @@ import { Component, Input, ElementRef, ViewEncapsulation } from '@angular/core';
|
||||||
// TODO(robwormald): shadow DOM and emulated don't work here (?!)
|
// TODO(robwormald): shadow DOM and emulated don't work here (?!)
|
||||||
// encapsulation: ViewEncapsulation.Native
|
// encapsulation: ViewEncapsulation.Native
|
||||||
})
|
})
|
||||||
export class DocViewerComponent {
|
export class DocViewerComponent implements DoCheck, OnDestroy {
|
||||||
|
|
||||||
@Input()
|
private displayedDoc: DisplayedDoc;
|
||||||
set doc(currentDoc) {
|
private embeddedComponentFactories: Map<string, EmbeddedComponentFactory> = new Map();
|
||||||
if (currentDoc) {
|
private hostElement: HTMLElement;
|
||||||
this.element.nativeElement.innerHTML = currentDoc.content;
|
|
||||||
|
constructor(
|
||||||
|
componentFactoryResolver: ComponentFactoryResolver,
|
||||||
|
elementRef: ElementRef,
|
||||||
|
embeddedComponents: EmbeddedComponents,
|
||||||
|
private injector: Injector,
|
||||||
|
) {
|
||||||
|
this.hostElement = elementRef.nativeElement;
|
||||||
|
// Security: the initialDocViewerContent comes from the prerendered DOM and is considered to be secure
|
||||||
|
this.hostElement.innerHTML = initialDocViewerContent;
|
||||||
|
|
||||||
|
for (const component of embeddedComponents.components) {
|
||||||
|
const factory = componentFactoryResolver.resolveComponentFactory(component);
|
||||||
|
const selector = factory.selector;
|
||||||
|
const contentPropertyName = this.selectorToContentPropertyName(selector);
|
||||||
|
this.embeddedComponentFactories.set(selector, { contentPropertyName, factory });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(private element: ElementRef) { }
|
@Input()
|
||||||
|
set doc(newDoc: Doc) {
|
||||||
|
this.ngOnDestroy();
|
||||||
|
if (newDoc) {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
this.build(newDoc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add doc content to host element and build it out with embedded components
|
||||||
|
*/
|
||||||
|
private build(doc: Doc) {
|
||||||
|
|
||||||
|
const displayedDoc = this.displayedDoc = new DisplayedDoc(doc);
|
||||||
|
|
||||||
|
// security: the doc.content is always authored by the documentation team
|
||||||
|
// and is considered to be safe
|
||||||
|
this.hostElement.innerHTML = doc.content || '';
|
||||||
|
|
||||||
|
if (!doc.content) { return; }
|
||||||
|
|
||||||
|
// TODO(i): why can't I use for-of? why doesn't typescript like Map#value() iterators?
|
||||||
|
this.embeddedComponentFactories.forEach(({ contentPropertyName, factory }, selector) => {
|
||||||
|
const embeddedComponentElements = this.hostElement.querySelectorAll(selector);
|
||||||
|
|
||||||
|
// cast due to https://github.com/Microsoft/TypeScript/issues/4947
|
||||||
|
for (const element of embeddedComponentElements as any as HTMLElement[]){
|
||||||
|
// hack: preserve the current element content because the factory will empty it out
|
||||||
|
// security: the source of this innerHTML is always authored by the documentation team
|
||||||
|
// and is considered to be safe
|
||||||
|
element[contentPropertyName] = element.innerHTML;
|
||||||
|
displayedDoc.addEmbeddedComponent(factory.create(this.injector, [], element));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngDoCheck() {
|
||||||
|
if (this.displayedDoc) { this.displayedDoc.detectChanges(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
// destroy components otherwise there will be memory leaks
|
||||||
|
if (this.displayedDoc) {
|
||||||
|
this.displayedDoc.destroy();
|
||||||
|
this.displayedDoc = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the component content property name by converting the selector to camelCase and appending
|
||||||
|
* 'Content', e.g. live-example => liveExampleContent
|
||||||
|
*/
|
||||||
|
private selectorToContentPropertyName(selector: string) {
|
||||||
|
return selector.replace(/-(.)/g, (match, $1) => $1.toUpperCase()) + 'Content';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DisplayedDoc {
|
||||||
|
|
||||||
|
metadata: DocMetadata;
|
||||||
|
|
||||||
|
private embeddedComponents: ComponentRef<any>[] = [];
|
||||||
|
|
||||||
|
constructor(doc: Doc) {
|
||||||
|
// ignore doc.content ... don't need to keep it around
|
||||||
|
this.metadata = doc.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
addEmbeddedComponent(component: ComponentRef<any>) {
|
||||||
|
this.embeddedComponents.push(component);
|
||||||
|
}
|
||||||
|
|
||||||
|
detectChanges() {
|
||||||
|
this.embeddedComponents.forEach(comp => comp.changeDetectorRef.detectChanges());
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
// destroy components otherwise there will be memory leaks
|
||||||
|
this.embeddedComponents.forEach(comp => comp.destroy());
|
||||||
|
this.embeddedComponents.length = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
/* tslint:disable:no-unused-variable */
|
||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { DebugElement } from '@angular/core';
|
||||||
|
|
||||||
|
import { CodeExampleComponent } from './code-example.component';
|
||||||
|
|
||||||
|
describe('CodeExampleComponent', () => {
|
||||||
|
let component: CodeExampleComponent;
|
||||||
|
let fixture: ComponentFixture<CodeExampleComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [ CodeExampleComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(CodeExampleComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,73 @@
|
||||||
|
/* tslint:disable component-selector */
|
||||||
|
|
||||||
|
import { Component, OnInit, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
|
||||||
|
|
||||||
|
// TODO(i): add clipboard copy functionality
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Angular.io Code Example
|
||||||
|
*
|
||||||
|
* Pretty renders a code block, primarily used in the docs and API reference. Can be used within an Angular app, or
|
||||||
|
* independently, provided that it is dynamically generated by the component resolver.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <code-example [language]="..." [escape]="..." [format]="..." [showcase]="..." [animated]="...">
|
||||||
|
* console.log('Hello World')
|
||||||
|
* </code-example>
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'code-example',
|
||||||
|
template: '<pre class="{{classes}}"><code class="{{animatedClasses}}" #codeContainer></code></pre>'
|
||||||
|
})
|
||||||
|
export class CodeExampleComponent implements OnInit, AfterViewInit {
|
||||||
|
|
||||||
|
@ViewChild('codeContainer') codeContainerRef: ElementRef;
|
||||||
|
|
||||||
|
language: string; // could be javascript, dart, typescript
|
||||||
|
// TODO(i): escape doesn't seem to be currently supported in the original code
|
||||||
|
escape: string; // could be 'html'
|
||||||
|
format: string; // some css class
|
||||||
|
showcase: string; // a string with the value 'true'
|
||||||
|
animated = false;
|
||||||
|
|
||||||
|
// TODO(i): could we use @HostBinding instead or does the CSS have to be scoped to <pre> and <code>
|
||||||
|
classes: string;
|
||||||
|
animatedClasses: string;
|
||||||
|
|
||||||
|
|
||||||
|
constructor(private elementRef: ElementRef) {
|
||||||
|
// TODO(i): @Input should be supported for host elements and should just do a one off initialization of properties
|
||||||
|
// from the host element => talk to Tobias
|
||||||
|
['language', 'escape', 'format', 'showcase', 'animated'].forEach(inputName => {
|
||||||
|
if (!this[inputName]) {
|
||||||
|
this[inputName] = this.elementRef.nativeElement.getAttribute(inputName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
const showcaseClass = this.showcase === 'true' ? ' is-showcase' : '';
|
||||||
|
this.classes = `
|
||||||
|
prettyprint
|
||||||
|
${this.format ? this.format : ''}
|
||||||
|
${this.language ? 'lang-' + this.language : '' }
|
||||||
|
${showcaseClass ? showcaseClass : ''}
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
this.animatedClasses = `${this.animated ? 'animated fadeIn' : ''}`;
|
||||||
|
|
||||||
|
// Security: the codeExampleContent is the original innerHTML of the host element provided by
|
||||||
|
// docs authors and as such its considered to be safe for innerHTML purposes
|
||||||
|
this.codeContainerRef.nativeElement.innerHTML = this.elementRef.nativeElement.codeExampleContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ngAfterViewInit() {
|
||||||
|
// TODO(i): import prettify.js from this file so that we don't need to preload it via index.html
|
||||||
|
// whenever a code example is used, use syntax highlighting.
|
||||||
|
// if(prettyPrint) {
|
||||||
|
// prettyPrint();
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { CodeExampleComponent } from './code-example.component';
|
||||||
|
|
||||||
|
/** Components that can be embedded in docs such as CodeExampleComponent, LiveExampleComponent,... */
|
||||||
|
export const embeddedComponents = [
|
||||||
|
CodeExampleComponent
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Injectable class w/ property returning components that can be embedded in docs */
|
||||||
|
export class EmbeddedComponents {
|
||||||
|
components = embeddedComponents;
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class Logger {
|
||||||
|
|
||||||
|
log(value: any, ...rest) {
|
||||||
|
console.log(value, ...rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(value: any, ...rest) {
|
||||||
|
console.error(value, ...rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(value: any, ...rest) {
|
||||||
|
console.warn(value, ...rest);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
import { DocFetchingService } from './doc-fetching.service';
|
||||||
|
// Write tests when/if this service is retained.
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { Http, Response } from '@angular/http';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { Observable } from 'rxjs/Observable';
|
||||||
|
import { of } from 'rxjs/observable/of';
|
||||||
|
import 'rxjs/add/operator/catch';
|
||||||
|
import 'rxjs/add/operator/do';
|
||||||
|
import 'rxjs/add/operator/map';
|
||||||
|
|
||||||
|
import { Logger } from '../logger.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DocFetchingService {
|
||||||
|
|
||||||
|
constructor(private http: Http, private logger: Logger) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch document from server.
|
||||||
|
* NB: pass 404 response to caller as empty string content
|
||||||
|
* Other errors and non-OK status responses are thrown errors.
|
||||||
|
* TODO: add timeout and retry for lost connection
|
||||||
|
*/
|
||||||
|
getFile(url: string): Observable<string> {
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
const emsg = 'getFile: no URL';
|
||||||
|
this.logger.error(emsg);
|
||||||
|
throw new Error(emsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('fetching document file at ', url);
|
||||||
|
|
||||||
|
return this.http.get(url)
|
||||||
|
.map(res => res.text())
|
||||||
|
.do(content => this.logger.log('fetched document file at ', url) )
|
||||||
|
.catch((error: Response) => {
|
||||||
|
if (error.status === 404) {
|
||||||
|
this.logger.error(`Document file not found at '$(url)'`);
|
||||||
|
return of('');
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
export interface DocMetadata {
|
||||||
|
id: string; // 'home'
|
||||||
|
title: string; // 'Home'
|
||||||
|
url: string; // 'assets/documents/home.html'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Doc {
|
||||||
|
metadata: DocMetadata;
|
||||||
|
content: string;
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { fakeAsync, tick } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { DocService } from './doc.service';
|
||||||
|
import { Doc, DocMetadata } from './doc.model';
|
||||||
|
import { DocFetchingService } from './doc-fetching.service';
|
||||||
|
import { SiteMapService } from './sitemap.service';
|
||||||
|
|
||||||
|
import { Observable } from 'rxjs/Observable';
|
||||||
|
import { of } from 'rxjs/observable/of';
|
||||||
|
import 'rxjs/add/operator/catch';
|
||||||
|
import 'rxjs/add/operator/delay';
|
||||||
|
|
||||||
|
describe('DocService', () => {
|
||||||
|
let docFetchingService: DocFetchingService;
|
||||||
|
let getFileSpy: jasmine.Spy;
|
||||||
|
let loggerSpy: any;
|
||||||
|
let siteMapService: SiteMapService;
|
||||||
|
let docService: DocService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
|
||||||
|
this.content = 'fake file contents';
|
||||||
|
this.metadata = {
|
||||||
|
id: 'fake',
|
||||||
|
title: 'All about the fake',
|
||||||
|
url: 'assets/documents/fake.html'
|
||||||
|
};
|
||||||
|
|
||||||
|
loggerSpy = jasmine.createSpyObj('logger', ['log', 'warn', 'error']);
|
||||||
|
siteMapService = new SiteMapService();
|
||||||
|
spyOn(siteMapService, 'getDocMetadata').and
|
||||||
|
.callFake((id: string) => of(this.metadata).delay(0));
|
||||||
|
|
||||||
|
docFetchingService = new DocFetchingService(null, loggerSpy);
|
||||||
|
getFileSpy = spyOn(docFetchingService, 'getFile').and
|
||||||
|
.callFake((url: string) => of(this.content).delay(0));
|
||||||
|
|
||||||
|
docService = new DocService(docFetchingService, loggerSpy, siteMapService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return fake doc for fake id', fakeAsync(() => {
|
||||||
|
docService.getDoc('fake').subscribe(doc =>
|
||||||
|
expect(doc.content).toBe(this.content)
|
||||||
|
);
|
||||||
|
tick();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should retrieve file once for first file request', fakeAsync(() => {
|
||||||
|
docService.getDoc('fake').subscribe();
|
||||||
|
expect(getFileSpy.calls.count()).toBe(0, 'no call before tick');
|
||||||
|
tick();
|
||||||
|
expect(getFileSpy.calls.count()).toBe(1, 'one call after tick');
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should retrieve file from cache the second time', fakeAsync(() => {
|
||||||
|
docService.getDoc('fake').subscribe();
|
||||||
|
tick();
|
||||||
|
expect(getFileSpy.calls.count()).toBe(1, 'one call after 1st request');
|
||||||
|
|
||||||
|
docService.getDoc('fake').subscribe();
|
||||||
|
tick();
|
||||||
|
expect(getFileSpy.calls.count()).toBe(1, 'still only one call after 2nd request');
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should pass along file error through its getDoc observable result', fakeAsync(() => {
|
||||||
|
const err = 'deliberate file error';
|
||||||
|
getFileSpy.and.throwError(err);
|
||||||
|
docService.getDoc('fake').subscribe(
|
||||||
|
doc => expect(false).toBe(true, 'should have failed'),
|
||||||
|
error => expect(error.message).toBe(err)
|
||||||
|
);
|
||||||
|
tick();
|
||||||
|
}));
|
||||||
|
});
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { Observable } from 'rxjs/Observable';
|
||||||
|
import { of } from 'rxjs/observable/of';
|
||||||
|
import 'rxjs/add/operator/map';
|
||||||
|
import 'rxjs/add/operator/switchMap';
|
||||||
|
|
||||||
|
import { Doc, DocMetadata } from './doc.model';
|
||||||
|
import { DocFetchingService } from './doc-fetching.service';
|
||||||
|
import { Logger } from '../logger.service';
|
||||||
|
|
||||||
|
import { SiteMapService } from './sitemap.service';
|
||||||
|
|
||||||
|
interface DocCache {
|
||||||
|
[index: string]: Doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DocService {
|
||||||
|
private cache: DocCache = {};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private fileService: DocFetchingService,
|
||||||
|
private logger: Logger,
|
||||||
|
private siteMapService: SiteMapService
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get document for documentId, from cache if found else server.
|
||||||
|
* Pass server errors along to caller
|
||||||
|
* Caller should interpret empty string content as "404 - file not found"
|
||||||
|
*/
|
||||||
|
getDoc(documentId: string): Observable<Doc> {
|
||||||
|
let doc = this.cache[documentId];
|
||||||
|
if (doc) {
|
||||||
|
this.logger.log('returned cached content for ', doc.metadata);
|
||||||
|
return of(cloneDoc(doc));
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.siteMapService
|
||||||
|
.getDocMetadata(documentId)
|
||||||
|
.switchMap(metadata => {
|
||||||
|
|
||||||
|
return this.fileService.getFile(metadata.url)
|
||||||
|
.map(content => {
|
||||||
|
this.logger.log('fetched content for', metadata);
|
||||||
|
doc = { metadata, content };
|
||||||
|
this.cache[metadata.id] = doc;
|
||||||
|
return cloneDoc(doc);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneDoc(doc: Doc) {
|
||||||
|
return {
|
||||||
|
metadata: Object.assign({}, doc.metadata),
|
||||||
|
content: doc.content
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { DocService } from './doc.service';
|
||||||
|
import { DocFetchingService } from './doc-fetching.service';
|
||||||
|
import { NavEngine } from './nav-engine.service';
|
||||||
|
import { NavLinkDirective } from './nav-link.directive';
|
||||||
|
import { SiteMapService } from './sitemap.service';
|
||||||
|
|
||||||
|
export { Doc, DocMetadata } from './doc.model';
|
||||||
|
|
||||||
|
export const navDirectives = [
|
||||||
|
NavLinkDirective
|
||||||
|
];
|
||||||
|
|
||||||
|
export const navProviders = [
|
||||||
|
DocService,
|
||||||
|
DocFetchingService,
|
||||||
|
NavEngine,
|
||||||
|
SiteMapService,
|
||||||
|
];
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { fakeAsync, tick} from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Observable } from 'rxjs/Observable';
|
||||||
|
import { of } from 'rxjs/observable/of';
|
||||||
|
import 'rxjs/add/operator/delay';
|
||||||
|
|
||||||
|
import { DocService } from './doc.service';
|
||||||
|
import { Doc, DocMetadata } from './doc.model';
|
||||||
|
|
||||||
|
import { NavEngine } from './nav-engine.service';
|
||||||
|
|
||||||
|
const fakeDoc: Doc = {
|
||||||
|
metadata: {
|
||||||
|
id: 'fake',
|
||||||
|
title: 'All about the fake',
|
||||||
|
url: 'assets/documents/fake.html'
|
||||||
|
},
|
||||||
|
content: 'fake content'
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('NavEngine', () => {
|
||||||
|
|
||||||
|
let navEngine: NavEngine;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
this.fakeDoc = {
|
||||||
|
metadata: {
|
||||||
|
id: 'fake',
|
||||||
|
title: 'All about the fake',
|
||||||
|
url: 'assets/documents/fake.html'
|
||||||
|
},
|
||||||
|
content: 'fake content'
|
||||||
|
};
|
||||||
|
|
||||||
|
const docService: any = jasmine.createSpyObj('docService', ['getDoc']);
|
||||||
|
docService.getDoc.and.callFake((id: string) => of(this.fakeDoc).delay(0));
|
||||||
|
|
||||||
|
navEngine = new NavEngine(docService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set currentDoc to fake doc when navigate to fake id', fakeAsync(() => {
|
||||||
|
navEngine.navigate('fake');
|
||||||
|
tick();
|
||||||
|
expect(navEngine.currentDoc.content).toBe(this.fakeDoc.content);
|
||||||
|
}));
|
||||||
|
});
|
|
@ -1,33 +1,24 @@
|
||||||
declare var fetch;
|
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
// TODO(robwormald): figure out how to handle this properly...
|
import { Doc } from './doc.model';
|
||||||
const siteMap = [
|
import { DocService } from './doc.service';
|
||||||
{ 'title': 'Home', 'url': 'assets/documents/home.html', id: 'home'},
|
|
||||||
{ 'title': 'Features', 'url': 'assets/documents/features.html', id: 'features'},
|
|
||||||
{ 'title': 'News', 'url': 'assets/documents/news.html', id: 'news'}
|
|
||||||
];
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NavEngine {
|
export class NavEngine {
|
||||||
currentDoc: any;
|
|
||||||
constructor() {}
|
|
||||||
navigate(documentId) {
|
|
||||||
console.log('navigating to', documentId);
|
|
||||||
const doc = siteMap.find(d => d.id === documentId);
|
|
||||||
if (doc) {
|
|
||||||
this.fetchDoc(doc.url)
|
|
||||||
.then(content => {
|
|
||||||
console.log('fetched content', content);
|
|
||||||
this.currentDoc = Object.assign({}, doc, {content});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fetchDoc(url) {
|
/** Document result of most recent `navigate` call */
|
||||||
// TODO(robwormald): use Http proper once new API is done.
|
currentDoc: Doc;
|
||||||
return fetch(url).then(res => res.text());
|
constructor(private docService: DocService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate sets `currentDoc` to the document for `documentId`.
|
||||||
|
* TODO: handle 'Document not found', signaled by empty string content
|
||||||
|
* TODO: handle document retrieval error
|
||||||
|
*/
|
||||||
|
navigate(documentId: string) {
|
||||||
|
this.docService.getDoc(documentId).subscribe(
|
||||||
|
doc => this.currentDoc = doc
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { fakeAsync, tick } from '@angular/core/testing';
|
||||||
|
import { DocMetadata } from './doc.model';
|
||||||
|
import { SiteMapService } from './sitemap.service';
|
||||||
|
|
||||||
|
describe('SiteMapService', () => {
|
||||||
|
let siteMapService: SiteMapService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
siteMapService = new SiteMapService();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get News metadata', fakeAsync(() => {
|
||||||
|
siteMapService.getDocMetadata('news').subscribe(
|
||||||
|
metadata => expect(metadata.url).toBe('assets/documents/news.html')
|
||||||
|
);
|
||||||
|
tick();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should calculate expected doc url for unknown id', fakeAsync(() => {
|
||||||
|
siteMapService.getDocMetadata('fizbuz').subscribe(
|
||||||
|
metadata => expect(metadata.url).toBe('assets/documents/fizbuz.html')
|
||||||
|
);
|
||||||
|
tick();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should calculate expected index doc url for unknown id ending in /', fakeAsync(() => {
|
||||||
|
siteMapService.getDocMetadata('fizbuz/').subscribe(
|
||||||
|
metadata => expect(metadata.url).toBe('assets/documents/fizbuz/index.html')
|
||||||
|
);
|
||||||
|
tick();
|
||||||
|
}));
|
||||||
|
});
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { Observable } from 'rxjs/Observable';
|
||||||
|
import { of } from 'rxjs/observable/of';
|
||||||
|
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||||
|
import 'rxjs/add/operator/map';
|
||||||
|
|
||||||
|
import { DocMetadata } from './doc.model';
|
||||||
|
|
||||||
|
const siteMap: DocMetadata[] = [
|
||||||
|
{ 'title': 'Home', 'url': 'assets/documents/home.html', id: 'home'},
|
||||||
|
{ 'title': 'Features', 'url': 'assets/documents/features.html', id: 'features'},
|
||||||
|
{ 'title': 'News', 'url': 'assets/documents/news.html', id: 'news'}
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SiteMapService {
|
||||||
|
private siteMap = new BehaviorSubject(siteMap);
|
||||||
|
|
||||||
|
getDocMetadata(id: string) {
|
||||||
|
const missing = () => this.getMissingMetadata(id);
|
||||||
|
return this.siteMap
|
||||||
|
.map(map =>
|
||||||
|
map.find(d => d.id === id) || missing());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternative way to calculate metadata. Will it be used?
|
||||||
|
private getMissingMetadata(id: string) {
|
||||||
|
|
||||||
|
const filename = id.startsWith('/') ? id.substring(1) : id; // strip leading '/'
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: id,
|
||||||
|
url: `assets/documents/${filename}${filename.endsWith('/') ? 'index' : ''}.html`
|
||||||
|
} as DocMetadata;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue