diff --git a/aio/content/navigation.json b/aio/content/navigation.json index 2610ca7b53..f4f50794a0 100644 --- a/aio/content/navigation.json +++ b/aio/content/navigation.json @@ -1,28 +1,86 @@ { "TopBar": [ { - "url": "api", - "title": "API" - }, { - "url": "news", - "title": "News" - }, { "url": "features", "title": "Features" + }, + { + "url": "events", + "title": "Events" } ], + "SideNav": [ { - "url": "quickstart", - "title": "Quickstart", - "tooltip": "A quick look at an Angular app." + "url": "overview", + "title": "Docs", + "hidden": true }, { - "url": "cli-quickstart", - "title": "CLI Quickstart", - "tooltip": "A quick look at an Angular app built with the Angular CLI." - }, + "title": "Getting started", + "tooltip": "A gentle introduction to Angular.", + "children": [ + { + "url": "guide/learning-angular", + "title": "Learning Angular", + "tooltip": "A suggested path through the documentation for Angular newcomers." + }, + + { + "url": "quickstart", + "title": "Basic Quickstart", + "tooltip": "A quick look at an Angular app without tooling." + }, + + { + "url": "cli-quickstart", + "title": "CLI Quickstart", + "tooltip": "A quick look at an Angular app built with the Angular CLI." + }, + + { + "url": "guide/setup", + "title": "Setup", + "tooltip": "Install the Angular QuickStart seed for faster, more efficient development on your machine." + }, + + { + "url": "guide/architecture", + "title": "Architecture", + "tooltip": "The basic building blocks of Angular applications." + }, + + { + "url": "guide/appmodule", + "title": "The root AppModule", + "tooltip": "Tell Angular how to construct and bootstrap the app in the root \"AppModule\"." + }, + + { + "url": "guide/displaying-data", + "title": "Displaying data", + "tooltip": "Property binding helps show app data in the UI." + }, + + { + "url": "guide/user-input", + "title": "User Input", + "tooltip": "User input triggers DOM events. We listen to those events with event bindings that funnel updated values back into our components and models." + }, + + { + "url": "guide/forms", + "title": "Forms", + "tooltip": "A form creates a cohesive, effective, and compelling data entry experience. An Angular form coordinates a set of data-bound user controls, tracks changes, validates input, and presents errors." + }, + + { + "url": "guide/dependency-injection", + "title": "Dependency Injection", + "tooltip": "Angular's dependency injection system creates and delivers dependent services \"just-in-time\"." + } + ]}, { "title": "Tutorial", @@ -66,94 +124,6 @@ ] }, - { - "title": "Getting started", - "tooltip": "A gentle introduction to Angular.", - "children": [ - { - "url": "guide/docs-overview", - "title": "Overview", - "tooltip": "How to read and use this documentation." - }, - { - "url": "guide/setup", - "title": "Setup", - "tooltip": "Install the Angular QuickStart seed for faster, more efficient development on your machine." - }, - - { - "url": "guide/learning-angular", - "title": "Learning Angular", - "tooltip": "A suggested path through the documentation for Angular newcomers." - }, - - { - "url": "guide/architecture", - "title": "Architecture", - "tooltip": "The basic building blocks of Angular applications." - }, - - { - "url": "guide/appmodule", - "title": "The root AppModule", - "tooltip": "Tell Angular how to construct and bootstrap the app in the root \"AppModule\"." - }, - - { - "url": "guide/displaying-data", - "title": "Displaying data", - "tooltip": "Property binding helps show app data in the UI." - }, - - { - "url": "guide/user-input", - "title": "User Input", - "tooltip": "User input triggers DOM events. We listen to those events with event bindings that funnel updated values back into our components and models." - }, - - { - "url": "guide/forms", - "title": "Forms", - "tooltip": "A form creates a cohesive, effective, and compelling data entry experience. An Angular form coordinates a set of data-bound user controls, tracks changes, validates input, and presents errors." - }, - - { - "url": "guide/dependency-injection", - "title": "Dependency Injection", - "tooltip": "Angular's dependency injection system creates and delivers dependent services \"just-in-time\"." - }, - - { - "url": "guide/template-syntax", - "title": "Template Syntax", - "tooltip": "Learn how to write templates that display data and consume user events with the help of data binding." - }, - - { - "url": "guide/cheatsheet", - "title": "Cheat Sheet", - "tooltip": "A quick guide to common Angular coding techniques." - }, - - { - "url": "guide/style-guide", - "title": "Style guide", - "tooltip": "Write Angular with style." - }, - - { - "url": "guide/glossary", - "title": "Glossary", - "tooltip": "Brief definitions of the most important words in the Angular vocabulary." - }, - - { - "url": "guide/change-log", - "title": "Change Log", - "tooltip": "An annotated history of recent documentation improvements." - } - ]}, - { "title": "Core", "tooltip": "Learn the core capabilities of Angular", @@ -266,9 +236,21 @@ "url": "guide/router", "title": "Routing & navigation", "tooltip": "Discover the basics of screen navigation with the Angular Router." + }, + + { + "url": "guide/template-syntax", + "title": "Template Syntax", + "tooltip": "Learn how to write templates that display data and consume user events with the help of data binding." } ]}, + { + "title": "API", + "tooltip": "Details of the Angular classes and values.", + "url": "api" + }, + { "title": "Additional Techniques", "tooltip": "Other", @@ -381,9 +363,27 @@ "title": "Resources", "children": [ { - "url": "about", - "title": "About", - "tooltip": "The people behind Angular." + "url": "guide/change-log", + "title": "Change Log", + "tooltip": "An annotated history of recent documentation improvements." + }, + + { + "url": "guide/cheatsheet", + "title": "Cheat Sheet", + "tooltip": "A quick guide to common Angular coding techniques." + }, + + { + "url": "guide/glossary", + "title": "Glossary", + "tooltip": "Brief definitions of the most important words in the Angular vocabulary." + }, + + { + "url": "guide/style-guide", + "title": "Style guide", + "tooltip": "Write Angular with style." } ] }, diff --git a/aio/e2e/app.e2e-spec.ts b/aio/e2e/app.e2e-spec.ts index fd18c98242..f17ae40885 100644 --- a/aio/e2e/app.e2e-spec.ts +++ b/aio/e2e/app.e2e-spec.ts @@ -21,9 +21,13 @@ describe('site App', function() { // navigate to a different page page.getLink('features').click(); + expect(page.getDocViewerText()).toMatch(/Features/i); - // check that we can navigate to the tutorial page via a link in the navigation - page.getLink('tutorial').click(); + // Show the menu; the tutorial section should be fully open from previous visit + page.docsMenuLink.click(); + + // Navigate to the tutorial introduction via a link in the sidenav + page.getNavItem(/introduction/i).click(); expect(page.getDocViewerText()).toMatch(/Tutorial: Tour of Heroes/i); }); diff --git a/aio/e2e/app.po.ts b/aio/e2e/app.po.ts index 3dc3d4a02f..e8a269b4bb 100644 --- a/aio/e2e/app.po.ts +++ b/aio/e2e/app.po.ts @@ -4,6 +4,7 @@ const githubRegex = /https:\/\/github.com\/angular\/angular\//; export class SitePage { links = element.all(by.css('md-toolbar a')); + docsMenuLink = element(by.cssContainingText('aio-top-menu a', 'Docs')); docViewer = element(by.css('aio-doc-viewer')); codeExample = element.all(by.css('aio-doc-viewer pre > code')); ghLink = this.docViewer @@ -11,7 +12,7 @@ export class SitePage { .filter((a: ElementFinder) => a.getAttribute('href').then(href => githubRegex.test(href))) .first(); gaReady: promise.Promise; - getNavHeading(pattern: RegExp) { + getNavItem(pattern: RegExp) { return element.all(by.css('aio-nav-item a')) .filter(element => element.getText().then(text => pattern.test(text))) .first(); diff --git a/aio/src/app/app.component.html b/aio/src/app/app.component.html index 7b9bb34a7a..ea443d173f 100644 --- a/aio/src/app/app.component.html +++ b/aio/src/app/app.component.html @@ -1,30 +1,27 @@ - - Home - + + Home + - - - + + +
- +
+
- + diff --git a/aio/src/app/app.component.spec.ts b/aio/src/app/app.component.spec.ts index d0c7f351da..9e65735bfb 100644 --- a/aio/src/app/app.component.spec.ts +++ b/aio/src/app/app.component.spec.ts @@ -1,11 +1,17 @@ import { async, inject, ComponentFixture, TestBed } from '@angular/core/testing'; import { APP_BASE_HREF } from '@angular/common'; +import { Http } from '@angular/http'; import { By } from '@angular/platform-browser'; + +import { of } from 'rxjs/observable/of'; + import { AppComponent } from './app.component'; import { AppModule } from './app.module'; import { GaService } from 'app/shared/ga.service'; import { SearchResultsComponent } from 'app/search/search-results/search-results.component'; import { SearchBoxComponent } from 'app/search/search-box/search-box.component'; +import { SearchService } from 'app/search/search.service'; +import { MockSearchService } from 'testing/search.service'; import { AutoScrollService } from 'app/shared/auto-scroll.service'; import { LocationService } from 'app/shared/location.service'; import { MockLocationService } from 'testing/location.service'; @@ -23,8 +29,10 @@ describe('AppComponent', () => { providers: [ { provide: APP_BASE_HREF, useValue: '/' }, { provide: GaService, useClass: TestGaService }, + { provide: Http, useClass: TestHttp }, { provide: LocationService, useFactory: () => new MockLocationService(initialUrl) }, - { provide: Logger, useClass: MockLogger } + { provide: Logger, useClass: MockLogger }, + { provide: SearchService, useClass: MockSearchService } ] }); TestBed.compileComponents(); @@ -40,19 +48,8 @@ describe('AppComponent', () => { expect(component).toBeDefined(); }); - describe('google analytics', () => { - it('should call gaService.locationChanged with initial URL', () => { - const { locationChanged } = TestBed.get(GaService) as TestGaService; - expect(locationChanged.calls.count()).toBe(1, 'gaService.locationChanged'); - const args = locationChanged.calls.first().args; - expect(args[0]).toBe(initialUrl); - }); - - // Todo: add test to confirm tracking URL when navigate. - }); - - describe('isHamburgerVisible', () => { - console.log('PENDING: AppComponent isHamburgerVisible'); + describe('is Hamburger Visible', () => { + console.log('PENDING: AppComponent'); }); describe('onResize', () => { @@ -64,6 +61,39 @@ describe('AppComponent', () => { }); }); + describe('shows/hide SideNav based on doc\'s navigation view', () => { + let locationService: MockLocationService; + + beforeEach(() => { + locationService = fixture.debugElement.injector.get(LocationService) as any; + component.onResize(1000); // side-by-side + }); + + it('should have sidenav open when doc in the sidenav (guide/pipes)', () => { + locationService.urlSubject.next('guide/pipes'); + + fixture.detectChanges(); + const sidenav = fixture.debugElement.query(By.css('md-sidenav')).nativeElement; + expect(sidenav.className).toMatch(/sidenav-open/); + }); + + it('should have sidenav open when doc is an api page', () => { + locationService.urlSubject.next('api/a/b/c/d'); + + fixture.detectChanges(); + const sidenav = fixture.debugElement.query(By.css('md-sidenav')).nativeElement; + expect(sidenav.className).toMatch(/sidenav-open/); + }); + + it('should have sidenav closed when doc not in the sidenav (features)', () => { + locationService.urlSubject.next('features'); + + fixture.detectChanges(); + const sidenav = fixture.debugElement.query(By.css('md-sidenav')).nativeElement; + expect(sidenav.className).toMatch(/sidenav-clos/); + }); + }); + describe('currentDocument', () => { console.log('PENDING: AppComponent currentDocument'); }); @@ -89,6 +119,14 @@ describe('AppComponent', () => { }); }); + describe('initialization', () => { + it('should initialize the search worker', inject([SearchService], (searchService: SearchService) => { + fixture.detectChanges(); // triggers ngOnInit + expect(searchService.initWorker).toHaveBeenCalled(); + expect(searchService.loadIndex).toHaveBeenCalled(); + })); + }); + describe('click intercepting', () => { it('should intercept clicks on anchors and call `location.handleAnchorClick()`', inject([LocationService], (location: LocationService) => { @@ -120,8 +158,95 @@ describe('AppComponent', () => { expect(searchResultsComponent.hideResults).not.toHaveBeenCalled(); }); }); + + describe('footer', () => { + it('should have version number', () => { + const versionEl: HTMLElement = fixture.debugElement.query(By.css('aio-footer p.version-info')).nativeElement; + expect(versionEl.innerText).toContain(TestHttp.versionFull); + }); + }); + }); class TestGaService { locationChanged = jasmine.createSpy('locationChanged'); } + +class TestSearchService { + initWorker = jasmine.createSpy('initWorker'); + loadIndex = jasmine.createSpy('loadIndex'); +} + +class TestHttp { + static versionFull = '4.0.0-local+sha.73808dd'; + + // tslint:disable:quotemark + navJson = { + "TopBar": [ + { + "url": "features", + "title": "Features" + } + ], + "SideNav": [ + { + "title": "Core", + "tooltip": "Learn the core capabilities of Angular", + "children": [ + { + "url": "guide/pipes", + "title": "Pipes", + "tooltip": "Pipes transform displayed values within a template." + } + ] + }, + { + "url": "api", + "title": "API", + "tooltip": "Details of the Angular classes and values." + } + ], + "__versionInfo": { + "raw": "4.0.0-rc.6", + "major": 4, + "minor": 0, + "patch": 0, + "prerelease": [ + "local" + ], + "build": "sha.73808dd", + "version": "4.0.0-local", + "codeName": "snapshot", + "isSnapshot": true, + "full": TestHttp.versionFull, + "branch": "master", + "commitSHA": "73808dd38b5ccd729404936834d1568bd066de81" + } + }; + + apiDoc = { + "title": "API", + "contents": "

API Doc

" + }; + + pipesDoc = { + "title": "Pipes", + "contents": "

Pipes Doc

" + }; + + testDoc = { + "title": "Test", + "contents": "

Test Doc

" + }; + + // get = jasmine.createSpy('get').and.callFake((url: string) => { ... }); + get(url: string) { + const json = + /navigation.json/.test(url) ? this.navJson : + /api/.test(url) ? this.apiDoc : + /pipes/.test(url) ? this.pipesDoc : + this.testDoc; + return of({ json: () => json }); + } + +} diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts index a81725bfec..81ac6abef3 100644 --- a/aio/src/app/app.component.ts +++ b/aio/src/app/app.component.ts @@ -1,33 +1,45 @@ -import { Component, ElementRef, HostListener, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'; -import { Observable } from 'rxjs/Observable'; +import { Component, ElementRef, HostListener, OnInit, + QueryList, ViewChild, ViewChildren } from '@angular/core'; +import { MdSidenav } from '@angular/material/sidenav'; -import { GaService } from 'app/shared/ga.service'; -import { LocationService } from 'app/shared/location.service'; +import { AutoScrollService } from 'app/shared/auto-scroll.service'; +import { CurrentNode, NavigationService, NavigationViews, NavigationNode, VersionInfo } from 'app/navigation/navigation.service'; import { DocumentService, DocumentContents } from 'app/documents/document.service'; import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component'; -import { NavigationService, NavigationViews, NavigationNode, VersionInfo } from 'app/navigation/navigation.service'; +import { LocationService } from 'app/shared/location.service'; +import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component'; import { SearchResultsComponent } from 'app/search/search-results/search-results.component'; -import { AutoScrollService } from 'app/shared/auto-scroll.service'; + +const sideNavView = 'SideNav'; @Component({ selector: 'aio-shell', templateUrl: './app.component.html', }) export class AppComponent implements OnInit { - readonly sideBySideWidth = 600; - get homeImageUrl() { - return this.isSideBySide ? - 'assets/images/logos/standard/logo-nav.png' : - 'assets/images/logos/standard/logo-nav-small.png'; - } - isHamburgerVisible = true; // always ... for now + currentNode: CurrentNode; + currentDocument: DocumentContents; + footerNodes: NavigationNode[]; isSideBySide = false; + private isSideNavDoc = false; + private previousNavView: string; + private readonly sideBySideWidth = 600; + sideNavNodes: NavigationNode[]; + topMenuNodes: NavigationNode[]; + versionInfo: VersionInfo; - currentDocument: Observable; - navigationViews: Observable; - selectedNodes: Observable; - versionInfo: Observable; + get homeImageUrl() { + return this.isSideBySide ? + 'assets/images/logos/standard/logo-nav.png' : + 'assets/images/logos/standard/logo-nav-small.png'; + } + get isOpened() { return this.isSideBySide && this.isSideNavDoc; } + get mode() { return this.isSideBySide ? 'side' : 'over'; } + + // Need the doc-viewer element for scrolling the contents + @ViewChild(DocViewerComponent, { read: ElementRef }) + docViewer: ElementRef; @ViewChildren('searchBox, searchResults', { read: ElementRef }) searchElements: QueryList; @@ -35,36 +47,54 @@ export class AppComponent implements OnInit { @ViewChild(SearchResultsComponent) searchResults: SearchResultsComponent; - // We need the doc-viewer element for scrolling the contents - @ViewChild(DocViewerComponent, { read: ElementRef }) - docViewer: ElementRef; + @ViewChild(MdSidenav) + sidenav: MdSidenav; - constructor(documentService: DocumentService, - gaService: GaService, - navigationService: NavigationService, - private autoScroll: AutoScrollService, - private locationService: LocationService) { - this.currentDocument = documentService.currentDocument; - locationService.currentUrl.subscribe(url => gaService.locationChanged(url)); - this.navigationViews = navigationService.navigationViews; - this.selectedNodes = navigationService.selectedNodes; - this.versionInfo = navigationService.versionInfo; - } + constructor( + private autoScrollService: AutoScrollService, + private documentService: DocumentService, + private locationService: LocationService, + private navigationService: NavigationService + ) { } ngOnInit() { - this.onResize(window.innerWidth); + /* No need to unsubscribe because this root component never dies */ - // The url changed, so scroll to the anchor in the hash fragment. - // This subscription is needed when navigating between anchors within a document - // and the document itself has not changed - this.locationService.currentUrl.subscribe(url => this.autoScroll.scroll(this.docViewer.nativeElement.offsetParent)); + this.documentService.currentDocument.subscribe(doc => this.currentDocument = doc); + + // scroll even if only the hash fragment changed + this.locationService.currentUrl.subscribe(url => this.autoScroll()); + + this.navigationService.currentNode.subscribe(currentNode => { + this.currentNode = currentNode; + + // Toggle the sidenav if the kind of view changed + if (this.previousNavView === currentNode.view) { return; } + this.previousNavView = currentNode.view; + this.isSideNavDoc = currentNode.view === sideNavView; + this.sideNavToggle(this.isSideNavDoc); + }); + + this.navigationService.navigationViews.subscribe(views => { + this.footerNodes = views.Footer || []; + this.sideNavNodes = views.SideNav || []; + this.topMenuNodes = views.TopBar || []; + }); + + this.navigationService.versionInfo.subscribe( vi => this.versionInfo = vi ); + + this.onResize(window.innerWidth); + } + + // Scroll to the anchor in the hash fragment. + autoScroll() { + this.autoScrollService.scroll(this.docViewer.nativeElement.offsetParent); } onDocRendered(doc: DocumentContents) { - // A new document has been rendered, so scroll to the anchor in the hash fragment. // This handler is needed because the subscription to the `currentUrl` in `ngOnInit` // gets triggered too early before the doc-viewer has finished rendering the doc - this.autoScroll.scroll(this.docViewer.nativeElement.offsetParent); + this.autoScroll(); } @HostListener('window:resize', ['$event.target.innerWidth']) @@ -84,9 +114,16 @@ export class AppComponent implements OnInit { } // Deal with anchor clicks + if (eventTarget instanceof HTMLImageElement) { + eventTarget = eventTarget.parentElement; // assume image wrapped in Anchor + } if (eventTarget instanceof HTMLAnchorElement) { return this.locationService.handleAnchorClick(eventTarget, button, ctrlKey, metaKey); } return true; } + + sideNavToggle(value?: boolean) { + this.sidenav.toggle(value); + } } diff --git a/aio/src/app/app.module.ts b/aio/src/app/app.module.ts index 45972fa9f0..0a653204f7 100644 --- a/aio/src/app/app.module.ts +++ b/aio/src/app/app.module.ts @@ -28,6 +28,7 @@ import { NavigationService } from 'app/navigation/navigation.service'; import { DocumentService } from 'app/documents/document.service'; import { SearchService } from 'app/search/search.service'; import { TopMenuComponent } from 'app/layout/top-menu/top-menu.component'; +import { FooterComponent } from 'app/layout/footer/footer.component'; import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component'; import { NavItemComponent } from 'app/layout/nav-item/nav-item.component'; import { SearchResultsComponent } from './search/search-results/search-results.component'; @@ -50,6 +51,7 @@ import { AutoScrollService } from 'app/shared/auto-scroll.service'; declarations: [ AppComponent, DocViewerComponent, + FooterComponent, TopMenuComponent, NavMenuComponent, NavItemComponent, diff --git a/aio/src/app/documents/document.service.ts b/aio/src/app/documents/document.service.ts index 3b1968dd72..949d60c1f0 100644 --- a/aio/src/app/documents/document.service.ts +++ b/aio/src/app/documents/document.service.ts @@ -3,6 +3,9 @@ import { Http, Response } from '@angular/http'; import { Observable } from 'rxjs/Observable'; import { AsyncSubject } from 'rxjs/AsyncSubject'; +import { of } from 'rxjs/observable/of'; +import 'rxjs/add/operator/catch'; +import 'rxjs/add/operator/map'; import 'rxjs/add/operator/switchMap'; import { DocumentContents } from './document-contents'; @@ -51,7 +54,7 @@ export class DocumentService { // using `getDocument` means that we can fetch the 404 doc contents from the server and cache it return this.getDocument(FILE_NOT_FOUND_URL); } else { - return Observable.of({ title: 'Not Found', contents: 'Document not found' }); + return of({ title: 'Not Found', contents: 'Document not found' }); } } else { this.logger.error('Error fetching document', error); diff --git a/aio/src/app/layout/footer/footer.component.ts b/aio/src/app/layout/footer/footer.component.ts new file mode 100644 index 0000000000..5505a5a48f --- /dev/null +++ b/aio/src/app/layout/footer/footer.component.ts @@ -0,0 +1,19 @@ +import { Component, Input } from '@angular/core'; +import { VersionInfo } from 'app/navigation/navigation.service'; + +@Component({ + selector: 'aio-footer', + template: ` +
+ +
` +}) +export class FooterComponent { + @Input() versionInfo: VersionInfo; +} + diff --git a/aio/src/app/layout/nav-menu/nav-menu.component.spec.ts b/aio/src/app/layout/nav-menu/nav-menu.component.spec.ts index 090c6572f0..9ffc5c28c1 100644 --- a/aio/src/app/layout/nav-menu/nav-menu.component.spec.ts +++ b/aio/src/app/layout/nav-menu/nav-menu.component.spec.ts @@ -1,6 +1,11 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; + +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; + import { NavMenuComponent } from './nav-menu.component'; +import { CurrentNode, NavigationService, NavigationViews, NavigationNode } from 'app/navigation/navigation.service'; + describe('NavMenuComponent', () => { let component: NavMenuComponent; @@ -9,7 +14,10 @@ describe('NavMenuComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ NavMenuComponent ], - schemas: [NO_ERRORS_SCHEMA] + providers: [ + {provide: NavigationService, useClass: TestNavigationService } + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] }) .compileComponents(); })); @@ -24,3 +32,22 @@ describe('NavMenuComponent', () => { expect(component).toBeTruthy(); }); }); + +//// Test Helpers //// +class TestNavigationService { + navJson = { + SideNav: [ + { title: 'a', children: [ + { url: 'b', title: 'b', children: [ + { url: 'c', title: 'c' }, + { url: 'd', title: 'd' } + ] }, + { url: 'e', title: 'e' } + ] }, + { url: 'f', title: 'f' } + ] + }; + + navigationViews = new BehaviorSubject(this.navJson); + currentNode = new BehaviorSubject(undefined); +} diff --git a/aio/src/app/layout/nav-menu/nav-menu.component.ts b/aio/src/app/layout/nav-menu/nav-menu.component.ts index 509f168360..e065f6548d 100644 --- a/aio/src/app/layout/nav-menu/nav-menu.component.ts +++ b/aio/src/app/layout/nav-menu/nav-menu.component.ts @@ -1,15 +1,14 @@ -import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; -import { NavigationNode } from 'app/navigation/navigation.service'; +import { Component, Input } from '@angular/core'; +import { CurrentNode, NavigationNode } from 'app/navigation/navigation.service'; @Component({ selector: 'aio-nav-menu', - template: `` + template: ` + + ` }) export class NavMenuComponent { - - @Input() - selectedNodes: NavigationNode[]; - - @Input() - nodes: NavigationNode[]; + @Input() currentNode: CurrentNode; + @Input() nodes: NavigationNode[] ; + get filteredNodes() { return this.nodes ? this.nodes.filter(n => !n.hidden) : []; } } diff --git a/aio/src/app/layout/top-menu/top-menu.component.scss b/aio/src/app/layout/top-menu/top-menu.component.scss new file mode 100644 index 0000000000..3f193367a3 --- /dev/null +++ b/aio/src/app/layout/top-menu/top-menu.component.scss @@ -0,0 +1,30 @@ +.fill-remaining-space { + flex: 1 1 auto; +} + +.nav-link { + margin-right: 10px; + margin-left: 20px; + cursor: pointer; +} + +.nav-link.home img { + position: relative; + margin-top: -15px; + top: 12px; + height: 36px; +} + +@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/layout/top-menu/top-menu.component.spec.ts b/aio/src/app/layout/top-menu/top-menu.component.spec.ts new file mode 100644 index 0000000000..906dda5ea5 --- /dev/null +++ b/aio/src/app/layout/top-menu/top-menu.component.spec.ts @@ -0,0 +1,43 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; + +import { TopMenuComponent } from './top-menu.component'; +import { NavigationService, NavigationViews, NavigationNode } from 'app/navigation/navigation.service'; + +describe('TopMenuComponent', () => { + let component: TopMenuComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ TopMenuComponent ], + providers: [ + { provide: NavigationService, useClass: TestNavigationService } + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TopMenuComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); + +//// Test Helpers //// +class TestNavigationService { + navJson = { + TopBar: [ + {url: 'api', title: 'API' }, + {url: 'features', title: 'Features' } + ], + }; + + navigationViews = new BehaviorSubject(this.navJson); +} diff --git a/aio/src/app/layout/top-menu/top-menu.component.ts b/aio/src/app/layout/top-menu/top-menu.component.ts index 0c0720ba32..5b5539c970 100644 --- a/aio/src/app/layout/top-menu/top-menu.component.ts +++ b/aio/src/app/layout/top-menu/top-menu.component.ts @@ -5,42 +5,12 @@ import { NavigationNode } from 'app/navigation/navigation.service'; selector: 'aio-top-menu', template: ` `, - styles: [` - .fill-remaining-space { - flex: 1 1 auto; - } - - .nav-link { - margin-right: 10px; - margin-left: 20px; - cursor: pointer; - } - - .nav-link.home img { - position: relative; - margin-top: -15px; - top: 12px; - height: 36px; - } - - @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; - } - }` - ] + styleUrls: ['top-menu.component.scss'] }) export class TopMenuComponent { - @Input() - nodes: NavigationNode[]; + @Input() nodes: NavigationNode[]; + } diff --git a/aio/src/app/navigation/navigation-node.ts b/aio/src/app/navigation/navigation-node.ts deleted file mode 100644 index 32fad09bae..0000000000 --- a/aio/src/app/navigation/navigation-node.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface NavigationNode { - url?: string; - title?: string; - tooltip?: string; - target?: string; - children?: NavigationNode[]; -} diff --git a/aio/src/app/navigation/navigation.model.ts b/aio/src/app/navigation/navigation.model.ts new file mode 100644 index 0000000000..4bf10d4f04 --- /dev/null +++ b/aio/src/app/navigation/navigation.model.ts @@ -0,0 +1,44 @@ +// Pulled all interfaces out of `navigation.service.ts` because of this: +// https://github.com/angular/angular-cli/issues/2034 +// Then re-export them from `navigation.service.ts` + +export interface NavigationNode { + url?: string; + title?: string; + tooltip?: string; + hidden?: string; + children?: NavigationNode[]; +} + +export type NavigationResponse = {__versionInfo: VersionInfo } & { [name: string]: NavigationNode[]|VersionInfo }; + +export interface NavigationViews { + [name: string]: NavigationNode[]; +} + +/** + * Navigation information about a node at specific URL + * url: the current URL + * view: 'SideNav' | 'TopBar' | 'Footer' + * nodes: the current node and its ancestor nodes within that view + */ +export interface CurrentNode { + url: string; + view: 'SideNav' | 'TopBar' | 'Footer'; + nodes: NavigationNode[]; +} + +export interface VersionInfo { + raw: string; + major: number; + minor: number; + patch: number; + prerelease: string[]; + build: string; + version: string; + codeName: string; + isSnapshot: boolean; + full: string; + branch: string; + commitSHA: string; +} diff --git a/aio/src/app/navigation/navigation.service.spec.ts b/aio/src/app/navigation/navigation.service.spec.ts index 11165dbf40..2029db5f06 100644 --- a/aio/src/app/navigation/navigation.service.spec.ts +++ b/aio/src/app/navigation/navigation.service.spec.ts @@ -1,7 +1,7 @@ import { ReflectiveInjector } from '@angular/core'; import { Http, ConnectionBackend, RequestOptions, BaseRequestOptions, Response, ResponseOptions } from '@angular/http'; import { MockBackend } from '@angular/http/testing'; -import { NavigationService, NavigationViews, NavigationNode, VersionInfo } from 'app/navigation/navigation.service'; +import { CurrentNode, NavigationService, NavigationViews, NavigationNode, VersionInfo } from 'app/navigation/navigation.service'; import { LocationService } from 'app/shared/location.service'; import { MockLocationService } from 'testing/location.service'; import { Logger } from 'app/shared/logger.service'; @@ -76,68 +76,112 @@ describe('NavigationService', () => { it('should do WHAT(?) if the request fails'); }); - describe('selectedNodes', () => { + describe('currentNode', () => { let service: NavigationService, location: MockLocationService; - let currentNodes: NavigationNode[]; - const nodeTree: NavigationNode[] = [ - { title: 'a', children: [ - { url: 'b', title: 'b', children: [ - { url: 'c/', title: 'c' }, - { url: 'd', title: 'd' } + let currentNode: CurrentNode; + + const topBarNodes: NavigationNode[] = [{ url: 'features', title: 'Features' }]; + const sideNavNodes: NavigationNode[] = [ + { title: 'a', children: [ + { url: 'b', title: 'b', children: [ + { url: 'c', title: 'c' }, + { url: 'd', title: 'd' } + ] }, + { url: 'e', title: 'e' } ] }, - { url: 'e', title: 'e' } - ] }, - { url: 'f', title: 'f' } - ]; + { url: 'f', title: 'f' } + ]; + + const navJson = { + TopBar: topBarNodes, + SideNav: sideNavNodes, + __versionInfo: {} + }; + beforeEach(() => { location = injector.get(LocationService); service = injector.get(NavigationService); - service.selectedNodes.subscribe(nodes => currentNodes = nodes); + service.currentNode.subscribe(selected => currentNode = selected); const backend = injector.get(ConnectionBackend); - backend.connectionsArray[0].mockRespond(createResponse({ nav: nodeTree })); + backend.connectionsArray[0].mockRespond(createResponse(navJson)); }); - it('should list the navigation node that matches the current location, and all its ancestors', () => { + it('should list the side navigation node that matches the current location, and all its ancestors', () => { location.urlSubject.next('b'); - expect(currentNodes).toEqual([ - nodeTree[0].children[0], - nodeTree[0] - ]); + expect(currentNode).toEqual({ + url: 'b', + view: 'SideNav', + nodes: [ + sideNavNodes[0].children[0], + sideNavNodes[0] + ] + }); location.urlSubject.next('d'); - expect(currentNodes).toEqual([ - nodeTree[0].children[0].children[1], - nodeTree[0].children[0], - nodeTree[0] - ]); + expect(currentNode).toEqual({ + url: 'd', + view: 'SideNav', + nodes: [ + sideNavNodes[0].children[0].children[1], + sideNavNodes[0].children[0], + sideNavNodes[0] + ] + }); location.urlSubject.next('f'); - expect(currentNodes).toEqual([ - nodeTree[1] - ]); + expect(currentNode).toEqual({ + url: 'f', + view: 'SideNav', + nodes: [ sideNavNodes[1] ] + }); }); - it('should be an empty array if no navigation node matches the current location', () => { + it('should be a TopBar selected node if the current location is a top menu node', () => { + location.urlSubject.next('features'); + expect(currentNode).toEqual({ + url: 'features', + view: 'TopBar', + nodes: [ topBarNodes[0] ] + }); + }); + + it('should be undefined if no side navigation node matches the current location', () => { location.urlSubject.next('g'); - expect(currentNodes).toEqual([]); + expect(currentNode).toEqual({ + url: 'g', + view: '', + nodes: [] + }); }); - it('should ignore trailing slashes on URLs in the navmap', () => { + it('should ignore trailing slashes, hashes, and search params on URLs in the navmap', () => { + const cnode = { + url: 'c', + view: 'SideNav', + nodes: [ + sideNavNodes[0].children[0].children[0], + sideNavNodes[0].children[0], + sideNavNodes[0] + ] + }; + location.urlSubject.next('c'); - expect(currentNodes).toEqual([ - nodeTree[0].children[0].children[0], - nodeTree[0].children[0], - nodeTree[0] - ]); + expect(currentNode).toEqual(cnode, 'location: c'); + location.urlSubject.next('c/'); - expect(currentNodes).toEqual([ - nodeTree[0].children[0].children[0], - nodeTree[0].children[0], - nodeTree[0] - ]); + expect(currentNode).toEqual(cnode, 'location: c/'); + + location.urlSubject.next('c#foo'); + expect(currentNode).toEqual(cnode, 'location: c#foo'); + + location.urlSubject.next('c?foo=1'); + expect(currentNode).toEqual(cnode, 'location: c?foo=1'); + + location.urlSubject.next('c#foo?bar=1&baz=2'); + expect(currentNode).toEqual(cnode, 'location: c#foo?bar=1&baz=2'); }); }); diff --git a/aio/src/app/navigation/navigation.service.ts b/aio/src/app/navigation/navigation.service.ts index 6085586126..28d47b7dac 100644 --- a/aio/src/app/navigation/navigation.service.ts +++ b/aio/src/app/navigation/navigation.service.ts @@ -1,49 +1,31 @@ import { Injectable } from '@angular/core'; import { Http } from '@angular/http'; + import { Observable } from 'rxjs/Observable'; import { AsyncSubject } from 'rxjs/AsyncSubject'; import { combineLatest } from 'rxjs/observable/combineLatest'; -import 'rxjs/add/operator/publishReplay'; import 'rxjs/add/operator/publishLast'; +import 'rxjs/add/operator/publishReplay'; import { Logger } from 'app/shared/logger.service'; import { LocationService } from 'app/shared/location.service'; -import { NavigationNode } from './navigation-node'; -export { NavigationNode } from './navigation-node'; - - -export type NavigationResponse = {__versionInfo: VersionInfo } & { [name: string]: NavigationNode[]|VersionInfo }; - -export interface NavigationViews { - [name: string]: NavigationNode[]; -} - -export interface NavigationMap { - [url: string]: NavigationNode; -} - -export interface VersionInfo { - raw: string; - major: number; - minor: number; - patch: number; - prerelease: string[]; - build: string; - version: string; - codeName: string; - isSnapshot: boolean; - full: string; - branch: string; - commitSHA: string; -} +// Import and re-export the Navigation model types +import { CurrentNode, NavigationNode, NavigationResponse, NavigationViews, VersionInfo } from './navigation.model'; +export { CurrentNode, NavigationNode, NavigationResponse, NavigationViews, VersionInfo } from './navigation.model'; const navigationPath = 'content/navigation.json'; +const urlParser = document.createElement('a'); +function cleanUrl(url: string) { + // remove hash (#) and query params (?) + urlParser.href = '/' + url; + // strip leading and trailing slashes + return urlParser.pathname.replace(/^\/+|\/$/g, ''); +} @Injectable() export class NavigationService { - /** * An observable collection of NavigationNode trees, which can be used to render navigational menus */ @@ -55,16 +37,18 @@ export class NavigationService { versionInfo: Observable; /** - * An observable array of nodes that indicate which nodes in the `navigationViews` match the current URL location + * An observable of the current node with info about the + * node (if any) that matches the current URL location + * including its navigation view and its ancestor nodes in that view */ - selectedNodes: Observable; + currentNode: Observable; constructor(private http: Http, private location: LocationService, private logger: Logger) { const navigationInfo = this.fetchNavigationInfo(); // The version information is packaged inside the navigation response to save us an extra request. this.versionInfo = this.getVersionInfo(navigationInfo); this.navigationViews = this.getNavigationViews(navigationInfo); - this.selectedNodes = this.getSelectedNodes(this.navigationViews); + this.currentNode = this.getCurrentNode(this.navigationViews); } /** @@ -99,45 +83,48 @@ export class NavigationService { } /** - * Get an observable that will list the nodes that are currently selected + * Get an observable of the current node (the one that matches the current URL) * We use `publishReplay(1)` because otherwise subscribers will have to wait until the next * URL change before they receive an emission. * See above for discussion of using `connect`. */ - private getSelectedNodes(navigationViews: Observable) { - const selectedNodes = combineLatest( - navigationViews.map(this.computeUrlToNodesMap), + private getCurrentNode(navigationViews: Observable): Observable { + const currentNode = combineLatest( + navigationViews.map(this.computeUrlToNavNodesMap), this.location.currentUrl, (navMap, url) => { - // strip trailing slashes from the currentUrl - they are not relevant to matching against the navMap - url = url.replace(/\/$/, ''); - return navMap[url] || []; + let urlKey = cleanUrl(url); + urlKey = urlKey.startsWith('api/') ? 'api' : urlKey; + return navMap[urlKey] || { view: '', url, nodes: [] }; }) .publishReplay(1); - selectedNodes.connect(); - return selectedNodes; + currentNode.connect(); + return currentNode; } /** * Compute a mapping from URL to an array of nodes, where the first node in the array * is the one that matches the URL and the rest are the ancestors of that node. * - * @param navigation A collection of navigation nodes that are to be mapped + * @param navigation - A collection of navigation nodes that are to be mapped */ - private computeUrlToNodesMap(navigation: NavigationViews) { - const navMap: NavigationMap = {}; - Object.keys(navigation).forEach(key => navigation[key].forEach(node => walkNodes(node))); + private computeUrlToNavNodesMap(navigation: NavigationViews) { + const navMap = new Map(); + Object.keys(navigation) + .forEach(view => navigation[view].forEach(node => walkNodes(view, node))); return navMap; - function walkNodes(node: NavigationNode, ancestors: NavigationNode[] = []) { + function walkNodes(view: string, node: NavigationNode, ancestors: NavigationNode[] = []) { const nodes = [node, ...ancestors]; + const url = node.url; + // only map to this node if it has a url associated with it - if (node.url) { + if (url) { // Strip off trailing slashes from nodes in the navMap - they are not relevant to matching - navMap[node.url.replace(/\/$/, '')] = nodes; + navMap[url.replace(/\/$/, '')] = { url, view, nodes }; } if (node.children) { - node.children.forEach(child => walkNodes(child, nodes)); + node.children.forEach(child => walkNodes(view, child, nodes)); } } } diff --git a/aio/src/app/search/search-box/search-box.component.html b/aio/src/app/search/search-box/search-box.component.html deleted file mode 100644 index 280889fa2c..0000000000 --- a/aio/src/app/search/search-box/search-box.component.html +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/aio/src/app/search/search-box/search-box.component.spec.ts b/aio/src/app/search/search-box/search-box.component.spec.ts index 4702508748..1f58b0c05c 100644 --- a/aio/src/app/search/search-box/search-box.component.spec.ts +++ b/aio/src/app/search/search-box/search-box.component.spec.ts @@ -1,5 +1,4 @@ import { async, ComponentFixture, TestBed, inject } from '@angular/core/testing'; -import { NO_ERRORS_SCHEMA } from '@angular/core'; import { By } from '@angular/platform-browser'; import { SearchBoxComponent } from './search-box.component'; import { SearchService } from '../search.service'; @@ -17,8 +16,7 @@ describe('SearchBoxComponent', () => { providers: [ { provide: SearchService, useFactory: () => new MockSearchService() }, { provide: LocationService, useFactory: () => new MockLocationService('') } - ], - schemas: [NO_ERRORS_SCHEMA] + ] }) .compileComponents(); })); diff --git a/aio/src/app/search/search-box/search-box.component.ts b/aio/src/app/search/search-box/search-box.component.ts index be2f0fd3c9..aeb744b71e 100644 --- a/aio/src/app/search/search-box/search-box.component.ts +++ b/aio/src/app/search/search-box/search-box.component.ts @@ -16,7 +16,11 @@ import { LocationService } from 'app/shared/location.service'; */ @Component({ selector: 'aio-search-box', - templateUrl: './search-box.component.html', + template: `` }) export class SearchBoxComponent implements OnInit { diff --git a/aio/src/app/shared/location.service.spec.ts b/aio/src/app/shared/location.service.spec.ts index ab0bf26fc4..cd03ddd353 100644 --- a/aio/src/app/shared/location.service.spec.ts +++ b/aio/src/app/shared/location.service.spec.ts @@ -1,12 +1,9 @@ import { ReflectiveInjector } from '@angular/core'; import { Location, LocationStrategy, PlatformLocation } from '@angular/common'; import { MockLocationStrategy } from '@angular/common/testing'; -import { LocationService } from './location.service'; -class MockPlatformLocation { - pathname = 'a/b/c'; - replaceState = jasmine.createSpy('PlatformLocation.replaceState'); -} +import { GaService } from 'app/shared/ga.service'; +import { LocationService } from './location.service'; describe('LocationService', () => { @@ -16,6 +13,7 @@ describe('LocationService', () => { injector = ReflectiveInjector.resolveAndCreate([ LocationService, Location, + { provide: GaService, useClass: TestGaService }, { provide: LocationStrategy, useClass: MockLocationStrategy }, { provide: PlatformLocation, useClass: MockPlatformLocation } ]); @@ -341,4 +339,54 @@ describe('LocationService', () => { }); }); }); + + describe('google analytics - GaService#locationChanged', () => { + + let gaLocationChanged: jasmine.Spy; + let location: Location; + let service: LocationService; + + beforeEach(() => { + const gaService = injector.get(GaService); + gaLocationChanged = gaService.locationChanged; + location = injector.get(Location); + service = injector.get(LocationService); + }); + + it('should call locationChanged with initial URL', () => { + const initialUrl = location.path().replace(/^\/+/, ''); + + expect(gaLocationChanged.calls.count()).toBe(1, 'gaService.locationChanged'); + const args = gaLocationChanged.calls.first().args; + expect(args[0]).toBe(initialUrl); + }); + + it('should call locationChanged when `go` to a page', () => { + service.go('some-new-url'); + expect(gaLocationChanged.calls.count()).toBe(2, 'gaService.locationChanged'); + const args = gaLocationChanged.calls.argsFor(1); + expect(args[0]).toBe('some-new-url'); + }); + + it('should call locationChanged when window history changes', () => { + const locationStrategy: MockLocationStrategy = injector.get(LocationStrategy); + locationStrategy.simulatePopState('/next-url'); + + expect(gaLocationChanged.calls.count()).toBe(2, 'gaService.locationChanged'); + const args = gaLocationChanged.calls.argsFor(1); + expect(args[0]).toBe('next-url'); + }); + + }); + }); + +/// Test Helpers /// +class MockPlatformLocation { + pathname = 'a/b/c'; + replaceState = jasmine.createSpy('PlatformLocation.replaceState'); +} + +class TestGaService { + locationChanged = jasmine.createSpy('locationChanged'); +} diff --git a/aio/src/app/shared/location.service.ts b/aio/src/app/shared/location.service.ts index 4990a0e453..2d1c5fa62e 100644 --- a/aio/src/app/shared/location.service.ts +++ b/aio/src/app/shared/location.service.ts @@ -1,19 +1,28 @@ import { Injectable } from '@angular/core'; import { Location, PlatformLocation } from '@angular/common'; + import { Observable } from 'rxjs/Observable'; -import { ReplaySubject } from 'rxjs/ReplaySubject'; +import { Subject } from 'rxjs/Subject'; +import 'rxjs/add/operator/do'; +import 'rxjs/add/operator/publishReplay'; + +import { GaService } from 'app/shared/ga.service'; @Injectable() export class LocationService { private readonly urlParser = document.createElement('a'); - private urlSubject = new ReplaySubject(1); - currentUrl = this.urlSubject.asObservable(); + private urlSubject = new Subject(); + currentUrl = this.urlSubject + .do(url => this.gaService.locationChanged(url)) + .publishReplay(1); constructor( + private gaService: GaService, private location: Location, private platformLocation: PlatformLocation) { + this.currentUrl.connect(); const initialUrl = this.stripLeadingSlashes(location.path(true)); this.urlSubject.next(initialUrl); diff --git a/aio/src/styles/1-layouts/_sidenav.scss b/aio/src/styles/1-layouts/_sidenav.scss index 9edce8a90f..27be30275b 100644 --- a/aio/src/styles/1-layouts/_sidenav.scss +++ b/aio/src/styles/1-layouts/_sidenav.scss @@ -41,6 +41,14 @@ } /************************************/ +/** STEF: HELP WITH LOGO SPACING **/ +.nav-link.home { + margin: 0 20px 0 10px; +} +/** STEF: MENU AT TOP OF SIDE-NAV HELP! **/ +aio-nav-menu.top-menu .vertical-menu-item { + background-color: $lightgray; +} .mat-sidenav.sidenav { box-shadow: 6px 0 6px rgba(0,0,0,0.10); @@ -109,7 +117,6 @@ .heading-children.expanded { visibility: visible; opacity: 1; - // height: auto; max-height: 4000px; // Arbitrary max-height. Can increase if needed. Must have measurement to transition height. transition: visibility 500ms, opacity 500ms, max-height 500ms; -webkit-transition-timing-function: ease-in-out; @@ -125,6 +132,7 @@ transition-timing-function: ease-out; } +a.selected.level-1, .heading.selected.level-1, .heading-children.selected.level-1 { border-left: 3px $blue solid; @@ -134,7 +142,6 @@ font-family: $main-font; font-size: 14px; font-weight: 400; - text-transform: uppercase; padding-left: 10px; transition: background-color 0.2s; } @@ -168,4 +175,4 @@ @include bp(small) { max-width: 100%; } -} \ No newline at end of file +}