feat(aio): allow SearchService to have multiple clients (#19682)

PR Close #19682
This commit is contained in:
Peter Bacon Darwin 2017-10-11 22:04:50 +01:00 committed by Tobias Bosch
parent 6121083ba5
commit c3f07b329f
10 changed files with 99 additions and 121 deletions

View File

@ -13,7 +13,7 @@
<aio-top-menu *ngIf="isSideBySide" [nodes]="topMenuNodes"></aio-top-menu>
<aio-search-box class="search-container" #searchBox (onSearch)="doSearch($event)" (onFocus)="doSearch($event)"></aio-search-box>
</md-toolbar>
<aio-search-results #searchResults *ngIf="showSearchResults" (resultSelected)="hideSearchResults()"></aio-search-results>
<aio-search-results #searchResultsView *ngIf="showSearchResults" [searchResults]="searchResults | async" (resultSelected)="hideSearchResults()"></aio-search-results>
<md-sidenav-container class="sidenav-container" [class.starting]="isStarting" [class.has-floating-toc]="hasFloatingToc" role="main">

View File

@ -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 = {

View File

@ -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<SearchResults>;
@ViewChildren('searchBox, searchResultsView', { read: ElementRef })
searchElements: QueryList<ElementRef>;
@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;
}

View File

@ -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[];
}

View File

@ -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';

View File

@ -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<SearchResultsComponent>;
let searchResults: Subject<SearchResults>;
/** 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');
});
});
});

View File

@ -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<SearchResult>();
/**
* 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;
}

View File

@ -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);
});
});
});

View File

@ -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<boolean>;
private searchesSubject = new ReplaySubject<string>(1);
searchResults: Observable<SearchResults>;
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<any>(
Observable.timer(initDelay),
this.searchesSubject.first()
(this.searchesSubject as Observable<string>).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<SearchResults>('query-index', query)
)
);
}).publish();
this.worker = WebWorkerClient.create(workerUrl, this.zone);
return this.worker.sendMessage<boolean>('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<SearchResults> {
// 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<SearchResults>('query-index', query));
}
}

View File

@ -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<SearchResults>();