diff --git a/aio/src/app/app.component.spec.ts b/aio/src/app/app.component.spec.ts index cc2a29b740..5aec4219d7 100644 --- a/aio/src/app/app.component.spec.ts +++ b/aio/src/app/app.component.spec.ts @@ -632,7 +632,6 @@ describe('AppComponent', () => { it('should initialize the search worker', inject([SearchService], (searchService: SearchService) => { fixture.detectChanges(); // triggers ngOnInit expect(searchService.initWorker).toHaveBeenCalled(); - expect(searchService.loadIndex).toHaveBeenCalled(); })); }); diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts index 87cc990bbd..5308bdce56 100644 --- a/aio/src/app/app.component.ts +++ b/aio/src/app/app.component.ts @@ -111,8 +111,8 @@ export class AppComponent implements OnInit { ngOnInit() { // Do not initialize the search on browsers that lack web worker support if ('Worker' in window) { - this.searchService.initWorker('app/search/search-worker.js'); - this.searchService.loadIndex(); + // Delay initialization by up to 2 seconds + this.searchService.initWorker('app/search/search-worker.js', 2000); } this.onResize(window.innerWidth); 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 3c7012c87d..234c7b62fb 100644 --- a/aio/src/app/search/search-box/search-box.component.spec.ts +++ b/aio/src/app/search/search-box/search-box.component.spec.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import { async, ComponentFixture, TestBed, inject } from '@angular/core/testing'; +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'; @@ -36,30 +36,67 @@ describe('SearchBoxComponent', () => { }); describe('initialisation', () => { - it('should get the current search query from the location service', inject([LocationService], (location: MockLocationService) => { + it('should get the current search query from the location service', + inject([LocationService], (location: MockLocationService) => fakeAsync(() => { location.search.and.returnValue({ search: 'initial search' }); component.ngOnInit(); expect(location.search).toHaveBeenCalled(); + tick(300); expect(host.searchHandler).toHaveBeenCalledWith('initial search'); expect(component.searchBox.nativeElement.value).toEqual('initial search'); + }))); + }); + + describe('onSearch', () => { + it('should debounce by 300ms', fakeAsync(() => { + component.doSearch(); + expect(host.searchHandler).not.toHaveBeenCalled(); + tick(300); + expect(host.searchHandler).toHaveBeenCalled(); + })); + + it('should pass through the value of the input box', fakeAsync(() => { + const input = fixture.debugElement.query(By.css('input')); + input.nativeElement.value = 'some query (input)'; + component.doSearch(); + tick(300); + expect(host.searchHandler).toHaveBeenCalledWith('some query (input)'); + })); + + it('should only send events if the search value has changed', fakeAsync(() => { + const input = fixture.debugElement.query(By.css('input')); + + input.nativeElement.value = 'some query'; + component.doSearch(); + tick(300); + expect(host.searchHandler).toHaveBeenCalledTimes(1); + + component.doSearch(); + tick(300); + expect(host.searchHandler).toHaveBeenCalledTimes(1); + + input.nativeElement.value = 'some other query'; + component.doSearch(); + tick(300); + expect(host.searchHandler).toHaveBeenCalledTimes(2); })); }); describe('on input', () => { - it('should trigger the onSearch event', () => { + it('should trigger a search', () => { const input = fixture.debugElement.query(By.css('input')); - input.nativeElement.value = 'some query (input)'; + spyOn(component, 'doSearch'); input.triggerEventHandler('input', { }); - expect(host.searchHandler).toHaveBeenCalledWith('some query (input)'); + expect(component.doSearch).toHaveBeenCalled(); }); }); describe('on keyup', () => { - it('should trigger the onSearch event', () => { + it('should trigger a search', () => { const input = fixture.debugElement.query(By.css('input')); - input.nativeElement.value = 'some query (keyup)'; + spyOn(component, 'doSearch'); input.triggerEventHandler('keyup', { }); - expect(host.searchHandler).toHaveBeenCalledWith('some query (keyup)'); + expect(component.doSearch).toHaveBeenCalled(); }); }); @@ -73,28 +110,11 @@ describe('SearchBoxComponent', () => { }); describe('on click', () => { - it('should trigger the search event', () => { + it('should trigger a search', () => { const input = fixture.debugElement.query(By.css('input')); - input.nativeElement.value = 'some query (click)'; + spyOn(component, 'doSearch'); input.triggerEventHandler('click', { }); - expect(host.searchHandler).toHaveBeenCalledWith('some query (click)'); - }); - }); - - describe('event filtering', () => { - it('should only send events if the search value has changed', () => { - const input = fixture.debugElement.query(By.css('input')); - - input.nativeElement.value = 'some query'; - input.triggerEventHandler('input', { }); - expect(host.searchHandler).toHaveBeenCalledTimes(1); - - input.triggerEventHandler('input', { }); - expect(host.searchHandler).toHaveBeenCalledTimes(1); - - input.nativeElement.value = 'some other query'; - input.triggerEventHandler('input', { }); - expect(host.searchHandler).toHaveBeenCalledTimes(2); + expect(component.doSearch).toHaveBeenCalled(); }); }); diff --git a/aio/src/app/search/search-box/search-box.component.ts b/aio/src/app/search/search-box/search-box.component.ts index 6c79f75002..7ef3149636 100644 --- a/aio/src/app/search/search-box/search-box.component.ts +++ b/aio/src/app/search/search-box/search-box.component.ts @@ -26,10 +26,11 @@ import 'rxjs/add/operator/distinctUntilChanged'; }) export class SearchBoxComponent implements OnInit { + private searchDebounce = 300; private searchSubject = new Subject(); @ViewChild('searchBox') searchBox: ElementRef; - @Output() onSearch = this.searchSubject.distinctUntilChanged(); + @Output() onSearch = this.searchSubject.distinctUntilChanged().debounceTime(this.searchDebounce); @Output() onFocus = new EventEmitter(); constructor(private locationService: LocationService) { } diff --git a/aio/src/app/search/search.service.spec.ts b/aio/src/app/search/search.service.spec.ts index f92dcb3ba8..daa596580d 100644 --- a/aio/src/app/search/search.service.spec.ts +++ b/aio/src/app/search/search.service.spec.ts @@ -1,24 +1,62 @@ import { ReflectiveInjector, NgZone } from '@angular/core'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { Observable } from 'rxjs/Observable'; import { SearchService } from './search.service'; +import { WebWorkerClient } from 'app/shared/web-worker'; describe('SearchService', () => { let injector: ReflectiveInjector; + let service: SearchService; + let sendMessageSpy: jasmine.Spy; + let mockWorker: WebWorkerClient; beforeEach(() => { + sendMessageSpy = jasmine.createSpy('sendMessage').and.returnValue(Observable.of({})); + mockWorker = { sendMessage: sendMessageSpy } as any; + spyOn(WebWorkerClient, 'create').and.returnValue(mockWorker); + injector = ReflectiveInjector.resolveAndCreate([ SearchService, { provide: NgZone, useFactory: () => new NgZone({ enableLongStackTrace: false }) } ]); + service = injector.get(SearchService); }); - describe('loadIndex', () => { - it('should send a "load-index" message to the worker'); - it('should connect the `ready` property to the response to the "load-index" message'); + describe('initWorker', () => { + it('should create the worker and load the index after the specified delay', fakeAsync(() => { + service.initWorker('some/url', 100); + expect(WebWorkerClient.create).not.toHaveBeenCalled(); + expect(mockWorker.sendMessage).not.toHaveBeenCalled(); + tick(100); + expect(WebWorkerClient.create).toHaveBeenCalledWith('some/url', jasmine.any(NgZone)); + expect(mockWorker.sendMessage).toHaveBeenCalledWith('load-index'); + })); }); describe('search', () => { - it('should send a "query-index" message to the worker'); - it('should push the response to the `searchResults` observable'); + beforeEach(() => { + // We must initialize the service before calling search + service.initWorker('some/url', 100); + }); + + it('should trigger a `loadIndex` synchronously', () => { + service.search('some query'); + expect(mockWorker.sendMessage).toHaveBeenCalledWith('load-index'); + }); + + it('should send a "query-index" message to the worker', () => { + service.search('some query'); + expect(mockWorker.sendMessage).toHaveBeenCalledWith('query-index', 'some query'); + }); + + it('should push the response to the `searchResults` observable', () => { + const mockSearchResults = { results: ['a', 'b'] }; + (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); + }); }); }); diff --git a/aio/src/app/search/search.service.ts b/aio/src/app/search/search.service.ts index 7e37b38521..8bb0a788f0 100644 --- a/aio/src/app/search/search.service.ts +++ b/aio/src/app/search/search.service.ts @@ -7,8 +7,10 @@ can be found in the LICENSE file at http://angular.io/license import { NgZone, Injectable, Type } from '@angular/core'; import { Observable } from 'rxjs/Observable'; import { ReplaySubject } from 'rxjs/ReplaySubject'; -import 'rxjs/add/operator/publishLast'; +import 'rxjs/add/observable/race'; +import 'rxjs/add/observable/timer'; import 'rxjs/add/operator/concatMap'; +import 'rxjs/add/operator/publish'; import { WebWorkerClient } from 'app/shared/web-worker'; export interface SearchResults { @@ -27,26 +29,50 @@ export interface SearchResult { @Injectable() export class SearchService { - private worker: WebWorkerClient; - private ready: Observable; - private resultsSubject = new ReplaySubject(1); - readonly searchResults = this.resultsSubject.asObservable(); + private searchesSubject = new ReplaySubject(1); + searchResults: Observable; constructor(private zone: NgZone) {} - initWorker(workerUrl) { - this.worker = new WebWorkerClient(new Worker(workerUrl), this.zone); - } - - loadIndex() { - const ready = this.ready = this.worker.sendMessage('load-index').publishLast(); - // trigger the index to be loaded immediately - ready.connect(); + /** + * Initialize the search engine. We offer an `initDelay` to prevent the search initialisation from delaying the + * initial rendering of the web page. Triggering a search will override this delay and cause the index to be + * loaded immediately. + * + * @param workerUrl the url of the WebWorker script that runs the searches + * @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 + // Wait for the initDelay or the first search + .race( + Observable.timer(initDelay), + this.searchesSubject.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(); + + // Connect to the observable to kick off the timer + searchResults.connect(); + + // Expose the connected observable to the rest of the world + this.searchResults = searchResults; } + /** + * Send a search query to the index. + * The results will appear on the `searchResults` observable. + */ search(query: string) { - this.ready.concatMap(ready => { - return this.worker.sendMessage('query-index', query) as Observable; - }).subscribe(results => this.resultsSubject.next(results)); + this.searchesSubject.next(query); } } diff --git a/aio/src/app/shared/web-worker.ts b/aio/src/app/shared/web-worker.ts index 95bf6e05ab..b70f993483 100644 --- a/aio/src/app/shared/web-worker.ts +++ b/aio/src/app/shared/web-worker.ts @@ -16,7 +16,11 @@ export interface WebWorkerMessage { export class WebWorkerClient { private nextId = 0; - constructor(private worker: Worker, private zone: NgZone) { + static create(workerUrl: string, zone: NgZone) { + return new WebWorkerClient(new Worker(workerUrl), zone); + } + + private constructor(private worker: Worker, private zone: NgZone) { } sendMessage(type: string, payload?: any): Observable {