Filter Cities by State page

Learn how to listen to form value changes with Angular.

Overview

In this part, we will:

  • Learn about Observables and Subscriptions
  • Learn about listening to form value changes
  • Learn about AbstractControl properties and methods
  • Create subscription to form changes
  • Use onDestroy to unsubscribe from form changes
  • Learn about HttpParams
  • Create new methods on our RestaurantService
  • Learn about Generics
  • Get state and city data in the Restaurant Component

Problem 1: Listen to Changes on the State and City formControls and log their value to the console

Our end goal is to be able to show restaurants based on state, then city. As we move through getting each piece of information from the user we want to be able to update the next step - like getting a list of cities based on the state selected. We'll implement this form functionality in a few small steps.

P1: What You Need to Know

  • How observables and subscriptions work
  • How to subscribe to the valueChanges method on a FormGroup (or FormControl)
  • How to unsubscribe from subscriptions

Observables and Subscriptions

For a more robust understanding of Observables, Subscriptions, and other RxJS core tenants check out our [RxJS RxJS guide]. For the following exercises, Observables are lazy collections of multiple values over time. We can subscribe to observables to get any new data, or create and add to Subscriptions of observables.

This example shows creating a subscription to an observable, saving it's value to a member on the component and displaying it in the template. This is useful for when we want to capture and observables values and make changes based on them, but subscriptions do need to be cleaned up to avoid memory leaks. Whenever a component is destroyed an ngOnDestroy method is called. This is a good place to put our cleanup code, like unsubscribing from observables.

In this example, click the button to start subscribing to the observables - you'll see two variables logged: the new observable value and the subscription value. Then click the "remove component" button to see what happens when a component is destroyed. Next delete line 95, follow the same process and see what happens!

<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.2.1/rxjs.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/core-js/2.5.7/core.js"/></script>
<script src="https://unpkg.com/@angular/core@7.2.0/bundles/core.umd.js"/></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/zone.js/0.8.26/zone.min.js"></script>
<script src="https://unpkg.com/@angular/common@7.2.0/bundles/common.umd.js"></script>
<script src="https://unpkg.com/@angular/compiler@7.2.0/bundles/compiler.umd.js"></script>
<script src="https://unpkg.com/@angular/router@7.2.0/bundles/router.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser@7.2.0/bundles/platform-browser.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser-dynamic@7.2.0/bundles/platform-browser-dynamic.umd.js"></script>
<script src="https://unpkg.com/@angular/forms@7.2.0/bundles/forms.umd.js"></script>
<base href="/">
<my-app></my-app>
<script type="typescript">
const { Component, NgModule, VERSION, OnInit, OnDestroy, Injectable } = ng.core;
const { BrowserModule } = ng.platformBrowser;
const { CommonModule } = ng.common;
const { Observable, Subscription } = rxjs;

@Injectable({
  providedIn: 'root'
})
class ObsService {

  constructor() { }

  getObs() {

    return new Observable<number>(observer => {
      console.log('New subscription created');
      let count = 0;
      
      const interval = setInterval(() => {
        count++;
        console.log("incrementing", count); 
        observer.next(count);
      }, 1000);
    
      return () => {
        clearInterval(interval);
        console.log('unsubscribed');
      }
    })
  }
}

@Component({
  selector: 'my-app',
  template: `
    <destroy-component *ngIf="yayComponent"></destroy-component>
    <button (click)="removeComponent()" *ngIf="yayComponent">remove component</button>
    <p *ngIf="!yayComponent">I am a very sad empty component now</p>
  `
})
class AppComponent implements OnInit{
  public yayComponent: boolean = true;
  constructor() {
  }
  
  ngOnInit() {
  }

  removeComponent() {
    this.yayComponent = false;
  }
}

@Component({
  selector: 'destroy-component',
  template: `
    <p>This component will be destroyed. Rawr.</p>
    <button (click)="startObservable()">start observable</button>

    <p>{{myDisplayValue}}</p>
    `
})
class DestroyComponent implements OnInit, OnDestroy {
  $mySubscription: Subscription;
  myDisplayValue: string;
  
  constructor(private obsService: ObsService) {}

  ngOnInit() {}

  startObservable() {
    //subscribing to a service that emits observable values every second.
    this.$mySubscription = this.obsService.getObs().subscribe((val) => {
      console.log('new value', val);
      this.myDisplayValue = val;
    });
  }

  ngOnDestroy() {
    console.log('destroying component');
    if(this.$mySubscription) {
      this.$mySubscription.unsubscribe();
    }
  }
}   
DestroyComponent.parameters = [ObsService]; 
                 
@NgModule({
  declarations: [AppComponent, DestroyComponent],
  imports: [
    BrowserModule,
    CommonModule
  ],
  bootstrap: [AppComponent],
  providers: []
})
class AppModule {}

const { platformBrowserDynamic } = ng.platformBrowserDynamic;

platformBrowserDynamic()
  .bootstrapModule(AppModule)
  .catch(err => console.error(err));
</script>


This example shows creating a subscription to an observable, and using an async pipe to display the value. This is useful for displaying observable values in templates without the need to unsubscribe as that's handled by the pipe.

<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.2.1/rxjs.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/core-js/2.5.7/core.js"/></script>
<script src="https://unpkg.com/@angular/core@7.2.0/bundles/core.umd.js"/></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/zone.js/0.8.26/zone.min.js"></script>
<script src="https://unpkg.com/@angular/common@7.2.0/bundles/common.umd.js"></script>
<script src="https://unpkg.com/@angular/compiler@7.2.0/bundles/compiler.umd.js"></script>
<script src="https://unpkg.com/@angular/router@7.2.0/bundles/router.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser@7.2.0/bundles/platform-browser.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser-dynamic@7.2.0/bundles/platform-browser-dynamic.umd.js"></script>
<script src="https://unpkg.com/@angular/forms@7.2.0/bundles/forms.umd.js"></script>
<base href="/">
<my-app></my-app>
<script type="typescript">
const { Component, NgModule, VERSION, OnInit, Injectable } = ng.core;
const { BrowserModule } = ng.platformBrowser;
const { CommonModule } = ng.common;
const { Routes, RouterModule } = ng.router;
const { ReactiveFormsModule, FormControl, FormGroup, FormBuilder } = ng.forms;
const { Observable, Subscription } = rxjs;


@Component({
  selector: 'my-app',
  template: `
    <ul class="nav">
      <li routerLinkActive="active">
        <a routerLink="/about" >About</a>
      </li>
    </ul>
    <router-outlet></router-outlet>
  `
})
class AppComponent {
  constructor() {}
}

@Component({
  selector: 'about-component',
  template: `
    <p>An about component!</p>
  `
})
class AboutComponent {
  constructor() {
  }
}

@Component({
  selector: 'home-component',
  template: `
    <p>A home component!</p>

    {{$mySubscription | async }}
    `
})
class HomeComponent implements OnInit{
  $myObservable: Observable<string>;
  $mySubscription: Subscription;
  
  constructor(private fb:FormBuilder) {
  
  }

