feat(aio): add search service and search UI

This commit is contained in:
Peter Bacon Darwin 2017-03-01 23:05:16 +00:00 committed by Igor Minar
parent 71e22b8d11
commit dca83ec738
8 changed files with 149 additions and 127 deletions

View File

@ -10,6 +10,7 @@
"assets": [
"assets",
"content",
"app/search/search-worker.js",
"favicon.ico"
],
"index": "index.html",

View File

@ -2,7 +2,7 @@
<button *ngIf="isHamburgerVisible" class="hamburger" md-button (click)="sidenav.toggle()"><md-icon>menu</md-icon></button>
<aio-top-menu [nodes]="(navigationViews | async)?.TopBar"></aio-top-menu>
<md-input-container >
<input #search mdInput placeholder="Search">
<input mdInput placeholder="Search" (keyup)="onSearch($event)">
</md-input-container>
<span class="fill-remaining-space"></span>
</md-toolbar>
@ -10,11 +10,15 @@
<md-sidenav-container class="sidenav-container" (window:resize)="onResize($event.target.innerWidth)">
<md-sidenav #sidenav class="sidenav" [opened]="isSideBySide" [mode] = "this.isSideBySide ? 'side' : 'over'">
<aio-nav-menu [nodes]="(navigationViews | async)?.SideNav"></aio-nav-menu>
</md-sidenav>
<section class="sidenav-content">
<div class="search-results">
<div *ngFor="let result of (searchResults | async)?.results">
<a href="{{ result.path }}">{{ result.title }}</a>
</div>
</div>
<aio-doc-viewer [doc]="currentDocument | async"></aio-doc-viewer>
</section>

View File

