4

Working on AutoComplete that pulls cities from an API and then allows users to search for trips. The problem is, even though I am using startWith, I first have to click in the field and then start typing for it to work but I can't get the drop down to immediately show as the user focuses on that input box. As a solution I want to call it after the subscription that populates the cities variable. How would I do this? Should the list as an observable? and then go ahead and resubscribe?

import { CityService } from "./services/city-list.service";
import { Component, OnInit, OnDestroy } from "@angular/core";
import { City } from "../cities/models/city";
import { Subscription, Observable } from "rxjs";
import { map, startWith, debounceTime } from "rxjs/operators";
import { FormGroup, FormControl, Validators, NgForm } from "@angular/forms";

@Component({
  selector: "<app-cities></app-cities>",
  templateUrl: "./city-list.component.html",
  styleUrls: ["./cities-list.component.css"]
})
export class CityListComponent implements OnInit, OnDestroy {
  cities: City[]=[];
  private citiesSub: Subscription;
  currentCity: Observable<City[]>;


  destinationCity: FormControl =  new FormControl();
  originCity: FormControl =  new FormControl();
  startDate: FormControl = new FormControl();



  constructor(public cityService: CityService) {}


  ngOnInit() {
    this.cityService.getCities();
    this.citiesSub = this.cityService
      .getCityUpdateListener()
      .subscribe(cities => {
        this.cities = cities;
    });
    this.currentCity = this.destinationCity.valueChanges
    .pipe(
      debounceTime(100),
      startWith(''),
      map(x=>{
        return this._filter(x);
      }
    ));
  }
private _filter(value: string): City[]{
  const filterValue = value.toLowerCase();
  return(this.cities.filter(option => option.name.toLowerCase().includes(filterValue)));
}

  ngOnDestroy() {
    this.citiesSub.unsubscribe();
  }
}
<mat-card>
  <form (submit)="onLogin(instantFlight)" #instantFlight="ngForm">
    <mat-form-field>
      <input  type="text" id="destinationCity" name="destinationCity" matInput [formControl]="destinationCity" [matAutocomplete]="autoDestination">

      <mat-autocomplete #autoDestination="matAutocomplete">
        <mat-option *ngFor="let c of currentCity | async" [value]="c.code">
          {{c.name}} - {{c.code}}
        </mat-option>
      </mat-autocomplete>
    </mat-form-field>
    <mat-form-field>
    <input  type="text" id="originCity" name="originCity" matInput [formControl]="originCity" [matAutocomplete]="autoOrigin">

    <mat-autocomplete #autoOrigin="matAutocomplete">
      <mat-option *ngFor="let c of cities" [value]="c.code">
        {{c.name}} - {{c.code}}
      </mat-option>
    </mat-autocomplete>
  </mat-form-field>
  <mat-form-field>
      <input matInput id="startDate" name="startDate" [formControl]="startDate" [matDatepicker]="picker" placeholder="Choose a date">
      <mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
      <mat-datepicker #picker></mat-datepicker>
    </mat-form-field>
    <button mat-raised-button type="submit" color="accent">Search</button>
  </form>
</mat-card>

WITH UPDATED CODE

import { CityService } from "./services/city-list.service";
import { Component, OnInit, OnDestroy } from "@angular/core";
import { City } from "../cities/models/city";
import { Subscription, Observable } from "rxjs";
import { map, filter, startWith, withLatestFrom, debounceTime } from "rxjs/operators";
import { FormGroup, FormControl, Validators, NgForm } from "@angular/forms";
import {forkJoin} from 'rxjs';
import { pipe } from "../../../node_modules/@angular/core/src/render3/pipe";

@Component({
  selector: "<app-cities></app-cities>",
  templateUrl: "./city-list.component.html",
  styleUrls: ["./cities-list.component.css"]
})
export class CityListComponent implements OnInit, OnDestroy {
  cities: City[]=[];
  private citiesSub: Subscription;
  currentCity: Observable<City[]>;
  testCities: Observable<City[]>;

  destinationCity: FormControl =  new FormControl();
  originCity: FormControl =  new FormControl();
  startDate: FormControl = new FormControl();

  constructor(public cityService: CityService) {}

  ngOnInit() {
    this.cityService.getCities();
    this.testCities = this.cityService
      .getCityUpdateListener();

    this.currentCity = this.destinationCity.valueChanges
    .pipe(
      withLatestFrom(this.testCities),
      debounceTime(100),
      map((x) =>{
       return this._filter(x);
            }
    ));

  }

private _filter(value): City[]{
  const filterValue = value.toLowerCase();
  return(this.testCities.filter(option => option.name.toLowerCase().includes(filterValue)));
}

  ngOnDestroy() {
    this.citiesSub.unsubscribe();
  }
}
Pari Baker
  • 696
  • 4
  • 19

2 Answers2

1

