Commit 5e87004e authored by Jean-Baptiste Nizet's avatar Jean-Baptiste Nizet
Browse files

feat: implement suggestions on the frontend

parent 08c4e4ed
......@@ -10,7 +10,7 @@ import { HomeComponent } from './home/home.component';
import { SearchComponent } from './search/search.component';
import { GeneticResourcesComponent } from './genetic-resources/genetic-resources.component';
import { GeneticResourceComponent } from './genetic-resource/genetic-resource.component';
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbPaginationModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { registerLocaleData } from '@angular/common';
import localeFr from '@angular/common/locales/fr';
......@@ -29,7 +29,8 @@ registerLocaleData(localeFr);
RouterModule.forRoot(routes),
ReactiveFormsModule,
HttpClientModule,
NgbPaginationModule.forRoot()
NgbPaginationModule.forRoot(),
NgbTypeaheadModule.forRoot()
],
providers: [
{ provide: LOCALE_ID, useValue: 'fr-FR' }
......
<div class="mt-5">
<form class="input-group"[formGroup]="searchForm" (ngSubmit)="search()">
<form class="input-group" [formGroup]="searchForm" (ngSubmit)="search()">
<input class="form-control form-control-lg" type="text"
placeholder="Exemples : pisum sativum, rosa"
formControlName="search">
formControlName="search"
[ngbTypeahead]="suggesterTypeahead">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="submit">Recherche</button>
</div>
......
......@@ -5,6 +5,9 @@ import { Router } from '@angular/router';
import { ComponentTester, speculoosMatchers } from 'ngx-speculoos';
import { HomeComponent } from './home.component';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { SearchService } from '../search.service';
class HomeComponentTester extends ComponentTester<HomeComponent> {
constructor() {
......@@ -22,7 +25,7 @@ class HomeComponentTester extends ComponentTester<HomeComponent> {
describe('HomeComponent', () => {
beforeEach(() => TestBed.configureTestingModule({
imports: [ReactiveFormsModule, RouterTestingModule],
imports: [ReactiveFormsModule, RouterTestingModule, HttpClientTestingModule, NgbTypeaheadModule.forRoot()],
declarations: [HomeComponent]
}));
......@@ -32,7 +35,10 @@ describe('HomeComponent', () => {
// given a component
const router = TestBed.get(Router) as Router;
spyOn(router, 'navigate');
const component = new HomeComponent(router);
const searchService = TestBed.get(SearchService) as SearchService;
const component = new HomeComponent(router, searchService);
// with a query
const query = 'Bacteria';
......
import { Component } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { Router } from '@angular/router';
import { Observable } from 'rxjs';
import { SearchService } from '../search.service';
@Component({
selector: 'rare-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss']
})
export class HomeComponent {
export class HomeComponent implements OnInit {
searchForm: FormGroup;
suggesterTypeahead: (text$: Observable<string>) => Observable<Array<string>>;
constructor(private router: Router) {
constructor(private router: Router, private searchService: SearchService) {
this.searchForm = new FormGroup({
search: new FormControl()
});
}
ngOnInit(): void {
this.suggesterTypeahead = this.searchService.getSuggesterTypeahead();
}
search() {
this.router.navigate(['/search'], {
queryParams: {
......@@ -23,5 +31,4 @@ export class HomeComponent {
}
});
}
}
import { TestBed } from '@angular/core/testing';
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { Subject } from 'rxjs';
import { toGeneticResource, toSinglePage } from './models/test-model-generators';
import { SearchService } from './search.service';
......@@ -7,12 +8,19 @@ import { Page } from './models/page';
import { GeneticResourceModel } from './models/genetic-resource.model';
describe('SearchService', () => {
beforeEach(() => TestBed.configureTestingModule({
imports: [HttpClientTestingModule]
}));
let service: SearchService;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule]
});
service = TestBed.get(SearchService) as SearchService;
http = TestBed.get(HttpTestingController) as HttpTestingController;
});
it('should search for the query', () => {
const service = TestBed.get(SearchService) as SearchService;
let actualResults: Page<GeneticResourceModel>;
service.search('Bacteria', 2)
.subscribe(results => actualResults = results);
......@@ -20,10 +28,57 @@ describe('SearchService', () => {
const resource = toGeneticResource('Bacteria');
const expectedResults = toSinglePage([resource]);
const http = TestBed.get(HttpTestingController) as HttpTestingController;
http.expectOne('/api/genetic-resources?query=Bacteria&page=1').flush(expectedResults);
expect(actualResults).toEqual(expectedResults);
http.verify();
});
it('should have a typeahead that suggests if text longer than 1, after 300 ms and only if changed', fakeAsync(() => {
const typeahead = service.getSuggesterTypeahead();
const entered = new Subject<string>();
const results: Array<Array<string>> = [];
typeahead(entered).subscribe(result => results.push(result));
// simulate what is entered, character by character, after a delay between each one
entered.next(' ');
tick(100);
entered.next(' v');
tick(300); // should not trigger a search, but emit an empty array because input is too short
entered.next(' vi');
tick(100);
entered.next(' vit');
tick(100);
entered.next(' vit');
tick(100);
entered.next(' vit ');
tick(300); // should finally trigger a search for 'vit'
const vitResult = ['vitis', 'vitis vinifera'];
http.expectOne('/api/genetic-resources-suggestions?query=vit').flush(vitResult);
entered.next(' viti ');
tick(100);
entered.next(' viti');
tick(100);
entered.next(' vit');
tick(300); // should not trigger a second search since same value
entered.next(' viti');
tick(100);
entered.next(' vitis');
tick(100);
entered.next(' vitis ');
tick(100);
entered.next(' vitis v');
tick(300); // should trigger a second search
const vitisVResult = ['vitis vinifera'];
http.expectOne('/api/genetic-resources-suggestions?query=vitis%20v').flush(vitisVResult);
expect(results).toEqual([[], vitResult, vitisVResult]);
http.verify();
}));
});
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Observable, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators';
import { GeneticResourceModel } from './models/genetic-resource.model';
import { Page } from './models/page';
......@@ -12,6 +13,9 @@ export class SearchService {
constructor(private http: HttpClient) {}
/**
* Searches genetic resources for the given query (full-text search), and retrieves the given page (starting at 1)
*/
search(query: string, pageAsNumber: number): Observable<Page<GeneticResourceModel>> {
// we decrease the page as the frontend is 1 based, and the backend 0 based.
const page = (pageAsNumber - 1).toString();
......@@ -20,4 +24,35 @@ export class SearchService {
});
}
/**
* Returns what the NgbTypeahead directive, used for search term suggestions, needs as an input
* (see https://ng-bootstrap.github.io/#/components/typeahead/api), i.e. a function that takes an Observable<string>
* as argument, representing the stream of values entered in the search field, and returns an
* Observable<Array<string>>, representing the stream of suggestions to display for each string emitted by the
* observable taken as argument.
*/
getSuggesterTypeahead(): ((obs: Observable<string>) => Observable<Array<string>>) {
return (text$: Observable<string>) =>
text$.pipe(
map(query => query.trim()), // start by ignoring the spaces at the beginning or end of the query
debounceTime(300), // only send a query if the user has stopped writing in the field for at least 300ms.
distinctUntilChanged(), // avoid sending a request if the input hasn't changed (for example igf the user enters
// a character then backspace)
switchMap(query => { // get the suggestions for the entered string
if (query.length < 2) { // if the query is only one character, prefer suggesting nothing: too vague
return of([]);
}
return this.suggest(query).pipe( // otherwise send a request to the server
catchError(() => of([])) // but if the request fails, suggest nothing
);
}
)
);
}
private suggest(query: string): Observable<Array<string>> {
return this.http.get<Array<string>>('/api/genetic-resources-suggestions', {
params: { query }
});
}
}
......@@ -2,7 +2,8 @@
<form class="input-group" [formGroup]="searchForm" (ngSubmit)="newSearch()">
<input class="form-control" type="text"
placeholder="Exemples : pisum sativum, rosa"
formControlName="search">
formControlName="search"
[ngbTypeahead]="suggesterTypeahead">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="submit">Recherche</button>
</div>
......
......@@ -4,7 +4,7 @@ import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { By } from '@angular/platform-browser';
import { NgbPagination, NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbPagination, NgbPaginationModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { of } from 'rxjs';
import { ComponentTester, fakeRoute, speculoosMatchers } from 'ngx-speculoos';
......@@ -39,7 +39,13 @@ class SearchComponentTester extends ComponentTester<SearchComponent> {
describe('SearchComponent', () => {
beforeEach(() => TestBed.configureTestingModule({
imports: [ReactiveFormsModule, RouterTestingModule, HttpClientTestingModule, NgbPaginationModule.forRoot()],
imports: [
ReactiveFormsModule,
RouterTestingModule,
HttpClientTestingModule,
NgbPaginationModule.forRoot(),
NgbTypeaheadModule.forRoot()
],
declarations: [SearchComponent, GeneticResourcesComponent, GeneticResourceComponent]
}));
......
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormControl, FormGroup } from '@angular/forms';
import { EMPTY } from 'rxjs';
import { EMPTY, Observable } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { SearchService } from '../search.service';
......@@ -17,6 +17,7 @@ export class SearchComponent implements OnInit {
query = '';
searchForm: FormGroup;
results: Page<GeneticResourceModel>;
suggesterTypeahead: (text$: Observable<string>) => Observable<Array<string>>;
constructor(private route: ActivatedRoute, private router: Router, private searchService: SearchService) {
this.searchForm = new FormGroup({
......@@ -42,6 +43,8 @@ export class SearchComponent implements OnInit {
})
)
.subscribe(results => this.results = results);
this.suggesterTypeahead = this.searchService.getSuggesterTypeahead();
}
/**
......
......@@ -13,7 +13,7 @@
@import "../node_modules/bootstrap/scss/forms";
@import "../node_modules/bootstrap/scss/buttons";
// @import "../node_modules/bootstrap/scss/transitions";
// @import "../node_modules/bootstrap/scss/dropdown";
@import "../node_modules/bootstrap/scss/dropdown";
// @import "../node_modules/bootstrap/scss/button-group";
@import "../node_modules/bootstrap/scss/input-group";
// @import "../node_modules/bootstrap/scss/custom-forms";
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment