Skip to content

Commit

Permalink
fix(search): improve search: loading indication and other hints
Browse files Browse the repository at this point in the history
closes #366 (PR #456)
  • Loading branch information
tomwwinter authored Jun 27, 2020
1 parent 7eef007 commit 6995476
Show file tree
Hide file tree
Showing 5 changed files with 234 additions and 60 deletions.
93 changes: 79 additions & 14 deletions src/app/core/ui/search/search.component.html
Original file line number Diff line number Diff line change
@@ -1,26 +1,91 @@
<div fxHide.xs="true" fxHide="false">
<mat-form-field fxFill class="search-field">
<span matPrefix><mat-icon class="search-icon" fontIcon="fa-search"></mat-icon>&nbsp;</span>
<input matInput (input)="search()" title="Search" [matAutocomplete]="autoResults" [(ngModel)]="searchText">
<div class="desktop-search-bar" fxHide.xs="true" fxHide="false">
<mat-form-field fxFill class="search-field" floatLabel="never">
<span matPrefix>
<mat-icon matPrefix class="search-icon" fontIcon="fa-search"></mat-icon>
</span>
<mat-label>Search</mat-label>
<input
matInput
title="Search"
[matAutocomplete]="autoResults"
[(ngModel)]="searchText"
(ngModelChange)="searchStringChanged.next($event)"
#searchInput
/>
</mat-form-field>

<mat-autocomplete #autoResults="matAutocomplete" (optionSelected)="clickOption($event.option)">
<mat-option *ngFor="let res of results" [value]="''">
<app-child-block *ngIf="res.getType()==='Child'" [entity]="res" [ngClass]="{'result-inactive': !res.isActive()}"></app-child-block>
<app-school-block *ngIf="res.getType()==='School'" [entity]="res"></app-school-block>
<mat-autocomplete
#autoResults="matAutocomplete"
(optionSelected)="clickOption($event.option)"
>
<mat-option
class="result-hint"
*ngIf="
searchInput.value.length > 0 &&
searchInput.value.length < MIN_CHARACTERS_FOR_SEARCH
"
>
<p>Insert at least {{ MIN_CHARACTERS_FOR_SEARCH }} characters</p>
</mat-option>

<mat-option
class="result-hint"
*ngIf="
results.length === 0 &&
searchInput.value.length >= MIN_CHARACTERS_FOR_SEARCH &&
!isSearching
"
>
<p>There were no results</p>
</mat-option>

<mat-option class="result-hint" *ngIf="isSearching">
<p>Search in progress...</p>
</mat-option>

<mat-option *ngFor="let res of results" [value]="">
<app-child-block
*ngIf="res.getType() === 'Child'"
[entity]="res"
[ngClass]="{ 'result-inactive': !res.isActive() }"
></app-child-block>
<app-school-block
*ngIf="res.getType() === 'School'"
[entity]="res"
></app-school-block>
</mat-option>
</mat-autocomplete>
</div>

<button mat-icon-button fxHide.xs="false" fxHide="true" (click)="toggleSearchToolbar()">
<button
mat-icon-button
fxHide.xs="false"
fxHide="true"
(click)="toggleSearchToolbar()"
>
<mat-icon class="header-icon" fontIcon="fa-search"></mat-icon>
</button>

<mat-toolbar *ngIf="showSearchToolbar" class="search-header mat-elevation-z7 mat-typography">
<mat-toolbar
*ngIf="showSearchToolbar"
class="search-header mat-elevation-z7 mat-typography"
>
<mat-toolbar-row fxLayout="row">
<span matPrefix><mat-icon class="search-icon" fontIcon="fa-search"></mat-icon>&nbsp;</span>
<input #searchInput fxFlex="grow" matInput (input)="search()" title="Search" [matAutocomplete]="autoResults" [(ngModel)]="searchText">
{{searchInput.focus()}}
<button mat-icon-button (click)="toggleSearchToolbar()"><mat-icon class="search-icon" fontIcon="fa-times"></mat-icon></button>
<span matPrefix>
<mat-icon class="search-icon" fontIcon="fa-search"></mat-icon>
</span>
<input
#searchInput
fxFlex="grow"
matInput
title="Search"
[matAutocomplete]="autoResults"
[(ngModel)]="searchText"
(ngModelChange)="searchStringChanged.next($event)"
/>
{{ searchInput.focus() }}
<button mat-icon-button (click)="toggleSearchToolbar()">
<mat-icon class="search-icon" fontIcon="fa-times"></mat-icon>
</button>
</mat-toolbar-row>
</mat-toolbar>
15 changes: 15 additions & 0 deletions src/app/core/ui/search/search.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@

