← Back to blog

Angular full-text search in Firestore with Elastic App Search Firebase Extension

Darren Ackers

Lead Developer

6th February, 2023

Need a custom tool? Invertase can help

Tired of investing in off-the-shelf software that doesn't quite fit your business? Invertase can build solutions tailored to your exact needs. Tell us what you're looking for here and we'll be in touch.

Introduction

This tutorial provides a guide on how to use the Elastic App Search Extension, in order to quickly and efficiently search data saved in your Firestore Database.

This extension works by syncing your configured Firestore data and automatically syncing it with the Elastics database.

Here we will build a search bar using Firebase, Angular, Elastic, and TailwindCSS

Getting started

Before starting the tutorial, we are required to have an API key to use the Elastic Search Firebase Extension.

If already signed up, you can skip the next step:

Without an existing account, you can visit Elastic and select to start a free trial.

Next, continue with the signup process…

Provide a name for your deployment and select create deployment

Next, we will be required to set up a new search engine. Navigate to App Search -> Engines

Select to create a new engine.

Keep App search managed docs selected and continue.

Finally, add an engine called movies and select create search engine

Once we have the search engine, navigate back to engines as we now need to add a meta engine.

Select to create a new meta engine

Add a name for the meta engine, and select the engine to be our previously created movies engine.

Now, let’s install the Elastic Search Firebase Extension.

  1. Navigate to https://extensions.dev
  2. Search for Elastic and select install the extension.

After selecting which project to install the extension, we now have the option to configure the extension to our preferences:

Set your preferred location for the Installation.

Add movies as the Collection path

Add movies as the Elastic `App Search engine name`

We can access the API key by navigating to App Search -> Credential

Copy the private key and add it to your configuration and select create secret.

The Elastic Enterprise Search URL, will also find this as yours, on the Elastic credentials page – copy this and add it to your configuration.

Finally, let’s define the indexes that we want to search on. To keep this example, simply enter `name`.

Next, click install.

Creating an app

For this example, we are going to develop in Angular (v15), using TailwindCSS as our styling framework.

To start with, we will require Angular CLI, so go ahead and globally install this dependency:

npm install -g @angular/cli

Next, let’s create a new project:

ng new my-search-app

Feel free to choose Angular routing as an option, although it is not a requirement for this demo.

Choose `CSS` as your styling framework, we will be modifying this later anyway!

Next, let’s move into our new directory and run the app to make sure everything is working as expected:

cd my-search-app/ && ng server

Visiting the website running on the provided port and you will see the following:

Nice! Ok, now it’s time to add our styling framework, fortunately, Tailwind has a handy tutorial making this quick and easy to get started.

Adding Tailwind CSS

Following the instructions, we are required to run the following commands:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init

Next, update tailwind.config.js to the following:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{html,ts}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Update `src/styles.css` with the following imports:

@tailwind base;
@tailwind components;
@tailwind utilities;

To ensure that we have fully installed Tailwind.

Navigate to src/app/app.component.html and update the following line:

  <!-- Resources -->
  <div class="text-3xl font-bold underline">Resources</div>
  <p>Here are some links to help you get started:</p>

The resources tag should now be underlined to demonstrate we are using Tailwind.

  1. Restart the server if needed.
  2. Remember to clear the browser cache if results do not immediately show.

Ok, so we have an app running and our styling framework added. What’s next?

Creating the Movies component

Run the following command:

ng generate component movies

This will create a new folder called app/movies and generate the following files:

  • movies.component.css
  • movies.component.html
  • movies.component.spec.html
  • movies.component.ts

This component will sit inside the main app component, let’s change the app.component.html to only show a new header along with this new component:

<!-- header -->
<div class="bg-blue-600 p-4 text-white text-center text-lg" role="banner">
  <span>Movie Search</span>
</div>

<div>
  <app-movies></app-movies>
</div>

So far so good!

Next, create a search box – this will allow us to filter on films when we have the correct data. Update `movies.component.html` to the following:

