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.
- Navigate to https://extensions.dev
- 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.
- Restart the server if needed.
- 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:
- movies.service.spec.ts
- 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 Twitter, Linkedin, 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.