feat(aio): revise Docs page; docs version selector in sidenav

This commit is contained in:
Ward Bell
2017-04-25 14:48:01 -07:00
committed by Pete Bacon Darwin
parent de25cfc0cb
commit 4be1966a21
16 changed files with 245 additions and 195 deletions

View File

@ -15,6 +15,12 @@
<md-sidenav [ngClass]="{'collapsed': !isSideBySide }" #sidenav class="sidenav" [opened]="isOpened" [mode]="mode">
<aio-nav-menu *ngIf="!isSideBySide" class="top-menu" [nodes]="topMenuNodes" [currentNode]="currentNode"></aio-nav-menu>
<aio-nav-menu [nodes]="sideNavNodes" [currentNode]="currentNode" ></aio-nav-menu>
<div class="doc-version" title="Angular docs version {{currentDocVersion?.title}}">
<select (change)="onDocVersionChange($event.target.selectedIndex)">
<option *ngFor="let version of docVersions" [value]="version.title">{{version.title}}</option>
</select>
</div>
</md-sidenav>
<section class="sidenav-content" [id]="pageId" role="content">

View File

@ -15,12 +15,13 @@ import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { GaService } from 'app/shared/ga.service';
import { LocationService } from 'app/shared/location.service';
import { Logger } from 'app/shared/logger.service';
import { MockLogger } from 'testing/logger.service';
import { MockLocationService } from 'testing/location.service';
import { MockLogger } from 'testing/logger.service';
import { MockSearchService } from 'testing/search.service';
import { MockSwUpdateNotificationsService } from 'testing/sw-update-notifications.service';
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
import { NavigationNode } from 'app/navigation/navigation.service';
import { SearchBoxComponent } from 'app/search/search-box/search-box.component';
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
import { SearchService } from 'app/search/search.service';
import { SwUpdateNotificationsService } from 'app/sw-updates/sw-update-notifications.service';
@ -194,6 +195,31 @@ describe('AppComponent', () => {
});
});
describe('SideNav version selector', () => {
beforeEach(() => {
component.onResize(1033); // side-by-side
});
it('should pick first (current) version by default', () => {
const versionSelector = sidenav.querySelector('select');
expect(versionSelector.value).toEqual(TestHttp.docVersions[0].title);
expect(versionSelector.selectedIndex).toEqual(0);
});
// Older docs versions have an href
it('should navigate when change to a version with an href', () => {
component.onDocVersionChange(1);
expect(locationService.go).toHaveBeenCalledWith(TestHttp.docVersions[1].url);
});
// The current docs version should not have an href
// This may change when we perfect our docs versioning approach
it('should not navigate when change to a version without an href', () => {
component.onDocVersionChange(0);
expect(locationService.go).not.toHaveBeenCalled();
});
});
describe('pageId', () => {
it('should set the id of the doc viewer container based on the current doc', () => {
@ -437,6 +463,11 @@ class TestSearchService {
class TestHttp {
static versionFull = '4.0.0-local+sha.73808dd';
static docVersions: NavigationNode[] = [
{ title: 'v4.0.0' },
{ title: 'v2', url: 'https://v2.angular.io' }
];
// tslint:disable:quotemark
navJson = {
"TopBar": [
@ -472,6 +503,8 @@ class TestHttp {
"tooltip": "Details of the Angular classes and values."
}
],
"docVersions": TestHttp.docVersions,
"__versionInfo": {
"raw": "4.0.0-rc.6",
"major": 4,

View File

@ -33,6 +33,9 @@ export class AppComponent implements OnInit {
private sideBySideWidth = 1032;
sideNavNodes: NavigationNode[];
topMenuNodes: NavigationNode[];
currentDocVersion: NavigationNode;
docVersions: NavigationNode[];
versionInfo: VersionInfo;
get homeImageUrl() {
@ -95,9 +98,12 @@ export class AppComponent implements OnInit {
});
this.navigationService.navigationViews.subscribe(views => {
this.docVersions = views['docVersions'] || [];
this.footerNodes = views['Footer'] || [];
this.sideNavNodes = views['SideNav'] || [];
this.topMenuNodes = views['TopBar'] || [];
this.currentDocVersion = this.docVersions[0];
});
this.navigationService.versionInfo.subscribe( vi => this.versionInfo = vi );
@ -116,6 +122,13 @@ export class AppComponent implements OnInit {
this.isStarting = false;
}
onDocVersionChange(versionIndex: number) {
const version = this.docVersions[versionIndex];
if (version.url) {
this.locationService.go(version.url);
}
}
@HostListener('window:resize', ['$event.target.innerWidth'])
onResize(width) {
this.isSideBySide = width > this.sideBySideWidth;

View File

@ -9,6 +9,8 @@ import { Logger } from 'app/shared/logger.service';
describe('NavigationService', () => {
let injector: ReflectiveInjector;
let backend: MockBackend;
let navService: NavigationService;
function createResponse(body: any) {
return new Response(new ResponseOptions({ body: JSON.stringify(body) }));
@ -25,19 +27,16 @@ describe('NavigationService', () => {
]);
});
beforeEach(() => {
backend = injector.get(ConnectionBackend);
navService = injector.get(NavigationService);
});
it('should be creatable', () => {
const navService: NavigationService = injector.get(NavigationService);
expect(navService).toBeTruthy();
});
describe('navigationViews', () => {
let backend: MockBackend;
let navService: NavigationService;
beforeEach(() => {
backend = injector.get(ConnectionBackend);
navService = injector.get(NavigationService);
});
it('should make a single connection to the server', () => {
expect(backend.connectionsArray.length).toEqual(1);
@ -78,14 +77,12 @@ describe('NavigationService', () => {
expect(views3).toBe(views1);
});
it('should do WHAT(?) if the request fails');
});
describe('currentNode', () => {
let currentNode: CurrentNode;
let locationService: MockLocationService;
let navService: NavigationService;
const topBarNodes: NavigationNode[] = [{ url: 'features', title: 'Features' }];
const sideNavNodes: NavigationNode[] = [
@ -105,14 +102,9 @@ describe('NavigationService', () => {
__versionInfo: {}
};
beforeEach(() => {
locationService = injector.get(LocationService);
navService = injector.get(NavigationService);
navService.currentNode.subscribe(selected => currentNode = selected);
const backend = injector.get(ConnectionBackend);
backend.connectionsArray[0].mockRespond(createResponse(navJson));
});
@ -190,13 +182,10 @@ describe('NavigationService', () => {
});
describe('versionInfo', () => {
let navService: NavigationService, versionInfo: VersionInfo;
let versionInfo: VersionInfo;
beforeEach(() => {
navService = injector.get(NavigationService);
navService.versionInfo.subscribe(info => versionInfo = info);
const backend = injector.get(ConnectionBackend);
backend.connectionsArray[0].mockRespond(createResponse({
__versionInfo: { raw: '4.0.0' }
}));
@ -206,4 +195,24 @@ describe('NavigationService', () => {
expect(versionInfo).toEqual({ raw: '4.0.0' });
});
});
describe('docVersions', () => {
let actualDocVersions: NavigationNode[];
let docVersions: NavigationNode[];
beforeEach(() => {
actualDocVersions = [];
docVersions = [
{ title: 'v4.0.0' },
{ title: 'v2', url: 'https://v2.angular.io' }
];
navService.navigationViews.subscribe(views => actualDocVersions = views.docVersions);
});
it('should extract the docVersions', () => {
backend.connectionsArray[0].mockRespond(createResponse({ docVersions }));
expect(actualDocVersions).toEqual(docVersions);
});
});
});

View File

@ -4,6 +4,7 @@ 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/map';
import 'rxjs/add/operator/publishLast';
import 'rxjs/add/operator/publishReplay';
@ -37,10 +38,11 @@ export class NavigationService {
constructor(private http: Http, private location: LocationService, private logger: Logger) {
const navigationInfo = this.fetchNavigationInfo();
this.navigationViews = this.getNavigationViews(navigationInfo);
this.currentNode = this.getCurrentNode(this.navigationViews);
// 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.currentNode = this.getCurrentNode(this.navigationViews);
}
/**
@ -69,7 +71,13 @@ export class NavigationService {
}
private getNavigationViews(navigationInfo: Observable<NavigationResponse>): Observable<NavigationViews> {
const navigationViews = navigationInfo.map(response => unpluck(response, '__versionInfo')).publishReplay(1);
const navigationViews = navigationInfo.map(response => {
const views: NavigationViews = Object.assign({}, response);
Object.keys(views).forEach(key => {
if (key[0] === '_') { delete views[key]; }
});
return views;
}).publishReplay(1);
navigationViews.connect();
return navigationViews;
}
@ -120,9 +128,3 @@ export class NavigationService {
}
}
}
function unpluck(obj: any, property: string) {
const result = Object.assign({}, obj);
delete result[property];
return result;
}

View File

@ -6,8 +6,9 @@ import { GaService } from 'app/shared/ga.service';
import { LocationService } from './location.service';
describe('LocationService', () => {
let injector: ReflectiveInjector;
let location: MockLocationStrategy;
let service: LocationService;
beforeEach(() => {
injector = ReflectiveInjector.resolveAndCreate([
@ -17,19 +18,18 @@ describe('LocationService', () => {
{ provide: LocationStrategy, useClass: MockLocationStrategy },
{ provide: PlatformLocation, useClass: MockPlatformLocation }
]);
location = injector.get(LocationStrategy);
service = injector.get(LocationService);
});
describe('currentUrl', () => {
it('should emit the latest url at the time it is subscribed to', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
location.simulatePopState('/initial-url1');
location.simulatePopState('/initial-url2');
location.simulatePopState('/initial-url3');
const service: LocationService = injector.get(LocationService);
location.simulatePopState('/next-url1');
location.simulatePopState('/next-url2');
location.simulatePopState('/next-url3');
@ -40,9 +40,6 @@ describe('LocationService', () => {
});
it('should emit all location changes after it has been subscribed to', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
location.simulatePopState('/initial-url1');
location.simulatePopState('/initial-url2');
location.simulatePopState('/initial-url3');
@ -63,9 +60,6 @@ describe('LocationService', () => {
});
it('should pass only the latest and later urls to each subscriber', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
location.simulatePopState('/initial-url1');
location.simulatePopState('/initial-url2');
location.simulatePopState('/initial-url3');
@ -95,8 +89,6 @@ describe('LocationService', () => {
});
it('should strip leading and trailing slashes', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
const urls: string[] = [];
service.currentUrl.subscribe(u => urls.push(u));
@ -117,8 +109,6 @@ describe('LocationService', () => {
describe('currentPath', () => {
it('should strip leading and trailing slashes off the url', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
const paths: string[] = [];
service.currentPath.subscribe(p => paths.push(p));
@ -137,8 +127,6 @@ describe('LocationService', () => {
});
it('should not strip other slashes off the url', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
const paths: string[] = [];
service.currentPath.subscribe(p => paths.push(p));
@ -157,8 +145,6 @@ describe('LocationService', () => {
});
it('should strip the query off the url', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
let path: string;
service.currentPath.subscribe(p => path = p);
@ -169,8 +155,6 @@ describe('LocationService', () => {
});
it('should strip the hash fragment off the url', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
const paths: string[] = [];
service.currentPath.subscribe(p => paths.push(p));
@ -185,14 +169,10 @@ describe('LocationService', () => {
});
it('should emit the latest path at the time it is subscribed to', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
location.simulatePopState('/initial/url1');
location.simulatePopState('/initial/url2');
location.simulatePopState('/initial/url3');
const service: LocationService = injector.get(LocationService);
location.simulatePopState('/next/url1');
location.simulatePopState('/next/url2');
location.simulatePopState('/next/url3');
@ -204,9 +184,6 @@ describe('LocationService', () => {
});
it('should emit all location changes after it has been subscribed to', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
location.simulatePopState('/initial/url1');
location.simulatePopState('/initial/url2');
location.simulatePopState('/initial/url3');
@ -227,9 +204,6 @@ describe('LocationService', () => {
});
it('should pass only the latest and later paths to each subscriber', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
location.simulatePopState('/initial/url1');
location.simulatePopState('/initial/url2');
location.simulatePopState('/initial/url3');
@ -260,27 +234,19 @@ describe('LocationService', () => {
});
describe('go', () => {
it('should update the location', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
service.go('some-new-url');
expect(location.internalPath).toEqual('some-new-url');
expect(location.path(true)).toEqual('some-new-url');
});
it('should emit the new url', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
const urls = [];
service.go('some-initial-url');
const urls = [];
service.currentUrl.subscribe(url => urls.push(url));
service.go('some-new-url');
expect(urls).toEqual([
'some-initial-url',
'some-new-url'
@ -288,8 +254,6 @@ describe('LocationService', () => {
});
it('should strip leading and trailing slashes', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
let url: string;
service.currentUrl.subscribe(u => url = u);
@ -299,21 +263,48 @@ describe('LocationService', () => {
expect(location.path(true)).toEqual('some/url');
expect(url).toBe('some/url');
});
it('should ignore undefined URL string', noUrlTest(undefined));
it('should ignore null URL string', noUrlTest(null));
it('should ignore empty URL string', noUrlTest(''));
function noUrlTest(testUrl: string) {
return function() {
const initialUrl = 'some/url';
const goExternalSpy = spyOn(service, 'goExternal');
let url: string;
service.go(initialUrl);
service.currentUrl.subscribe(u => url = u);
service.go(testUrl);
expect(url).toEqual(initialUrl, 'should not have re-navigated locally');
expect(goExternalSpy.wasCalled).toBeFalsy('should not have navigated externally');
};
}
it('should leave the site for external url that starts with "http"', () => {
const goExternalSpy = spyOn(service, 'goExternal');
const externalUrl = 'http://some/far/away/land';
service.go(externalUrl);
expect(goExternalSpy).toHaveBeenCalledWith(externalUrl);
});
it('should not update currentUrl for external url that starts with "http"', () => {
let localUrl: string;
spyOn(service, 'goExternal');
service.currentUrl.subscribe(url => localUrl = url);
service.go('https://some/far/away/land');
expect(localUrl).toBeFalsy('should not set local url');
});
});
describe('search', () => {
it('should read the query from the current location.path', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
location.simulatePopState('a/b/c?foo=bar&moo=car');
expect(service.search()).toEqual({ foo: 'bar', moo: 'car' });
});
it('should cope with an empty query', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
location.simulatePopState('a/b/c');
expect(service.search()).toEqual({ });
@ -328,25 +319,16 @@ describe('LocationService', () => {
});
it('should URL decode query values', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
location.simulatePopState('a/b/c?query=a%26b%2Bc%20d');
expect(service.search()).toEqual({ query: 'a&b+c d' });
});
it('should URL decode query keys', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
location.simulatePopState('a/b/c?a%26b%2Bc%20d=value');
expect(service.search()).toEqual({ 'a&b+c d': 'value' });
});
it('should cope with a hash on the URL', () => {
const location: MockLocationStrategy = injector.get(LocationStrategy);
const service: LocationService = injector.get(LocationService);
spyOn(location, 'path').and.callThrough();
service.search();
expect(location.path).toHaveBeenCalledWith(false);
@ -354,53 +336,47 @@ describe('LocationService', () => {
});
describe('setSearch', () => {
it('should call replaceState on PlatformLocation', () => {
const location: MockPlatformLocation = injector.get(PlatformLocation);
const service: LocationService = injector.get(LocationService);
let platformLocation: MockPlatformLocation;
beforeEach(() => {
platformLocation = injector.get(PlatformLocation);
});
it('should call replaceState on PlatformLocation', () => {
const params = {};
service.setSearch('Some label', params);
expect(location.replaceState).toHaveBeenCalledWith(jasmine.any(Object), 'Some label', 'a/b/c');
expect(platformLocation.replaceState).toHaveBeenCalledWith(jasmine.any(Object), 'Some label', 'a/b/c');
});
it('should convert the params to a query string', () => {
const location: MockPlatformLocation = injector.get(PlatformLocation);
const service: LocationService = injector.get(LocationService);
const params = { foo: 'bar', moo: 'car' };
service.setSearch('Some label', params);
expect(location.replaceState).toHaveBeenCalledWith(jasmine.any(Object), 'Some label', jasmine.any(String));
const [path, query] = location.replaceState.calls.mostRecent().args[2].split('?');
expect(platformLocation.replaceState).toHaveBeenCalledWith(jasmine.any(Object), 'Some label', jasmine.any(String));
const [path, query] = platformLocation.replaceState.calls.mostRecent().args[2].split('?');
expect(path).toEqual('a/b/c');
expect(query).toContain('foo=bar');
expect(query).toContain('moo=car');
});
it('should URL encode param values', () => {
const location: MockPlatformLocation = injector.get(PlatformLocation);
const service: LocationService = injector.get(LocationService);
const params = { query: 'a&b+c d' };
service.setSearch('', params);
const [, query] = location.replaceState.calls.mostRecent().args[2].split('?');
const [, query] = platformLocation.replaceState.calls.mostRecent().args[2].split('?');
expect(query).toContain('query=a%26b%2Bc%20d');
});
it('should URL encode param keys', () => {
const location: MockPlatformLocation = injector.get(PlatformLocation);
const service: LocationService = injector.get(LocationService);
const params = { 'a&b+c d': 'value' };
service.setSearch('', params);
const [, query] = location.replaceState.calls.mostRecent().args[2].split('?');
const [, query] = platformLocation.replaceState.calls.mostRecent().args[2].split('?');
expect(query).toContain('a%26b%2Bc%20d=value');
});
});
describe('handleAnchorClick', () => {
let service: LocationService, anchor: HTMLAnchorElement;
let anchor: HTMLAnchorElement;
beforeEach(() => {
service = injector.get(LocationService);
anchor = document.createElement('a');
});
@ -520,14 +496,10 @@ 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', () => {
@ -546,8 +518,7 @@ describe('LocationService', () => {
});
it('should call locationChanged when window history changes', () => {
const locationStrategy: MockLocationStrategy = injector.get(LocationStrategy);
locationStrategy.simulatePopState('/next-url');
location.simulatePopState('/next-url');
expect(gaLocationChanged.calls.count()).toBe(2, 'gaService.locationChanged');
const args = gaLocationChanged.calls.argsFor(1);

View File

@ -36,9 +36,19 @@ export class LocationService {
// TODO?: ignore if url-without-hash-or-search matches current location?
go(url: string) {
if (!url) { return; }
url = this.stripSlashes(url);
this.location.go(url);
this.urlSubject.next(url);
if (/^http/.test(url)) {
// Has http protocol so leave the site
this.goExternal(url);
} else {
this.location.go(url);
this.urlSubject.next(url);
}
}
goExternal(url: string) {
location.assign(url);
}
private stripSlashes(url: string) {