feat(aio): allow SearchService to have multiple clients (#19682)
PR Close #19682
This commit is contained in:
parent
6121083ba5
commit
c3f07b329f
@ -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">
|
||||
|
||||
|
@ -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 = {
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
19
aio/src/app/search/interfaces.ts
Normal file
19
aio/src/app/search/interfaces.ts
Normal 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[];
|
||||
}
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
@ -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>();
|
||||
|
Loading…
x
Reference in New Issue
Block a user