refactor(aio): centralise search ui management
Previously the logic for deciding when to display the search result was spread between different parts of the application and used non-intuitive logic such as sending a blank results set to the searchResults. This commit moves the management of displaying the search results (and also setting focus of the search input box) to the AppComponent. This makes it easier to understand what happens and why; but also allows the search UI components to be more easily reused (such as embedding them in the 404 page).
This commit is contained in:
parent
7a759df49e
commit
98e31290a7
|
@ -5,10 +5,10 @@
|
||||||
</button>
|
</button>
|
||||||
<a class="nav-link home" href="/"><img src="{{ homeImageUrl }}" title="Home" alt="Home"></a>
|
<a class="nav-link home" href="/"><img src="{{ homeImageUrl }}" title="Home" alt="Home"></a>
|
||||||
<aio-top-menu *ngIf="isSideBySide" [nodes]="topMenuNodes"></aio-top-menu>
|
<aio-top-menu *ngIf="isSideBySide" [nodes]="topMenuNodes"></aio-top-menu>
|
||||||
<aio-search-box class="search-container" #searchBox></aio-search-box>
|
<aio-search-box class="search-container" #searchBox (search)="doSearch($event)"></aio-search-box>
|
||||||
<span class="fill-remaining-space"></span>
|
<span class="fill-remaining-space"></span>
|
||||||
</md-toolbar>
|
</md-toolbar>
|
||||||
<aio-search-results #searchResults></aio-search-results>
|
<aio-search-results #searchResults *ngIf="showSearchResults" (resultSelected)="hideSearchResults()"></aio-search-results>
|
||||||
|
|
||||||
<md-sidenav-container class="sidenav-container" [class.starting]="isStarting" role="main">
|
<md-sidenav-container class="sidenav-container" [class.starting]="isStarting" role="main">
|
||||||
|
|
||||||
|
|
|
@ -421,27 +421,6 @@ describe('AppComponent', () => {
|
||||||
imageElement.click();
|
imageElement.click();
|
||||||
expect(location.handleAnchorClick).not.toHaveBeenCalled();
|
expect(location.handleAnchorClick).not.toHaveBeenCalled();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should intercept clicks not on the search elements and hide the search results', () => {
|
|
||||||
const searchResults: SearchResultsComponent = fixture.debugElement.query(By.directive(SearchResultsComponent)).componentInstance;
|
|
||||||
spyOn(searchResults, 'hideResults');
|
|
||||||
// docViewer is a commonly-clicked, non-search element
|
|
||||||
docViewer.click();
|
|
||||||
expect(searchResults.hideResults).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not intercept clicks on any of the search elements', () => {
|
|
||||||
const searchResults = fixture.debugElement.query(By.directive(SearchResultsComponent));
|
|
||||||
const searchResultsComponent: SearchResultsComponent = searchResults.componentInstance;
|
|
||||||
const searchBox = fixture.debugElement.query(By.directive(SearchBoxComponent));
|
|
||||||
spyOn(searchResultsComponent, 'hideResults');
|
|
||||||
|
|
||||||
searchResults.nativeElement.click();
|
|
||||||
expect(searchResultsComponent.hideResults).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
searchBox.nativeElement.click();
|
|
||||||
expect(searchResultsComponent.hideResults).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('aio-toc', () => {
|
describe('aio-toc', () => {
|
||||||
|
@ -456,7 +435,7 @@ describe('AppComponent', () => {
|
||||||
|
|
||||||
it('should have a non-embedded `<aio-toc>` element', () => {
|
it('should have a non-embedded `<aio-toc>` element', () => {
|
||||||
expect(tocDebugElement).toBeDefined();
|
expect(tocDebugElement).toBeDefined();
|
||||||
expect(tocDebugElement.classes.embedded).toBeFalsy();
|
expect(tocDebugElement.classes['embedded']).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update the TOC container\'s `maxHeight` based on `tocMaxHeight`', () => {
|
it('should update the TOC container\'s `maxHeight` based on `tocMaxHeight`', () => {
|
||||||
|
@ -476,6 +455,84 @@ describe('AppComponent', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('search', () => {
|
||||||
|
describe('initialization', () => {
|
||||||
|
it('should initialize the search worker', inject([SearchService], (searchService: SearchService) => {
|
||||||
|
fixture.detectChanges(); // triggers ngOnInit
|
||||||
|
expect(searchService.initWorker).toHaveBeenCalled();
|
||||||
|
expect(searchService.loadIndex).toHaveBeenCalled();
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('click handling', () => {
|
||||||
|
it('should intercept clicks not on the search elements and hide the search results', () => {
|
||||||
|
component.showSearchResults = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
// docViewer is a commonly-clicked, non-search element
|
||||||
|
docViewer.click();
|
||||||
|
expect(component.showSearchResults).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not intercept clicks on the searchResults', () => {
|
||||||
|
component.showSearchResults = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const searchResults = fixture.debugElement.query(By.directive(SearchResultsComponent));
|
||||||
|
searchResults.nativeElement.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.showSearchResults).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not intercept clicks om the searchBox', () => {
|
||||||
|
component.showSearchResults = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const searchBox = fixture.debugElement.query(By.directive(SearchBoxComponent));
|
||||||
|
searchBox.nativeElement.click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(component.showSearchResults).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('keyup handling', () => {
|
||||||
|
it('should grab focus when the / key is pressed', () => {
|
||||||
|
const searchBox: SearchBoxComponent = fixture.debugElement.query(By.directive(SearchBoxComponent)).componentInstance;
|
||||||
|
spyOn(searchBox, 'focus');
|
||||||
|
window.document.dispatchEvent(new KeyboardEvent('keyup', { 'key': '/' }));
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(searchBox.focus).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('showing search results', () => {
|
||||||
|
it('should not display search results when query is empty', () => {
|
||||||
|
const searchService: MockSearchService = TestBed.get(SearchService);
|
||||||
|
searchService.searchResults.next({ query: '', results: [] });
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(component.showSearchResults).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should hide the results when a search result is selected', () => {
|
||||||
|
const searchService: MockSearchService = TestBed.get(SearchService);
|
||||||
|
|
||||||
|
const results = [
|
||||||
|
{ path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' }
|
||||||
|
];
|
||||||
|
|
||||||
|
searchService.searchResults.next({ query: 'something', results: results });
|
||||||
|
component.showSearchResults = true;
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const searchResultsComponent = fixture.debugElement.query(By.directive(SearchResultsComponent));
|
||||||
|
searchResultsComponent.triggerEventHandler('resultSelected', {});
|
||||||
|
fixture.detectChanges();
|
||||||
|
expect(component.showSearchResults).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
//// test helpers ////
|
//// test helpers ////
|
||||||
|
|
|
@ -9,6 +9,8 @@ import { LocationService } from 'app/shared/location.service';
|
||||||
import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
|
import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
|
||||||
import { ScrollService } from 'app/shared/scroll.service';
|
import { ScrollService } from 'app/shared/scroll.service';
|
||||||
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
|
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
|
||||||
|
import { SearchBoxComponent } from 'app/search/search-box/search-box.component';
|
||||||
|
import { SearchService } from 'app/search/search.service';
|
||||||
import { SwUpdateNotificationsService } from 'app/sw-updates/sw-update-notifications.service';
|
import { SwUpdateNotificationsService } from 'app/sw-updates/sw-update-notifications.service';
|
||||||
|
|
||||||
const sideNavView = 'SideNav';
|
const sideNavView = 'SideNav';
|
||||||
|
@ -74,11 +76,14 @@ export class AppComponent implements OnInit {
|
||||||
@ViewChild(DocViewerComponent, { read: ElementRef })
|
@ViewChild(DocViewerComponent, { read: ElementRef })
|
||||||
docViewer: ElementRef;
|
docViewer: ElementRef;
|
||||||
|
|
||||||
|
// Search related properties
|
||||||
|
showSearchResults = false;
|
||||||
@ViewChildren('searchBox, searchResults', { read: ElementRef })
|
@ViewChildren('searchBox, searchResults', { read: ElementRef })
|
||||||
searchElements: QueryList<ElementRef>;
|
searchElements: QueryList<ElementRef>;
|
||||||
|
|
||||||
@ViewChild(SearchResultsComponent)
|
@ViewChild(SearchResultsComponent)
|
||||||
searchResults: SearchResultsComponent;
|
searchResults: SearchResultsComponent;
|
||||||
|
@ViewChild(SearchBoxComponent)
|
||||||
|
searchBox: SearchBoxComponent;
|
||||||
|
|
||||||
@ViewChild(MdSidenav)
|
@ViewChild(MdSidenav)
|
||||||
sidenav: MdSidenav;
|
sidenav: MdSidenav;
|
||||||
|
@ -89,10 +94,14 @@ export class AppComponent implements OnInit {
|
||||||
private locationService: LocationService,
|
private locationService: LocationService,
|
||||||
private navigationService: NavigationService,
|
private navigationService: NavigationService,
|
||||||
private scrollService: ScrollService,
|
private scrollService: ScrollService,
|
||||||
|
private searchService: SearchService,
|
||||||
private swUpdateNotifications: SwUpdateNotificationsService
|
private swUpdateNotifications: SwUpdateNotificationsService
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
this.searchService.initWorker('app/search/search-worker.js');
|
||||||
|
this.searchService.loadIndex();
|
||||||
|
|
||||||
this.onResize(window.innerWidth);
|
this.onResize(window.innerWidth);
|
||||||
|
|
||||||
/* No need to unsubscribe because this root component never dies */
|
/* No need to unsubscribe because this root component never dies */
|
||||||
|
@ -170,14 +179,12 @@ export class AppComponent implements OnInit {
|
||||||
@HostListener('click', ['$event.target', '$event.button', '$event.ctrlKey', '$event.metaKey', '$event.altKey'])
|
@HostListener('click', ['$event.target', '$event.button', '$event.ctrlKey', '$event.metaKey', '$event.altKey'])
|
||||||
onClick(eventTarget: HTMLElement, button: number, ctrlKey: boolean, metaKey: boolean, altKey: boolean): boolean {
|
onClick(eventTarget: HTMLElement, button: number, ctrlKey: boolean, metaKey: boolean, altKey: boolean): boolean {
|
||||||
|
|
||||||
// Hide the search results if we clicked outside both the search box and the search results
|
// Hide the search results if we clicked outside both the "search box" and the "search results"
|
||||||
if (this.searchResults) {
|
if (!this.searchElements.some(element => element.nativeElement.contains(eventTarget))) {
|
||||||
const hits = this.searchElements.filter(element => element.nativeElement.contains(eventTarget));
|
this.hideSearchResults();
|
||||||
if (hits.length === 0) {
|
|
||||||
this.searchResults.hideResults();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show developer source view if the footer is clicked while holding the meta and alt keys
|
||||||
if (eventTarget.tagName === 'FOOTER' && metaKey && altKey) {
|
if (eventTarget.tagName === 'FOOTER' && metaKey && altKey) {
|
||||||
this.dtOn = !this.dtOn;
|
this.dtOn = !this.dtOn;
|
||||||
return false;
|
return false;
|
||||||
|
@ -191,6 +198,8 @@ export class AppComponent implements OnInit {
|
||||||
if (target instanceof HTMLAnchorElement) {
|
if (target instanceof HTMLAnchorElement) {
|
||||||
return this.locationService.handleAnchorClick(target, button, ctrlKey, metaKey);
|
return this.locationService.handleAnchorClick(target, button, ctrlKey, metaKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow the click to pass through
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -230,4 +239,36 @@ export class AppComponent implements OnInit {
|
||||||
|
|
||||||
this.tocMaxHeight = (document.body.scrollHeight - window.pageYOffset - this.tocMaxHeightOffset).toFixed(2);
|
this.tocMaxHeight = (document.body.scrollHeight - window.pageYOffset - this.tocMaxHeightOffset).toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Search related methods and handlers
|
||||||
|
|
||||||
|
hideSearchResults() {
|
||||||
|
this.showSearchResults = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
focusSearchBox() {
|
||||||
|
if (this.searchBox) {
|
||||||
|
this.searchBox.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doSearch(query) {
|
||||||
|
this.searchService.search(query);
|
||||||
|
this.showSearchResults = !!query;
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:keyup', ['$event.key', '$event.which'])
|
||||||
|
onKeyUp(key: string, keyCode: number) {
|
||||||
|
// forward slash "/"
|
||||||
|
if (key === '/' || keyCode === 191) {
|
||||||
|
this.focusSearchBox();
|
||||||
|
}
|
||||||
|
if (key === 'Escape' || keyCode === 27 ) {
|
||||||
|
// escape key
|
||||||
|
if (this.showSearchResults) {
|
||||||
|
this.hideSearchResults();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,27 @@
|
||||||
|
import { Component } from '@angular/core';
|
||||||
import { async, ComponentFixture, TestBed, inject } from '@angular/core/testing';
|
import { async, ComponentFixture, TestBed, inject } from '@angular/core/testing';
|
||||||
import { By } from '@angular/platform-browser';
|
import { By } from '@angular/platform-browser';
|
||||||
import { SearchBoxComponent } from './search-box.component';
|
import { SearchBoxComponent } from './search-box.component';
|
||||||
import { SearchService } from '../search.service';
|
|
||||||
import { MockSearchService } from 'testing/search.service';
|
import { MockSearchService } from 'testing/search.service';
|
||||||
import { LocationService } from 'app/shared/location.service';
|
import { LocationService } from 'app/shared/location.service';
|
||||||
import { MockLocationService } from 'testing/location.service';
|
import { MockLocationService } from 'testing/location.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: '<aio-search-box (search)="doSearch($event)"></aio-search-box>'
|
||||||
|
})
|
||||||
|
class HostComponent {
|
||||||
|
doSearch = jasmine.createSpy('doSearch');
|
||||||
|
}
|
||||||
|
|
||||||
describe('SearchBoxComponent', () => {
|
describe('SearchBoxComponent', () => {
|
||||||
let component: SearchBoxComponent;
|
let component: SearchBoxComponent;
|
||||||
let fixture: ComponentFixture<SearchBoxComponent>;
|
let host: HostComponent;
|
||||||
|
let fixture: ComponentFixture<HostComponent>;
|
||||||
|
|
||||||
beforeEach(async(() => {
|
beforeEach(async(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
declarations: [ SearchBoxComponent ],
|
declarations: [ SearchBoxComponent, HostComponent ],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: SearchService, useFactory: () => new MockSearchService() },
|
|
||||||
{ provide: LocationService, useFactory: () => new MockLocationService('') }
|
{ provide: LocationService, useFactory: () => new MockLocationService('') }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@ -22,61 +29,51 @@ describe('SearchBoxComponent', () => {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fixture = TestBed.createComponent(SearchBoxComponent);
|
fixture = TestBed.createComponent(HostComponent);
|
||||||
component = fixture.componentInstance;
|
host = fixture.componentInstance;
|
||||||
|
component = fixture.debugElement.query(By.directive(SearchBoxComponent)).componentInstance;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('initialisation', () => {
|
describe('initialisation', () => {
|
||||||
it('should initialize the search worker', inject([SearchService], (searchService: SearchService) => {
|
|
||||||
fixture.detectChanges(); // triggers ngOnInit
|
|
||||||
expect(searchService.initWorker).toHaveBeenCalled();
|
|
||||||
expect(searchService.loadIndex).toHaveBeenCalled();
|
|
||||||
}));
|
|
||||||
|
|
||||||
it('should get the current search query from the location service', inject([LocationService], (location: MockLocationService) => {
|
it('should get the current search query from the location service', inject([LocationService], (location: MockLocationService) => {
|
||||||
location.search.and.returnValue({ search: 'initial search' });
|
location.search.and.returnValue({ search: 'initial search' });
|
||||||
spyOn(component, 'onSearch');
|
|
||||||
component.ngOnInit();
|
component.ngOnInit();
|
||||||
expect(location.search).toHaveBeenCalled();
|
expect(location.search).toHaveBeenCalled();
|
||||||
expect(component.onSearch).toHaveBeenCalledWith('initial search');
|
expect(host.doSearch).toHaveBeenCalledWith('initial search');
|
||||||
expect(component.searchBox.nativeElement.value).toEqual('initial search');
|
expect(component.searchBox.nativeElement.value).toEqual('initial search');
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('on keyup', () => {
|
describe('on keyup', () => {
|
||||||
it('should call the search service, if it is not triggered by the ESC key', inject([SearchService], (search: MockSearchService) => {
|
it('should trigger the search event', () => {
|
||||||
const input = fixture.debugElement.query(By.css('input'));
|
const input = fixture.debugElement.query(By.css('input'));
|
||||||
input.triggerEventHandler('keyup', { target: { value: 'some query' } });
|
input.triggerEventHandler('keyup', { target: { value: 'some query' } });
|
||||||
expect(search.search).toHaveBeenCalledWith('some query');
|
expect(host.doSearch).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 grab focus when the / key is pressed', () => {
|
|
||||||
const input = fixture.debugElement.query(By.css('input'));
|
|
||||||
window.document.dispatchEvent(new KeyboardEvent('keyup', { 'key': '/' }));
|
|
||||||
expect(document.activeElement).toBe(input.nativeElement, 'Search box should be active element');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('on focus', () => {
|
describe('on focus', () => {
|
||||||
it('should call the search service on focus', inject([SearchService], (search: SearchService) => {
|
it('should trigger the search event', () => {
|
||||||
const input = fixture.debugElement.query(By.css('input'));
|
const input = fixture.debugElement.query(By.css('input'));
|
||||||
input.triggerEventHandler('focus', { target: { value: 'some query' } });
|
input.triggerEventHandler('focus', { target: { value: 'some query' } });
|
||||||
expect(search.search).toHaveBeenCalledWith('some query');
|
expect(host.doSearch).toHaveBeenCalledWith('some query');
|
||||||
}));
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('on click', () => {
|
describe('on click', () => {
|
||||||
it('should call the search service on click', inject([SearchService], (search: SearchService) => {
|
it('should trigger the search event', () => {
|
||||||
const input = fixture.debugElement.query(By.css('input'));
|
const input = fixture.debugElement.query(By.css('input'));
|
||||||
input.triggerEventHandler('click', { target: { value: 'some query'}});
|
input.triggerEventHandler('click', { target: { value: 'some query'}});
|
||||||
expect(search.search).toHaveBeenCalledWith('some query');
|
expect(host.doSearch).toHaveBeenCalledWith('some query');
|
||||||
}));
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('focus', () => {
|
||||||
|
it('should set the focus to the input box', () => {
|
||||||
|
const input = fixture.debugElement.query(By.css('input'));
|
||||||
|
component.focus();
|
||||||
|
expect(document.activeElement).toBe(input.nativeElement);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,13 +1,10 @@
|
||||||
import { Component, OnInit, ViewChild, ElementRef, HostListener } from '@angular/core';
|
import { Component, OnInit, ViewChild, ElementRef, EventEmitter, Output } from '@angular/core';
|
||||||
import { SearchService } from 'app/search/search.service';
|
|
||||||
import { LocationService } from 'app/shared/location.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.
|
* 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
|
||||||
*
|
|
||||||
* 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.
|
* 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
|
* Focussing on the input box will resend whatever query is there. This can be useful if the search
|
||||||
|
@ -19,20 +16,21 @@ import { LocationService } from 'app/shared/location.service';
|
||||||
template: `<input #searchBox
|
template: `<input #searchBox
|
||||||
aria-label="search"
|
aria-label="search"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
(keyup)="onSearch($event.target.value, $event.which)"
|
(keyup)="onSearch($event.target.value)"
|
||||||
(focus)="onSearch($event.target.value)"
|
(focus)="onSearch($event.target.value)"
|
||||||
(click)="onSearch($event.target.value)">`
|
(click)="onSearch($event.target.value)">`
|
||||||
})
|
})
|
||||||
export class SearchBoxComponent implements OnInit {
|
export class SearchBoxComponent implements OnInit {
|
||||||
|
|
||||||
@ViewChild('searchBox') searchBox: ElementRef;
|
@ViewChild('searchBox') searchBox: ElementRef;
|
||||||
|
@Output() search = new EventEmitter<string>();
|
||||||
|
|
||||||
constructor(private searchService: SearchService, private locationService: LocationService) { }
|
constructor(private locationService: LocationService) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When we first show this search box we trigger a search if there is a search query in the URL
|
||||||
|
*/
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.searchService.initWorker('app/search/search-worker.js');
|
|
||||||
this.searchService.loadIndex();
|
|
||||||
|
|
||||||
const query = this.locationService.search()['search'];
|
const query = this.locationService.search()['search'];
|
||||||
if (query) {
|
if (query) {
|
||||||
this.searchBox.nativeElement.value = query;
|
this.searchBox.nativeElement.value = query;
|
||||||
|
@ -40,17 +38,11 @@ export class SearchBoxComponent implements OnInit {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onSearch(query: string, keyCode?: number) {
|
onSearch(query: string) {
|
||||||
if (keyCode === 27) { // ignore escape key
|
this.search.emit(query);
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.searchService.search(query);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('document:keyup', ['$event'])
|
focus() {
|
||||||
onKeyUp($event: KeyboardEvent) {
|
this.searchBox.nativeElement.focus();
|
||||||
if ($event.key === '/') {
|
|
||||||
this.searchBox.nativeElement.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
<div class="search-results" *ngIf="hasAreas | async" >
|
<div class="search-results">
|
||||||
|
<div *ngIf="searchAreas.length; then searchResults; else notFound"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-template #searchResults>
|
||||||
<h2 class="visually-hidden">Search Results</h2>
|
<h2 class="visually-hidden">Search Results</h2>
|
||||||
<div class="search-area" *ngFor="let area of searchAreas | async">
|
<div class="search-area" *ngFor="let area of searchAreas">
|
||||||
<h3>{{area.name}} ({{area.pages.length}})</h3>
|
<h3>{{area.name}} ({{area.pages.length}})</h3>
|
||||||
<ul class="priority-pages" >
|
<ul class="priority-pages" >
|
||||||
<li class="search-page" *ngFor="let page of area.priorityPages">
|
<li class="search-page" *ngFor="let page of area.priorityPages">
|
||||||
|
@ -18,7 +22,8 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ng-template>
|
||||||
<div class="search-results" *ngIf="notFound" >
|
|
||||||
|
<ng-template #notFound>
|
||||||
<p>No results found.</p>
|
<p>No results found.</p>
|
||||||
</div>
|
</ng-template>
|
||||||
|
|
|
@ -9,9 +9,7 @@ import { MockSearchService } from 'testing/search.service';
|
||||||
describe('SearchResultsComponent', () => {
|
describe('SearchResultsComponent', () => {
|
||||||
let component: SearchResultsComponent;
|
let component: SearchResultsComponent;
|
||||||
let fixture: ComponentFixture<SearchResultsComponent>;
|
let fixture: ComponentFixture<SearchResultsComponent>;
|
||||||
let searchService: SearchService;
|
|
||||||
let searchResults: Subject<SearchResults>;
|
let searchResults: Subject<SearchResults>;
|
||||||
let currentAreas: SearchArea[];
|
|
||||||
|
|
||||||
/** Get all text from component element */
|
/** Get all text from component element */
|
||||||
function getText() { return fixture.debugElement.nativeElement.innerText; }
|
function getText() { return fixture.debugElement.nativeElement.innerText; }
|
||||||
|
@ -48,17 +46,15 @@ describe('SearchResultsComponent', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fixture = TestBed.createComponent(SearchResultsComponent);
|
fixture = TestBed.createComponent(SearchResultsComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
searchService = fixture.debugElement.injector.get(SearchService);
|
searchResults = TestBed.get(SearchService).searchResults;
|
||||||
searchResults = searchService.searchResults as Subject<SearchResults>;
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
component.searchAreas.subscribe(areas => currentAreas = areas);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should map the search results into groups based on their containing folder', () => {
|
it('should map the search results into groups based on their containing folder', () => {
|
||||||
const results = getTestResults(3);
|
const results = getTestResults(3);
|
||||||
|
|
||||||
searchResults.next({ query: '', results: results});
|
searchResults.next({ query: '', results: results});
|
||||||
expect(currentAreas).toEqual([
|
expect(component.searchAreas).toEqual([
|
||||||
{ name: 'api', pages: [
|
{ name: 'api', pages: [
|
||||||
{ path: 'api/d', title: 'API D', type: '', keywords: '', titleWords: '' }
|
{ path: 'api/d', title: 'API D', type: '', keywords: '', titleWords: '' }
|
||||||
], priorityPages: [] },
|
], priorityPages: [] },
|
||||||
|
@ -74,7 +70,7 @@ describe('SearchResultsComponent', () => {
|
||||||
{ path: 'tutorial', title: 'Tutorial index', type: '', keywords: '', titleWords: '' },
|
{ path: 'tutorial', title: 'Tutorial index', type: '', keywords: '', titleWords: '' },
|
||||||
{ path: 'tutorial/toh-pt1', title: 'Tutorial - part 1', type: '', keywords: '', titleWords: '' },
|
{ path: 'tutorial/toh-pt1', title: 'Tutorial - part 1', type: '', keywords: '', titleWords: '' },
|
||||||
]});
|
]});
|
||||||
expect(currentAreas).toEqual([
|
expect(component.searchAreas).toEqual([
|
||||||
{ name: 'tutorial', pages: [
|
{ name: 'tutorial', pages: [
|
||||||
{ path: 'tutorial/toh-pt1', title: 'Tutorial - part 1', type: '', keywords: '', titleWords: '' },
|
{ path: 'tutorial/toh-pt1', title: 'Tutorial - part 1', type: '', keywords: '', titleWords: '' },
|
||||||
{ path: 'tutorial', title: 'Tutorial index', type: '', keywords: '', titleWords: '' },
|
{ path: 'tutorial', title: 'Tutorial index', type: '', keywords: '', titleWords: '' },
|
||||||
|
@ -86,7 +82,7 @@ describe('SearchResultsComponent', () => {
|
||||||
const results = getTestResults(5);
|
const results = getTestResults(5);
|
||||||
searchResults.next({ query: '', results: results });
|
searchResults.next({ query: '', results: results });
|
||||||
|
|
||||||
expect(currentAreas).toEqual([
|
expect(component.searchAreas).toEqual([
|
||||||
{ name: 'api', pages: [
|
{ name: 'api', pages: [
|
||||||
{ path: 'api/c', title: 'API C', type: '', keywords: '', titleWords: '' },
|
{ path: 'api/c', title: 'API C', type: '', keywords: '', titleWords: '' },
|
||||||
{ path: 'api/d', title: 'API D', type: '', keywords: '', titleWords: '' },
|
{ path: 'api/d', title: 'API D', type: '', keywords: '', titleWords: '' },
|
||||||
|
@ -116,7 +112,7 @@ describe('SearchResultsComponent', () => {
|
||||||
];
|
];
|
||||||
|
|
||||||
searchResults.next({ query: '', results: results });
|
searchResults.next({ query: '', results: results });
|
||||||
expect(currentAreas).toEqual(expected);
|
expect(component.searchAreas).toEqual(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should put search results with no containing folder into the default area (other)', () => {
|
it('should put search results with no containing folder into the default area (other)', () => {
|
||||||
|
@ -125,7 +121,7 @@ describe('SearchResultsComponent', () => {
|
||||||
];
|
];
|
||||||
|
|
||||||
searchResults.next({ query: '', results: results });
|
searchResults.next({ query: '', results: results });
|
||||||
expect(currentAreas).toEqual([
|
expect(component.searchAreas).toEqual([
|
||||||
{ name: 'other', pages: [
|
{ name: 'other', pages: [
|
||||||
{ path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' }
|
{ path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' }
|
||||||
], priorityPages: [] }
|
], priorityPages: [] }
|
||||||
|
@ -137,73 +133,30 @@ describe('SearchResultsComponent', () => {
|
||||||
{ path: 'news', title: undefined, type: 'marketing', keywords: '', titleWords: '' }
|
{ path: 'news', title: undefined, type: 'marketing', keywords: '', titleWords: '' }
|
||||||
];
|
];
|
||||||
|
|
||||||
searchResults.next({ query: '', results: results });
|
searchResults.next({ query: 'something', results: results });
|
||||||
expect(currentAreas).toEqual([]);
|
expect(component.searchAreas).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit an "resultSelected" event when a search result anchor is clicked', () => {
|
it('should emit a "resultSelected" event when a search result anchor is clicked', () => {
|
||||||
let selectedResult: SearchResult;
|
const searchResult = { path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' };
|
||||||
component.resultSelected.subscribe((result: SearchResult) => selectedResult = result);
|
let selected: SearchResult;
|
||||||
const results = [
|
component.resultSelected.subscribe(result => selected = result);
|
||||||
{ path: 'news', title: 'News', type: 'marketing', keywords: '', titleWords: '' }
|
|
||||||
];
|
|
||||||
|
|
||||||
searchResults.next({ query: '', results: results });
|
searchResults.next({ query: 'something', results: [searchResult] });
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
const anchor = fixture.debugElement.query(By.css('a'));
|
expect(selected).toBeUndefined();
|
||||||
|
|
||||||
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'));
|
const anchor = fixture.debugElement.query(By.css('a'));
|
||||||
anchor.triggerEventHandler('click', {});
|
anchor.triggerEventHandler('click', {});
|
||||||
|
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(fixture.debugElement.queryAll(By.css('a'))).toEqual([]);
|
expect(selected).toEqual(searchResult);
|
||||||
});
|
|
||||||
|
|
||||||
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(getText()).toBe('');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when no query results', () => {
|
describe('when no query results', () => {
|
||||||
|
|
||||||
it('should display "not found" message', () => {
|
it('should display "not found" message', () => {
|
||||||
searchResults.next({ query: 'something', results: [] });
|
searchResults.next({ query: 'something', results: [] });
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
expect(getText()).toContain('No results');
|
expect(getText()).toContain('No results');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not display "not found" message after hideResults()', () => {
|
|
||||||
searchResults.next({ query: 'something', results: [] });
|
|
||||||
fixture.detectChanges();
|
|
||||||
component.hideResults();
|
|
||||||
fixture.detectChanges();
|
|
||||||
expect(getText()).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not display "not found" message when query is empty', () => {
|
|
||||||
searchResults.next({ query: '', results: [] });
|
|
||||||
fixture.detectChanges();
|
|
||||||
expect(getText()).toBe('');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Component, ChangeDetectionStrategy, EventEmitter, HostListener, OnInit, Output } from '@angular/core';
|
import { Component, EventEmitter, HostListener, OnDestroy, OnInit, Output } from '@angular/core';
|
||||||
import { ReplaySubject } from 'rxjs/ReplaySubject';
|
import { Observable } from 'rxjs/Observable';
|
||||||
|
import { Subscription } from 'rxjs/Subscription';
|
||||||
|
|
||||||
import { SearchResult, SearchResults, SearchService } from '../search.service';
|
import { SearchResult, SearchResults, SearchService } from '../search.service';
|
||||||
|
|
||||||
|
@ -15,50 +16,41 @@ export interface SearchArea {
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'aio-search-results',
|
selector: 'aio-search-results',
|
||||||
templateUrl: './search-results.component.html',
|
templateUrl: './search-results.component.html',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
|
||||||
})
|
})
|
||||||
export class SearchResultsComponent implements OnInit {
|
export class SearchResultsComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
|
private resultsSubscription: Subscription;
|
||||||
readonly defaultArea = 'other';
|
readonly defaultArea = 'other';
|
||||||
readonly topLevelFolders = ['guide', 'tutorial'];
|
readonly topLevelFolders = ['guide', 'tutorial'];
|
||||||
|
|
||||||
notFound = false;
|
/**
|
||||||
|
* Emitted when the user selects a search result
|
||||||
|
*/
|
||||||
@Output()
|
@Output()
|
||||||
resultSelected = new EventEmitter<SearchResult>();
|
resultSelected = new EventEmitter<SearchResult>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A mapping of the search results grouped into areas
|
* A mapping of the search results grouped into areas
|
||||||
*/
|
*/
|
||||||
searchAreas = new ReplaySubject<SearchArea[]>(1);
|
searchAreas: SearchArea[] = [];
|
||||||
hasAreas = this.searchAreas.map(areas => areas.length > 0);
|
|
||||||
|
|
||||||
constructor(private searchService: SearchService) {}
|
constructor(private searchService: SearchService) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.searchService.searchResults.subscribe(search => this.searchAreas.next(this.processSearchResults(search)));
|
this.resultsSubscription = this.searchService.searchResults
|
||||||
|
.subscribe(search => this.searchAreas = this.processSearchResults(search));
|
||||||
}
|
}
|
||||||
|
|
||||||
onResultSelected(result: SearchResult) {
|
ngOnDestroy() {
|
||||||
this.resultSelected.emit(result);
|
this.resultsSubscription.unsubscribe();
|
||||||
this.hideResults();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('document:keyup', ['$event.which'])
|
onResultSelected(page: SearchResult) {
|
||||||
onKeyUp(keyCode: number) {
|
this.resultSelected.emit(page);
|
||||||
if (keyCode === 27) {
|
|
||||||
this.hideResults();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hideResults() {
|
|
||||||
this.searchAreas.next([]);
|
|
||||||
this.notFound = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map the search results into groups by area
|
// Map the search results into groups by area
|
||||||
private processSearchResults(search: SearchResults) {
|
private processSearchResults(search: SearchResults) {
|
||||||
this.notFound = search.query.trim() && search.results.length === 0;
|
|
||||||
const searchAreaMap = {};
|
const searchAreaMap = {};
|
||||||
search.results.forEach(result => {
|
search.results.forEach(result => {
|
||||||
if (!result.title) { return; } // bad data; should fix
|
if (!result.title) { return; } // bad data; should fix
|
||||||
|
@ -72,7 +64,7 @@ export class SearchResultsComponent implements OnInit {
|
||||||
const priorityPages = pages.length > 10 ? searchAreaMap[name].slice(0, 5) : [];
|
const priorityPages = pages.length > 10 ? searchAreaMap[name].slice(0, 5) : [];
|
||||||
pages = pages.sort(compareResults);
|
pages = pages.sort(compareResults);
|
||||||
return { name, pages, priorityPages };
|
return { name, pages, priorityPages };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split the search result path and use the top level folder, if there is one, as the area name.
|
// Split the search result path and use the top level folder, if there is one, as the area name.
|
||||||
|
|
|
@ -6,7 +6,7 @@ can be found in the LICENSE file at http://angular.io/license
|
||||||
|
|
||||||
import { NgZone, Injectable, Type } from '@angular/core';
|
import { NgZone, Injectable, Type } from '@angular/core';
|
||||||
import { Observable } from 'rxjs/Observable';
|
import { Observable } from 'rxjs/Observable';
|
||||||
import { Subject } from 'rxjs/Subject';
|
import { ReplaySubject } from 'rxjs/ReplaySubject';
|
||||||
import 'rxjs/add/operator/publishLast';
|
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';
|
||||||
|
@ -29,7 +29,7 @@ export interface SearchResult {
|
||||||
export class SearchService {
|
export class SearchService {
|
||||||
private worker: WebWorkerClient;
|
private worker: WebWorkerClient;
|
||||||
private ready: Observable<boolean>;
|
private ready: Observable<boolean>;
|
||||||
private resultsSubject = new Subject<SearchResults>();
|
private resultsSubject = new ReplaySubject<SearchResults>(1);
|
||||||
readonly searchResults = this.resultsSubject.asObservable();
|
readonly searchResults = this.resultsSubject.asObservable();
|
||||||
|
|
||||||
constructor(private zone: NgZone) {}
|
constructor(private zone: NgZone) {}
|
||||||
|
|
Loading…
Reference in New Issue