feat(aio): refactors AppComponent and its kids + sidenav hiding (#15570)

-hides sidenav when current doc is not in sidenav menu
-displays top menu on the side as nodes instead of mini top menu
This commit is contained in:
Ward Bell 2017-03-29 14:13:40 -07:00 committed by Victor Berchet
parent 9c77a7cdaf
commit 9f2acf54bc
24 changed files with 724 additions and 338 deletions

View File

@ -1,28 +1,86 @@
{ {
"TopBar": [ "TopBar": [
{ {
"url": "api",
"title": "API"
}, {
"url": "news",
"title": "News"
}, {
"url": "features", "url": "features",
"title": "Features" "title": "Features"
},
{
"url": "events",
"title": "Events"
} }
], ],
"SideNav": [ "SideNav": [
{ {
"url": "quickstart", "url": "overview",
"title": "Quickstart", "title": "Docs",
"tooltip": "A quick look at an Angular app." "hidden": true
}, },
{ {
"url": "cli-quickstart", "title": "Getting started",
"title": "CLI Quickstart", "tooltip": "A gentle introduction to Angular.",
"tooltip": "A quick look at an Angular app built with the Angular CLI." "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", "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", "title": "Core",
"tooltip": "Learn the core capabilities of Angular", "tooltip": "Learn the core capabilities of Angular",
@ -266,9 +236,21 @@
"url": "guide/router", "url": "guide/router",
"title": "Routing & navigation", "title": "Routing & navigation",
"tooltip": "Discover the basics of screen navigation with the Angular Router." "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", "title": "Additional Techniques",
"tooltip": "Other", "tooltip": "Other",
@ -381,9 +363,27 @@
"title": "Resources", "title": "Resources",
"children": [ "children": [
{ {
"url": "about", "url": "guide/change-log",
"title": "About", "title": "Change Log",
"tooltip": "The people behind Angular." "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."
} }
] ]
}, },

View File

@ -21,9 +21,13 @@ describe('site App', function() {
// navigate to a different page // navigate to a different page
page.getLink('features').click(); 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 // Show the menu; the tutorial section should be fully open from previous visit
page.getLink('tutorial').click(); 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); expect(page.getDocViewerText()).toMatch(/Tutorial: Tour of Heroes/i);
}); });

View File

@ -4,6 +4,7 @@ const githubRegex = /https:\/\/github.com\/angular\/angular\//;
export class SitePage { export class SitePage {
links = element.all(by.css('md-toolbar a')); links = element.all(by.css('md-toolbar a'));
docsMenuLink = element(by.cssContainingText('aio-top-menu a', 'Docs'));
docViewer = element(by.css('aio-doc-viewer')); docViewer = element(by.css('aio-doc-viewer'));
codeExample = element.all(by.css('aio-doc-viewer pre > code')); codeExample = element.all(by.css('aio-doc-viewer pre > code'));
ghLink = this.docViewer ghLink = this.docViewer
@ -11,7 +12,7 @@ export class SitePage {
.filter((a: ElementFinder) => a.getAttribute('href').then(href => githubRegex.test(href))) .filter((a: ElementFinder) => a.getAttribute('href').then(href => githubRegex.test(href)))
.first(); .first();
gaReady: promise.Promise<any>; gaReady: promise.Promise<any>;
getNavHeading(pattern: RegExp) { getNavItem(pattern: RegExp) {
return element.all(by.css('aio-nav-item a')) return element.all(by.css('aio-nav-item a'))
.filter(element => element.getText().then(text => pattern.test(text))) .filter(element => element.getText().then(text => pattern.test(text)))
.first(); .first();

View File

@ -1,30 +1,27 @@
<md-toolbar color="primary" class="app-toolbar"> <md-toolbar color="primary" class="app-toolbar">
<button *ngIf="isHamburgerVisible" class="hamburger" md-button (click)="sidenav.toggle()"><md-icon>menu</md-icon></button> <button class="hamburger" md-button
<a class="nav-link home" href="/"><img [src]="homeImageUrl" title="Home" alt="Home"></a> (click)="sidenav.toggle()" title="Docs menu">
<aio-top-menu *ngIf="isSideBySide" [nodes]="(navigationViews | async)?.TopBar"></aio-top-menu> <md-icon>menu</md-icon>
</button>
<a class="nav-link home" href="/"><img src="{{ homeImageUrl }}" title="Home" alt="Home"></a>
<aio-top-menu *ngIf="isSideBySide" [nodes]="topMenuNodes"></aio-top-menu>
<aio-search-box class="search-container" #searchBox></aio-search-box> <aio-search-box class="search-container" #searchBox></aio-search-box>
<span class="fill-remaining-space"></span> <span class="fill-remaining-space"></span>
</md-toolbar> </md-toolbar>
<md-sidenav-container class="sidenav-container"> <md-sidenav-container class="sidenav-container">
<md-sidenav #sidenav class="sidenav" [opened]="isSideBySide" [mode] = "isSideBySide ? 'side' : 'over'"> <md-sidenav #sidenav class="sidenav" [opened]="isOpened" [mode]="mode">
<aio-nav-menu *ngIf="!isSideBySide" [nodes]="(navigationViews | async)?.TopBar" [selectedNodes]="selectedNodes | async"></aio-nav-menu> <aio-nav-menu *ngIf="!isSideBySide" class="top-menu" [nodes]="topMenuNodes" [currentNode]="currentNode"></aio-nav-menu>
<aio-nav-menu [nodes]="(navigationViews | async)?.SideNav" [selectedNodes]="selectedNodes | async"></aio-nav-menu> <aio-nav-menu [nodes]="sideNavNodes" [currentNode]="currentNode" ></aio-nav-menu>
</md-sidenav> </md-sidenav>
<section class="sidenav-content"> <section class="sidenav-content">
<aio-doc-viewer [doc]="currentDocument | async" (docRendered)="onDocRendered($event)"></aio-doc-viewer> <aio-doc-viewer [doc]="currentDocument" (docRendered)="onDocRendered($event)"></aio-doc-viewer>
</section> </section>
</md-sidenav-container> </md-sidenav-container>
<aio-search-results #searchResults></aio-search-results> <aio-search-results #searchResults></aio-search-results>
<footer> <aio-footer [versionInfo]="versionInfo" ></aio-footer>
<div class="footer">
<p>Powered by Google ©2010-2017. Code licensed under an <a href="/license">MIT-style License</a>.</p>
<p>Documentation licensed under <a href="http://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>.
</p>
<p class="version-info">Version Info | {{ (versionInfo | async)?.full }}</p>
</div>
</footer>

View File

@ -1,11 +1,17 @@
import { async, inject, ComponentFixture, TestBed } from '@angular/core/testing'; import { async, inject, ComponentFixture, TestBed } from '@angular/core/testing';
import { APP_BASE_HREF } from '@angular/common'; import { APP_BASE_HREF } from '@angular/common';
import { Http } from '@angular/http';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { of } from 'rxjs/observable/of';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { GaService } from 'app/shared/ga.service'; import { GaService } from 'app/shared/ga.service';
import { SearchResultsComponent } from 'app/search/search-results/search-results.component'; import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
import { SearchBoxComponent } from 'app/search/search-box/search-box.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 { AutoScrollService } from 'app/shared/auto-scroll.service';
import { LocationService } from 'app/shared/location.service'; import { LocationService } from 'app/shared/location.service';
import { MockLocationService } from 'testing/location.service'; import { MockLocationService } from 'testing/location.service';
@ -23,8 +29,10 @@ describe('AppComponent', () => {
providers: [ providers: [
{ provide: APP_BASE_HREF, useValue: '/' }, { provide: APP_BASE_HREF, useValue: '/' },
{ provide: GaService, useClass: TestGaService }, { provide: GaService, useClass: TestGaService },
{ provide: Http, useClass: TestHttp },
{ provide: LocationService, useFactory: () => new MockLocationService(initialUrl) }, { provide: LocationService, useFactory: () => new MockLocationService(initialUrl) },
{ provide: Logger, useClass: MockLogger } { provide: Logger, useClass: MockLogger },
{ provide: SearchService, useClass: MockSearchService }
] ]
}); });
TestBed.compileComponents(); TestBed.compileComponents();
@ -40,19 +48,8 @@ describe('AppComponent', () => {
expect(component).toBeDefined(); expect(component).toBeDefined();
}); });
describe('google analytics', () => { describe('is Hamburger Visible', () => {
it('should call gaService.locationChanged with initial URL', () => { console.log('PENDING: AppComponent');
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('onResize', () => { 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', () => { describe('currentDocument', () => {
console.log('PENDING: AppComponent 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', () => { describe('click intercepting', () => {
it('should intercept clicks on anchors and call `location.handleAnchorClick()`', it('should intercept clicks on anchors and call `location.handleAnchorClick()`',
inject([LocationService], (location: LocationService) => { inject([LocationService], (location: LocationService) => {
@ -120,8 +158,95 @@ describe('AppComponent', () => {
expect(searchResultsComponent.hideResults).not.toHaveBeenCalled(); 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 { class TestGaService {
locationChanged = jasmine.createSpy('locationChanged'); 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": "<h1>API Doc</h1>"
};
pipesDoc = {
"title": "Pipes",
"contents": "<h1>Pipes Doc</h1>"
};
testDoc = {
"title": "Test",
"contents": "<h1>Test Doc</h1>"
};
// 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 });
}
}

View File

@ -1,33 +1,45 @@
import { Component, ElementRef, HostListener, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'; import { Component, ElementRef, HostListener, OnInit,
import { Observable } from 'rxjs/Observable'; QueryList, ViewChild, ViewChildren } from '@angular/core';
import { MdSidenav } from '@angular/material/sidenav';
import { GaService } from 'app/shared/ga.service'; import { AutoScrollService } from 'app/shared/auto-scroll.service';
import { LocationService } from 'app/shared/location.service'; import { CurrentNode, NavigationService, NavigationViews, NavigationNode, VersionInfo } from 'app/navigation/navigation.service';
import { DocumentService, DocumentContents } from 'app/documents/document.service'; import { DocumentService, DocumentContents } from 'app/documents/document.service';
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component'; 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 { SearchResultsComponent } from 'app/search/search-results/search-results.component';
import { AutoScrollService } from 'app/shared/auto-scroll.service';
const sideNavView = 'SideNav';
@Component({ @Component({
selector: 'aio-shell', selector: 'aio-shell',
templateUrl: './app.component.html', templateUrl: './app.component.html',
}) })
export class AppComponent implements OnInit { 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; isSideBySide = false;
private isSideNavDoc = false;
private previousNavView: string;
private readonly sideBySideWidth = 600;
sideNavNodes: NavigationNode[];
topMenuNodes: NavigationNode[];
versionInfo: VersionInfo;
currentDocument: Observable<DocumentContents>; get homeImageUrl() {
navigationViews: Observable<NavigationViews>; return this.isSideBySide ?
selectedNodes: Observable<NavigationNode[]>; 'assets/images/logos/standard/logo-nav.png' :
versionInfo: Observable<VersionInfo>; '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 }) @ViewChildren('searchBox, searchResults', { read: ElementRef })
searchElements: QueryList<ElementRef>; searchElements: QueryList<ElementRef>;
@ -35,36 +47,54 @@ export class AppComponent implements OnInit {
@ViewChild(SearchResultsComponent) @ViewChild(SearchResultsComponent)
searchResults: SearchResultsComponent; searchResults: SearchResultsComponent;
// We need the doc-viewer element for scrolling the contents @ViewChild(MdSidenav)
@ViewChild(DocViewerComponent, { read: ElementRef }) sidenav: MdSidenav;
docViewer: ElementRef;
constructor(documentService: DocumentService, constructor(
gaService: GaService, private autoScrollService: AutoScrollService,
navigationService: NavigationService, private documentService: DocumentService,
private autoScroll: AutoScrollService, private locationService: LocationService,
private locationService: LocationService) { private navigationService: NavigationService
this.currentDocument = documentService.currentDocument; ) { }
locationService.currentUrl.subscribe(url => gaService.locationChanged(url));
this.navigationViews = navigationService.navigationViews;
this.selectedNodes = navigationService.selectedNodes;
this.versionInfo = navigationService.versionInfo;
}
ngOnInit() { 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.documentService.currentDocument.subscribe(doc => this.currentDocument = doc);
// This subscription is needed when navigating between anchors within a document
// and the document itself has not changed // scroll even if only the hash fragment changed
this.locationService.currentUrl.subscribe(url => this.autoScroll.scroll(this.docViewer.nativeElement.offsetParent)); 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) { 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` // 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 // 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']) @HostListener('window:resize', ['$event.target.innerWidth'])
@ -84,9 +114,16 @@ export class AppComponent implements OnInit {
} }
// Deal with anchor clicks // Deal with anchor clicks
if (eventTarget instanceof HTMLImageElement) {
eventTarget = eventTarget.parentElement; // assume image wrapped in Anchor
}
if (eventTarget instanceof HTMLAnchorElement) { if (eventTarget instanceof HTMLAnchorElement) {
return this.locationService.handleAnchorClick(eventTarget, button, ctrlKey, metaKey); return this.locationService.handleAnchorClick(eventTarget, button, ctrlKey, metaKey);
} }
return true; return true;
} }
sideNavToggle(value?: boolean) {
this.sidenav.toggle(value);
}
} }

View File

@ -28,6 +28,7 @@ import { NavigationService } from 'app/navigation/navigation.service';
import { DocumentService } from 'app/documents/document.service'; import { DocumentService } from 'app/documents/document.service';
import { SearchService } from 'app/search/search.service'; import { SearchService } from 'app/search/search.service';
import { TopMenuComponent } from 'app/layout/top-menu/top-menu.component'; 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 { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
import { NavItemComponent } from 'app/layout/nav-item/nav-item.component'; import { NavItemComponent } from 'app/layout/nav-item/nav-item.component';
import { SearchResultsComponent } from './search/search-results/search-results.component'; import { SearchResultsComponent } from './search/search-results/search-results.component';
@ -50,6 +51,7 @@ import { AutoScrollService } from 'app/shared/auto-scroll.service';
declarations: [ declarations: [
AppComponent, AppComponent,
DocViewerComponent, DocViewerComponent,
FooterComponent,
TopMenuComponent, TopMenuComponent,
NavMenuComponent, NavMenuComponent,
NavItemComponent, NavItemComponent,

View File

@ -3,6 +3,9 @@ import { Http, Response } from '@angular/http';
import { Observable } from 'rxjs/Observable'; import { Observable } from 'rxjs/Observable';
import { AsyncSubject } from 'rxjs/AsyncSubject'; 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 'rxjs/add/operator/switchMap';
import { DocumentContents } from './document-contents'; 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 // using `getDocument` means that we can fetch the 404 doc contents from the server and cache it
return this.getDocument(FILE_NOT_FOUND_URL); return this.getDocument(FILE_NOT_FOUND_URL);
} else { } else {
return Observable.of({ title: 'Not Found', contents: 'Document not found' }); return of({ title: 'Not Found', contents: 'Document not found' });
} }
} else { } else {
this.logger.error('Error fetching document', error); this.logger.error('Error fetching document', error);

View File

@ -0,0 +1,19 @@
import { Component, Input } from '@angular/core';
import { VersionInfo } from 'app/navigation/navigation.service';
@Component({
selector: 'aio-footer',
template: `
<footer>
<div class="footer">
<p>Powered by Google ©2010-2017. Code licensed under an <a href="/license">MIT-style License</a>.</p>
<p>Documentation licensed under <a href="http://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>.
</p>
<p class="version-info">Version Info | {{ versionInfo?.full }}</p>
</div>
</footer>`
})
export class FooterComponent {
@Input() versionInfo: VersionInfo;
}

View File

@ -1,6 +1,11 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 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 { NavMenuComponent } from './nav-menu.component';
import { CurrentNode, NavigationService, NavigationViews, NavigationNode } from 'app/navigation/navigation.service';
describe('NavMenuComponent', () => { describe('NavMenuComponent', () => {
let component: NavMenuComponent; let component: NavMenuComponent;
@ -9,7 +14,10 @@ describe('NavMenuComponent', () => {
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ NavMenuComponent ], declarations: [ NavMenuComponent ],
schemas: [NO_ERRORS_SCHEMA] providers: [
{provide: NavigationService, useClass: TestNavigationService }
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
.compileComponents(); .compileComponents();
})); }));
@ -24,3 +32,22 @@ describe('NavMenuComponent', () => {
expect(component).toBeTruthy(); 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<NavigationViews>(this.navJson);
currentNode = new BehaviorSubject<CurrentNode>(undefined);
}

View File

@ -1,15 +1,14 @@
import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; import { Component, Input } from '@angular/core';
import { NavigationNode } from 'app/navigation/navigation.service'; import { CurrentNode, NavigationNode } from 'app/navigation/navigation.service';
@Component({ @Component({
selector: 'aio-nav-menu', selector: 'aio-nav-menu',
template: `<aio-nav-item *ngFor="let node of nodes" [selectedNodes]="selectedNodes" [node]="node"></aio-nav-item>` template: `
<aio-nav-item *ngFor="let node of filteredNodes" [node]="node" [selectedNodes]="currentNode.nodes">
</aio-nav-item>`
}) })
export class NavMenuComponent { export class NavMenuComponent {
@Input() currentNode: CurrentNode;
@Input() @Input() nodes: NavigationNode[] ;
selectedNodes: NavigationNode[]; get filteredNodes() { return this.nodes ? this.nodes.filter(n => !n.hidden) : []; }
@Input()
nodes: NavigationNode[];
} }

View File

@ -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;
}
}

View File

@ -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<TopMenuComponent>;
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<NavigationViews>(this.navJson);
}

View File

@ -5,42 +5,12 @@ import { NavigationNode } from 'app/navigation/navigation.service';
selector: 'aio-top-menu', selector: 'aio-top-menu',
template: ` template: `
<ul role="navigation"> <ul role="navigation">
<li *ngFor="let node of nodes"><a class="nav-link" [href]="node.path || node.url">{{ node.title }}</a></li> <li><a class="nav-link" href="overview" title="Angular Documentation">Docs</a></li>
<li *ngFor="let node of nodes"><a class="nav-link" [href]="node.path || node.url" [title]="node.title">{{ node.title }}</a></li>
</ul>`, </ul>`,
styles: [` styleUrls: ['top-menu.component.scss']
.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;
}
}`
]
}) })
export class TopMenuComponent { export class TopMenuComponent {
@Input() @Input() nodes: NavigationNode[];
nodes: NavigationNode[];
} }

View File

@ -1,7 +0,0 @@
export interface NavigationNode {
url?: string;
title?: string;
tooltip?: string;
target?: string;
children?: NavigationNode[];
}

View File

@ -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;
}

View File

@ -1,7 +1,7 @@
import { ReflectiveInjector } from '@angular/core'; import { ReflectiveInjector } from '@angular/core';
import { Http, ConnectionBackend, RequestOptions, BaseRequestOptions, Response, ResponseOptions } from '@angular/http'; import { Http, ConnectionBackend, RequestOptions, BaseRequestOptions, Response, ResponseOptions } from '@angular/http';
import { MockBackend } from '@angular/http/testing'; 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 { LocationService } from 'app/shared/location.service';
import { MockLocationService } from 'testing/location.service'; import { MockLocationService } from 'testing/location.service';
import { Logger } from 'app/shared/logger.service'; import { Logger } from 'app/shared/logger.service';
@ -76,68 +76,112 @@ describe('NavigationService', () => {
it('should do WHAT(?) if the request fails'); it('should do WHAT(?) if the request fails');
}); });
describe('selectedNodes', () => { describe('currentNode', () => {
let service: NavigationService, location: MockLocationService; let service: NavigationService, location: MockLocationService;
let currentNodes: NavigationNode[]; let currentNode: CurrentNode;
const nodeTree: NavigationNode[] = [
{ title: 'a', children: [ const topBarNodes: NavigationNode[] = [{ url: 'features', title: 'Features' }];
{ url: 'b', title: 'b', children: [ const sideNavNodes: NavigationNode[] = [
{ url: 'c/', title: 'c' }, { title: 'a', children: [
{ url: 'd', title: 'd' } { 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(() => { beforeEach(() => {
location = injector.get(LocationService); location = injector.get(LocationService);
service = injector.get(NavigationService); service = injector.get(NavigationService);
service.selectedNodes.subscribe(nodes => currentNodes = nodes); service.currentNode.subscribe(selected => currentNode = selected);
const backend = injector.get(ConnectionBackend); 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'); location.urlSubject.next('b');
expect(currentNodes).toEqual([ expect(currentNode).toEqual({
nodeTree[0].children[0], url: 'b',
nodeTree[0] view: 'SideNav',
]); nodes: [
sideNavNodes[0].children[0],
sideNavNodes[0]
]
});
location.urlSubject.next('d'); location.urlSubject.next('d');
expect(currentNodes).toEqual([ expect(currentNode).toEqual({
nodeTree[0].children[0].children[1], url: 'd',
nodeTree[0].children[0], view: 'SideNav',
nodeTree[0] nodes: [
]); sideNavNodes[0].children[0].children[1],
sideNavNodes[0].children[0],
sideNavNodes[0]
]
});
location.urlSubject.next('f'); location.urlSubject.next('f');
expect(currentNodes).toEqual([ expect(currentNode).toEqual({
nodeTree[1] 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'); 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'); location.urlSubject.next('c');
expect(currentNodes).toEqual([ expect(currentNode).toEqual(cnode, 'location: c');
nodeTree[0].children[0].children[0],
nodeTree[0].children[0],
nodeTree[0]
]);
location.urlSubject.next('c/'); location.urlSubject.next('c/');
expect(currentNodes).toEqual([ expect(currentNode).toEqual(cnode, 'location: c/');
nodeTree[0].children[0].children[0],
nodeTree[0].children[0], location.urlSubject.next('c#foo');
nodeTree[0] 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');
}); });
}); });

View File

@ -1,49 +1,31 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Http } from '@angular/http'; import { Http } from '@angular/http';
import { Observable } from 'rxjs/Observable'; import { Observable } from 'rxjs/Observable';
import { AsyncSubject } from 'rxjs/AsyncSubject'; import { AsyncSubject } from 'rxjs/AsyncSubject';
import { combineLatest } from 'rxjs/observable/combineLatest'; import { combineLatest } from 'rxjs/observable/combineLatest';
import 'rxjs/add/operator/publishReplay';
import 'rxjs/add/operator/publishLast'; import 'rxjs/add/operator/publishLast';
import 'rxjs/add/operator/publishReplay';
import { Logger } from 'app/shared/logger.service'; import { Logger } from 'app/shared/logger.service';
import { LocationService } from 'app/shared/location.service'; import { LocationService } from 'app/shared/location.service';
import { NavigationNode } from './navigation-node'; // Import and re-export the Navigation model types
export { NavigationNode } from './navigation-node'; import { CurrentNode, NavigationNode, NavigationResponse, NavigationViews, VersionInfo } from './navigation.model';
export { CurrentNode, NavigationNode, NavigationResponse, NavigationViews, VersionInfo } from './navigation.model';
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;
}
const navigationPath = 'content/navigation.json'; 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() @Injectable()
export class NavigationService { export class NavigationService {
/** /**
* An observable collection of NavigationNode trees, which can be used to render navigational menus * An observable collection of NavigationNode trees, which can be used to render navigational menus
*/ */
@ -55,16 +37,18 @@ export class NavigationService {
versionInfo: Observable<VersionInfo>; versionInfo: Observable<VersionInfo>;
/** /**
* 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<NavigationNode[]>; currentNode: Observable<CurrentNode>;
constructor(private http: Http, private location: LocationService, private logger: Logger) { constructor(private http: Http, private location: LocationService, private logger: Logger) {
const navigationInfo = this.fetchNavigationInfo(); const navigationInfo = this.fetchNavigationInfo();
// The version information is packaged inside the navigation response to save us an extra request. // The version information is packaged inside the navigation response to save us an extra request.
this.versionInfo = this.getVersionInfo(navigationInfo); this.versionInfo = this.getVersionInfo(navigationInfo);
this.navigationViews = this.getNavigationViews(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 * We use `publishReplay(1)` because otherwise subscribers will have to wait until the next
* URL change before they receive an emission. * URL change before they receive an emission.
* See above for discussion of using `connect`. * See above for discussion of using `connect`.
*/ */
private getSelectedNodes(navigationViews: Observable<NavigationViews>) { private getCurrentNode(navigationViews: Observable<NavigationViews>): Observable<CurrentNode> {
const selectedNodes = combineLatest( const currentNode = combineLatest(
navigationViews.map(this.computeUrlToNodesMap), navigationViews.map(this.computeUrlToNavNodesMap),
this.location.currentUrl, this.location.currentUrl,
(navMap, url) => { (navMap, url) => {
// strip trailing slashes from the currentUrl - they are not relevant to matching against the navMap let urlKey = cleanUrl(url);
url = url.replace(/\/$/, ''); urlKey = urlKey.startsWith('api/') ? 'api' : urlKey;
return navMap[url] || []; return navMap[urlKey] || { view: '', url, nodes: [] };
}) })
.publishReplay(1); .publishReplay(1);
selectedNodes.connect(); currentNode.connect();
return selectedNodes; return currentNode;
} }
/** /**
* Compute a mapping from URL to an array of nodes, where the first node in the array * 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. * 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) { private computeUrlToNavNodesMap(navigation: NavigationViews) {
const navMap: NavigationMap = {}; const navMap = new Map<string, CurrentNode>();
Object.keys(navigation).forEach(key => navigation[key].forEach(node => walkNodes(node))); Object.keys(navigation)
.forEach(view => navigation[view].forEach(node => walkNodes(view, node)));
return navMap; return navMap;
function walkNodes(node: NavigationNode, ancestors: NavigationNode[] = []) { function walkNodes(view: string, node: NavigationNode, ancestors: NavigationNode[] = []) {
const nodes = [node, ...ancestors]; const nodes = [node, ...ancestors];
const url = node.url;
// only map to this node if it has a url associated with it // 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 // 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) { if (node.children) {
node.children.forEach(child => walkNodes(child, nodes)); node.children.forEach(child => walkNodes(view, child, nodes));
} }
} }
} }

View File

@ -1,5 +0,0 @@
<input #searchBox
placeholder="Search"
(keyup)="onSearch($event.target.value, $event.which)"
(focus)="onSearch($event.target.value)"
(click)="onSearch($event.target.value)">

View File

@ -1,5 +1,4 @@
import { async, ComponentFixture, TestBed, inject } from '@angular/core/testing'; import { async, ComponentFixture, TestBed, inject } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import { SearchBoxComponent } from './search-box.component'; import { SearchBoxComponent } from './search-box.component';
import { SearchService } from '../search.service'; import { SearchService } from '../search.service';
@ -17,8 +16,7 @@ describe('SearchBoxComponent', () => {
providers: [ providers: [
{ provide: SearchService, useFactory: () => new MockSearchService() }, { provide: SearchService, useFactory: () => new MockSearchService() },
{ provide: LocationService, useFactory: () => new MockLocationService('') } { provide: LocationService, useFactory: () => new MockLocationService('') }
], ]
schemas: [NO_ERRORS_SCHEMA]
}) })
.compileComponents(); .compileComponents();
})); }));

View File

@ -16,7 +16,11 @@ import { LocationService } from 'app/shared/location.service';
*/ */
@Component({ @Component({
selector: 'aio-search-box', selector: 'aio-search-box',
templateUrl: './search-box.component.html', template: `<input #searchBox
placeholder="Search"
(keyup)="onSearch($event.target.value, $event.which)"
(focus)="onSearch($event.target.value)"
(click)="onSearch($event.target.value)">`
}) })
export class SearchBoxComponent implements OnInit { export class SearchBoxComponent implements OnInit {

View File

@ -1,12 +1,9 @@
import { ReflectiveInjector } from '@angular/core'; import { ReflectiveInjector } from '@angular/core';
import { Location, LocationStrategy, PlatformLocation } from '@angular/common'; import { Location, LocationStrategy, PlatformLocation } from '@angular/common';
import { MockLocationStrategy } from '@angular/common/testing'; import { MockLocationStrategy } from '@angular/common/testing';
import { LocationService } from './location.service';
class MockPlatformLocation { import { GaService } from 'app/shared/ga.service';
pathname = 'a/b/c'; import { LocationService } from './location.service';
replaceState = jasmine.createSpy('PlatformLocation.replaceState');
}
describe('LocationService', () => { describe('LocationService', () => {
@ -16,6 +13,7 @@ describe('LocationService', () => {
injector = ReflectiveInjector.resolveAndCreate([ injector = ReflectiveInjector.resolveAndCreate([
LocationService, LocationService,
Location, Location,
{ provide: GaService, useClass: TestGaService },
{ provide: LocationStrategy, useClass: MockLocationStrategy }, { provide: LocationStrategy, useClass: MockLocationStrategy },
{ provide: PlatformLocation, useClass: MockPlatformLocation } { 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');
}

View File

@ -1,19 +1,28 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Location, PlatformLocation } from '@angular/common'; import { Location, PlatformLocation } from '@angular/common';
import { Observable } from 'rxjs/Observable'; 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() @Injectable()
export class LocationService { export class LocationService {
private readonly urlParser = document.createElement('a'); private readonly urlParser = document.createElement('a');
private urlSubject = new ReplaySubject<string>(1); private urlSubject = new Subject<string>();
currentUrl = this.urlSubject.asObservable(); currentUrl = this.urlSubject
.do(url => this.gaService.locationChanged(url))
.publishReplay(1);
constructor( constructor(
private gaService: GaService,
private location: Location, private location: Location,
private platformLocation: PlatformLocation) { private platformLocation: PlatformLocation) {
this.currentUrl.connect();
const initialUrl = this.stripLeadingSlashes(location.path(true)); const initialUrl = this.stripLeadingSlashes(location.path(true));
this.urlSubject.next(initialUrl); this.urlSubject.next(initialUrl);

View File

@ -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 { .mat-sidenav.sidenav {
box-shadow: 6px 0 6px rgba(0,0,0,0.10); box-shadow: 6px 0 6px rgba(0,0,0,0.10);
@ -109,7 +117,6 @@
.heading-children.expanded { .heading-children.expanded {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
// height: auto;
max-height: 4000px; // Arbitrary max-height. Can increase if needed. Must have measurement to transition height. max-height: 4000px; // Arbitrary max-height. Can increase if needed. Must have measurement to transition height.
transition: visibility 500ms, opacity 500ms, max-height 500ms; transition: visibility 500ms, opacity 500ms, max-height 500ms;
-webkit-transition-timing-function: ease-in-out; -webkit-transition-timing-function: ease-in-out;
@ -125,6 +132,7 @@
transition-timing-function: ease-out; transition-timing-function: ease-out;
} }
a.selected.level-1,
.heading.selected.level-1, .heading.selected.level-1,
.heading-children.selected.level-1 { .heading-children.selected.level-1 {
border-left: 3px $blue solid; border-left: 3px $blue solid;
@ -134,7 +142,6 @@
font-family: $main-font; font-family: $main-font;
font-size: 14px; font-size: 14px;
font-weight: 400; font-weight: 400;
text-transform: uppercase;
padding-left: 10px; padding-left: 10px;
transition: background-color 0.2s; transition: background-color 0.2s;
} }