  ngOnInit() {
    this.$myObservable = new Observable(observer => {
      setTimeout(() => {
          observer.next('hello observable');
      }, 1000);

      setTimeout(() => {
          observer.next('how are you');
      }, 2000);

      setTimeout(() => {
          observer.complete();
      }, 3000);
  });

   this.$mySubscription = this.$myObservable;

  }
}
//THIS IS A HACK JUST FOR CODEPEN TO WORK
HomeComponent.parameters = [FormBuilder];


const routes: Routes = [
  { path: 'about', component: AboutComponent },
  { path: '**', component: HomeComponent }
]
@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
class AppRoutingModule { }

                 
@NgModule({
  declarations: [AppComponent, AboutComponent, HomeComponent],
  imports: [
    BrowserModule,
    CommonModule,
    AppRoutingModule, 
    ReactiveFormsModule
  ],
  bootstrap: [AppComponent],
  providers: []
})
class AppModule {}

const { platformBrowserDynamic } = ng.platformBrowserDynamic;

platformBrowserDynamic()
  .bootstrapModule(AppModule)
  .catch(err => console.error(err));
</script>


This example creates a subscription, then adds to it.

<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.2.1/rxjs.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/core-js/2.5.7/core.js"/></script>
<script src="https://unpkg.com/@angular/core@7.2.0/bundles/core.umd.js"/></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/zone.js/0.8.26/zone.min.js"></script>
<script src="https://unpkg.com/@angular/common@7.2.0/bundles/common.umd.js"></script>
<script src="https://unpkg.com/@angular/compiler@7.2.0/bundles/compiler.umd.js"></script>
<script src="https://unpkg.com/@angular/router@7.2.0/bundles/router.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser@7.2.0/bundles/platform-browser.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser-dynamic@7.2.0/bundles/platform-browser-dynamic.umd.js"></script>
<script src="https://unpkg.com/@angular/forms@7.2.0/bundles/forms.umd.js"></script>
<base href="/">
<my-app></my-app>
<script type="typescript">
const { Component, NgModule, VERSION, OnInit, Injectable } = ng.core;
const { BrowserModule } = ng.platformBrowser;
const { CommonModule } = ng.common;
const { Routes, RouterModule } = ng.router;
const { ReactiveFormsModule, FormControl, FormGroup, FormBuilder } = ng.forms;
const { Observable, Subscription } = rxjs;


@Component({
  selector: 'my-app',
  template: `
    <ul class="nav">
      <li routerLinkActive="active">
        <a routerLink="/about" >About</a>
      </li>
    </ul>
    <router-outlet></router-outlet>
  `
})
class AppComponent {
  constructor() {}
}

@Component({
  selector: 'about-component',
  template: `
    <p>An about component!</p>
  `
})
class AboutComponent {
  constructor() {
  }
}

@Component({
  selector: 'home-component',
  template: `
    <p>A home component!</p>

    <p>{{myDisplayValue}}</p>

    <p>{{myOtherDisplayValue}}</p>
    `
})
class HomeComponent implements OnInit{
  $myObservable: Observable<string>;
  $myOtherObservable: Observable<string>;
  $mySubscription: Subscription;
  myDisplayValue: string;
  myOtherDisplayValue: string;
  
  constructor(private fb:FormBuilder) {
  
  }

  ngOnInit() {
    this.$myObservable = new Observable(observer => {
      setTimeout(() => {
          observer.next('hello observable');
      }, 1000);

      setTimeout(() => {
          observer.next('how are you');
      }, 2000);

      setTimeout(() => {
          observer.complete();
      }, 3000);
    });

    this.$myOtherObservable = new Observable(observer => {
      setTimeout(() => {
          observer.next('hello other observable');
      }, 1500);

      setTimeout(() => {
          observer.next('I am good');
      }, 2500);

      setTimeout(() => {
          observer.complete();
      }, 3500);
    });

    //creating the first subscription
    this.$mySubscription = this.$myObservable.subscribe((val) => {
      this.myDisplayValue = val;
    });

    //taking the first subscription and adding another to it
    this.$mySubscription.add(this.$myOtherObservable.subscribe((val) => {
      this.myOtherDisplayValue = val;
    }));
   
  }

  ngOnDestroy() {
    this.$mySubscription.unsubscribe();
  }
}
//THIS IS A HACK JUST FOR CODEPEN TO WORK
HomeComponent.parameters = [FormBuilder];


const routes: Routes = [
  { path: 'about', component: AboutComponent },
  { path: '**', component: HomeComponent }
]
@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
class AppRoutingModule { }

                 
@NgModule({
  declarations: [AppComponent, AboutComponent, HomeComponent],
  imports: [
    BrowserModule,
    CommonModule,
    AppRoutingModule, 
    ReactiveFormsModule
  ],
  bootstrap: [AppComponent],
  providers: []
})
class AppModule {}

const { platformBrowserDynamic } = ng.platformBrowserDynamic;

platformBrowserDynamic()
  .bootstrapModule(AppModule)
  .catch(err => console.error(err));
</script>


Listening to Form Changes

We can listen to changes to values on FormControls and FormGroup using the valueChanges method, which emits an observable. The following example subscribes to any changes to the FormGroup (which must be unsubscribed on destroy to avoid memory leaks) and also subscribes to a single FormControl and displays the value using an async pipe.

<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.2.1/rxjs.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/core-js/2.5.7/core.js"/></script>
<script src="https://unpkg.com/@angular/core@7.2.0/bundles/core.umd.js"/></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/zone.js/0.8.26/zone.min.js"></script>
<script src="https://unpkg.com/@angular/common@7.2.0/bundles/common.umd.js"></script>
<script src="https://unpkg.com/@angular/compiler@7.2.0/bundles/compiler.umd.js"></script>
<script src="https://unpkg.com/@angular/router@7.2.0/bundles/router.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser@7.2.0/bundles/platform-browser.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser-dynamic@7.2.0/bundles/platform-browser-dynamic.umd.js"></script>
<script src="https://unpkg.com/@angular/forms@7.2.0/bundles/forms.umd.js"></script>
<base href="/">
<my-app></my-app>
<script type="typescript">
const { Component, NgModule, VERSION, OnInit, Injectable } = ng.core;
const { BrowserModule } = ng.platformBrowser;
const { CommonModule } = ng.common;
const { ReactiveFormsModule, FormControl, FormGroup, FormBuilder } = ng.forms;

@Component({
  selector: 'my-app',
  template: `
    <p>A home component!</p>
    <form [formGroup]="myQuickForm">
      <label>
          First name:
          <input type="text" formControlName="firstName">
        </label>
        <label>
          Last name:
          <input type="text" formControlName="lastName">
        </label>
        <label>
          Email:
          <input type="text" formControlName="email">
        </label>
    </form>
  `
})
class AppComponent {
  myQuickForm: FormGroup;

  constructor(private fb:FormBuilder) {
  
  }

  ngOnInit() {
    this.myQuickForm = this.fb.group({
      firstName: {value: '', disabled: false},
      lastName: {value: '', disabled: false},
      email: {value: '', disabled: false}
    });

    this.myQuickForm.valueChanges.subscribe((val) => {
      console.log(val);
    });
  }
}
//THIS IS A HACK JUST FOR CODEPEN TO WORK
AppComponent.parameters = [FormBuilder];
            