.search-field {
color: white;

span {
margin-right: 6px;
}
}

.result-inactive {
color: grey;
}

.desktop-search-bar {
font-size: 16px;
}

.search-header {
position: fixed;
background: white;
Expand All @@ -23,3 +31,10 @@
color: white;
font-size: 15pt;
}

.result-hint {
background-color: lightgrey;
font-style: italic;
font-size: 14px;
height: 36px;
}
35 changes: 27 additions & 8 deletions src/app/core/ui/search/search.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,28 @@ describe("SearchComponent", () => {
expect(mockDatabase.saveDatabaseIndex).toHaveBeenCalled();
});

it("should not search for less than MIN_CHARACTERS_FOR_SEARCH character of input", async () => {
let result = await component.startSearch("A");
component.handleSearchQueryResult(result);
expect(mockDatabase.query).not.toHaveBeenCalled();
expect(component.results).toEqual([]);

result = await component.startSearch("AB");
component.handleSearchQueryResult(result);
expect(mockDatabase.query).not.toHaveBeenCalled();
expect(component.results).toEqual([]);
});

it("should not search for less than one real character of input", async () => {
component.searchText = " ";
await component.search();
const result = await component.startSearch(" ");
component.handleSearchQueryResult(result);
expect(mockDatabase.query).not.toHaveBeenCalled();
expect(component.results).toEqual([]);
});

it("should reset results if a a null search is performed", async () => {
const result = await component.startSearch(null);
component.handleSearchQueryResult(result);
expect(mockDatabase.query).not.toHaveBeenCalled();
expect(component.results).toEqual([]);
});
Expand All @@ -81,8 +100,8 @@ describe("SearchComponent", () => {
};
mockDatabase.query.and.returnValue(Promise.resolve(mockQueryResults));

component.searchText = "A";
await component.search();
const result = await component.startSearch("Ada");
component.handleSearchQueryResult(result);
expect(mockDatabase.query).toHaveBeenCalled();
expect(component.results).toEqual([child1, school1]);
});
Expand All @@ -99,8 +118,8 @@ describe("SearchComponent", () => {
};
mockDatabase.query.and.returnValue(Promise.resolve(mockQueryResults));

component.searchText = "A";
await component.search();
const result = await component.startSearch("Ada");
component.handleSearchQueryResult(result);
expect(mockDatabase.query).toHaveBeenCalled();
expect(component.results.length).toBe(1);
expect(component.results).toEqual([child1]);
Expand All @@ -120,8 +139,8 @@ describe("SearchComponent", () => {
};
mockDatabase.query.and.returnValue(Promise.resolve(mockQueryResults));

component.searchText = "A X";
await component.search();
const result = await component.startSearch("A X");
component.handleSearchQueryResult(result);
expect(mockDatabase.query).toHaveBeenCalled();
expect(component.results).toEqual([child1]);
});
Expand Down
147 changes: 109 additions & 38 deletions src/app/core/ui/search/search.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { Child } from "../../../child-dev-project/children/model/child";
import { School } from "../../../child-dev-project/schools/model/school";
import { Entity } from "../../entity/entity";
import { EntitySchemaService } from "../../entity/schema/entity-schema.service";
import { Subject } from "rxjs";
import { debounceTime, switchMap } from "rxjs/operators";
import { LoggingService } from "../../logging/logging.service";
import { LogLevel } from "../../logging/log-level";
import { AlertService } from "../../alerts/alert.service";

