diff --git a/aio/e2e/app.po.ts b/aio/e2e/app.po.ts
index cf063860f7..3a62faf792 100644
--- a/aio/e2e/app.po.ts
+++ b/aio/e2e/app.po.ts
@@ -3,7 +3,7 @@ import { browser, element, by } from 'protractor';
export class SitePage {
links = element.all(by.css('md-toolbar a'));
- datePipeLink = element(by.css('md-toolbar a[aioNavLink="docs/api/common/DatePipe"]'));
+ datePipeLink = element(by.css('md-toolbar a[aioNavLink="api/common/date-pipe"]'));
docViewer = element(by.css('aio-doc-viewer'));
codeExample = element.all(by.css('aio-doc-viewer pre > code'));
featureLink = element(by.css('md-toolbar a[aioNavLink="features"]'));
diff --git a/aio/src/app/app.component.html b/aio/src/app/app.component.html
index 3e8f7411ca..ba1f14991d 100644
--- a/aio/src/app/app.component.html
+++ b/aio/src/app/app.component.html
@@ -1,11 +1,11 @@
- Angular
- Home
- News
- Features
- DatePipe
+ menu
+
+
+
+
-
+
+
+
diff --git a/aio/src/app/app.component.scss b/aio/src/app/app.component.scss
index 9c7f0a83e8..24f4a11d9d 100644
--- a/aio/src/app/app.component.scss
+++ b/aio/src/app/app.component.scss
@@ -1,8 +1,22 @@
.fill-remaining-space {
flex: 1 1 auto;
}
-.nav-link {
- margin-right: 10px;
- margin-left: 20px;
- cursor: pointer;
+
+md-input-container {
+ margin-left: 10px;
+ input {
+ min-width:200px;
+ }
+}
+
+
+.md-input-element {
+ font-size: 70%;
+ font-style: italic;
+}
+
+@media (max-width: 600px) {
+ aio-menu {
+ display: none;
+ }
}
diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts
index 1fc3295212..a72528609b 100644
--- a/aio/src/app/app.component.ts
+++ b/aio/src/app/app.component.ts
@@ -1,6 +1,6 @@
-import { Component } from '@angular/core';
+import { Component, ViewChild } from '@angular/core';
-import { NavEngine } from './nav-engine/nav-engine.service';
+import { SidenavComponent } from './sidenav/sidenav.component';
@Component({
selector: 'aio-shell',
@@ -8,5 +8,9 @@ import { NavEngine } from './nav-engine/nav-engine.service';
styleUrls: ['./app.component.scss']
})
export class AppComponent {
- constructor(public navEngine: NavEngine) {}
+ isHamburgerVisible = true; // always ... for now
+
+ @ViewChild(SidenavComponent) sidenav: SidenavComponent;
+
+ toggleSideNav() { this.sidenav.toggle(); }
}
diff --git a/aio/src/app/app.module.ts b/aio/src/app/app.module.ts
index 491bb9dcdf..c7b5ed0830 100644
--- a/aio/src/app/app.module.ts
+++ b/aio/src/app/app.module.ts
@@ -1,32 +1,51 @@
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { HttpModule } from '@angular/http';
+
import { MdToolbarModule } from '@angular/material/toolbar';
import { MdButtonModule} from '@angular/material/button';
+import { MdIconModule} from '@angular/material/icon';
+import { MdInputModule } from '@angular/material/input';
+import { MdSidenavModule } from '@angular/material/sidenav';
+import { Platform } from '@angular/material/core';
+
+// Temporary fix for MdSidenavModule issue:
+// crashes with "missing first" operator when SideNav.mode is "over"
+import 'rxjs/add/operator/first';
import { AppComponent } from './app.component';
import { DocViewerComponent } from './doc-viewer/doc-viewer.component';
import { embeddedComponents, EmbeddedComponents } from './embedded';
import { Logger } from './logger.service';
import { navDirectives, navProviders } from './nav-engine';
+import { SidenavComponent } from './sidenav/sidenav.component';
+import { NavItemComponent } from './sidenav/nav-item.component';
+import { MenuComponent } from './sidenav/menu.component';
@NgModule({
imports: [
BrowserModule,
HttpModule,
+ MdButtonModule.forRoot(),
+ MdIconModule.forRoot(),
+ MdInputModule.forRoot(),
MdToolbarModule.forRoot(),
- MdButtonModule.forRoot()
+ MdSidenavModule.forRoot()
],
declarations: [
AppComponent,
embeddedComponents,
DocViewerComponent,
+ MenuComponent,
navDirectives,
+ NavItemComponent,
+ SidenavComponent,
],
providers: [
EmbeddedComponents,
Logger,
- navProviders
+ navProviders,
+ Platform
],
entryComponents: [ embeddedComponents ],
bootstrap: [AppComponent]
diff --git a/aio/src/app/doc-viewer/doc-viewer.component.spec.ts b/aio/src/app/doc-viewer/doc-viewer.component.spec.ts
index 8918eb109b..4b40ac6741 100644
--- a/aio/src/app/doc-viewer/doc-viewer.component.spec.ts
+++ b/aio/src/app/doc-viewer/doc-viewer.component.spec.ts
@@ -18,9 +18,7 @@ import { embeddedComponents, EmbeddedComponents } from '../embedded';
selector: 'aio-foo',
template: `Foo Component`
})
-class FooComponent {
-
-}
+class FooComponent { }
///// BarComponent /////
@@ -101,7 +99,7 @@ class TestComponent {
//////// Tests //////////////
describe('DocViewerComponent', () => {
- const mockDocMetadata: DocMetadata = { id: 'mock', title: 'Mock Doc', url: '' };
+ const fakeDocMetadata: DocMetadata = { docId: 'fake', title: 'fake Doc' };
let component: TestComponent;
let docViewerDE: DebugElement;
let docViewerEl: HTMLElement;
@@ -135,21 +133,21 @@ describe('DocViewerComponent', () => {
});
it(('should display nothing when set DocViewer.doc to doc w/o content'), () => {
- component.docViewer.doc = { metadata: mockDocMetadata, content: '' };
+ component.docViewer.doc = { metadata: fakeDocMetadata, content: '' };
expect(docViewerEl.innerHTML).toBe('');
});
it(('should display simple static content doc'), () => {
const content = '
Howdy, doc viewer
';
- component.docViewer.doc = { metadata: mockDocMetadata, content };
+ component.docViewer.doc = { metadata: fakeDocMetadata, 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 };
+ component.docViewer.doc = { metadata: fakeDocMetadata, content };
fixture.detectChanges();
- component.docViewer.doc = { metadata: mockDocMetadata, content: '' };
+ component.docViewer.doc = { metadata: fakeDocMetadata, content: '' };
expect(docViewerEl.innerHTML).toEqual('');
});
@@ -159,7 +157,7 @@ describe('DocViewerComponent', () => {
Below Foo
`;
- component.docViewer.doc = { metadata: mockDocMetadata, content };
+ component.docViewer.doc = { metadata: fakeDocMetadata, content };
const fooHtml = docViewerEl.querySelector('aio-foo').innerHTML;
expect(fooHtml).toContain('Foo Component');
});
@@ -174,7 +172,7 @@ describe('DocViewerComponent', () => {
Below Foo
`;
- component.docViewer.doc = { metadata: mockDocMetadata, content };
+ component.docViewer.doc = { metadata: fakeDocMetadata, content };
const foos = docViewerEl.querySelectorAll('aio-foo');
expect(foos.length).toBe(2);
});
@@ -185,7 +183,7 @@ describe('DocViewerComponent', () => {
Below Bar
`;
- component.docViewer.doc = { metadata: mockDocMetadata, content };
+ component.docViewer.doc = { metadata: fakeDocMetadata, content };
const barHtml = docViewerEl.querySelector('aio-bar').innerHTML;
expect(barHtml).toContain('Bar Component');
});
@@ -196,7 +194,7 @@ describe('DocViewerComponent', () => {
###bar content###
Below Bar
`;
- component.docViewer.doc = { metadata: mockDocMetadata, content };
+ component.docViewer.doc = { metadata: fakeDocMetadata, content };
// necessary to trigger projection within ngOnInit
fixture.detectChanges();
@@ -214,7 +212,7 @@ describe('DocViewerComponent', () => {
Bottom
`;
- component.docViewer.doc = { metadata: mockDocMetadata, content };
+ component.docViewer.doc = { metadata: fakeDocMetadata, content };
// necessary to trigger Bar's projection within ngOnInit
fixture.detectChanges();
@@ -237,7 +235,7 @@ describe('DocViewerComponent', () => {
Bottom
`;
- component.docViewer.doc = { metadata: mockDocMetadata, content };
+ component.docViewer.doc = { metadata: fakeDocMetadata, content };
// necessary to trigger Bar's projection within ngOnInit
fixture.detectChanges();
@@ -261,7 +259,7 @@ describe('DocViewerComponent', () => {
Bottom
`;
- component.docViewer.doc = { metadata: mockDocMetadata, content };
+ component.docViewer.doc = { metadata: fakeDocMetadata, content };
// necessary to trigger Bar's projection within ngOnInit
fixture.detectChanges();
@@ -289,7 +287,7 @@ describe('DocViewerComponent', () => {
---More baz--
Bottom
`;
- component.docViewer.doc = { metadata: mockDocMetadata, content };
+ component.docViewer.doc = { metadata: fakeDocMetadata, content };
// necessary to trigger Bar's projection within ngOnInit
fixture.detectChanges();
diff --git a/aio/src/app/doc-viewer/doc-viewer.component.ts b/aio/src/app/doc-viewer/doc-viewer.component.ts
index b46783cc9b..182e15549d 100644
--- a/aio/src/app/doc-viewer/doc-viewer.component.ts
+++ b/aio/src/app/doc-viewer/doc-viewer.component.ts
@@ -3,7 +3,7 @@ import {
DoCheck, ElementRef, Injector, Input, OnDestroy, ViewEncapsulation
} from '@angular/core';
-import { Doc, DocMetadata } from '../nav-engine';
+import { Doc, DocMetadata, DocMetadataService, NavNode } from '../nav-engine';
import { EmbeddedComponents } from '../embedded';
interface EmbeddedComponentFactory {
@@ -17,7 +17,14 @@ const initialDocViewerContent = initialDocViewerElement ? initialDocViewerElemen
@Component({
selector: 'aio-doc-viewer',
- template: ''
+ template: '',
+ providers: [ DocMetadataService ],
+ styles: [ `
+ :host >>> doc-title.not-found h1 {
+ color: white;
+ background-color: red;
+ }
+ `]
// TODO(robwormald): shadow DOM and emulated don't work here (?!)
// encapsulation: ViewEncapsulation.Native
})
@@ -31,7 +38,8 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
componentFactoryResolver: ComponentFactoryResolver,
elementRef: ElementRef,
embeddedComponents: EmbeddedComponents,
- private injector: Injector,
+ private docMetadataService: DocMetadataService,
+ private injector: Injector
) {
this.hostElement = elementRef.nativeElement;
// Security: the initialDocViewerContent comes from the prerendered DOM and is considered to be secure
@@ -49,6 +57,7 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
set doc(newDoc: Doc) {
this.ngOnDestroy();
if (newDoc) {
+ this.docMetadataService.metadata = newDoc.metadata;
window.scrollTo(0, 0);
this.build(newDoc);
}
diff --git a/aio/src/app/embedded/doc-title.component.ts b/aio/src/app/embedded/doc-title.component.ts
new file mode 100644
index 0000000000..da94762de5
--- /dev/null
+++ b/aio/src/app/embedded/doc-title.component.ts
@@ -0,0 +1,14 @@
+/* tslint:disable component-selector */
+import { Component } from '@angular/core';
+import { DocMetadataService } from '../nav-engine';
+
+@Component({
+ selector: 'doc-title',
+ template: ''
+})
+export class DocTitleComponent {
+ title: string;
+ constructor(metadataService: DocMetadataService) {
+ this.title = metadataService.metadata.title;
+ }
+}
diff --git a/aio/src/app/embedded/index.ts b/aio/src/app/embedded/index.ts
index ac4cdc52ed..42ce07bbee 100644
--- a/aio/src/app/embedded/index.ts
+++ b/aio/src/app/embedded/index.ts
@@ -1,8 +1,9 @@
import { CodeExampleComponent } from './code-example.component';
+import { DocTitleComponent } from './doc-title.component';
/** Components that can be embedded in docs such as CodeExampleComponent, LiveExampleComponent,... */
-export const embeddedComponents = [
- CodeExampleComponent
+export const embeddedComponents: any[] = [
+ CodeExampleComponent, DocTitleComponent
];
/** Injectable class w/ property returning components that can be embedded in docs */
diff --git a/aio/src/app/nav-engine/doc-fetching.service.ts b/aio/src/app/nav-engine/doc-fetching.service.ts
index 827263ca04..887b3b1ee6 100644
--- a/aio/src/app/nav-engine/doc-fetching.service.ts
+++ b/aio/src/app/nav-engine/doc-fetching.service.ts
@@ -7,39 +7,74 @@ import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/do';
import 'rxjs/add/operator/map';
+import { Doc, DocMetadata } from './doc.model';
import { Logger } from '../logger.service';
@Injectable()
export class DocFetchingService {
- constructor(private http: Http, private logger: Logger) { }
+ private base = 'content/';
+
+ constructor(
+ private http: Http,
+ private logger: Logger) { }
+
+ getPath(docId: string) {
+ return this.base + addPathExtension(docId);
+ }
/**
* Fetch document from server.
- * NB: pass 404 response to caller as empty string content
+ * NB: pass 404 response to caller as Doc with 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 {
+ getDocFile(docId: string): Observable {
- if (!url) {
- const emsg = 'getFile: no URL';
+ if (!docId) {
+ const emsg = 'getFile: no document id';
this.logger.error(emsg);
throw new Error(emsg);
}
- this.logger.log('fetching document file at ', url);
+ // TODO: Metadata will be updated from file
+ const metadata: DocMetadata = { docId, title: docId };
+ const url = this.getPath(docId);
+
+ 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) )
+ .map(res => { metadata, content: res.text() }) // TODO: It will come as JSON soon
+ .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('');
+ this.logger.error(`Document file not found at '${url}'`);
+ return of({metadata, content: ''} as Doc);
} else {
throw error;
}
});
}
}
+
+function addPathExtension(path: string) {
+ if (path) {
+ if (path.endsWith('/')) {
+ return path + 'index.html';
+ } else if (!path.endsWith('.html')) {
+ return path + '.html';
+ }
+ }
+ return path;
+}
+
+// function removePathExtension(path: string) {
+// if (path) {
+// if (path.endsWith('/index.html')) {
+// return path.substring(0, path.length - 10);
+// } else if (path.endsWith('.html')) {
+// return path.substring(0, path.length - 5);
+// }
+// }
+// return path;
+// }
diff --git a/aio/src/app/nav-engine/doc-metadata.service.ts b/aio/src/app/nav-engine/doc-metadata.service.ts
new file mode 100644
index 0000000000..cdeda1398c
--- /dev/null
+++ b/aio/src/app/nav-engine/doc-metadata.service.ts
@@ -0,0 +1,7 @@
+import { Injectable } from '@angular/core';
+import { DocMetadata } from './doc.model';
+
+@Injectable()
+export class DocMetadataService {
+ metadata: DocMetadata;
+}
diff --git a/aio/src/app/nav-engine/doc.model.ts b/aio/src/app/nav-engine/doc.model.ts
index 6833ff348f..48d9b11287 100644
--- a/aio/src/app/nav-engine/doc.model.ts
+++ b/aio/src/app/nav-engine/doc.model.ts
@@ -1,10 +1,54 @@
export interface DocMetadata {
- id: string; // 'home'
- title: string; // 'Home'
- url: string; // 'assets/documents/home.html'
+ docId: string;
+ title: string;
}
export interface Doc {
metadata: DocMetadata;
content: string;
}
+
+/**
+ * UI navigation node that describes a document or container of documents
+ * Each node corresponds to a link in the UI.
+ */
+export interface NavNode {
+ /** unique integer id for this node */
+ id: number;
+ /** Document id if this is a document node */
+ docId: string;
+ /** Document path (calculated from docId) if this is a document node */
+ docPath: string;
+ /** url to an external web page; docPath and url are mutually exclusive. */
+ url?: string;
+ /** Title to display in the navigation link; typically shorter */
+ navTitle: string;
+ /** Tooltip for links */
+ tooltip: string;
+ /** Ids of ancestor nodes higher in the hierarchy */
+ ancestorIds: number[];
+ /** true if should not be displayed in UI. Can still be loaded directly */
+ // hide?: boolean; NO NO. If the JSON says, hide, simply omit it from this map
+ /** If defined, this node is a container of child nodes */
+ children?: NavNode[];
+}
+
+
+/**
+ * Navigation for the site.
+ * - nodes: the top-level navigation nodes; node can have children.
+ * - docs: find node for a given document id.
+ */
+export interface NavMap {
+ /**
+ * Map that drives the UI navigation.
+ * Each node correspond to a navigation link in the UI.
+ * Supports unlimited node nesting.
+ */
+ nodes: NavNode[];
+
+ /**
+ * NavNode for a document id in the NavMap.
+ */
+ docs: Map;
+}
diff --git a/aio/src/app/nav-engine/doc.service.spec.ts b/aio/src/app/nav-engine/doc.service.spec.ts
index 1782f1ae97..7de0ad073b 100644
--- a/aio/src/app/nav-engine/doc.service.spec.ts
+++ b/aio/src/app/nav-engine/doc.service.spec.ts
@@ -1,71 +1,80 @@
import { fakeAsync, tick } from '@angular/core/testing';
import { DocService } from './doc.service';
-import { Doc, DocMetadata } from './doc.model';
+import { Doc, DocMetadata, NavNode } 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/observable/throw';
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/delay';
+import 'rxjs/add/operator/take';
describe('DocService', () => {
let docFetchingService: DocFetchingService;
let getFileSpy: jasmine.Spy;
let loggerSpy: any;
- let siteMapService: SiteMapService;
let docService: DocService;
+ let testDoc: Doc;
+ let testDocId: string;
+ let testContent: string;
beforeEach(() => {
-
- this.content = 'fake file contents';
- this.metadata = {
- id: 'fake',
- title: 'All about the fake',
- url: 'assets/documents/fake.html'
+ testDocId = 'fake';
+ testContent = 'fake file contents';
+ testDoc = {
+ metadata: {docId: testDocId, title: 'Fake Title'} as DocMetadata,
+ content: testContent
};
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));
+ getFileSpy = spyOn(docFetchingService, 'getDocFile').and
+ .returnValue(of(testDoc).delay(0).take(1)); // take(1) -> completes
- docService = new DocService(docFetchingService, loggerSpy, siteMapService);
+ docService = new DocService(docFetchingService, loggerSpy);
});
it('should return fake doc for fake id', fakeAsync(() => {
- docService.getDoc('fake').subscribe(doc =>
- expect(doc.content).toBe(this.content)
+ docService.getDoc(testDocId).subscribe(doc =>
+ expect(doc.content).toBe(testContent)
);
tick();
}));
it('should retrieve file once for first file request', fakeAsync(() => {
- docService.getDoc('fake').subscribe();
+ let doc: Doc;
expect(getFileSpy.calls.count()).toBe(0, 'no call before tick');
+ docService.getDoc(testDocId).subscribe(d => doc = d);
tick();
expect(getFileSpy.calls.count()).toBe(1, 'one call after tick');
+ expect(doc).toBe(testDoc, 'expected doc');
}));
it('should retrieve file from cache the second time', fakeAsync(() => {
- docService.getDoc('fake').subscribe();
+ docService.getDoc(testDocId).subscribe(doc =>
+ expect(doc).toBe(testDoc, 'expected doc from server')
+ );
tick();
expect(getFileSpy.calls.count()).toBe(1, 'one call after 1st request');
- docService.getDoc('fake').subscribe();
+ docService.getDoc(testDocId).subscribe(doc =>
+ expect(doc).toBe(testDoc, 'expected doc from cache')
+ );
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(
+ getFileSpy.and.returnValue(
+ // simulate async error in the file retrieval
+ of('').delay(0).map(() => { throw new Error(err); })
+ );
+
+ docService.getDoc(testDocId).subscribe(
doc => expect(false).toBe(true, 'should have failed'),
error => expect(error.message).toBe(err)
);
diff --git a/aio/src/app/nav-engine/doc.service.ts b/aio/src/app/nav-engine/doc.service.ts
index 4cc1de54c5..d5403f799a 100644
--- a/aio/src/app/nav-engine/doc.service.ts
+++ b/aio/src/app/nav-engine/doc.service.ts
@@ -2,59 +2,56 @@ import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { of } from 'rxjs/observable/of';
+import 'rxjs/add/operator/do';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/switchMap';
-import { Doc, DocMetadata } from './doc.model';
+import { Doc, NavNode } from './doc.model';
import { DocFetchingService } from './doc-fetching.service';
import { Logger } from '../logger.service';
-import { SiteMapService } from './sitemap.service';
-
-interface DocCache {
- [index: string]: Doc;
-}
+import { NavMapService } from './nav-map.service';
@Injectable()
export class DocService {
- private cache: DocCache = {};
+ private cache = new Map();
+ private notFoundContent: string;
constructor(
private fileService: DocFetchingService,
- private logger: Logger,
- private siteMapService: SiteMapService
+ private logger: Logger
) { }
/**
- * Get document for documentId, from cache if found else server.
+ * Get document for id, from cache if found else server.
* Pass server errors along to caller
- * Caller should interpret empty string content as "404 - file not found"
+ * Constructs and caches a "Not Found" doc when fileservice returns a doc with no content.
*/
- getDoc(documentId: string): Observable {
- let doc = this.cache[documentId];
- if (doc) {
- this.logger.log('returned cached content for ', doc.metadata);
- return of(cloneDoc(doc));
+ getDoc(docId: string): Observable {
+ const cached = this.cache.get(docId);
+ if (cached) {
+ this.logger.log(`Returned cached document for '${docId}'`);
+ return of(cached);
}
- return this.siteMapService
- .getDocMetadata(documentId)
- .switchMap(metadata => {
+ return this.fileService.getDocFile(docId)
+ .switchMap(doc => {
+ this.logger.log(`Fetched document for '${docId}'`);
+ return doc.content ? of(doc) :
+ this.getNotFound()
+ .map(nfContent => {metadata: {docId, title: docId}, content: nfContent});
+ })
+ .do(doc => this.cache.set(docId, doc));
+ }
- 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);
- });
+ getNotFound(): Observable {
+ if (this.notFoundContent) { return of(this.notFoundContent); }
+ const nfDocId = 'not-found';
+ return this.fileService.getDocFile(nfDocId)
+ .map(doc => {
+ this.logger.log(`Fetched "not found" document for '${nfDocId}'`);
+ this.notFoundContent = doc.content;
+ return doc.content;
});
}
}
-
-function cloneDoc(doc: Doc) {
- return {
- metadata: Object.assign({}, doc.metadata),
- content: doc.content
- };
-}
diff --git a/aio/src/app/nav-engine/index.ts b/aio/src/app/nav-engine/index.ts
index b8ad833621..32a4982a51 100644
--- a/aio/src/app/nav-engine/index.ts
+++ b/aio/src/app/nav-engine/index.ts
@@ -2,9 +2,12 @@ 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';
+import { NavMapService } from './nav-map.service';
-export { Doc, DocMetadata } from './doc.model';
+export { Doc, DocMetadata, NavNode, NavMap } from './doc.model';
+export { DocMetadataService } from './doc-metadata.service';
+export { NavEngine } from './nav-engine.service';
+export { NavMapService } from './nav-map.service';
export const navDirectives = [
NavLinkDirective
@@ -14,5 +17,5 @@ export const navProviders = [
DocService,
DocFetchingService,
NavEngine,
- SiteMapService,
+ NavMapService,
];
diff --git a/aio/src/app/nav-engine/nav-engine.service.spec.ts b/aio/src/app/nav-engine/nav-engine.service.spec.ts
index f113cd03c2..9e64ac767a 100644
--- a/aio/src/app/nav-engine/nav-engine.service.spec.ts
+++ b/aio/src/app/nav-engine/nav-engine.service.spec.ts
@@ -5,42 +5,35 @@ import { of } from 'rxjs/observable/of';
import 'rxjs/add/operator/delay';
import { DocService } from './doc.service';
-import { Doc, DocMetadata } from './doc.model';
+import { Doc, DocMetadata, NavNode } 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 fakeDoc: Doc;
let navEngine: NavEngine;
beforeEach(() => {
- this.fakeDoc = {
+ fakeDoc = {
metadata: {
- id: 'fake',
- title: 'All about the fake',
- url: 'assets/documents/fake.html'
+ docId: 'fake',
+ title: 'All about the fake'
},
content: 'fake content'
};
const docService: any = jasmine.createSpyObj('docService', ['getDoc']);
- docService.getDoc.and.callFake((id: string) => of(this.fakeDoc).delay(0));
+ docService.getDoc.and.returnValue(of(fakeDoc).delay(0));
navEngine = new NavEngine(docService);
});
it('should set currentDoc to fake doc when navigate to fake id', fakeAsync(() => {
navEngine.navigate('fake');
+ navEngine.currentDoc.subscribe(doc =>
+ expect(doc.content).toBe(fakeDoc.content)
+ );
tick();
- expect(navEngine.currentDoc.content).toBe(this.fakeDoc.content);
}));
});
diff --git a/aio/src/app/nav-engine/nav-engine.service.ts b/aio/src/app/nav-engine/nav-engine.service.ts
index 5bd3301320..2e2b8bdbde 100644
--- a/aio/src/app/nav-engine/nav-engine.service.ts
+++ b/aio/src/app/nav-engine/nav-engine.service.ts
@@ -1,24 +1,33 @@
-import { Injectable } from '@angular/core';
+import { Injectable, OnDestroy } from '@angular/core';
+
+import { ReplaySubject } from 'rxjs/ReplaySubject';
+import { Subscription } from 'rxjs/Subscription';
import { Doc } from './doc.model';
import { DocService } from './doc.service';
@Injectable()
-export class NavEngine {
+export class NavEngine implements OnDestroy {
+
+ private docSubject = new ReplaySubject(1);
+ private subscription: Subscription;
+
+ /** Observable of the most recent document from a `navigate` call */
+ currentDoc = this.docSubject.asObservable();
- /** 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
+ * Navigate pushes new doc for the given `id` into the `currentDoc` observable.
* TODO: handle document retrieval error
*/
- navigate(documentId: string) {
- this.docService.getDoc(documentId).subscribe(
- doc => this.currentDoc = doc
- );
+ navigate(docId: string) {
+ this.ngOnDestroy();
+ this.subscription = this.docService.getDoc(docId).subscribe(doc => this.docSubject.next(doc));
+ }
+
+ ngOnDestroy() {
+ if (this.subscription) { this.subscription.unsubscribe(); }
}
}
diff --git a/aio/src/app/nav-engine/nav-map.service.spec.ts b/aio/src/app/nav-engine/nav-map.service.spec.ts
new file mode 100644
index 0000000000..413bcb6092
--- /dev/null
+++ b/aio/src/app/nav-engine/nav-map.service.spec.ts
@@ -0,0 +1,199 @@
+import { fakeAsync, tick } from '@angular/core/testing';
+import { Http, Response } from '@angular/http';
+
+import { of } from 'rxjs/observable/of';
+import 'rxjs/add/operator/catch';
+import 'rxjs/add/operator/delay';
+
+import { DocFetchingService } from './doc-fetching.service';
+import { NavNode, NavMap } from './doc.model';
+import { NavMapService } from './nav-map.service';
+
+import { getTestNavMapResponse } from '../../testing/nav-map-json-response';
+
+describe('NavMapService', () => {
+ let httpSpy: any;
+ let loggerSpy: any;
+ let navMapService: NavMapService;
+ let navMap: NavMap;
+
+ beforeEach(done => {
+ httpSpy = jasmine.createSpyObj('http', ['get']);
+ httpSpy.get.and.returnValue(of(getTestNavMapResponse()).delay(0).take(1)); // take(1) -> completes
+ loggerSpy = jasmine.createSpyObj('logger', ['log', 'warn', 'error']);
+
+ navMapService = new NavMapService(new DocFetchingService(null, null), httpSpy, loggerSpy);
+
+ navMapService.navMap.take(1).subscribe(
+ nm => navMap = nm,
+ null,
+ done);
+ });
+
+ it('should return a navMap', () => {
+ expect(navMap).toBeDefined();
+ });
+
+ it('should have filtered away the "cli-quickstart" because `hide`===true', () => {
+ const item = navMap.nodes.find(n => n.docId === 'guide/cli-quickstart');
+ expect(item).toBeUndefined();
+ });
+
+ describe('Quickstart', () => {
+ let item: NavNode;
+
+ beforeEach(() => {
+ item = navMap.nodes.find(n => n.navTitle === 'Quickstart');
+ });
+
+ it('should have expected item', () => {
+ expect(item).toBeDefined();
+ });
+
+ it('should have expected docId', () => {
+ expect(item.docId).toBe('guide/quickstart');
+ });
+
+ it('should have calculated expected docPath', () => {
+ expect(item.docPath).toBe('content/guide/quickstart.html');
+ });
+
+ it('should have no ancestors because it is a top-level item', () => {
+ expect(item.ancestorIds).toEqual([]);
+ });
+ });
+
+ describe('Getting Started', () => {
+ let section: NavNode;
+
+ beforeEach(() => {
+ section = navMap.nodes.find(n => n.navTitle === 'Getting started');
+ });
+
+ it('should have an id', () => {
+ expect(section.id).toBeGreaterThan(0);
+ });
+
+ it('should have distinct tooltip', () => {
+ expect(section.tooltip).not.toBe(section.navTitle);
+ });
+
+ it('should have 2 children', () => {
+ expect(section.children.length).toBe(2);
+ });
+
+ it('should have itself as ancestor because it has children', () => {
+ expect(section.ancestorIds).toEqual([section.id]);
+ });
+ });
+
+ describe('Tutorial', () => {
+ let section: NavNode;
+ let intro: NavNode;
+
+ beforeEach(() => {
+ section = navMap.nodes.find(n => n.navTitle === 'Tutorial');
+ if (section && section.children) {
+ intro = section.children.find(n => n.navTitle === 'Introduction');
+ }
+ });
+
+ it('should have 2 children', () => {
+ expect(section.children.length).toBe(2);
+ });
+
+ it('intro child\'s docId ends in "/"', () => {
+ expect(intro.docId[intro.docId.length - 1]).toEqual('/');
+ });
+
+ it('intro child\'s calculated docPath ends in "index.html"', () => {
+ expect(intro.docPath).toMatch(/index.html$/);
+ });
+ });
+
+ describe('Core (3-level)', () => {
+ let section: NavNode;
+
+ beforeEach(() => {
+ section = navMap.nodes.find(n => n.navTitle === 'Core');
+ });
+
+ it('should have 2 children', () => {
+ expect(section.children.length).toBe(2);
+ });
+
+ describe('->directives', () => {
+ let directives: NavNode;
+
+ beforeEach(() => {
+ directives = section.children.find(n => n.navTitle === 'Directives');
+ });
+
+ it('should have a heading docId', () => {
+ expect(directives.docId).toBeTruthy();
+ });
+
+ it('should have calculated expected docPath', () => {
+ expect(directives.docPath).toBe('content/guide/directives.html');
+ });
+
+ it('should have 2 children', () => {
+ expect(directives.children.length).toBe(2);
+ });
+
+ it('children should have two ancestor ids in lineal order', () => {
+ const expectedAncestors = [section.id, directives.id];
+ expect(directives.children[0].ancestorIds).toEqual(expectedAncestors, '#1');
+ expect(directives.children[1].ancestorIds).toEqual(expectedAncestors, '#2');
+ });
+ });
+ });
+
+ describe('Empty Heading', () => {
+ let section: NavNode;
+
+ beforeEach(() => {
+ section = navMap.nodes.find(n => n.navTitle === 'Empty Heading');
+ });
+
+ it('should have no children', () => {
+ expect(section.children.length).toBe(0);
+ });
+
+ it('should have itself as ancestor because it has a `children` array', () => {
+ expect(section.ancestorIds).toEqual([section.id]);
+ });
+ });
+
+ describe('External', () => {
+ let section: NavNode;
+ let gitter: NavNode;
+
+ beforeEach(() => {
+ section = navMap.nodes.find(n => n.navTitle === 'External');
+ if (section && section.children) {
+ gitter = section.children[0];
+ }
+ });
+
+ it('should have one child (gitter)', () => {
+ expect(section.children.length).toBe(1);
+ });
+
+ it('child should have a url', () => {
+ expect(gitter.url).toBeTruthy();
+ });
+
+ it('child should not have a docId', () => {
+ expect(gitter.docId).toBeUndefined();
+ });
+
+ it('child should not have a docPath', () => {
+ expect(gitter.docPath).toBeUndefined();
+ });
+
+ it('child should have parent as only ancestor id', () => {
+ expect(gitter.ancestorIds).toEqual([section.id]);
+ });
+ });
+});
diff --git a/aio/src/app/nav-engine/nav-map.service.ts b/aio/src/app/nav-engine/nav-map.service.ts
new file mode 100644
index 0000000000..7fd8ee6f63
--- /dev/null
+++ b/aio/src/app/nav-engine/nav-map.service.ts
@@ -0,0 +1,88 @@
+import { Injectable } from '@angular/core';
+import { Http } from '@angular/http';
+
+import { Observable } from 'rxjs/Observable';
+import { of } from 'rxjs/observable/of';
+import { ReplaySubject } from 'rxjs/ReplaySubject';
+import 'rxjs/add/operator/do';
+import 'rxjs/add/operator/map';
+
+import { Doc, NavNode, NavMap } from './doc.model';
+import { DocFetchingService } from './doc-fetching.service';
+import { Logger } from '../logger.service';
+
+const navMapUrl = 'content/navmap.json';
+
+@Injectable()
+export class NavMapService {
+
+ private getDocPath: (string) => string;
+ private navMapSubject: ReplaySubject;
+ private nextNodeId = 1;
+
+ constructor(
+ docFetchingService: DocFetchingService,
+ private http: Http,
+ private logger: Logger) {
+ this.getDocPath = docFetchingService.getPath.bind(docFetchingService);
+ }
+
+ get navMap(): Observable {
+ return (this.navMapSubject ? this.navMapSubject : this.createNavMapSubject()).asObservable() ;
+ }
+
+ private createNavMapSubject(): ReplaySubject {
+ this.navMapSubject = new ReplaySubject(1);
+
+ this.http.get(navMapUrl)
+ .map(res => res.json().nodes)
+ .do(() => this.logger.log(`Fetched navigation map JSON at '${navMapUrl}'`))
+ .subscribe(
+ navNodes => this.navMapSubject.next(this.createNavMap(navNodes))
+ );
+
+ return this.navMapSubject;
+ }
+
+
+ ////// private helper functions ////
+
+ private createNavMap(nodes: NavNode[]) {
+ nodes = this.removeHidden(nodes);
+ const navMap: NavMap = { nodes, docs: new Map()};
+ nodes.forEach(node => this.adjustNode(node, navMap, []));
+ return navMap;
+ }
+
+ // Adjust properties of a node from JSON and build navMap.docs
+ private adjustNode(node: NavNode, navMap: NavMap, ancestorIds: number[] ) {
+ node.id = this.nextNodeId++;
+ node.ancestorIds = ancestorIds;
+ if ( node.tooltip === undefined ) { node.tooltip = node.navTitle; }
+
+ if (node.docId) {
+ // This node is associated with a document
+ node.docId = node.docId.toLocaleLowerCase();
+ node.docPath = this.getDocPath(node.docId);
+ navMap.docs.set(node.docId, node);
+ }
+
+
+ if (node.children) {
+ // Ancestors include self when this node has children
+ node.ancestorIds = ancestorIds.concat(node.id);
+ node.children.forEach(n => this.adjustNode(n, navMap, node.ancestorIds));
+ }
+ }
+
+ private removeHidden(nodes: NavNode[]) {
+ return nodes.filter(node => {
+ if (node['hide'] === true ) { return false; }
+ if (node.children) {
+ node.children = this.removeHidden(node.children);
+ }
+ return true;
+ });
+ }
+}
+
diff --git a/aio/src/app/nav-engine/sitemap.service.spec.ts b/aio/src/app/nav-engine/sitemap.service.spec.ts
deleted file mode 100644
index e924de5901..0000000000
--- a/aio/src/app/nav-engine/sitemap.service.spec.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-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('content/news.html')
- );
- tick();
- }));
-
- it('should calculate expected doc url for unknown id', fakeAsync(() => {
- siteMapService.getDocMetadata('fizbuz').subscribe(
- metadata => expect(metadata.url).toBe('content/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('content/fizbuz/index.html')
- );
- tick();
- }));
-});
diff --git a/aio/src/app/nav-engine/sitemap.service.ts b/aio/src/app/nav-engine/sitemap.service.ts
deleted file mode 100644
index dfaf26f024..0000000000
--- a/aio/src/app/nav-engine/sitemap.service.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-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': 'content/home.html', id: 'home'},
- { 'title': 'Features', 'url': 'content/features.html', id: 'features'},
- { 'title': 'News', 'url': 'content/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: `content/${filename}${filename.endsWith('/') ? 'index' : ''}.html`
- } as DocMetadata;
- }
-}
diff --git a/aio/src/app/sidenav/menu.component.scss b/aio/src/app/sidenav/menu.component.scss
new file mode 100644
index 0000000000..8ac7a69556
--- /dev/null
+++ b/aio/src/app/sidenav/menu.component.scss
@@ -0,0 +1,23 @@
+.fill-remaining-space {
+ flex: 1 1 auto;
+}
+
+.nav-link {
+ margin-right: 10px;
+ margin-left: 20px;
+ cursor: pointer;
+}
+
+@media (max-width: 700px) {
+ .nav-link {
+ margin-right: 8px;
+ margin-left: 0px;
+ }
+}
+@media (max-width: 600px) {
+ .nav-link {
+ font-size: 80%;
+ margin-right: 8px;
+ margin-left: 0px;
+ }
+}
diff --git a/aio/src/app/sidenav/menu.component.ts b/aio/src/app/sidenav/menu.component.ts
new file mode 100644
index 0000000000..1e66304357
--- /dev/null
+++ b/aio/src/app/sidenav/menu.component.ts
@@ -0,0 +1,16 @@
+import { Component } from '@angular/core';
+
+@Component({
+ selector: 'aio-menu',
+ template: `
+ Home
+
+ API
+ News
+ Features
+ `,
+ styleUrls: ['./menu.component.scss'],
+ animations: []
+})
+export class MenuComponent {
+}
diff --git a/aio/src/app/sidenav/nav-item.component.html b/aio/src/app/sidenav/nav-item.component.html
new file mode 100644
index 0000000000..882228d190
--- /dev/null
+++ b/aio/src/app/sidenav/nav-item.component.html
@@ -0,0 +1,19 @@
+
+
+
+
+
diff --git a/aio/src/app/sidenav/nav-item.component.scss b/aio/src/app/sidenav/nav-item.component.scss
new file mode 100644
index 0000000000..19ba2a0b86
--- /dev/null
+++ b/aio/src/app/sidenav/nav-item.component.scss
@@ -0,0 +1,114 @@
+
+/************************************
+
+ Media queries
+
+To use these, put this snippet in the approriate selector:
+
+ @include bp(tiny) {
+ background-color: purple;
+ }
+
+ Replace "tiny" with "medium" or "big" as necessary.
+
+*************************************/
+
+@mixin bp($point) {
+
+ $bp-xsmall: "(min-width: 320px)";
+ $bp-teeny: "(min-width: 480px)";
+ $bp-tiny: "(min-width: 600px)";
+ $bp-small: "(min-width: 650px)";
+ $bp-medium: "(min-width: 800px)";
+ $bp-big: "(min-width: 1000px)";
+
+ @if $point == big {
+ @media #{$bp-big} { @content; }
+ }
+ @else if $point == medium {
+ @media #{$bp-medium} { @content; }
+ }
+ @else if $point == small {
+ @media #{$bp-small} { @content; }
+ }
+ @else if $point == tiny {
+ @media #{$bp-tiny} { @content; }
+ }
+ @else if $point == teeny {
+ @media #{$bp-teeny} { @content; }
+ }
+ @else if $point == xsmall {
+ @media #{$bp-xsmall} { @content; }
+ }
+}
+
+/************************************/
+
+.vertical-menu {
+ padding-left: 0;
+}
+
+a.vertical-menu {
+ color: #545454;
+ cursor: pointer;
+ display: block;
+ padding-bottom: 10px;
+ padding-top: 10px;
+ padding-right: 10px;
+ text-decoration: none;
+ text-align: left;
+ &:hover {
+ background-color: #ddd;
+ }
+}
+
+.vertical-menu.selected {
+ color:#018494;
+}
+
+.heading {
+ color: #444;
+ cursor: pointer;
+ font-size: .85rem;
+ min-width: 200px;
+ padding-left: 10px;
+ position: relative;
+ text-transform: uppercase;
+}
+
+.material-icons {
+ display: none;
+}
+
+.material-icons.active {
+ display: inline-block;
+ position: absolute;
+ top: 6px;
+ // left: 4px;
+}
+
+.heading-children {
+ display: none;
+}
+
+.heading-children.active {
+ display: block;
+}
+
+
+.heading.selected.level-1,
+.heading-children.selected.level-1 {
+ border-left: 3px #00bcd4 solid;
+}
+
+.level-1 {
+ padding-left: 10px;
+}
+
+.level-2 {
+ padding-left: 20px;
+}
+
+.level-3 {
+ padding-left: 30px;
+}
diff --git a/aio/src/app/sidenav/nav-item.component.ts b/aio/src/app/sidenav/nav-item.component.ts
new file mode 100644
index 0000000000..aa6283f715
--- /dev/null
+++ b/aio/src/app/sidenav/nav-item.component.ts
@@ -0,0 +1,83 @@
+import { Component, EventEmitter, Input, OnInit, OnDestroy } from '@angular/core';
+import { Subscription } from 'rxjs/Subscription';
+
+import { Doc, NavNode } from '../nav-engine';
+
+@Component({
+ selector: 'aio-navitem',
+ templateUrl: 'nav-item.component.html',
+ styleUrls: ['nav-item.component.scss']
+})
+export class NavItemComponent implements OnInit, OnDestroy {
+ @Input() selectedNode: EventEmitter;
+ @Input() node: NavNode;
+ @Input() level = 1;
+
+ isActive = false;
+ isSelected = false;
+ isItem = false;
+ classes: {[index: string]: boolean };
+ href = '';
+ label = '';
+ selectedNodeSubscription: Subscription;
+ target = '';
+ tooltip = '';
+
+ ngOnInit() {
+ this.label = this.node.navTitle;
+ this.tooltip = this.node.tooltip;
+ this.isItem = this.node.children == null;
+ this.href = this.node.url || this.node.docPath ;
+ this.target = this.node.url ? '_blank' : '_self';
+ this.setClasses();
+
+ if (this.selectedNode) {
+ this.selectedNodeSubscription = this.selectedNode.subscribe((n: NavNode) => {
+ this.isSelected = n &&
+ ( n === this.node ||
+ (n.ancestorIds && n.ancestorIds.indexOf(this.node.id) > -1)
+ );
+ // this.isActive = this.isSelected; // disabled per meeting Feb 13
+ this.setClasses();
+ });
+ }
+ }
+
+ ngOnDestroy() {
+ if (this.selectedNodeSubscription) {
+ this.selectedNodeSubscription.unsubscribe();
+ this.selectedNodeSubscription = null;
+ }
+ }
+
+ setClasses() {
+ this.classes = {
+ ['level-' + this.level]: true,
+ active: this.isActive,
+ selected: this.isSelected
+ };
+ }
+
+ itemClicked() {
+ this.isActive = true;
+ this.isSelected = !!this.node.docId;
+ this.setClasses();
+ if (this.isSelected) {
+ this.selectedNode.emit(this.node);
+ return false;
+ }
+ return !!this.node.url; // let browser handle the external page request.
+ }
+
+ headerClicked() {
+ this.isActive = !this.isActive;
+ if (this.isActive && this.node.docId) {
+ this.isSelected = true;
+ if (this.selectedNode) {
+ this.selectedNode.emit(this.node);
+ }
+ }
+ this.setClasses();
+ return false;
+ }
+}
diff --git a/aio/src/app/sidenav/sidenav.component.html b/aio/src/app/sidenav/sidenav.component.html
new file mode 100644
index 0000000000..8cf111103e
--- /dev/null
+++ b/aio/src/app/sidenav/sidenav.component.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/aio/src/app/sidenav/sidenav.component.scss b/aio/src/app/sidenav/sidenav.component.scss
new file mode 100644
index 0000000000..c1577d1036
--- /dev/null
+++ b/aio/src/app/sidenav/sidenav.component.scss
@@ -0,0 +1,27 @@
+.sidenav-container {
+ width: 100%;
+ height: 100vh;
+}
+
+.sidenav-content {
+ height: 100%;
+ width: 100%;
+ margin: auto;
+ padding: 1rem;
+}
+
+.sidenav-content button {
+ min-width: 50px;
+}
+
+.sidenav {
+ padding: 0;
+}
+
+md-toolbar {
+ display: none;
+ padding-left: 10px !important;
+}
+md-toolbar.active {
+ display: block;
+}
diff --git a/aio/src/app/sidenav/sidenav.component.spec.ts b/aio/src/app/sidenav/sidenav.component.spec.ts
new file mode 100644
index 0000000000..43cf000b8d
--- /dev/null
+++ b/aio/src/app/sidenav/sidenav.component.spec.ts
@@ -0,0 +1,223 @@
+/* tslint:disable:no-unused-variable */
+import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+
+import { Component, CUSTOM_ELEMENTS_SCHEMA, DebugElement, EventEmitter, Input, OnInit } from '@angular/core';
+
+import { Observable } from 'rxjs/Observable';
+import { of } from 'rxjs/observable/of';
+import 'rxjs/add/operator/delay';
+
+import { Doc, DocMetadata, NavEngine, NavMapService, NavMap, NavNode } from '../nav-engine';
+import { SidenavComponent } from './sidenav.component';
+
+//// Test Components ///
+@Component({
+ // tslint:disable-next-line:component-selector
+ selector: 'md-sidenav',
+ template: ''
+})
+export class FakeMdSideNavComponent {
+ _isOpen = false;
+ @Input() opened: boolean;
+ @Input() mode: 'side' | 'over';
+ toggle = jasmine.createSpy('toggle');
+}
+
+@Component({
+ selector: 'aio-doc-viewer',
+ template: ''
+})
+export class FakeDocViewerComponent {
+ @Input() doc: Doc;
+}
+
+//// Tests /////
+describe('SidenavComponent', () => {
+ let component: SidenavComponent;
+ let fixture: ComponentFixture;
+
+ let fakeDoc: Doc;
+ let fakeNode: NavNode;
+ let fakeNavMap: NavMap;
+ let navEngine: NavEngine;
+ let navMapService: NavMapService;
+ let navigateSpy: jasmine.Spy;
+
+ beforeEach(async(() => {
+ fakeDoc = {
+ metadata: {docId: 'fake'} as DocMetadata,
+ content: 'Fake content'
+ };
+
+ navEngine = {
+ currentDoc: of(fakeDoc).delay(0).take(1),
+ navigate: (docId: string) => { }
+ } as NavEngine;
+ navigateSpy = spyOn(navEngine, 'navigate');
+
+ fakeNode = {
+ id: 42,
+ docId: fakeDoc.metadata.docId,
+ navTitle: 'Fakery',
+ docPath: 'content/fake.hmlt'
+ } as NavNode;
+
+ fakeNavMap = {
+ nodes: [fakeNode],
+ docs: new Map([[fakeNode.docId, fakeNode]])
+ };
+
+ navMapService = {
+ navMap: of(fakeNavMap).delay(0).take(1)
+ } as NavMapService;
+
+ TestBed.configureTestingModule({
+ declarations: [
+ SidenavComponent,
+ FakeMdSideNavComponent,
+ FakeDocViewerComponent
+ ],
+ providers: [
+ {provide: NavEngine, useValue: navEngine },
+ {provide: NavMapService, useValue: navMapService }
+ ],
+ schemas: [ CUSTOM_ELEMENTS_SCHEMA ]
+ })
+ .compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SidenavComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ describe('#currentDoc', () => {
+ it('should have "currentDoc" after a tick', fakeAsync(() => {
+ component.currentDoc.subscribe(doc => {
+ expect(doc).toBe(fakeDoc);
+ });
+ tick();
+ }));
+ it('should set "currentDocId" as side effect', fakeAsync(() => {
+ component.currentDoc.subscribe(doc => {
+ expect(component.currentDocId).toEqual(fakeDoc.metadata.docId);
+ });
+ tick();
+ }));
+ });
+
+ describe('#nodes', () => {
+ it('should have "nodes" after a tick', fakeAsync(() => {
+ component.nodes.subscribe(nodes => {
+ expect(nodes).toEqual(fakeNavMap.nodes);
+ });
+ tick();
+ }));
+ });
+
+ describe('#selectedNode', () => {
+ // Simulate when user clicks a left nav link in a `NavItemComponent`
+ // which calls `emit` on the selectedNode navigates
+ // all of this synchronously
+ it('should call navigate after emitting a node', () => {
+ expect(navigateSpy.calls.count()).toBe(0, 'before emit');
+ component.selectedNode.emit(fakeNode);
+ expect(navigateSpy.calls.count()).toBe(1, 'after emit');
+ });
+
+ it('should raise event when currentDoc changes', done => {
+ component.selectedNode.subscribe((node: NavNode) => {
+ expect(node.docId).toBe(fakeDoc.metadata.docId);
+ done();
+ });
+ });
+ });
+
+ describe('#onResize', () => {
+ it('should go into side-by-side when width > 600', () => {
+ component.onResize(601);
+ expect(component.isSideBySide).toBe(true);
+ });
+
+ it('should emit overlay mode when width > 600', () => {
+ component.isOverlayMode.subscribe(isOverlay =>
+ expect(isOverlay).toBe(false)
+ );
+ component.onResize(601);
+ });
+ it('should go into side-by-side when width == 600', () => {
+ component.onResize(600);
+ expect(component.isSideBySide).toBe(false);
+ });
+
+ it('should emit overlay mode when width == 600', () => {
+ component.isOverlayMode.subscribe(isOverlay =>
+ expect(isOverlay).toBe(true)
+ );
+ component.onResize(600);
+ });
+ });
+
+ describe('-> MdSideNav', () => {
+
+ let mdSideNavComponent: FakeMdSideNavComponent;
+
+ beforeEach(() => {
+ mdSideNavComponent = fixture.debugElement
+ .query(By.directive(FakeMdSideNavComponent))
+ .componentInstance as FakeMdSideNavComponent;
+ });
+
+ it('toggle should call through to MdSideNav toggle', () => {
+ const calls = mdSideNavComponent.toggle.calls;
+ expect(calls.count()).toBe(0, 'before toggle');
+ component.toggle();
+ expect(calls.count()).toBe(1, 'after toggle');
+ });
+
+ it('should be opened when width > 600', () => {
+ component.onResize(601);
+ fixture.detectChanges();
+ expect(mdSideNavComponent.opened).toBe(true);
+ });
+
+ it('should be not open when width == 600', () => {
+ component.onResize(600);
+ fixture.detectChanges();
+ expect(mdSideNavComponent.opened).toBe(false);
+ });
+ });
+
+ describe('-> DocViewer', () => {
+ let docViewer: FakeDocViewerComponent;
+
+ beforeEach(() => {
+ docViewer = fixture.debugElement
+ .query(By.directive(FakeDocViewerComponent))
+ .componentInstance as FakeDocViewerComponent;
+ });
+
+ it('should not have a document at the start', () => {
+ expect(docViewer.doc).toBeNull();
+ });
+
+ // Doesn't work with fakeAsync and async complains about the delay timer (setInterval);
+ // it('should have a document after NavEngine has a current doc', (fakeAsync(() => {
+ // tick();
+ // fixture.detectChanges();
+ // expect(docViewer.doc).toBe(fakeDoc);
+ // })));
+
+ // Must go old school with setTimeout and `done`
+ it('should have a document after NavEngine has a current doc', (done => {
+ setTimeout(() => {
+ fixture.detectChanges();
+ expect(docViewer.doc).toBe(fakeDoc);
+ done();
+ }, 1);
+ }));
+
+ });
+});
diff --git a/aio/src/app/sidenav/sidenav.component.ts b/aio/src/app/sidenav/sidenav.component.ts
new file mode 100644
index 0000000000..b06648a1d0
--- /dev/null
+++ b/aio/src/app/sidenav/sidenav.component.ts
@@ -0,0 +1,68 @@
+import { Component, EventEmitter, Output, OnInit, OnChanges, ViewChild } from '@angular/core';
+import { MdSidenav } from '@angular/material/sidenav';
+
+import { Observable } from 'rxjs/Observable';
+import 'rxjs/add/operator/do';
+import 'rxjs/add/operator/map';
+import 'rxjs/add/operator/take';
+
+import { Doc, NavEngine, NavMap, NavMapService, NavNode } from '../nav-engine';
+
+@Component({
+ selector: 'aio-sidenav',
+ templateUrl: './sidenav.component.html',
+ styleUrls: ['./sidenav.component.scss'],
+ animations: []
+})
+export class SidenavComponent implements OnInit {
+
+ @Output() isOverlayMode = new EventEmitter();
+ @ViewChild('sidenav') private sidenav: MdSidenav;
+
+ currentDoc: Observable;
+ currentDocId: string;
+ isSideBySide = false;
+ nodes: Observable;
+ selectedNode = new EventEmitter();
+ sideBySideWidth = 600;
+ windowWidth = 0;
+
+ constructor(
+ private navEngine: NavEngine,
+ private navMapService: NavMapService ) {}
+
+ ngOnInit() {
+ this.onResize(window.innerWidth);
+
+ this.nodes = this.navMapService.navMap.map( navMap => navMap.nodes );
+
+ this.currentDoc = this.navEngine.currentDoc
+ .do(doc => {
+ // Side effect: when the current doc changes,
+ // get its NavNode and alert the navigation panel
+ this.currentDocId = doc.metadata.docId;
+ this.navMapService.navMap.take(1) // take makes sure it completes!
+ .map(navMap => navMap.docs.get(this.currentDocId))
+ .subscribe( node => this.selectedNode.emit(node));
+ });
+
+ this.selectedNode.subscribe((node: NavNode) => {
+ // Navigate when the user selects a doc other than the current doc
+ const docId = node && node.docId;
+ if (docId && docId !== this.currentDocId) {
+ this.navEngine.navigate(docId);
+ }
+ });
+ }
+
+ onResize(width) {
+ this.windowWidth = width;
+ this.isSideBySide = width > this.sideBySideWidth;
+ this.sidenav.mode = this.isSideBySide ? 'side' : 'over';
+ this.isOverlayMode.emit(!this.isSideBySide);
+ }
+
+ toggle() {
+ this.sidenav.toggle();
+ }
+}
diff --git a/aio/src/content/api/common/date-pipe.html b/aio/src/content/api/common/date-pipe.html
new file mode 100755
index 0000000000..4ef7301aa3
--- /dev/null
+++ b/aio/src/content/api/common/date-pipe.html
@@ -0,0 +1,239 @@
+
+
+
+
What it does
+
Formats a date according to locale rules.
+
+
+
+
How to use
+
date_expression | date[:format]
+
+
+
+
NgModule
+
CommonModule
+
+
+
+
+
Description
+
Where:
+
+ expression
is a date object or a number (milliseconds since UTC epoch) or
+ an ISO string
+ (https://www.w3.org/TR/NOTE-datetime ).
+
+ format
indicates which date/time components to include. The format can be
+ predifined as
+ shown below or custom as shown in the table.
+
+ 'medium'
: equivalent to 'yMMMdjms'
(e.g.
+ Sep 3, 2010, 12:05:08 PM
for en-US
)
+
+ 'short'
: equivalent to 'yMdjm'
(e.g.
+ 9/3/2010, 12:05 PM
for en-US
)
+
+ 'fullDate'
: equivalent to 'yMMMMEEEEd'
+ (e.g. Friday, September 3, 2010
for en-US
)
+
+ 'longDate'
: equivalent to 'yMMMMd'
(e.g.
+ September 3, 2010
for en-US
)
+
+ 'mediumDate'
: equivalent to 'yMMMd'
(e.g.
+ Sep 3, 2010
for en-US
)
+
+ 'shortDate'
: equivalent to 'yMd'
(e.g.
+ 9/3/2010
for en-US
)
+
+ 'mediumTime'
: equivalent to 'jms'
(e.g.
+ 12:05:08 PM
for en-US
)
+
+ 'shortTime'
: equivalent to 'jm'
(e.g.
+ 12:05 PM
for en-US
)
+
+
+
+
+
+
+
+ Component
+ Symbol
+ Narrow
+ Short Form
+ Long Form
+ Numeric
+ 2-digit
+
+
+
+
+ era
+ G
+ G (A)
+ GGG (AD)
+ GGGG (Anno Domini)
+ -
+ -
+
+
+ year
+ y
+ -
+ -
+ -
+ y (2015)
+ yy (15)
+
+
+ month
+ M
+ L (S)
+ MMM (Sep)
+ MMMM (September)
+ M (9)
+ MM (09)
+
+
+ day
+ d
+ -
+ -
+ -
+ d (3)
+ dd (03)
+
+
+ weekday
+ E
+ E (S)
+ EEE (Sun)
+ EEEE (Sunday)
+ -
+ -
+
+
+ hour
+ j
+ -
+ -
+ -
+ j (13)
+ jj (13)
+
+
+ hour12
+ h
+ -
+ -
+ -
+ h (1 PM)
+ hh (01 PM)
+
+
+ hour24
+ H
+ -
+ -
+ -
+ H (13)
+ HH (13)
+
+
+ minute
+ m
+ -
+ -
+ -
+ m (5)
+ mm (05)
+
+
+ second
+ s
+ -
+ -
+ -
+ s (9)
+ ss (09)
+
+
+ timezone
+ z
+ -
+ -
+ z (Pacific Standard Time)
+ -
+ -
+
+
+ timezone
+ Z
+ -
+ Z (GMT-8:00)
+ -
+ -
+ -
+
+
+ timezone
+ a
+ -
+ a (PM)
+ -
+ -
+ -
+
+
+
+
In javascript, only the components specified will be respected (not the ordering,
+ punctuations, ...) and details of the formatting will be dependent on the locale.
+
Timezone of the formatted text will be the local system timezone of the end-user's
+ machine.
+
WARNINGS:
+
+ this pipe is marked as pure hence it will not be re-evaluated when the input is mutated.
+ Instead users should treat the date as an immutable object and change the reference when
+ the
+ pipe needs to re-run (this is to avoid reformatting the date on every change detection run
+ which would be an expensive operation).
+
+ this pipe uses the Internationalization API. Therefore it is only reliable in Chrome and
+ Opera
+ browsers.
+
+
+
Examples
+
Assuming dateObj
is (year: 2015, month: 6, day: 15, hour: 21, minute: 43,
+ second: 11)
+ in the local time and locale is 'en-US':
+
{{ dateObj | date }} // output is 'Jun 15,
+ 2015'
+ {{ dateObj | date:'medium' }} // output is 'Jun 15, 2015, 9:43:11 PM'
+ {{ dateObj | date:'shortTime' }} // output is '9:43 PM'
+ {{ dateObj | date:'mmss' }} // output is '43:11'
+
+
+ @Component({
+ selector: 'date-pipe',
+ template: `<div>
+ <p>Today is {{today | date}}</p>
+ <p>Or if you prefer, {{today | date:'fullDate'}}</p>
+ <p>The time is {{today | date:'jmZ'}}</p>
+ </div>`
+ })
+ export class DatePipeComponent {
+ today: number = Date.now();
+ }
+
+
+
+
+ exported from @angular/common/index
+ defined in @angular/common/src/pipes/date_pipe.ts
+
\ No newline at end of file
diff --git a/aio/src/content/navmap.json b/aio/src/content/navmap.json
new file mode 100644
index 0000000000..2d845389c4
--- /dev/null
+++ b/aio/src/content/navmap.json
@@ -0,0 +1,110 @@
+{ "nodes": [
+ {
+ "docId": "guide/quickstart",
+ "navTitle": "Quickstart",
+ "tooltip": "A quick look at an Angular app."
+ },
+
+ {
+ "docId": "guide/cli-quickstart",
+ "navTitle": "CLI Quickstart",
+ "tooltip": "A quick look at an Angular app built with the Angular CLI."
+ },
+
+ {
+ "navTitle": "Tutorial",
+ "tooltip": "The Tour of Heroes tutorial takes you through the steps of creating an Angular application in TypeScript.",
+ "children": [
+ {
+ "docId": " tutorial/",
+ "navTitle": "Introduction",
+ "tooltip": "Introduction to the Tour of Heroes tutorial"
+ },
+ {
+ "docId": "tutorial/toh-1",
+ "navTitle": "The Hero Editor",
+ "tooltip": "Build a simple hero editor."
+ }
+ ]
+ },
+
+ {
+ "navTitle": "Getting started",
+ "tooltip": "A gentle introduction to Angular",
+ "children": [
+ {
+ "docId": "guide/docs-overview",
+ "navTitle": "Overview",
+ "tooltip": "How to read and use this documentation."
+ },
+ {
+ "docId": "guide/setup",
+ "navTitle": "Setup",
+ "tooltip": "Install the Angular QuickStart seed for faster, more efficient development on your machine."
+ }
+ ]
+ },
+
+ {
+ "navTitle": "Core",
+ "tooltip": "Learn the core capabilities of Angular",
+ "children": [
+ {
+ "docId": "guide/ngmodule",
+ "navTitle": "Angular Modules (NgModule)",
+ "tooltip": "Define application modules with @NgModule."
+ },
+ {
+ "docId": "guide/directives",
+ "navTitle": "Directives",
+ "tooltip": "Learn how directives modify the layout and behavior of elements.",
+ "children": [
+ {
+ "docId": "guide/attribute-directives",
+ "navTitle": "Attribute directives",
+ "tooltip": "Attribute directives attach behavior to elements."
+ },
+ {
+ "docId": "guide/structural-directives",
+ "navTitle": "Structural directives",
+ "tooltip": "Structural directives manipulate the layout of the page."
+ }
+ ]
+ },
+ {
+ "docId": "guide/pipes",
+ "navTitle": "Pipes",
+ "tooltip": "Pipes transform displayed values within a template."
+ }
+ ]
+ },
+
+ {
+ "navTitle": "Resources",
+ "children": [
+ {
+ "docId": "about",
+ "navTitle": "About",
+ "tooltip": "The people behind Angular"
+ }
+ ]
+ },
+
+ {
+ "navTitle": "Help",
+ "children": [
+ {
+ "url":
+ "http://stackoverflow.com/questions/tagged/angular2",
+ "urlNew": "http://stackoverflow.com/questions/tagged/angular2.html",
+ "navTitle": "Stack Overflow",
+ "tooltip": "Stack Overflow: where the community answers your technical Angular questions."
+ },
+ {
+ "url": "https://gitter.im/angular/angular",
+ "navTitle": "Gitter",
+ "tooltip": "Chat about Angular with other birds of a feather"
+ }
+ ]
+ }
+]}
diff --git a/aio/src/content/not-found.html b/aio/src/content/not-found.html
new file mode 100644
index 0000000000..8eaffd716d
--- /dev/null
+++ b/aio/src/content/not-found.html
@@ -0,0 +1,2 @@
+
+Document not found
diff --git a/aio/src/index.html b/aio/src/index.html
index 1ebac24507..d0c27a795b 100644
--- a/aio/src/index.html
+++ b/aio/src/index.html
@@ -19,6 +19,9 @@
sizes="192x192">
+
+
+
diff --git a/aio/src/styles/main.scss b/aio/src/styles/main.scss
index f7231ea357..6419b9a283 100755
--- a/aio/src/styles/main.scss
+++ b/aio/src/styles/main.scss
@@ -25,16 +25,16 @@ body {
}
h1, h2 {
- weight: 400;
+ font-weight: 400;
}
.docs-primary-header {
padding-left: 20px;
+}
- h1 {
+h1.docs-primary-header, h1 .docs-primary-header{
font-size: 30px;
font-weight: 300;
margin: 0;
- padding: 50px;
- }
+ padding: 30px;
}
diff --git a/aio/src/testing/nav-map-json-response.ts b/aio/src/testing/nav-map-json-response.ts
new file mode 100644
index 0000000000..8fd7fc6762
--- /dev/null
+++ b/aio/src/testing/nav-map-json-response.ts
@@ -0,0 +1,102 @@
+import { Response } from '@angular/http';
+
+// tslint:disable:quotemark
+export function getTestNavMapResponse(): Response {
+
+ const navMapJson = { "nodes": [
+ {
+ "docId": "guide/quickstart",
+ "navTitle": "Quickstart",
+ "tooltip": "A quick look at an Angular app."
+ },
+
+ {
+ "docId": "guide/cli-quickstart",
+ "navTitle": "CLI Quickstart",
+ "tooltip": "A quick look at an Angular app built with the Angular CLI.",
+ "hide": true // <----- SHOULD BE FILTERED OUT
+ },
+
+ {
+ "navTitle": "Tutorial",
+ "children": [
+ {
+ "docId": " tutorial/",
+ "navTitle": "Introduction",
+ "tooltip": "Introduction to the Tour of Heroes tutorial"
+ },
+ {
+ "docId": "tutorial/toh-1",
+ "navTitle": "The Hero Editor",
+ "tooltip": "Build a simple hero editor."
+ }
+ ]
+ },
+
+ {
+ "navTitle": "Getting started",
+ "tooltip": "A gentle introduction to Angular",
+ "children": [
+ {
+ "docId": "guide/docs-overview",
+ "navTitle": "Overview",
+ "tooltip": "How to read and use this documentation."
+ },
+ {
+ "docId": "guide/setup",
+ "navTitle": "Setup",
+ "tooltip": "Install the Angular QuickStart seed for faster, more efficient development on your machine."
+ }
+ ]
+ },
+
+ {
+ "navTitle": "Core",
+ "tooltip": "Learn the core capabilities of Angular",
+ "children": [
+ {
+ "docId": "guide/NgModule",
+ "navTitle": "Angular Modules (NgModule)",
+ "tooltip": "Define application modules with @NgModule."
+ },
+ {
+ "docId": "guide/directives",
+ "navTitle": "Directives",
+ "tooltip": "Learn how directives modify the layout and behavior of elements.",
+ "children": [
+ {
+ "docId": "guide/attribute-directives",
+ "navTitle": "Attribute directives",
+ "tooltip": "Attribute directives attach behavior to elements."
+ },
+ {
+ "docId": "guide/structural-directives",
+ "navTitle": "Structural directives",
+ "tooltip": "Structural directives manipulate the layout of the page."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "navTitle": "Empty Heading",
+ "children": [ ]
+ },
+ {
+ "navTitle": "External",
+ "children": [
+ {
+ "url": "https://gitter.im/angular/angular",
+ "navTitle": "Gitter",
+ "tooltip": "Chat about Angular with other birds of a feather"
+ }
+ ]
+ }
+ ]};
+
+ // tslint:enable:quotemark
+ return {
+ status: 200,
+ json: () => navMapJson
+ } as Response;
+}
diff --git a/aio/transforms/examples-package/processors/collect-examples.spec.js b/aio/transforms/examples-package/processors/collect-examples.spec.js
index e2fc5bffee..3ea7facaaf 100644
--- a/aio/transforms/examples-package/processors/collect-examples.spec.js
+++ b/aio/transforms/examples-package/processors/collect-examples.spec.js
@@ -190,4 +190,4 @@ function createDoc(content, relativePath, docType) {
docType: docType || 'example-file',
startingLine: 1
};
-}
\ No newline at end of file
+}