Description
General search box that provides results out of any kind of entities from the system
as soon as the user starts typing.
This is usually displayed in the app header to be available to the user anywhere, allowing to navigate quickly.
Example
Methods
|
Async
clickOption
|
clickOption(optionElement: unknown)
|
|
|
Parameters :
| Name |
Type |
Optional |
| optionElement |
unknown
|
No
|
|
|
searchResults
|
searchResults(searchTerm: string)
|
|
|
Parameters :
| Name |
Type |
Optional |
| searchTerm |
string
|
No
|
|
|
toggleSearch
|
toggleSearch()
|
|
|
|
|
|
autocomplete
|
Type : MatAutocomplete
|
Decorators :
@ViewChild('autoResults')
|
|
|
|
formControl
|
Type : unknown
|
Default value : new FormControl("")
|
|
|
|
Static
INPUT_DEBOUNCE_TIME_MS
|
Type : number
|
Default value : 400
|
|
|
|
MIN_CHARACTERS_FOR_SEARCH
|
Type : number
|
Default value : 2
|
|
|
|
mobile
|
Type : unknown
|
Default value : signal(false)
|
|
|
|
Readonly
NO_RESULTS
|
Type : number
|
Default value : 3
|
|
|
|
Readonly
NOTHING_ENTERED
|
Type : number
|
Default value : 0
|
|
|
|
Readonly
SEARCH_IN_PROGRESS
|
Type : number
|
Default value : 2
|
|
|
|
searchActive
|
Type : unknown
|
Default value : signal(false)
|
|
|
|
searchInput
|
Type : ElementRef<HTMLInputElement>
|
Decorators :
@ViewChild('searchInput')
|
|
|
|
Readonly
SHOW_RESULTS
|
Type : number
|
Default value : 4
|
|
|
|
state
|
Type : unknown
|
Default value : signal(this.NOTHING_ENTERED)
|
|
|
|
Readonly
TOO_FEW_CHARACTERS
|
Type : number
|
Default value : 1
|
|
|
import {
ChangeDetectionStrategy,
Component,
ElementRef,
signal,
ViewChild,
ViewEncapsulation,
inject,
} from "@angular/core";
import { Entity } from "../../entity/model/entity";
import { BehaviorSubject, Observable, from, of } from "rxjs";
import { switchMap, debounceTime, tap, map, catchError } from "rxjs/operators";
import { Router } from "@angular/router";
import { FormControl, ReactiveFormsModule } from "@angular/forms";
import { UserRoleGuard } from "../../permissions/permission-guard/user-role.guard";
import { MatFormFieldModule } from "@angular/material/form-field";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { MatInputModule } from "@angular/material/input";
import {
MatAutocomplete,
MatAutocompleteModule,
} from "@angular/material/autocomplete";
import { AsyncPipe } from "@angular/common";
import { EntityBlockComponent } from "../../basic-datatypes/entity/entity-block/entity-block.component";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
import { SearchService } from "./search.service";
import { ScreenWidthObserver } from "app/utils/media/screen-size-observer.service";
import { MatButtonModule } from "@angular/material/button";
import { getEntityRuntimeRoute } from "../../entity/entity-config.service";
/**
* General search box that provides results out of any kind of entities from the system
* as soon as the user starts typing.
*
* This is usually displayed in the app header to be available to the user anywhere, allowing to navigate quickly.
*/
@UntilDestroy()
@Component({
selector: "app-search",
templateUrl: "./search.component.html",
styleUrls: ["./search.component.scss"],
encapsulation: ViewEncapsulation.None,
imports: [
MatFormFieldModule,
FontAwesomeModule,
MatInputModule,
ReactiveFormsModule,
MatAutocompleteModule,
EntityBlockComponent,
AsyncPipe,
MatButtonModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchComponent {
private router = inject(Router);
private userRoleGuard = inject(UserRoleGuard);
private searchService = inject(SearchService);
private readonly resultsSubject = new BehaviorSubject<Entity[]>([]);
static INPUT_DEBOUNCE_TIME_MS = 400;
MIN_CHARACTERS_FOR_SEARCH = 2;
readonly NOTHING_ENTERED = 0;
readonly TOO_FEW_CHARACTERS = 1;
readonly SEARCH_IN_PROGRESS = 2;
readonly NO_RESULTS = 3;
readonly SHOW_RESULTS = 4;
state = signal(this.NOTHING_ENTERED);
mobile = signal(false);
searchActive = signal(false);
formControl = new FormControl("");
results: Observable<Entity[]> = this.resultsSubject.asObservable();
@ViewChild("searchInput") searchInput: ElementRef<HTMLInputElement>;
@ViewChild("autoResults") autocomplete: MatAutocomplete;
private currentSearchString = signal("");
constructor() {
const screenWithObserver = inject(ScreenWidthObserver);
screenWithObserver
.platform()
.pipe(untilDestroyed(this))
.subscribe((isDesktop) => this.mobile.set(!isDesktop));
this.formControl.valueChanges
.pipe(
debounceTime(SearchComponent.INPUT_DEBOUNCE_TIME_MS),
tap((next) => {
const searchTerm = this.normalizeSearchTerm(next);
this.currentSearchString.set(searchTerm);
this.state.set(this.updateState(searchTerm));
}),
map((next) => this.normalizeSearchTerm(next)),
switchMap((next: string) => this.searchResults(next)),
untilDestroyed(this),
)
.subscribe((entities) => {
this.resultsSubject.next(entities);
});
}
private updateState(searchTerm: string): number {
if (searchTerm.length === 0) {
return this.NOTHING_ENTERED;
}
return searchTerm.length < this.MIN_CHARACTERS_FOR_SEARCH
? this.TOO_FEW_CHARACTERS
: this.SEARCH_IN_PROGRESS;
}
searchResults(searchTerm: string): Observable<Entity[]> {
// Return empty results for invalid states or empty input
if (this.state() !== this.SEARCH_IN_PROGRESS) {
return of([]);
}
const originalSearchString = searchTerm;
return from(this.searchService.getSearchResults(searchTerm)).pipe(
map((entities) => {
if (this.currentSearchString() !== originalSearchString) {
// Abort because the results are not relevant anymore
return [];
}
const filtered = this.prepareResults(entities);
this.state.set(
filtered.length === 0 ? this.NO_RESULTS : this.SHOW_RESULTS,
);
return filtered;
}),
catchError((_err) => {
this.state.set(this.NO_RESULTS);
return of([]);
}),
);
}
async clickOption(optionElement) {
const route = getEntityRuntimeRoute(optionElement.value.getConstructor());
await this.router.navigate([route, optionElement.value.getId(true)]);
this.formControl.setValue("");
this.state.set(this.NOTHING_ENTERED);
if (this.mobile()) {
this.searchActive.set(false);
}
}
private prepareResults(entities: Entity[]): Entity[] {
return entities.filter((entity) =>
this.userRoleGuard.checkRoutePermissions(
getEntityRuntimeRoute(entity.getConstructor()),
),
);
}
toggleSearch() {
this.searchActive.update((active) => !active);
if (!this.searchActive()) {
this.formControl.setValue("");
this.currentSearchString.set("");
this.state.set(this.NOTHING_ENTERED);
} else {
setTimeout(() => this.searchInput?.nativeElement.focus());
}
}
onFocusOut() {
if (this.mobile() && !this.autocomplete.isOpen) {
this.searchActive.set(false);
}
// Reset state if field is empty when losing focus
const currentValue = this.normalizeSearchTerm(this.formControl.value);
if (currentValue.length === 0) {
this.currentSearchString.set("");
this.state.set(this.NOTHING_ENTERED);
}
}
private normalizeSearchTerm(searchTerm: unknown): string {
return typeof searchTerm === "string" ? searchTerm.trim() : "";
}
}
<div class="white mat-subtitle-2 remove-margin-bottom">
@if (mobile() && !searchActive()) {
<button
mat-icon-button
matTooltip="Search"
i18n-matTooltip="search toolbar icon tooltip"
(click)="toggleSearch()"
>
<fa-icon icon="search"></fa-icon>
</button>
} @else {
<mat-form-field
class="full-width searchbar"
[class.mobile-search-wrapper]="mobile()"
>
<fa-icon
matIconPrefix
class="padding-right-small"
icon="search"
></fa-icon>
<input
#searchInput
matInput
class="full-width search-input"
i18n-title
title="Search"
i18n-placeholder="Search label"
placeholder="Search"
[formControl]="formControl"
[matAutocomplete]="autoResults"
(blur)="onFocusOut()"
/>
@if (mobile()) {
<button mat-icon-button matSuffix (click)="toggleSearch()">
<fa-icon icon="times"></fa-icon>
</button>
}
</mat-form-field>
}
</div>
<mat-autocomplete
#autoResults="matAutocomplete"
(optionSelected)="clickOption($event.option)"
>
@switch (state()) {
@case (TOO_FEW_CHARACTERS) {
<mat-option class="result-hint" [disabled]="true">
<p
i18n="The user has inserted too few characters to start a search"
class="remove-margin-bottom"
>
Insert at least {{ MIN_CHARACTERS_FOR_SEARCH }} characters
</p>
</mat-option>
}
@case (SEARCH_IN_PROGRESS) {
<mat-option class="result-hint" [disabled]="true">
<p i18n="A search is in progress" class="remove-margin-bottom">
Search in progress...
</p>
</mat-option>
}
@case (NO_RESULTS) {
<mat-option class="result-hint" [disabled]="true">
<p i18n="No search results are available" class="remove-margin-bottom">
There were no results
</p>
</mat-option>
}
@default {
@for (res of results | async; track res.getId()) {
<mat-option [value]="res">
<app-entity-block [entity]="res"></app-entity-block>
</mat-option>
}
}
}
</mat-autocomplete>
@use "variables/colors";
.result-hint {
background-color: colors.$grey-light;
font-style: italic;
}
.search-input {
caret-color: white !important;
}
.search-input,
.search-input::placeholder {
color: white !important;
}
.searchbar {
margin-top: 10px;
.mat-mdc-form-field-focus-overlay {
background-color: transparent !important;
}
.mdc-text-field--filled .mdc-line-ripple::before,
.mdc-text-field--active .mdc-line-ripple::before,
.mdc-text-field--filled .mdc-line-ripple::after {
border-bottom-color: white !important;
}
}
.mobile-search-wrapper {
position: absolute;
top: 0;
left: 0;
width: -webkit-fill-available;
z-index: 1000;
background-color: colors.$primary !important;
margin-top: 0;
padding-left: 10px;
.mat-mdc-form-field-subscript-wrapper {
display: none !important;
}
.mdc-line-ripple {
display: none !important;
}
}
Legend
Html element with directive