/**
* General search box that provides results out of any kind of entities from the system
Expand All @@ -20,17 +25,113 @@ export class SearchComponent implements OnInit {
results = [];
searchText = "";
showSearchToolbar = false;
isSearching: boolean = false;

MIN_CHARACTERS_FOR_SEARCH: number = 3;
INPUT_DEBOUNCE_TIME_MS: number = 400;

searchStringChanged: Subject<string> = new Subject<string>();
searchQuery: Subject<Promise<any>> = new Subject<Promise<any>>();

constructor(
private db: Database,
private entitySchemaService: EntitySchemaService
private entitySchemaService: EntitySchemaService,
private loggingService: LoggingService,
private alertService: AlertService
) {}

ngOnInit() {
this.createSearchIndex();

this.searchStringChanged
.pipe(debounceTime(this.INPUT_DEBOUNCE_TIME_MS))
.subscribe((searchString) => {
this.searchQuery.next(this.startSearch(searchString));
});

this.searchQuery
.pipe(
switchMap((value) => {
return value;
})
)
.subscribe(
(result: { queryResults: any; searchTerms: string[] }) => {
this.handleSearchQueryResult(result);
},
(error) => {
this.loggingService.log(
{
message: "[Search] An error has occurred in a search query.",
error,
},
LogLevel.ERROR
);
this.alertService.addWarning(
"An error has occurred in your search query. Please try again."
);
}
);
}

async startSearch(searchString: string): Promise<any> {
this.isSearching = true;
this.results = [];

if (!searchString) {
return Promise.resolve();
}

searchString = searchString.toLowerCase();

if (!this.isRelevantSearchInput(searchString)) {
return Promise.resolve();
}

const searchTerms = searchString.split(" ");

const queryResults = await this.db.query("search_index/by_name", {
startkey: searchTerms[0],
endkey: searchTerms[0] + "\ufff0",
include_docs: true,
});

return {
queryResults,
searchTerms,
};
}

clickOption(optionElement) {
// simulate a click on the EntityBlock inside the selected option element
optionElement._element.nativeElement.children["0"].children["0"].click();
if (this.showSearchToolbar === true) {
this.showSearchToolbar = false;
}
}

toggleSearchToolbar() {
this.showSearchToolbar = !this.showSearchToolbar;
}

private createSearchIndex() {
handleSearchQueryResult(result: {
queryResults: any;
searchTerms: string[];
}) {
this.isSearching = false;

if (!result || !result.queryResults) {
this.results = [];
return;
}

this.results = this.prepareResults(
result.queryResults.rows,
result.searchTerms
);
}

private createSearchIndex(): Promise<any> {
// `emit(x)` to add x as a key to the index that can be searched
const searchMapFunction =
"(doc) => {" +
Expand All @@ -46,28 +147,7 @@ export class SearchComponent implements OnInit {
},
};

this.db.saveDatabaseIndex(designDoc);
}

async search() {
this.searchText = this.searchText.toLowerCase();
if (!this.isRelevantSearchInput(this.searchText)) {
this.results = [];
return;
}

const searchHash = JSON.stringify(this.searchText);
const searchTerms = this.searchText.split(" ");
const queryResults = await this.db.query("search_index/by_name", {
startkey: searchTerms[0],
endkey: searchTerms[0] + "\ufff0",
include_docs: true,
});

if (JSON.stringify(this.searchText) === searchHash) {
// only set result if the user hasn't continued typing and changed the search term already
this.results = this.prepareResults(queryResults.rows, searchTerms);
}
return this.db.saveDatabaseIndex(designDoc);
}

/**
Expand All @@ -77,10 +157,13 @@ export class SearchComponent implements OnInit {
*/
private isRelevantSearchInput(searchText: string) {
const regexp = new RegExp("[a-z]+|[0-9]+");
return this.searchText.match(regexp);
return (
searchText.match(regexp) &&
searchText.length >= this.MIN_CHARACTERS_FOR_SEARCH
);
}

private prepareResults(rows, searchTerms: string[]) {
private prepareResults(rows, searchTerms: string[]): any[] {
return this.getResultsWithoutDuplicates(rows)
.map((doc) => this.transformDocToEntity(doc))
.filter((r) => r !== null)
Expand Down Expand Up @@ -150,16 +233,4 @@ export class SearchComponent implements OnInit {

return t[0].localeCompare(t[1]);
}

clickOption(optionElement) {
// simulate a click on the EntityBlock inside the selected option element
optionElement._element.nativeElement.children["0"].children["0"].click();
if (this.showSearchToolbar === true) {
this.showSearchToolbar = false;
}
}

toggleSearchToolbar() {
this.showSearchToolbar = !this.showSearchToolbar;
}
}
Loading

0 comments on commit 6995476

Please sign in to comment.