fix(aio): make search results better

* update to latest version of lunr search
* add trailing wildcard to search terms to increase matches
* fix unwanted error when escape was pressed

Closes #17417
This commit is contained in:
Peter Bacon Darwin 2017-06-13 11:01:04 +01:00 committed by Pete Bacon Darwin
parent bffccf4622
commit 0a846a2fce
4 changed files with 76 additions and 34 deletions

View File

@ -11,7 +11,7 @@
</button> </button>
<a class="nav-link home" href="/"><img src="{{ homeImageUrl }}" title="Home" alt="Home"></a> <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-top-menu *ngIf="isSideBySide" [nodes]="topMenuNodes"></aio-top-menu>
<aio-search-box class="search-container" #searchBox (search)="doSearch($event)"></aio-search-box> <aio-search-box class="search-container" #searchBox (onSearch)="doSearch($event)"></aio-search-box>
</md-toolbar> </md-toolbar>
<aio-search-results #searchResults *ngIf="showSearchResults" (resultSelected)="hideSearchResults()"></aio-search-results> <aio-search-results #searchResults *ngIf="showSearchResults" (resultSelected)="hideSearchResults()"></aio-search-results>

View File

@ -7,10 +7,10 @@ import { LocationService } from 'app/shared/location.service';
import { MockLocationService } from 'testing/location.service'; import { MockLocationService } from 'testing/location.service';
@Component({ @Component({
template: '<aio-search-box (search)="doSearch($event)"></aio-search-box>' template: '<aio-search-box (onSearch)="searchHandler($event)"></aio-search-box>'
}) })
class HostComponent { class HostComponent {
doSearch = jasmine.createSpy('doSearch'); searchHandler = jasmine.createSpy('searchHandler');
} }
describe('SearchBoxComponent', () => { describe('SearchBoxComponent', () => {
@ -39,40 +39,61 @@ describe('SearchBoxComponent', () => {
location.search.and.returnValue({ search: 'initial search' }); location.search.and.returnValue({ search: 'initial search' });
component.ngOnInit(); component.ngOnInit();
expect(location.search).toHaveBeenCalled(); expect(location.search).toHaveBeenCalled();
expect(host.doSearch).toHaveBeenCalledWith('initial search'); expect(host.searchHandler).toHaveBeenCalledWith('initial search');
expect(component.searchBox.nativeElement.value).toEqual('initial search'); expect(component.searchBox.nativeElement.value).toEqual('initial search');
})); }));
}); });
describe('on input', () => { describe('on input', () => {
it('should trigger the search event', () => { it('should trigger the onSearch event', () => {
const input = fixture.debugElement.query(By.css('input')); const input = fixture.debugElement.query(By.css('input'));
input.triggerEventHandler('input', { target: { value: 'some query' } }); input.nativeElement.value = 'some query (input)';
expect(host.doSearch).toHaveBeenCalledWith('some query'); input.triggerEventHandler('input', { });
expect(host.searchHandler).toHaveBeenCalledWith('some query (input)');
}); });
}); });
describe('on keyup', () => { describe('on keyup', () => {
it('should trigger the search event', () => { it('should trigger the onSearch event', () => {
const input = fixture.debugElement.query(By.css('input')); const input = fixture.debugElement.query(By.css('input'));
input.triggerEventHandler('keyup', { target: { value: 'some query' } }); input.nativeElement.value = 'some query (keyup)';
expect(host.doSearch).toHaveBeenCalledWith('some query'); input.triggerEventHandler('keyup', { });
expect(host.searchHandler).toHaveBeenCalledWith('some query (keyup)');
}); });
}); });
describe('on focus', () => { describe('on focus', () => {
it('should trigger the search event', () => { it('should trigger the onSearch event', () => {
const input = fixture.debugElement.query(By.css('input')); const input = fixture.debugElement.query(By.css('input'));
input.triggerEventHandler('focus', { target: { value: 'some query' } }); input.nativeElement.value = 'some query (focus)';
expect(host.doSearch).toHaveBeenCalledWith('some query'); input.triggerEventHandler('focus', { });
expect(host.searchHandler).toHaveBeenCalledWith('some query (focus)');
}); });
}); });
describe('on click', () => { describe('on click', () => {
it('should trigger the search event', () => { it('should trigger the search event', () => {
const input = fixture.debugElement.query(By.css('input')); const input = fixture.debugElement.query(By.css('input'));
input.triggerEventHandler('click', { target: { value: 'some query'}}); input.nativeElement.value = 'some query (click)';
expect(host.doSearch).toHaveBeenCalledWith('some query'); 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);
}); });
}); });