@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    CommonModule,
    ReactiveFormsModule
  ],
  bootstrap: [AppComponent],
  providers: []
})
class AppModule {}

const { platformBrowserDynamic } = ng.platformBrowserDynamic;

platformBrowserDynamic()
  .bootstrapModule(AppModule)
  .catch(err => console.error(err));
</script>


Call Methods on FormControls

The ReactiveForms API makes it easy for us to change our FormControls as needed. As a reminder, the FormControl class extends the AbstractControl class which has a lot of helpful properties and methods on it. The following example shows enabling and disabling controls via the enable and disable methods, and displaying the enabled FormControl property.

<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.2.1/rxjs.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/core-js/2.5.7/core.js"/></script>
<script src="https://unpkg.com/@angular/core@7.2.0/bundles/core.umd.js"/></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/zone.js/0.8.26/zone.min.js"></script>
<script src="https://unpkg.com/@angular/common@7.2.0/bundles/common.umd.js"></script>
<script src="https://unpkg.com/@angular/compiler@7.2.0/bundles/compiler.umd.js"></script>
<script src="https://unpkg.com/@angular/router@7.2.0/bundles/router.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser@7.2.0/bundles/platform-browser.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser-dynamic@7.2.0/bundles/platform-browser-dynamic.umd.js"></script>
<script src="https://unpkg.com/@angular/forms@7.2.0/bundles/forms.umd.js"></script>
<base href="/">
<my-app></my-app>
<script type="typescript">
const { Component, NgModule, VERSION, OnInit, Injectable } = ng.core;
const { BrowserModule } = ng.platformBrowser;
const { CommonModule } = ng.common;
const { ReactiveFormsModule, FormControl, FormGroup, FormBuilder } = ng.forms;

@Component({
  selector: 'my-app',
  template: `
    <p>A home component!</p>
    <form [formGroup]="myQuickForm">
      <label>
          First name:
          <input type="text" formControlName="firstName">
          <p>this control is enabled: {{myQuickForm.controls.firstName.enabled}}</p>
          <button (click)="toggleControl(myQuickForm.controls.firstName)">toggle firstName control</button><br />
        </label>
        <label>
          Last name:
          <input type="text" formControlName="lastName">
          <p>this control is enabled: {{myQuickForm.controls.lastName.enabled}}</p>
          <button (click)="toggleControl(myQuickForm.controls.lastName)">toggle lastName control</button><br />
        </label>
        <label>
          Email:
          <input type="text" formControlName="email">
          <p>this control is enabled: {{myQuickForm.controls.email.enabled}}</p>
          <button (click)="toggleControl(myQuickForm.controls.email)">toggle email control</button><br />
        </label>
    </form>

  `
})
class AppComponent {
  myQuickForm: FormGroup;

  constructor(private fb:FormBuilder) {}

  ngOnInit() {
    this.myQuickForm = this.fb.group({
      firstName: {value: '', disabled: false},
      lastName: {value: '', disabled: false},
      email: {value: '', disabled: false}
    });

    this.myQuickForm.valueChanges.subscribe((val) => {
      console.log(val);
    });
  }

  toggleControl(control) {
    let ctrl = control as FormControl;
    if(ctrl.enabled) {
      ctrl.disable();
    }
    else {
      ctrl.enable();
    }
  }
}
//THIS IS A HACK JUST FOR CODEPEN TO WORK
AppComponent.parameters = [FormBuilder];
            
@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    CommonModule,
    ReactiveFormsModule
  ],
  bootstrap: [AppComponent],
  providers: []
})
class AppModule {}

const { platformBrowserDynamic } = ng.platformBrowserDynamic;

platformBrowserDynamic()
  .bootstrapModule(AppModule)
  .catch(err => console.error(err));
</script>


P1: Technical Requirements

  1. Subscribe to the state and city formControl value changes and log the resulting value to the console.
  2. Unsubscribe from subscription created in step 1 in the ngOnDestroy function

P1: To Verify Your Solution is Correct

When you interact with the dropdown menus, you should see their values logged to the console as you change them.

P1: Solution

✏️ Update src/app/restaurant/restaurant.component.ts

import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { Subscription } from 'rxjs';

import { RestaurantService, ResponseData } from './restaurant.service';
import { Restaurant } from './restaurant';

export interface Data {
  value: Array<Restaurant>;
  isPending: boolean;
}

@Component({
  selector: 'pmo-restaurant',
  templateUrl: './restaurant.component.html',
  styleUrls: ['./restaurant.component.less']
})
export class RestaurantComponent implements OnInit, OnDestroy {
  form: FormGroup;

  public restaurants: Data = {
    value: [],
    isPending: false
  }

  public states = {
    isPending: false,
    value: [{name: "Illinois", short: "IL"}, {name: "Wisconsin", short: "WI"}]
  };

  public cities = {
    isPending: false,
    value: [{name: "Springfield"},{name: "Madison"}]
  }

  private subscription: Subscription;

  constructor(
    private restaurantService: RestaurantService,
    private fb: FormBuilder
    ) {
  }

  ngOnInit() {
    this.createForm();

    this.restaurantService.getRestaurants().subscribe((res: ResponseData) => {
      this.restaurants.value = res.data;
      this.restaurants.isPending = false;
    });
  }

  ngOnDestroy() {
    if(this.subscription) {
      this.subscription.unsubscribe();
    }
  }

  createForm() {
    this.form = this.fb.group({
      state: {value: '', disabled: false},
      city: {value: '', disabled: false},
    });

    this.onChanges();
  }
  onChanges(): void {
    const stateChanges = this.form.get('state').valueChanges.subscribe(val => {
      console.log('state', val);
    });
    this.subscription = stateChanges;


    const cityChanges = this.form.get('city').valueChanges.subscribe(val => {
      console.log('city', val);
    });
    this.subscription.add(cityChanges);
  }
}

Now that we know how to get values from our dropdowns, let's populate them with real data. We can get our list of states immediately, but to get our cities, we'll want to make an get request based on the state the user selected.

Problem 2: Write Service Methods to Get States and Cities from API

We want to be able to get lists of cities and states from our API to populate the dropdown options.

P2: What You Need to Know

How to use HttpParams

HttpParams are part of Angulars HTTPClient API and help us create parameters for our requests.

<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.2.1/rxjs.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/core-js/2.5.7/core.js"/></script>
<script src="https://unpkg.com/@angular/core@7.2.0/bundles/core.umd.js"/></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/zone.js/0.8.26/zone.min.js"></script>
<script src="https://unpkg.com/@angular/common@7.2.0/bundles/common.umd.js"></script>
<script src="https://unpkg.com/@angular/common@7.2.0/bundles/common-http.umd.js"></script>
<script src="https://unpkg.com/@angular/compiler@7.2.0/bundles/compiler.umd.js"></script>
<script src="https://unpkg.com/@angular/router@7.2.0/bundles/router.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser@7.2.0/bundles/platform-browser.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser-dynamic@7.2.0/bundles/platform-browser-dynamic.umd.js"></script>
<base href="/">
<my-app></my-app>
<script type="typescript">
const { Component, NgModule, VERSION, OnInit, Injectable } = ng.core;
const { BrowserModule } = ng.platformBrowser;
const { CommonModule } = ng.common;
const { HttpClient, HttpParams, HttpClientModule } = ng.common.http