<div class="flex-col space-y-12 w-full p-8">
  <div class="flex justify-center">
    <div class="w-1/2">
      <div
        class="border border-solid border-gray-300 rounded-md flex flex-col p-4 space-y-8"
      >
        <div class="w-full flex space-x-2">
          <svg
            aria-hidden="true"
            focusable="false"
            data-prefix="fas"
            data-icon="search"
            class="w-4 text-gray-300"
            role="img"
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 512 512"
          >
            <path
              fill="currentColor"
              d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z"
            ></path>
          </svg>
        </div>
      </div>
    </div>
  </div>
</div>

Now we have a search box – we should look next to find out data…

Setting up the data models.

Using the Elastic App Search API reference. We can define our models for receiving the data when we introduce our search functions. Based on this information, create a new file called `src/app/movie.ts` and add the following:

export interface RawValue {
  [key: string]: any;
}

export interface Movie {
  id: RawValue;
  name: RawValue;
  description: RawValue;
}

export interface Page {
  current: number;
  total_pages: number;
}

export interface Meta {
  page: Page;
}

export interface Movies {
  results: Movie[];
  meta: Meta;
}

These models should match the expected response for our API.

Adding a service to the component

Now we have a model, we can add a service to retrieve the search results.

To do this, we are going to create an HTTP service to request data based on the Elastic Search API.

First, we need to add the ability client module to make an HTTP request and inject it into the main application.

Configuring the HTTP Client Module

Let’s add the dependency through NPM:

npm i @angular/common/http'

Add the following to `src/app/app.module.ts`:

import { HttpClientModule } from '@angular/common/http';

And include as part of the modules imports:

 imports: [
    BrowserModule,
    HttpClientModule
  ],

Generating a Movie Service

Run the following command to create a new movie service file.

ng generate service movies

This will create two new files:

  1. movies.service.spec.ts
  2. movies.service.ts

Next, move these into the src/app/movies folder.

And update the `movies.service.ts` file to:

import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpHeaders } from '@angular/common/http';

import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';

import { Movies } from './movie';
import { HttpErrorHandler, HandleError } from '../http-error-handler.service';

const httpOptions = {
  headers: new HttpHeaders({
    'Content-Type': 'application/json',
    Authorization: 'Bearer {private_key}',
  }),
};

@Injectable()
export class MoviesService {
  hostUrl = '{hostUrl}';
  moviesDocumentsUrl = `${this.hostUrl}/api/as/v1/engines/movies/documents/list`;
  moviesSearchUrl = `${this.hostUrl}/api/as/v1/engines/movies/search`;
  private handleError: HandleError;

  constructor(private http: HttpClient, httpErrorHandler: HttpErrorHandler) {
    this.handleError = httpErrorHandler.createHandleError('MoviesService');
  }

  /* GET Movies whose name contains search term */
  searchMovies(term: string): Observable<Movies> {
    term = term.trim();

    // Add safe, URL encoded search parameter if there is a search term
    const options = {
      params: new HttpParams().set('query', term),
    };

    return this.http
      .get<Movies>(this.moviesSearchUrl, { ...httpOptions, ...options })
      .pipe(catchError(this.handleError<Movies>('searchMovies')));
  }
}

Ensure that you replace the appropriate variables with your own credentials and URLs.

Navigate to credentials:

  • private_key: Can be found as the private key, select to copy this key.
  • Host Url: This is the endpoint URL located at the top of the page.

The search movies function will receive a string (term), which will be used to pass the name query to the Elastic API.

Error Handling Service

Now we have created an HTTP service, we will require error handling. Although we are not expecting any errors and that is not part of this tutorial, this aspect at least provides good practices for when expanding beyond this demo.

Taking inspiration from the excellent StackBlitz, let’s look into adding error support for our service.

To add the HTTPErrorHandler, add a new file called src/app/http-error-handler.service.ts and add the following:

import { Injectable } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';

import { Observable, of } from 'rxjs';