In this case startWith will in fact emit that empty string value and your map function, but that first emission is already done before this.cities is assigned. The next emission would be in fact when valueChanges emits again.

So, we can run that map method when the first cities Observable emits instead. In practice, we just want to run that map method when either Observable emits. We can accomplish that with a little refactoring and withLatestFrom:

  ngOnInit() {
    this.cityService.getCities();
    this.cities = this.cityService.getCityUpdateListener();

    this.currentCity = this.destinationCity.valueChanges
    .pipe(
      debounceTime(100),
      withLatestFrom(this.cities)
      map([value, cities] => cities.filter(s => s.name.toLowerCase().includes(value.toLowerCase)));
    ));

  }

withLatestFrom will wait for the given Observable to emit at least one value before continuing the stream. Since it's the slower Observable here, the map function will only run once it's emitted something. It also emits a pairwise value from both observables, so some destructuring took care of that.

We can also alter your _filter function to accept a cities parameter or just do the filter inline, since we don't have the this.cities static array value anymore. I like the second approach as it keeps all the data relevant to the stream contained in one stream.

Also, this change requires the async pipe in your markup when repeating on cities. That's good though, because the async pipes handles unsubscribing automatically.

joh04667
  • 7,159
  • 27
  • 34
  • I have tried this and it seems to be working, however now that I do not have my cities array and instead am using a testCities observable I cannot use .filter I have to use rxjs' .filter on an observable, but I get an error – Pari Baker Jul 24 '18 at 18:06
  • Well, `withLatestFrom` will also output the value from the second Observable, so we can merge it into the same stream. I'll update my answer. – joh04667 Jul 24 '18 at 20:33
  • right but I couldnt do it with cities since it is a variable of type City[], and updateListener() is of type observable. Instead I used another observable but then I couldn't use the .filter method – Pari Baker Jul 25 '18 at 14:40
  • Just update `cities`' type to `Observable` and take a look at my updated code – joh04667 Jul 25 '18 at 15:01
  • Worked! Just need to post an updated answer with the correct syntax – Pari Baker Jul 25 '18 at 16:02
  • needed to change which one comes first and which one comes second, as well as remembering to return the value to the observable so it is not void. – Pari Baker Jul 25 '18 at 16:05
0

import { CityService } from "../services/city-list.service";
import { Component, OnInit, OnDestroy } from "@angular/core";
import { City } from "../models/city";
import { Subscription, Observable } from "rxjs";
import { map, filter, startWith, withLatestFrom, debounceTime } from "rxjs/operators";
import { FormGroup, FormControl, Validators, NgForm } from "@angular/forms";

@Component({
  selector: 'app-city-list',
  templateUrl: './city-list.component.html',
  styleUrls: ['./city-list.component.css']
})
export class CityListComponent implements OnInit {

  cities: Observable<City[]>;
  private citiesSub: Subscription;
  currentCity: Observable<City[]>;
  testCities: Observable<City[]>;

  destinationCity: FormControl =  new FormControl();
  originCity: FormControl =  new FormControl();
  startDate: FormControl = new FormControl();

  constructor(public cityService: CityService) {}

 ngOnInit() {
  this.cityService.getCities();
  this.cities = this.cityService.getCityUpdateListener();
  this.currentCity = this.destinationCity.valueChanges
    .pipe(
      withLatestFrom(this.cities),
       debounceTime(100),
      map(
        ([first, second]) =>{
       return this._filter(first,second);
            }
    )
  );
 }

 private _filter(first, second): City[]{
  const filterValue = first.toLowerCase();
  return(second.filter(option => option.name.toLowerCase().includes(filterValue)));
}

  }
<mat-card>
  <form #instantFlight="ngForm">
    <mat-form-field>
      <input  type="text" id="destinationCity" name="destinationCity" matInput [formControl]="destinationCity" [matAutocomplete]="autoDestination">

      <mat-autocomplete #autoDestination="matAutocomplete">
        <mat-option *ngFor="let c of currentCity|async" [value]="c.code">
          {{c.name}} - {{c.code}}
        </mat-option>
      </mat-autocomplete>
    </mat-form-field>
    <mat-form-field>
    <input  type="text" id="originCity" name="originCity" matInput [formControl]="originCity" [matAutocomplete]="autoOrigin">

    <mat-autocomplete #autoOrigin="matAutocomplete">
      <mat-option *ngFor="let c of currentCity|async" [value]="c.code">
        {{c.name}} - {{c.code}}
      </mat-option>
    </mat-autocomplete>
  </mat-form-field>
  <mat-form-field>
      <input matInput id="startDate" name="startDate" [formControl]="startDate" [matDatepicker]="picker" placeholder="Choose a date">
      <mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
      <mat-datepicker #picker></mat-datepicker>
    </mat-form-field>
    <button mat-raised-button type="submit" color="accent">Search</button>
  </form>
</mat-card>
Pari Baker
  • 696
  • 4
  • 19