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 @@
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 @@
+
\ 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({