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 + + + + + -
- -
+ + + 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: '

{{title}}

' +}) +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 @@ +

DatePipe

Stable +
+

Pipe

+
+
+

What it does

+

Formats a date according to locale rules.

+
+
+
+

How to use

+

date_expression | date[:format]

+
+
+
+

NgModule

+ +
+
+

Description

+ +
+

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 +}