Howdy, doc viewer
'; + component.docViewer.doc = { metadata: mockDocMetadata, content }; + expect(docViewerEl.innerHTML).toEqual(content); + }); + + it(('should display nothing after reset static content doc'), () => { + const content = 'Howdy, doc viewer
'; + component.docViewer.doc = { metadata: mockDocMetadata, content }; + fixture.detectChanges(); + component.docViewer.doc = { metadata: mockDocMetadata, content: '' }; + expect(docViewerEl.innerHTML).toEqual(''); + }); + + it(('should apply FooComponent'), () => { + const content = ` +Above Foo
+Below Foo
+ `; + 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 = ` +Above Foo
+Below Foo
+ `; + component.docViewer.doc = { metadata: mockDocMetadata, content }; + const foos = docViewerEl.querySelectorAll('aio-foo'); + expect(foos.length).toBe(2); + }); + + it(('should apply BarComponent'), () => { + const content = ` +Above Bar
+Below Bar
+ `; + 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 = ` +Above Bar
+Below Bar
+ `; + 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 = ` +Top
+Bottom
+ `; + 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 = ` +Top
++
Bottom
+ `; + 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 = ` +Top
+Bottom
+ `; + 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'); + }); + + // TheTop
+Bottom
+ `; + 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'); + }); }); diff --git a/angular.io/src/app/doc-viewer/doc-viewer.component.ts b/angular.io/src/app/doc-viewer/doc-viewer.component.ts index 8314cf77f1..b46783cc9b 100644 --- a/angular.io/src/app/doc-viewer/doc-viewer.component.ts +++ b/angular.io/src/app/doc-viewer/doc-viewer.component.ts @@ -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
'
+})
+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 and
+ 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();
+ // }
+ }
+}
diff --git a/angular.io/src/app/embedded/index.ts b/angular.io/src/app/embedded/index.ts
new file mode 100644
index 0000000000..ac4cdc52ed
--- /dev/null
+++ b/angular.io/src/app/embedded/index.ts
@@ -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;
+}
diff --git a/angular.io/src/app/logger.service.ts b/angular.io/src/app/logger.service.ts
new file mode 100644
index 0000000000..900de146c1
--- /dev/null
+++ b/angular.io/src/app/logger.service.ts
@@ -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);
+ }
+}
diff --git a/angular.io/src/app/nav-engine/doc-fetching.service.spec.ts b/angular.io/src/app/nav-engine/doc-fetching.service.spec.ts
new file mode 100644
index 0000000000..9b69019df8
--- /dev/null
+++ b/angular.io/src/app/nav-engine/doc-fetching.service.spec.ts
@@ -0,0 +1,2 @@
+import { DocFetchingService } from './doc-fetching.service';
+// Write tests when/if this service is retained.
diff --git a/angular.io/src/app/nav-engine/doc-fetching.service.ts b/angular.io/src/app/nav-engine/doc-fetching.service.ts
new file mode 100644
index 0000000000..827263ca04
--- /dev/null
+++ b/angular.io/src/app/nav-engine/doc-fetching.service.ts
@@ -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 {
+
+ 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;
+ }
+ });
+ }
+}
diff --git a/angular.io/src/app/nav-engine/doc.model.ts b/angular.io/src/app/nav-engine/doc.model.ts
new file mode 100644
index 0000000000..6833ff348f
--- /dev/null
+++ b/angular.io/src/app/nav-engine/doc.model.ts
@@ -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;
+}
diff --git a/angular.io/src/app/nav-engine/doc.service.spec.ts b/angular.io/src/app/nav-engine/doc.service.spec.ts
new file mode 100644
index 0000000000..1782f1ae97
--- /dev/null
+++ b/angular.io/src/app/nav-engine/doc.service.spec.ts
@@ -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();
+ }));
+});
diff --git a/angular.io/src/app/nav-engine/doc.service.ts b/angular.io/src/app/nav-engine/doc.service.ts
new file mode 100644
index 0000000000..4cc1de54c5
--- /dev/null
+++ b/angular.io/src/app/nav-engine/doc.service.ts
@@ -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 {
+ 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
+ };
+}
diff --git a/angular.io/src/app/nav-engine/index.ts b/angular.io/src/app/nav-engine/index.ts
new file mode 100644
index 0000000000..b8ad833621
--- /dev/null
+++ b/angular.io/src/app/nav-engine/index.ts
@@ -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,
+];
diff --git a/angular.io/src/app/nav-engine/nav-engine.service.spec.ts b/angular.io/src/app/nav-engine/nav-engine.service.spec.ts
new file mode 100644
index 0000000000..f113cd03c2
--- /dev/null
+++ b/angular.io/src/app/nav-engine/nav-engine.service.spec.ts
@@ -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);
+ }));
+});
diff --git a/angular.io/src/app/nav-engine/nav-engine.service.ts b/angular.io/src/app/nav-engine/nav-engine.service.ts
index 79493e694d..5bd3301320 100644
--- a/angular.io/src/app/nav-engine/nav-engine.service.ts
+++ b/angular.io/src/app/nav-engine/nav-engine.service.ts
@@ -1,33 +1,24 @@
-declare var fetch;
-
import { Injectable } from '@angular/core';
-// TODO(robwormald): figure out how to handle this properly...
-const siteMap = [
- { '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'}
-];
+import { Doc } from './doc.model';
+import { DocService } from './doc.service';
@Injectable()
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) {
- // TODO(robwormald): use Http proper once new API is done.
- return fetch(url).then(res => res.text());
+ /** Document result of most recent `navigate` call */
+ currentDoc: Doc;
+ 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
+ );
}
}
diff --git a/angular.io/src/app/nav-engine/sitemap.service.spec.ts b/angular.io/src/app/nav-engine/sitemap.service.spec.ts
new file mode 100644
index 0000000000..9ad1555d1c
--- /dev/null
+++ b/angular.io/src/app/nav-engine/sitemap.service.spec.ts
@@ -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();
+ }));
+});
diff --git a/angular.io/src/app/nav-engine/sitemap.service.ts b/angular.io/src/app/nav-engine/sitemap.service.ts
new file mode 100644
index 0000000000..f98787eb9f
--- /dev/null
+++ b/angular.io/src/app/nav-engine/sitemap.service.ts
@@ -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;
+ }
+}