interface User {
  name: string;
  id: number;
  role: string;
}

@Injectable({
  providedIn: 'root'
})
class UsersService {

  constructor(private httpClient: HttpClient) { }

  getUsers() {
    const params = new HttpParams()
    params.set('_page', "1").set('_limit', "1");
    return this.httpClient.get<any>('/api/users', {params});
  }

  getUser(id: number) {
    return this.httpClient.get<any>('/api/users/' + id);
  }
}
//THIS IS A HACK JUST FOR CODEPEN TO WORK
//UsersService.parameters = [HttpClient];

@Component({
  selector: 'my-app',
  template: `
    <p>A home component!</p>
    <ul>
      <li *ngFor="let user of users">
        {{user.name}}
      </li>
    </ul>
  `
})
class AppComponent implements OnInit{
  public user: User;

  constructor(private usersService: UsersService) {}

  ngOnInit() {
    this.user = this.usersService.getUser(1);
  }
}
//THIS IS A HACK JUST FOR CODEPEN TO WORK
AppComponent.parameters = [UsersService];

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

const { platformBrowserDynamic } = ng.platformBrowserDynamic;

platformBrowserDynamic()
  .bootstrapModule(AppModule)
  .catch(err => console.error(err));
</script>

P2: Technical Requirements

Write two new methods in the RestaurantsService to get state and city lists.

Method 1 - getStates takes no params and makes a request to '/api/states'

Method 2 - getCities, takes a string param called 'state' a makes a request to '/api/cities?state="{state abbreviation here}"'

P2: How to Verify Your Solution is Correct

✏️ Update the spec file src/app/restaurant/restaurant.service.spec.ts to be:

import { TestBed } from '@angular/core/testing';

import { ResponseData, RestaurantService } from './restaurant.service';
import { Restaurant } from './restaurant';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

describe('RestaurantService', () => {
  let httpMock : HttpTestingController;
  let restaurantService: RestaurantService;

  beforeEach(() => TestBed.configureTestingModule({
    imports: [HttpClientTestingModule],
    providers: [
      RestaurantService
    ]
  }));

  beforeEach(() => {
    httpMock = TestBed.get(HttpTestingController);
    restaurantService = TestBed.get(RestaurantService);
  })

  it('should be created', () => {
    expect(restaurantService).toBeTruthy();
  });
 
  it('should make a get request to restaurants', () => {
    const mockRestaurants = {
      data: [{
      "name":"Brunch Place",
      "slug":"brunch-place",
      "images":{
        "thumbnail":"node_modules/place-my-order-assets/images/4-thumbnail.jpg",
        "owner":"node_modules/place-my-order-assets/images/2-owner.jpg",
        "banner":"node_modules/place-my-order-assets/images/2-banner.jpg"},
        "menu":{
          "lunch":[
            {"name":"Ricotta Gnocchi","price":15.99},
            {"name":"Garlic Fries","price":15.99},
            {"name":"Charred Octopus","price":25.99}
          ],
          "dinner":[
            {"name":"Steamed Mussels","price":21.99},
            {"name":"Roasted Salmon","price":23.99},
            {"name":"Crab Pancakes with Sorrel Syrup","price":35.99}
          ]
        },
        "address":{
          "street":"2451 W Washburne Ave",
          "city":"Ann Arbor","state":"MI","zip":"53295"},
          "_id":"xugqxQIX5rPJTLBv"
        },
        {
          "name":"Taco Joint",
          "slug":"taco-joint",
          "images":{
            "thumbnail":"node_modules/place-my-order-assets/images/4-thumbnail.jpg",
            "owner":"node_modules/place-my-order-assets/images/2-owner.jpg",
            "banner":"node_modules/place-my-order-assets/images/2-banner.jpg"},
            "menu":{
              "lunch":[
                {"name":"Beef Tacos","price":15.99},
                {"name":"Chicken Tacos","price":15.99},
                {"name":"Guacamole","price":25.99}
              ],
              "dinner":[
                {"name":"Shrimp Tacos","price":21.99},
                {"name":"Chicken Enchilada","price":23.99},
                {"name":"Elotes","price":35.99}
              ]
            },
            "address":{
              "street":"13 N 21st St",
              "city":"Chicago","state":"IL","zip":"53295"},
              "_id":"xugqxQIX5dfgdgTLBv"
            }]
          };

    restaurantService.getRestaurants().subscribe((restaurants:ResponseData) => {
      expect(restaurants).toEqual(mockRestaurants);
    });

    let url = 'http://localhost:7070/restaurants';
    const req = httpMock.expectOne(url);


    expect(req.request.method).toEqual('GET');
    req.flush(mockRestaurants);

    httpMock.verify();
  });

  it('can set proper properties on restaurant type', () => {
    let restaurant: Restaurant = {
      name:"Taco Joint",
      slug:"taco-joint",
      images:{
        thumbnail:"node_modules/place-my-order-assets/images/4-thumbnail.jpg",
        owner:"node_modules/place-my-order-assets/images/2-owner.jpg",
        banner:"node_modules/place-my-order-assets/images/2-banner.jpg"
      },
      menu:{
        lunch:[
          {name:"Beef Tacos","price":15.99},
          {name:"Chicken Tacos","price":15.99},
          {name:"Guacamole","price":25.99}
        ],
        dinner:[
          {name:"Shrimp Tacos","price":21.99},
          {name:"Chicken Enchilada","price":23.99},
          {name:"Elotes","price":35.99}
        ]
      },
      address:{
        street:"13 N 21st St",
        city:"Chicago","state":"IL","zip":"53295"
      },
      _id:"xugqxQIX5dfgdgTLBv"
    }
    //will error if interface isn't implemented correctly
    expect(true).toBe(true);
  });

  it('should make a get request to states', () => {
    const mockStates = {
      data: [
        {name: 'Missouri', short: 'MO'}
      ]
    };

    restaurantService.getStates().subscribe((states) => {
      expect(states).toEqual(mockStates);
    });

    let url = 'http://localhost:7070/states';
    const req = httpMock.expectOne(url);

    expect(req.request.method).toEqual('GET');
    req.flush(mockStates);

    httpMock.verify();
  });

  it('should make a get request to cities', () => {
    const mockCities = {
      data: [
        {name: 'Kansas City', state: 'MO'}
      ]
    };

    restaurantService.getCities('MO').subscribe((cities) => {
      expect(cities).toEqual(mockCities);
    });

    let url = 'http://localhost:7070/cities?state=MO';
    const req = httpMock.expectOne(url);
    expect(req.request.method).toEqual('GET');
    req.flush(mockCities);

    httpMock.verify();
  });

});

If you've implemented the solution correctly, when you run npm run test all tests will pass!

P2: Solution

✏️ Update src/app/restaurant/restaurant.service.ts

import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Restaurant } from './restaurant';
import { environment } from '../../environments/environment';

export interface ResponseData {
  data: Restaurant[];
}

@Injectable({
  providedIn: 'root'
})
export class RestaurantService {