@ -2,6 +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 } from 'app/navigation/navigation.service';
import { SearchService, QueryResults } from 'app/search/search.service';
@Component({
selector: 'aio-shell',
@ -91,10 +92,12 @@ export class AppComponent implements OnInit {
currentDocument: Observable<DocumentContents>;
navigationViews: Observable<NavigationViews>;
searchResults: Observable<QueryResults>;
constructor(documentService: DocumentService, navigationService: NavigationService) {
constructor(documentService: DocumentService, navigationService: NavigationService, private searchService: SearchService) {
this.currentDocument = documentService.currentDocument;
this.navigationViews = navigationService.navigationViews;
this.searchResults = searchService.searchResults;
}
ngOnInit() {
@ -104,4 +107,10 @@ export class AppComponent implements OnInit {
onResize(width) {
this.isSideBySide = width > this.sideBySideWidth;
}
onSearch(event: KeyboardEvent) {
const query = (event.target as HTMLInputElement).value;
console.log(query);
this.searchService.search(query);
}
}

View File

@ -22,6 +22,7 @@ import { Logger } from 'app/shared/logger.service';
import { LocationService } from 'app/shared/location.service';
import { NavigationService } from 'app/navigation/navigation.service';
import { DocumentService } from 'app/documents/document.service';
import { SearchService } from 'app/search/search.service';
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';
@ -54,6 +55,7 @@ import { LinkDirective } from 'app/shared/link.directive';
LocationService,
NavigationService,
DocumentService,
SearchService,
Platform
],
entryComponents: [ embeddedComponents ],

View File

@ -1,111 +0,0 @@
/*
Copyright 2016 Google Inc. All Rights Reserved.
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} from '@angular/core';
import {Observable} from 'rxjs/Observable';
import {Subscriber} from 'rxjs/Subscriber';
import 'rxjs/add/observable/fromPromise';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/switchMap';
/**
* We will use this client from a component with something like...
*
* ngOnInit() {
* const searchWorker = new SearchWorkerClient('app/search-worker.js', this.zone);
* this.indexReady = searchWorker.ready;
* this.searchInput = new FormControl();
* this.searchResult$ = this.searchInput.valueChanges
* .switchMap((searchText: string) => searchWorker.search(searchText));
* }
*
* TODO(petebd): do we need a fallback for browsers that do not support service workers?
*/
type QueryResults = Object[];
export interface ResultsReadyMessage {
type: 'query-results';
id: number;
query: string;
results: QueryResults;
}
export class SearchWorkerClient {
ready: Promise<boolean>;
worker: Worker;
private _queryId = 0;
constructor(url: string, private zone: NgZone) {
this.worker = new Worker(url);
this.ready = this._waitForIndex(this.worker);
}
search(query: string) {
return Observable.fromPromise(this.ready)
.switchMap(() => this._createQuery(query));
}
private _waitForIndex(worker: Worker) {
return new Promise((resolve, reject) => {
worker.onmessage = (e) => {
if (e.data.type === 'index-ready') {
resolve(true);
cleanup();
}
};
worker.onerror = (e) => {
reject(e);
cleanup();
};
});
function cleanup() {
worker.onmessage = null;
worker.onerror = null;
}
}
private _createQuery(query: string) {
return new Observable<QueryResults>((subscriber: Subscriber<QueryResults>) => {
// get a new identifier for this query that we can match to results
const id = this._queryId++;
const handleMessage = (message: MessageEvent) => {
const {type, id: queryId, results} = message.data as ResultsReadyMessage;
if (type === 'query-results' && id === queryId) {
this.zone.run(() => {
subscriber.next(results);
subscriber.complete();
});
}
};
const handleError = (error: ErrorEvent) => {
this.zone.run(() => {
subscriber.error(error);
});
};
// Wire up the event listeners for this query
this.worker.addEventListener('message', handleMessage);
this.worker.addEventListener('error', handleError);
// Post the query to the web worker
this.worker.postMessage({query, id});
// At completion/error unwire the event listeners
return () => {
this.worker.removeEventListener('message', handleMessage);
this.worker.removeEventListener('error', handleError);
};
});
}
}

View File

@ -3,13 +3,13 @@
/* eslint-env worker */
/* global importScripts, lunr */
var SEARCH_TERMS_URL = '/content/docs/app/search-data.json';
importScripts('https://unpkg.com/lunr@0.7.2');
var index = createIndex();
var pages = {};
makeRequest('search-data.json', loadIndex);
self.onmessage = handleMessage;
// Create the lunr index - the docs should be an array of objects, each object containing
@ -23,9 +23,38 @@ function createIndex() {
});
}
// The worker receives a message to load the index and to query the index
function handleMessage(message) {
var type = message.data.type;
var id = message.data.id;
var payload = message.data.payload;
switch(type) {
case 'load-index':
makeRequest(SEARCH_TERMS_URL, function(searchInfo) {
loadIndex(searchInfo);
self.postMessage({type: type, id: id, payload: true});
});
break;
case 'query-index':
self.postMessage({type: type, id: id, payload: {query: payload, results: queryIndex(payload)}});
break;
default:
self.postMessage({type: type, id: id, payload: {error: 'invalid message type'}})
}
}
// Use XHR to make a request to the server
function makeRequest(url, callback) {
// The JSON file that is loaded should be an array of SearchTerms:
//
// export interface SearchTerms {
// path: string;
// type: string,
// titleWords: string;
// keyWords: string;
// }
var searchDataRequest = new XMLHttpRequest();
searchDataRequest.onload = function() {
callback(JSON.parse(this.responseText));
@ -43,19 +72,8 @@ function loadIndex(searchInfo) {
index.add(page);
pages[page.path] = page;
});
self.postMessage({type: 'index-ready'});
}
// The worker receives a message everytime the web app wants to query the index
function handleMessage(message) {
var id = message.data.id;
var query = message.data.query;
var results = queryIndex(query);
self.postMessage({type: 'query-results', id: id, query: query, results: results});
}
// Query the index and return the processed results
function queryIndex(query) {
// Only return the array of paths to pages

View File

@ -0,0 +1,40 @@
/*
Copyright 2016 Google Inc. All Rights Reserved.
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 } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import 'rxjs/add/operator/publishLast';
import 'rxjs/add/operator/concatMap';
import { WebWorkerClient } from 'app/shared/web-worker';
export interface QueryResults {
query: string;
results: Object[];
}
const SEARCH_WORKER_URL = 'app/search/search-worker.js';
@Injectable()
export class SearchService {
private worker: WebWorkerClient;
private ready: Observable<boolean>;
private resultsSubject = new Subject<QueryResults>();
get searchResults() { return this.resultsSubject.asObservable(); }
constructor(private zone: NgZone) {
this.worker = new WebWorkerClient(SEARCH_WORKER_URL, zone);
const ready = this.ready = this.worker.sendMessage<boolean>('load-index').publishLast();
// trigger the index to be loaded immediately
ready.connect();
}
search(query: string) {
this.ready.concatMap(ready => {
return this.worker.sendMessage('query-index', query) as Observable<QueryResults>;
}).subscribe(results => this.resultsSubject.next(results));
}
}

View File

@ -0,0 +1,59 @@
/*
Copyright 2016 Google Inc. All Rights Reserved.
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} from '@angular/core';
import {Observable} from 'rxjs/Observable';
export interface WebWorkerMessage {
type: string;
payload: any;
id?: number;
}
export class WebWorkerClient {
worker: Worker;
private messageId = 0;
constructor(url: string, private zone: NgZone) {
this.worker = new Worker(url);
}
sendMessage<T>(type: string, payload?: any): Observable<T> {
return new Observable<T>(subscriber => {
const id = this.messageId++;
const handleMessage = (response: MessageEvent) => {
const {type: responseType, id: responseId, payload: responsePayload} = response.data as WebWorkerMessage;
if (type === responseType && id === responseId) {
this.zone.run(() => {
subscriber.next(responsePayload);
subscriber.complete();
});
}
};
const handleError = (error: ErrorEvent) => {
// Since we do not check type and id any error from the webworker will kill all subscribers
this.zone.run(() => subscriber.error(error));
};
// Wire up the event listeners for this message
this.worker.addEventListener('message', handleMessage);
this.worker.addEventListener('error', handleError);
// Post the message to the web worker
this.worker.postMessage({type, id, payload});
// At completion/error unwire the event listeners
return () => {
this.worker.removeEventListener('message', handleMessage);
this.worker.removeEventListener('error', handleError);
};
});
}
}