Angular Text Search
Oct 08 2018 11:36am | | Share

Angular Text Search

By Alain Thibodeau

It is common in modern web applications to have textual searches that query a remote service. This post will explore one way of accomplishing this using Angular 6 and RxJS 6.

We will be generating an app with Angular CLI, then building our search component along with its service. Our app will be simple with one text input and an area to display the results from the server in a list. The user will then be allowed to load the next page of results with a next button. Along the way we will discover an issue and explore one way of addressing it, then discuss further options. So, let’s get started.

 

The set up

Let’s create all our boilerplate files using Angular CLI up front.

 

If you haven’t done so already, install Angular CLI (I am using version 6.1.5):

npm install -g @angular/cli

 

Let’s create a new project:

ng new ng-search

 

and move to its directory:

cd ng-search

 

we now need to create a component:

ng generate component ./views/search

 

and this component will need a service:

ng generate service ./core/services/search
 

Once this is complete, we need to serve the app:

ng serve

 

You should now have the CLI sample application running at http://localhost:4200.

 

Our service

For our sample data we will use the great free service jsonplaceholder. This service offers various resources and also allows for search and pagination. We will be using its ‘/post’ resource which returns an array with posts objects in JSON format. The post objects look like this:

 


{

    "userId": 1,

    "id": 1,

    "title": "This is the title",

    "body": "This is the body"

}

 

Now that we know what the data will look like, let’s wrap our data requests in the service we created. Open up the “./src/app/core/services/search.service.ts” file and add the below code to it:

 


import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/internal/Observable';
import { HttpClient } from '@angular/common/http';

@Injectable({
    providedIn: 'root'
})
export class SearchService {
    private path = 'https://jsonplaceholder.typicode.com/posts';
    private defaultLimit = 5;

    constructor(private http: HttpClient) {
    }

    search(term: string, page: number, limit: number = this.defaultLimit): Observable<any> {
        return this.http.get(`${this.path}?q=${term}&_page=${page}&_limit=${limit}`);
    }
}

 

We created two private properties in this service to hold our service “path” and our “defaultLimit”. This relates to the server path and how many items we want to receive per response.

 

We declared a public search method which prepares the request URL from passed the arguments term, page and limit. If no limit is passed, we will use our local default limit of 5. This method returns the injected http client’s get request observable which can then be subscribed to by anyone interested.

 

Adding required modules

We will be using a few specific Angular modules. Before we go any further, we should take the time to import them, otherwise we will get errors. Open the “src/app/app.module.ts” and in the imports lets add the HttpClientModule and ReactiveFormsModule. Your module should now look like this:
 


import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { SearchComponent } from './views/search/search.component';
import { HttpClientModule } from '@angular/common/http';
import { ReactiveFormsModule } from '@angular/forms';

@NgModule({
    declarations: [
        AppComponent,
        SearchComponent
    ],
    imports: [
        BrowserModule,
        HttpClientModule,
        ReactiveFormsModule
    ],
    providers: [],
    bootstrap: [AppComponent]
})
export class AppModule {
}

Notice how the Angular CLI had already declared the “SearchComponent” for us.

 

Our component

Our search component will reside on the main page, so let’s add it there.

 

Open the file “src/app/application.component.html” and delete what is there. We will add our new component to this page.

<app-search></app-search>

 

If all went well, in your browser at http://localhost:4200 you should now only see:

search works!

 

Template

With all the set up behind us we can now get to the implementation of the search. Open “src/app/views/search/search.component.html” template and remove the “search works!” boiler text and add the following:

 


<input type="text" [formControl]="searchInput" placeholder="Search">

<div *ngFor="let post of posts" class="post">

   <h3>{{ post.title }}</h3>

   {{ post.body }}

</div>


<button type="button" *ngIf="posts" (click)="showMore()">Next</button>

 

This template is pretty straight forward and kept simple for clarity. We added an input control that will be bound to a “FormControl” in our upcoming class file. This will allow us to listen for changes from this input control.

 

We also added a “div” which iterates over the server response to display the results of the search.

 

Lastly, the button will allow the user to request the next page of search results.

 

Styles

We won’t win any design awards, but let’s add a few minimal styles in our “src/app/views/search.component.css” like this:

h3 {

margin: 0;

}

 

.post {

margin: 15px 0 ;

}

 

Component class

Now onto our component’s class. We will replace “src/app/views/search.component.ts” with the code below.

 

import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Subject } from 'rxjs/internal/Subject';
import { debounceTime, distinctUntilChanged, filter, scan, startWith, switchMap } from 'rxjs/operators';
import { combineLatest } from 'rxjs/internal/observable/combineLatest';
import { SearchService } from '../../core/services/search.service';

@Component({
    selector: 'app-search',
    templateUrl: './search.component.html',
    styleUrls: ['./search.component.css']
})
export class SearchComponent implements OnInit {
    posts: any
    searchInput: FormControl = new FormControl();
    showMore$: Subject<any> = new Subject();

    constructor(private service: SearchService) {
    }

    ngOnInit() {
        const search$ = this.searchInput.valueChanges.pipe(
            debounceTime(300),
            distinctUntilChanged(),
            filter(term => {
                return term.length >= 3;
            })
        );


        const combined$ = combineLatest(
            search$,
            this.showMore$
                .pipe(
                    startWith(1),
                    scan((page) => page + 1, 0)
                ));

        combined$
            .pipe(switchMap(([searchTerm, page]) => this.service.search(searchTerm, page)))
            .subscribe(response => {
                this.posts = response;
            });
    }