  constructor(private httpClient: HttpClient) { }

  getRestaurants() {
    return this.httpClient.get<ResponseData>(environment.apiUrl + '/restaurants');
  }

  getStates() {
    return this.httpClient.get<any>(environment.apiUrl + '/states');
  }

  getCities(state:string) {
    const params = new HttpParams().set('state', state);
    return this.httpClient.get<any>(environment.apiUrl + '/cities', {params});
  }
}

Problem 3: Use Generics to Modify ResponseData interface to Work with States and Cities Data

We would like to use the ResponseData interface we wrote to describe the response for the state and city requests, but it only works with and array of type Restaurant.

P3: What You Need to Know

How to write a generic

For an in-depth understanding of generics in TypeScript, check out our TypeScript guide. For now, generics are a way to abstract functions, interfaces, etc to use different types in different situations.

This example shows creating a generic for a list that can be used to create arrays of various types, including Dinosaurs. Codepen doesn't have a typescript compiler that will throw errors, but if you paste the code into your IDE you'll be able to see the TypeScript errors thrown.

<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.2.1/rxjs.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/core-js/2.5.7/core.js"/></script>
<script src="https://unpkg.com/@angular/core@7.2.0/bundles/core.umd.js"/></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/zone.js/0.8.26/zone.min.js"></script>
<script src="https://unpkg.com/@angular/common@7.2.0/bundles/common.umd.js"></script>
<script src="https://unpkg.com/@angular/common@7.2.0/bundles/common-http.umd.js"></script>
<script src="https://unpkg.com/@angular/compiler@7.2.0/bundles/compiler.umd.js"></script>
<script src="https://unpkg.com/@angular/router@7.2.0/bundles/router.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser@7.2.0/bundles/platform-browser.umd.js"></script>
<script src="https://unpkg.com/@angular/platform-browser-dynamic@7.2.0/bundles/platform-browser-dynamic.umd.js"></script>
<base href="/">
<my-app></my-app>
<script type="typescript">
const { Component, NgModule, VERSION, OnInit } = ng.core;
const { BrowserModule } = ng.platformBrowser;
const { CommonModule } = ng.common;

class GenericCollection<T> {
  private list: T[] = [];
  pushItem(thing:T) {
    this.list.push(thing);
  }
}

interface Dinosaur {
  name: string;
  breed: string;
  teeth: number;
}

@Component({
  selector: 'my-app',
  template: `
    <p>My strings</p>
    <ul>
      <li *ngFor="let string of myListOfStrings; let i=index">{{string}}</li>
    </ul>

    <p>My dinosaurs</p>
    <ul>
      <li *ngFor="let dino of myListOfDinosaurs; let i=index">{{dino.name}} is a {{dino.breed}} and has {{dino.teeth}} teeth.</li>
    </ul>
  `
})
class AppComponent implements OnInit {
  public myListOfStrings = new GenericCollection<string>();
  public myListOfDinosaurs = new GenericCollection<Dinosaur>();

  constructor() {}

  ngOnInit() {
    this.myListOfStrings.pushItem('booop');
    this.myListOfStrings.pushItem('TA DA');
    this.myListOfStrings.pushItem(5);
    //error Argument type of '5' is not assignable to parameter of type 'string'

    let dinoA = {
      name: 'Blue',
      breed: 'Velociraptor',
      teeth: 100
    }

    let dinoB = {
      name: 'Killorex',
      breed: 'Tyranasuarus Rex',
      teeth: 95
    }
    this.myListOfDinosaurs.pushItem(dinoA);
    this.myListOfDinosaurs.pushItem(dinoB);
    this.myListOfDinosaurs.pushItem({name: 'Charlie'});
    //error Argument type '{ name: string; }' is not assignable to parameter of type 'Dinosaur'.
  }

}

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    CommonModule
  ],
  bootstrap: [AppComponent],
  providers: []
})
class AppModule {}

const { platformBrowserDynamic } = ng.platformBrowserDynamic;

platformBrowserDynamic()
  .bootstrapModule(AppModule)
  .catch(err => console.error(err));
</script>


P3: Technical Requirements

Convert the ResponseData interface use generics so it can take a type of Restaurant, State, or City. We've written the state & city interfaces for you. Make sure to update the getRestaurants method in the RestaurantComponent as well.

P3: Setup

✏️ Update your src/app/restaurant/restaurant.service.ts file to be:

import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Restaurant } from './restaurant';
import { environment } from '../../environments/environment';

export interface ResponseData {
  data: Restaurant[];
}

export interface State {
  name: string;
  short: string;
}

export interface City {
  name: string;
  state: string;
}

@Injectable({
  providedIn: 'root'
})
export class RestaurantService {

  constructor(private httpClient: HttpClient) { }

  getRestaurants() {
    return this.httpClient.get<ResponseData>(environment.apiUrl + '/restaurants');
  }

  getStates() {
    return this.httpClient.get<any>(environment.apiUrl + '/states');
  }

  getCities(state:string) {
    const params = new HttpParams().set('state', state);
    return this.httpClient.get<any>(environment.apiUrl + '/cities', {params});
  }
}

P3: How to Verify Your Solution is Correct

✏️ Update the spec file src/app/restaurant/restaurant.service.spec.ts to be:

import { TestBed } from '@angular/core/testing';

import { ResponseData, RestaurantService, State, City } from './restaurant.service';
import { Restaurant } from './restaurant';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

