diff --git a/aio/src/app/app.component.spec.ts b/aio/src/app/app.component.spec.ts index 15638cd345..9c599367a1 100644 --- a/aio/src/app/app.component.spec.ts +++ b/aio/src/app/app.component.spec.ts @@ -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 = { diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts index 0729636a53..b950d23323 100644 --- a/aio/src/app/app.component.ts +++ b/aio/src/app/app.component.ts @@ -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; + @ViewChildren('searchBox, searchResultsView', { read: ElementRef }) searchElements: QueryList; - @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; } diff --git a/aio/src/app/search/interfaces.ts b/aio/src/app/search/interfaces.ts new file mode 100644 index 0000000000..c0c18d5c98 --- /dev/null +++ b/aio/src/app/search/interfaces.ts @@ -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[]; +} + 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 234c7b62fb..94a70f76b7 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 @@ -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'; diff --git a/aio/src/app/search/search-results/search-results.component.spec.ts b/aio/src/app/search/search-results/search-results.component.spec.ts index eb46dd85d4..335f74b9e3 100644 --- a/aio/src/app/search/search-results/search-results.component.spec.ts +++ b/aio/src/app/search/search-results/search-results.component.spec.ts @@ -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; - let searchResults: Subject; /** 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'); }); }); - }); diff --git a/aio/src/app/search/search-results/search-results.component.ts b/aio/src/app/search/search-results/search-results.component.ts index d3e7407d68..837459f344 100644 --- a/aio/src/app/search/search-results/search-results.component.ts +++ b/aio/src/app/search/search-results/search-results.component.ts @@ -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(); - /** - * 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; } diff --git a/aio/src/app/search/search.service.spec.ts b/aio/src/app/search/search.service.spec.ts index daa596580d..d8a0bb21ce 100644 --- a/aio/src/app/search/search.service.spec.ts +++ b/aio/src/app/search/search.service.spec.ts @@ -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); }); }); }); diff --git a/aio/src/app/search/search.service.ts b/aio/src/app/search/search.service.ts index 45a7fdfaa2..7febd9543e 100644 --- a/aio/src/app/search/search.service.ts +++ b/aio/src/app/search/search.service.ts @@ -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; private searchesSubject = new ReplaySubject(1); - searchResults: Observable; - + 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( Observable.timer(initDelay), - this.searchesSubject.first() + (this.searchesSubject as Observable).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('query-index', query) - ) - ); - }).publish(); + this.worker = WebWorkerClient.create(workerUrl, this.zone); + return this.worker.sendMessage('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 { + // 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('query-index', query)); } } diff --git a/aio/src/testing/search.service.ts b/aio/src/testing/search.service.ts index e004487db9..4d06f47e86 100644 --- a/aio/src/testing/search.service.ts +++ b/aio/src/testing/search.service.ts @@ -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();