/** Type of the handleError function returned by HttpErrorHandler.createHandleError */
export type HandleError = <T>(
  operation?: string,
  result?: T
) => (error: HttpErrorResponse) => Observable<T>;

/** Handles HttpClient errors */
@Injectable()
export class HttpErrorHandler {
  constructor() {}

  /** Create curried handleError function that already knows the service name */
  createHandleError =
    (serviceName = '') =>
    <T>(operation = 'operation', result = {} as T) =>
      this.handleError(serviceName, operation, result);

  /**
   * Returns a function that handles Http operation failures.
   * This error handler lets the app continue to run as if no error occurred.
   *
   * @param serviceName = name of the data service that attempted the operation
   * @param operation - name of the operation that failed
   * @param result - optional value to return as the observable result
   */
  handleError<T>(serviceName = '', operation = 'operation', result = {} as T) {
    return (error: HttpErrorResponse): Observable<T> => {
      // TODO: send the error to remote logging infrastructure
      console.error(error); // log to console instead

      const message =
        error.error instanceof ErrorEvent
          ? error.error.message
          : `server returned code ${error.status} with body "${error.error}"`;

      // Let the app keep running by returning a safe result.
      return of(result);
    };
  }
}

/*
Copyright 2017-2018 Google Inc. All Rights Reserved.
Use of this source code is governed by an MIT-style license that
can be found in the LICENSE file at http://angular.io/license
*/

Next, inject the HttpErrorHandler through the main application.

import { HttpErrorHandler } from './http-error-handler.service';

Finally, add the MovieService and HttpErrorHandler as providers in your `app.module.ts`

 @NgModule({
  declarations: [AppComponent, MoviesComponent],
  imports: [
    BrowserModule,
    HttpClientModule,
    FormsModule,
    ReactiveFormsModule,
    AngularFireModule.initializeApp(environment.firebase),
  ],
  providers: [MoviesService, HttpErrorHandler], // Add providers
  bootstrap: [AppComponent],
})
export class AppModule {}

Connecting the Movie Service to our Movies component

In order to access this functionality from the UI, we will need to update the movies component to be the interface between the UI and the new service we have just created.

import { Component, OnInit } from '@angular/core';

import { Movie, Movies } from './movie';
import { MoviesService } from './movies.service';

@Component({
  selector: 'app-movies',
  templateUrl: './movies.component.html',
  providers: [MoviesService],
  styleUrls: ['./movies.component.css'],
})
export class MoviesComponent implements OnInit {
  movies: Movies | undefined;
  movieName = '';

  constructor(private moviesService: MoviesService) {}

  ngOnInit(): void {}

  search(searchTerm: string) {
    if (searchTerm) {
      this.moviesService.searchMovies(searchTerm).subscribe((movies) => {
        this.movies = movies;
      });
    }

    this.movies = undefined;
  }
}

Setup forms for Search input

For entering data into our search, we will need to include the Angular Forms module to process our input. Install the dependency…

npm i @angular/forms

Then navigate back the `src/app/app.module.ts` file and included the import:

import { FormsModule } from '@angular/forms';

Then inject it into the main module:

  imports: [
    BrowserModule,
    HttpClientModule,
    FormsModule,
  ],

Now we have a model, service, and a component to interface with – the movies HTML file can include an input for entering the search value:

<div class="flex-col space-y-12 w-full p-8">
  <div class="flex justify-center">
    <div class="w-1/2">
      <div
        class="border border-solid border-gray-300 rounded-md flex flex-col p-4 space-y-8"
      >
        <div class="w-full flex space-x-2">
          <svg
            aria-hidden="true"
            focusable="false"
            data-prefix="fas"
            data-icon="search"
            class="w-4 text-gray-300"
            role="img"
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 512 512"
          >
            <path
              fill="currentColor"
              d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z"
            ></path>
          </svg>
          <input
            type="text"
            [(ngModel)]="movieName"
            id="movie-name"
            placeholder="Search..."
            (keyup)="search(this.movieName)"
            class="outline-none"
          />
        </div>
      </div>
    </div>
  </div>