describe('RestaurantService', () => {
  let httpMock : HttpTestingController;
  let restaurantService: RestaurantService;

  beforeEach(() => TestBed.configureTestingModule({
    imports: [HttpClientTestingModule],
    providers: [
      RestaurantService
    ]
  }));

  beforeEach(() => {
    httpMock = TestBed.get(HttpTestingController);
    restaurantService = TestBed.get(RestaurantService);
  })

  it('should be created', () => {
    expect(restaurantService).toBeTruthy();
  });
 
  it('should make a get request to restaurants', () => {
    const mockRestaurants = {
      data: [{
      "name":"Brunch Place",
      "slug":"brunch-place",
      "images":{
        "thumbnail":"node_modules/place-my-order-assets/images/4-thumbnail.jpg",
        "owner":"node_modules/place-my-order-assets/images/2-owner.jpg",
        "banner":"node_modules/place-my-order-assets/images/2-banner.jpg"},
        "menu":{
          "lunch":[
            {"name":"Ricotta Gnocchi","price":15.99},
            {"name":"Garlic Fries","price":15.99},
            {"name":"Charred Octopus","price":25.99}
          ],
          "dinner":[
            {"name":"Steamed Mussels","price":21.99},
            {"name":"Roasted Salmon","price":23.99},
            {"name":"Crab Pancakes with Sorrel Syrup","price":35.99}
          ]
        },
        "address":{
          "street":"2451 W Washburne Ave",
          "city":"Ann Arbor","state":"MI","zip":"53295"},
          "_id":"xugqxQIX5rPJTLBv"
        },
        {
          "name":"Taco Joint",
          "slug":"taco-joint",
          "images":{
            "thumbnail":"node_modules/place-my-order-assets/images/4-thumbnail.jpg",
            "owner":"node_modules/place-my-order-assets/images/2-owner.jpg",
            "banner":"node_modules/place-my-order-assets/images/2-banner.jpg"},
            "menu":{
              "lunch":[
                {"name":"Beef Tacos","price":15.99},
                {"name":"Chicken Tacos","price":15.99},
                {"name":"Guacamole","price":25.99}
              ],
              "dinner":[
                {"name":"Shrimp Tacos","price":21.99},
                {"name":"Chicken Enchilada","price":23.99},
                {"name":"Elotes","price":35.99}
              ]
            },
            "address":{
              "street":"13 N 21st St",
              "city":"Chicago","state":"IL","zip":"53295"},
              "_id":"xugqxQIX5dfgdgTLBv"
            }]
          };

    restaurantService.getRestaurants().subscribe((restaurants:ResponseData<Restaurant>) => {
      expect(restaurants).toEqual(mockRestaurants);
    });

    let url = 'http://localhost:7070/restaurants';
    const req = httpMock.expectOne(url);


    expect(req.request.method).toEqual('GET');
    req.flush(mockRestaurants);

    httpMock.verify();
  });

  it('can set proper properties on restaurant type', () => {
    let restaurant: Restaurant = {
      name:"Taco Joint",
      slug:"taco-joint",
      images:{
        thumbnail:"node_modules/place-my-order-assets/images/4-thumbnail.jpg",
        owner:"node_modules/place-my-order-assets/images/2-owner.jpg",
        banner:"node_modules/place-my-order-assets/images/2-banner.jpg"
      },
      menu:{
        lunch:[
          {name:"Beef Tacos","price":15.99},
          {name:"Chicken Tacos","price":15.99},
          {name:"Guacamole","price":25.99}
        ],
        dinner:[
          {name:"Shrimp Tacos","price":21.99},
          {name:"Chicken Enchilada","price":23.99},
          {name:"Elotes","price":35.99}
        ]
      },
      address:{
        street:"13 N 21st St",
        city:"Chicago","state":"IL","zip":"53295"
      },
      _id:"xugqxQIX5dfgdgTLBv"
    }
    //will error if interface isn't implemented correctly
    expect(true).toBe(true);
  });

  it('should make a get request to states', () => {
    const mockStates = {
      data: [
        {name: 'Missouri', short: 'MO'}
      ]
    };

    restaurantService.getStates().subscribe((states:ResponseData<State>) => {
      expect(states).toEqual(mockStates);
    });

    let url = 'http://localhost:7070/states';
    const req = httpMock.expectOne(url);

    expect(req.request.method).toEqual('GET');
    req.flush(mockStates);

    httpMock.verify();
  });

  it('should make a get request to cities', () => {
    const mockCities = {
      data: [
        {name: 'Kansas City', state: 'MO'}
      ]
    };

    restaurantService.getCities('MO').subscribe((cities:ResponseData<City>) => {
      expect(cities).toEqual(mockCities);
    });

    let url = 'http://localhost:7070/cities?state=MO';
    const req = httpMock.expectOne(url);
    expect(req.request.method).toEqual('GET');
    req.flush(mockCities);

    httpMock.verify();
  });

});

P3: Solution

✏️ Update src/app/restaurant/restaurant.service.ts

import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Restaurant } from './restaurant';
import { environment } from '../../environments/environment';

export interface ResponseData<dataType> {
  data: Array<dataType>;
}

export interface State {
  name: string;
  short: string;
}

export interface City {
  name: string;
  state: string;
}

@Injectable({
  providedIn: 'root'
})
export class RestaurantService {

  constructor(private httpClient: HttpClient) { }

  getRestaurants() {
    return this.httpClient.get<ResponseData<Restaurant>>(environment.apiUrl + '/restaurants');
  }

  getStates() {
    return this.httpClient.get<ResponseData<State>>(environment.apiUrl + '/states');
  }

  getCities(state:string) {
    const params = new HttpParams().set('state', state);
    return this.httpClient.get<ResponseData<City>>(environment.apiUrl + '/cities', {params});
  }
}

✏️ Update src/app/restaurant/restaurant.component.ts

import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { Subscription } from 'rxjs';

import { RestaurantService, ResponseData } from './restaurant.service';
import { Restaurant } from './restaurant';

export interface Data {
  value: Array<Restaurant>;
  isPending: boolean;
}

@Component({
  selector: 'pmo-restaurant',
  templateUrl: './restaurant.component.html',
  styleUrls: ['./restaurant.component.less']
})
export class RestaurantComponent implements OnInit, OnDestroy {
  form: FormGroup;

  public restaurants: Data = {
    value: [],
    isPending: false
  }

  public states = {
    isPending: false,
    value: [{name: "Illinois", short: "IL"}, {name: "Wisconsin", short: "WI"}]
  };

  public cities = {
    isPending: false,
    value: [{name: "Springfield"},{name: "Madison"}]
  }

  private subscription: Subscription;

  constructor(
    private restaurantService: RestaurantService,
    private fb: FormBuilder
    ) {
  }

  ngOnInit() {
    this.createForm();

    this.restaurantService.getRestaurants().subscribe((res: ResponseData<Restaurant>) => {
      this.restaurants.value = res.data;
      this.restaurants.isPending = false;
    });
  }

  ngOnDestroy() {
    if(this.subscription) {
      this.subscription.unsubscribe();
    }
  }

  createForm() {
    this.form = this.fb.group({
      state: {value: '', disabled: false},
      city: {value: '', disabled: false},
    });

    this.onChanges();
  }
  onChanges(): void {
    const stateChanges = this.form.get('state').valueChanges.subscribe(val => {
      console.log('state', val);
    });
    this.subscription = stateChanges;


    const cityChanges = this.form.get('city').valueChanges.subscribe(val => {
      console.log('city', val);
    });
    this.subscription.add(cityChanges);
  }
}

Problem 4: Get Cities and States Based on Dropdown Values

Now that our service is in working order, let's populate our dropdowns with state and city data. We will want our list of states to be available right away, but we will want to fetch our list of cities only after we have the state value selected by the user.

P4: Technical Requirements

  1. Rewrite the Data interface to be a generic to work with State and City types as well
  2. Mark state and city dropdowns as disabled until they are populated with data
  3. Fetch the states list when the component first loads (ngOnInit) and populate the dropdown options with the values
  4. When the State FormControl value changes, fetch the list of cities with the selected state as the parameter
  5. If the state value changes, fetch the new list of cities, and reset the list of restaurants to an empty array
  6. When a City is selected, fetch the list of restaurants

Hint: You'll want to clear the fake data from the state and city value props, and move the call to get restaurants out of the ngOnInit function.

P4: How to Verify Your Solution is Correct

✏️ Update the spec file src/app/restaurant/restaurant.component.spec.ts to be:

import { async, ComponentFixture, TestBed, tick, fakeAsync } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs'; 

import { RestaurantComponent } from './restaurant.component';
import { RestaurantService } from './restaurant.service';
import { ImageUrlPipe } from '../image-url.pipe';
import { ReactiveFormsModule } from '@angular/forms';

