diff --git a/aio/src/app/app.component.html b/aio/src/app/app.component.html index 2f3de1c54b..6f9fd7fd11 100644 --- a/aio/src/app/app.component.html +++ b/aio/src/app/app.component.html @@ -1,9 +1,7 @@ - - - + @@ -15,11 +13,7 @@
-
-
- {{ result.title }} -
-
+
diff --git a/aio/src/app/app.component.spec.ts b/aio/src/app/app.component.spec.ts index 8ebac2296a..f477958bbd 100644 --- a/aio/src/app/app.component.spec.ts +++ b/aio/src/app/app.component.spec.ts @@ -3,6 +3,7 @@ import { APP_BASE_HREF } from '@angular/common'; import { AppComponent } from './app.component'; import { AppModule } from './app.module'; import { SearchService } from 'app/search/search.service'; +import { MockSearchService } from 'testing/search.service'; describe('AppComponent', () => { let component: AppComponent; @@ -12,7 +13,8 @@ describe('AppComponent', () => { TestBed.configureTestingModule({ imports: [ AppModule ], providers: [ - { provide: APP_BASE_HREF, useValue: '/' } + { provide: APP_BASE_HREF, useValue: '/' }, + { provide: SearchService, useClass: MockSearchService } ] }); 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', () => { - + console.log('PENDING: AppComponent currentDocument'); }); 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(); + })); }); - - }); diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts index b24797baa0..f21a1cd562 100644 --- a/aio/src/app/app.component.ts +++ b/aio/src/app/app.component.ts @@ -2,7 +2,7 @@ import { Component, ViewChild, OnInit } from '@angular/core'; import { Observable } from 'rxjs/Observable'; import { DocumentService, DocumentContents } from 'app/documents/document.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({ selector: 'aio-shell', @@ -19,13 +19,11 @@ export class AppComponent implements OnInit { currentDocument: Observable; navigationViews: Observable; selectedNodes: Observable; - searchResults: Observable; constructor(documentService: DocumentService, navigationService: NavigationService, private searchService: SearchService) { this.currentDocument = documentService.currentDocument; this.navigationViews = navigationService.navigationViews; this.selectedNodes = navigationService.selectedNodes; - this.searchResults = searchService.searchResults; } ngOnInit() { @@ -38,8 +36,4 @@ export class AppComponent implements OnInit { onResize(width) { this.isSideBySide = width > this.sideBySideWidth; } - - onSearch(query: string) { - this.searchService.search(query); - } } diff --git a/aio/src/app/app.module.ts b/aio/src/app/app.module.ts index 5505696724..3037a46534 100644 --- a/aio/src/app/app.module.ts +++ b/aio/src/app/app.module.ts @@ -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 { NavItemComponent } from 'app/layout/nav-item/nav-item.component'; 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({ imports: [ @@ -47,6 +49,8 @@ import { LinkDirective } from 'app/shared/link.directive'; NavMenuComponent, NavItemComponent, LinkDirective, + SearchResultsComponent, + SearchBoxComponent, ], providers: [ ApiService, diff --git a/aio/src/app/search/search-box/search-box.component.html b/aio/src/app/search/search-box/search-box.component.html new file mode 100644 index 0000000000..5bdf87ca98 --- /dev/null +++ b/aio/src/app/search/search-box/search-box.component.html @@ -0,0 +1,7 @@ + + + diff --git a/aio/src/app/search/search-box/search-box.component.scss b/aio/src/app/search/search-box/search-box.component.scss new file mode 100644 index 0000000000..e69de29bb2 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 new file mode 100644 index 0000000000..bfd9116d04 --- /dev/null +++ b/aio/src/app/search/search-box/search-box.component.spec.ts @@ -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; + + 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'); + })); + }); +}); diff --git a/aio/src/app/search/search-box/search-box.component.ts b/aio/src/app/search/search-box/search-box.component.ts new file mode 100644 index 0000000000..811019977b --- /dev/null +++ b/aio/src/app/search/search-box/search-box.component.ts @@ -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); + } +} diff --git a/aio/src/app/search/search-results/search-results.component.html b/aio/src/app/search/search-results/search-results.component.html new file mode 100644 index 0000000000..0b9039853c --- /dev/null +++ b/aio/src/app/search/search-results/search-results.component.html @@ -0,0 +1,8 @@ +
+
+

{{area.name}}

+ +
+
\ No newline at end of file diff --git a/aio/src/app/search/search-results/search-results.component.scss b/aio/src/app/search/search-results/search-results.component.scss new file mode 100644 index 0000000000..e69de29bb2 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 new file mode 100644 index 0000000000..14b37e9d36 --- /dev/null +++ b/aio/src/app/search/search-results/search-results.component.spec.ts @@ -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; + let searchService: SearchService; + let searchResults: Subject; + 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; + 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([]); + }); + }); +}); diff --git a/aio/src/app/search/search-results/search-results.component.ts b/aio/src/app/search/search-results/search-results.component.ts new file mode 100644 index 0000000000..f274ad6395 --- /dev/null +++ b/aio/src/app/search/search-results/search-results.component.ts @@ -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(); + + /** + * A mapping of the search results grouped into areas + */ + searchAreas = new ReplaySubject(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; + } +} diff --git a/aio/src/app/search/search.service.ts b/aio/src/app/search/search.service.ts index 391b719465..7a34109c24 100644 --- a/aio/src/app/search/search.service.ts +++ b/aio/src/app/search/search.service.ts @@ -11,9 +11,17 @@ import 'rxjs/add/operator/publishLast'; import 'rxjs/add/operator/concatMap'; import { WebWorkerClient } from 'app/shared/web-worker'; -export interface QueryResults { +export interface SearchResults { 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 { private worker: WebWorkerClient; private ready: Observable; - private resultsSubject = new Subject(); + private resultsSubject = new Subject(); get searchResults() { return this.resultsSubject.asObservable(); } constructor(private zone: NgZone) {} @@ -38,7 +46,7 @@ export class SearchService { search(query: string) { this.ready.concatMap(ready => { - return this.worker.sendMessage('query-index', query) as Observable; + return this.worker.sendMessage('query-index', query) as Observable; }).subscribe(results => this.resultsSubject.next(results)); } } diff --git a/aio/src/content/not-found.html b/aio/src/content/not-found.html deleted file mode 100644 index 8eaffd716d..0000000000 --- a/aio/src/content/not-found.html +++ /dev/null @@ -1,2 +0,0 @@ - -

Document not found

diff --git a/aio/src/testing/location.service.ts b/aio/src/testing/location.service.ts index 2d3ae9d64d..918b79bc1b 100644 --- a/aio/src/testing/location.service.ts +++ b/aio/src/testing/location.service.ts @@ -3,6 +3,8 @@ import { BehaviorSubject } from 'rxjs/BehaviorSubject'; export class MockLocationService { urlSubject = new BehaviorSubject(this.initialUrl); currentUrl = this.urlSubject.asObservable(); + search = jasmine.createSpy('search').and.returnValue({}); + setSearch = jasmine.createSpy('setSearch'); constructor(private initialUrl) {} } diff --git a/aio/src/testing/search.service.ts b/aio/src/testing/search.service.ts new file mode 100644 index 0000000000..ed1eb96ea4 --- /dev/null +++ b/aio/src/testing/search.service.ts @@ -0,0 +1,10 @@ +import { Subject } from 'rxjs/Subject'; +import { SearchResults } from 'app/search/search.service'; + +export class MockSearchService { + searchResults = new Subject(); + initWorker = jasmine.createSpy('initWorker'); + loadIndex = jasmine.createSpy('loadIndex'); + search = jasmine.createSpy('search'); + hideResults = jasmine.createSpy('hideResults'); +} diff --git a/aio/transforms/angular.io-package/processors/generateKeywords.js b/aio/transforms/angular.io-package/processors/generateKeywords.js index 0e70499928..4af71f681a 100644 --- a/aio/transforms/angular.io-package/processors/generateKeywords.js +++ b/aio/transforms/angular.io-package/processors/generateKeywords.js @@ -112,7 +112,7 @@ module.exports = function generateKeywordsProcessor(log, readFilesProcessor) { var searchData = filteredDocs.filter(function(page) { return page.searchTerms; }).map(function(page) { 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({