feat(aio): improve search results functionality

* Ensure that all indexed documents are displayed in the search results.
(Previously the guide documents were not appearing because we only showed
results that had a `name` property, rather than a `name` or `title`.)
* Group the results by their containing folder (e.g. api, guide, tutorial, etc).
* Hide the results when the user hits the ESC key.
* Hide the results when the user clicks on a search result

Closes #14852
This commit is contained in:
Peter Bacon Darwin 2017-03-13 19:58:31 +00:00 committed by Chuck Jazdzewski
parent 8850098ea4
commit 6497633529
17 changed files with 356 additions and 37 deletions

View File

@ -1,9 +1,7 @@
<md-toolbar color="primary" class="app-toolbar"> <md-toolbar color="primary" class="app-toolbar">
<button *ngIf="isHamburgerVisible" class="hamburger" md-button (click)="sidenav.toggle()"><md-icon>menu</md-icon></button> <button *ngIf="isHamburgerVisible" class="hamburger" md-button (click)="sidenav.toggle()"><md-icon>menu</md-icon></button>
<aio-top-menu *ngIf="isSideBySide" [nodes]="(navigationViews | async)?.TopBar" [homeImageUrl]="homeImageUrl"></aio-top-menu> <aio-top-menu *ngIf="isSideBySide" [nodes]="(navigationViews | async)?.TopBar" [homeImageUrl]="homeImageUrl"></aio-top-menu>
<md-input-container > <aio-search-box></aio-search-box>
<input mdInput placeholder="Search" (keyup)="onSearch($event.target.value)">
</md-input-container>
<span class="fill-remaining-space"></span> <span class="fill-remaining-space"></span>
</md-toolbar> </md-toolbar>
@ -15,11 +13,7 @@
</md-sidenav> </md-sidenav>
<section class="sidenav-content"> <section class="sidenav-content">
<div class="search-results"> <aio-search-results></aio-search-results>
<div *ngFor="let result of (searchResults | async)?.results">
<a href="{{ result.path }}">{{ result.title }}</a>
</div>
</div>
<aio-doc-viewer [doc]="currentDocument | async"></aio-doc-viewer> <aio-doc-viewer [doc]="currentDocument | async"></aio-doc-viewer>
</section> </section>

View File

@ -3,6 +3,7 @@ import { APP_BASE_HREF } from '@angular/common';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { SearchService } from 'app/search/search.service'; import { SearchService } from 'app/search/search.service';
import { MockSearchService } from 'testing/search.service';
describe('AppComponent', () => { describe('AppComponent', () => {
let component: AppComponent; let component: AppComponent;
@ -12,7 +13,8 @@ describe('AppComponent', () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ AppModule ], imports: [ AppModule ],
providers: [ providers: [
{ provide: APP_BASE_HREF, useValue: '/' } { provide: APP_BASE_HREF, useValue: '/' },
{ provide: SearchService, useClass: MockSearchService }
] ]
}); });
TestBed.compileComponents(); TestBed.compileComponents();
@ -39,25 +41,19 @@ describe('AppComponent', () => {
}); });
}); });
describe('onSearch', () => {
it('should call the search service', inject([SearchService], (search: SearchService) => {
spyOn(search, 'search');
component.onSearch('some query');
expect(search.search).toHaveBeenCalledWith('some query');
}));
});
describe('currentDocument', () => { describe('currentDocument', () => {
console.log('PENDING: AppComponent currentDocument');
}); });
describe('navigationViews', () => { describe('navigationViews', () => {
console.log('PENDING: AppComponent navigationViews');
}); });
describe('searchResults', () => { describe('initialisation', () => {
it('should initialize the search worker', inject([SearchService], (searchService: SearchService) => {
fixture.detectChanges(); // triggers ngOnInit
expect(searchService.initWorker).toHaveBeenCalled();
expect(searchService.loadIndex).toHaveBeenCalled();
}));
}); });
}); });