class MockRestaurantService {
  getRestaurants() {
    return of({
      data: [{
        "name": "Poutine Palace",
        "slug": "poutine-palace",
        "images": {
          "thumbnail": "node_modules/place-my-order-assets/images/4-thumbnail.jpg",
          "owner": "node_modules/place-my-order-assets/images/3-owner.jpg",
          "banner": "node_modules/place-my-order-assets/images/2-banner.jpg"
        },
        "menu": {
          "lunch": [
            {
              "name": "Crab Pancakes with Sorrel Syrup",
              "price": 35.99
            },
            {
              "name": "Steamed Mussels",
              "price": 21.99
            },
            {
              "name": "Spinach Fennel Watercress Ravioli",
              "price": 35.99
            }
          ],
          "dinner": [
            {
              "name": "Gunthorp Chicken",
              "price": 21.99
            },
            {
              "name": "Herring in Lavender Dill Reduction",
              "price": 45.99
            },
            {
              "name": "Chicken with Tomato Carrot Chutney Sauce",
              "price": 45.99
            }
          ]
        },
        "address": {
          "street": "230 W Kinzie Street",
          "city": "Green Bay",
          "state": "WI",
          "zip": "53205"
        },
        "_id": "3ZOZyTY1LH26LnVw"
      },
      {
        "name": "Cheese Curd City",
        "slug": "cheese-curd-city",
        "images": {
          "thumbnail": "node_modules/place-my-order-assets/images/2-thumbnail.jpg",
          "owner": "node_modules/place-my-order-assets/images/3-owner.jpg",
          "banner": "node_modules/place-my-order-assets/images/2-banner.jpg"
        },
        "menu": {
          "lunch": [
            {
              "name": "Ricotta Gnocchi",
              "price": 15.99
            },
            {
              "name": "Gunthorp Chicken",
              "price": 21.99
            },
            {
              "name": "Garlic Fries",
              "price": 15.99
            }
          ],
          "dinner": [
            {
              "name": "Herring in Lavender Dill Reduction",
              "price": 45.99
            },
            {
              "name": "Truffle Noodles",
              "price": 14.99
            },
            {
              "name": "Charred Octopus",
              "price": 25.99
            }
          ]
        },
        "address": {
          "street": "2451 W Washburne Ave",
          "city": "Green Bay",
          "state": "WI",
          "zip": "53295"
        },
        "_id": "Ar0qBJHxM3ecOhcr"
      }]}
            )
  }

  getStates() {
    return of({
      data: [
        {"short":"MO","name":"Missouri"},
        {"short":"CA  ","name":"California"},
        {"short":"MI","name":"Michigan"}]
    });
  }

  getCities(state:string) {
    return of({
      data: [{"name":"Sacramento","state":"CA"},{"name":"Oakland","state":"CA"}]
    });
  }
}
describe('RestaurantComponent', () => {
  let component: RestaurantComponent;
  let fixture: ComponentFixture<RestaurantComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule,
        ReactiveFormsModule
      ],
      providers: [{
        provide: RestaurantService,
        useClass: MockRestaurantService
      }],
      declarations: [ RestaurantComponent, ImageUrlPipe ]
    })
    .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(RestaurantComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should render title in a h2 tag', () => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    expect(compiled.querySelector('h2').textContent).toContain('Restaurants');
  });

  it('should not show any restaurants markup if no restaurants', () => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    const compiled = fixture.debugElement.nativeElement;
    expect(compiled.querySelector('.restaurant')).toBe(null);
  });

  it('should have two .restaurant divs',  <any>fakeAsync((): void => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    tick(501);
    fixture.componentInstance.form.get('state').patchValue('CA');
    fixture.componentInstance.form.get('city').patchValue('Sacramento');
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    let restaurantDivs = compiled.getElementsByClassName('restaurant');
    let hoursDivs = compiled.getElementsByClassName('hours-price');
    expect(restaurantDivs.length).toEqual(2);
    expect(hoursDivs.length).toEqual(2);
  }));

  it('should display restaurant information',  <any>fakeAsync((): void => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    tick(501);
    fixture.componentInstance.form.get('state').patchValue('CA');
    fixture.componentInstance.form.get('city').patchValue('Sacramento');
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    expect(compiled.querySelector('.restaurant h3').textContent).toContain('Poutine Palace');
  }));

  it('should set restaurants value to restaurants response data and set isPending to false when state and city form values are selected', <any>fakeAsync((): void => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    tick();
    fixture.componentInstance.form.get('state').patchValue('CA');
    fixture.componentInstance.form.get('city').patchValue('Sacramento');
    fixture.detectChanges();
    let expectedRestaurants = {
      value: [{
        "name": "Poutine Palace",
        "slug": "poutine-palace",
        "images": {
          "thumbnail": "node_modules/place-my-order-assets/images/4-thumbnail.jpg",
          "owner": "node_modules/place-my-order-assets/images/3-owner.jpg",
          "banner": "node_modules/place-my-order-assets/images/2-banner.jpg"
        },
        "menu": {
          "lunch": [
            {
              "name": "Crab Pancakes with Sorrel Syrup",
              "price": 35.99
            },
            {
              "name": "Steamed Mussels",
              "price": 21.99
            },
            {
              "name": "Spinach Fennel Watercress Ravioli",
              "price": 35.99
            }
          ],
          "dinner": [
            {
              "name": "Gunthorp Chicken",
              "price": 21.99
            },
            {
              "name": "Herring in Lavender Dill Reduction",
              "price": 45.99
            },
            {
              "name": "Chicken with Tomato Carrot Chutney Sauce",
              "price": 45.99
            }
          ]
        },
        "address": {
          "street": "230 W Kinzie Street",
          "city": "Green Bay",
          "state": "WI",
          "zip": "53205"
        },
        "_id": "3ZOZyTY1LH26LnVw"
      },
      {
        "name": "Cheese Curd City",
        "slug": "cheese-curd-city",
        "images": {
          "thumbnail": "node_modules/place-my-order-assets/images/2-thumbnail.jpg",
          "owner": "node_modules/place-my-order-assets/images/3-owner.jpg",
          "banner": "node_modules/place-my-order-assets/images/2-banner.jpg"
        },
        "menu": {
          "lunch": [
            {
              "name": "Ricotta Gnocchi",
              "price": 15.99
            },
            {
              "name": "Gunthorp Chicken",
              "price": 21.99
            },
            {
              "name": "Garlic Fries",
              "price": 15.99
            }
          ],
          "dinner": [
            {
              "name": "Herring in Lavender Dill Reduction",
              "price": 45.99
            },
            {
              "name": "Truffle Noodles",
              "price": 14.99
            },
            {
              "name": "Charred Octopus",
              "price": 25.99
            }
          ]
        },
        "address": {
          "street": "2451 W Washburne Ave",
          "city": "Green Bay",
          "state": "WI",
          "zip": "53295"
        },
        "_id": "Ar0qBJHxM3ecOhcr"
      }],
      isPending: false
    }
    expect(fixture.componentInstance.restaurants).toEqual(expectedRestaurants);
  }));

  it('should show a loading div while isPending is true', () => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    fixture.componentInstance.restaurants.isPending = true;
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    let loadingDiv = compiled.querySelector('.loading');
    expect(loadingDiv).toBeTruthy();
  });

  it('should not show a loading div if isPending is false', () => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.componentInstance.restaurants.isPending = false;
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    let loadingDiv = compiled.querySelector('.loading');
    expect(loadingDiv).toBe(null);
  });

  it('should have a form property with city and state keys', () => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    expect(fixture.componentInstance.form.controls.state).toBeTruthy();
    expect(fixture.componentInstance.form.controls.city).toBeTruthy();
  });

  it('should show a state dropdown', () => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    let stateSelect = compiled.querySelector('select[formcontrolname="state"]');
    expect(stateSelect).toBeTruthy();
  });

  it('should show a city dropdown', () => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    let citySelect = compiled.querySelector('select[formcontrolname="city"]');
    expect(citySelect).toBeTruthy();
  });

  it('should set states value to states response data and set isPending to false', <any>fakeAsync((): void => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    tick();
    fixture.detectChanges();
    let expectedStates = {
      value: [
        {"short":"MO","name":"Missouri"},
        {"short":"CA  ","name":"California"},
        {"short":"MI","name":"Michigan"}
      ],
      isPending: false
    }
    expect(fixture.componentInstance.states).toEqual(expectedStates);
  }));

  it('should set state dropdown options to be values of states member', <any>fakeAsync((): void => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    tick();
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    let stateOption = compiled.querySelector('select[formcontrolname="state"] option:nth-child(2)');
    expect(stateOption.textContent).toEqual(' Missouri');
    expect(stateOption.value).toEqual('MO');
  }));

  it('should set cities value to cities response data and set isPending to false', <any>fakeAsync((): void => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    tick();
    fixture.componentInstance.form.get('state').patchValue('CA');
    fixture.detectChanges();
    let expectedCities = {
      value: [
        {"name":"Sacramento","state":"CA"},
        {"name":"Oakland","state":"CA"}
      ],
      isPending: false
    }
    expect(fixture.componentInstance.cities).toEqual(expectedCities);
  }));

  it('should set city dropdown options to be values of cities member when state value is selected', <any>fakeAsync((): void => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges();
    tick();
    fixture.componentInstance.form.get('state').patchValue('CA');
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    let cityOption = compiled.querySelector('select[formcontrolname="city"] option:nth-child(2)');
    expect(cityOption.textContent).toEqual(' Sacramento');
    expect(cityOption.value).toEqual('Sacramento');
  }));

  it('state dropdown should be disabled until states are populated', <any>fakeAsync((): void => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    let storeGetStatesFunc = fixture.componentInstance.getStates;
    fixture.componentInstance.getStates = () => {}; //preventing getStates func from being called 
    fixture.detectChanges(); //detecting changes for createForm func to be called
    let stateFormControl1 = fixture.componentInstance.form.get('state');
    expect(stateFormControl1.enabled).toBe(false);
    fixture.componentInstance.getStates = storeGetStatesFunc;
    fixture.componentInstance.getStates();  //calling getStates func when we want it
    fixture.detectChanges();
    let stateFormControl2 = fixture.componentInstance.form.get('state');
    expect(stateFormControl2.enabled).toBe(true);
  }));

  it('city dropdown should be disabled until cities are populated', <any>fakeAsync((): void => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges(); //detecting changes for createForm func to be called
    let cityFormControl1 = fixture.componentInstance.form.get('city');
    expect(cityFormControl1.enabled).toBe(false);
    fixture.componentInstance.form.get('state').patchValue('CA');
    fixture.detectChanges();
    let cityFormControl2 = fixture.componentInstance.form.get('city');
    expect(cityFormControl2.enabled).toBe(true);
  }));

  it('should reset list of cities when new state is selected', <any>fakeAsync((): void => {
    const fixture = TestBed.createComponent(RestaurantComponent);
    fixture.detectChanges(); //detecting changes for createForm func to be called
    fixture.componentInstance.form.get('state').patchValue('CA');
    fixture.componentInstance.form.get('city').patchValue('Sacramento');
    fixture.detectChanges();
    expect(fixture.componentInstance.restaurants.value.length).toEqual(2);
    fixture.componentInstance.form.get('state').patchValue('MO');
    fixture.detectChanges();
    expect(fixture.componentInstance.restaurants.value.length).toEqual(0);
  }));
});

