src/app/core/ui/search/search.component.ts

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

Metadata

Relationships

Used by

Depends on

Index

Properties
Methods

Constructor

constructor()

Methods

Async clickOption
clickOption(optionElement: unknown)
Parameters :
Name Type Optional
optionElement unknown No
Returns : any
onFocusOut
onFocusOut()
Returns : void
searchResults
searchResults(searchTerm: string)
Parameters :
Name Type Optional
searchTerm string No
toggleSearch
toggleSearch()
Returns : void

Properties

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
results
Type : Observable<Entity[]>
Default value : this.resultsSubject.asObservable()
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>

./search.component.scss

@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
Component
Html element with directive

results matching ""

    No results matching ""