</div>

Phew, that was a lot! We have now successfully added a service, component, and a view to allow our search component to pass a term to our service which sends an HTTP request to our Elastic Api.

Let’s see how this looks…

A simple test entering test will now start returning data from the search!

You should now see a successful request – our search is working and connected to the Elastic Search engine that we created at the start of the tutorial.

You’ll also notice, that there aren’t any actual search results! This is to be expected as we have not yet added any movies to our database. Let’s fix that…

Adding Movies with Firestore

The Elastic Search extension works by saving the specified collection data in its database when any records are written to Firestore. So let’s add that to our project:

Environment Configuration

Tip: When developing in the latest versions of Angular/AngularFire, limitations mean that cli cannot automatically create the environment files. This tutorial skips the bug, and encourages developers to create these files through environments cli.

https://github.com/angular/angularfire/issues/3290

First, let’s create our environments for configuration.

ng g environments

Then include it as an import, ready for the angular installation.

import { environment } from '../environments/environment';

Add AngularFire

Install AngularFire as a dependency

ng add @angular/fire

When prompted, select include `Firestore` as a selected feature.

Next, select your account and the Firebase project that you would like to use

Select a hosting site

And finally, select your application.

Once the installation has been completed, verify the `environments` folder and ensure your configuration has been correctly updated.

Now that AngularFire has been configured, we can add it to our main application

import { AngularFireModule } from '@angular/fire/compat';

Then inject it as an import

@NgModule({
  declarations: [AppComponent, MoviesComponent],
  imports: [
    BrowserModule,
    HttpClientModule,
    FormsModule,
    ReactiveFormsModule,
    AngularFireModule.initializeApp(environment.firebase),
  ],
  providers: [MoviesService, HttpErrorHandler],
  bootstrap: [AppComponent],
})

Now we have added Firebase to our application, we can look to see how to how transport this information from the UI to the search component, to this we utilize Angular forms.

Creating a form

Angular has a library for creating forms called `ReactiveFormsModule`, let’s add that in our imports from the main app:

import { FormsModule, ReactiveFormsModule } from '@angular/forms';

Then include in the imports

@NgModule({
  declarations: [AppComponent, MoviesComponent],
  imports: [
    BrowserModule,
    HttpClientModule,
    FormsModule,
    ReactiveFormsModule,      AngularFireModule.initializeApp(environment.firebase),
  ],
  providers: [MoviesService, HttpErrorHandler],
  bootstrap: [AppComponent],
})

Now we have the new forms injected as a dependency into the application.

The next step is to navigate back to the `src/app/movies/movies.component.ts` module and add the following function for adding a movie:

Adding Reactive Forms to the Movie component

First, we need to add our imports

import { FormBuilder, FormGroup, Validators } from '@angular/forms';

Next, create a new form above the constructor:

/** Create the new movie form */
newMovieForm!: FormGroup;

Then include a reference inside the constructor below:

  constructor(
    private moviesService: MoviesService,
    private formBuilder: FormBuilder
  ) {}

Create a new form for initialisation

  ngOnInit() {
    this.newMovieForm = this.formBuilder.group({
      name: ['', Validators.required],
    });
  }

Now we have a form available, we can create our new function for submitting the new movie information too…

  async addMovie() {
    const formValue = this.newMovieForm.value as Movie;
    
    //log out new movie data
    console.log(formValue);
  }

Finally, let’s add a text box and submit button to HTML to add a new movie:

  <!-- Add Movie-->  
 <div class="flex justify-center">
    <div class="w-1/2">
      <form [formGroup]="newMovieForm">
        <div class="flex">
          <input
            id="name"
            name="name"
            formControlName="name"
            class="border border-solid border-gray-300 rounded-md flex flex-col w-full py-2 px-2"
          />

          <button
            type="button"
            (click)="addMovie()"
            class="bg-blue-500 hover:bg-blue-700 text-white font-bold px-4 rounded w-1/3"
          >
            Add Movie
          </button>
        </div>
      </form>
    </div>
  </div>