    showMore(): void {
        this.showMore$.next();
    }
}

 

Let’s take a look at what is going on in here:

 

There are three properties in this class.

  • Posts: Holds the response from the server and shared with the template for display
  • searchInput: This is the “FormControl” that is bound to the input control in the template
  • showMore$: An RxJS Subject used to trigger the fetch of the next search page

 

In the “ngOnInit()”, we reference to “search$” the searchInput’s “valueChanges” Observable and pipe in some operators.

  • debounceTime(300) – Wait 300 milliseconds between each entered key, then continue
  • distinctUntilChanged() – Continue only when the current value is different than the last value.
  • filter(term => term.length >= 3) – Continue only when we have a min of 3 characters.

 

For our pagination to work, we need to use the “combineLatest” method to combine our search inputs and our “next” button clicks via the “showMore$” subject. This subject is used as the trigger to tell our search stream to run again with a new page count.

 

const combined$ = combineLatest(

            search$,

            this.showMore$

                .pipe(

                    startWith(1),

                    scan((page) => page + 1, 0)

                ));

 

Take note that in previous versions of RxJS we could use “combineLatest” as an operator, but not anymore. “combineLatest” will also not emit any values unless both “search$” and “showMore$” have emitted at least once. This is why we use the “startWith(1)” operator on the “showMore$” Subject. This is convenient as we need a starting page count as well.

 

We then pipe in the scan operator to keep track of what page we are on. The scan operator is great for remembering the previous count and incrementing it.

 

Now that we have a mechanism that ties in our input and button streams, we can call our service for search results. From the “combined$” observable we can “switchMap” to our service observable and then subscribe for the results. “switchMap” will be passed 2 arguments, which represent the emitted values of our previously combined observables “search$” and “showMore$”.

combined$

            .pipe(switchMap(([searchTerm, page]) => this.service.search(searchTerm, page)))

            .subscribe(response => {

                this.posts = response;

            });

    }

 

If all worked out well, we should be able to search on text values and get results as in this screen capture.

Screen Capture Angular Text Search

All looks good, right?

At first all appears to work well, and this is a good first pass, but there is an issue here. There is no logic for resetting the page count when the search term changes. For example, if we search for a term and “next” to the third result page then change the term, we will end up sending a request for page 3 of the new term. Thus, we need to reset the page count to get the first page of the new search term results.

 

As we try to address this, the goal is to keep everything within the observable stream and avoid side effects. One approach would be to move the scan operator from the “showMore$” subject to the “combined$” observable pipe. Here is the updated code in our “init” method:

 

 ngOnInit() {

        const search$ = this.searchInput.valueChanges.pipe(

            debounceTime(300),

            distinctUntilChanged(),

            filter(term => {

                return term.length >= 3;

            })

        );


        const combined$ = combineLatest(

            search$,

            this.showMore$

                .pipe(

                    startWith(1)

                ));


        combined$

            .pipe(

                scan((acc, [term, page]) => {

                        let value = [term, page];

                        if (acc.length > 0) {//is first pass?

                            if (acc[0] == term) {//new search term?

                                value = [term, acc[1] + 1];

                            } else {

                                value = [term, 1];

                            }

                        }

                        return value;

                    }, []

                ),

                switchMap(([searchTerm, page]) => this.service.search(searchTerm, page))

            )

            .subscribe(response => {

                this.posts = response;

            });

    }

 

Now, there is a bit more going on in here other than incrementing the page count. With the scan operator’s accumulator, we can maintain the state of the search. The scan operator is passed the term and the page arguments from the “combineLatest”, and that is pretty much all we need. The scan’s seed is set to an empty array.

 

Upon the first pass, we update the empty array with the term and initial page number and return these values. These values are remembered by the accumulator for the next pass, which is triggered by a term change or a page update in the “combineLatest”. Take note that our “showMore$” from this point will always come back as undefined because we are not passing anything from the button click to the subject’s “next()”. But that is ok, because after the initial run, we ignore the page argument anyway. We assume that if the accumulator’s term (value from previous pass) is the same as the new term, then the “showMore$” has triggered the “combinedLatest” and we therefore increment the page count. On the other hand, if the terms are not the same, we then need to reset the page count as the user has searched for a new term.

 

This addresses the issue of resetting the page count when the user searches for a new term. The scan operator can come in handy in scenarios like this, where we need to maintain some form of state.

 

Where to from here?

We’ve covered an example of how to create a simple text search that calls a server and paginates the results. Along the way we discovered an issue with the pagination which we fixed with the scan operator. We could further build upon this example.

 

For instance, we could turn the pagination into an infinite scroll, or add a previous button with a page counter. If this was a large application, we could even ensure the search state was preserved upon navigating away and back. Obviously, the more features we add, the more complex the code will become. With RxJS, this might mean several more subjects/observables that create a very large stream that can become hard to maintain. It is at that point we should consider breaking down the large stream into smaller more manageable streams. We could even consider leveraging other approaches like using Angular services and subjects. This would eliminate longer RxJS streams. In the end, the design and approach are a decision you need to make based on the requirements and complexity.