diff --git a/aio/src/app/app.component.html b/aio/src/app/app.component.html index 52ce34ba0f..7aef453b5e 100644 --- a/aio/src/app/app.component.html +++ b/aio/src/app/app.component.html @@ -11,7 +11,7 @@ Home - + 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 c8c9618544..075b0278a1 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 @@ -7,10 +7,10 @@ import { LocationService } from 'app/shared/location.service'; import { MockLocationService } from 'testing/location.service'; @Component({ - template: '' + template: '' }) class HostComponent { - doSearch = jasmine.createSpy('doSearch'); + searchHandler = jasmine.createSpy('searchHandler'); } describe('SearchBoxComponent', () => { @@ -39,40 +39,61 @@ describe('SearchBoxComponent', () => { location.search.and.returnValue({ search: 'initial search' }); component.ngOnInit(); expect(location.search).toHaveBeenCalled(); - expect(host.doSearch).toHaveBeenCalledWith('initial search'); + expect(host.searchHandler).toHaveBeenCalledWith('initial search'); expect(component.searchBox.nativeElement.value).toEqual('initial search'); })); }); describe('on input', () => { - it('should trigger the search event', () => { + it('should trigger the onSearch event', () => { const input = fixture.debugElement.query(By.css('input')); - input.triggerEventHandler('input', { target: { value: 'some query' } }); - expect(host.doSearch).toHaveBeenCalledWith('some query'); + input.nativeElement.value = 'some query (input)'; + input.triggerEventHandler('input', { }); + expect(host.searchHandler).toHaveBeenCalledWith('some query (input)'); }); }); describe('on keyup', () => { - it('should trigger the search event', () => { + it('should trigger the onSearch event', () => { const input = fixture.debugElement.query(By.css('input')); - input.triggerEventHandler('keyup', { target: { value: 'some query' } }); - expect(host.doSearch).toHaveBeenCalledWith('some query'); + input.nativeElement.value = 'some query (keyup)'; + input.triggerEventHandler('keyup', { }); + expect(host.searchHandler).toHaveBeenCalledWith('some query (keyup)'); }); }); describe('on focus', () => { - it('should trigger the search event', () => { + it('should trigger the onSearch event', () => { const input = fixture.debugElement.query(By.css('input')); - input.triggerEventHandler('focus', { target: { value: 'some query' } }); - expect(host.doSearch).toHaveBeenCalledWith('some query'); + input.nativeElement.value = 'some query (focus)'; + input.triggerEventHandler('focus', { }); + expect(host.searchHandler).toHaveBeenCalledWith('some query (focus)'); }); }); describe('on click', () => { it('should trigger the search event', () => { const input = fixture.debugElement.query(By.css('input')); - input.triggerEventHandler('click', { target: { value: 'some query'}}); - expect(host.doSearch).toHaveBeenCalledWith('some query'); + input.nativeElement.value = 'some query (click)'; + 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); }); }); 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 5073d41906..3603ebf56a 100644 --- a/aio/src/app/search/search-box/search-box.component.ts +++ b/aio/src/app/search/search-box/search-box.component.ts @@ -1,5 +1,8 @@ import { Component, OnInit, ViewChild, ElementRef, EventEmitter, Output } from '@angular/core'; import { LocationService } from 'app/shared/location.service'; +import { Subject } from 'rxjs/Subject'; +import 'rxjs/add/operator/filter'; +import 'rxjs/add/operator/distinctUntilChanged'; /** * This component provides a text box to type a search query that will be sent to the SearchService. @@ -17,15 +20,19 @@ import { LocationService } from 'app/shared/location.service'; type="search" aria-label="search" placeholder="Search" - (input)="onSearch($event.target.value)" - (keyup)="onSearch($event.target.value)" - (focus)="onSearch($event.target.value)" - (click)="onSearch($event.target.value)">` + (input)="doSearch()" + (keyup)="doSearch()" + (focus)="doSearch()" + (click)="doSearch()">` }) export class SearchBoxComponent implements OnInit { + private searchSubject = new Subject(); + @ViewChild('searchBox') searchBox: ElementRef; - @Output() search = new EventEmitter(); + @Output() onSearch = this.searchSubject + .filter(value => !!(value && value.trim())) + .distinctUntilChanged(); constructor(private locationService: LocationService) { } @@ -36,12 +43,12 @@ export class SearchBoxComponent implements OnInit { const query = this.locationService.search()['search']; if (query) { this.searchBox.nativeElement.value = query; - this.onSearch(query); + this.doSearch(); } } - onSearch(query: string) { - this.search.emit(query); + doSearch() { + this.searchSubject.next(this.searchBox.nativeElement.value); } focus() { diff --git a/aio/src/app/search/search-worker.js b/aio/src/app/search/search-worker.js index ae00a13e07..ed4ea4c08e 100644 --- a/aio/src/app/search/search-worker.js +++ b/aio/src/app/search/search-worker.js @@ -6,21 +6,22 @@ var SEARCH_TERMS_URL = '/generated/docs/app/search-data.json'; // NOTE: This needs to be kept in sync with `ngsw-manifest.json`. -importScripts('https://unpkg.com/lunr@0.7.2/lunr.min.js'); +importScripts('https://unpkg.com/lunr@2.1.0/lunr.js'); -var index = createIndex(); +var index; var pages = {}; self.onmessage = handleMessage; // Create the lunr index - the docs should be an array of objects, each object containing // the path and search terms for a page -function createIndex() { +function createIndex(addFn) { return lunr(/** @this */function() { this.ref('path'); this.field('titleWords', {boost: 50}); this.field('members', {boost: 40}); this.field('keywords', {boost: 20}); + addFn(this); }); } @@ -32,7 +33,7 @@ function handleMessage(message) { switch(type) { case 'load-index': makeRequest(SEARCH_TERMS_URL, function(searchInfo) { - loadIndex(searchInfo); + index = createIndex(loadIndex(searchInfo)); self.postMessage({type: type, id: id, payload: true}); }); break; @@ -67,16 +68,29 @@ function makeRequest(url, callback) { // Create the search index from the searchInfo which contains the information about each page to be indexed function loadIndex(searchInfo) { - // Store the pages data to be used in mapping query results back to pages - // Add search terms from each page to the search index - searchInfo.forEach(function(page) { - index.add(page); - pages[page.path] = page; - }); + return function(index) { + // Store the pages data to be used in mapping query results back to pages + // Add search terms from each page to the search index + searchInfo.forEach(function(page) { + index.add(page); + pages[page.path] = page; + }); + }; } // Query the index and return the processed results function queryIndex(query) { + // The index requires the query to be lowercase + var terms = query.toLowerCase().split(/\s+/); + var results = index.query(function(qb) { + terms.forEach(function(term) { + // Only include terms that are longer than 2 characters, if there is more than one term + // Add trailing wildcard to each term so that it will match more results + if (terms.length === 1 || term.trim().length > 2) { + qb.term(term, { wildcard: lunr.Query.wildcard.TRAILING }); + } + }); + }); // Only return the array of paths to pages - return index.search(query).map(function(hit) { return pages[hit.ref]; }); + return results.map(function(hit) { return pages[hit.ref]; }); }