Submitting a new movie should now print out the name of the movie in the console…

Ok, great. We’re nearly there! Let’s add Firestore to our add movie function…

Updating Firestore through the Movie function

Import the required Firestore dependencies:

import {
  AngularFirestore,
  AngularFirestoreCollection,
} from '@angular/fire/compat/firestore';

This allows a firestore reference and a firestore collection reference.

Next, add a private reference above the constructor:

private moviesCollection: AngularFirestoreCollection<Movie>;

Inject Firestore into the constructor and reference the result to the moviesCollection.

Note: Ensure that the collection name matches the name provided in the Extension configuration form the start of the tutorial

  constructor(
    private moviesService: MoviesService,
    firestore: AngularFirestore,
    private formBuilder: FormBuilder
  ) {
    // Edit to match your collection name specified in the Extension configuration
    this.moviesCollection = firestore.collection('movies');
  }

Finally, add the document to a collection when the movie is submitted…

  async addMovie() {
    const formValue = this.newMovieForm.value as Movie;
    await this.moviesCollection.add(formValue);

    /** Reset the form */
    this.newMovieForm.reset({ name: '' });
  }

Updating Typescript Configuration

As is often the case with imported modules and Typescript, we will occasionally see errors like the following:

To fix this we are going to add some additional configuration to the `tsconfig.json` file at the root of our project.

Add the following to the compilerOptions object:

"skipLibCheck": true,

Restart the server if this does not automatically compile.

Now let’s test adding a movie:

Following submission, your Firestore database should have a new record!

Woo! ok we’re nearly, there…

Displaying the results

So we have one last thing to do…and that is to show the results of the search from the Elastic API.

Add the following HTML to src/app/movies/movies.component.html

    <div class="w-1/2">
      <div
        class="border border-solid border-gray-300 rounded-md flex flex-col p-4 space-y-8"
      >
        <div class="w-full flex space-x-2">
          <svg
            aria-hidden="true"
            focusable="false"
            data-prefix="fas"
            data-icon="search"
            class="w-4 text-gray-300"
            role="img"
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 512 512"
          >
            <path
              fill="currentColor"
              d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z"
            ></path>
          </svg>
          <input
            type="text"
            [(ngModel)]="movieName"
            id="movie-name"
            placeholder="Search..."
            (keyup)="search(this.movieName)"
            class="outline-none"
          />
        </div>

        <!-- Display search results -->
        <div class="{{ movies?.results?.length ? 'flex' : 'hidden' }}">
          <ul class="movies">
            <li *ngFor="let movie of movies?.results">
              <span class="name"> {{ movie.name?.raw }} </span>
            </li>
          </ul>
        </div>
      </div>
    </div>

Allowing strict type initialization

Now before we search, one last bit of configuration. You should now see the following error in your app and on the website…

To fix this, go back to your tsconfig.json file and update the following property:

 "strictPropertyInitialization": false

This update will ensure that the typescript compiler ignores any properties that may or not exist. The `?` optional operator will allow this to be safely typed at runtime.

Testing our search component

We made it! Let’s try our search function:

To make sure all updates are working Let’s add another movie…

And let’s test for the new movies we have just added.

And that is everything, we have now introduced Search on our Firebase application!

Here is the full search in action…

Summary

In this tutorial, we have learned how to create an Angular app that allows for the searching of a Firebase collection by using the Elastic App Search API. This is a basic search example and with more fields and indexing, a Firestore Search feature would be a very powerful tool!

Feel free to clone the project from the Invertase samples directory and share your ideas with us. We’d love to see what developers can create using the power of Firebase Extensions!

Stay tuned for more updates and exciting news that we will share in the future. Follow us on Invertase TwitterLinkedin, and Youtube, and subscribe to our monthly newsletter to stay up-to-date. You may also join our Discord to have an instant conversation with us.

Darren Ackers

Lead Developer

Categories

Tags