feat(aio): improve search results functionality
* Ensure that all indexed documents are displayed in the search results. (Previously the guide documents were not appearing because we only showed results that had a `name` property, rather than a `name` or `title`.) * Group the results by their containing folder (e.g. api, guide, tutorial, etc). * Hide the results when the user hits the ESC key. * Hide the results when the user clicks on a search result Closes #14852
This commit is contained in:
parent
8850098ea4
commit
6497633529
|
@ -1,9 +1,7 @@
|
|||
<md-toolbar color="primary" class="app-toolbar">
|
||||
<button *ngIf="isHamburgerVisible" class="hamburger" md-button (click)="sidenav.toggle()"><md-icon>menu</md-icon></button>
|
||||
<aio-top-menu *ngIf="isSideBySide" [nodes]="(navigationViews | async)?.TopBar" [homeImageUrl]="homeImageUrl"></aio-top-menu>
|
||||
<md-input-container >
|
||||
<input mdInput placeholder="Search" (keyup)="onSearch($event.target.value)">
|
||||
</md-input-container>
|
||||
<aio-search-box></aio-search-box>
|
||||
<span class="fill-remaining-space"></span>
|
||||
</md-toolbar>
|
||||
|
||||
|
@ -15,11 +13,7 @@
|
|||
</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-search-results></aio-search-results>
|
||||
<aio-doc-viewer [doc]="currentDocument | async"></aio-doc-viewer>
|
||||
</section>
|
||||
|
||||
|
|
|
@ -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();
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
|
|
@ -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<DocumentContents>;
|
||||
navigationViews: Observable<NavigationViews>;
|
||||
selectedNodes: Observable<NavigationNode[]>;
|
||||
searchResults: Observable<QueryResults>;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
<md-input-container >
|
||||
<input #searchBox
|
||||
mdInput
|
||||
placeholder="Search"
|
||||
(keyup)="onSearch($event.target.value, $event.which)"
|
||||
(focus)="onSearch($event.target.value)">
|
||||
</md-input-container>
|
|
@ -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<SearchBoxComponent>;
|
||||
|
||||
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');
|
||||
}));
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
<div class="search-results">
|
||||
<div class="search-area" *ngFor="let area of searchAreas | async">
|
||||
<h2>{{area.name}}</h2>
|
||||
<div class="search-page" *ngFor="let page of area.pages">
|
||||
<a href="{{ page.path }}" (click)="onResultSelected(page)">{{ page.title }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -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<SearchResultsComponent>;
|
||||
let searchService: SearchService;
|
||||
let searchResults: Subject<SearchResults>;
|
||||
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<SearchResults>;
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<SearchResult>();
|
||||
|
||||
/**
|
||||
* A mapping of the search results grouped into areas
|
||||
*/
|
||||
searchAreas = new ReplaySubject<SearchArea[]>(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;
|
||||
}
|
||||
}
|
|
@ -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<boolean>;
|
||||
private resultsSubject = new Subject<QueryResults>();
|
||||
private resultsSubject = new Subject<SearchResults>();
|
||||
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<QueryResults>;
|
||||
return this.worker.sendMessage('query-index', query) as Observable<SearchResults>;
|
||||
}).subscribe(results => this.resultsSubject.next(results));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
<doc-title class="not-found"></doc-title>
|
||||
<h3>Document not found</h3>
|
|
@ -3,6 +3,8 @@ import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
|||
export class MockLocationService {
|
||||
urlSubject = new BehaviorSubject<string>(this.initialUrl);
|
||||
currentUrl = this.urlSubject.asObservable();
|
||||
search = jasmine.createSpy('search').and.returnValue({});
|
||||
setSearch = jasmine.createSpy('setSearch');
|
||||
constructor(private initialUrl) {}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
import { Subject } from 'rxjs/Subject';
|
||||
import { SearchResults } from 'app/search/search.service';
|
||||
|
||||
export class MockSearchService {
|
||||
searchResults = new Subject<SearchResults>();
|
||||
initWorker = jasmine.createSpy('initWorker');
|
||||
loadIndex = jasmine.createSpy('loadIndex');
|
||||
search = jasmine.createSpy('search');
|
||||
hideResults = jasmine.createSpy('hideResults');
|
||||
}
|
|
@ -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({
|
||||
|
|
Loading…
Reference in New Issue