P4: What You Need to Know

  • How to call service methods in a component
  • How to write generics

P4: Solution

✏️ Update src/app/restaurant/restaurant.component.ts

import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { Subscription } from 'rxjs';

import { RestaurantService, ResponseData, State, City } from './restaurant.service';
import { Restaurant } from './restaurant';

export interface Data<T> {
  value: Array<T>;
  isPending: boolean;
}

@Component({
  selector: 'pmo-restaurant',
  templateUrl: './restaurant.component.html',
  styleUrls: ['./restaurant.component.less']
})
export class RestaurantComponent implements OnInit, OnDestroy {
  form: FormGroup;

  public restaurants: Data<Restaurant> = {
    value: [],
    isPending: false
  }

  public states: Data<State> = {
    isPending: false,
    value: []
  };

  public cities: Data<City> = {
    isPending: false,
    value: []
  };

  private subscription: Subscription;

  constructor(
    private restaurantService: RestaurantService,
    private fb: FormBuilder
    ) {
  }

  ngOnInit() {
    this.createForm();

    this.getStates();
  }

  ngOnDestroy() {
    if(this.subscription) {
      this.subscription.unsubscribe();
    }
  }

  createForm() {
    this.form = this.fb.group({
      state: {value: '', disabled: true},
      city: {value: '', disabled: true},
    });

    this.onChanges();
  }

  onChanges(): void {
    let state:string;
    const stateChanges = this.form.get('state').valueChanges.subscribe(val => {
      this.restaurants.value = [];
      if (val) {
        //only enable city if state has value
        this.form.get('city').enable({
          onlySelf: true,
          emitEvent: false
        });
        //if state has a value and has changed, clear previous city value
        if (state != val) {
          this.form.get('city').patchValue('');
        }
        //fetch cities based on state val
        this.getCities(val);
        state = val;
      }
      else {
        //disable city if no value
        this.form.get('city').disable({
          onlySelf: true,
          emitEvent: false
        });
        state = '';
      }
    });
    this.subscription = stateChanges;


    const cityChanges = this.form.get('city').valueChanges.subscribe(val => {
      if(val) {
        this.getRestaurants();
      }
    });
    this.subscription.add(cityChanges);
  }

  getStates() {
    this.restaurantService.getStates().subscribe((res: ResponseData<State>) => {
      this.states.value = res.data;
      this.states.isPending = false;
      this.form.get('state').enable();
    });
  }

  getCities(state:string) {
    this.cities.isPending = true;
    this.restaurantService.getCities(state).subscribe((res: ResponseData<City>) => {
      this.cities.value = res.data;
      this.cities.isPending = false;
      this.form.get('city').enable({
        onlySelf: true,
        emitEvent: false
      });
    });
  }

  getRestaurants() {
    this.restaurantService.getRestaurants().subscribe((res: ResponseData<Restaurant>) => {
      this.restaurants.value = res.data;
      this.restaurants.isPending = false;
    });
  }
}