View File

@ -2,7 +2,7 @@ import { Component, ViewChild, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable'; import { Observable } from 'rxjs/Observable';
import { DocumentService, DocumentContents } from 'app/documents/document.service'; import { DocumentService, DocumentContents } from 'app/documents/document.service';
import { NavigationService, NavigationViews, NavigationNode } from 'app/navigation/navigation.service'; import { NavigationService, NavigationViews, NavigationNode } from 'app/navigation/navigation.service';
import { SearchService, QueryResults } from 'app/search/search.service'; import { SearchService } from 'app/search/search.service';
@Component({ @Component({
selector: 'aio-shell', selector: 'aio-shell',
@ -19,13 +19,11 @@ export class AppComponent implements OnInit {
currentDocument: Observable<DocumentContents>; currentDocument: Observable<DocumentContents>;
navigationViews: Observable<NavigationViews>; navigationViews: Observable<NavigationViews>;
selectedNodes: Observable<NavigationNode[]>; selectedNodes: Observable<NavigationNode[]>;
searchResults: Observable<QueryResults>;
constructor(documentService: DocumentService, navigationService: NavigationService, private searchService: SearchService) { constructor(documentService: DocumentService, navigationService: NavigationService, private searchService: SearchService) {
this.currentDocument = documentService.currentDocument; this.currentDocument = documentService.currentDocument;
this.navigationViews = navigationService.navigationViews; this.navigationViews = navigationService.navigationViews;
this.selectedNodes = navigationService.selectedNodes; this.selectedNodes = navigationService.selectedNodes;
this.searchResults = searchService.searchResults;
} }
ngOnInit() { ngOnInit() {
@ -38,8 +36,4 @@ export class AppComponent implements OnInit {
onResize(width) { onResize(width) {
this.isSideBySide = width > this.sideBySideWidth; this.isSideBySide = width > this.sideBySideWidth;
} }
onSearch(query: string) {
this.searchService.search(query);
}
} }

View File

@ -28,6 +28,8 @@ import { TopMenuComponent } from 'app/layout/top-menu/top-menu.component';
import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component'; import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
import { NavItemComponent } from 'app/layout/nav-item/nav-item.component'; import { NavItemComponent } from 'app/layout/nav-item/nav-item.component';
import { LinkDirective } from 'app/shared/link.directive'; import { LinkDirective } from 'app/shared/link.directive';
import { SearchResultsComponent } from './search/search-results/search-results.component';
import { SearchBoxComponent } from './search/search-box/search-box.component';
@NgModule({ @NgModule({
imports: [ imports: [
@ -47,6 +49,8 @@ import { LinkDirective } from 'app/shared/link.directive';
NavMenuComponent, NavMenuComponent,
NavItemComponent, NavItemComponent,
LinkDirective, LinkDirective,
SearchResultsComponent,
SearchBoxComponent,
], ],
providers: [ providers: [
ApiService, ApiService,

View File

@ -0,0 +1,7 @@
<md-input-container >
<input #searchBox
mdInput
placeholder="Search"
(keyup)="onSearch($event.target.value, $event.which)"
(focus)="onSearch($event.target.value)">
</md-input-container>

View File

@ -0,0 +1,70 @@
import { async, ComponentFixture, TestBed, inject } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser';
import { SearchBoxComponent } from './search-box.component';
import { SearchService } from '../search.service';
import { MockSearchService } from 'testing/search.service';
import { LocationService } from 'app/shared/location.service';
import { MockLocationService } from 'testing/location.service';
describe('SearchBoxComponent', () => {
let component: SearchBoxComponent;
let fixture: ComponentFixture<SearchBoxComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ SearchBoxComponent ],
providers: [
{ provide: SearchService, useFactory: () => new MockSearchService() },
{ provide: LocationService, useFactory: () => new MockLocationService('') }
],
schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchBoxComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
describe('initialisation', () => {
it('should get the current search query from the location service', inject([LocationService], (location: MockLocationService) => {
location.search.and.returnValue({ search: 'initial search' });
spyOn(component, 'onSearch');
component.ngOnInit();
expect(location.search).toHaveBeenCalled();
expect(component.onSearch).toHaveBeenCalledWith('initial search');
expect(component.searchBox.nativeElement.value).toEqual('initial search');
}));
});
describe('on keyup', () => {
it('should call the search service, if it is not triggered by the ESC key', inject([SearchService], (search: MockSearchService) => {
const input = fixture.debugElement.query(By.css('input'));
input.triggerEventHandler('keyup', { target: { value: 'some query' } });
expect(search.search).toHaveBeenCalledWith('some query');
}));
it('should not call the search service if it is triggered by the ESC key', inject([SearchService], (search: MockSearchService) => {
const input = fixture.debugElement.query(By.css('input'));
input.triggerEventHandler('keyup', { target: { value: 'some query' }, which: 27 });
expect(search.search).not.toHaveBeenCalled();
}));
it('should set the search part of the browser location', inject([LocationService], (location: MockLocationService) => {
const input = fixture.debugElement.query(By.css('input'));
input.triggerEventHandler('keyup', { target: { value: 'some query' } });
expect(location.setSearch).toHaveBeenCalledWith('Full Text Search', { search: 'some query' });
}));
});
describe('on focus', () => {
it('should call the search service on focus', inject([SearchService], (search: SearchService) => {
const input = fixture.debugElement.query(By.css('input'));
input.triggerEventHandler('focus', { target: { value: 'some query' } });
expect(search.search).toHaveBeenCalledWith('some query');
}));
});
});

View File

@ -0,0 +1,44 @@
import { Component, OnInit, ViewChild, ElementRef } from '@angular/core';
import { SearchService } from 'app/search/search.service';
import { LocationService } from 'app/shared/location.service';
/**
* This component provides a text box to type a search query that will be sent to the SearchService.
*
* Whatever is typed in this box will be placed in the browser address bar as `?search=...`.
*
* When you arrive at a page containing this component, it will retrieve the query from the browser
* address bar. If there is a query then this will be made.
*
* Focussing on the input box will resend whatever query is there. This can be useful if the search
* results had been hidden for some reason.
*
*/
@Component({
selector: 'aio-search-box',
templateUrl: './search-box.component.html',
styleUrls: ['./search-box.component.scss']
})
export class SearchBoxComponent implements OnInit {
@ViewChild('searchBox') searchBox: ElementRef;
constructor(private searchService: SearchService, private locationService: LocationService) { }
ngOnInit() {
const query = this.locationService.search()['search'];
if (query) {
this.searchBox.nativeElement.value = query;
this.onSearch(query);
}
}
onSearch(query: string, keyCode?: number) {
if (keyCode === 27) {
// Ignore escape key
return;
}
this.locationService.setSearch('Full Text Search', { search: query });
this.searchService.search(query);
}
}

View File

@ -0,0 +1,8 @@
<div class="search-results">
<div class="search-area" *ngFor="let area of searchAreas | async">
<h2>{{area.name}}</h2>
<div class="search-page" *ngFor="let page of area.pages">
<a href="{{ page.path }}" (click)="onResultSelected(page)">{{ page.title }}</a>
</div>
</div>
</div>

View File

@ -0,0 +1,111 @@
import { async, 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';
describe('SearchResultsComponent', () => {
let component: SearchResultsComponent;
let fixture: ComponentFixture<SearchResultsComponent>;
let searchService: SearchService;
let searchResults: Subject<SearchResults>;
let currentAreas: SearchArea[];
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ SearchResultsComponent ],
providers: [
{ provide: SearchService, useFactory: () => new MockSearchService() }
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchResultsComponent);
component = fixture.componentInstance;
searchService = fixture.debugElement.injector.get(SearchService);
searchResults = searchService.searchResults as Subject<SearchResults>;
fixture.detectChanges();
component.searchAreas.subscribe(areas => currentAreas = areas);
});
it('should map the search results into groups based on their containing folder', () => {
const results = [
{path: 'guide/a', title: 'Guide A', type: 'content', keywords: '', titleWords: '' },
{path: 'guide/b', title: 'Guide B', type: 'content', keywords: '', titleWords: '' },
{path: 'api/c', title: 'API C', type: 'class', keywords: '', titleWords: '' },
{path: 'guide/b/c', title: 'Guide B - C', type: 'content', keywords: '', titleWords: '' },
];
searchResults.next({ query: '', results: results});
expect(currentAreas).toEqual([
{ name: 'guide', pages: [
{ path: 'guide/a', title: 'Guide A', type: 'content', keywords: '', titleWords: '' },
{ path: 'guide/b', title: 'Guide B', type: 'content', keywords: '', titleWords: '' },
{ path: 'guide/b/c', title: 'Guide B - C', type: 'content', keywords: '', titleWords: '' }
] },
{ name: 'api', pages: [
{ path: 'api/c', title: 'API C', type: 'class', keywords: '', titleWords: '' }
] }
]);
});
it('should put search results with no containing folder into the default area (Other)', () => {
const results = [
{path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' }
];
searchResults.next({ query: '', results: results });
expect(currentAreas).toEqual([
{ name: 'Other', pages: [
{ path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' }
] }
]);
});
it('should emit an "resultSelected" event when a search result anchor is clicked', () => {
let selectedResult: SearchResult;
component.resultSelected.subscribe((result: SearchResult) => selectedResult = result);
const results = [
{path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' }
];
searchResults.next({ query: '', results: results });
fixture.detectChanges();
const anchor = fixture.debugElement.query(By.css('a'));
anchor.triggerEventHandler('click', {});
expect(selectedResult).toEqual({path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' });
});
it('should clear the results when a search result is clicked', () => {
const results = [
{path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' }
];
searchResults.next({ query: '', results: results });
fixture.detectChanges();
const anchor = fixture.debugElement.query(By.css('a'));
anchor.triggerEventHandler('click', {});
fixture.detectChanges();
expect(fixture.debugElement.queryAll(By.css('a'))).toEqual([]);
});
describe('hideResults', () => {
it('should clear the results', () => {
const results = [
{path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' }
];
searchResults.next({ query: '', results: results });
fixture.detectChanges();
component.hideResults();
fixture.detectChanges();
expect(fixture.debugElement.queryAll(By.css('a'))).toEqual([]);
});
});
});

View File

@ -0,0 +1,73 @@
import { Component, ChangeDetectionStrategy, EventEmitter, HostListener, OnInit, Output } from '@angular/core';
import { ReplaySubject } from 'rxjs/ReplaySubject';
import { SearchResult, SearchResults, SearchService } from '../search.service';
export interface SearchArea {
name: string;
pages: SearchResult[];
}
/**
* A component to display the search results
*/
@Component({
selector: 'aio-search-results',
templateUrl: './search-results.component.html',
styleUrls: ['./search-results.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class SearchResultsComponent implements OnInit {
readonly defaultArea = 'Other';
showResults = false;
@Output()
resultSelected = new EventEmitter<SearchResult>();
/**
* A mapping of the search results grouped into areas
*/
searchAreas = new ReplaySubject<SearchArea[]>(1);
constructor(private searchService: SearchService) {}
ngOnInit() {
this.searchService.searchResults.subscribe(search => this.searchAreas.next(this.processSearchResults(search)));
}
onResultSelected(result: SearchResult) {
this.resultSelected.emit(result);
this.hideResults();
}
@HostListener('document:keyup', ['$event.which'])
onKeyUp(keyCode: number) {
if (keyCode === 27) {
this.hideResults();
}
}
hideResults() {
this.searchAreas.next([]);
}
// Map the search results into groups by area
private processSearchResults(search: SearchResults) {
this.showResults = true;
const searchAreaMap = {};
search.results.forEach(result => {
const areaName = this.computeAreaName(result) || this.defaultArea;
const area = searchAreaMap[areaName] = searchAreaMap[areaName] || [];
area.push(result);
});
return Object.keys(searchAreaMap).map(name => ({ name, pages: searchAreaMap[name] }));
}
// Split the search result path and use the top level folder, if there is one, as the area name.
private computeAreaName(result: SearchResult) {
const [areaName, rest] = result.path.split('/', 2);
return rest && areaName;
}
}

View File

@ -11,9 +11,17 @@ import 'rxjs/add/operator/publishLast';
import 'rxjs/add/operator/concatMap'; import 'rxjs/add/operator/concatMap';
import { WebWorkerClient } from 'app/shared/web-worker'; import { WebWorkerClient } from 'app/shared/web-worker';
export interface QueryResults { export interface SearchResults {
query: string; query: string;
results: Object[]; results: SearchResult[];
}
export interface SearchResult {
path: string;
title: string;
type: string;
titleWords: string;
keywords: string;
} }
@ -21,7 +29,7 @@ export interface QueryResults {
export class SearchService { export class SearchService {
private worker: WebWorkerClient; private worker: WebWorkerClient;
private ready: Observable<boolean>; private ready: Observable<boolean>;
private resultsSubject = new Subject<QueryResults>(); private resultsSubject = new Subject<SearchResults>();
get searchResults() { return this.resultsSubject.asObservable(); } get searchResults() { return this.resultsSubject.asObservable(); }
constructor(private zone: NgZone) {} constructor(private zone: NgZone) {}
@ -38,7 +46,7 @@ export class SearchService {
search(query: string) { search(query: string) {
this.ready.concatMap(ready => { this.ready.concatMap(ready => {
return this.worker.sendMessage('query-index', query) as Observable<QueryResults>; return this.worker.sendMessage('query-index', query) as Observable<SearchResults>;
}).subscribe(results => this.resultsSubject.next(results)); }).subscribe(results => this.resultsSubject.next(results));
} }
} }

View File

@ -1,2 +0,0 @@
<doc-title class="not-found"></doc-title>
<h3>Document not found</h3>

View File

@ -3,6 +3,8 @@ import { BehaviorSubject } from 'rxjs/BehaviorSubject';
export class MockLocationService { export class MockLocationService {
urlSubject = new BehaviorSubject<string>(this.initialUrl); urlSubject = new BehaviorSubject<string>(this.initialUrl);
currentUrl = this.urlSubject.asObservable(); currentUrl = this.urlSubject.asObservable();
search = jasmine.createSpy('search').and.returnValue({});
setSearch = jasmine.createSpy('setSearch');
constructor(private initialUrl) {} constructor(private initialUrl) {}
} }

View File

@ -0,0 +1,10 @@
import { Subject } from 'rxjs/Subject';
import { SearchResults } from 'app/search/search.service';
export class MockSearchService {
searchResults = new Subject<SearchResults>();
initWorker = jasmine.createSpy('initWorker');
loadIndex = jasmine.createSpy('loadIndex');
search = jasmine.createSpy('search');
hideResults = jasmine.createSpy('hideResults');
}

View File

@ -112,7 +112,7 @@ module.exports = function generateKeywordsProcessor(log, readFilesProcessor) {
var searchData = var searchData =
filteredDocs.filter(function(page) { return page.searchTerms; }).map(function(page) { filteredDocs.filter(function(page) { return page.searchTerms; }).map(function(page) {
return Object.assign( return Object.assign(
{path: page.path, title: page.name, type: page.docType}, page.searchTerms); {path: page.path, title: page.name || page.title, type: page.docType}, page.searchTerms);
}); });
docs.push({ docs.push({