View File

@ -1,5 +1,8 @@
import { Component, OnInit, ViewChild, ElementRef, EventEmitter, Output } from '@angular/core'; import { Component, OnInit, ViewChild, ElementRef, EventEmitter, Output } from '@angular/core';
import { LocationService } from 'app/shared/location.service'; 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. * 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" type="search"
aria-label="search" aria-label="search"
placeholder="Search" placeholder="Search"
(input)="onSearch($event.target.value)" (input)="doSearch()"
(keyup)="onSearch($event.target.value)" (keyup)="doSearch()"
(focus)="onSearch($event.target.value)" (focus)="doSearch()"
(click)="onSearch($event.target.value)">` (click)="doSearch()">`
}) })
export class SearchBoxComponent implements OnInit { export class SearchBoxComponent implements OnInit {
private searchSubject = new Subject<string>();
@ViewChild('searchBox') searchBox: ElementRef; @ViewChild('searchBox') searchBox: ElementRef;
@Output() search = new EventEmitter<string>(); @Output() onSearch = this.searchSubject
.filter(value => !!(value && value.trim()))
.distinctUntilChanged();
constructor(private locationService: LocationService) { } constructor(private locationService: LocationService) { }
@ -36,12 +43,12 @@ export class SearchBoxComponent implements OnInit {
const query = this.locationService.search()['search']; const query = this.locationService.search()['search'];
if (query) { if (query) {
this.searchBox.nativeElement.value = query; this.searchBox.nativeElement.value = query;
this.onSearch(query); this.doSearch();
} }
} }
onSearch(query: string) { doSearch() {
this.search.emit(query); this.searchSubject.next(this.searchBox.nativeElement.value);
} }
focus() { focus() {

View File

@ -6,21 +6,22 @@
var SEARCH_TERMS_URL = '/generated/docs/app/search-data.json'; var SEARCH_TERMS_URL = '/generated/docs/app/search-data.json';
// NOTE: This needs to be kept in sync with `ngsw-manifest.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 = {}; var pages = {};
self.onmessage = handleMessage; self.onmessage = handleMessage;
// Create the lunr index - the docs should be an array of objects, each object containing // Create the lunr index - the docs should be an array of objects, each object containing
// the path and search terms for a page // the path and search terms for a page
function createIndex() { function createIndex(addFn) {
return lunr(/** @this */function() { return lunr(/** @this */function() {
this.ref('path'); this.ref('path');
this.field('titleWords', {boost: 50}); this.field('titleWords', {boost: 50});
this.field('members', {boost: 40}); this.field('members', {boost: 40});
this.field('keywords', {boost: 20}); this.field('keywords', {boost: 20});
addFn(this);
}); });
} }
@ -32,7 +33,7 @@ function handleMessage(message) {
switch(type) { switch(type) {
case 'load-index': case 'load-index':
makeRequest(SEARCH_TERMS_URL, function(searchInfo) { makeRequest(SEARCH_TERMS_URL, function(searchInfo) {
loadIndex(searchInfo); index = createIndex(loadIndex(searchInfo));
self.postMessage({type: type, id: id, payload: true}); self.postMessage({type: type, id: id, payload: true});
}); });
break; 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 // Create the search index from the searchInfo which contains the information about each page to be indexed
function loadIndex(searchInfo) { function loadIndex(searchInfo) {
return function(index) {
// Store the pages data to be used in mapping query results back to pages // Store the pages data to be used in mapping query results back to pages
// Add search terms from each page to the search index // Add search terms from each page to the search index
searchInfo.forEach(function(page) { searchInfo.forEach(function(page) {
index.add(page); index.add(page);
pages[page.path] = page; pages[page.path] = page;
}); });
};
} }
// Query the index and return the processed results // Query the index and return the processed results
function queryIndex(query) { 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 // 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]; });
} }