diff --git a/aio/src/app/app.component.html b/aio/src/app/app.component.html
index 8f42887b50..f1131a8c02 100644
--- a/aio/src/app/app.component.html
+++ b/aio/src/app/app.component.html
@@ -13,7 +13,7 @@
-
+
diff --git a/aio/src/app/app.component.spec.ts b/aio/src/app/app.component.spec.ts
index 15638cd345..9c599367a1 100644
--- a/aio/src/app/app.component.spec.ts
+++ b/aio/src/app/app.component.spec.ts
@@ -1,12 +1,11 @@
import { NO_ERRORS_SCHEMA, DebugElement } from '@angular/core';
-import { async, inject, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { inject, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { Title } from '@angular/platform-browser';
import { APP_BASE_HREF } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { MdProgressBar, MdSidenav } from '@angular/material';
import { By } from '@angular/platform-browser';
-import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { of } from 'rxjs/observable/of';
import { AppComponent } from './app.component';
@@ -24,7 +23,7 @@ import { ScrollService } from 'app/shared/scroll.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 { SelectComponent, Option } from 'app/shared/select/select.component';
+import { SelectComponent } from 'app/shared/select/select.component';
import { TocComponent } from 'app/embedded/toc/toc.component';
import { TocItem, TocService } from 'app/shared/toc.service';
@@ -1054,11 +1053,6 @@ class TestGaService {
locationChanged = jasmine.createSpy('locationChanged');
}
-class TestSearchService {
- initWorker = jasmine.createSpy('initWorker');
- loadIndex = jasmine.createSpy('loadIndex');
-}
-
class TestHttpClient {
static versionInfo = {
diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts
index 0729636a53..b950d23323 100644
--- a/aio/src/app/app.component.ts
+++ b/aio/src/app/app.component.ts
@@ -2,18 +2,18 @@ import { Component, ElementRef, HostBinding, HostListener, OnInit,
QueryList, ViewChild, ViewChildren } from '@angular/core';
import { MdSidenav } from '@angular/material';
-import { CurrentNodes, NavigationService, NavigationViews, NavigationNode, VersionInfo } from 'app/navigation/navigation.service';
+import { CurrentNodes, NavigationService, 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 { Deployment } from 'app/shared/deployment.service';
import { LocationService } from 'app/shared/location.service';
-import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
import { ScrollService } from 'app/shared/scroll.service';
-import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
import { SearchBoxComponent } from 'app/search/search-box/search-box.component';
+import { SearchResults } from 'app/search/interfaces';
import { SearchService } from 'app/search/search.service';
import { TocService } from 'app/shared/toc.service';
+import { Observable } from 'rxjs/Observable';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { combineLatest } from 'rxjs/observable/combineLatest';
@@ -89,10 +89,9 @@ export class AppComponent implements OnInit {
// Search related properties
showSearchResults = false;
- @ViewChildren('searchBox, searchResults', { read: ElementRef })
+ searchResults: Observable;
+ @ViewChildren('searchBox, searchResultsView', { read: ElementRef })
searchElements: QueryList;
- @ViewChild(SearchResultsComponent)
- searchResults: SearchResultsComponent;
@ViewChild(SearchBoxComponent)
searchBox: SearchBoxComponent;
@@ -332,7 +331,7 @@ export class AppComponent implements OnInit {
}
doSearch(query) {
- this.searchService.search(query);
+ this.searchResults = this.searchService.search(query);
this.showSearchResults = !!query;
}
diff --git a/aio/src/app/search/interfaces.ts b/aio/src/app/search/interfaces.ts
new file mode 100644
index 0000000000..c0c18d5c98
--- /dev/null
+++ b/aio/src/app/search/interfaces.ts
@@ -0,0 +1,19 @@
+export interface SearchResults {
+ query: string;
+ results: SearchResult[];
+}
+
+export interface SearchResult {
+ path: string;
+ title: string;
+ type: string;
+ titleWords: string;
+ keywords: string;
+}
+
+export interface SearchArea {
+ name: string;
+ pages: SearchResult[];
+ priorityPages: SearchResult[];
+}
+
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 234c7b62fb..94a70f76b7 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
@@ -2,7 +2,6 @@ import { Component } from '@angular/core';
import { ComponentFixture, fakeAsync, inject, TestBed, tick } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { SearchBoxComponent } from './search-box.component';
-import { MockSearchService } from 'testing/search.service';
import { LocationService } from 'app/shared/location.service';
import { MockLocationService } from 'testing/location.service';
diff --git a/aio/src/app/search/search-results/search-results.component.spec.ts b/aio/src/app/search/search-results/search-results.component.spec.ts
index eb46dd85d4..335f74b9e3 100644
--- a/aio/src/app/search/search-results/search-results.component.spec.ts
+++ b/aio/src/app/search/search-results/search-results.component.spec.ts
@@ -1,16 +1,12 @@
import { DebugElement } from '@angular/core';
-import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
-import { Observable } from 'rxjs/Observable';
-import { Subject } from 'rxjs/Subject';
-import { SearchService, SearchResult, SearchResults } from '../search.service';
-import { SearchResultsComponent, SearchArea } from './search-results.component';
-import { MockSearchService } from 'testing/search.service';
+import { SearchResult } from 'app/search/interfaces';
+import { SearchResultsComponent } from './search-results.component';
describe('SearchResultsComponent', () => {
let component: SearchResultsComponent;
let fixture: ComponentFixture;
- let searchResults: Subject;
/** Get all text from component element */
function getText() { return fixture.debugElement.nativeElement.textContent; }
@@ -38,27 +34,26 @@ describe('SearchResultsComponent', () => {
return l.title.toUpperCase() > r.title.toUpperCase() ? 1 : -1;
}
+ function setSearchResults(query: string, results: SearchResult[]) {
+ component.searchResults = {query, results};
+ component.ngOnChanges({});
+ fixture.detectChanges();
+ }
beforeEach(() => {
TestBed.configureTestingModule({
- declarations: [ SearchResultsComponent ],
- providers: [
- { provide: SearchService, useFactory: () => new MockSearchService() }
- ]
+ declarations: [ SearchResultsComponent ]
});
});
beforeEach(() => {
fixture = TestBed.createComponent(SearchResultsComponent);
component = fixture.componentInstance;
- searchResults = TestBed.get(SearchService).searchResults;
fixture.detectChanges();
});
it('should map the search results into groups based on their containing folder', () => {
- const results = getTestResults(3);
-
- searchResults.next({ query: '', results: results});
+ setSearchResults('', getTestResults(3));
expect(component.searchAreas).toEqual([
{ name: 'api', priorityPages: [
{ path: 'api/d', title: 'API D', type: '', keywords: '', titleWords: '' }
@@ -71,10 +66,10 @@ describe('SearchResultsComponent', () => {
});
it('should special case results that are top level folders', () => {
- searchResults.next({ query: '', results: [
+ setSearchResults('', [
{ path: 'tutorial', title: 'Tutorial index', type: '', keywords: '', titleWords: '' },
{ path: 'tutorial/toh-pt1', title: 'Tutorial - part 1', type: '', keywords: '', titleWords: '' },
- ]});
+ ]);
expect(component.searchAreas).toEqual([
{ name: 'tutorial', priorityPages: [
{ path: 'tutorial', title: 'Tutorial index', type: '', keywords: '', titleWords: '' },
@@ -85,21 +80,21 @@ describe('SearchResultsComponent', () => {
it('should put first 5 results for each area into priorityPages', () => {
const results = getTestResults();
- searchResults.next({ query: '', results: results });
+ setSearchResults('', results);
expect(component.searchAreas[0].priorityPages).toEqual(results.filter(p => p.path.startsWith('api')).slice(0, 5));
expect(component.searchAreas[1].priorityPages).toEqual(results.filter(p => p.path.startsWith('guide')).slice(0, 5));
});
it('should put the nonPriorityPages into the pages array, sorted by title', () => {
const results = getTestResults();
- searchResults.next({ query: '', results: results });
+ setSearchResults('', results);
expect(component.searchAreas[0].pages).toEqual([]);
expect(component.searchAreas[1].pages).toEqual(results.filter(p => p.path.startsWith('guide')).slice(5).sort(compareTitle));
});
it('should put a total count in the header of each area of search results', () => {
const results = getTestResults();
- searchResults.next({ query: '', results: results });
+ setSearchResults('', results);
fixture.detectChanges();
const headers = fixture.debugElement.queryAll(By.css('h3'));
expect(headers.length).toEqual(2);
@@ -112,7 +107,7 @@ describe('SearchResultsComponent', () => {
{ path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' }
];
- searchResults.next({ query: '', results: results });
+ setSearchResults('', results);
expect(component.searchAreas).toEqual([
{ name: 'other', priorityPages: [
{ path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' }
@@ -125,7 +120,7 @@ describe('SearchResultsComponent', () => {
{ path: 'news', title: undefined, type: 'marketing', keywords: '', titleWords: '' }
];
- searchResults.next({ query: 'something', results: results });
+ setSearchResults('something', results);
expect(component.searchAreas).toEqual([]);
});
@@ -144,7 +139,7 @@ describe('SearchResultsComponent', () => {
selected = null;
searchResult = { path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' };
- searchResults.next({ query: 'something', results: [searchResult] });
+ setSearchResults('something', [searchResult]);
fixture.detectChanges();
anchor = fixture.debugElement.query(By.css('a'));
@@ -179,10 +174,8 @@ describe('SearchResultsComponent', () => {
describe('when no query results', () => {
it('should display "not found" message', () => {
- searchResults.next({ query: 'something', results: [] });
- fixture.detectChanges();
+ setSearchResults('something', []);
expect(getText()).toContain('No results');
});
});
-
});
diff --git a/aio/src/app/search/search-results/search-results.component.ts b/aio/src/app/search/search-results/search-results.component.ts
index d3e7407d68..837459f344 100644
--- a/aio/src/app/search/search-results/search-results.component.ts
+++ b/aio/src/app/search/search-results/search-results.component.ts
@@ -1,28 +1,20 @@
-import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output } from '@angular/core';
-import { Observable } from 'rxjs/Observable';
-import { Subscription } from 'rxjs/Subscription';
-
-import { SearchResult, SearchResults, SearchService } from '../search.service';
-
-export interface SearchArea {
- name: string;
- pages: SearchResult[];
- priorityPages: SearchResult[];
-}
+import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
+import { SearchResult, SearchResults, SearchArea } from 'app/search/interfaces';
/**
- * A component to display the search results
+ * A component to display search results in groups
*/
@Component({
selector: 'aio-search-results',
templateUrl: './search-results.component.html',
})
-export class SearchResultsComponent implements OnInit, OnDestroy {
+export class SearchResultsComponent implements OnChanges {
- private resultsSubscription: Subscription;
- readonly defaultArea = 'other';
- notFoundMessage = 'Searching ...';
- readonly topLevelFolders = ['guide', 'tutorial'];
+ /**
+ * The results to display
+ */
+ @Input()
+ searchResults: SearchResults;
/**
* Emitted when the user selects a search result
@@ -30,20 +22,13 @@ export class SearchResultsComponent implements OnInit, OnDestroy {
@Output()
resultSelected = new EventEmitter();
- /**
- * A mapping of the search results grouped into areas
- */
+ readonly defaultArea = 'other';
+ notFoundMessage = 'Searching ...';
+ readonly topLevelFolders = ['guide', 'tutorial'];
searchAreas: SearchArea[] = [];
- constructor(private searchService: SearchService) {}
-
- ngOnInit() {
- this.resultsSubscription = this.searchService.searchResults
- .subscribe(search => this.searchAreas = this.processSearchResults(search));
- }
-
- ngOnDestroy() {
- this.resultsSubscription.unsubscribe();
+ ngOnChanges(changes: SimpleChanges) {
+ this.searchAreas = this.processSearchResults(this.searchResults);
}
onResultSelected(page: SearchResult, event: MouseEvent) {
@@ -55,6 +40,9 @@ export class SearchResultsComponent implements OnInit, OnDestroy {
// Map the search results into groups by area
private processSearchResults(search: SearchResults) {
+ if (!search) {
+ return [];
+ }
this.notFoundMessage = 'No results found.';
const searchAreaMap = {};
search.results.forEach(result => {
@@ -84,6 +72,6 @@ export class SearchResultsComponent implements OnInit, OnDestroy {
}
}
-function compareResults(l: {title: string}, r: {title: string}) {
+function compareResults(l: SearchResult, r: SearchResult) {
return l.title.toUpperCase() > r.title.toUpperCase() ? 1 : -1;
}
diff --git a/aio/src/app/search/search.service.spec.ts b/aio/src/app/search/search.service.spec.ts
index daa596580d..d8a0bb21ce 100644
--- a/aio/src/app/search/search.service.spec.ts
+++ b/aio/src/app/search/search.service.spec.ts
@@ -1,6 +1,7 @@
import { ReflectiveInjector, NgZone } from '@angular/core';
import { fakeAsync, tick } from '@angular/core/testing';
import { Observable } from 'rxjs/Observable';
+import 'rxjs/add/observable/of';
import { SearchService } from './search.service';
import { WebWorkerClient } from 'app/shared/web-worker';
@@ -36,27 +37,29 @@ describe('SearchService', () => {
describe('search', () => {
beforeEach(() => {
- // We must initialize the service before calling search
- service.initWorker('some/url', 100);
+ // We must initialize the service before calling connectSearches
+ service.initWorker('some/url', 1000);
+ // Simulate the index being ready so that searches get sent to the worker
+ (service as any).ready = Observable.of(true);
});
- it('should trigger a `loadIndex` synchronously', () => {
- service.search('some query');
+ it('should trigger a `loadIndex` synchronously (not waiting for the delay)', () => {
+ expect(mockWorker.sendMessage).not.toHaveBeenCalled();
+ service.search('some query').subscribe();
expect(mockWorker.sendMessage).toHaveBeenCalledWith('load-index');
});
it('should send a "query-index" message to the worker', () => {
- service.search('some query');
+ service.search('some query').subscribe();
expect(mockWorker.sendMessage).toHaveBeenCalledWith('query-index', 'some query');
});
- it('should push the response to the `searchResults` observable', () => {
+ it('should push the response to the returned observable', () => {
const mockSearchResults = { results: ['a', 'b'] };
+ let actualSearchResults;
(mockWorker.sendMessage as jasmine.Spy).and.returnValue(Observable.of(mockSearchResults));
- let searchResults: any;
- service.searchResults.subscribe(results => searchResults = results);
- service.search('some query');
- expect(searchResults).toEqual(mockSearchResults);
+ service.search('some query').subscribe(results => actualSearchResults = results);
+ expect(actualSearchResults).toEqual(mockSearchResults);
});
});
});
diff --git a/aio/src/app/search/search.service.ts b/aio/src/app/search/search.service.ts
index 45a7fdfaa2..7febd9543e 100644
--- a/aio/src/app/search/search.service.ts
+++ b/aio/src/app/search/search.service.ts
@@ -4,34 +4,21 @@ Use of this source code is governed by an MIT-style license that
can be found in the LICENSE file at http://angular.io/license
*/
-import { NgZone, Injectable, Type } from '@angular/core';
+import { NgZone, Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { ReplaySubject } from 'rxjs/ReplaySubject';
import 'rxjs/add/observable/race';
import 'rxjs/add/observable/timer';
import 'rxjs/add/operator/concatMap';
-import 'rxjs/add/operator/publish';
+import 'rxjs/add/operator/publishReplay';
import { WebWorkerClient } from 'app/shared/web-worker';
-
-export interface SearchResults {
- query: string;
- results: SearchResult[];
-}
-
-export interface SearchResult {
- path: string;
- title: string;
- type: string;
- titleWords: string;
- keywords: string;
-}
-
+import { SearchResults } from 'app/search/interfaces';
@Injectable()
export class SearchService {
+ private ready: Observable;
private searchesSubject = new ReplaySubject(1);
- searchResults: Observable;
-
+ private worker: WebWorkerClient;
constructor(private zone: NgZone) {}
/**
@@ -43,36 +30,32 @@ export class SearchService {
* @param initDelay the number of milliseconds to wait before we load the WebWorker and generate the search index
*/
initWorker(workerUrl: string, initDelay: number) {
- const searchResults = Observable
+ const ready = this.ready = Observable
// Wait for the initDelay or the first search
.race(
Observable.timer(initDelay),
- this.searchesSubject.first()
+ (this.searchesSubject as Observable).first()
)
.concatMap(() => {
// Create the worker and load the index
- const worker = WebWorkerClient.create(workerUrl, this.zone);
- return worker.sendMessage('load-index').concatMap(() =>
- // Once the index has loaded, switch to listening to the searches coming in
- this.searchesSubject.switchMap((query) =>
- // Each search gets switched to a web worker message, whose results are returned via an observable
- worker.sendMessage('query-index', query)
- )
- );
- }).publish();
+ this.worker = WebWorkerClient.create(workerUrl, this.zone);
+ return this.worker.sendMessage('load-index');
+ }).publishReplay(1);
- // Connect to the observable to kick off the timer
- searchResults.connect();
-
- // Expose the connected observable to the rest of the world
- this.searchResults = searchResults;
+ // Connect to the observable to kick off the timer
+ ready.connect();
+ return ready;
}
/**
- * Send a search query to the index.
- * The results will appear on the `searchResults` observable.
+ * Search the index using the given query and emit results on the observable that is returned.
+ * @param query The query to run against the index.
+ * @returns an observable collection of search results
*/
- search(query: string) {
+ search(query: string): Observable {
+ // Trigger the searches subject to override the init delay timer
this.searchesSubject.next(query);
+ // Once the index has loaded, switch to listening to the searches coming in.
+ return this.ready.concatMap(() => this.worker.sendMessage('query-index', query));
}
}
diff --git a/aio/src/testing/search.service.ts b/aio/src/testing/search.service.ts
index e004487db9..4d06f47e86 100644
--- a/aio/src/testing/search.service.ts
+++ b/aio/src/testing/search.service.ts
@@ -1,5 +1,5 @@
import { Subject } from 'rxjs/Subject';
-import { SearchResults } from 'app/search/search.service';
+import { SearchResults } from 'app/search/interfaces';
export class MockSearchService {
searchResults = new Subject();