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">
|
<md-toolbar color="primary" class="app-toolbar">
|
||||||
<button *ngIf="isHamburgerVisible" class="hamburger" md-button (click)="sidenav.toggle()"><md-icon>menu</md-icon></button>
|
<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>
|
<aio-top-menu *ngIf="isSideBySide" [nodes]="(navigationViews | async)?.TopBar" [homeImageUrl]="homeImageUrl"></aio-top-menu>
|
||||||
<md-input-container >
|
<aio-search-box></aio-search-box>
|
||||||
<input mdInput placeholder="Search" (keyup)="onSearch($event.target.value)">
|
|
||||||
</md-input-container>
|
|
||||||
<span class="fill-remaining-space"></span>
|
<span class="fill-remaining-space"></span>
|
||||||
</md-toolbar>
|
</md-toolbar>
|
||||||
|
|
||||||
|
@ -15,11 +13,7 @@
|
||||||
</md-sidenav>
|
</md-sidenav>
|
||||||
|
|
||||||
<section class="sidenav-content">
|
<section class="sidenav-content">
|
||||||
<div class="search-results">
|
<aio-search-results></aio-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>
|
<aio-doc-viewer [doc]="currentDocument | async"></aio-doc-viewer>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { APP_BASE_HREF } from '@angular/common';
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { SearchService } from 'app/search/search.service';
|
import { SearchService } from 'app/search/search.service';
|
||||||
|
import { MockSearchService } from 'testing/search.service';
|
||||||
|
|
||||||
describe('AppComponent', () => {
|
describe('AppComponent', () => {
|
||||||
let component: AppComponent;
|
let component: AppComponent;
|
||||||
|
@ -12,7 +13,8 @@ describe('AppComponent', () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [ AppModule ],
|
imports: [ AppModule ],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_BASE_HREF, useValue: '/' }
|
{ provide: APP_BASE_HREF, useValue: '/' },
|
||||||
|
{ provide: SearchService, useClass: MockSearchService }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
TestBed.compileComponents();
|
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', () => {
|
describe('currentDocument', () => {
|
||||||
|
console.log('PENDING: AppComponent currentDocument');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('navigationViews', () => {
|
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 { Observable } from 'rxjs/Observable';
|
||||||
import { DocumentService, DocumentContents } from 'app/documents/document.service';
|
import { DocumentService, DocumentContents } from 'app/documents/document.service';
|
||||||
import { NavigationService, NavigationViews, NavigationNode } from 'app/navigation/navigation.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({
|
@Component({
|
||||||
selector: 'aio-shell',
|
selector: 'aio-shell',
|
||||||
|
@ -19,13 +19,11 @@ export class AppComponent implements OnInit {
|
||||||
currentDocument: Observable<DocumentContents>;
|
currentDocument: Observable<DocumentContents>;
|
||||||
navigationViews: Observable<NavigationViews>;
|
navigationViews: Observable<NavigationViews>;
|
||||||
selectedNodes: Observable<NavigationNode[]>;
|
selectedNodes: Observable<NavigationNode[]>;
|
||||||
searchResults: Observable<QueryResults>;
|
|
||||||
|
|
||||||
constructor(documentService: DocumentService, navigationService: NavigationService, private searchService: SearchService) {
|
constructor(documentService: DocumentService, navigationService: NavigationService, private searchService: SearchService) {
|
||||||
this.currentDocument = documentService.currentDocument;
|
this.currentDocument = documentService.currentDocument;
|
||||||
this.navigationViews = navigationService.navigationViews;
|
this.navigationViews = navigationService.navigationViews;
|
||||||
this.selectedNodes = navigationService.selectedNodes;
|
this.selectedNodes = navigationService.selectedNodes;
|
||||||
this.searchResults = searchService.searchResults;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
@ -38,8 +36,4 @@ export class AppComponent implements OnInit {
|
||||||
onResize(width) {
|
onResize(width) {
|
||||||
this.isSideBySide = width > this.sideBySideWidth;
|
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 { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
|
||||||
import { NavItemComponent } from 'app/layout/nav-item/nav-item.component';
|
import { NavItemComponent } from 'app/layout/nav-item/nav-item.component';
|
||||||
import { LinkDirective } from 'app/shared/link.directive';
|
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({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
|
@ -47,6 +49,8 @@ import { LinkDirective } from 'app/shared/link.directive';
|
||||||
NavMenuComponent,
|
NavMenuComponent,
|
||||||
NavItemComponent,
|
NavItemComponent,
|
||||||
LinkDirective,
|
LinkDirective,
|
||||||
|
SearchResultsComponent,
|
||||||
|
SearchBoxComponent,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
ApiService,
|
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 'rxjs/add/operator/concatMap';
|
||||||
import { WebWorkerClient } from 'app/shared/web-worker';
|
import { WebWorkerClient } from 'app/shared/web-worker';
|
||||||
|
|
||||||
export interface QueryResults {
|
export interface SearchResults {
|
||||||
query: string;
|
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 {
|
export class SearchService {
|
||||||
private worker: WebWorkerClient;
|
private worker: WebWorkerClient;
|
||||||
private ready: Observable<boolean>;
|
private ready: Observable<boolean>;
|
||||||
private resultsSubject = new Subject<QueryResults>();
|
private resultsSubject = new Subject<SearchResults>();
|
||||||
get searchResults() { return this.resultsSubject.asObservable(); }
|
get searchResults() { return this.resultsSubject.asObservable(); }
|
||||||
|
|
||||||
constructor(private zone: NgZone) {}
|
constructor(private zone: NgZone) {}
|
||||||
|
@ -38,7 +46,7 @@ export class SearchService {
|
||||||
|
|
||||||
search(query: string) {
|
search(query: string) {
|
||||||
this.ready.concatMap(ready => {
|
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));
|
}).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 {
|
export class MockLocationService {
|
||||||
urlSubject = new BehaviorSubject<string>(this.initialUrl);
|
urlSubject = new BehaviorSubject<string>(this.initialUrl);
|
||||||
currentUrl = this.urlSubject.asObservable();
|
currentUrl = this.urlSubject.asObservable();
|
||||||
|
search = jasmine.createSpy('search').and.returnValue({});
|
||||||
|
setSearch = jasmine.createSpy('setSearch');
|
||||||
constructor(private initialUrl) {}
|
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 =
|
var searchData =
|
||||||
filteredDocs.filter(function(page) { return page.searchTerms; }).map(function(page) {
|
filteredDocs.filter(function(page) { return page.searchTerms; }).map(function(page) {
|
||||||
return Object.assign(
|
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({
|
docs.push({
|
||||||
|
|
Loading…
Reference in New Issue