Home > Back-end >  How do you filter an Observable with form input?
How do you filter an Observable with form input?

Time:01-04

I have a component with this "countries$" variable:

  countries$!: Observable<Country[]>;

that I'm populating with this data in an "ngOnInit" like this:

  ngOnInit(){
    this.countries$ = this.apiService.getAllCountries();
  }

and I'm accessing this variable/Observable in the html template like this:

<div>
  <app-country-card *ngFor="let country of countries$ | async" [country]="country"></app-country-card>
</div>

I want to include a search bar that filters the countries down to whatever is typed in.

I thought I could use the filter function inside a pipe like this:

  searchFilterCountries(searchTerm: string){
    this.countries$.pipe(filter((country: any) => country.name.common.toLowerCase().includes(searchTerm.toLowerCase())))
  }

and put the input in the html template like this:

<input type="text"  (input)="searchFilterCountries($event.target.value)"/>

so that the filter function would fire every time theres an input, narrowing down the list of countries on display.


This doesn't work however. I'm getting the typescript error:

Object is possibly 'null'.ngtsc(2531)

Property 'value' does not exist on type 'EventTarget'.ngtsc(2339)


Then I found a "sample" of a working filtered list here on Material UI

https://material.angular.io/components/autocomplete/examples (The FILTER one)

I attempted to implement this and came up with this code:

 export class HomeComponent {
      countries$!: Observable<Country[]>;
      myControl = new FormControl('');
      constructor(private apiService: ApiService) { }
    
      ngOnInit(){
        this.countries$ = this.apiService.getAllCountries();
      }
    
    
      private _filter(value: string): Observable<Country[]> {
        const filterValue = value.toLowerCase();
    
        return this.countries$.pipe(filter(option => option.name.common.toLowerCase().includes(filterValue))) <----ERROR #2
      }
    
    }

It doesn't work however. I think because the values are observables, not the data inside the observable.

I have squiggly lines showing a TS error under the under the "name" property in "option.name.common" saying:

option.name.common TS error

Property 'name' does not exist on type 'Country[]'

If I do this instead though:

 option => option[0].name.common.toLowerCase().includes(filterValue)))

the error goes away, but I wouldn't be able to search all the values if I did that.


Am I on the right track here? Am I using the right operators? How do I fix the TS errors? I'm new to angular and don't know all the operators available. If I use mergeMap/switchMap will that solve my problem? If I do fix the typescript errors would it even work? Or is my approach wrong?

Can somebody help me get this working?

CodePudding user response:

Example pipe

import { Pipe, PipeTransform } from '@angular/core';
import { Country } from './country';

@Pipe({
  name: 'filterList',
})
export class FilterListPipe implements PipeTransform {
  transform(countries: Country[]|null, searchText: string): Country[] {
    if(!countries) return []
    return countries.filter(country=>country.name.indexOf(searchText) != -1);
  }
}

app.component.html

<form [formGroup]="controlsGroup">
  <input type="text" formControlName="searchInput"/>

  <div *ngFor="let country of countries | async | filterList:searchText">
    <div>Name: {{country.name}}</div>
    <div>Ranking: {{country.ranking}}</div>
    <div>Metric: {{country.metric}}</div>
  </div>
</form>

app.component.ts

import { Component } from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { Country } from './country';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'piper-example-app';
  searchText = ''

  controlsGroup: FormGroup

  constructor(public fb:FormBuilder){
    this.controlsGroup = fb.group({
      searchInput: new FormControl('')
    })

    this.controlsGroup.get('searchInput')?.valueChanges.subscribe(value => this.searchText=value)
  }

  countries: Observable<Country[]> = of([{
    name: 'United States of America',
    ranking: 1,
    metric: 'burgers per capita'
  },
  {
    name: 'China',
    ranking: 9000,
    metric: 'power level lower bound'
  }])
}

Admittedly I'm doing a few things that are "dirty" here where filtering the incoming observable stream of arrays of countries might be a bit more efficient. Also note you'd need to still expand the filter function to check all the properties (can use for(prop in obj) type loop to iterate over all properties to see if any of them matches the searchText or adjust the criteria as see fit.

Bit more of a complete example showing the filter part with different types of properties being filtered slightly differently:

filter-list.pipe.ts (alternative)

import { Pipe, PipeTransform } from '@angular/core';
import { Country } from './country';

@Pipe({
  name: 'filterList',
})
export class FilterListPipe implements PipeTransform {
  transform(countries: Country[]|null, searchText: string): Country[] {
    if(!countries) return []
    return countries.filter(country => {
      let foundMatch = false;
      let property: keyof typeof country
      for(property in country) {
        if(typeof country[property] === 'string') {
          if((country[property] as string).indexOf(searchText) != -1)
          foundMatch = true
        }else {
          if((country[property] as number) == parseInt(searchText))
          foundMatch = true
        }
      }
      return foundMatch
    });
  }
}

CodePudding user response:

I would like to expand on your current code and suggest some changes like this:

export class HomeComponent {
  allCountries: Country[] = [];
  countries$!: Observable<Country[]>;
  myControl = new FormControl('');
  constructor(private apiService: ApiService) {}

  ngOnInit() {
    this.apiService
      .getAllCountries().subscribe(countries => this.allCountries = countries);

    this.countries$ = this.myControl.valueChanges.pipe(
      startWith(''),
      map(searchTerm => this._filter(searchTerm))
    );
  }

  private _filter(value: string | null): Country[] {
    if (value === null) {
      return this.allCountries;
    }
    const filterValue = value?.toLowerCase();

    return this.allCountries.filter(country => country.name.common.toLowerCase().includes(filterValue));
  }
}

So we're keeping the original country list in a separate variable, and we are using the form control's valueChange event to filter the countries that we need to display.

The template should look like this:

<input type="text" [formControl]="myControl" />

<div *ngFor="let country of countries$ | async">
  <div>Name: {{ country.name.common }}</div>>
</div>
  • Related